diff --git a/src/app/routes/AdminAnalyticsPage.tsx b/src/app/routes/AdminAnalyticsPage.tsx index 9baeb62..9ce24b5 100644 --- a/src/app/routes/AdminAnalyticsPage.tsx +++ b/src/app/routes/AdminAnalyticsPage.tsx @@ -2,7 +2,10 @@ import {useState} from 'react'; import {Link, useSearch} from 'wouter'; import type {VoteResult} from '../../shared/administration'; -import {analyticsVideoExportFilename} from '../../shared/analytics-export'; +import { + analyticsProjectExportFilename, + analyticsYearExportFilename, +} from '../../shared/analytics-export'; import {QueryState} from '../components/AppLayout'; import {UserAvatar} from '../components/UserAvatar'; import {ApiError, apiResponseError} from '../queries/api'; @@ -13,21 +16,27 @@ export function AdminAnalyticsPage() { const query = useAnalytics(yearId); const [exportError, setExportError] = useState(null); const [exporting, setExporting] = useState(false); + const exportScope = yearId ? 'projects' : 'years'; - async function downloadReadyVideoCsv() { - if (!yearId || exporting) return; + async function downloadCsv() { + if (exporting) return; setExporting(true); setExportError(null); try { - const response = await fetch( - `/api/admin/analytics/export?year=${encodeURIComponent(yearId)}`, - ); + const path = + exportScope === 'years' + ? '/api/admin/analytics/export' + : `/api/admin/analytics/export?year=${encodeURIComponent(yearId!)}`; + const response = await fetch(path); if (!response.ok) throw await apiResponseError(response); const blob = await response.blob(); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; - anchor.download = analyticsVideoExportFilename(yearId); + anchor.download = + exportScope === 'years' + ? analyticsYearExportFilename() + : analyticsProjectExportFilename(yearId!); document.body.append(anchor); anchor.click(); anchor.remove(); @@ -36,7 +45,7 @@ export function AdminAnalyticsPage() { setExportError( error instanceof ApiError ? error.message - : 'Ready-video CSV could not be downloaded', + : 'Analytics CSV could not be downloaded', ); } finally { setExporting(false); @@ -55,21 +64,23 @@ export function AdminAnalyticsPage() {

- D1 computes these totals server-side. No raw historical database is sent to - this page. + D1 computes these totals server-side. Export year metrics for retros, or open + a year for project rows with optional ready-video fields.

- {yearId && ( - - )} + {exportError &&

{exportError}

}
@@ -93,6 +104,10 @@ export function AdminAnalyticsPage() {
Projects
{year.projectCount}
+
+
Ideas
+
{year.ideaCount}
+
Awards
{year.awardCount}
diff --git a/src/shared/administration.ts b/src/shared/administration.ts index 98284f3..89c4e89 100644 --- a/src/shared/administration.ts +++ b/src/shared/administration.ts @@ -114,19 +114,35 @@ export interface AnalyticsResponse { voteResults: VoteResult[]; } -/** One ready-video project row for the admin analytics CSV export. */ -export interface AnalyticsVideoExportRow { +/** One year row for the multi-year participation CSV export. */ +export interface AnalyticsYearExportRow { + yearId: string; + activeVoters: number; + voteCount: number; + projectCount: number; + ideaCount: number; + participantCount: number; + readyVideoCount: number; + categoryCount: number; + awardCount: number; +} + +/** One project/idea row for a year-scoped analytics CSV export. */ +export interface AnalyticsProjectExportRow { voteRank: number; totalVotes: number; projectId: string; projectName: string; projectUrl: string; - videoId: string; - videoUrl: string; - originalName: string; - durationSeconds: number | null; + kind: 'project' | 'idea'; + groupName: string; description: string; teamMembers: string; awards: string; categoryVotes: string; + hasReadyVideo: boolean; + videoId: string; + videoUrl: string; + originalName: string; + durationSeconds: number | null; } diff --git a/src/shared/analytics-export.ts b/src/shared/analytics-export.ts index 73aae27..88f69ef 100644 --- a/src/shared/analytics-export.ts +++ b/src/shared/analytics-export.ts @@ -1,31 +1,59 @@ -import type {AnalyticsVideoExportRow} from './administration'; +import type {AnalyticsProjectExportRow, AnalyticsYearExportRow} from './administration'; -export const ANALYTICS_VIDEO_EXPORT_HEADERS = [ +export const ANALYTICS_YEAR_EXPORT_HEADERS = [ + 'year', + 'active_voters', + 'votes', + 'projects', + 'ideas', + 'participants', + 'ready_videos', + 'award_categories', + 'awards', +] as const; + +export const ANALYTICS_PROJECT_EXPORT_HEADERS = [ 'vote_rank', 'total_votes', 'project_name', 'project_url', - 'video_url', - 'video_id', - 'original_name', - 'duration_seconds', + 'kind', + 'group_name', 'description', 'team_members', 'awards', 'category_votes', + 'has_ready_video', + 'video_id', + 'video_url', + 'original_name', + 'duration_seconds', ] as const; -export interface AnalyticsVideoExportSource { +export interface AnalyticsYearExportSource { + yearId: string; + activeVoters: number; + voteCount: number; + projectCount: number; + ideaCount: number; + participantCount: number; + readyVideoCount: number; + categoryCount: number; + awardCount: number; +} + +export interface AnalyticsProjectExportSource { projectId: string; projectName: string; + kind: 'project' | 'idea'; + groupName: string | null; summary: string | null; - videoId: string; - originalName: string; - durationSeconds: number | null; teamMembers: string[]; awards: string[]; - /** category display name → vote count */ categoryVotes: Array<{categoryName: string; voteCount: number}>; + videoId: string | null; + originalName: string | null; + durationSeconds: number | null; } /** Competition rank: ties share a rank, next rank skips (1, 2, 2, 4). */ @@ -41,10 +69,28 @@ export function assignVoteRanks( }); } -export function buildAnalyticsVideoExportRows( +export function buildAnalyticsYearExportRows( + sources: AnalyticsYearExportSource[], +): AnalyticsYearExportRow[] { + return [...sources] + .sort((left, right) => left.yearId.localeCompare(right.yearId)) + .map((source) => ({ + yearId: source.yearId, + activeVoters: source.activeVoters, + voteCount: source.voteCount, + projectCount: source.projectCount, + ideaCount: source.ideaCount, + participantCount: source.participantCount, + readyVideoCount: source.readyVideoCount, + categoryCount: source.categoryCount, + awardCount: source.awardCount, + })); +} + +export function buildAnalyticsProjectExportRows( yearId: string, - sources: AnalyticsVideoExportSource[], -): AnalyticsVideoExportRow[] { + sources: AnalyticsProjectExportSource[], +): AnalyticsProjectExportRow[] { const sorted = [...sources] .map((source) => ({ ...source, @@ -57,47 +103,80 @@ export function buildAnalyticsVideoExportRows( left.projectId.localeCompare(right.projectId), ); - return assignVoteRanks(sorted).map((source) => ({ - voteRank: source.voteRank, - totalVotes: source.totalVotes, - projectId: source.projectId, - projectName: source.projectName, - projectUrl: `/years/${yearId}/projects/${source.projectId}`, - videoId: source.videoId, - videoUrl: `/years/${yearId}/watch/${source.videoId}`, - originalName: source.originalName, - durationSeconds: source.durationSeconds, - description: source.summary?.trim() || '', - teamMembers: source.teamMembers.join('; '), - awards: source.awards.join('; '), - categoryVotes: [...source.categoryVotes] - .sort( - (left, right) => - right.voteCount - left.voteCount || - left.categoryName.localeCompare(right.categoryName), - ) - .map((item) => `${item.categoryName}:${item.voteCount}`) - .join('; '), - })); + return assignVoteRanks(sorted).map((source) => { + const hasReadyVideo = Boolean(source.videoId); + return { + voteRank: source.voteRank, + totalVotes: source.totalVotes, + projectId: source.projectId, + projectName: source.projectName, + projectUrl: `/years/${yearId}/projects/${source.projectId}`, + kind: source.kind, + groupName: source.groupName ?? '', + description: source.summary?.trim() || '', + teamMembers: source.teamMembers.join('; '), + awards: source.awards.join('; '), + categoryVotes: [...source.categoryVotes] + .sort( + (left, right) => + right.voteCount - left.voteCount || + left.categoryName.localeCompare(right.categoryName), + ) + .map((item) => `${item.categoryName}:${item.voteCount}`) + .join('; '), + hasReadyVideo, + videoId: source.videoId ?? '', + videoUrl: source.videoId ? `/years/${yearId}/watch/${source.videoId}` : '', + originalName: source.originalName ?? '', + durationSeconds: source.durationSeconds, + }; + }); +} + +export function formatAnalyticsYearExportCsv(rows: AnalyticsYearExportRow[]): string { + const lines = [ + ANALYTICS_YEAR_EXPORT_HEADERS.join(','), + ...rows.map((row) => + [ + row.yearId, + row.activeVoters, + row.voteCount, + row.projectCount, + row.ideaCount, + row.participantCount, + row.readyVideoCount, + row.categoryCount, + row.awardCount, + ] + .map(escapeCsvField) + .join(','), + ), + ]; + return `${lines.join('\r\n')}\r\n`; } -export function formatAnalyticsVideoExportCsv(rows: AnalyticsVideoExportRow[]): string { +export function formatAnalyticsProjectExportCsv( + rows: AnalyticsProjectExportRow[], +): string { const lines = [ - ANALYTICS_VIDEO_EXPORT_HEADERS.join(','), + ANALYTICS_PROJECT_EXPORT_HEADERS.join(','), ...rows.map((row) => [ row.voteRank, row.totalVotes, row.projectName, row.projectUrl, - row.videoUrl, - row.videoId, - row.originalName, - row.durationSeconds ?? '', + row.kind, + row.groupName, row.description, row.teamMembers, row.awards, row.categoryVotes, + row.hasReadyVideo ? 'yes' : 'no', + row.videoId, + row.videoUrl, + row.originalName, + row.durationSeconds ?? '', ] .map(escapeCsvField) .join(','), @@ -106,8 +185,12 @@ export function formatAnalyticsVideoExportCsv(rows: AnalyticsVideoExportRow[]): return `${lines.join('\r\n')}\r\n`; } -export function analyticsVideoExportFilename(yearId: string) { - return `hackweek-${yearId}-ready-videos.csv`; +export function analyticsYearExportFilename() { + return 'hackweek-year-metrics.csv'; +} + +export function analyticsProjectExportFilename(yearId: string) { + return `hackweek-${yearId}-projects.csv`; } function escapeCsvField(value: string | number): string { diff --git a/src/worker/repositories/administration.ts b/src/worker/repositories/administration.ts index 745d6a6..28d20f9 100644 --- a/src/worker/repositories/administration.ts +++ b/src/worker/repositories/administration.ts @@ -1,7 +1,8 @@ import type { AdminYearResponse, + AnalyticsProjectExportRow, AnalyticsResponse, - AnalyticsVideoExportRow, + AnalyticsYearExportRow, AwardCategorySummary, AwardSummary, AwardWriteRequest, @@ -11,8 +12,10 @@ import type { VoteSummary, } from '../../shared/administration'; import { - buildAnalyticsVideoExportRows, - type AnalyticsVideoExportSource, + buildAnalyticsProjectExportRows, + buildAnalyticsYearExportRows, + type AnalyticsProjectExportSource, + type AnalyticsYearExportSource, } from '../../shared/analytics-export'; import {ServiceError} from '../services/errors'; import {getYear} from './projects'; @@ -491,31 +494,98 @@ export async function getAnalytics( }; } -/** Ready-video projects for offline curation: ceremony, supercut, retrospectives. */ -export async function getAnalyticsVideoExport( +/** Multi-year participation metrics for retrospective comparisons. */ +export async function getAnalyticsYearExport( + db: D1Database, +): Promise { + const {results} = await db + .prepare( + `SELECT y.id year_id, + (SELECT COUNT(DISTINCT v.creator_id) FROM votes v WHERE v.year_id = y.id) + active_voters, + (SELECT COUNT(*) FROM votes v WHERE v.year_id = y.id) vote_count, + (SELECT COUNT(*) FROM projects p + WHERE p.year_id = y.id AND p.kind = 'project' AND p.status = 'active') + project_count, + (SELECT COUNT(*) FROM projects p + WHERE p.year_id = y.id AND p.kind = 'idea' AND p.status = 'active') + idea_count, + (SELECT COUNT(DISTINCT pm.user_id) + FROM project_members pm + JOIN projects p ON p.id = pm.project_id + WHERE p.year_id = y.id AND p.status = 'active') participant_count, + (SELECT COUNT(*) + FROM video_submissions pv + JOIN projects p ON p.id = pv.project_id + WHERE p.year_id = y.id AND p.kind = 'project' AND p.status = 'active' + AND pv.status = 'ready' AND pv.retired_at IS NULL) ready_video_count, + (SELECT COUNT(*) FROM award_categories c WHERE c.year_id = y.id) + category_count, + (SELECT COUNT(*) FROM awards a WHERE a.year_id = y.id) award_count + FROM years y + ORDER BY y.id`, + ) + .all<{ + year_id: string; + active_voters: number; + vote_count: number; + project_count: number; + idea_count: number; + participant_count: number; + ready_video_count: number; + category_count: number; + award_count: number; + }>(); + + const sources: AnalyticsYearExportSource[] = results.map((row) => ({ + yearId: row.year_id, + activeVoters: row.active_voters, + voteCount: row.vote_count, + projectCount: row.project_count, + ideaCount: row.idea_count, + participantCount: row.participant_count, + readyVideoCount: row.ready_video_count, + categoryCount: row.category_count, + awardCount: row.award_count, + })); + + return buildAnalyticsYearExportRows(sources); +} + +/** + * Year-scoped project analytics for retros, ceremony prep, and optional media harvest. + * Includes active projects/ideas even when no ready video exists. + */ +export async function getAnalyticsProjectExport( db: D1Database, yearId: string, -): Promise { +): Promise { await getYear(db, yearId); const [projectsResult, votesResult, membersResult, awardsResult] = await Promise.all([ db .prepare( - `SELECT p.id project_id, p.name project_name, p.summary, + `SELECT p.id project_id, p.name project_name, p.kind, p.summary, + g.name group_name, pv.id video_id, pv.original_name, pv.duration_seconds FROM projects p - JOIN video_submissions pv ON pv.project_id = p.id - WHERE p.year_id = ? AND p.status = 'active' AND p.kind = 'project' - AND pv.status = 'ready' AND pv.retired_at IS NULL + LEFT JOIN groups g ON g.id = p.group_id + LEFT JOIN video_submissions pv + ON pv.project_id = p.id + AND pv.status = 'ready' + AND pv.retired_at IS NULL + WHERE p.year_id = ? AND p.status = 'active' ORDER BY p.name COLLATE NOCASE, p.id`, ) .bind(yearId) .all<{ project_id: string; project_name: string; + kind: 'project' | 'idea'; summary: string | null; - video_id: string; - original_name: string; + group_name: string | null; + video_id: string | null; + original_name: string | null; duration_seconds: number | null; }>(), db @@ -535,9 +605,7 @@ export async function getAnalyticsVideoExport( FROM project_members pm JOIN users u ON u.id = pm.user_id JOIN projects p ON p.id = pm.project_id - JOIN video_submissions pv ON pv.project_id = p.id - WHERE p.year_id = ? AND p.status = 'active' AND p.kind = 'project' - AND pv.status = 'ready' AND pv.retired_at IS NULL + WHERE p.year_id = ? AND p.status = 'active' ORDER BY pm.project_id, u.display_name COLLATE NOCASE, u.id`, ) .bind(yearId) @@ -582,19 +650,21 @@ export async function getAnalyticsVideoExport( awardsByProject.set(row.project_id, awards); } - const sources: AnalyticsVideoExportSource[] = projectsResult.results.map((row) => ({ + const sources: AnalyticsProjectExportSource[] = projectsResult.results.map((row) => ({ projectId: row.project_id, projectName: row.project_name, + kind: row.kind, + groupName: row.group_name, summary: row.summary, - videoId: row.video_id, - originalName: row.original_name, - durationSeconds: row.duration_seconds, teamMembers: membersByProject.get(row.project_id) ?? [], awards: awardsByProject.get(row.project_id) ?? [], categoryVotes: votesByProject.get(row.project_id) ?? [], + videoId: row.video_id, + originalName: row.original_name, + durationSeconds: row.duration_seconds, })); - return buildAnalyticsVideoExportRows(yearId, sources); + return buildAnalyticsProjectExportRows(yearId, sources); } async function assertVotingEnabled(db: D1Database, yearId: string) { diff --git a/src/worker/routes/analytics.ts b/src/worker/routes/analytics.ts index cbea278..eadbe6c 100644 --- a/src/worker/routes/analytics.ts +++ b/src/worker/routes/analytics.ts @@ -1,13 +1,19 @@ import {Hono} from 'hono'; import { - analyticsVideoExportFilename, - formatAnalyticsVideoExportCsv, + analyticsProjectExportFilename, + analyticsYearExportFilename, + formatAnalyticsProjectExportCsv, + formatAnalyticsYearExportCsv, } from '../../shared/analytics-export'; import type {WorkerEnv} from '../index'; import {requireRole} from '../middleware/user'; -import {getAnalytics, getAnalyticsVideoExport} from '../repositories/administration'; -import {errorResponse, ServiceError} from '../services/errors'; +import { + getAnalytics, + getAnalyticsProjectExport, + getAnalyticsYearExport, +} from '../repositories/administration'; +import {errorResponse} from '../services/errors'; export const analyticsRoutes = new Hono(); analyticsRoutes.use('*', requireRole('admin')); @@ -25,25 +31,30 @@ analyticsRoutes.get('/export', async (c) => { try { const yearId = c.req.query('year')?.trim(); if (!yearId) { - throw new ServiceError( - 'VALIDATION_FAILED', - 'year is required for the ready-video export', - 400, + const rows = await getAnalyticsYearExport(c.env.DB); + return csvResponse( + formatAnalyticsYearExportCsv(rows), + analyticsYearExportFilename(), ); } - const rows = await getAnalyticsVideoExport(c.env.DB, yearId); - const csv = formatAnalyticsVideoExportCsv(rows); - const filename = analyticsVideoExportFilename(yearId); - return new Response(csv, { - status: 200, - headers: { - 'Content-Type': 'text/csv; charset=utf-8', - 'Content-Disposition': `attachment; filename="${filename}"`, - 'Cache-Control': 'no-store', - }, - }); + const rows = await getAnalyticsProjectExport(c.env.DB, yearId); + return csvResponse( + formatAnalyticsProjectExportCsv(rows), + analyticsProjectExportFilename(yearId), + ); } catch (error) { const result = errorResponse(error); return c.json(result.response, result.status); } }); + +function csvResponse(csv: string, filename: string) { + return new Response(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/test/admin/admin.test.ts b/test/admin/admin.test.ts index b97d7d0..04bb6df 100644 --- a/test/admin/admin.test.ts +++ b/test/admin/admin.test.ts @@ -45,6 +45,7 @@ describe('year and award administration', () => { const responses = await Promise.all([ api(`/admin/years/${yearId}`, memberToken), api(`/admin/analytics?year=${yearId}`, memberToken), + api('/admin/analytics/export', memberToken, {raw: true}), api(`/admin/analytics/export?year=${yearId}`, memberToken, {raw: true}), api(`/admin/years/${yearId}/categories`, memberToken, { method: 'POST', @@ -55,7 +56,7 @@ describe('year and award administration', () => { body: {name: 'No', projectId, categoryId: 'no'}, }), ]); - expect(responses.map(({status}) => status)).toEqual([403, 403, 403, 403, 403]); + expect(responses.map(({status}) => status)).toEqual([403, 403, 403, 403, 403, 403]); }); it('manages year state and categories through the centralized admin role', async () => { @@ -238,13 +239,14 @@ describe('year and award administration', () => { expect(JSON.stringify(analytics.body)).not.toContain('creatorId'); }); - it('exports ready-video CSV ranked by total votes for offline curation', async () => { + it('exports year metrics and project analytics CSVs without requiring ready videos', async () => { const category = await createCategory('Delight'); const secondCategory = await createCategory('Craft'); const secondVoterToken = await extraVoterToken('second'); const thirdVoterToken = await extraVoterToken('third'); - const zeroVoteId = `admin-project-zero-${sequence}`; + const noVideoId = `admin-project-novid-${sequence}`; const failedVideoProjectId = `admin-project-failed-${sequence}`; + const ideaId = `admin-idea-${sequence}`; await env.DB.batch([ env.DB.prepare('UPDATE years SET voting_enabled = 1 WHERE id = ?').bind(yearId), env.DB.prepare(`UPDATE projects SET summary = ? WHERE id = ?`).bind( @@ -252,25 +254,32 @@ describe('year and award administration', () => { projectId, ), env.DB.prepare( - `INSERT INTO projects (id, source_id, year_id, creator_id, name, summary) - VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)`, + `INSERT INTO projects (id, source_id, year_id, creator_id, name, summary, kind) + VALUES (?, ?, ?, ?, ?, ?, 'project'), (?, ?, ?, ?, ?, ?, 'project'), + (?, ?, ?, ?, ?, ?, 'idea')`, ).bind( - zeroVoteId, - zeroVoteId, + noVideoId, + noVideoId, yearId, adminId, - 'Zero votes ready', + 'No ready video', 'Still useful archive material', failedVideoProjectId, failedVideoProjectId, yearId, adminId, 'Failed video project', - 'Should not export', + 'Failed media only', + ideaId, + ideaId, + yearId, + adminId, + 'Floating idea', + 'Idea without a demo', ), env.DB.prepare( - 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?), (?, ?)', - ).bind(projectId, adminId, projectTwoId, adminId), + 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?), (?, ?), (?, ?)', + ).bind(projectId, adminId, projectTwoId, adminId, noVideoId, adminId), env.DB.prepare( `INSERT INTO video_submissions ( id, project_id, original_name, size_bytes, original_r2_key, @@ -278,7 +287,6 @@ describe('year and award administration', () => { ) VALUES (?, ?, 'first.mp4', 100, ?, ?, 'ready', 42.5), (?, ?, 'second.mp4', 100, ?, ?, 'ready', 18), - (?, ?, 'zero.mp4', 100, ?, ?, 'ready', 9), (?, ?, 'failed.mp4', 100, ?, NULL, 'failed', NULL)`, ).bind( `ready-video-1-${sequence}`, @@ -289,17 +297,12 @@ describe('year and award administration', () => { projectTwoId, `ready-original-2-${sequence}`, `ready-processed-2-${sequence}`, - `ready-video-zero-${sequence}`, - zeroVoteId, - `ready-original-zero-${sequence}`, - `ready-processed-zero-${sequence}`, `failed-video-${sequence}`, failedVideoProjectId, `failed-original-${sequence}`, ), ]); - // Distinct non-members cast votes so ownership and one-vote-per-category rules hold. const votes = await Promise.all([ api('/votes', memberToken, { method: 'POST', @@ -322,20 +325,34 @@ describe('year and award administration', () => { }); expect(award.status).toBe(201); - const missingYear = await api('/admin/analytics/export', adminToken, {raw: true}); - expect(missingYear.status).toBe(400); + const yearsExport = await api('/admin/analytics/export', adminToken, {raw: true}); + expect(yearsExport.status).toBe(200); + expect(yearsExport.headers.get('Content-Type')).toContain('text/csv'); + expect(yearsExport.headers.get('Content-Disposition')).toContain( + 'filename="hackweek-year-metrics.csv"', + ); + // SAFETY: raw CSV responses return text bodies; JSON error payloads fail the status check above. + const yearsBody = yearsExport.body as string; + expect( + yearsBody.startsWith('year,active_voters,votes,projects,ideas,participants,'), + ).toBe(true); + expect(yearsBody).toContain(`${yearId},`); + expect(yearsBody).toMatch(new RegExp(`${yearId},3,3,4,1,1,2,2,1`)); - const exported = await api(`/admin/analytics/export?year=${yearId}`, adminToken, { - raw: true, - }); - expect(exported.status).toBe(200); - expect(exported.headers.get('Content-Type')).toContain('text/csv'); - expect(exported.headers.get('Content-Disposition')).toContain( - `filename="hackweek-${yearId}-ready-videos.csv"`, + const projectsExport = await api( + `/admin/analytics/export?year=${yearId}`, + adminToken, + { + raw: true, + }, + ); + expect(projectsExport.status).toBe(200); + expect(projectsExport.headers.get('Content-Disposition')).toContain( + `filename="hackweek-${yearId}-projects.csv"`, ); // SAFETY: raw CSV responses return text bodies; JSON error payloads fail the status check above. - const body = exported.body as string; + const body = projectsExport.body as string; const lines = body.trim().split(/\r?\n/); expect(lines[0]).toBe( [ @@ -343,26 +360,31 @@ describe('year and award administration', () => { 'total_votes', 'project_name', 'project_url', - 'video_url', - 'video_id', - 'original_name', - 'duration_seconds', + 'kind', + 'group_name', 'description', 'team_members', 'awards', 'category_votes', + 'has_ready_video', + 'video_id', + 'video_url', + 'original_name', + 'duration_seconds', ].join(','), ); - expect(lines).toHaveLength(4); + expect(lines).toHaveLength(6); expect(lines[1]).toContain('First screening'); expect(lines[1]).toContain('"A punchy demo, with commas"'); expect(lines[1]).toContain('Delight: People’s choice'); expect(lines[1]).toMatch(/^1,2,/); - expect(lines[2]).toMatch(/^2,1,/); - expect(lines[3]).toMatch(/^3,0,/); + expect(lines[1]).toContain(',yes,'); + expect(body).toContain('No ready video'); + expect(body).toContain(',no,,,'); + expect(body).toContain('Floating idea'); expect(body).toContain(`/years/${yearId}/projects/${projectId}`); expect(body).toContain(`/years/${yearId}/watch/ready-video-1-${sequence}`); - expect(body).not.toContain('Failed video project'); + expect(body).toContain('Failed video project'); expect(body).not.toContain('creatorId'); }); }); diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index 982cedc..240931d 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -454,8 +454,10 @@ describe('voting and administration journeys', () => { renderRoute(, '/admin/analytics?year=2026', '/admin/analytics'); expect(await screen.findByText('Active voters')).toBeTruthy(); - expect(screen.getByRole('button', {name: 'Export ready videos CSV'})).toBeTruthy(); + expect(screen.getByRole('button', {name: 'Export 2026 projects CSV'})).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'Export year metrics CSV'})).toBeNull(); expect(screen.getByText('14')).toBeTruthy(); + expect(screen.getByText('Ideas')).toBeTruthy(); expect(screen.getByRole('heading', {name: 'Award standings'})).toBeTruthy(); expect(screen.getByRole('heading', {name: 'Delight'})).toBeTruthy(); expect(screen.getByRole('heading', {name: 'First project'})).toBeTruthy(); diff --git a/test/app/analytics-export.test.ts b/test/app/analytics-export.test.ts index aebbe56..eafcc76 100644 --- a/test/app/analytics-export.test.ts +++ b/test/app/analytics-export.test.ts @@ -2,11 +2,13 @@ import {describe, expect, it} from 'vitest'; import { assignVoteRanks, - buildAnalyticsVideoExportRows, - formatAnalyticsVideoExportCsv, + buildAnalyticsProjectExportRows, + buildAnalyticsYearExportRows, + formatAnalyticsProjectExportCsv, + formatAnalyticsYearExportCsv, } from '../../src/shared/analytics-export'; -describe('analytics video export helpers', () => { +describe('analytics export helpers', () => { it('ranks by total votes with competition ties and stable name ordering', () => { const ranked = assignVoteRanks([ {totalVotes: 5, name: 'b'}, @@ -16,65 +18,93 @@ describe('analytics video export helpers', () => { expect(ranked.map((row) => row.voteRank)).toEqual([1, 1, 3]); }); - it('builds CSV rows for ready videos with awards and category tallies', () => { - const rows = buildAnalyticsVideoExportRows('2026', [ + it('builds multi-year participation rows without requiring videos', () => { + const rows = buildAnalyticsYearExportRows([ { - projectId: 'low', - projectName: 'Low votes', - summary: 'Quiet demo', - videoId: 'video-low', - originalName: 'low.mp4', - durationSeconds: 12, + yearId: '2026', + activeVoters: 10, + voteCount: 40, + projectCount: 12, + ideaCount: 3, + participantCount: 28, + readyVideoCount: 9, + categoryCount: 5, + awardCount: 5, + }, + { + yearId: '2025', + activeVoters: 8, + voteCount: 30, + projectCount: 11, + ideaCount: 2, + participantCount: 22, + readyVideoCount: 0, + categoryCount: 5, + awardCount: 5, + }, + ]); + + expect(rows.map((row) => row.yearId)).toEqual(['2025', '2026']); + const csv = formatAnalyticsYearExportCsv(rows); + expect(csv.startsWith('year,active_voters,votes,projects,ideas,participants,')).toBe( + true, + ); + expect(csv).toContain('2025,8,30,11,2,22,0,5,5'); + }); + + it('builds project rows with optional ready-video fields', () => { + const rows = buildAnalyticsProjectExportRows('2025', [ + { + projectId: 'no-video', + projectName: 'Archive only', + kind: 'project', + groupName: 'Europe', + summary: 'Still useful without R2 media', teamMembers: ['Sam'], - awards: [], - categoryVotes: [{categoryName: 'Craft', voteCount: 1}], + awards: ['Craft'], + categoryVotes: [{categoryName: 'Craft', voteCount: 3}], + videoId: null, + originalName: null, + durationSeconds: null, }, { projectId: 'top', projectName: 'Top project', + kind: 'project', + groupName: null, summary: 'A punchy demo, with commas', - videoId: 'video-top', - originalName: 'top.mp4', - durationSeconds: 41.5, teamMembers: ['Ada', 'Grace'], awards: ["Delight: People's choice"], categoryVotes: [ {categoryName: 'Delight', voteCount: 4}, {categoryName: 'Craft', voteCount: 2}, ], - }, - { - projectId: 'zero', - projectName: 'Zero votes', - summary: null, - videoId: 'video-zero', - originalName: 'zero.mp4', - durationSeconds: null, - teamMembers: [], - awards: [], - categoryVotes: [], + videoId: 'video-top', + originalName: 'top.mp4', + durationSeconds: 41.5, }, ]); expect(rows.map((row) => [row.voteRank, row.totalVotes, row.projectId])).toEqual([ [1, 6, 'top'], - [2, 1, 'low'], - [3, 0, 'zero'], + [2, 3, 'no-video'], ]); expect(rows[0]).toMatchObject({ - projectUrl: '/years/2026/projects/top', - videoUrl: '/years/2026/watch/video-top', - teamMembers: 'Ada; Grace', - awards: "Delight: People's choice", + hasReadyVideo: true, + videoUrl: '/years/2025/watch/video-top', categoryVotes: 'Delight:4; Craft:2', }); + expect(rows[1]).toMatchObject({ + hasReadyVideo: false, + videoId: '', + videoUrl: '', + groupName: 'Europe', + }); - const csv = formatAnalyticsVideoExportCsv(rows); + const csv = formatAnalyticsProjectExportCsv(rows); expect(csv.startsWith('vote_rank,total_votes,project_name,')).toBe(true); expect(csv).toContain('"A punchy demo, with commas"'); - expect(csv).toContain('/years/2026/watch/video-top'); - expect(csv).toContain( - '3,0,Zero votes,/years/2026/projects/zero,/years/2026/watch/video-zero,video-zero,zero.mp4,,,', - ); + expect(csv).toContain(',yes,video-top,'); + expect(csv).toContain(',no,,,'); }); });