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'
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<string | null>(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
</CardTitle>
<CardDescription>
Summary dashboard optionally filter to a specific round
{selectedScopeLabel}
</CardDescription>
</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>
</CardHeader>
<CardContent className="space-y-5">
@@ -550,9 +531,7 @@ function findDefaultRound(rounds: Array<{ id: string; status?: string }>): strin
return rounds[0]?.id
}
function StageAnalytics() {
const [selectedValue, setSelectedValue] = useState<string | null>(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 (
<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 && (
<div className="space-y-6">
{/* Row 1: Score Distribution & Status Breakdown */}
@@ -838,22 +788,10 @@ function CrossStageTab() {
)
}
function JurorConsistencyTab() {
const [selectedValue, setSelectedValue] = useState<string | null>(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 (
<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]" />}
{consistency && (
@@ -1052,22 +969,10 @@ function JurorCalibrationPanel({ roundId }: { roundId: string }) {
)
}
function DiversityTab() {
const [selectedValue, setSelectedValue] = useState<string | null>(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 (
<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]" />}
{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<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(() => {
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 (
<div className="space-y-6">
@@ -1237,6 +1159,35 @@ export default function ReportsPage() {
</p>
</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 defaultValue="overview" className="space-y-6">
<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" />
</SelectTrigger>
<SelectContent>
{pdfStages.map((stage) => (
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
@@ -1290,11 +1241,11 @@ export default function ReportsPage() {
</div>
<TabsContent value="overview">
<ReportsOverview />
<ReportsOverview scope={scope} />
</TabsContent>
<TabsContent value="analytics">
<StageAnalytics />
<StageAnalytics scope={scope} />
</TabsContent>
<TabsContent value="cross-stage">
@@ -1302,15 +1253,15 @@ export default function ReportsPage() {
</TabsContent>
<TabsContent value="consistency">
<JurorConsistencyTab />
<JurorConsistencyTab scope={scope} />
</TabsContent>
<TabsContent value="diversity">
<DiversityTab />
<DiversityTab scope={scope} />
</TabsContent>
<TabsContent value="pipeline">
<RoundPipelineTab />
<RoundPipelineTab scope={scope} />
</TabsContent>
</Tabs>
</div>