Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions src/app/routes/AdminAnalyticsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,48 @@
import {useState} from 'react';
import {Link, useSearch} from 'wouter';

import type {VoteResult} from '../../shared/administration';
import {analyticsVideoExportFilename} from '../../shared/analytics-export';
import {QueryState} from '../components/AppLayout';
import {UserAvatar} from '../components/UserAvatar';
import {ApiError, apiResponseError} from '../queries/api';
import {useAnalytics} from '../queries/administration';

export function AdminAnalyticsPage() {
const yearId = new URLSearchParams(useSearch()).get('year') ?? undefined;
const query = useAnalytics(yearId);
const [exportError, setExportError] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);

async function downloadReadyVideoCsv() {
if (!yearId || exporting) return;
setExporting(true);
setExportError(null);
try {
const response = await fetch(
`/api/admin/analytics/export?year=${encodeURIComponent(yearId)}`,
);
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);
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
} catch (error) {
setExportError(
error instanceof ApiError
? error.message
: 'Ready-video CSV could not be downloaded',
);
} finally {
setExporting(false);
}
}

return (
<main className="operationsPage">
<Link className="backLink" href={yearId ? `/admin/years/${yearId}` : '/years'}>
Expand All @@ -18,10 +53,25 @@ export function AdminAnalyticsPage() {
<p className="kicker">Hackweek analytics</p>
<h1>participation</h1>
</div>
<p>
D1 computes these totals server-side. No raw historical database is sent to this
page.
</p>
<div className="analyticsHeroActions">
<p>
D1 computes these totals server-side. No raw historical database is sent to
this page.
</p>
{yearId && (
<button
type="button"
className="primaryAction"
disabled={exporting || query.isLoading}
onClick={() => {
void downloadReadyVideoCsv();
}}
>
{exporting ? 'Exporting…' : 'Export ready videos CSV'}
</button>
)}
{exportError && <p className="formError">{exportError}</p>}
</div>
</header>
<QueryState loading={query.isLoading} error={query.error}>
{query.data && (
Expand Down
9 changes: 8 additions & 1 deletion src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -440,13 +440,20 @@ main {
.archiveHero p:last-child,
.projectsHero > div > p:last-of-type,
.editorPage > header > p:last-child,
.operationsHero > p {
.operationsHero > p,
.analyticsHeroActions > p:not(.formError) {
max-width: 34rem;
margin: 0;
color: var(--muted);
font-size: 1rem;
line-height: 1.75;
}
.analyticsHeroActions {
display: grid;
gap: 1rem;
justify-items: start;
align-content: end;
}
.myProjects {
margin-top: 1.5rem;
}
Expand Down
17 changes: 17 additions & 0 deletions src/shared/administration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,20 @@ export interface AnalyticsResponse {
years: AnalyticsYear[];
voteResults: VoteResult[];
}

/** One ready-video project row for the admin analytics CSV export. */
export interface AnalyticsVideoExportRow {
voteRank: number;
totalVotes: number;
projectId: string;
projectName: string;
projectUrl: string;
videoId: string;
videoUrl: string;
originalName: string;
durationSeconds: number | null;
description: string;
teamMembers: string;
awards: string;
categoryVotes: string;
}
119 changes: 119 additions & 0 deletions src/shared/analytics-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type {AnalyticsVideoExportRow} from './administration';

export const ANALYTICS_VIDEO_EXPORT_HEADERS = [
'vote_rank',
'total_votes',
'project_name',
'project_url',
'video_url',
'video_id',
'original_name',
'duration_seconds',
'description',
'team_members',
'awards',
'category_votes',
] as const;

export interface AnalyticsVideoExportSource {
projectId: string;
projectName: string;
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}>;
}

/** Competition rank: ties share a rank, next rank skips (1, 2, 2, 4). */
export function assignVoteRanks<T extends {totalVotes: number}>(
rows: T[],
): Array<T & {voteRank: number}> {
let rank = 0;
return rows.map((row, index) => {
if (index === 0 || row.totalVotes !== rows[index - 1]?.totalVotes) {
rank = index + 1;
}
return {...row, voteRank: rank};
});
}

export function buildAnalyticsVideoExportRows(
yearId: string,
sources: AnalyticsVideoExportSource[],
): AnalyticsVideoExportRow[] {
const sorted = [...sources]
.map((source) => ({
...source,
totalVotes: source.categoryVotes.reduce((sum, item) => sum + item.voteCount, 0),
}))
.sort(
(left, right) =>
right.totalVotes - left.totalVotes ||
left.projectName.localeCompare(right.projectName) ||
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('; '),
}));
}

export function formatAnalyticsVideoExportCsv(rows: AnalyticsVideoExportRow[]): string {
const lines = [
ANALYTICS_VIDEO_EXPORT_HEADERS.join(','),
...rows.map((row) =>
[
row.voteRank,
row.totalVotes,
row.projectName,
row.projectUrl,
row.videoUrl,
row.videoId,
row.originalName,
row.durationSeconds ?? '',
row.description,
row.teamMembers,
row.awards,
row.categoryVotes,
]
.map(escapeCsvField)
.join(','),
),
];
return `${lines.join('\r\n')}\r\n`;
}

export function analyticsVideoExportFilename(yearId: string) {
return `hackweek-${yearId}-ready-videos.csv`;
}

function escapeCsvField(value: string | number): string {
const text = String(value);
if (/[",\r\n]/.test(text)) {
return `"${text.replaceAll('"', '""')}"`;
}
return text;
}
111 changes: 111 additions & 0 deletions src/worker/repositories/administration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AdminYearResponse,
AnalyticsResponse,
AnalyticsVideoExportRow,
AwardCategorySummary,
AwardSummary,
AwardWriteRequest,
Expand All @@ -9,6 +10,10 @@ import type {
ScreeningOrderItem,
VoteSummary,
} from '../../shared/administration';
import {
buildAnalyticsVideoExportRows,
type AnalyticsVideoExportSource,
} from '../../shared/analytics-export';
import {ServiceError} from '../services/errors';
import {getYear} from './projects';
import {getEffectiveYearFlags} from './years';
Expand Down Expand Up @@ -486,6 +491,112 @@ export async function getAnalytics(
};
}

/** Ready-video projects for offline curation: ceremony, supercut, retrospectives. */
export async function getAnalyticsVideoExport(
db: D1Database,
yearId: string,
): Promise<AnalyticsVideoExportRow[]> {
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,
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
ORDER BY p.name COLLATE NOCASE, p.id`,
)
.bind(yearId)
.all<{
project_id: string;
project_name: string;
summary: string | null;
video_id: string;
original_name: string;
duration_seconds: number | null;
}>(),
db
.prepare(
`SELECT v.project_id, c.name category_name, COUNT(v.id) vote_count
FROM votes v
JOIN award_categories c ON c.id = v.award_category_id
WHERE v.year_id = ?
GROUP BY v.project_id, c.id
ORDER BY v.project_id, vote_count DESC, c.name COLLATE NOCASE`,
)
.bind(yearId)
.all<{project_id: string; category_name: string; vote_count: number}>(),
db
.prepare(
`SELECT pm.project_id, u.display_name
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
ORDER BY pm.project_id, u.display_name COLLATE NOCASE, u.id`,
)
.bind(yearId)
.all<{project_id: string; display_name: string}>(),
db
.prepare(
`SELECT a.project_id, c.name category_name, a.name award_name
FROM awards a
JOIN award_categories c ON c.id = a.category_id
WHERE a.year_id = ?
ORDER BY a.project_id, c.name COLLATE NOCASE, a.name COLLATE NOCASE`,
)
.bind(yearId)
.all<{project_id: string; category_name: string; award_name: string}>(),
]);

const membersByProject = new Map<string, string[]>();
for (const row of membersResult.results) {
const members = membersByProject.get(row.project_id) ?? [];
members.push(row.display_name);
membersByProject.set(row.project_id, members);
}

const votesByProject = new Map<
string,
Array<{categoryName: string; voteCount: number}>
>();
for (const row of votesResult.results) {
const votes = votesByProject.get(row.project_id) ?? [];
votes.push({categoryName: row.category_name, voteCount: row.vote_count});
votesByProject.set(row.project_id, votes);
}

const awardsByProject = new Map<string, string[]>();
for (const row of awardsResult.results) {
const awards = awardsByProject.get(row.project_id) ?? [];
const label =
row.award_name.trim().toLowerCase() === row.category_name.trim().toLowerCase()
? row.category_name
: `${row.category_name}: ${row.award_name}`;
awards.push(label);
awardsByProject.set(row.project_id, awards);
}

const sources: AnalyticsVideoExportSource[] = projectsResult.results.map((row) => ({
projectId: row.project_id,
projectName: row.project_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) ?? [],
}));

return buildAnalyticsVideoExportRows(yearId, sources);
}

async function assertVotingEnabled(db: D1Database, yearId: string) {
const year = await getEffectiveYearFlags(db, yearId);
if (!year?.votingEnabled) {
Expand Down
Loading
Loading