From 7c775a7a091c7dbe2d7d9b8a4a2e297e59ed12e9 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 21 Aug 2026 13:08:13 +0200 Subject: [PATCH 1/2] feat(projects): surface your projects under the archive title Show a compact yours strip on the year projects page for work you created or belong to, using the same group capsules, idea treatment, and member stack as the cards below. Year payloads now include that list so membership updates stay in sync, and the main project and idea tiles are clickable as a whole while markdown URLs remain independent links. --- src/app/components/ProjectCard.tsx | 2 +- src/app/queries/projects.ts | 6 ++- src/app/routes/ProjectsPage.tsx | 28 +++++++++- src/app/styles.css | 83 ++++++++++++++++++++++++++++- src/shared/projects.ts | 1 + src/worker/repositories/projects.ts | 20 +++++++ src/worker/routes/years.ts | 5 +- test/app/routes.test.tsx | 76 +++++++++++++++++++++++++- test/projects/projects.test.ts | 33 ++++++++++++ 9 files changed, 247 insertions(+), 7 deletions(-) diff --git a/src/app/components/ProjectCard.tsx b/src/app/components/ProjectCard.tsx index 3c1edb2..31ba6aa 100644 --- a/src/app/components/ProjectCard.tsx +++ b/src/app/components/ProjectCard.tsx @@ -125,7 +125,7 @@ function ProjectTags({project, className}: {project: ProjectSummary; className: ); } -function MemberStack({ +export function MemberStack({ members, emptyLabel = 'up for grabs', }: { diff --git a/src/app/queries/projects.ts b/src/app/queries/projects.ts index 10f4261..f7f3a3a 100644 --- a/src/app/queries/projects.ts +++ b/src/app/queries/projects.ts @@ -118,6 +118,7 @@ export function useSaveProject(projectId?: string, claim = false) { onSuccess: ({project}) => { cache.setQueryData(['project', project.id], {project}); void cache.invalidateQueries({queryKey: ['projects', project.yearId]}); + void cache.invalidateQueries({queryKey: ['year', project.yearId]}); void cache.invalidateQueries({queryKey: ['years']}); }, }); @@ -128,7 +129,10 @@ export function useDeleteProject() { return useMutation({ mutationFn: (projectId: string) => apiRequest(`/projects/${encodeURIComponent(projectId)}`, {method: 'DELETE'}), - onSuccess: () => void cache.invalidateQueries({queryKey: ['projects']}), + onSuccess: () => { + void cache.invalidateQueries({queryKey: ['projects']}); + void cache.invalidateQueries({queryKey: ['year']}); + }, }); } diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx index 31f4666..11c98db 100644 --- a/src/app/routes/ProjectsPage.tsx +++ b/src/app/routes/ProjectsPage.tsx @@ -2,8 +2,9 @@ import {useEffect, useRef, useState} from 'react'; import {Link, useParams} from 'wouter'; import type {BallotStatusResponse} from '../../shared/administration'; +import type {ProjectSummary} from '../../shared/projects'; import {GroupManager} from '../components/GroupManager'; -import {ProjectCard} from '../components/ProjectCard'; +import {MemberStack, ProjectCard} from '../components/ProjectCard'; import {PageState, QueryState} from '../components/AppLayout'; import {useBallotStatus} from '../queries/administration'; import {useProjects, useYear} from '../queries/projects'; @@ -116,6 +117,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { ? 'browse the finished projects, teams, and award winners.' : 'see what everyone is building, join a team, or share an idea.'}

+
{(isAdmin || year.data.year.submissionsClosed) && ( @@ -331,6 +333,30 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { ); } +function MyProjectsStrip({projects}: {projects: ProjectSummary[]}) { + if (!projects.length) return null; + return ( +
+

yours

+
+ {projects.map((project) => ( + + + {project.kind === 'idea' ? 'open idea' : (project.group?.name ?? 'ungrouped')} + + {project.name} + + + ))} +
+
+ ); +} + function selectedCategoriesByProject(ballot?: BallotStatusResponse) { const result = new Map(); if (!ballot) return result; diff --git a/src/app/styles.css b/src/app/styles.css index efea3d6..5ce8fd3 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -292,7 +292,7 @@ main { letter-spacing: -0.055em; } .archiveHero p:last-child, -.projectsHero > div > p:last-child, +.projectsHero > div > p:last-of-type, .editorPage > header > p:last-child, .operationsHero > p { max-width: 34rem; @@ -301,6 +301,64 @@ main { font-size: 1rem; line-height: 1.75; } +.myProjects { + margin-top: 1.5rem; +} +.myProjects .kicker { + margin-bottom: 0.75rem; +} +.myProjectsTiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); + gap: 0.65rem; +} +.myProjectTile { + display: grid; + gap: 0.4rem; + min-width: 0; + padding: 0.85rem 0.95rem; + color: inherit; + text-decoration: none; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: #fff; + transition: + transform 120ms ease, + box-shadow 120ms ease, + border-color 120ms ease; +} +.myProjectTile:hover { + color: inherit; + border-color: var(--blurple); + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(78, 42, 154, 0.1); +} +.myProjectTile--idea { + background: #fcfaff; +} +.myProjectTile .tag { + overflow: hidden; + max-width: 100%; + text-overflow: ellipsis; + white-space: nowrap; +} +.myProjectTile strong { + overflow: hidden; + font-size: 0.95rem; + font-weight: 600; + line-height: 1.25; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} +.myProjectTile .memberStack, +.myProjectTile .openSeat { + margin-top: 0.15rem; +} +.myProjectTile .memberStack > span { + width: 1.65rem; + height: 1.65rem; +} .currentYearHero { animation: rise 0.35s ease-out both; @@ -912,6 +970,7 @@ main { } .projectRow { display: grid; + position: relative; grid-template-columns: minmax(12rem, 1fr) auto auto; gap: 1rem; align-items: center; @@ -920,6 +979,14 @@ main { border-bottom: 1px solid var(--line); animation: rise 0.3s ease-out both; } +.projectRow:has(h2 a) { + cursor: pointer; +} +.projectRow h2 a::after { + position: absolute; + inset: 0; + content: ''; +} .projectRow h2 { min-width: 0; margin: 0; @@ -984,6 +1051,7 @@ main { } .projectCard { display: flex; + position: relative; min-width: 0; min-height: 18rem; padding: 1.25rem; @@ -993,6 +1061,7 @@ main { background: #fff; box-shadow: 0 1px 0 rgba(29, 17, 39, 0.03); animation: rise 0.3s ease-out both; + cursor: pointer; transition: box-shadow 120ms ease, transform 120ms ease; @@ -1041,6 +1110,15 @@ main { .projectCard h2 a { text-decoration: none; } +.projectCard h2 a::after { + position: absolute; + inset: 0; + content: ''; +} +.projectCard > .markdown a { + position: relative; + z-index: 1; +} .projectCard > .markdown { display: -webkit-box; overflow: hidden; @@ -3389,6 +3467,9 @@ kbd { align-items: stretch; flex-direction: column; } + .myProjectsTiles { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } .ballotOverview { grid-template-columns: 1fr; gap: 1.75rem; diff --git a/src/shared/projects.ts b/src/shared/projects.ts index bb4e36c..4dfd2cf 100644 --- a/src/shared/projects.ts +++ b/src/shared/projects.ts @@ -69,6 +69,7 @@ export interface YearResponse { year: YearSummary; groups: GroupSummary[]; awards: AwardSummary[]; + myProjects: ProjectSummary[]; } export interface ProjectsResponse { diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts index 5920c05..46a6fb7 100644 --- a/src/worker/repositories/projects.ts +++ b/src/worker/repositories/projects.ts @@ -164,6 +164,26 @@ export async function listProjectOptions(db: D1Database, yearId: string) { }; } +export async function listMyProjects(db: D1Database, yearId: string, userId: string) { + const {results} = await db + .prepare( + `${projectSelect()} WHERE p.year_id = ? AND p.status = 'active' + AND (p.creator_id = ? OR EXISTS ( + SELECT 1 FROM project_members pm + WHERE pm.project_id = p.id AND pm.user_id = ? + )) + ORDER BY CASE p.kind WHEN 'project' THEN 0 ELSE 1 END, + p.name COLLATE NOCASE, p.id`, + ) + .bind(yearId, userId, userId) + .all(); + const members = await membersByProjectIds( + db, + results.map(({id}) => id), + ); + return results.map((row) => mapProject(row, members.get(row.id) ?? [])); +} + export async function listProjects( db: D1Database, options: { diff --git a/src/worker/routes/years.ts b/src/worker/routes/years.ts index b49d02b..df344fc 100644 --- a/src/worker/routes/years.ts +++ b/src/worker/routes/years.ts @@ -7,6 +7,7 @@ import {createGroup} from '../repositories/groups'; import { getYear, listGroups, + listMyProjects, listProjectOptions, listYears, } from '../repositories/projects'; @@ -23,9 +24,10 @@ yearsRoutes.get('/', async (c) => { yearsRoutes.get('/:yearId', async (c) => { try { const yearId = c.req.param('yearId'); - const [year, groups, awardResult] = await Promise.all([ + const [year, groups, myProjects, awardResult] = await Promise.all([ getYear(c.env.DB, yearId), listGroups(c.env.DB, yearId), + listMyProjects(c.env.DB, yearId, c.get('user').id), c.env.DB.prepare( `SELECT a.id, a.year_id, a.project_id, p.name project_name, a.category_id, category.name category_name, a.name @@ -47,6 +49,7 @@ yearsRoutes.get('/:yearId', async (c) => { const response: YearResponse = { year, groups, + myProjects, awards: awardResult.results.map((award) => ({ id: award.id, yearId: award.year_id, diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 0b781ae..765ae21 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -206,6 +206,7 @@ describe('clickable project routes', () => { }, groups: [], awards: [], + myProjects: [], }); } if (url.includes('/api/votes?')) { @@ -471,6 +472,58 @@ describe('clickable project routes', () => { expect(listBadge.closest('.projectRow')).toBeTruthy(); }); + it('tiles owned and attached projects under the page title', async () => { + const ownedIdea = { + ...projectFixture, + id: 'owned-idea', + name: 'Postcard idea', + kind: 'idea' as const, + group: null, + members: [], + }; + const attachedProject = { + ...projectFixture, + id: 'attached-project', + name: 'Shared machine', + creator: { + ...projectFixture.creator, + id: 'someone-else', + displayName: 'Someone Else', + }, + }; + mockProjectsOverview({ + votingEnabled: false, + projects: [projectFixture, ownedIdea, attachedProject], + myProjects: [attachedProject, ownedIdea], + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + const heading = await screen.findByRole('heading', {name: 'projects & ideas'}); + const mine = screen.getByRole('region', {name: 'your projects'}); + expect( + heading.compareDocumentPosition(mine) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(within(mine).getByText('yours')).toBeTruthy(); + const attached = within(mine).getByRole('link', {name: /Shared machine/}); + expect(attached.getAttribute('href')).toBe('/years/2026/projects/attached-project'); + expect(within(attached).getByLabelText('Member One')).toBeTruthy(); + expect( + within(mine) + .getByRole('link', {name: /Postcard idea/}) + .getAttribute('href'), + ).toBe('/years/2026/projects/owned-idea'); + expect(within(mine).queryByRole('link', {name: /A small machine/})).toBeNull(); + }); + + it('hides the personal project tiles when none belong to you', async () => { + mockProjectsOverview({votingEnabled: false, projects: [projectFixture]}); + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy(); + expect(screen.queryByRole('region', {name: 'your projects'})).toBeNull(); + }); + it('keeps closed-year browsing and ballot read failures local', async () => { mockProjectsOverview({votingEnabled: false, projects: [projectFixture]}); const closed = renderRoute( @@ -514,6 +567,7 @@ describe('clickable project routes', () => { }, groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}], awards: [], + myProjects: [], }); } return json({ @@ -592,6 +646,7 @@ describe('clickable project routes', () => { }, groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}], awards: [], + myProjects: [], }); } return json({ @@ -655,6 +710,7 @@ describe('clickable project routes', () => { }, groups: [], awards: [], + myProjects: [], }); } @@ -759,6 +815,7 @@ describe('clickable project routes', () => { }, groups: [], awards: [], + myProjects: [], }); } @@ -830,6 +887,7 @@ describe('clickable project routes', () => { }, groups: [], awards: [], + myProjects: [], }); } @@ -882,6 +940,7 @@ describe('clickable project routes', () => { }, groups: [], awards: [], + myProjects: [], }); } @@ -938,6 +997,7 @@ describe('clickable project routes', () => { }, groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}], awards: [], + myProjects: [], streamMode: 'disabled', }); } @@ -1297,8 +1357,17 @@ describe('clickable project routes', () => { renderRoute(, '/'); - const link = screen.getByRole('link', {name: 'link'}); - const description = link.closest('.markdown'); + const card = screen + .getByRole('heading', {name: 'A small machine'}) + .closest('.projectCard'); + expect(card).toBeTruthy(); + const projectLink = screen.getByRole('link', {name: 'A small machine'}); + const markdownLink = screen.getByRole('link', {name: 'link'}); + expect(projectLink.getAttribute('href')).toBe('/years/2026/projects/project'); + expect(markdownLink.getAttribute('href')).toBe('https://example.com'); + expect(markdownLink.closest('.projectCard')).toBe(card); + expect(projectLink.contains(markdownLink)).toBe(false); + const description = markdownLink.closest('.markdown'); expect(description?.classList.contains('markdown--compact')).toBe(true); expect(description?.getAttribute('title')).toBe(summary); expect(screen.getByText('detail').tagName).toBe('STRONG'); @@ -1528,12 +1597,14 @@ function mockProjectsOverview({ categories = [], votes = [], projects = [], + myProjects = [], ballotError = false, }: { votingEnabled?: boolean; categories?: Array<{id: string; yearId: string; name: string}>; votes?: BallotStatusResponse['votes']; projects?: ProjectDetail[]; + myProjects?: ProjectDetail[]; ballotError?: boolean; }) { fetchMock.mockImplementation(async (input) => { @@ -1552,6 +1623,7 @@ function mockProjectsOverview({ }, groups: [], awards: [], + myProjects, }); } if (url.includes('/api/votes?')) { diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts index 3ba8bc8..f5c1eb1 100644 --- a/test/projects/projects.test.ts +++ b/test/projects/projects.test.ts @@ -95,6 +95,39 @@ describe('project and history APIs', () => { expect(page.body.projects[0].members).toBeInstanceOf(Array); }); + it('includes owned and attached projects on the year payload', async () => { + const owned = await createProject(memberToken, {name: 'Owned engine'}); + const idea = await createProject(memberToken, { + name: 'Owned postcard', + kind: 'idea', + groupId: null, + }); + await session(outsiderToken); + const outsiderProject = await createProject(outsiderToken, {name: 'Outsider engine'}); + const attached = await createProject(outsiderToken, {name: 'Attached engine'}); + const member = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') + .bind(`project-member-${suffix}`) + .first<{id: string}>(); + await env.DB.prepare( + 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)', + ) + .bind(attached.id, member!.id) + .run(); + + const year = await api(`/years/${yearId}`, memberToken); + const outsiderYear = await api(`/years/${yearId}`, outsiderToken); + + expect(year.status).toBe(200); + expect(year.body.myProjects.map((project: {id: string}) => project.id)).toEqual([ + attached.id, + owned.id, + idea.id, + ]); + expect( + outsiderYear.body.myProjects.map((project: {id: string}) => project.id), + ).toEqual([attached.id, outsiderProject.id]); + }); + it('returns ordered year categories and persists ordered project nominations', async () => { const options = await api(`/years/${yearId}/options`, memberToken); const allCategories = await createProject(memberToken, { From 77f69deeb51a899088dc836ffd539d4a9dbdac03 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 21 Aug 2026 14:04:52 +0200 Subject: [PATCH 2/2] style(projects): wrap yours-tile group label CI format check failed on the yours strip ternary. Split the open-idea vs group label across lines so vp fmt is clean. --- src/app/routes/ProjectsPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx index 11c98db..62fd902 100644 --- a/src/app/routes/ProjectsPage.tsx +++ b/src/app/routes/ProjectsPage.tsx @@ -346,7 +346,9 @@ function MyProjectsStrip({projects}: {projects: ProjectSummary[]}) { key={project.id} > - {project.kind === 'idea' ? 'open idea' : (project.group?.name ?? 'ungrouped')} + {project.kind === 'idea' + ? 'open idea' + : (project.group?.name ?? 'ungrouped')} {project.name}