From 67458321fd6de401053834b43c90e0dbe3db45cd Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 17 Aug 2026 15:04:29 +0200 Subject: [PATCH 1/7] refactor(admin): retire project nomination management Remove nomination configuration from the admin API, response contract, query mutations, and year-management UI so projects are no longer presented as category-restricted. Keep the legacy ballot nomination read and historical schema intact for the transitional voting flow, while updating worker and app coverage to prove the administration path is inert. --- src/app/queries/administration.ts | 10 +- src/app/routes/AdminPage.tsx | 61 ----------- src/app/styles.css | 19 +--- src/shared/administration.ts | 5 - src/worker/repositories/administration.ts | 114 +++++++------------- src/worker/routes/admin.ts | 14 --- src/worker/services/administration-input.ts | 13 --- test/admin/admin.test.ts | 47 +++----- test/app/administration.test.tsx | 3 +- 9 files changed, 60 insertions(+), 226 deletions(-) diff --git a/src/app/queries/administration.ts b/src/app/queries/administration.ts index 5161b26..826d1c2 100644 --- a/src/app/queries/administration.ts +++ b/src/app/queries/administration.ts @@ -69,14 +69,6 @@ export function useAdminMutations(yearId: string) { apiRequest(`/admin/categories/${encodeURIComponent(id)}`, {method: 'DELETE'}), onSuccess: refresh, }); - const nominations = useMutation({ - mutationFn: ({projectId, categoryIds}: {projectId: string; categoryIds: string[]}) => - apiRequest( - `/admin/projects/${encodeURIComponent(projectId)}/nominations`, - jsonRequest('PUT', {categoryIds}), - ), - onSuccess: refresh, - }); const award = useMutation({ mutationFn: ({id, input}: {id?: string; input: AwardWriteRequest}) => apiRequest<{award: AwardSummary}>( @@ -100,7 +92,7 @@ export function useAdminMutations(yearId: string) { ), onSuccess: refresh, }); - return {year, category, removeCategory, nominations, award, removeAward, screening}; + return {year, category, removeCategory, award, removeAward, screening}; } export function useAnalytics(yearId?: string) { diff --git a/src/app/routes/AdminPage.tsx b/src/app/routes/AdminPage.tsx index 8d100d5..ea8a289 100644 --- a/src/app/routes/AdminPage.tsx +++ b/src/app/routes/AdminPage.tsx @@ -2,7 +2,6 @@ import {useEffect, useState} from 'react'; import type {FormEvent} from 'react'; import {Link, useParams} from 'wouter'; -import type {AdminProjectSummary} from '../../shared/administration'; import {QueryState} from '../components/AppLayout'; import {useAdminMutations, useAdminYear} from '../queries/administration'; @@ -35,7 +34,6 @@ export function AdminPage() { actions.year, actions.category, actions.removeCategory, - actions.nominations, actions.award, actions.removeAward, actions.screening, @@ -119,20 +117,6 @@ export function AdminPage() { ))} -
-

Eligibility

-

Project nominations

- {query.data.projects.map((project) => ( - - actions.nominations.mutate({projectId: project.id, categoryIds}) - } - /> - ))} -

Results

Awards

