From cb8fefcee8c3f3c3803b530de9043309ba103154 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 24 Aug 2026 21:05:14 +0200 Subject: [PATCH 1/2] feat(admin): present vote results as award standings Replace the flat analytics table with category-focused standings that emphasize the vote leader and two runners-up while preserving ties and category totals. Link ranked projects and their members to existing detail pages, show each project's group, and extend the aggregate analytics response with the public member identity fields needed by the UI. Keep avatars compact so team context remains scannable. --- src/app/routes/AdminAnalyticsPage.tsx | 199 ++++++++++-- src/app/styles.css | 379 +++++++++++++++++++++- src/shared/administration.ts | 7 + src/worker/repositories/administration.ts | 36 +- test/admin/admin.test.ts | 16 +- test/app/administration.test.tsx | 48 ++- 6 files changed, 638 insertions(+), 47 deletions(-) diff --git a/src/app/routes/AdminAnalyticsPage.tsx b/src/app/routes/AdminAnalyticsPage.tsx index 145e724..b5df2f6 100644 --- a/src/app/routes/AdminAnalyticsPage.tsx +++ b/src/app/routes/AdminAnalyticsPage.tsx @@ -1,6 +1,8 @@ import {Link, useSearch} from 'wouter'; +import type {VoteResult} from '../../shared/administration'; import {QueryState} from '../components/AppLayout'; +import {UserAvatar} from '../components/UserAvatar'; import {useAnalytics} from '../queries/administration'; export function AdminAnalyticsPage() { @@ -50,34 +52,7 @@ export function AdminAnalyticsPage() { ))} {yearId && ( -
-

{yearId} vote distribution

-

Category results

