From 2e080a5d09b19d437f9b0c291882ded50ca6f15b Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 24 Apr 2026 18:39:18 +0200 Subject: [PATCH] feat: lift round selector to reports page top-level Top-level selector in the URL (?round=...) drives every single-round tab (Overview, Analytics, Juror Consistency, Diversity) and narrows the Pipeline tab to the selected program. Cross-Round keeps its own multi-select because it compares multiple rounds by design. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(admin)/admin/reports/page.tsx | 261 ++++++++++--------------- 1 file changed, 106 insertions(+), 155 deletions(-) diff --git a/src/app/(admin)/admin/reports/page.tsx b/src/app/(admin)/admin/reports/page.tsx index cf22a20..38df4f2 100644 --- a/src/app/(admin)/admin/reports/page.tsx +++ b/src/app/(admin)/admin/reports/page.tsx @@ -1,7 +1,8 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import Link from 'next/link' +import { useSearchParams, useRouter, usePathname } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Card, @@ -66,27 +67,24 @@ import { import { ExportPdfButton } from '@/components/shared/export-pdf-button' import { AnimatedCard } from '@/components/shared/animated-container' -function ReportsOverview() { +function ReportsOverview({ scope }: { scope: string | null }) { const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true }) const { data: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery() - // Flatten stages from all programs const rounds = programs?.flatMap(p => ((p.stages ?? []) as Array<{ id: string; name: string; status: string; votingEndAt?: string | Date | null }>).map((s: { id: string; name: string; status: string; votingEndAt?: string | Date | null }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` })) ) || [] - // Project reporting scope (default: latest program, all rounds) - const [selectedValue, setSelectedValue] = useState(null) - - useEffect(() => { - if (programs?.length && !selectedValue) { - setSelectedValue(`all:${programs[0].id}`) - } - }, [programs, selectedValue]) - - const scopeInput = parseSelection(selectedValue) + const scopeInput = parseSelection(scope) const hasScope = !!scopeInput.roundId || !!scopeInput.programId + const selectedRound = scope ? rounds.find((r) => r.id === scope) : null + const selectedScopeLabel = selectedRound + ? `${selectedRound.programName} — ${selectedRound.name}` + : scope?.startsWith('all:') + ? `${programs?.find((p) => `all:${p.id}` === scope)?.year ?? ''} Edition — All Rounds` + : 'All projects' + const { data: projectRankings, isLoading: projectsLoading } = trpc.analytics.getProjectRankings.useQuery( { ...scopeInput, limit: 5000 }, @@ -243,26 +241,9 @@ function ReportsOverview() { Project Reports - Summary dashboard — optionally filter to a specific round + {selectedScopeLabel} - @@ -550,9 +531,7 @@ function findDefaultRound(rounds: Array<{ id: string; status?: string }>): strin return rounds[0]?.id } -function StageAnalytics() { - const [selectedValue, setSelectedValue] = useState(null) - +function StageAnalytics({ scope }: { scope: string | null }) { const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true }) // Flatten stages from all programs with program name @@ -560,14 +539,7 @@ function StageAnalytics() { ((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` })) ) || [] - // Set default selected stage — prefer active round - useEffect(() => { - if (rounds.length && !selectedValue) { - setSelectedValue(findDefaultRound(rounds) ?? rounds[0].id) - } - }, [rounds.length, selectedValue]) - - const queryInput = parseSelection(selectedValue) + const queryInput = parseSelection(scope) const hasSelection = !!queryInput.roundId || !!queryInput.programId const { data: scoreDistribution, isLoading: scoreLoading } = @@ -606,7 +578,7 @@ function StageAnalytics() { { enabled: hasSelection } ) - const selectedRound = rounds.find((r) => r.id === selectedValue) + const selectedRound = rounds.find((r) => r.id === scope) const geoInput = queryInput.programId ? { programId: queryInput.programId } : { programId: selectedRound?.programId || '', roundId: queryInput.roundId } @@ -644,28 +616,6 @@ function StageAnalytics() { return (
- {/* Round Selector */} -
- - -
- {hasSelection && (
{/* Row 1: Score Distribution & Status Breakdown */} @@ -838,22 +788,10 @@ function CrossStageTab() { ) } -function JurorConsistencyTab() { - const [selectedValue, setSelectedValue] = useState(null) +function JurorConsistencyTab({ scope }: { scope: string | null }) { + const { isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true }) - const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true }) - - const stages = programs?.flatMap((p) => - ((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` })) - ) || [] - - useEffect(() => { - if (stages.length && !selectedValue) { - setSelectedValue(findDefaultRound(stages) ?? stages[0].id) - } - }, [stages.length, selectedValue]) - - const queryInput = parseSelection(selectedValue) + const queryInput = parseSelection(scope) const hasSelection = !!queryInput.roundId || !!queryInput.programId const { data: consistency, isLoading: consistencyLoading } = @@ -868,27 +806,6 @@ function JurorConsistencyTab() { return (
-
- - -
- {consistencyLoading && } {consistency && ( @@ -1052,22 +969,10 @@ function JurorCalibrationPanel({ roundId }: { roundId: string }) { ) } -function DiversityTab() { - const [selectedValue, setSelectedValue] = useState(null) +function DiversityTab({ scope }: { scope: string | null }) { + const { isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true }) - const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true }) - - const stages = programs?.flatMap((p) => - ((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` })) - ) || [] - - useEffect(() => { - if (stages.length && !selectedValue) { - setSelectedValue(findDefaultRound(stages) ?? stages[0].id) - } - }, [stages.length, selectedValue]) - - const queryInput = parseSelection(selectedValue) + const queryInput = parseSelection(scope) const hasSelection = !!queryInput.roundId || !!queryInput.programId const { data: diversity, isLoading: diversityLoading } = @@ -1082,27 +987,6 @@ function DiversityTab() { return (
-
- - -
- {diversityLoading && } {diversity && ( @@ -1120,10 +1004,10 @@ function DiversityTab() { ) } -function RoundPipelineTab() { +function RoundPipelineTab({ scope }: { scope: string | null }) { const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true }) - const rounds = programs?.flatMap(p => + const allRounds = programs?.flatMap(p => ((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({ ...s, programId: p.id, @@ -1131,6 +1015,16 @@ function RoundPipelineTab() { })) ) || [] + // Pipeline is inherently multi-round. Narrow to the selected program if one + // is picked (either via "all:programId" or a specific round whose program we + // can resolve). Otherwise show every round across every program. + const scopeProgramId = scope?.startsWith('all:') + ? scope.slice(4) + : allRounds.find((r) => r.id === scope)?.programId + const rounds = scopeProgramId + ? allRounds.filter((r) => r.programId === scopeProgramId) + : allRounds + const roundIds = rounds.map(r => r.id) const { data: comparison, isLoading: comparisonLoading } = @@ -1212,20 +1106,48 @@ function RoundPipelineTab() { } export default function ReportsPage() { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const urlRound = searchParams.get('round') + + const { data: programs } = trpc.program.list.useQuery({ includeStages: true }) + + const stages = useMemo( + () => programs?.flatMap((p) => + ((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map( + (s: { id: string; name: string; status: string }) => ({ + id: s.id, + name: s.name, + status: s.status, + programId: p.id, + programName: `${p.year} Edition`, + }), + ), + ) || [], + [programs], + ) + const [pdfStageId, setPdfStageId] = useState(null) - - const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true }) - const pdfStages = pdfPrograms?.flatMap((p) => - ((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programName: `${p.year} Edition` })) - ) || [] - useEffect(() => { - if (pdfStages.length && !pdfStageId) { - setPdfStageId(findDefaultRound(pdfStages) ?? pdfStages[0].id) + if (stages.length && !pdfStageId) { + setPdfStageId(findDefaultRound(stages) ?? stages[0].id) } - }, [pdfStages.length, pdfStageId]) + }, [stages.length, pdfStageId, stages]) - const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId) + // Top-level selection drives every single-round tab. Persisted to the URL + // so reloads and shared links preserve the view. Defaults to the newest + // program's "All Rounds" entry. + const defaultScope = programs?.length ? `all:${programs[0].id}` : null + const scope = urlRound ?? defaultScope + + const setScope = (value: string) => { + const params = new URLSearchParams(searchParams.toString()) + params.set('round', value) + router.replace(`${pathname}?${params.toString()}`, { scroll: false }) + } + + const selectedPdfStage = stages.find((r) => r.id === pdfStageId) return (
@@ -1237,6 +1159,35 @@ export default function ReportsPage() {

+ {/* Top-level round selector — drives every tab below */} + + +
+ + +

+ This selection applies to every tab except Cross-Round (which compares multiple rounds). +

+
+
+
+ {/* Tabs */}
@@ -1272,7 +1223,7 @@ export default function ReportsPage() { - {pdfStages.map((stage) => ( + {stages.map((stage) => ( {stage.name} @@ -1290,11 +1241,11 @@ export default function ReportsPage() {
- + - + @@ -1302,15 +1253,15 @@ export default function ReportsPage() { - + - + - +