From 9464f47c3b2046caa24fb07cbc3e3e37a65d8aae Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:47:19 +0000 Subject: [PATCH 1/2] feat(admin): export ready-video CSV from analytics Add an admin-only CSV download of ready project videos ranked by total votes for awards ceremony prep, supercut harvesting, and year retrospectives. Co-Authored-By: Chris Jennings --- src/app/routes/AdminAnalyticsPage.tsx | 58 +++++++- src/app/styles.css | 9 +- src/shared/administration.ts | 17 +++ src/shared/analytics-export.ts | 119 +++++++++++++++++ src/worker/repositories/administration.ts | 111 ++++++++++++++++ src/worker/routes/analytics.ts | 35 ++++- test/admin/admin.test.ts | 153 +++++++++++++++++++++- test/app/administration.test.tsx | 1 + test/app/analytics-export.test.ts | 80 +++++++++++ vitest.app.config.ts | 1 + 10 files changed, 574 insertions(+), 10 deletions(-) create mode 100644 src/shared/analytics-export.ts create mode 100644 test/app/analytics-export.test.ts diff --git a/src/app/routes/AdminAnalyticsPage.tsx b/src/app/routes/AdminAnalyticsPage.tsx index 78ed054..9baeb62 100644 --- a/src/app/routes/AdminAnalyticsPage.tsx +++ b/src/app/routes/AdminAnalyticsPage.tsx @@ -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(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 (
@@ -18,10 +53,25 @@ export function AdminAnalyticsPage() {

Hackweek analytics

participation

-

- D1 computes these totals server-side. No raw historical database is sent to this - page. -

+
+

+ D1 computes these totals server-side. No raw historical database is sent to + this page. +

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

{exportError}

} +
{query.data && ( diff --git a/src/app/styles.css b/src/app/styles.css index 4ce5876..9a8b479 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -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 { 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; } diff --git a/src/shared/administration.ts b/src/shared/administration.ts index 46f1bf8..98284f3 100644 --- a/src/shared/administration.ts +++ b/src/shared/administration.ts @@ -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; +} diff --git a/src/shared/analytics-export.ts b/src/shared/analytics-export.ts new file mode 100644 index 0000000..73aae27 --- /dev/null +++ b/src/shared/analytics-export.ts @@ -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( + rows: T[], +): Array { + 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; +} diff --git a/src/worker/repositories/administration.ts b/src/worker/repositories/administration.ts index 9839391..745d6a6 100644 --- a/src/worker/repositories/administration.ts +++ b/src/worker/repositories/administration.ts @@ -1,6 +1,7 @@ import type { AdminYearResponse, AnalyticsResponse, + AnalyticsVideoExportRow, AwardCategorySummary, AwardSummary, AwardWriteRequest, @@ -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'; @@ -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 { + 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(); + 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(); + 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) { diff --git a/src/worker/routes/analytics.ts b/src/worker/routes/analytics.ts index 9c1f529..cbea278 100644 --- a/src/worker/routes/analytics.ts +++ b/src/worker/routes/analytics.ts @@ -1,9 +1,13 @@ import {Hono} from 'hono'; +import { + analyticsVideoExportFilename, + formatAnalyticsVideoExportCsv, +} from '../../shared/analytics-export'; import type {WorkerEnv} from '../index'; import {requireRole} from '../middleware/user'; -import {getAnalytics} from '../repositories/administration'; -import {errorResponse} from '../services/errors'; +import {getAnalytics, getAnalyticsVideoExport} from '../repositories/administration'; +import {errorResponse, ServiceError} from '../services/errors'; export const analyticsRoutes = new Hono(); analyticsRoutes.use('*', requireRole('admin')); @@ -16,3 +20,30 @@ analyticsRoutes.get('/', async (c) => { return c.json(result.response, result.status); } }); + +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 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', + }, + }); + } catch (error) { + const result = errorResponse(error); + return c.json(result.response, result.status); + } +}); diff --git a/test/admin/admin.test.ts b/test/admin/admin.test.ts index e8d02aa..b97d7d0 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?year=${yearId}`, memberToken, {raw: true}), api(`/admin/years/${yearId}/categories`, memberToken, { method: 'POST', body: {name: 'No'}, @@ -54,7 +55,7 @@ describe('year and award administration', () => { body: {name: 'No', projectId, categoryId: 'no'}, }), ]); - expect(responses.map(({status}) => status)).toEqual([403, 403, 403, 403]); + expect(responses.map(({status}) => status)).toEqual([403, 403, 403, 403, 403]); }); it('manages year state and categories through the centralized admin role', async () => { @@ -236,6 +237,134 @@ 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 () => { + 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 failedVideoProjectId = `admin-project-failed-${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( + 'A punchy demo, with commas', + projectId, + ), + env.DB.prepare( + `INSERT INTO projects (id, source_id, year_id, creator_id, name, summary) + VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)`, + ).bind( + zeroVoteId, + zeroVoteId, + yearId, + adminId, + 'Zero votes ready', + 'Still useful archive material', + failedVideoProjectId, + failedVideoProjectId, + yearId, + adminId, + 'Failed video project', + 'Should not export', + ), + env.DB.prepare( + 'INSERT INTO project_members (project_id, user_id) VALUES (?, ?), (?, ?)', + ).bind(projectId, adminId, projectTwoId, adminId), + env.DB.prepare( + `INSERT INTO video_submissions ( + id, project_id, original_name, size_bytes, original_r2_key, + processed_r2_key, status, duration_seconds + ) 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}`, + projectId, + `ready-original-1-${sequence}`, + `ready-processed-1-${sequence}`, + `ready-video-2-${sequence}`, + 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', + body: {yearId, projectId, categoryId: category.id}, + }), + api('/votes', secondVoterToken, { + method: 'POST', + body: {yearId, projectId, categoryId: secondCategory.id}, + }), + api('/votes', thirdVoterToken, { + method: 'POST', + body: {yearId, projectId: projectTwoId, categoryId: category.id}, + }), + ]); + expect(votes.map(({status}) => status)).toEqual([201, 201, 201]); + + const award = await api(`/admin/awards/years/${yearId}`, adminToken, { + method: 'POST', + body: {name: 'People’s choice', projectId, categoryId: category.id}, + }); + expect(award.status).toBe(201); + + const missingYear = await api('/admin/analytics/export', adminToken, {raw: true}); + expect(missingYear.status).toBe(400); + + 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"`, + ); + + // SAFETY: raw CSV responses return text bodies; JSON error payloads fail the status check above. + const body = exported.body as string; + const lines = body.trim().split(/\r?\n/); + expect(lines[0]).toBe( + [ + 'vote_rank', + 'total_votes', + 'project_name', + 'project_url', + 'video_url', + 'video_id', + 'original_name', + 'duration_seconds', + 'description', + 'team_members', + 'awards', + 'category_votes', + ].join(','), + ); + expect(lines).toHaveLength(4); + 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(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).not.toContain('creatorId'); + }); }); async function createCategory(name: string) { @@ -263,10 +392,21 @@ async function tokenAndSession(kind: 'admin' | 'member') { return token; } +async function extraVoterToken(label: string) { + const subject = `admin-voter-${label}-${sequence}`; + const token = await createSessionCookie({ + sub: subject, + email: `${subject}@sentry.io`, + name: label, + }); + await SELF.fetch(`${base}/session`, {headers: {Cookie: token}}); + return token; +} + async function api( path: string, token: string, - options: {method?: string; body?: unknown} = {}, + options: {method?: string; body?: unknown; raw?: boolean} = {}, ) { const headers = new Headers({Cookie: token}); if (options.method && options.method !== 'GET') { @@ -278,8 +418,15 @@ async function api( headers, body: options.body === undefined ? undefined : JSON.stringify(options.body), }); + const body = + response.status === 204 + ? null + : options.raw && !response.headers.get('Content-Type')?.includes('application/json') + ? await response.text() + : await response.json(); return { status: response.status, - body: response.status === 204 ? null : await response.json(), + headers: response.headers, + body, }; } diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index 75dc188..982cedc 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -454,6 +454,7 @@ 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.getByText('14')).toBeTruthy(); expect(screen.getByRole('heading', {name: 'Award standings'})).toBeTruthy(); expect(screen.getByRole('heading', {name: 'Delight'})).toBeTruthy(); diff --git a/test/app/analytics-export.test.ts b/test/app/analytics-export.test.ts new file mode 100644 index 0000000..aebbe56 --- /dev/null +++ b/test/app/analytics-export.test.ts @@ -0,0 +1,80 @@ +import {describe, expect, it} from 'vitest'; + +import { + assignVoteRanks, + buildAnalyticsVideoExportRows, + formatAnalyticsVideoExportCsv, +} from '../../src/shared/analytics-export'; + +describe('analytics video export helpers', () => { + it('ranks by total votes with competition ties and stable name ordering', () => { + const ranked = assignVoteRanks([ + {totalVotes: 5, name: 'b'}, + {totalVotes: 5, name: 'a'}, + {totalVotes: 2, name: 'c'}, + ]); + 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', [ + { + projectId: 'low', + projectName: 'Low votes', + summary: 'Quiet demo', + videoId: 'video-low', + originalName: 'low.mp4', + durationSeconds: 12, + teamMembers: ['Sam'], + awards: [], + categoryVotes: [{categoryName: 'Craft', voteCount: 1}], + }, + { + projectId: 'top', + projectName: 'Top project', + 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: [], + }, + ]); + + expect(rows.map((row) => [row.voteRank, row.totalVotes, row.projectId])).toEqual([ + [1, 6, 'top'], + [2, 1, 'low'], + [3, 0, 'zero'], + ]); + expect(rows[0]).toMatchObject({ + projectUrl: '/years/2026/projects/top', + videoUrl: '/years/2026/watch/video-top', + teamMembers: 'Ada; Grace', + awards: "Delight: People's choice", + categoryVotes: 'Delight:4; Craft:2', + }); + + const csv = formatAnalyticsVideoExportCsv(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,,,', + ); + }); +}); diff --git a/vitest.app.config.ts b/vitest.app.config.ts index cf829ac..fdbfe4c 100644 --- a/vitest.app.config.ts +++ b/vitest.app.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ test: { environment: 'jsdom', include: [ + 'test/app/**/*.test.ts', 'test/app/**/*.test.tsx', 'test/player/**/*.test.ts', 'test/player/**/*.test.tsx', From fb20da948132af15d96872db4748578b25897f7d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:08:53 +0000 Subject: [PATCH 2/2] fix(admin): keep export error text on danger color Narrow the analytics hero muted paragraph selector so .formError is not overridden. --- src/app/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/styles.css b/src/app/styles.css index 9a8b479..4e4b996 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -441,7 +441,7 @@ main { .projectsHero > div > p:last-of-type, .editorPage > header > p:last-child, .operationsHero > p, -.analyticsHeroActions > p { +.analyticsHeroActions > p:not(.formError) { max-width: 34rem; margin: 0; color: var(--muted);