diff --git a/src/app/routes/AdminAnalyticsPage.tsx b/src/app/routes/AdminAnalyticsPage.tsx index 145e724..78ed054 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,200 @@ 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] = rankResults(categoryResults).slice(0, 3); + const totalVotes = categoryResults.reduce( + (total, result) => total + result.voteCount, + 0, + ); + + return ( +
+
+ +
+

Award category

+

{categoryName}

+
+

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

+
+
+
+
+ + {leader.tied ? 'Tied for lead' : 'Winner by votes'} +
+
+ +

+ + {leader.projectName} + +

+ +
+ +
+
+

Runners-up

+ {runnerUps.length ? ( +
    + {runnerUps.map((result) => ( +
  1. + + {String(result.rank).padStart(2, '0')} + +
    + + + {result.projectName} + + + + {result.tied && ( + + {tieLabel(result.rank)} + + )} + +
    + +
  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 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/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..75dc188 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -385,8 +385,69 @@ 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, + }, + { + 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, + }, ], }), ); @@ -394,7 +455,27 @@ 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(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); }); });