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
59 changes: 37 additions & 22 deletions src/app/routes/AdminAnalyticsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -13,21 +16,27 @@ export function AdminAnalyticsPage() {
const query = useAnalytics(yearId);
const [exportError, setExportError] = useState<string | null>(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();
Expand All @@ -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);
Expand All @@ -55,21 +64,23 @@ export function AdminAnalyticsPage() {
</div>
<div className="analyticsHeroActions">
<p>
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.
</p>
{yearId && (
<button
type="button"
className="primaryAction"
disabled={exporting || query.isLoading}
onClick={() => {
void downloadReadyVideoCsv();
}}
>
{exporting ? 'Exporting…' : 'Export ready videos CSV'}
</button>
)}
<button
type="button"
className="primaryAction"
disabled={exporting || query.isLoading}
onClick={() => {
void downloadCsv();
}}
>
{exporting
? 'Exporting…'
: yearId
? `Export ${yearId} projects CSV`
: 'Export year metrics CSV'}
</button>
{exportError && <p className="formError">{exportError}</p>}
</div>
</header>
Expand All @@ -93,6 +104,10 @@ export function AdminAnalyticsPage() {
<dt>Projects</dt>
<dd>{year.projectCount}</dd>
</div>
<div>
<dt>Ideas</dt>
<dd>{year.ideaCount}</dd>
</div>
<div>
<dt>Awards</dt>
<dd>{year.awardCount}</dd>
Expand Down
28 changes: 22 additions & 6 deletions src/shared/administration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
171 changes: 127 additions & 44 deletions src/shared/analytics-export.ts
Original file line number Diff line number Diff line change
@@ -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). */
Expand All @@ -41,10 +69,28 @@ export function assignVoteRanks<T extends {totalVotes: number}>(
});
}

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,
Expand All @@ -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(','),
Expand All @@ -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 {
Expand Down
Loading
Loading