feat: lift round selector to reports page top-level
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m42s

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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-24 18:39:18 +02:00
parent 982d5193c5
commit 2e080a5d09

View File

@@ -1,7 +1,8 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useSearchParams, useRouter, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -66,27 +67,24 @@ import {
import { ExportPdfButton } from '@/components/shared/export-pdf-button' import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container' 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: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const { data: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery() const { data: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery()
// Flatten stages from all programs
const rounds = programs?.flatMap(p => 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` })) ((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 scopeInput = parseSelection(scope)
const [selectedValue, setSelectedValue] = useState<string | null>(null)
useEffect(() => {
if (programs?.length && !selectedValue) {
setSelectedValue(`all:${programs[0].id}`)
}
}, [programs, selectedValue])
const scopeInput = parseSelection(selectedValue)
const hasScope = !!scopeInput.roundId || !!scopeInput.programId 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 } = const { data: projectRankings, isLoading: projectsLoading } =
trpc.analytics.getProjectRankings.useQuery( trpc.analytics.getProjectRankings.useQuery(
{ ...scopeInput, limit: 5000 }, { ...scopeInput, limit: 5000 },
@@ -243,26 +241,9 @@ function ReportsOverview() {
Project Reports Project Reports
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Summary dashboard optionally filter to a specific round {selectedScopeLabel}
</CardDescription> </CardDescription>
</div> </div>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
@@ -550,9 +531,7 @@ function findDefaultRound(rounds: Array<{ id: string; status?: string }>): strin
return rounds[0]?.id return rounds[0]?.id
} }
function StageAnalytics() { function StageAnalytics({ scope }: { scope: string | null }) {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true }) const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true })
// Flatten stages from all programs with program name // 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` })) ((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 const queryInput = parseSelection(scope)
useEffect(() => {
if (rounds.length && !selectedValue) {
setSelectedValue(findDefaultRound(rounds) ?? rounds[0].id)
}
}, [rounds.length, selectedValue])
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } = const { data: scoreDistribution, isLoading: scoreLoading } =
@@ -606,7 +578,7 @@ function StageAnalytics() {
{ enabled: hasSelection } { enabled: hasSelection }
) )
const selectedRound = rounds.find((r) => r.id === selectedValue) const selectedRound = rounds.find((r) => r.id === scope)
const geoInput = queryInput.programId const geoInput = queryInput.programId
? { programId: queryInput.programId } ? { programId: queryInput.programId }
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId } : { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
@@ -644,28 +616,6 @@ function StageAnalytics() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Round Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{hasSelection && ( {hasSelection && (
<div className="space-y-6"> <div className="space-y-6">
{/* Row 1: Score Distribution & Status Breakdown */} {/* Row 1: Score Distribution & Status Breakdown */}
@@ -838,22 +788,10 @@ function CrossStageTab() {
) )
} }
function JurorConsistencyTab() { function JurorConsistencyTab({ scope }: { scope: string | null }) {
const [selectedValue, setSelectedValue] = useState<string | null>(null) const { isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true }) const queryInput = parseSelection(scope)
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 hasSelection = !!queryInput.roundId || !!queryInput.programId const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: consistency, isLoading: consistencyLoading } = const { data: consistency, isLoading: consistencyLoading } =
@@ -868,27 +806,6 @@ function JurorConsistencyTab() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Stage:</label>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Stages
</SelectItem>
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{consistencyLoading && <Skeleton className="h-[400px]" />} {consistencyLoading && <Skeleton className="h-[400px]" />}
{consistency && ( {consistency && (
@@ -1052,22 +969,10 @@ function JurorCalibrationPanel({ roundId }: { roundId: string }) {
) )
} }
function DiversityTab() { function DiversityTab({ scope }: { scope: string | null }) {
const [selectedValue, setSelectedValue] = useState<string | null>(null) const { isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true }) const queryInput = parseSelection(scope)
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 hasSelection = !!queryInput.roundId || !!queryInput.programId const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: diversity, isLoading: diversityLoading } = const { data: diversity, isLoading: diversityLoading } =
@@ -1082,27 +987,6 @@ function DiversityTab() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Stage:</label>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Stages
</SelectItem>
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{diversityLoading && <Skeleton className="h-[400px]" />} {diversityLoading && <Skeleton className="h-[400px]" />}
{diversity && ( {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 { 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) => ({ ((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({
...s, ...s,
programId: p.id, 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 roundIds = rounds.map(r => r.id)
const { data: comparison, isLoading: comparisonLoading } = const { data: comparison, isLoading: comparisonLoading } =
@@ -1212,20 +1106,48 @@ function RoundPipelineTab() {
} }
export default function ReportsPage() { 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<string | null>(null) const [pdfStageId, setPdfStageId] = useState<string | null>(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(() => { useEffect(() => {
if (pdfStages.length && !pdfStageId) { if (stages.length && !pdfStageId) {
setPdfStageId(findDefaultRound(pdfStages) ?? pdfStages[0].id) 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -1237,6 +1159,35 @@ export default function ReportsPage() {
</p> </p>
</div> </div>
{/* Top-level round selector — drives every tab below */}
<Card>
<CardContent className="p-4">
<div className="flex flex-wrap items-center gap-3">
<label className="text-sm font-medium">Viewing:</label>
<Select value={scope ?? ''} onValueChange={setScope}>
<SelectTrigger className="w-[320px]">
<SelectValue placeholder="Select a round or edition" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
</SelectItem>
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} {stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
This selection applies to every tab except Cross-Round (which compares multiple rounds).
</p>
</div>
</CardContent>
</Card>
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue="overview" className="space-y-6"> <Tabs defaultValue="overview" className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4"> <div className="flex items-center justify-between flex-wrap gap-4">
@@ -1272,7 +1223,7 @@ export default function ReportsPage() {
<SelectValue placeholder="Select stage for PDF" /> <SelectValue placeholder="Select stage for PDF" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{pdfStages.map((stage) => ( {stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}> <SelectItem key={stage.id} value={stage.id}>
{stage.name} {stage.name}
</SelectItem> </SelectItem>
@@ -1290,11 +1241,11 @@ export default function ReportsPage() {
</div> </div>
<TabsContent value="overview"> <TabsContent value="overview">
<ReportsOverview /> <ReportsOverview scope={scope} />
</TabsContent> </TabsContent>
<TabsContent value="analytics"> <TabsContent value="analytics">
<StageAnalytics /> <StageAnalytics scope={scope} />
</TabsContent> </TabsContent>
<TabsContent value="cross-stage"> <TabsContent value="cross-stage">
@@ -1302,15 +1253,15 @@ export default function ReportsPage() {
</TabsContent> </TabsContent>
<TabsContent value="consistency"> <TabsContent value="consistency">
<JurorConsistencyTab /> <JurorConsistencyTab scope={scope} />
</TabsContent> </TabsContent>
<TabsContent value="diversity"> <TabsContent value="diversity">
<DiversityTab /> <DiversityTab scope={scope} />
</TabsContent> </TabsContent>
<TabsContent value="pipeline"> <TabsContent value="pipeline">
<RoundPipelineTab /> <RoundPipelineTab scope={scope} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>