@@ -276,51 +260,6 @@ export function AdminPage() { ); } -function NominationEditor({ - project, - categories, - onSave, -}: { - project: AdminProjectSummary; - categories: {id: string; name: string}[]; - onSave: (ids: string[]) => void; -}) { - const [selected, setSelected] = useState( - project.nominations.map(({categoryId}) => categoryId), - ); - useEffect( - () => setSelected(project.nominations.map(({categoryId}) => categoryId)), - [project.nominations], - ); - return ( -
- {project.name} -
- {categories.map((category) => ( - - ))} -
- -
- ); -} - function move(items: string[], from: number, to: number) { const next = [...items]; const [item] = next.splice(from, 1); diff --git a/src/app/styles.css b/src/app/styles.css index 76ff0db..3566eab 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -940,8 +940,7 @@ main { } .groupManager li, .adminList li, -.orderList li, -.nominationRow { +.orderList li { display: flex; gap: 1rem; align-items: center; @@ -1818,19 +1817,6 @@ main { .orderList { margin: 1.5rem 0; } -.nominationRow > div { - display: flex; - flex-wrap: wrap; - gap: 0.8rem; -} -.nominationRow label { - color: var(--muted); - font-size: 0.82rem; -} -.nominationRow input { - margin-right: 0.35rem; - accent-color: var(--blurple); -} .orderList li div { display: flex; gap: 0.35rem; @@ -2757,8 +2743,7 @@ kbd { .projectControls, .projectSearch, .operationsBar, - .groupManager > header, - .nominationRow { + .groupManager > header { align-items: stretch; flex-direction: column; } diff --git a/src/shared/administration.ts b/src/shared/administration.ts index ffc2621..9abc81f 100644 --- a/src/shared/administration.ts +++ b/src/shared/administration.ts @@ -52,7 +52,6 @@ export interface AwardSummary { export interface AdminProjectSummary { id: string; name: string; - nominations: NominationSummary[]; videoStatus: import('./videos').VideoStatus | null; } @@ -90,10 +89,6 @@ export interface AwardWriteRequest { categoryId: string; } -export interface NominationsWriteRequest { - categoryIds: string[]; -} - export interface ScreeningOrderWriteRequest { projectIds: string[]; } diff --git a/src/worker/repositories/administration.ts b/src/worker/repositories/administration.ts index adb1d1d..94e7aa8 100644 --- a/src/worker/repositories/administration.ts +++ b/src/worker/repositories/administration.ts @@ -206,63 +206,53 @@ export async function getAdminYear( yearId: string, ): Promise { const year = await getYear(db, yearId); - const [categoryResult, awardResult, projectResult, nominationResult, orderResult] = - await Promise.all([ - db - .prepare( - 'SELECT id, year_id, name FROM award_categories WHERE year_id = ? ORDER BY name COLLATE NOCASE, id', - ) - .bind(yearId) - .all(), - db - .prepare( - `SELECT a.id, a.year_id, a.project_id, p.name project_name, + const [categoryResult, awardResult, projectResult, orderResult] = await Promise.all([ + db + .prepare( + 'SELECT id, year_id, name FROM award_categories WHERE year_id = ? ORDER BY name COLLATE NOCASE, id', + ) + .bind(yearId) + .all(), + db + .prepare( + `SELECT a.id, a.year_id, a.project_id, p.name project_name, a.category_id, c.name category_name, a.name FROM awards a JOIN projects p ON p.id = a.project_id JOIN award_categories c ON c.id = a.category_id WHERE a.year_id = ? ORDER BY c.name COLLATE NOCASE, a.id`, - ) - .bind(yearId) - .all<{ - id: string; - year_id: string; - project_id: string; - project_name: string; - category_id: string; - category_name: string; - name: string; - }>(), - db - .prepare( - `SELECT p.id, p.name, pv.status video_status FROM projects p + ) + .bind(yearId) + .all<{ + id: string; + year_id: string; + project_id: string; + project_name: string; + category_id: string; + category_name: string; + name: string; + }>(), + db + .prepare( + `SELECT p.id, p.name, pv.status video_status FROM projects p LEFT JOIN video_submissions pv ON pv.project_id = p.id AND pv.retired_at IS NULL WHERE p.year_id = ? AND p.kind = 'project' AND p.status = 'active' ORDER BY p.name COLLATE NOCASE, p.id`, - ) - .bind(yearId) - .all<{ - id: string; - name: string; - video_status: import('../../shared/videos').VideoStatus | null; - }>(), - db - .prepare( - `SELECT n.project_id, n.award_category_id, n.position - FROM project_nominations n JOIN projects p ON p.id = n.project_id - WHERE p.year_id = ? ORDER BY n.project_id, n.position`, - ) - .bind(yearId) - .all(), - db - .prepare( - `SELECT o.project_id, p.name project_name, o.position + ) + .bind(yearId) + .all<{ + id: string; + name: string; + video_status: import('../../shared/videos').VideoStatus | null; + }>(), + db + .prepare( + `SELECT o.project_id, p.name project_name, o.position FROM screening_order o JOIN projects p ON p.id = o.project_id WHERE o.year_id = ? ORDER BY o.position`, - ) - .bind(yearId) - .all<{project_id: string; project_name: string; position: number}>(), - ]); - const nominations = nominationsByProject(nominationResult.results); + ) + .bind(yearId) + .all<{project_id: string; project_name: string; position: number}>(), + ]); return { year: { id: year.id, @@ -276,7 +266,6 @@ export async function getAdminYear( id: project.id, name: project.name, videoStatus: project.video_status, - nominations: nominations.get(project.id) ?? [], })), screeningOrder: orderResult.results.map( (row): ScreeningOrderItem => ({ @@ -340,32 +329,6 @@ export async function deleteCategory(db: D1Database, id: string) { } } -export async function replaceNominations( - db: D1Database, - projectId: string, - categoryIds: string[], -) { - try { - await db.batch([ - db.prepare('DELETE FROM project_nominations WHERE project_id = ?').bind(projectId), - ...categoryIds.map((categoryId, index) => - db - .prepare( - `INSERT INTO project_nominations (project_id, award_category_id, position) - VALUES (?, ?, ?)`, - ) - .bind(projectId, categoryId, index + 1), - ), - ]); - } catch (error) { - throw administrationConstraint(error, 'Nominations could not be saved'); - } - return categoryIds.map((categoryId, index) => ({ - categoryId, - position: index === 0 ? 1 : 2, - })); -} - export async function createAward( db: D1Database, yearId: string, @@ -617,7 +580,6 @@ function administrationConstraint(cause: unknown, fallback: string) { 'vote project must', 'vote category must', 'users cannot vote', - 'nomination category and project', 'award references must', 'screening entry must', 'FOREIGN KEY constraint failed', diff --git a/src/worker/routes/admin.ts b/src/worker/routes/admin.ts index e8174dd..0e0554b 100644 --- a/src/worker/routes/admin.ts +++ b/src/worker/routes/admin.ts @@ -8,14 +8,12 @@ import { createYear, deleteCategory, getAdminYear, - replaceNominations, replaceScreeningOrder, updateCategory, updateYear, } from '../repositories/administration'; import { parseNamed, - parseNominations, parseScreeningOrder, parseYear, } from '../services/administration-input'; @@ -77,18 +75,6 @@ adminRoutes.delete('/categories/:categoryId', async (c) => }), ); -adminRoutes.put('/projects/:projectId/nominations', async (c) => - run(c, async () => - c.json({ - nominations: await replaceNominations( - c.env.DB, - c.req.param('projectId'), - parseNominations(await c.req.json()).categoryIds, - ), - }), - ), -); - adminRoutes.put('/years/:yearId/screening-order', async (c) => run(c, async () => c.json({ diff --git a/src/worker/services/administration-input.ts b/src/worker/services/administration-input.ts index 62cf74d..588e15c 100644 --- a/src/worker/services/administration-input.ts +++ b/src/worker/services/administration-input.ts @@ -1,7 +1,6 @@ import type { AwardWriteRequest, NamedWriteRequest, - NominationsWriteRequest, ScreeningOrderWriteRequest, VoteWriteRequest, YearWriteRequest, @@ -51,18 +50,6 @@ export function parseAward(value: JsonInput): AwardWriteRequest { }; } -export function parseNominations(value: JsonInput): NominationsWriteRequest { - const body = record(value); - if (!Array.isArray(body.categoryIds) || body.categoryIds.length > 2) { - invalid('A project can have at most two nominations'); - } - const categoryIds = body.categoryIds.map((value) => identifier(value, 'Category')); - if (new Set(categoryIds).size !== categoryIds.length) { - invalid('Nomination categories must be distinct'); - } - return {categoryIds}; -} - export function parseScreeningOrder(value: JsonInput): ScreeningOrderWriteRequest { const body = record(value); if (!Array.isArray(body.projectIds)) invalid('Project order must be an array'); diff --git a/test/admin/admin.test.ts b/test/admin/admin.test.ts index 5adbe26..5232b99 100644 --- a/test/admin/admin.test.ts +++ b/test/admin/admin.test.ts @@ -98,40 +98,27 @@ describe('year and award administration', () => { expect(stored).toEqual({voting_enabled: 1, submissions_closed: 0}); }); - it('enforces two distinct same-year nominations in validation and D1', async () => { - const first = await createCategory('First'); - const second = await createCategory('Second'); - const third = await createCategory('Third'); - const saved = await api(`/admin/projects/${projectId}/nominations`, adminToken, { + it('does not expose project nomination administration', async () => { + const category = await createCategory('Unused nomination'); + const response = await SELF.fetch(`${base}/admin/projects/${projectId}/nominations`, { method: 'PUT', - body: {categoryIds: [first.id, second.id]}, + headers: { + Cookie: adminToken, + Origin: 'https://hackweek.test', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({categoryIds: [category.id]}), }); - const duplicate = await api(`/admin/projects/${projectId}/nominations`, adminToken, { - method: 'PUT', - body: {categoryIds: [first.id, first.id]}, - }); - const tooMany = await api(`/admin/projects/${projectId}/nominations`, adminToken, { - method: 'PUT', - body: {categoryIds: [first.id, second.id, third.id]}, - }); - const otherYear = `${yearId}-other`; - await env.DB.prepare('INSERT INTO years (id) VALUES (?)').bind(otherYear).run(); - const crossId = `cross-category-${sequence}`; - await env.DB.prepare( - `INSERT INTO award_categories (id, source_id, year_id, name, creator_id) - VALUES (?, ?, ?, ?, ?)`, + const state = await api(`/admin/years/${yearId}`, adminToken); + const nomination = await env.DB.prepare( + 'SELECT project_id FROM project_nominations WHERE project_id = ?', ) - .bind(crossId, crossId, otherYear, 'Cross', adminId) - .run(); - const cross = await api(`/admin/projects/${projectId}/nominations`, adminToken, { - method: 'PUT', - body: {categoryIds: [crossId]}, - }); + .bind(projectId) + .first(); - expect(saved.body.nominations).toHaveLength(2); - expect(duplicate.status).toBe(400); - expect(tooMany.status).toBe(400); - expect(cross.body.error.message).toMatch(/nomination/); + expect(response.headers.get('Content-Type')).toContain('text/html'); + expect(nomination).toBeNull(); + expect(state.body.projects[0]).not.toHaveProperty('nominations'); }); it('creates one same-year award per category and rejects invalid references', async () => { diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index 213430e..302ca11 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -72,6 +72,7 @@ describe('voting and administration journeys', () => { renderRoute(, '/admin/years/2026', '/admin/years/:yearId'); const submissions = await screen.findByRole('checkbox', {name: 'Submissions closed'}); + expect(screen.queryByRole('heading', {name: 'Project nominations'})).toBeNull(); await userEvent.click(submissions); await userEvent.type(screen.getByLabelText('Category name'), 'New category'); await userEvent.click(screen.getByRole('button', {name: 'Add category'})); @@ -209,6 +210,6 @@ const adminFixture = { }, categories: [{id: 'category-1', yearId: '2026', name: 'Delight'}], awards: [], - projects: [{id: 'project-1', name: 'First project', nominations: []}], + projects: [{id: 'project-1', name: 'First project', videoStatus: null}], screeningOrder: [], }; From 19ba5cd57224f2a27182ec8a35a6718ab1ed5449 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 17 Aug 2026 15:14:09 +0200 Subject: [PATCH 2/7] feat(voting): replace aggregate ballot with status API Return only effective voting state, award categories, and the signed-in user's selections with current project names from the yearly vote read. Expose project-level voting eligibility while preserving existing vote write invariants. Retire the standalone ballot route, header action, repeated-project UI, and obsolete styles. Add focused Worker and app coverage for the compact response, nomination-independent voting, viewer permissions, and removed route. --- src/app/App.tsx | 2 - src/app/queries/administration.ts | 16 ++- src/app/routes/ProjectsPage.tsx | 5 - src/app/routes/VotingPage.tsx | 135 ---------------------- src/app/styles.css | 62 +--------- src/shared/administration.ts | 24 +--- src/shared/projects.ts | 1 + src/worker/repositories/administration.ts | 126 ++++++-------------- src/worker/repositories/projects.ts | 1 + test/app/ProjectForm.test.tsx | 8 +- test/app/administration.test.tsx | 77 ------------ test/app/auth.test.tsx | 38 ++++++ test/app/routes.test.tsx | 13 ++- test/projects/projects.test.ts | 25 ++++ test/voting/voting.test.ts | 51 ++++---- 15 files changed, 163 insertions(+), 421 deletions(-) delete mode 100644 src/app/routes/VotingPage.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 9ed4bcd..0346ae5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -7,7 +7,6 @@ import {AdminPage} from './routes/AdminPage'; import {EditProjectPage, NewProjectPage} from './routes/ProjectEditorPage'; import {ProjectDetailsPage} from './routes/ProjectDetailsPage'; import {ProjectsPage} from './routes/ProjectsPage'; -import {VotingPage} from './routes/VotingPage'; import {YearAdministrationPage} from './routes/YearAdministrationPage'; import {ProjectVideoWatchPage, VideoWatchPage, WatchPage} from './routes/WatchPage'; import {YearsPage} from './routes/YearsPage'; @@ -72,7 +71,6 @@ export function App() { {session.user.role === 'admin' ? : } - diff --git a/src/app/queries/administration.ts b/src/app/queries/administration.ts index 826d1c2..6be791d 100644 --- a/src/app/queries/administration.ts +++ b/src/app/queries/administration.ts @@ -5,19 +5,22 @@ import type { AnalyticsResponse, AwardSummary, AwardWriteRequest, + BallotStatusResponse, ScreeningOrderItem, VoteSummary, VoteWriteRequest, - VotingResponse, YearWriteRequest, } from '../../shared/administration'; import {apiRequest, jsonRequest} from './api'; -export function useVoting(yearId: string) { +const ballotStatusQueryKey = (yearId: string) => ['ballot-status', yearId] as const; + +export function useBallotStatus(yearId: string, enabled = true) { return useQuery({ - queryKey: ['voting', yearId], + queryKey: ballotStatusQueryKey(yearId), queryFn: () => - apiRequest(`/votes?year=${encodeURIComponent(yearId)}`), + apiRequest(`/votes?year=${encodeURIComponent(yearId)}`), + enabled, }); } @@ -29,7 +32,8 @@ export function useVoteMutation(yearId: string) { voteId ? `/votes/${encodeURIComponent(voteId)}` : '/votes', jsonRequest(voteId ? 'PUT' : 'POST', input), ), - onSuccess: () => void cache.invalidateQueries({queryKey: ['voting', yearId]}), + onSuccess: () => + void cache.invalidateQueries({queryKey: ballotStatusQueryKey(yearId)}), }); } @@ -45,7 +49,7 @@ export function useAdminMutations(yearId: string) { const cache = useQueryClient(); const refresh = () => { void cache.invalidateQueries({queryKey: ['admin-year', yearId]}); - void cache.invalidateQueries({queryKey: ['voting', yearId]}); + void cache.invalidateQueries({queryKey: ballotStatusQueryKey(yearId)}); void cache.invalidateQueries({queryKey: ['year', yearId]}); void cache.invalidateQueries({queryKey: ['years']}); }; diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx index efceb57..aeea3ba 100644 --- a/src/app/routes/ProjectsPage.tsx +++ b/src/app/routes/ProjectsPage.tsx @@ -81,11 +81,6 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { watch reel )} - {year.data.year.votingEnabled && ( - - vote - - )} {isAdmin && ( manage year diff --git a/src/app/routes/VotingPage.tsx b/src/app/routes/VotingPage.tsx deleted file mode 100644 index bbb02e4..0000000 --- a/src/app/routes/VotingPage.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import {useState} from 'react'; -import {Link, useParams} from 'wouter'; - -import {QueryState} from '../components/AppLayout'; -import {Markdown} from '../components/Markdown'; -import {useVoteMutation, useVoting} from '../queries/administration'; - -export function VotingPage() { - const {yearId} = useParams<{yearId: string}>(); - const query = useVoting(yearId); - const vote = useVoteMutation(yearId); - const [search, setSearch] = useState(''); - - return ( -
- - ← Projects - -
-
-

Hackweek {yearId}

-

vote for projects

-
-

- choose one project per award category. choosing again moves your vote; you - cannot vote for your own project. -

-
- - {query.data && ( - <> -
- - {query.data.year.votingEnabled ? 'Voting is open' : 'Voting is closed'} - - -
- {!query.data.categories.length ? ( -

No award categories are configured.

- ) : ( -
- {query.data.categories.map((category) => { - const current = query.data.votes.find( - (item) => item.categoryId === category.id, - ); - const projects = query.data.projects.filter((project) => { - const phrase = - `${project.name} ${project.memberNames.join(' ')}`.toLowerCase(); - return ( - phrase.includes(search.toLowerCase()) && - (project.nominations.length === 0 || - project.nominations.some( - (nomination) => nomination.categoryId === category.id, - )) - ); - }); - return ( -
-
-

Award category

-

{category.name}

-
-
- {projects.map((project) => { - const selected = current?.projectId === project.id; - return ( -
-
- {project.groupName ?? 'Independent'} -

{project.name}

- {project.summary} -
-
- {project.memberNames.join(' · ')} - -
-
- ); - })} -
-
- ); - })} -
- )} - {vote.error && ( -

- {vote.error.message} -

- )} - - )} -
-
- ); -} diff --git a/src/app/styles.css b/src/app/styles.css index 3566eab..3b895e4 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1027,7 +1027,6 @@ main { .projectNarrative > h2, .teamPanel h2, .mediaSection h2, -.ballotSection h2, .controlPanel h2, .resultsTable h2 { margin: 0 0 1.25rem; @@ -1730,63 +1729,6 @@ main { min-width: 14rem; margin-left: 0.5rem; } -.ballotSections { - width: 100%; -} -.ballotSection { - margin-bottom: 4rem; -} -.ballotSection > header { - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--line); -} -.ballotGrid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1rem; - padding-top: 1rem; -} -.ballotCard { - display: flex; - min-height: 17rem; - padding: 1.25rem; - flex-direction: column; - border: 1px solid var(--line); - border-radius: 0.75rem; - background: #fff; -} -.ballotCard--selected { - border-color: var(--blurple); - background: #f7f4ff; - box-shadow: inset 0 0 0 1px var(--blurple); -} -.ballotCard small { - color: var(--dark-blurple); - font-weight: 600; -} -.ballotCard h3 { - margin: 1rem 0 0.5rem; - font-size: 1.35rem; -} -.ballotCard .markdown { - display: -webkit-box; - overflow: hidden; - line-height: 1.55; - -webkit-box-orient: vertical; - -webkit-line-clamp: 6; -} -.ballotCard footer { - display: flex; - gap: 1rem; - align-items: end; - justify-content: space-between; - margin-top: auto; -} -.ballotCard footer span { - color: var(--muted); - font-size: 0.75rem; -} - .adminGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -2690,8 +2632,7 @@ kbd { .yearTimeline { gap: 2rem; } - .projectGrid, - .ballotGrid { + .projectGrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .detailLayout { @@ -2731,7 +2672,6 @@ kbd { } .yearTimeline, .projectGrid, - .ballotGrid, .adminGrid, .metricGrid { grid-template-columns: 1fr; diff --git a/src/shared/administration.ts b/src/shared/administration.ts index 9abc81f..a8f1dd6 100644 --- a/src/shared/administration.ts +++ b/src/shared/administration.ts @@ -4,21 +4,6 @@ export interface AwardCategorySummary { name: string; } -export interface NominationSummary { - categoryId: string; - position: 1 | 2; -} - -export interface VotingProject { - id: string; - name: string; - summary: string; - groupName: string | null; - memberNames: string[]; - nominations: NominationSummary[]; - eligible: boolean; -} - export interface VoteSummary { id: string; yearId: string; @@ -26,11 +11,14 @@ export interface VoteSummary { categoryId: string; } -export interface VotingResponse { +export interface BallotSelection extends VoteSummary { + projectName: string; +} + +export interface BallotStatusResponse { year: {id: string; votingEnabled: boolean}; categories: AwardCategorySummary[]; - projects: VotingProject[]; - votes: VoteSummary[]; + votes: BallotSelection[]; } export interface VoteWriteRequest { diff --git a/src/shared/projects.ts b/src/shared/projects.ts index dd66d79..c288bdc 100644 --- a/src/shared/projects.ts +++ b/src/shared/projects.ts @@ -56,6 +56,7 @@ export interface ProjectDetail extends ProjectSummary { canDelete: boolean; canClaim: boolean; canManageMedia: boolean; + canVote: boolean; }; } diff --git a/src/worker/repositories/administration.ts b/src/worker/repositories/administration.ts index 94e7aa8..c13841d 100644 --- a/src/worker/repositories/administration.ts +++ b/src/worker/repositories/administration.ts @@ -4,10 +4,10 @@ import type { AwardCategorySummary, AwardSummary, AwardWriteRequest, + BallotSelection, + BallotStatusResponse, ScreeningOrderItem, VoteSummary, - VotingProject, - VotingResponse, } from '../../shared/administration'; import {ServiceError} from '../services/errors'; import {getYear} from './projects'; @@ -18,91 +18,43 @@ interface CategoryRow { year_id: string; name: string; } -interface NominationRow { - project_id: string; - award_category_id: string; - position: 1 | 2; -} -interface ProjectRow { - id: string; - name: string; - summary: string | null; - group_name: string | null; - member_names: string | null; - eligible: number; -} - export async function getVoting( db: D1Database, yearId: string, userId: string, -): Promise { +): Promise { const year = await getYear(db, yearId); - const [categoryResult, projectResult, nominationResult, voteResult] = await Promise.all( - [ - db - .prepare( - `SELECT id, year_id, name FROM award_categories - WHERE year_id = ? ORDER BY name COLLATE NOCASE, id`, - ) - .bind(yearId) - .all(), - db - .prepare( - `SELECT p.id, p.name, p.summary, g.name group_name, - GROUP_CONCAT(u.display_name, ' · ') member_names, - CASE WHEN p.creator_id = ? OR EXISTS ( - SELECT 1 FROM project_members own - WHERE own.project_id = p.id AND own.user_id = ? - ) THEN 0 ELSE 1 END eligible - FROM projects p - LEFT JOIN groups g ON g.id = p.group_id - LEFT JOIN project_members pm ON pm.project_id = p.id - LEFT JOIN users u ON u.id = pm.user_id - WHERE p.year_id = ? AND p.kind = 'project' AND p.status = 'active' - GROUP BY p.id ORDER BY p.name COLLATE NOCASE, p.id`, - ) - .bind(userId, userId, yearId) - .all(), - db - .prepare( - `SELECT n.project_id, n.award_category_id, n.position - FROM project_nominations n - JOIN projects p ON p.id = n.project_id - WHERE p.year_id = ? ORDER BY n.project_id, n.position`, - ) - .bind(yearId) - .all(), - db - .prepare( - `SELECT id, year_id, project_id, award_category_id - FROM votes WHERE year_id = ? AND creator_id = ? ORDER BY award_category_id`, - ) - .bind(yearId, userId) - .all<{ - id: string; - year_id: string; - project_id: string; - award_category_id: string; - }>(), - ], - ); - const nominations = nominationsByProject(nominationResult.results); + const [categoryResult, voteResult] = await Promise.all([ + db + .prepare( + `SELECT id, year_id, name FROM award_categories + WHERE year_id = ? ORDER BY name COLLATE NOCASE, id`, + ) + .bind(yearId) + .all(), + db + .prepare( + `SELECT v.id, v.year_id, v.project_id, v.award_category_id, + p.name project_name + FROM votes v + JOIN projects p ON p.id = v.project_id + AND p.kind = 'project' AND p.status = 'active' + WHERE v.year_id = ? AND v.creator_id = ? + ORDER BY v.award_category_id`, + ) + .bind(yearId, userId) + .all<{ + id: string; + year_id: string; + project_id: string; + award_category_id: string; + project_name: string; + }>(), + ]); return { year: {id: year.id, votingEnabled: year.votingEnabled}, categories: categoryResult.results.map(mapCategory), - projects: projectResult.results.map( - (row): VotingProject => ({ - id: row.id, - name: row.name, - summary: row.summary ?? '', - groupName: row.group_name, - memberNames: row.member_names ? row.member_names.split(' · ') : [], - nominations: nominations.get(row.id) ?? [], - eligible: Boolean(row.eligible), - }), - ), - votes: voteResult.results.map(mapVote), + votes: voteResult.results.map(mapBallotSelection), }; } @@ -522,30 +474,22 @@ async function getAward(db: D1Database, id: string): Promise { return mapAward(row); } -function nominationsByProject(rows: NominationRow[]) { - const result = new Map(); - for (const row of rows) { - const list = result.get(row.project_id) ?? []; - list.push({categoryId: row.award_category_id, position: row.position}); - result.set(row.project_id, list); - } - return result; -} - function mapCategory(row: CategoryRow): AwardCategorySummary { return {id: row.id, yearId: row.year_id, name: row.name}; } -function mapVote(row: { +function mapBallotSelection(row: { id: string; year_id: string; project_id: string; award_category_id: string; -}): VoteSummary { + project_name: string; +}): BallotSelection { return { id: row.id, yearId: row.year_id, projectId: row.project_id, + projectName: row.project_name, categoryId: row.award_category_id, }; } diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts index 5c26a97..da3c738 100644 --- a/src/worker/repositories/projects.ts +++ b/src/worker/repositories/projects.ts @@ -249,6 +249,7 @@ export async function getProject( canClaim: !year.submissionsClosed && row.kind === 'idea' && projectMembers.length === 0, canManageMedia: canWrite && row.kind === 'project', + canVote: row.kind === 'project' && !isCreator && !isMember, }, }; } diff --git a/test/app/ProjectForm.test.tsx b/test/app/ProjectForm.test.tsx index b516bce..a95fbb8 100644 --- a/test/app/ProjectForm.test.tsx +++ b/test/app/ProjectForm.test.tsx @@ -198,5 +198,11 @@ const projectFixture: ProjectDetail = { members: [alice], mediaCount: 0, media: [], - permissions: {canEdit: true, canDelete: true, canClaim: false, canManageMedia: true}, + permissions: { + canEdit: true, + canDelete: true, + canClaim: false, + canManageMedia: true, + canVote: false, + }, }; diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index 302ca11..8fa0fa2 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -8,58 +8,12 @@ import {afterEach, describe, expect, it, vi} from 'vitest'; import {AdminAnalyticsPage} from '../../src/app/routes/AdminAnalyticsPage'; import {AdminPage} from '../../src/app/routes/AdminPage'; -import {VotingPage} from '../../src/app/routes/VotingPage'; const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); afterEach(() => fetchMock.mockReset()); describe('voting and administration journeys', () => { - it('renders compact Markdown in voting cards', async () => { - fetchMock.mockResolvedValue( - json({ - ...votingFixture, - projects: [ - { - ...votingFixture.projects[0], - summary: '**Working** details at [the docs](https://example.com).', - }, - ], - }), - ); - renderRoute(, '/years/2026/vote', '/years/:yearId/vote'); - - expect((await screen.findByText('Working')).tagName).toBe('STRONG'); - const link = screen.getByRole('link', {name: 'the docs'}); - expect(link.closest('.markdown')?.classList.contains('markdown--compact')).toBe(true); - expect(link.getAttribute('target')).toBe('_blank'); - }); - - it('moves an existing vote to the selected project through the API', async () => { - fetchMock.mockImplementation(async (_input, init) => { - if (init?.method === 'PUT') return json({vote: {...vote, projectId: 'project-2'}}); - return json(votingFixture); - }); - renderRoute(, '/years/2026/vote', '/years/:yearId/vote'); - - expect(await screen.findByText('First project')).toBeTruthy(); - await userEvent.click(screen.getByRole('button', {name: 'Move vote'})); - - await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - '/api/votes/vote-1', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ - yearId: '2026', - projectId: 'project-2', - categoryId: 'category-1', - }), - }), - ), - ); - }); - it('renders admin controls and sends year/category changes to aggregate APIs', async () => { fetchMock.mockImplementation(async (input, init) => { const url = input instanceof Request ? input.url : input.toString(); @@ -170,37 +124,6 @@ function json(value: T, status = 200) { }); } -const vote = { - id: 'vote-1', - yearId: '2026', - projectId: 'project-1', - categoryId: 'category-1', -}; -const votingFixture = { - year: {id: '2026', votingEnabled: true}, - categories: [{id: 'category-1', yearId: '2026', name: 'Delight'}], - projects: [ - { - id: 'project-1', - name: 'First project', - summary: 'One.', - groupName: 'Orbital', - memberNames: ['A'], - nominations: [{categoryId: 'category-1', position: 1}], - eligible: true, - }, - { - id: 'project-2', - name: 'Second project', - summary: 'Two.', - groupName: null, - memberNames: ['B'], - nominations: [{categoryId: 'category-1', position: 1}], - eligible: true, - }, - ], - votes: [vote], -}; const adminFixture = { year: { id: '2026', diff --git a/test/app/auth.test.tsx b/test/app/auth.test.tsx index 772196e..829dd75 100644 --- a/test/app/auth.test.tsx +++ b/test/app/auth.test.tsx @@ -78,6 +78,44 @@ describe('Google sign-in experience', () => { expect(screen.getByRole('link', {name: 'Sign in with Google'})).toBeTruthy(); }); + it('does not register the retired standalone ballot route', async () => { + window.history.replaceState(null, '', '/years/2026/vote'); + fetchMock.mockImplementation(async (input) => { + const path = + input instanceof Request ? new URL(input.url).pathname : input.toString(); + if (path === '/api/session') { + return Response.json({ + user: { + id: 'member', + email: 'member@sentry.io', + displayName: 'Member One', + avatarUrl: null, + role: 'member', + actualRole: 'member', + }, + }); + } + return Response.json({years: []}); + }); + const queryClient = new QueryClient({ + defaultOptions: {queries: {retry: false}}, + }); + + const rendered = render( + + + + + , + ); + + expect( + await screen.findByRole('heading', {name: 'Lost in the archive'}), + ).toBeTruthy(); + expect(screen.queryByRole('heading', {name: 'vote for projects'})).toBeNull(); + rendered.unmount(); + }); + it('explains a failed fixed callback without reflecting arbitrary text', async () => { window.history.replaceState(null, '', '/?auth_error=failed&message=attacker'); fetchMock.mockResolvedValue( diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 24598e5..024e27a 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -172,7 +172,7 @@ describe('clickable project routes', () => { return json({ year: { id: '2026', - votingEnabled: false, + votingEnabled: true, submissionsClosed, projectCount: 0, ideaCount: 0, @@ -193,6 +193,7 @@ describe('clickable project routes', () => { ); expect(await screen.findByRole('heading', {name: 'projects & ideas'})).toBeTruthy(); expect(screen.queryByRole('link', {name: 'watch reel'})).toBeNull(); + expect(screen.queryByRole('link', {name: 'vote'})).toBeNull(); member.unmount(); const admin = renderRoute( @@ -565,6 +566,7 @@ describe('clickable project routes', () => { canDelete: false, canClaim: true, canManageMedia: false, + canVote: false, }, }, }), @@ -591,6 +593,7 @@ describe('clickable project routes', () => { canDelete: true, canClaim: false, canManageMedia: true, + canVote: false, }, }; fetchMock.mockImplementation(async (input, init) => { @@ -667,5 +670,11 @@ const projectFixture: ProjectDetail = { ], mediaCount: 0, media: [], - permissions: {canEdit: true, canDelete: true, canClaim: false, canManageMedia: true}, + permissions: { + canEdit: true, + canDelete: true, + canClaim: false, + canManageMedia: true, + canVote: false, + }, }; diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts index 813b40d..479f819 100644 --- a/test/projects/projects.test.ts +++ b/test/projects/projects.test.ts @@ -74,6 +74,31 @@ describe('project and history APIs', () => { expect(page.body.projects[0].members).toBeInstanceOf(Array); }); + it('exposes project voting permission for eligible viewers but not creators, members, or ideas', async () => { + const project = await createProject(memberToken); + const idea = await createProject(memberToken, {kind: 'idea', groupId: null}); + const creatorView = await api(`/projects/${project.id}`, memberToken); + + await session(outsiderToken); + const outsider = await env.DB.prepare('SELECT id FROM users WHERE google_subject = ?') + .bind(`project-outsider-${suffix}`) + .first<{id: string}>(); + const eligibleView = await api(`/projects/${project.id}`, outsiderToken); + const ideaView = await api(`/projects/${idea.id}`, outsiderToken); + + await env.DB.prepare( + 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)', + ) + .bind(project.id, outsider!.id) + .run(); + const memberView = await api(`/projects/${project.id}`, outsiderToken); + + expect(creatorView.body.project.permissions.canVote).toBe(false); + expect(eligibleView.body.project.permissions.canVote).toBe(true); + expect(memberView.body.project.permissions.canVote).toBe(false); + expect(ideaView.body.project.permissions.canVote).toBe(false); + }); + it('searches titles and descriptions before pagination with relevant results first', async () => { const exact = await createProject(memberToken, { name: 'Signal', diff --git a/test/voting/voting.test.ts b/test/voting/voting.test.ts index 66af722..bb11188 100644 --- a/test/voting/voting.test.ts +++ b/test/voting/voting.test.ts @@ -76,37 +76,40 @@ beforeEach(async () => { }); describe('voting invariants', () => { - it('returns categories, nominated projects, and only the current user votes', async () => { - await env.DB.prepare( - `INSERT INTO project_nominations (project_id, award_category_id, position) - VALUES (?, ?, 1)`, - ) - .bind(projectId, categoryId) - .run(); + it('returns compact current-user ballot status without requiring nominations', async () => { const created = await api('/votes', voterToken, {method: 'POST', body: voteBody()}); expect(created.status).toBe(201); + await env.DB.prepare('UPDATE projects SET name = ? WHERE id = ?') + .bind('Renamed signal', projectId) + .run(); const voting = await api(`/votes?year=${yearId}`, voterToken); const otherUser = await api(`/votes?year=${yearId}`, memberToken); + const nominations = await env.DB.prepare( + 'SELECT COUNT(*) count FROM project_nominations WHERE project_id = ?', + ) + .bind(projectId) + .first<{count: number}>(); - expect(voting.body).toMatchObject({ + expect(nominations?.count).toBe(0); + expect(voting.body).toEqual({ year: {id: yearId, votingEnabled: true}, - categories: [{id: categoryId, name: 'Delight'}], - projects: expect.arrayContaining([ - expect.objectContaining({ - id: projectId, - nominations: [{categoryId, position: 1}], - eligible: true, - }), - ]), - votes: [expect.objectContaining({projectId, categoryId})], + categories: [{id: categoryId, yearId, name: 'Delight'}], + votes: [ + { + id: created.body.vote.id, + yearId, + projectId, + projectName: 'Renamed signal', + categoryId, + }, + ], + }); + expect(otherUser.body).toEqual({ + year: {id: yearId, votingEnabled: true}, + categories: [{id: categoryId, yearId, name: 'Delight'}], + votes: [], }); - expect(otherUser.body.votes).toEqual([]); - expect( - otherUser.body.projects.find( - (project: {id: string}) => project.id === ownProjectId, - ), - ).toMatchObject({eligible: false}); }); it('rejects disabled, self-project, cross-year, and invalid reference votes', async () => { @@ -167,6 +170,7 @@ describe('voting invariants', () => { ), ]); + const status = await api(`/votes?year=${otherYearId}`, voterToken); const cast = await api('/votes', voterToken, { method: 'POST', body: { @@ -187,6 +191,7 @@ describe('voting invariants', () => { method: 'DELETE', }); + expect(status.body.year).toEqual({id: otherYearId, votingEnabled: false}); for (const response of [cast, replaced, deleted]) { expect(response).toMatchObject({ status: 400, From e5963bb101d4bc809a5bbb64efcc11b4f305541b Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 17 Aug 2026 15:21:04 +0200 Subject: [PATCH 3/7] feat(voting): add ballot progress overview Show open-voting progress, remaining category votes, and linked project selections above the existing project discovery controls. Provide distinct empty, partial, complete, zero-category, loading, and localized error states with responsive Hackweek styling. Cover progress semantics, selection links, closed-year query suppression, completion messaging, and resilient project browsing in app route tests. --- src/app/routes/ProjectsPage.tsx | 112 ++++++++++++++++++ src/app/styles.css | 172 +++++++++++++++++++++++++++ test/app/routes.test.tsx | 198 ++++++++++++++++++++++++++++++++ 3 files changed, 482 insertions(+) diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx index aeea3ba..b6ccb64 100644 --- a/src/app/routes/ProjectsPage.tsx +++ b/src/app/routes/ProjectsPage.tsx @@ -1,9 +1,11 @@ import {useEffect, useState} from 'react'; import {Link, useParams} from 'wouter'; +import type {BallotStatusResponse} from '../../shared/administration'; import {GroupManager} from '../components/GroupManager'; import {ProjectCard} from '../components/ProjectCard'; import {PageState, QueryState} from '../components/AppLayout'; +import {useBallotStatus} from '../queries/administration'; import {useProjects, useYear} from '../queries/projects'; type ProjectsView = 'grid' | 'list'; @@ -37,6 +39,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { const [search, setSearch] = useState(''); const [view, setView] = useState(getProjectsView); const year = useYear(yearId); + const ballot = useBallotStatus(yearId, year.data?.year.votingEnabled ?? false); const projects = useProjects( yearId, kind, @@ -93,6 +96,14 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { )} + {year.data.year.votingEnabled && ( + + )}
); } + +function BallotOverview({ + yearId, + data, + error, + loading, +}: { + yearId: string; + data?: BallotStatusResponse; + error: Error | null; + loading: boolean; +}) { + if (loading) { + return ( +
+
+

your ballot

+

counting your picks…

+
+

you can keep browsing while your progress loads.

+
+ ); + } + + if (error) { + return ( +
+
+

your ballot

+

progress is taking a break

+
+

we couldn't load your picks, but every project is still here to explore.

+
+ ); + } + + if (!data?.year.votingEnabled) return null; + + const selections = data.categories.flatMap((category) => { + const vote = data.votes.find((item) => item.categoryId === category.id); + return vote ? [{category, vote}] : []; + }); + const categoryCount = data.categories.length; + const castCount = data.votes.length; + const remainingCount = Math.max(categoryCount - castCount, 0); + const complete = categoryCount > 0 && remainingCount === 0; + let message = 'open a project to cast your first vote.'; + if (categoryCount === 0) { + message = 'award categories are still being set up. check back soon.'; + } else if (complete) { + message = 'ballot complete — every category has your pick.'; + } else if (castCount > 0) { + message = `keep exploring — ${remainingCount} ${remainingCount === 1 ? 'vote' : 'votes'} left to cast.`; + } + + return ( +
+
+

voting is open

+

your ballot

+

{message}

+
+ + {castCount} {castCount === 1 ? 'vote' : 'votes'} cast + + + {remainingCount}{' '} + {remainingCount === 1 ? 'vote' : 'votes'} remaining + +
+ + {castCount} of {categoryCount} + + + {castCount} of {categoryCount} categor{categoryCount === 1 ? 'y' : 'ies'} + +
+
+

{selections.length ? 'your picks so far' : 'where to begin'}

+ {selections.length ? ( +
    + {selections.map(({category, vote}) => ( +
  • + {category.name} + + {vote.projectName} + +
  • + ))} +
+ ) : ( +

+ {categoryCount + ? 'open any project that catches your eye and choose a category there.' + : 'once categories are ready, project pages will be the place to vote.'} +

+ )} +
+
+ ); +} diff --git a/src/app/styles.css b/src/app/styles.css index 3b895e4..15cd742 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -567,6 +567,167 @@ main { background: var(--danger); } +.ballotOverview { + position: relative; + display: grid; + grid-template-columns: minmax(15rem, 0.78fr) minmax(0, 1.22fr); + gap: clamp(1.5rem, 4vw, 3.5rem); + overflow: hidden; + padding: clamp(1.5rem, 4vw, 2.25rem); + margin: 0 0 2.5rem; + color: #fff; + border-radius: 0.9rem; + background: + radial-gradient( + circle at 93% 15%, + rgba(255, 112, 188, 0.36) 0 5rem, + transparent 5.1rem + ), + var(--dark-blurple); + box-shadow: 0 14px 32px rgba(29, 17, 39, 0.16); + animation: rise 0.35s ease-out both; +} +.ballotOverview::after { + position: absolute; + right: -1.5rem; + bottom: -2.5rem; + width: 8rem; + height: 8rem; + content: ''; + border: 1.25rem solid rgba(255, 255, 255, 0.08); + border-radius: 50%; + pointer-events: none; +} +.ballotOverview .kicker { + color: var(--pink); +} +.ballotOverview h2, +.ballotOverview h3, +.ballotOverview p { + margin-top: 0; +} +.ballotOverview h2 { + margin-bottom: 0.7rem; + font-size: clamp(1.65rem, 4vw, 2.35rem); + line-height: 1; + letter-spacing: -0.045em; +} +.ballotOverviewProgress > p:last-of-type { + max-width: 28rem; + margin-bottom: 1.35rem; + color: rgba(255, 255, 255, 0.76); + font-size: 0.88rem; + line-height: 1.55; +} +.ballotCounts { + display: flex; + gap: 1.5rem; + margin-bottom: 0.85rem; +} +.ballotCounts strong { + font-size: 1.35rem; + font-weight: 600; +} +.ballotCounts span { + color: rgba(255, 255, 255, 0.6); + font-size: 0.7rem; + font-weight: 500; +} +.ballotOverview progress { + display: block; + width: 100%; + height: 0.55rem; + overflow: hidden; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); + appearance: none; +} +.ballotOverview progress::-webkit-progress-bar { + border-radius: 999px; + background: rgba(255, 255, 255, 0.16); +} +.ballotOverview progress::-webkit-progress-value { + border-radius: 999px; + background: var(--green); +} +.ballotOverview progress::-moz-progress-bar { + border-radius: 999px; + background: var(--green); +} +.ballotOverviewProgress small { + display: block; + margin-top: 0.45rem; + color: rgba(255, 255, 255, 0.58); + font-size: 0.68rem; +} +.ballotSelections { + position: relative; + z-index: 1; + align-self: center; +} +.ballotSelections h3 { + margin-bottom: 0.8rem; + color: rgba(255, 255, 255, 0.64); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.ballotSelections ul { + padding: 0; + margin: 0; + list-style: none; +} +.ballotSelections li { + display: grid; + grid-template-columns: minmax(7rem, 0.7fr) minmax(0, 1.3fr); + gap: 1rem; + align-items: baseline; + padding: 0.72rem 0; + border-top: 1px solid rgba(255, 255, 255, 0.18); +} +.ballotSelections li > span { + color: rgba(255, 255, 255, 0.62); + font-size: 0.75rem; +} +.ballotSelections a { + min-width: 0; + overflow: hidden; + color: #fff; + font-size: 0.88rem; + font-weight: 600; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} +.ballotSelections a:hover { + color: var(--green); +} +.ballotSelections > p { + max-width: 30rem; + margin-bottom: 0; + color: rgba(255, 255, 255, 0.74); + font-size: 0.88rem; + line-height: 1.6; +} +.ballotOverview--notice { + grid-template-columns: minmax(14rem, 0.6fr) minmax(0, 1fr); + align-items: end; + padding-block: 1.5rem; + background: var(--dark-blurple); +} +.ballotOverview--notice h2 { + margin-bottom: 0; + font-size: 1.4rem; +} +.ballotOverview--notice > p { + margin-bottom: 0; + color: rgba(255, 255, 255, 0.74); + font-size: 0.85rem; + line-height: 1.55; +} + .projectControls, .operationsBar { display: flex; @@ -2680,6 +2841,7 @@ kbd { grid-column: auto; } .detailHero, + .ballotOverview, .projectControls, .projectSearch, .operationsBar, @@ -2687,6 +2849,16 @@ kbd { align-items: stretch; flex-direction: column; } + .ballotOverview { + gap: 1.75rem; + } + .ballotSelections li { + grid-template-columns: 1fr; + gap: 0.25rem; + } + .ballotSelections a { + text-align: left; + } .projectSearch > div { width: 100%; } diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 024e27a..2393db6 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -183,6 +183,13 @@ describe('clickable project routes', () => { awards: [], }); } + if (url.includes('/api/votes?')) { + return json({ + year: {id: '2026', votingEnabled: true}, + categories: [], + votes: [], + }); + } return json({projects: [], nextCursor: null}); }); @@ -209,6 +216,147 @@ describe('clickable project routes', () => { expect(await screen.findByRole('link', {name: 'watch reel'})).toBeTruthy(); }); + it('shows open-voting progress and links each selected project', async () => { + mockProjectsOverview({ + categories: [ + {id: 'delight', yearId: '2026', name: 'Delight'}, + {id: 'impact', yearId: '2026', name: 'Impact'}, + {id: 'craft', yearId: '2026', name: 'Craft'}, + ], + votes: [ + { + id: 'vote-1', + yearId: '2026', + projectId: 'signal-forge', + projectName: 'Signal forge', + categoryId: 'delight', + }, + { + id: 'vote-2', + yearId: '2026', + projectId: 'quiet-hours', + projectName: 'Quiet hours', + categoryId: 'impact', + }, + ], + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + const ballot = await screen.findByRole('region', {name: 'your ballot'}); + const counts = within(ballot).getByLabelText('Ballot counts'); + expect(counts.textContent).toContain('2 votes cast'); + expect(counts.textContent).toContain('1 vote remaining'); + expect( + within(ballot).getByText('keep exploring — 1 vote left to cast.'), + ).toBeTruthy(); + const progress = within(ballot).getByRole('progressbar', { + name: 'ballot progress', + }); + expect(progress.getAttribute('value')).toBe('2'); + expect(progress.getAttribute('max')).toBe('3'); + expect( + within(ballot) + .getByRole('link', {name: /Signal forge/}) + .getAttribute('href'), + ).toBe('/years/2026/projects/signal-forge'); + expect( + within(ballot) + .getByRole('link', {name: /Quiet hours/}) + .getAttribute('href'), + ).toBe('/years/2026/projects/quiet-hours'); + expect(within(ballot).getAllByRole('link')).toHaveLength(2); + }); + + it('encourages a first vote and celebrates a completed ballot', async () => { + mockProjectsOverview({ + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + }); + const emptyBallot = renderRoute( + , + '/years/2026/projects', + '/years/:yearId/projects', + ); + + expect( + await screen.findByText('open a project to cast your first vote.'), + ).toBeTruthy(); + expect( + screen.getByText( + 'open any project that catches your eye and choose a category there.', + ), + ).toBeTruthy(); + emptyBallot.unmount(); + + fetchMock.mockReset(); + mockProjectsOverview({ + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [ + { + id: 'vote-1', + yearId: '2026', + projectId: 'signal-forge', + projectName: 'Signal forge', + categoryId: 'delight', + }, + ], + }); + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect( + await screen.findByText('ballot complete — every category has your pick.'), + ).toBeTruthy(); + expect(screen.getByLabelText('Ballot counts').textContent).toContain( + '0 votes remaining', + ); + }); + + it('explains when open voting has no configured categories', async () => { + mockProjectsOverview({}); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect( + await screen.findByText( + 'award categories are still being set up. check back soon.', + ), + ).toBeTruthy(); + expect( + screen.getByText( + 'once categories are ready, project pages will be the place to vote.', + ), + ).toBeTruthy(); + const progress = screen.getByRole('progressbar', {name: 'ballot progress'}); + expect(progress.getAttribute('value')).toBe('0'); + }); + + it('keeps closed-year browsing and ballot read failures local', async () => { + mockProjectsOverview({votingEnabled: false, projects: [projectFixture]}); + const closed = renderRoute( + , + '/years/2026/projects', + '/years/:yearId/projects', + ); + + expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy(); + expect(screen.queryByRole('region', {name: 'your ballot'})).toBeNull(); + expect( + fetchMock.mock.calls.some(([input]) => { + const url = input instanceof Request ? input.url : input.toString(); + return url.includes('/api/votes?'); + }), + ).toBe(false); + closed.unmount(); + + fetchMock.mockReset(); + mockProjectsOverview({ballotError: true, projects: [projectFixture]}); + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + expect(await screen.findByText('progress is taking a break')).toBeTruthy(); + expect(screen.getByRole('heading', {name: 'A small machine'})).toBeTruthy(); + expect(screen.queryByRole('heading', {name: 'Something went wrong'})).toBeNull(); + }); + it('defaults to the grid view when storage is unavailable', async () => { fetchMock.mockImplementation(async (input) => { const url = input instanceof Request ? input.url : input.toString(); @@ -638,6 +786,56 @@ function json(value: T, status = 200) { }); } +function mockProjectsOverview({ + votingEnabled = true, + categories = [], + votes = [], + projects = [], + ballotError = false, +}: { + votingEnabled?: boolean; + categories?: Array<{id: string; yearId: string; name: string}>; + votes?: Array<{ + id: string; + yearId: string; + projectId: string; + projectName: string; + categoryId: string; + }>; + projects?: ProjectDetail[]; + ballotError?: boolean; +}) { + fetchMock.mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/api/years/2026')) { + return json({ + year: { + id: '2026', + votingEnabled, + submissionsClosed: false, + isCurrent: true, + projectCount: projects.length, + ideaCount: 0, + groupCount: 0, + participantCount: 0, + }, + groups: [], + awards: [], + }); + } + if (url.includes('/api/votes?')) { + if (ballotError) { + return json( + {error: {code: 'BALLOT_UNAVAILABLE', message: 'Ballot unavailable'}}, + 503, + ); + } + return json({year: {id: '2026', votingEnabled}, categories, votes}); + } + return json({projects, nextCursor: null}); + }); +} + const projectFixture: ProjectDetail = { id: 'project', yearId: '2026', From d5a42213f5625b9ed1f2e8a64aa7b088e25a05b4 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 17 Aug 2026 15:33:39 +0200 Subject: [PATCH 4/7] feat(voting): add project detail voting controls Place the shared yearly ballot directly on eligible project details with clear uncast, selected, moved, and own-project states. Require an inline confirmation before replacing a vote, keep mutation feedback local and accessible, and await ballot refreshes after successful writes. Add responsive Hackweek-styled category rows plus focused app coverage for first-vote POSTs, confirmed PUTs, cancellation, pending and error feedback, project/idea visibility, and media ordering. --- src/app/components/ProjectVoting.tsx | 199 +++++++++++++++++++++ src/app/queries/administration.ts | 3 +- src/app/routes/ProjectDetailsPage.tsx | 14 ++ src/app/styles.css | 211 ++++++++++++++++++++++ test/app/administration.test.tsx | 220 ++++++++++++++++++++++- test/app/routes.test.tsx | 241 +++++++++++++++++++------- 6 files changed, 824 insertions(+), 64 deletions(-) create mode 100644 src/app/components/ProjectVoting.tsx diff --git a/src/app/components/ProjectVoting.tsx b/src/app/components/ProjectVoting.tsx new file mode 100644 index 0000000..91ccbf5 --- /dev/null +++ b/src/app/components/ProjectVoting.tsx @@ -0,0 +1,199 @@ +import {useState} from 'react'; + +import type { + AwardCategorySummary, + BallotSelection, + BallotStatusResponse, +} from '../../shared/administration'; +import {useVoteMutation} from '../queries/administration'; + +export function ProjectVoting({ + ballot, + project, +}: { + ballot: BallotStatusResponse; + project: {id: string; name: string; yearId: string; canVote: boolean}; +}) { + const vote = useVoteMutation(project.yearId); + const [confirmingCategoryId, setConfirmingCategoryId] = useState(null); + const [statusMessage, setStatusMessage] = useState(null); + const pendingCategory = ballot.categories.find( + (category) => category.id === vote.variables?.input.categoryId, + ); + + function submit(category: AwardCategorySummary, selection?: BallotSelection) { + setStatusMessage(null); + vote.reset(); + vote.mutate( + { + voteId: selection?.id, + input: { + yearId: project.yearId, + projectId: project.id, + categoryId: category.id, + }, + }, + { + onSuccess: () => { + setConfirmingCategoryId(null); + setStatusMessage(`your ${category.name} vote is now on ${project.name}.`); + }, + }, + ); + } + + return ( +
+
+
+

award ballot

+

vote for this project

+
+

+ choose the award categories where {project.name} stands out. each category gets + one project. +

+
+ + {!project.canVote && ( +
+ your project sits this one out +

+ creators and teammates can’t vote for their own work, but your ballot is still + open on every other project. +

+
+ )} + + {!ballot.categories.length ? ( +

+ award categories are still being set up. check back soon. +

+ ) : ( +
    + {ballot.categories.map((category, index) => { + const selection = ballot.votes.find( + (item) => item.categoryId === category.id, + ); + const selectedHere = selection?.projectId === project.id; + const selectedElsewhere = Boolean(selection && !selectedHere); + const confirming = selectedElsewhere && confirmingCategoryId === category.id; + const pending = + vote.isPending && vote.variables?.input.categoryId === category.id; + const state = !project.canVote + ? 'unavailable' + : selectedHere + ? 'selected' + : selectedElsewhere + ? 'elsewhere' + : 'open'; + + return ( +
  • + +
    +

    {category.name}

    + {!project.canVote ? ( +

    unavailable on your own project

    + ) : selectedHere ? ( +

    + your vote +

    + ) : selection ? ( +

    + currently on {selection.projectName} +

    + ) : ( +

    no project selected yet

    + )} +
    + + {project.canVote && !selectedHere && !confirming && ( + + )} + + {confirming && selection && ( +
    +

    + move your {category.name} vote from{' '} + {selection.projectName} to{' '} + {project.name}? +

    +
    + + +
    +
    + )} +
  • + ); + })} +
+ )} + + {vote.isPending && ( +

+ {vote.variables?.voteId ? 'moving' : 'casting'} your{' '} + {pendingCategory?.name ?? 'award'} vote… +

+ )} + {statusMessage && ( +

+ {statusMessage} +

+ )} + {vote.error && ( +

+ {vote.error.message} +

+ )} +
+ ); +} diff --git a/src/app/queries/administration.ts b/src/app/queries/administration.ts index 6be791d..724ddeb 100644 --- a/src/app/queries/administration.ts +++ b/src/app/queries/administration.ts @@ -32,8 +32,7 @@ export function useVoteMutation(yearId: string) { voteId ? `/votes/${encodeURIComponent(voteId)}` : '/votes', jsonRequest(voteId ? 'PUT' : 'POST', input), ), - onSuccess: () => - void cache.invalidateQueries({queryKey: ballotStatusQueryKey(yearId)}), + onSuccess: () => cache.invalidateQueries({queryKey: ballotStatusQueryKey(yearId)}), }); } diff --git a/src/app/routes/ProjectDetailsPage.tsx b/src/app/routes/ProjectDetailsPage.tsx index 8f82d7b..e747855 100644 --- a/src/app/routes/ProjectDetailsPage.tsx +++ b/src/app/routes/ProjectDetailsPage.tsx @@ -4,6 +4,8 @@ import {Link, useLocation, useParams} from 'wouter'; import {QueryState} from '../components/AppLayout'; import {Markdown} from '../components/Markdown'; +import {ProjectVoting} from '../components/ProjectVoting'; +import {useBallotStatus} from '../queries/administration'; import {getPlayback, useProjectVideo} from '../queries/videos'; import {ProjectVideoPanel} from '../video/ProjectVideoPanel'; import { @@ -20,6 +22,7 @@ export function ProjectDetailsPage() { }>(); const [, navigate] = useLocation(); const project = useProject(projectId); + const ballot = useBallotStatus(yearId, project.data?.project.kind === 'project'); const withdraw = useDeleteProject(); const upload = useUploadMedia(projectId); const removeMedia = useDeleteMedia(projectId); @@ -144,6 +147,17 @@ export function ProjectDetailsPage() {
+ {project.data.project.kind === 'project' && ballot.data?.year.votingEnabled && ( + + )} {project.data.project.kind === 'project' && ( header { + display: grid; + grid-template-columns: minmax(13rem, 0.8fr) minmax(0, 1.2fr); + gap: clamp(1.5rem, 5vw, 4rem); + align-items: end; + padding-bottom: 1.5rem; +} +.projectVoting > header h2 { + margin: 0; + font-size: clamp(1.8rem, 4vw, 2.65rem); + line-height: 1; + letter-spacing: -0.045em; +} +.projectVoting > header > p { + max-width: 32rem; + margin: 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.65; +} +.projectVotingOwn { + display: grid; + grid-template-columns: minmax(12rem, 0.65fr) minmax(0, 1.35fr); + gap: 1.5rem; + align-items: baseline; + padding: 1rem 1.15rem; + margin-bottom: 1rem; + color: #56380a; + border: 1px solid #ead07f; + border-radius: 0.65rem; + background: #fff9df; +} +.projectVotingOwn strong { + font-size: 0.88rem; +} +.projectVotingOwn p { + margin: 0; + font-size: 0.8rem; + line-height: 1.55; +} +.projectVotingEmpty { + padding: 1.25rem; + margin: 0; + color: var(--muted); + border-top: 1px solid var(--line); +} +.projectVotingCategories { + padding: 0; + margin: 0; + list-style: none; + border-top: 1px solid var(--line); +} +.projectVotingCategory { + display: grid; + grid-template-columns: 2.5rem minmax(10rem, 1fr) auto; + gap: 1rem; + align-items: center; + min-height: 6rem; + padding: 1rem 1.1rem; + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + border-left: 4px solid transparent; + background: rgba(255, 255, 255, 0.82); + transition: + border-color 120ms ease, + background 120ms ease; +} +.projectVotingCategory--selected { + border-left-color: var(--green); + background: #f5ffe5; +} +.projectVotingCategory--elsewhere { + border-left-color: var(--yellow); +} +.projectVotingCategory--unavailable { + border-left-color: #bdb4c8; + background: rgba(247, 245, 250, 0.9); +} +.projectVotingNumber { + align-self: start; + padding-top: 0.15rem; + color: #a59aad; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.08em; +} +.projectVotingCategoryCopy h3 { + margin: 0 0 0.35rem; + font-size: 1.05rem; + line-height: 1.2; +} +.projectVotingCategoryCopy p { + margin: 0; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.45; +} +.projectVotingCategoryCopy p strong { + color: var(--ink); +} +.projectVotingSelected { + display: inline-flex; + gap: 0.35rem; + align-items: center; + color: #305500 !important; +} +.projectVotingSelected::before { + content: '✓'; + font-size: 0.75rem; +} +.projectVotingCategory > button { + max-width: 17rem; +} +.projectVotingConfirm { + grid-column: 2 / -1; + display: grid; + grid-template-columns: minmax(12rem, 1fr) auto; + gap: 1rem; + align-items: center; + padding: 1rem; + margin-top: 0.15rem; + color: #56380a; + border: 1px solid #ead07f; + border-radius: 0.65rem; + background: #fff9df; +} +.projectVotingConfirm p { + margin: 0; + font-size: 0.82rem; + line-height: 1.55; +} +.projectVotingConfirm > div { + display: flex; + gap: 0.6rem; +} +.projectVotingConfirm .textAction, +.projectVotingConfirm .primaryAction { + min-height: 2.35rem; + padding: 0.55rem 0.75rem; + font-size: 0.75rem; +} +.projectVotingFeedback { + padding: 0.85rem 1rem; + margin: 1rem 0 0; + font-size: 0.82rem; + font-weight: 500; + border-radius: 0.55rem; +} +.projectVotingFeedback--pending { + color: var(--dark-blurple); + border: 1px solid #d2bfff; + background: var(--lavender); +} +.projectVotingFeedback--success { + color: #305500; + border: 1px solid #b8db78; + background: #f0ffd7; +} +.projectVotingFeedback--error { + color: #7a1426; + border: 1px solid #efb8c0; + background: #fff0f2; +} .projectNarrative > h2, .teamPanel h2, .mediaSection h2, @@ -2799,6 +2984,12 @@ kbd { .detailLayout { grid-template-columns: 1fr; } + .projectVotingCategory { + grid-template-columns: 2rem minmax(9rem, 1fr) auto; + } + .projectVotingConfirm { + grid-column: 1 / -1; + } .teamPanel { padding: 2rem 0 0; border-top: 1px solid var(--line); @@ -2888,6 +3079,26 @@ kbd { .detailActions { min-width: 0; } + .projectVoting > header, + .projectVotingOwn, + .projectVotingCategory, + .projectVotingConfirm { + grid-template-columns: 1fr; + } + .projectVotingNumber { + display: none; + } + .projectVotingCategory > button { + width: 100%; + max-width: none; + } + .projectVotingConfirm { + grid-column: auto; + } + .projectVotingConfirm > div { + align-items: stretch; + flex-direction: column-reverse; + } .projectForm { grid-template-columns: 1fr; } diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index 8fa0fa2..92c1dd0 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -1,13 +1,16 @@ import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; import type {ReactNode} from 'react'; -import {render, screen, waitFor} from '@testing-library/react'; +import {act, render, screen, waitFor, within} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {Route, Router} from 'wouter'; import {memoryLocation} from 'wouter/memory-location'; import {afterEach, describe, expect, it, vi} from 'vitest'; +import {ProjectVoting} from '../../src/app/components/ProjectVoting'; +import {useBallotStatus} from '../../src/app/queries/administration'; import {AdminAnalyticsPage} from '../../src/app/routes/AdminAnalyticsPage'; import {AdminPage} from '../../src/app/routes/AdminPage'; +import type {BallotStatusResponse} from '../../src/shared/administration'; const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); @@ -70,6 +73,201 @@ describe('voting and administration journeys', () => { expect(votingEnabled.disabled).toBe(true); }); + it('casts a first vote and requires an explicit confirmed move', async () => { + let ballotReads = 0; + let ballot: BallotStatusResponse = { + year: {id: '2026', votingEnabled: true}, + categories: [ + {id: 'delight', yearId: '2026', name: 'Delight'}, + {id: 'impact', yearId: '2026', name: 'Impact'}, + {id: 'craft', yearId: '2026', name: 'Craft'}, + ], + votes: [ + { + id: 'vote-impact', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + categoryId: 'impact', + }, + { + id: 'vote-craft', + yearId: '2026', + projectId: 'other-project', + projectName: 'Quiet hours', + categoryId: 'craft', + }, + ], + }; + fetchMock.mockImplementation(async (input, init) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) { + ballotReads += 1; + return json(ballot); + } + if (url === '/api/votes' && init?.method === 'POST') { + const selection = { + id: 'vote-delight', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + categoryId: 'delight', + }; + ballot = {...ballot, votes: [...ballot.votes, selection]}; + return json({vote: selection}, 201); + } + if (url === '/api/votes/vote-craft' && init?.method === 'PUT') { + const selection = { + id: 'vote-craft', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + categoryId: 'craft', + }; + ballot = { + ...ballot, + votes: ballot.votes.map((item) => + item.id === 'vote-craft' ? selection : item, + ), + }; + return json({vote: selection}); + } + throw new Error(`unexpected request: ${url}`); + }); + + renderRoute(, '/', '/'); + + const voting = await screen.findByRole('region', {name: 'vote for this project'}); + const impactRow = within(voting).getByRole('heading', {name: 'Impact'}).closest('li'); + expect(impactRow).toBeTruthy(); + if (!(impactRow instanceof HTMLElement)) throw new Error(); + expect(within(impactRow).getByText('your vote')).toBeTruthy(); + expect(within(impactRow).queryByRole('button')).toBeNull(); + + await userEvent.click( + within(voting).getByRole('button', { + name: /vote for a small machine in delight/i, + }), + ); + + await waitFor(() => { + const request = fetchMock.mock.calls.find( + ([input, init]) => input === '/api/votes' && init?.method === 'POST', + ); + expect(request).toBeTruthy(); + expect(request?.[1]?.body).toBe( + JSON.stringify({ + yearId: '2026', + projectId: 'project', + categoryId: 'delight', + }), + ); + expect(ballotReads).toBeGreaterThanOrEqual(2); + }); + expect( + await within(voting).findByText('your Delight vote is now on A small machine.'), + ).toBeTruthy(); + const delightRow = within(voting) + .getByRole('heading', {name: 'Delight'}) + .closest('li'); + expect(delightRow).toBeTruthy(); + if (!(delightRow instanceof HTMLElement)) throw new Error(); + expect(within(delightRow).getByText('your vote')).toBeTruthy(); + + await userEvent.click( + within(voting).getByRole('button', {name: /move craft vote here/i}), + ); + expect(within(voting).getByText(/move your Craft vote from/).textContent).toContain( + 'Quiet hours', + ); + await userEvent.click( + within(voting).getByRole('button', {name: /cancel move for craft/i}), + ); + expect( + fetchMock.mock.calls.some( + ([input, init]) => input === '/api/votes/vote-craft' && init?.method === 'PUT', + ), + ).toBe(false); + expect( + within(voting).queryByRole('button', {name: /confirm move for craft/i}), + ).toBeNull(); + + await userEvent.click( + within(voting).getByRole('button', {name: /move craft vote here/i}), + ); + await userEvent.click( + within(voting).getByRole('button', {name: /confirm move for craft/i}), + ); + + await waitFor(() => { + const request = fetchMock.mock.calls.find( + ([input, init]) => input === '/api/votes/vote-craft' && init?.method === 'PUT', + ); + expect(request).toBeTruthy(); + expect(request?.[1]?.body).toBe( + JSON.stringify({ + yearId: '2026', + projectId: 'project', + categoryId: 'craft', + }), + ); + expect(ballotReads).toBeGreaterThanOrEqual(3); + }); + expect( + await within(voting).findByText('your Craft vote is now on A small machine.'), + ).toBeTruthy(); + }); + + it('reports a pending first vote and keeps API errors local', async () => { + let resolveVote!: (response: Response) => void; + const pendingVote = new Promise((resolve) => { + resolveVote = resolve; + }); + const ballot: BallotStatusResponse = { + year: {id: '2026', votingEnabled: true}, + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [], + }; + fetchMock.mockImplementation(async (input, init) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) return json(ballot); + if (url === '/api/votes' && init?.method === 'POST') return pendingVote; + throw new Error(`unexpected request: ${url}`); + }); + + renderRoute(, '/', '/'); + const voting = await screen.findByRole('region', {name: 'vote for this project'}); + await userEvent.click( + within(voting).getByRole('button', { + name: /vote for a small machine in delight/i, + }), + ); + + const pending = await within(voting).findByRole('button', { + name: 'casting your vote…', + }); + expect(within(voting).getByRole('status').textContent).toContain( + 'casting your Delight vote…', + ); + expect(pending).toBeInstanceOf(HTMLButtonElement); + if (!(pending instanceof HTMLButtonElement)) throw new Error(); + expect(pending.disabled).toBe(true); + + await act(async () => { + resolveVote( + json( + {error: {code: 'VOTE_CONFLICT', message: 'This vote changed elsewhere'}}, + 409, + ), + ); + }); + + expect((await within(voting).findByRole('alert')).textContent).toContain( + 'This vote changed elsewhere', + ); + expect(screen.queryByRole('heading', {name: 'Something went wrong'})).toBeNull(); + }); + it('renders D1 aggregate analytics without raw vote identities', async () => { fetchMock.mockResolvedValue( json({ @@ -105,6 +303,26 @@ describe('voting and administration journeys', () => { }); }); +function requestUrl(input: string | URL | Request) { + return input instanceof Request ? input.url : input instanceof URL ? input.href : input; +} + +function VotingHarness() { + const ballot = useBallotStatus('2026'); + if (!ballot.data) return null; + return ( + + ); +} + function renderRoute(element: ReactNode, path: string, pattern: string) { const client = new QueryClient({defaultOptions: {queries: {retry: false}}}); const {hook} = memoryLocation({path}); diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 2393db6..b709c9a 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -11,6 +11,7 @@ import {ProjectCard} from '../../src/app/components/ProjectCard'; import {ProjectDetailsPage} from '../../src/app/routes/ProjectDetailsPage'; import {ProjectsPage} from '../../src/app/routes/ProjectsPage'; import {YearsPage} from '../../src/app/routes/YearsPage'; +import type {BallotStatusResponse} from '../../src/shared/administration'; import type {ProjectDetail} from '../../src/shared/projects'; const fetchMock = vi.fn(); @@ -581,6 +582,97 @@ describe('clickable project routes', () => { }); }); + it('adds every open award category before project media and video', async () => { + mockProjectDetails({ + detail: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + ballot: { + year: {id: '2026', votingEnabled: true}, + categories: [ + {id: 'delight', yearId: '2026', name: 'Delight'}, + {id: 'impact', yearId: '2026', name: 'Impact'}, + {id: 'craft', yearId: '2026', name: 'Craft'}, + ], + votes: [], + }, + }); + + renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const voting = await screen.findByRole('region', {name: 'vote for this project'}); + expect(within(voting).getByRole('heading', {name: 'Delight'})).toBeTruthy(); + expect(within(voting).getByRole('heading', {name: 'Impact'})).toBeTruthy(); + expect(within(voting).getByRole('heading', {name: 'Craft'})).toBeTruthy(); + expect(within(voting).getAllByRole('listitem')).toHaveLength(3); + + const video = screen.getByRole('region', {name: 'project video'}); + expect( + voting.compareDocumentPosition(video) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByRole('heading', {name: 'attachments'})).toBeTruthy(); + }); + + it('explains unavailable own-project voting and hides controls when closed', async () => { + mockProjectDetails({ + detail: projectFixture, + ballot: { + year: {id: '2026', votingEnabled: true}, + categories: [ + {id: 'delight', yearId: '2026', name: 'Delight'}, + {id: 'impact', yearId: '2026', name: 'Impact'}, + ], + votes: [], + }, + }); + + const ownProject = renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const voting = await screen.findByRole('region', {name: 'vote for this project'}); + expect(within(voting).getByText('your project sits this one out')).toBeTruthy(); + expect(within(voting).getAllByText('unavailable on your own project')).toHaveLength( + 2, + ); + expect(within(voting).queryByRole('button')).toBeNull(); + ownProject.unmount(); + + fetchMock.mockReset(); + mockProjectDetails({ + detail: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + ballot: { + year: {id: '2026', votingEnabled: false}, + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [], + }, + }); + + renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + expect(await screen.findByRole('heading', {name: 'A small machine'})).toBeTruthy(); + await waitFor(() => + expect( + fetchMock.mock.calls.some(([input]) => requestUrl(input).includes('/api/votes?')), + ).toBe(true), + ); + expect(screen.queryByRole('region', {name: 'vote for this project'})).toBeNull(); + }); + it('renders compact Markdown in project cards without exposing block layout', () => { const summary = '# Overview\nFirst line\nSecond line with **detail** and a [link](https://example.com).'; @@ -596,15 +688,13 @@ describe('clickable project routes', () => { }); it('renders project descriptions as GitHub-flavored Markdown', async () => { - fetchMock.mockResolvedValue( - json({ - project: { - ...projectFixture, - summary: - 'Built with **care**. Visit https://example.com/docs.\nSecond line with [details](#details).\n\nUse safely.\n\n- [x] Links work', - }, - }), - ); + mockProjectDetails({ + detail: { + ...projectFixture, + summary: + 'Built with **care**. Visit https://example.com/docs.\nSecond line with [details](#details).\n\nUse safely.\n\n- [x] Links work', + }, + }); const rendered = renderRoute( , @@ -627,14 +717,12 @@ describe('clickable project routes', () => { }); it('sanitizes unsafe Markdown URLs and raw HTML', async () => { - fetchMock.mockResolvedValue( - json({ - project: { - ...projectFixture, - summary: '[unsafe](javascript:alert(1))\n\n', - }, - }), - ); + mockProjectDetails({ + detail: { + ...projectFixture, + summary: '[unsafe](javascript:alert(1))\n\n', + }, + }); const rendered = renderRoute( , @@ -649,31 +737,29 @@ describe('clickable project routes', () => { }); it('previews image attachments and opens the original in a new tab', async () => { - fetchMock.mockResolvedValue( - json({ - project: { - ...projectFixture, - media: [ - { - id: 'screenshot', - originalName: 'Launch screenshot.PNG', - mediaType: 'IMAGE/PNG', - sizeBytes: 2048, - status: 'available', - createdAt: '2026-01-02', - }, - { - id: 'notes', - originalName: 'Notes.txt', - mediaType: 'text/plain', - sizeBytes: 9, - status: 'available', - createdAt: '2026-01-03', - }, - ], - }, - }), - ); + mockProjectDetails({ + detail: { + ...projectFixture, + media: [ + { + id: 'screenshot', + originalName: 'Launch screenshot.PNG', + mediaType: 'IMAGE/PNG', + sizeBytes: 2048, + status: 'available', + createdAt: '2026-01-02', + }, + { + id: 'notes', + originalName: 'Notes.txt', + mediaType: 'text/plain', + sizeBytes: 9, + status: 'available', + createdAt: '2026-01-03', + }, + ], + }, + }); const rendered = renderRoute( , @@ -700,25 +786,23 @@ describe('clickable project routes', () => { }); it('renders an idea with no video and exposes the server claim permission', async () => { - fetchMock.mockResolvedValue( - json({ - project: { - ...projectFixture, - id: 'idea', - kind: 'idea', - group: null, - members: [], - media: [], - permissions: { - canEdit: false, - canDelete: false, - canClaim: true, - canManageMedia: false, - canVote: false, - }, + mockProjectDetails({ + detail: { + ...projectFixture, + id: 'idea', + kind: 'idea', + group: null, + members: [], + media: [], + permissions: { + canEdit: false, + canDelete: false, + canClaim: true, + canManageMedia: false, + canVote: false, }, - }), - ); + }, + }); renderRoute( , @@ -730,6 +814,10 @@ describe('clickable project routes', () => { expect( screen.getByRole('link', {name: 'Claim this idea'}).getAttribute('href'), ).toContain('?claim'); + expect(screen.queryByRole('region', {name: 'vote for this project'})).toBeNull(); + expect( + fetchMock.mock.calls.some(([input]) => requestUrl(input).includes('/api/votes?')), + ).toBe(false); }); it('uploads media and refreshes project query state', async () => { @@ -745,8 +833,16 @@ describe('clickable project routes', () => { }, }; fetchMock.mockImplementation(async (input, init) => { - if (init?.method === 'POST') + const url = requestUrl(input); + if (url === '/api/media/projects/project' && init?.method === 'POST') return json({media: {id: 'media', originalName: 'proof.txt'}}, 201); + if (url.includes('/api/votes?')) + return json({ + year: {id: '2026', votingEnabled: false}, + categories: [], + votes: [], + }); + if (url.endsWith('/video')) return json({video: null}); return json({project: detail}); }); @@ -786,6 +882,29 @@ function json(value: T, status = 200) { }); } +function requestUrl(input: string | URL | Request) { + return input instanceof Request ? input.url : input instanceof URL ? input.href : input; +} + +function mockProjectDetails({ + detail, + ballot = { + year: {id: detail.yearId, votingEnabled: false}, + categories: [], + votes: [], + }, +}: { + detail: ProjectDetail; + ballot?: BallotStatusResponse; +}) { + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) return json(ballot); + if (url.endsWith('/video')) return json({video: null}); + return json({project: detail}); + }); +} + function mockProjectsOverview({ votingEnabled = true, categories = [], From 66fdb1939ba92c4f13242786a5a6ad55e471dba7 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 17 Aug 2026 15:56:59 +0200 Subject: [PATCH 5/7] fix(voting): reconcile ballot state edge cases Keep votes on withdrawn projects visible and movable while clearly marking their inactive selections. Query ballots using the loaded project year, refresh ballot state after failed writes, and expose localized loading and retry states on project details.\n\nCorrect the mobile overview grid and add regression coverage for withdrawal, stale conflicts, mismatched routes, and ballot request failures. --- src/app/components/ProjectVoting.tsx | 1 + src/app/queries/administration.ts | 2 +- src/app/routes/ProjectDetailsPage.tsx | 30 +++++- src/app/routes/ProjectsPage.tsx | 13 ++- src/app/styles.css | 37 ++++++- src/shared/administration.ts | 1 + src/worker/repositories/administration.ts | 8 +- test/app/administration.test.tsx | 24 ++++- test/app/routes.test.tsx | 124 ++++++++++++++++++++-- test/voting/voting.test.ts | 49 +++++++++ 10 files changed, 273 insertions(+), 16 deletions(-) diff --git a/src/app/components/ProjectVoting.tsx b/src/app/components/ProjectVoting.tsx index 91ccbf5..adcc23c 100644 --- a/src/app/components/ProjectVoting.tsx +++ b/src/app/components/ProjectVoting.tsx @@ -107,6 +107,7 @@ export function ProjectVoting({ ) : selection ? (

currently on {selection.projectName} + {!selection.projectActive && ' (project withdrawn)'}

) : (

no project selected yet

diff --git a/src/app/queries/administration.ts b/src/app/queries/administration.ts index 724ddeb..285d7bf 100644 --- a/src/app/queries/administration.ts +++ b/src/app/queries/administration.ts @@ -32,7 +32,7 @@ export function useVoteMutation(yearId: string) { voteId ? `/votes/${encodeURIComponent(voteId)}` : '/votes', jsonRequest(voteId ? 'PUT' : 'POST', input), ), - onSuccess: () => cache.invalidateQueries({queryKey: ballotStatusQueryKey(yearId)}), + onSettled: () => cache.invalidateQueries({queryKey: ballotStatusQueryKey(yearId)}), }); } diff --git a/src/app/routes/ProjectDetailsPage.tsx b/src/app/routes/ProjectDetailsPage.tsx index e747855..dd49498 100644 --- a/src/app/routes/ProjectDetailsPage.tsx +++ b/src/app/routes/ProjectDetailsPage.tsx @@ -22,7 +22,8 @@ export function ProjectDetailsPage() { }>(); const [, navigate] = useLocation(); const project = useProject(projectId); - const ballot = useBallotStatus(yearId, project.data?.project.kind === 'project'); + const ballotYearId = project.data?.project.yearId ?? yearId; + const ballot = useBallotStatus(ballotYearId, project.data?.project.kind === 'project'); const withdraw = useDeleteProject(); const upload = useUploadMedia(projectId); const removeMedia = useDeleteMedia(projectId); @@ -147,6 +148,33 @@ export function ProjectDetailsPage() { + {project.data.project.kind === 'project' && ballot.isLoading && ( +
+

award ballot

+

loading voting status…

+
+ )} + {project.data.project.kind === 'project' && ballot.error && ( +
+

award ballot

+

voting status unavailable

+

{ballot.error.message}

+ +
+ )} {project.data.project.kind === 'project' && ballot.data?.year.votingEnabled && ( (
  • {category.name} - - {vote.projectName} - + {vote.projectActive ? ( + + {vote.projectName} + + ) : ( + + {vote.projectName} + project withdrawn — choose another project + + )}
  • ))} diff --git a/src/app/styles.css b/src/app/styles.css index 53aaecb..07ea4d6 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -704,6 +704,22 @@ main { .ballotSelections a:hover { color: var(--green); } +.ballotSelectionInactive { + text-align: right; +} +.ballotSelectionInactive strong, +.ballotSelectionInactive small { + display: block; +} +.ballotSelectionInactive strong { + color: #fff; + font-size: 0.88rem; +} +.ballotSelectionInactive small { + margin-top: 0.2rem; + color: rgba(255, 255, 255, 0.62); + font-size: 0.68rem; +} .ballotSelections > p { max-width: 30rem; margin-bottom: 0; @@ -1207,6 +1223,23 @@ main { content: ''; background: var(--pink); } +.projectVoting--notice { + display: grid; + gap: 0.75rem; +} +.projectVoting--notice h2, +.projectVoting--notice p { + margin: 0; +} +.projectVoting--notice h2 { + font-size: clamp(1.5rem, 3vw, 2rem); +} +.projectVoting--notice > p:not(.kicker) { + color: var(--danger); +} +.projectVoting--notice .textAction { + justify-self: start; +} .projectVoting > header { display: grid; grid-template-columns: minmax(13rem, 0.8fr) minmax(0, 1.2fr); @@ -3041,13 +3074,15 @@ kbd { flex-direction: column; } .ballotOverview { + grid-template-columns: 1fr; gap: 1.75rem; } .ballotSelections li { grid-template-columns: 1fr; gap: 0.25rem; } - .ballotSelections a { + .ballotSelections a, + .ballotSelectionInactive { text-align: left; } .projectSearch > div { diff --git a/src/shared/administration.ts b/src/shared/administration.ts index a8f1dd6..747b21a 100644 --- a/src/shared/administration.ts +++ b/src/shared/administration.ts @@ -13,6 +13,7 @@ export interface VoteSummary { export interface BallotSelection extends VoteSummary { projectName: string; + projectActive: boolean; } export interface BallotStatusResponse { diff --git a/src/worker/repositories/administration.ts b/src/worker/repositories/administration.ts index c13841d..e3d76ee 100644 --- a/src/worker/repositories/administration.ts +++ b/src/worker/repositories/administration.ts @@ -35,10 +35,11 @@ export async function getVoting( db .prepare( `SELECT v.id, v.year_id, v.project_id, v.award_category_id, - p.name project_name + p.name project_name, + p.year_id = v.year_id AND p.kind = 'project' AND p.status = 'active' + project_active FROM votes v JOIN projects p ON p.id = v.project_id - AND p.kind = 'project' AND p.status = 'active' WHERE v.year_id = ? AND v.creator_id = ? ORDER BY v.award_category_id`, ) @@ -49,6 +50,7 @@ export async function getVoting( project_id: string; award_category_id: string; project_name: string; + project_active: number; }>(), ]); return { @@ -484,12 +486,14 @@ function mapBallotSelection(row: { project_id: string; award_category_id: string; project_name: string; + project_active: number; }): BallotSelection { return { id: row.id, yearId: row.year_id, projectId: row.project_id, projectName: row.project_name, + projectActive: Boolean(row.project_active), categoryId: row.award_category_id, }; } diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index 92c1dd0..44d4a4d 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -88,6 +88,7 @@ describe('voting and administration journeys', () => { yearId: '2026', projectId: 'project', projectName: 'A small machine', + projectActive: true, categoryId: 'impact', }, { @@ -95,6 +96,7 @@ describe('voting and administration journeys', () => { yearId: '2026', projectId: 'other-project', projectName: 'Quiet hours', + projectActive: true, categoryId: 'craft', }, ], @@ -111,6 +113,7 @@ describe('voting and administration journeys', () => { yearId: '2026', projectId: 'project', projectName: 'A small machine', + projectActive: true, categoryId: 'delight', }; ballot = {...ballot, votes: [...ballot.votes, selection]}; @@ -122,6 +125,7 @@ describe('voting and administration journeys', () => { yearId: '2026', projectId: 'project', projectName: 'A small machine', + projectActive: true, categoryId: 'craft', }; ballot = { @@ -218,12 +222,12 @@ describe('voting and administration journeys', () => { ).toBeTruthy(); }); - it('reports a pending first vote and keeps API errors local', async () => { + it('reports a pending first vote, keeps errors local, and reconciles conflicts', async () => { let resolveVote!: (response: Response) => void; const pendingVote = new Promise((resolve) => { resolveVote = resolve; }); - const ballot: BallotStatusResponse = { + let ballot: BallotStatusResponse = { year: {id: '2026', votingEnabled: true}, categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], votes: [], @@ -254,6 +258,19 @@ describe('voting and administration journeys', () => { expect(pending.disabled).toBe(true); await act(async () => { + ballot = { + ...ballot, + votes: [ + { + id: 'vote-delight', + yearId: '2026', + projectId: 'other-project', + projectName: 'Quiet hours', + projectActive: true, + categoryId: 'delight', + }, + ], + }; resolveVote( json( {error: {code: 'VOTE_CONFLICT', message: 'This vote changed elsewhere'}}, @@ -265,6 +282,9 @@ describe('voting and administration journeys', () => { expect((await within(voting).findByRole('alert')).textContent).toContain( 'This vote changed elsewhere', ); + expect( + await within(voting).findByRole('button', {name: /move delight vote here/i}), + ).toBeTruthy(); expect(screen.queryByRole('heading', {name: 'Something went wrong'})).toBeNull(); }); diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index b709c9a..458cb2c 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -217,7 +217,7 @@ describe('clickable project routes', () => { expect(await screen.findByRole('link', {name: 'watch reel'})).toBeTruthy(); }); - it('shows open-voting progress and links each selected project', async () => { + it('shows progress, links active picks, and identifies withdrawn picks', async () => { mockProjectsOverview({ categories: [ {id: 'delight', yearId: '2026', name: 'Delight'}, @@ -230,6 +230,7 @@ describe('clickable project routes', () => { yearId: '2026', projectId: 'signal-forge', projectName: 'Signal forge', + projectActive: true, categoryId: 'delight', }, { @@ -237,6 +238,7 @@ describe('clickable project routes', () => { yearId: '2026', projectId: 'quiet-hours', projectName: 'Quiet hours', + projectActive: false, categoryId: 'impact', }, ], @@ -261,12 +263,12 @@ describe('clickable project routes', () => { .getByRole('link', {name: /Signal forge/}) .getAttribute('href'), ).toBe('/years/2026/projects/signal-forge'); + expect(within(ballot).queryByRole('link', {name: /Quiet hours/})).toBeNull(); + expect(within(ballot).getByText('Quiet hours')).toBeTruthy(); expect( - within(ballot) - .getByRole('link', {name: /Quiet hours/}) - .getAttribute('href'), - ).toBe('/years/2026/projects/quiet-hours'); - expect(within(ballot).getAllByRole('link')).toHaveLength(2); + within(ballot).getByText('project withdrawn — choose another project'), + ).toBeTruthy(); + expect(within(ballot).getAllByRole('link')).toHaveLength(1); }); it('encourages a first vote and celebrates a completed ballot', async () => { @@ -298,6 +300,7 @@ describe('clickable project routes', () => { yearId: '2026', projectId: 'signal-forge', projectName: 'Signal forge', + projectActive: true, categoryId: 'delight', }, ], @@ -618,6 +621,114 @@ describe('clickable project routes', () => { expect(screen.getByRole('heading', {name: 'attachments'})).toBeTruthy(); }); + it('uses the loaded project year for ballot state', async () => { + mockProjectDetails({ + detail: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + ballot: { + year: {id: '2026', votingEnabled: true}, + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [], + }, + }); + + renderRoute( + , + '/years/2025/projects/project', + '/years/:yearId/projects/:projectId', + ); + + expect( + await screen.findByRole('region', {name: 'vote for this project'}), + ).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledWith('/api/votes?year=2026', undefined); + expect(fetchMock).not.toHaveBeenCalledWith('/api/votes?year=2025', undefined); + }); + + it('shows local ballot loading and retry states on project details', async () => { + let resolveBallot!: (response: Response) => void; + const pendingBallot = new Promise((resolve) => { + resolveBallot = resolve; + }); + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) return pendingBallot; + if (url.endsWith('/video')) return json({video: null}); + return json({ + project: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + }); + }); + + const loading = renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + expect( + await screen.findByRole('region', {name: 'loading voting status…'}), + ).toBeTruthy(); + resolveBallot( + json({ + year: {id: '2026', votingEnabled: false}, + categories: [], + votes: [], + }), + ); + await waitFor(() => + expect(screen.queryByRole('region', {name: 'loading voting status…'})).toBeNull(), + ); + loading.unmount(); + + fetchMock.mockReset(); + let ballotReads = 0; + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) { + ballotReads += 1; + if (ballotReads === 1) { + return json( + {error: {code: 'BALLOT_UNAVAILABLE', message: 'Ballot unavailable'}}, + 503, + ); + } + return json({ + year: {id: '2026', votingEnabled: true}, + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [], + }); + } + if (url.endsWith('/video')) return json({video: null}); + return json({ + project: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + }); + }); + + renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const error = await screen.findByRole('region', { + name: 'voting status unavailable', + }); + expect(within(error).getByRole('alert').textContent).toContain('Ballot unavailable'); + await userEvent.click(within(error).getByRole('button', {name: 'try again'})); + expect( + await screen.findByRole('region', {name: 'vote for this project'}), + ).toBeTruthy(); + expect(ballotReads).toBe(2); + }); + it('explains unavailable own-project voting and hides controls when closed', async () => { mockProjectDetails({ detail: projectFixture, @@ -919,6 +1030,7 @@ function mockProjectsOverview({ yearId: string; projectId: string; projectName: string; + projectActive: boolean; categoryId: string; }>; projects?: ProjectDetail[]; diff --git a/test/voting/voting.test.ts b/test/voting/voting.test.ts index bb11188..4bd23b1 100644 --- a/test/voting/voting.test.ts +++ b/test/voting/voting.test.ts @@ -101,6 +101,7 @@ describe('voting invariants', () => { yearId, projectId, projectName: 'Renamed signal', + projectActive: true, categoryId, }, ], @@ -112,6 +113,54 @@ describe('voting invariants', () => { }); }); + it('keeps a withdrawn project selection visible and movable', async () => { + const replacementProjectId = `${projectId}-replacement`; + await env.DB.prepare( + `INSERT INTO projects (id, source_id, year_id, creator_id, name) + VALUES (?, ?, ?, ?, ?)`, + ) + .bind( + replacementProjectId, + replacementProjectId, + yearId, + creatorId, + 'Replacement signal', + ) + .run(); + const created = await api('/votes', voterToken, {method: 'POST', body: voteBody()}); + await env.DB.prepare("UPDATE projects SET status = 'withdrawn' WHERE id = ?") + .bind(projectId) + .run(); + + const withdrawnStatus = await api(`/votes?year=${yearId}`, voterToken); + expect(withdrawnStatus.body.votes).toEqual([ + expect.objectContaining({ + id: created.body.vote.id, + projectId, + projectName: 'Signal', + projectActive: false, + categoryId, + }), + ]); + + const moved = await api(`/votes/${created.body.vote.id}`, voterToken, { + method: 'PUT', + body: {...voteBody(), projectId: replacementProjectId}, + }); + expect(moved.body.vote.projectId).toBe(replacementProjectId); + + const movedStatus = await api(`/votes?year=${yearId}`, voterToken); + expect(movedStatus.body.votes).toEqual([ + expect.objectContaining({ + id: created.body.vote.id, + projectId: replacementProjectId, + projectName: 'Replacement signal', + projectActive: true, + categoryId, + }), + ]); + }); + it('rejects disabled, self-project, cross-year, and invalid reference votes', async () => { await env.DB.prepare('UPDATE years SET voting_enabled = 0 WHERE id = ?') .bind(yearId) From 9e9a0282642c837eed2bfed392e15f70d5154f4b Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 17 Aug 2026 16:28:59 +0200 Subject: [PATCH 6/7] feat(voting): surface personal picks in project discovery Mark projects in both grid and list views with the signed-in user's pick count and award categories, making an in-progress ballot visible while browsing.\n\nShow voting-open state on the year overview and simplify vote and move controls with distinct neutral and confirm styling. Add focused route and interaction coverage for the updated labels and indicators. --- src/app/components/ProjectCard.tsx | 21 ++++++++++ src/app/components/ProjectVoting.tsx | 14 +++---- src/app/routes/ProjectsPage.tsx | 24 ++++++++++- src/app/routes/YearsPage.tsx | 13 +++--- src/app/styles.css | 38 +++++++++++++++++ test/app/administration.test.tsx | 26 ++++-------- test/app/routes.test.tsx | 62 ++++++++++++++++++++++++++++ 7 files changed, 165 insertions(+), 33 deletions(-) diff --git a/src/app/components/ProjectCard.tsx b/src/app/components/ProjectCard.tsx index 37c4b96..3c1edb2 100644 --- a/src/app/components/ProjectCard.tsx +++ b/src/app/components/ProjectCard.tsx @@ -11,9 +11,11 @@ interface ProjectListMember { export function ProjectCard({ project, view = 'grid', + voteCategories = [], }: { project: ProjectSummary; view?: 'grid' | 'list'; + voteCategories?: string[]; }) { const projectLink = `/years/${project.yearId}/projects/${project.id}`; @@ -26,6 +28,7 @@ export function ProjectCard({ groupName={project.group?.name ?? 'ungrouped'} members={project.members} needsHelp={project.needsHelp} + voteCategories={voteCategories} /> ); } @@ -39,6 +42,7 @@ export function ProjectCard({ {project.summary}
    +
    ); @@ -55,6 +59,7 @@ export function ProjectListItem({ members, needsHelp = false, emptyMemberLabel = 'up for grabs', + voteCategories = [], }: { name: string; href?: string; @@ -66,6 +71,7 @@ export function ProjectListItem({ members: ProjectListMember[]; needsHelp?: boolean; emptyMemberLabel?: string; + voteCategories?: string[]; }) { return (
    @@ -86,6 +92,7 @@ export function ProjectListItem({
    {groupName} {detail && {detail}} + {needsHelp && looking for help}
    @@ -93,6 +100,20 @@ export function ProjectListItem({ ); } +function ProjectVoteBadge({categories}: {categories: string[]}) { + if (!categories.length) return null; + const count = categories.length; + return ( + + your picks · {count} + + ); +} + function ProjectTags({project, className}: {project: ProjectSummary; className: string}) { return (
    diff --git a/src/app/components/ProjectVoting.tsx b/src/app/components/ProjectVoting.tsx index adcc23c..75be3a8 100644 --- a/src/app/components/ProjectVoting.tsx +++ b/src/app/components/ProjectVoting.tsx @@ -132,8 +132,8 @@ export function ProjectVoting({ {pending ? 'casting your vote…' : selection - ? `move ${category.name} vote here` - : `vote for ${project.name} in ${category.name}`} + ? 'move vote here' + : `vote for ${category.name}`} )} @@ -151,24 +151,22 @@ export function ProjectVoting({
    diff --git a/src/app/routes/ProjectsPage.tsx b/src/app/routes/ProjectsPage.tsx index 46c725a..008f230 100644 --- a/src/app/routes/ProjectsPage.tsx +++ b/src/app/routes/ProjectsPage.tsx @@ -47,6 +47,7 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { search || undefined, ); const error = year.error ?? projects.error; + const voteCategoriesByProject = selectedCategoriesByProject(ballot.data); useEffect(() => { const timeout = window.setTimeout(() => { @@ -221,7 +222,12 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { aria-label={`${kind} list`} > {projects.data.projects.map((project) => ( - + ))}
    )} @@ -231,6 +237,22 @@ export function ProjectsPage({isAdmin = false}: {isAdmin?: boolean}) { ); } +function selectedCategoriesByProject(ballot?: BallotStatusResponse) { + const result = new Map(); + if (!ballot) return result; + const categoryNames = new Map( + ballot.categories.map((category) => [category.id, category.name]), + ); + for (const vote of ballot.votes) { + const categoryName = categoryNames.get(vote.categoryId); + if (!categoryName || !vote.projectActive) continue; + const categories = result.get(vote.projectId) ?? []; + categories.push(categoryName); + result.set(vote.projectId, categories); + } + return result; +} + function BallotOverview({ yearId, data, diff --git a/src/app/routes/YearsPage.tsx b/src/app/routes/YearsPage.tsx index 689f875..12615c6 100644 --- a/src/app/routes/YearsPage.tsx +++ b/src/app/routes/YearsPage.tsx @@ -67,8 +67,7 @@ export function YearsPage() { className="currentYearAction" href={`/years/${currentYear.id}/projects`} > - {currentYear.submissionsClosed ? 'view archive' : 'submissions open'}{' '} - → + {yearActionLabel(currentYear)} → @@ -99,10 +98,7 @@ export function YearsPage() { {year.ideaCount > 0 ? ` · ${year.ideaCount} ideas` : ''} - - {year.submissionsClosed ? 'view archive' : 'submissions open'}{' '} - → - + {yearActionLabel(year)} → @@ -119,6 +115,11 @@ export function YearsPage() { ); } +function yearActionLabel(year: {votingEnabled: boolean; submissionsClosed: boolean}) { + if (year.votingEnabled) return 'voting open'; + return year.submissionsClosed ? 'view archive' : 'submissions open'; +} + function YearBanner({yearId}: {yearId: string}) { const banner = yearBanners[yearId]; return banner ? ( diff --git a/src/app/styles.css b/src/app/styles.css index 07ea4d6..865871d 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -928,6 +928,24 @@ main { font-size: 0.65rem; white-space: nowrap; } +.projectVoteBadge { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 0.3rem 0.55rem; + color: #305500; + font-size: 0.66rem; + font-weight: 600; + line-height: 1; + white-space: nowrap; + border: 1px solid #b8db78; + border-radius: 999px; + background: #f0ffd7; +} +.projectRow .projectVoteBadge { + padding: 0.27rem 0.5rem; + font-size: 0.65rem; +} .projectRow .memberStack > span { width: 1.65rem; height: 1.65rem; @@ -1381,6 +1399,26 @@ main { padding: 0.55rem 0.75rem; font-size: 0.75rem; } +.projectVotingConfirm .projectVotingCancel { + color: var(--muted); + border-color: #bdb4c8; + background: #fff; +} +.projectVotingConfirm .projectVotingCancel:hover:not(:disabled) { + color: var(--ink); + border-color: var(--ink); + background: var(--soft); +} +.projectVotingConfirm .projectVotingMoveAction { + color: var(--ink); + border-color: var(--green); + background: var(--green); +} +.projectVotingConfirm .projectVotingMoveAction:hover:not(:disabled) { + color: #fff; + border-color: var(--dark-blurple); + background: var(--dark-blurple); +} .projectVotingFeedback { padding: 0.85rem 1rem; margin: 1rem 0 0; diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index 44d4a4d..fde6191 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -150,7 +150,7 @@ describe('voting and administration journeys', () => { await userEvent.click( within(voting).getByRole('button', { - name: /vote for a small machine in delight/i, + name: /vote for delight/i, }), ); @@ -178,30 +178,20 @@ describe('voting and administration journeys', () => { if (!(delightRow instanceof HTMLElement)) throw new Error(); expect(within(delightRow).getByText('your vote')).toBeTruthy(); - await userEvent.click( - within(voting).getByRole('button', {name: /move craft vote here/i}), - ); + await userEvent.click(within(voting).getByRole('button', {name: 'move vote here'})); expect(within(voting).getByText(/move your Craft vote from/).textContent).toContain( 'Quiet hours', ); - await userEvent.click( - within(voting).getByRole('button', {name: /cancel move for craft/i}), - ); + await userEvent.click(within(voting).getByRole('button', {name: 'cancel'})); expect( fetchMock.mock.calls.some( ([input, init]) => input === '/api/votes/vote-craft' && init?.method === 'PUT', ), ).toBe(false); - expect( - within(voting).queryByRole('button', {name: /confirm move for craft/i}), - ).toBeNull(); + expect(within(voting).queryByRole('button', {name: 'confirm move'})).toBeNull(); - await userEvent.click( - within(voting).getByRole('button', {name: /move craft vote here/i}), - ); - await userEvent.click( - within(voting).getByRole('button', {name: /confirm move for craft/i}), - ); + await userEvent.click(within(voting).getByRole('button', {name: 'move vote here'})); + await userEvent.click(within(voting).getByRole('button', {name: 'confirm move'})); await waitFor(() => { const request = fetchMock.mock.calls.find( @@ -243,7 +233,7 @@ describe('voting and administration journeys', () => { const voting = await screen.findByRole('region', {name: 'vote for this project'}); await userEvent.click( within(voting).getByRole('button', { - name: /vote for a small machine in delight/i, + name: /vote for delight/i, }), ); @@ -283,7 +273,7 @@ describe('voting and administration journeys', () => { 'This vote changed elsewhere', ); expect( - await within(voting).findByRole('button', {name: /move delight vote here/i}), + await within(voting).findByRole('button', {name: 'move vote here'}), ).toBeTruthy(); expect(screen.queryByRole('heading', {name: 'Something went wrong'})).toBeNull(); }); diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index 458cb2c..f9e511a 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -108,6 +108,30 @@ describe('clickable project routes', () => { ); }); + it('shows voting open as the current-year action while voting is enabled', async () => { + fetchMock.mockResolvedValue( + json({ + years: [ + { + id: '2026', + votingEnabled: true, + submissionsClosed: false, + projectCount: 4, + ideaCount: 2, + groupCount: 1, + participantCount: 8, + }, + ], + }), + ); + + renderRoute(, '/years'); + + const hero = await screen.findByRole('region', {name: 'Hackweek 2026'}); + expect(within(hero).getByRole('link', {name: /voting open/})).toBeTruthy(); + expect(within(hero).queryByRole('link', {name: /submissions open/})).toBeNull(); + }); + it('promotes the latest year to the hero and renders earlier years as archives', async () => { fetchMock.mockResolvedValue( json({ @@ -334,6 +358,44 @@ describe('clickable project routes', () => { expect(progress.getAttribute('value')).toBe('0'); }); + it('marks personal vote counts in both project views', async () => { + mockProjectsOverview({ + categories: [ + {id: 'delight', yearId: '2026', name: 'Delight'}, + {id: 'impact', yearId: '2026', name: 'Impact'}, + ], + votes: [ + { + id: 'vote-delight', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + projectActive: true, + categoryId: 'delight', + }, + { + id: 'vote-impact', + yearId: '2026', + projectId: 'project', + projectName: 'A small machine', + projectActive: true, + categoryId: 'impact', + }, + ], + projects: [projectFixture], + }); + + renderRoute(, '/years/2026/projects', '/years/:yearId/projects'); + + const gridBadge = await screen.findByLabelText('2 of your picks: Delight, Impact'); + expect(gridBadge.textContent).toBe('your picks · 2'); + expect(gridBadge.closest('.projectCard')).toBeTruthy(); + + await userEvent.click(screen.getByRole('button', {name: 'list view'})); + const listBadge = screen.getByLabelText('2 of your picks: Delight, Impact'); + expect(listBadge.closest('.projectRow')).toBeTruthy(); + }); + it('keeps closed-year browsing and ballot read failures local', async () => { mockProjectsOverview({votingEnabled: false, projects: [projectFixture]}); const closed = renderRoute( From 8ba103857b6b2c58eeb84e935eed0e44e2c2f766 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Tue, 18 Aug 2026 13:25:41 +0200 Subject: [PATCH 7/7] fix(voting): reconcile invalid ballot status displays Count only active-project selections toward ballot completion and call out withdrawn picks that still need replacement. Prevent stale voting controls from rendering alongside a failed ballot refresh.\n\nAdd route coverage for inactive progress and post-vote refresh failures. --- src/app/routes/ProjectDetailsPage.tsx | 24 +++++----- src/app/routes/ProjectsPage.tsx | 5 +- test/app/routes.test.tsx | 66 +++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src/app/routes/ProjectDetailsPage.tsx b/src/app/routes/ProjectDetailsPage.tsx index dd49498..49ecf4f 100644 --- a/src/app/routes/ProjectDetailsPage.tsx +++ b/src/app/routes/ProjectDetailsPage.tsx @@ -175,17 +175,19 @@ export function ProjectDetailsPage() { )} - {project.data.project.kind === 'project' && ballot.data?.year.votingEnabled && ( - - )} + {project.data.project.kind === 'project' && + !ballot.error && + ballot.data?.year.votingEnabled && ( + + )} {project.data.project.kind === 'project' && ( vote.projectActive).length; + const inactiveCount = selections.filter(({vote}) => !vote.projectActive).length; const remainingCount = Math.max(categoryCount - castCount, 0); const complete = categoryCount > 0 && remainingCount === 0; let message = 'open a project to cast your first vote.'; @@ -303,6 +304,8 @@ function BallotOverview({ message = 'award categories are still being set up. check back soon.'; } else if (complete) { message = 'ballot complete — every category has your pick.'; + } else if (inactiveCount > 0) { + message = `${inactiveCount} withdrawn ${inactiveCount === 1 ? 'pick needs' : 'picks need'} a new project — ${remainingCount} ${remainingCount === 1 ? 'vote' : 'votes'} left to cast.`; } else if (castCount > 0) { message = `keep exploring — ${remainingCount} ${remainingCount === 1 ? 'vote' : 'votes'} left to cast.`; } diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index f9e511a..a9c7b7a 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -272,15 +272,17 @@ describe('clickable project routes', () => { const ballot = await screen.findByRole('region', {name: 'your ballot'}); const counts = within(ballot).getByLabelText('Ballot counts'); - expect(counts.textContent).toContain('2 votes cast'); - expect(counts.textContent).toContain('1 vote remaining'); + expect(counts.textContent).toContain('1 vote cast'); + expect(counts.textContent).toContain('2 votes remaining'); expect( - within(ballot).getByText('keep exploring — 1 vote left to cast.'), + within(ballot).getByText( + '1 withdrawn pick needs a new project — 2 votes left to cast.', + ), ).toBeTruthy(); const progress = within(ballot).getByRole('progressbar', { name: 'ballot progress', }); - expect(progress.getAttribute('value')).toBe('2'); + expect(progress.getAttribute('value')).toBe('1'); expect(progress.getAttribute('max')).toBe('3'); expect( within(ballot) @@ -791,6 +793,62 @@ describe('clickable project routes', () => { expect(ballotReads).toBe(2); }); + it('replaces stale voting controls when a ballot refresh fails', async () => { + let ballotReads = 0; + fetchMock.mockImplementation(async (input, init) => { + const url = requestUrl(input); + if (url.includes('/api/votes?')) { + ballotReads += 1; + if (ballotReads === 1) { + return json({ + year: {id: '2026', votingEnabled: true}, + categories: [{id: 'delight', yearId: '2026', name: 'Delight'}], + votes: [], + }); + } + return json( + {error: {code: 'BALLOT_UNAVAILABLE', message: 'Ballot unavailable'}}, + 503, + ); + } + if (url === '/api/votes' && init?.method === 'POST') { + return json( + { + vote: { + id: 'vote-delight', + yearId: '2026', + projectId: 'project', + categoryId: 'delight', + }, + }, + 201, + ); + } + if (url.endsWith('/video')) return json({video: null}); + return json({ + project: { + ...projectFixture, + permissions: {...projectFixture.permissions, canVote: true}, + }, + }); + }); + + renderRoute( + , + '/years/2026/projects/project', + '/years/:yearId/projects/:projectId', + ); + + const voting = await screen.findByRole('region', {name: 'vote for this project'}); + await userEvent.click(within(voting).getByRole('button', {name: 'vote for Delight'})); + + expect( + await screen.findByRole('region', {name: 'voting status unavailable'}), + ).toBeTruthy(); + expect(screen.queryByRole('region', {name: 'vote for this project'})).toBeNull(); + expect(ballotReads).toBe(2); + }); + it('explains unavailable own-project voting and hides controls when closed', async () => { mockProjectDetails({ detail: projectFixture,