Round system redesign: Phases 1-7 complete

Full pipeline/track/stage architecture replacing the legacy round system.

Schema: 11 new models (Pipeline, Track, Stage, StageTransition,
ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor,
OverrideAction, AudienceVoter) + 8 new enums.

Backend: 9 new routers (pipeline, stage, routing, stageFiltering,
stageAssignment, cohort, live, decision, award) + 6 new services
(stage-engine, routing-engine, stage-filtering, stage-assignment,
stage-notifications, live-control).

Frontend: Pipeline wizard (17 components), jury stage pages (7),
applicant pipeline pages (3), public stage pages (2), admin pipeline
pages (5), shared stage components (3), SSE route, live hook.

Phase 6 refit: 23 routers/services migrated from roundId to stageId,
all frontend components refitted. Deleted round.ts (985 lines),
roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx,
10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs.

Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing,
TypeScript 0 errors, Next.js build succeeds, 13 integrity checks,
legacy symbol sweep clean, auto-seed on first Docker startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 13:57:09 +01:00
parent 8a328357e3
commit 331b67dae0
256 changed files with 29117 additions and 21424 deletions

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -41,6 +42,7 @@ import {
GitCompare,
UserCheck,
Globe,
Layers,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
@@ -51,7 +53,7 @@ import {
ProjectRankingsChart,
CriteriaScoresChart,
GeographicDistribution,
CrossRoundComparisonChart,
CrossStageComparisonChart,
JurorConsistencyChart,
DiversityMetricsChart,
} from '@/components/charts'
@@ -59,11 +61,13 @@ import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container'
function ReportsOverview() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const { data: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery()
// Flatten rounds from all programs
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
// 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)
@@ -73,7 +77,7 @@ function ReportsOverview() {
}
const scopeInput = parseSelection(selectedValue)
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
const hasScope = !!scopeInput.stageId || !!scopeInput.programId
const { data: projectRankings, isLoading: projectsLoading } =
trpc.analytics.getProjectRankings.useQuery(
@@ -103,7 +107,7 @@ function ReportsOverview() {
const totalPrograms = dashStats?.programCount ?? programs?.length ?? 0
const totalProjects = dashStats?.projectCount ?? 0
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r) => r.status === 'ACTIVE').length
const activeRounds = dashStats?.activeStageCount ?? rounds.filter((r: { status: string }) => r.status === 'STAGE_ACTIVE').length
const jurorCount = dashStats?.jurorCount ?? 0
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
const totalEvaluations = dashStats?.totalEvaluations ?? 0
@@ -365,7 +369,7 @@ function ReportsOverview() {
<div className="flex justify-end gap-2 flex-wrap">
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/evaluations?roundId=${round.id}`}
href={`/api/export/evaluations?stageId=${round.id}`}
target="_blank"
rel="noopener noreferrer"
>
@@ -375,7 +379,7 @@ function ReportsOverview() {
</Button>
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/results?roundId=${round.id}`}
href={`/api/export/results?stageId=${round.id}`}
target="_blank"
rel="noopener noreferrer"
>
@@ -396,28 +400,30 @@ function ReportsOverview() {
)
}
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
// Parse selection value: "all:programId" for edition-wide, or stageId
function parseSelection(value: string | null): { stageId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { roundId: value }
return { stageId: value }
}
function RoundAnalytics() {
function StageAnalytics() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true })
// Flatten rounds from all programs with program name
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
// Flatten stages from all programs with program name
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
) || []
// Set default selected round
// Set default selected stage
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
@@ -458,11 +464,11 @@ function RoundAnalytics() {
const selectedRound = rounds.find((r) => r.id === selectedValue)
const geoInput = queryInput.programId
? { programId: queryInput.programId }
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
: { programId: selectedRound?.programId || '', stageId: queryInput.stageId }
const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
geoInput,
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
{ enabled: hasSelection && !!(geoInput.programId || geoInput.stageId) }
)
if (roundsLoading) {
@@ -600,26 +606,26 @@ function RoundAnalytics() {
)
}
function CrossRoundTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
function CrossStageTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const [selectedStageIds, setSelectedStageIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
trpc.analytics.getCrossStageComparison.useQuery(
{ stageIds: selectedStageIds },
{ enabled: selectedStageIds.length >= 2 }
)
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
const toggleStage = (stageId: string) => {
setSelectedStageIds((prev) =>
prev.includes(stageId)
? prev.filter((id) => id !== stageId)
: [...prev, stageId]
)
}
@@ -629,40 +635,40 @@ function CrossRoundTab() {
return (
<div className="space-y-6">
{/* Round selector */}
{/* Stage selector */}
<Card>
<CardHeader>
<CardTitle>Select Rounds to Compare</CardTitle>
<CardTitle>Select Stages to Compare</CardTitle>
<CardDescription>
Choose at least 2 rounds to compare metrics side by side
Choose at least 2 stages to compare metrics side by side
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{rounds.map((round) => {
const isSelected = selectedRoundIds.includes(round.id)
{stages.map((stage) => {
const isSelected = selectedStageIds.includes(stage.id)
return (
<Badge
key={round.id}
key={stage.id}
variant={isSelected ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(round.id)}
onClick={() => toggleStage(stage.id)}
>
{round.programName} - {round.name}
{stage.programName} - {stage.name}
</Badge>
)
})}
</div>
{selectedRoundIds.length < 2 && (
{selectedStageIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 rounds to enable comparison
Select at least 2 stages to enable comparison
</p>
)}
</CardContent>
</Card>
{/* Comparison charts */}
{comparisonLoading && selectedRoundIds.length >= 2 && (
{comparisonLoading && selectedStageIds.length >= 2 && (
<div className="space-y-6">
<Skeleton className="h-[350px]" />
<div className="grid gap-6 lg:grid-cols-2">
@@ -673,9 +679,9 @@ function CrossRoundTab() {
)}
{comparison && (
<CrossRoundComparisonChart data={comparison as Array<{
roundId: string
roundName: string
<CrossStageComparisonChart data={comparison as Array<{
stageId: string
stageName: string
projectCount: number
evaluationCount: number
completionRate: number
@@ -690,18 +696,18 @@ function CrossRoundTab() {
function JurorConsistencyTab() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
) || []
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const { data: consistency, isLoading: consistencyLoading } =
trpc.analytics.getJurorConsistency.useQuery(
@@ -716,20 +722,20 @@ function JurorConsistencyTab() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<label className="text-sm font-medium">Select Stage:</label>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
{p.year} Edition All Stages
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
</SelectItem>
))}
</SelectContent>
@@ -762,18 +768,18 @@ function JurorConsistencyTab() {
function DiversityTab() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
) || []
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const hasSelection = !!queryInput.stageId || !!queryInput.programId
const { data: diversity, isLoading: diversityLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
@@ -788,20 +794,20 @@ function DiversityTab() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<label className="text-sm font-medium">Select Stage:</label>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
{p.year} Edition All Stages
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
</SelectItem>
))}
</SelectContent>
@@ -826,18 +832,18 @@ function DiversityTab() {
}
export default function ReportsPage() {
const [pdfRoundId, setPdfRoundId] = useState<string | null>(null)
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeRounds: true })
const pdfRounds = pdfPrograms?.flatMap((p) =>
p.rounds.map((r) => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
const pdfStages = pdfPrograms?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
if (pdfRounds.length && !pdfRoundId) {
setPdfRoundId(pdfRounds[0].id)
if (pdfStages.length && !pdfStageId) {
setPdfStageId(pdfStages[0].id)
}
const selectedPdfRound = pdfRounds.find((r) => r.id === pdfRoundId)
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
return (
<div className="space-y-6">
@@ -861,7 +867,7 @@ export default function ReportsPage() {
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="cross-round" className="gap-2">
<TabsTrigger value="cross-stage" className="gap-2">
<GitCompare className="h-4 w-4" />
Cross-Round
</TabsTrigger>
@@ -873,25 +879,31 @@ export default function ReportsPage() {
<Globe className="h-4 w-4" />
Diversity
</TabsTrigger>
<TabsTrigger value="pipeline" className="gap-2" asChild>
<Link href={"/admin/reports/stages" as Route}>
<Layers className="h-4 w-4" />
By Pipeline
</Link>
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
<Select value={pdfRoundId || ''} onValueChange={setPdfRoundId}>
<Select value={pdfStageId || ''} onValueChange={setPdfStageId}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Select round for PDF" />
<SelectValue placeholder="Select stage for PDF" />
</SelectTrigger>
<SelectContent>
{pdfRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
{pdfStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
{pdfRoundId && (
{pdfStageId && (
<ExportPdfButton
roundId={pdfRoundId}
roundName={selectedPdfRound?.name}
programName={selectedPdfRound?.programName}
stageId={pdfStageId}
roundName={selectedPdfStage?.name}
programName={selectedPdfStage?.programName}
/>
)}
</div>
@@ -902,11 +914,11 @@ export default function ReportsPage() {
</TabsContent>
<TabsContent value="analytics">
<RoundAnalytics />
<StageAnalytics />
</TabsContent>
<TabsContent value="cross-round">
<CrossRoundTab />
<TabsContent value="cross-stage">
<CrossStageTab />
</TabsContent>
<TabsContent value="consistency">