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.
- ) : (
-
-
-
- | Category |
- Project |
- Group |
- Votes |
-
-
-
- {query.data.voteResults.map((row) => (
-
- | {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 (
+
+
+ {!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 (
+
+
+
+ {String(index + 1).padStart(2, '0')}
+
+
+
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) => (
+ -
+
+ {String(result.rank).padStart(2, '0')}
+
+
+
+
+ {result.projectName}
+
+
+
+ {result.tied && (
+
+ {tieLabel(result.rank)}
+
+ )}
+
+
+
+
+ ))}
+
+ ) : (
+ 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 (
+
+ {members.map((member) => (
+ -
+
+
+ {member.displayName}
+
+
+ ))}
+
+ );
+}
+
+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);
});
});