- {!query.data.voteResults.length ? ( -

No votes have been recorded.

- ) : ( - - - - - - - - - - - {query.data.voteResults.map((row) => ( - - - - - - - ))} - -
CategoryProjectGroupVotes
{row.categoryName}{row.projectName}{row.groupName ?? 'โ€”'}{row.voteCount}
- )} -
+ )} )} @@ -85,3 +60,171 @@ export function AdminAnalyticsPage() { ); } + +function AwardStandings({yearId, results}: {yearId: string; results: VoteResult[]}) { + const categories = groupByCategory(results); + + return ( +
+
+
+

{yearId} vote distribution

+

Award standings

+
+

The leading project and two runners-up in every category.

+
+ {!categories.length ? ( +

No votes have been recorded.

+ ) : ( +
+ {categories.map( + ({categoryId, categoryName, results: categoryResults}, index) => { + const [leader, ...runnerUps] = categoryResults.slice(0, 3); + const totalVotes = categoryResults.reduce( + (total, result) => total + result.voteCount, + 0, + ); + const tiedForLead = categoryResults[1]?.voteCount === leader.voteCount; + + return ( +
+
+ +
+

Award category

+

{categoryName}

+
+

+ {totalVotes} {voteLabel(totalVotes)} + + {categoryResults.length}{' '} + {categoryResults.length === 1 ? 'project' : 'projects'} +

+
+
+
+
+ + {tiedForLead ? 'Tied for lead' : 'Winner by votes'} +
+
+ +

+ + {leader.projectName} + +

+ +
+ +
+
+

Runners-up

+ {runnerUps.length ? ( +
    + {runnerUps.map((result, runnerIndex) => ( +
  1. + + {String(runnerIndex + 2).padStart(2, '0')} + +
    + + + {result.projectName} + + + + {result.voteCount === leader.voteCount && ( + Tied for lead + )} + +
    + +
  2. + ))} +
+ ) : ( +

No runner-ups yet.

+ )} +
+
+
+ ); + }, + )} +
+ )} +
+ ); +} + +function ProjectOrigin({groupName}: {groupName: string | null}) { + return ( +

+ From {groupName ?? 'an independent team'} +

+ ); +} + +function ProjectTeam({members}: {members: VoteResult['members']}) { + if (!members.length) return

Team not listed

; + + return ( + + ); +} + +function VoteCount({count}: {count: number}) { + return ( +

+ {count} + {voteLabel(count)} +

+ ); +} + +function groupByCategory(results: VoteResult[]) { + const categories = new Map< + string, + {categoryId: string; categoryName: string; results: VoteResult[]} + >(); + + for (const result of results) { + const category = categories.get(result.categoryId) ?? { + categoryId: result.categoryId, + categoryName: result.categoryName, + results: [], + }; + category.results.push(result); + categories.set(result.categoryId, category); + } + + return [...categories.values()].map((category) => ({ + ...category, + results: [...category.results].sort( + (left, right) => + right.voteCount - left.voteCount || + left.projectName.localeCompare(right.projectName), + ), + })); +} + +function voteLabel(count: number) { + return count === 1 ? 'vote' : 'votes'; +} diff --git a/src/app/styles.css b/src/app/styles.css index c6c67dc..eb3bf70 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1895,7 +1895,7 @@ main { .teamPanel h2, .mediaSection h2, .controlPanel h2, -.resultsTable h2 { +.awardStandings h2 { margin: 0 0 1.25rem; font-size: clamp(1.6rem, 3vw, 2.4rem); line-height: 1.15; @@ -2895,23 +2895,354 @@ main { margin: 0; font-weight: 600; } -.resultsTable { - margin-top: 4rem; - overflow-x: auto; +.awardStandings { + margin-top: clamp(4rem, 8vw, 6rem); } -.resultsTable table { - width: 100%; - border-collapse: collapse; +.awardStandingsHeader { + display: flex; + gap: 2rem; + align-items: end; + justify-content: space-between; + margin-bottom: 1.5rem; } -.resultsTable th, -.resultsTable td { - padding: 0.85rem; - text-align: left; +.awardStandingsHeader h2 { + margin-bottom: 0; +} +.awardStandingsHeader > p { + max-width: 23rem; + margin: 0 0 0.2rem; + color: var(--muted); + font-size: 0.88rem; + line-height: 1.55; + text-align: right; +} +.awardStandingsEmpty { + padding: 2rem; + color: var(--muted); + text-align: center; + border: 1px dashed #c9c3d1; + border-radius: 0.85rem; + background: var(--soft); +} +.awardSections { + display: grid; + gap: 1.5rem; +} +.awardSection { + --award-accent: var(--green); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 1rem; + background: #fff; + box-shadow: 0 12px 35px rgba(29, 17, 39, 0.08); + animation: rise 0.35s ease-out both; +} +.awardSection:nth-child(3n + 2) { + --award-accent: var(--pink); +} +.awardSection:nth-child(3n) { + --award-accent: var(--yellow); +} +.awardSectionHeader { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 1rem; + align-items: center; + padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--line); + background: var(--soft); } -.resultsTable th { +.awardNumber { + display: grid; + width: 2.8rem; + height: 2.8rem; + place-items: center; color: var(--dark-blurple); - font-size: 0.78rem; + font-size: 0.75rem; + font-weight: 700; + border: 1px solid #d2bfff; + border-radius: 50%; + background: #fff; +} +.awardSectionHeader p { + margin: 0; +} +.awardSectionHeader > div:nth-child(2) > p { + margin-bottom: 0.25rem; + color: var(--blurple); + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.awardSectionHeader h3 { + margin: 0; + font-size: clamp(1.25rem, 3vw, 1.75rem); + line-height: 1.1; + letter-spacing: -0.035em; +} +.awardTally { + display: flex; + gap: 0.3rem; + align-items: baseline; + color: var(--muted); + font-size: 0.72rem; + white-space: nowrap; +} +.awardTally strong { + color: var(--ink); + font-size: 0.82rem; +} +.awardTally span { + margin-inline: 0.2rem; + color: #b0a7ba; +} +.awardPodium { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr); + min-height: 14rem; +} +.awardWinner { + display: grid; + position: relative; + gap: 1.5rem; + min-width: 0; + padding: clamp(1.5rem, 4vw, 2.25rem); + overflow: hidden; + color: #fff; + background: var(--dark-blurple); +} +.awardWinner::after { + position: absolute; + right: -3rem; + bottom: -4.5rem; + width: 10rem; + height: 10rem; + content: ''; + border: 1.5rem solid rgba(255, 255, 255, 0.07); + border-radius: 50%; + pointer-events: none; +} +.awardPlacement { + display: flex; + gap: 0.45rem; + align-items: center; + width: fit-content; + color: var(--award-accent); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.awardPlacement span { + font-size: 0.85rem; +} +.awardProjectCopy { + position: relative; + z-index: 1; + align-self: end; + padding-right: 4.5rem; +} +.awardOrigin { + margin: 0; + color: var(--muted); + font-size: 0.7rem; + line-height: 1.3; +} +.awardOrigin span { + color: var(--blurple); + font-weight: 600; +} +.awardProjectCopy .awardOrigin { + margin-bottom: 0.45rem; + color: rgba(255, 255, 255, 0.68); + font-size: 0.75rem; +} +.awardProjectCopy .awardOrigin span { + color: var(--award-accent); +} +.awardProjectCopy h4 { + max-width: 22ch; + margin: 0; + overflow-wrap: anywhere; + font-size: clamp(1.5rem, 4vw, 2.25rem); + line-height: 1.05; + letter-spacing: -0.045em; +} +.awardProjectCopy h4 a, +.awardRunnerUps li strong a { + color: inherit; + text-decoration-thickness: 0.06em; + transition: color 100ms ease; +} +.awardProjectCopy h4 a:hover { + color: var(--award-accent); +} +.awardRunnerUps li strong a:hover { + color: var(--blurple); +} +.awardTeam { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.65rem; + padding: 0; + margin: 0.85rem 0 0; + list-style: none; +} +.awardTeam li, +.awardRunnerUps .awardTeam li { + display: block; + min-height: 0; + padding: 0; + border: 0; +} +.awardTeam a { + display: inline-flex; + gap: 0.35rem; + align-items: center; + color: inherit; + font-size: 0.7rem; + line-height: 1.2; + text-decoration: none; +} +.awardTeam a:hover span:last-child { + text-decoration: underline; + text-underline-offset: 0.18em; +} +.awardTeam .userAvatar { + width: 1.25rem; + height: 1.25rem; + flex: 0 0 1.25rem; + color: var(--dark-blurple); + font-size: 0.42rem; + font-weight: 700; + border: 1px solid rgba(255, 255, 255, 0.5); + border-radius: 50%; + background: var(--pink); +} +.awardRunnerUps .awardTeam { + gap: 0.25rem 0.55rem; + margin-top: 0.4rem; +} +.awardRunnerUps .awardTeam a { + color: var(--muted); + font-size: 0.65rem; +} +.awardRunnerUps .awardTeam .userAvatar { + width: 1rem; + height: 1rem; + flex-basis: 1rem; + font-size: 0.36rem; + border-color: var(--line); + background: var(--lavender); +} +.awardTeamEmpty { + margin: 0.75rem 0 0; + color: rgba(255, 255, 255, 0.52); + font-size: 0.68rem; +} +.awardRunnerUps .awardTeamEmpty { + margin-top: 0.35rem; + color: var(--muted); +} +.awardVoteCount { + display: flex; + gap: 0.35rem; + align-items: baseline; + width: fit-content; + margin: 0; +} +.awardVoteCount strong { + font-size: 1.6rem; + line-height: 1; +} +.awardVoteCount span { + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.04em; + opacity: 0.64; + text-transform: uppercase; +} +.awardWinner > .awardVoteCount { + position: absolute; + z-index: 1; + right: clamp(1.5rem, 4vw, 2.25rem); + bottom: clamp(1.5rem, 4vw, 2.25rem); + flex-direction: column; + gap: 0.15rem; + align-items: flex-end; +} +.awardWinner > .awardVoteCount strong { + color: var(--award-accent); + font-size: 3rem; + letter-spacing: -0.06em; +} +.awardRunnerUps { + min-width: 0; + padding: clamp(1.5rem, 4vw, 2.25rem); + background: #fff; +} +.awardRunnerUps > h4 { + margin: 0 0 0.65rem; + color: var(--muted); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.awardRunnerUps ol { + padding: 0; + margin: 0; + list-style: none; +} +.awardRunnerUps > ol > li { + display: grid; + grid-template-columns: 2rem minmax(0, 1fr) auto; + gap: 0.8rem; + align-items: center; + min-height: 4.7rem; + padding: 0.75rem 0; + border-top: 1px solid var(--line); +} +.awardRunnerRank { + color: var(--blurple); + font-size: 0.74rem; + font-weight: 700; +} +.awardRunnerUps > ol > li > div { + display: grid; + gap: 0.28rem; + min-width: 0; +} +.awardRunnerUps > ol > li > div > strong { + overflow-wrap: anywhere; + font-size: 0.9rem; + line-height: 1.3; +} +.awardTie { + width: fit-content; + padding: 0.17rem 0.35rem; + color: var(--dark-blurple); + font-size: 0.58rem; + font-weight: 700; + line-height: 1; + border-radius: 999px; + background: var(--lavender); + text-transform: uppercase; +} +.awardRunnerUps > ol > li > .awardVoteCount { + min-width: 3rem; + justify-content: flex-end; +} +.awardRunnerUps > ol > li > .awardVoteCount strong { + font-size: 1.3rem; +} +.awardRunnerUpsEmpty { + padding: 1.25rem 0; + margin: 0; + color: var(--muted); + font-size: 0.82rem; + border-top: 1px solid var(--line); } .yearCreateForm { display: flex; @@ -3790,6 +4121,28 @@ kbd { .controlPanel--wide { grid-column: auto; } + .awardStandingsHeader { + gap: 0.75rem; + align-items: flex-start; + flex-direction: column; + } + .awardStandingsHeader > p { + text-align: left; + } + .awardSectionHeader { + grid-template-columns: auto minmax(0, 1fr); + } + .awardTally { + grid-column: 2; + flex-wrap: wrap; + white-space: normal; + } + .awardPodium { + grid-template-columns: 1fr; + } + .awardWinner { + min-height: 14rem; + } .detailHero, .ballotOverview, .projectControls, diff --git a/src/shared/administration.ts b/src/shared/administration.ts index 0800914..46f1bf8 100644 --- a/src/shared/administration.ts +++ b/src/shared/administration.ts @@ -93,12 +93,19 @@ export interface AnalyticsYear { awardCount: number; } +export interface VoteResultMember { + id: string; + displayName: string; + avatarUrl: string | null; +} + export interface VoteResult { categoryId: string; categoryName: string; projectId: string; projectName: string; groupName: string | null; + members: VoteResultMember[]; voteCount: number; } diff --git a/src/worker/repositories/administration.ts b/src/worker/repositories/administration.ts index 2481199..9839391 100644 --- a/src/worker/repositories/administration.ts +++ b/src/worker/repositories/administration.ts @@ -383,7 +383,7 @@ export async function getAnalytics( selectedYear?: string, ): Promise { if (selectedYear) await getYear(db, selectedYear); - const [yearsResult, votesResult] = await Promise.all([ + const [yearsResult, votesResult, membersResult] = await Promise.all([ db .prepare( `SELECT y.id year_id, @@ -430,7 +430,40 @@ export async function getAnalytics( vote_count: number; }>() : Promise.resolve({results: []}), + selectedYear + ? db + .prepare( + `SELECT pm.project_id, u.id, u.display_name, u.avatar_url + FROM project_members pm JOIN users u ON u.id = pm.user_id + WHERE EXISTS ( + SELECT 1 FROM votes v + WHERE v.project_id = pm.project_id AND v.year_id = ? + ) + ORDER BY pm.project_id, u.display_name COLLATE NOCASE, u.id`, + ) + .bind(selectedYear) + .all<{ + project_id: string; + id: string; + display_name: string; + avatar_url: string | null; + }>() + : Promise.resolve({results: []}), ]); + const membersByProject = new Map< + string, + {id: string; displayName: string; avatarUrl: string | null}[] + >(); + for (const row of membersResult.results) { + const members = membersByProject.get(row.project_id) ?? []; + members.push({ + id: row.id, + displayName: row.display_name, + avatarUrl: row.avatar_url, + }); + membersByProject.set(row.project_id, members); + } + return { years: yearsResult.results.map((row) => ({ yearId: row.year_id, @@ -447,6 +480,7 @@ export async function getAnalytics( projectId: row.project_id, projectName: row.project_name, groupName: row.group_name, + members: membersByProject.get(row.project_id) ?? [], voteCount: row.vote_count, })), }; diff --git a/test/admin/admin.test.ts b/test/admin/admin.test.ts index 60c8360..e8d02aa 100644 --- a/test/admin/admin.test.ts +++ b/test/admin/admin.test.ts @@ -205,9 +205,12 @@ describe('year and award administration', () => { it('serves aggregate year and vote analytics rather than raw records', async () => { const category = await createCategory('Data'); - await env.DB.prepare('UPDATE years SET voting_enabled = 1 WHERE id = ?') - .bind(yearId) - .run(); + await env.DB.batch([ + env.DB.prepare('UPDATE years SET voting_enabled = 1 WHERE id = ?').bind(yearId), + env.DB.prepare( + 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?)', + ).bind(projectId, adminId), + ]); const vote = await api('/votes', memberToken, { method: 'POST', body: {yearId, projectId, categoryId: category.id}, @@ -224,7 +227,12 @@ describe('year and award administration', () => { }), ); expect(analytics.body.voteResults).toEqual([ - expect.objectContaining({projectId, categoryId: category.id, voteCount: 1}), + expect.objectContaining({ + projectId, + categoryId: category.id, + voteCount: 1, + members: [expect.objectContaining({id: adminId, displayName: 'admin'})], + }), ]); expect(JSON.stringify(analytics.body)).not.toContain('creatorId'); }); diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index d52ef40..4565b0d 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -385,8 +385,42 @@ describe('voting and administration journeys', () => { projectId: 'project', projectName: 'First project', groupName: 'Orbital', + members: [ + { + id: 'alice', + displayName: 'Alice Example', + avatarUrl: 'https://example.com/alice.jpg', + }, + ], voteCount: 8, }, + { + categoryId: 'cat', + categoryName: 'Delight', + projectId: 'second-project', + projectName: 'Second project', + groupName: 'Orbital', + members: [{id: 'bob', displayName: 'Bob Example', avatarUrl: null}], + voteCount: 8, + }, + { + categoryId: 'cat', + categoryName: 'Delight', + projectId: 'third-project', + projectName: 'Third project', + groupName: null, + members: [], + voteCount: 8, + }, + { + categoryId: 'cat', + categoryName: 'Delight', + projectId: 'fourth-project', + projectName: 'Fourth project', + groupName: null, + members: [], + voteCount: 2, + }, ], }), ); @@ -394,7 +428,19 @@ describe('voting and administration journeys', () => { expect(await screen.findByText('Active voters')).toBeTruthy(); expect(screen.getByText('14')).toBeTruthy(); - expect(screen.getByRole('cell', {name: 'First project'})).toBeTruthy(); + expect(screen.getByRole('heading', {name: 'Award standings'})).toBeTruthy(); + expect(screen.getByRole('heading', {name: 'Delight'})).toBeTruthy(); + expect(screen.getByRole('heading', {name: 'First project'})).toBeTruthy(); + expect(screen.getByRole('link', {name: 'First project'}).getAttribute('href')).toBe( + '/years/2026/projects/project', + ); + expect(screen.getAllByText(/Orbital/)).toHaveLength(2); + expect(screen.getByText('Alice Example')).toBeTruthy(); + expect(screen.getByText('Second project')).toBeTruthy(); + expect(screen.getByText('Bob Example')).toBeTruthy(); + expect(screen.getByText('Third project')).toBeTruthy(); + expect(screen.getAllByText('Tied for lead')).toHaveLength(3); + expect(screen.queryByText('Fourth project')).toBeNull(); expect(fetchMock).toHaveBeenCalledWith('/api/admin/analytics?year=2026', undefined); }); }); From ca28ff131fc5be9b1e166538e8e7cc41dba8d73b Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 24 Aug 2026 21:56:00 +0200 Subject: [PATCH 2/2] fix(admin): preserve tied award ranks Derive displayed standings from vote-count groups instead of list positions so tied leaders and runners-up share the same competition rank. Label every tied placement and cover both lead and second-place ties in the admin analytics journey. --- src/app/routes/AdminAnalyticsPage.tsx | 43 ++++++++++++++++++++++----- test/app/administration.test.tsx | 35 ++++++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/app/routes/AdminAnalyticsPage.tsx b/src/app/routes/AdminAnalyticsPage.tsx index b5df2f6..78ed054 100644 --- a/src/app/routes/AdminAnalyticsPage.tsx +++ b/src/app/routes/AdminAnalyticsPage.tsx @@ -79,12 +79,11 @@ function AwardStandings({yearId, results}: {yearId: string; results: VoteResult[
{categories.map( ({categoryId, categoryName, results: categoryResults}, index) => { - const [leader, ...runnerUps] = categoryResults.slice(0, 3); + const [leader, ...runnerUps] = rankResults(categoryResults).slice(0, 3); const totalVotes = categoryResults.reduce( (total, result) => total + result.voteCount, 0, ); - const tiedForLead = categoryResults[1]?.voteCount === leader.voteCount; return (
@@ -107,7 +106,7 @@ function AwardStandings({yearId, results}: {yearId: string; results: VoteResult[
- {tiedForLead ? 'Tied for lead' : 'Winner by votes'} + {leader.tied ? 'Tied for lead' : 'Winner by votes'}
@@ -124,10 +123,10 @@ function AwardStandings({yearId, results}: {yearId: string; results: VoteResult[

Runners-up

{runnerUps.length ? (
    - {runnerUps.map((result, runnerIndex) => ( + {runnerUps.map((result) => (
  1. - {String(runnerIndex + 2).padStart(2, '0')} + {String(result.rank).padStart(2, '0')}
    @@ -138,8 +137,10 @@ function AwardStandings({yearId, results}: {yearId: string; results: VoteResult[ - {result.voteCount === leader.voteCount && ( - Tied for lead + {result.tied && ( + + {tieLabel(result.rank)} + )}
    @@ -225,6 +226,34 @@ function groupByCategory(results: VoteResult[]) { })); } +function rankResults(results: VoteResult[]) { + let rank = 0; + + return results.map((result, index) => { + if (index === 0 || result.voteCount !== results[index - 1].voteCount) { + rank = index + 1; + } + const tied = + results[index - 1]?.voteCount === result.voteCount || + results[index + 1]?.voteCount === result.voteCount; + return {...result, rank, tied}; + }); +} + +function tieLabel(rank: number) { + if (rank === 1) return 'Tied for lead'; + return `Tied for ${ordinal(rank)}`; +} + +function ordinal(value: number) { + const remainder = value % 100; + if (remainder >= 11 && remainder <= 13) return `${value}th`; + if (value % 10 === 1) return `${value}st`; + if (value % 10 === 2) return `${value}nd`; + if (value % 10 === 3) return `${value}rd`; + return `${value}th`; +} + function voteLabel(count: number) { return count === 1 ? 'vote' : 'votes'; } diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index 4565b0d..75dc188 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -421,6 +421,33 @@ describe('voting and administration journeys', () => { members: [], voteCount: 2, }, + { + categoryId: 'craft', + categoryName: 'Craft', + projectId: 'craft-leader', + projectName: 'Craft leader', + groupName: 'Europe', + members: [], + voteCount: 9, + }, + { + categoryId: 'craft', + categoryName: 'Craft', + projectId: 'craft-second', + projectName: 'Craft second', + groupName: 'Europe', + members: [], + voteCount: 7, + }, + { + categoryId: 'craft', + categoryName: 'Craft', + projectId: 'craft-third', + projectName: 'Craft third', + groupName: 'Europe', + members: [], + voteCount: 7, + }, ], }), ); @@ -441,6 +468,14 @@ describe('voting and administration journeys', () => { expect(screen.getByText('Third project')).toBeTruthy(); expect(screen.getAllByText('Tied for lead')).toHaveLength(3); expect(screen.queryByText('Fourth project')).toBeNull(); + expect(screen.getAllByText('Tied for 2nd')).toHaveLength(2); + + const runnerSections = screen + .getAllByRole('heading', {name: 'Runners-up'}) + .map((heading) => heading.closest('section')); + if (!runnerSections[0] || !runnerSections[1]) throw new Error(); + expect(within(runnerSections[0]).getAllByText('01')).toHaveLength(2); + expect(within(runnerSections[1]).getAllByText('02')).toHaveLength(2); expect(fetchMock).toHaveBeenCalledWith('/api/admin/analytics?year=2026', undefined); }); });