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:
@@ -47,32 +47,32 @@ import {
|
||||
JurorWorkloadChart,
|
||||
ProjectRankingsChart,
|
||||
CriteriaScoresChart,
|
||||
CrossRoundComparisonChart,
|
||||
CrossStageComparisonChart,
|
||||
JurorConsistencyChart,
|
||||
DiversityMetricsChart,
|
||||
} from '@/components/charts'
|
||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
// 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 OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({
|
||||
...r,
|
||||
const stages = programs?.flatMap(p =>
|
||||
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
|
||||
...s,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
|
||||
const { data: overviewStats, isLoading: statsLoading } =
|
||||
trpc.analytics.getOverviewStats.useQuery(
|
||||
@@ -100,8 +100,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
const totalProjects = rounds.reduce((acc, r) => acc + (r._count?.projects || 0), 0)
|
||||
const activeRounds = rounds.filter((r) => r.status === 'ACTIVE').length
|
||||
const totalProjects = stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0)
|
||||
const activeStages = stages.filter((s) => s.status === 'STAGE_ACTIVE').length
|
||||
const totalPrograms = programs?.length || 0
|
||||
|
||||
return (
|
||||
@@ -113,10 +113,10 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Rounds</p>
|
||||
<p className="text-2xl font-bold mt-1">{rounds.length}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Stages</p>
|
||||
<p className="text-2xl font-bold mt-1">{stages.length}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeRounds} active
|
||||
{activeStages} active
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
@@ -134,7 +134,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across all rounds</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across all stages</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<ClipboardList className="h-5 w-5 text-emerald-600" />
|
||||
@@ -149,8 +149,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Active Rounds</p>
|
||||
<p className="text-2xl font-bold mt-1">{activeRounds}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">Active Stages</p>
|
||||
<p className="text-2xl font-bold mt-1">{activeStages}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Currently active</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
@@ -251,48 +251,48 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Rounds Table - Desktop */}
|
||||
{/* Stages Table - Desktop */}
|
||||
<Card className="hidden md:block">
|
||||
<CardHeader>
|
||||
<CardTitle>Round Reports</CardTitle>
|
||||
<CardDescription>Progress overview for each round</CardDescription>
|
||||
<CardTitle>Stage Reports</CardTitle>
|
||||
<CardDescription>Progress overview for each stage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead>Projects</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rounds.map((round) => (
|
||||
<TableRow key={round.id}>
|
||||
{stages.map((stage) => (
|
||||
<TableRow key={stage.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{round.name}</p>
|
||||
{round.votingEndAt && (
|
||||
<p className="font-medium">{stage.name}</p>
|
||||
{stage.windowCloseAt && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ends: {formatDateOnly(round.votingEndAt)}
|
||||
Ends: {formatDateOnly(stage.windowCloseAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{round.programName}</TableCell>
|
||||
<TableCell>{round._count?.projects || '-'}</TableCell>
|
||||
<TableCell>{stage.programName}</TableCell>
|
||||
<TableCell>{stage._count?.projects || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
round.status === 'ACTIVE'
|
||||
stage.status === 'STAGE_ACTIVE'
|
||||
? 'default'
|
||||
: round.status === 'CLOSED'
|
||||
: stage.status === 'STAGE_CLOSED'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{round.status}
|
||||
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -302,34 +302,34 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Rounds Cards - Mobile */}
|
||||
{/* Stages Cards - Mobile */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
<h2 className="text-lg font-semibold">Round Reports</h2>
|
||||
{rounds.map((round) => (
|
||||
<Card key={round.id}>
|
||||
<h2 className="text-lg font-semibold">Stage Reports</h2>
|
||||
{stages.map((stage) => (
|
||||
<Card key={stage.id}>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium">{round.name}</p>
|
||||
<p className="font-medium">{stage.name}</p>
|
||||
<Badge
|
||||
variant={
|
||||
round.status === 'ACTIVE'
|
||||
stage.status === 'STAGE_ACTIVE'
|
||||
? 'default'
|
||||
: round.status === 'CLOSED'
|
||||
: stage.status === 'STAGE_CLOSED'
|
||||
? 'secondary'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{round.status}
|
||||
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{round.programName}</p>
|
||||
{round.votingEndAt && (
|
||||
<p className="text-sm text-muted-foreground">{stage.programName}</p>
|
||||
{stage.windowCloseAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ends: {formatDateOnly(round.votingEndAt)}
|
||||
Ends: {formatDateOnly(stage.windowCloseAt)}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-sm">
|
||||
<span>{round._count?.projects || 0} projects</span>
|
||||
<span>{stage._count?.projects || 0} projects</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -341,7 +341,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
|
||||
function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
|
||||
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(
|
||||
@@ -455,26 +455,26 @@ function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
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: 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]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -484,35 +484,35 @@ function CrossRoundTab() {
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Rounds to Compare</CardTitle>
|
||||
<CardDescription>Choose at least 2 rounds</CardDescription>
|
||||
<CardTitle>Select Stages to Compare</CardTitle>
|
||||
<CardDescription>Choose at least 2 stages</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rounds.map((round) => (
|
||||
{stages.map((stage) => (
|
||||
<Badge
|
||||
key={round.id}
|
||||
variant={selectedRoundIds.includes(round.id) ? 'default' : 'outline'}
|
||||
key={stage.id}
|
||||
variant={selectedStageIds.includes(stage.id) ? '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>
|
||||
|
||||
{comparisonLoading && selectedRoundIds.length >= 2 && <Skeleton className="h-[350px]" />}
|
||||
{comparisonLoading && selectedStageIds.length >= 2 && <Skeleton className="h-[350px]" />}
|
||||
|
||||
{comparison && (
|
||||
<CrossRoundComparisonChart data={comparison as Array<{
|
||||
roundId: string; roundName: string; projectCount: number; evaluationCount: number
|
||||
<CrossStageComparisonChart data={comparison as Array<{
|
||||
stageId: string; stageName: string; projectCount: number; evaluationCount: number
|
||||
completionRate: number; averageScore: number | null
|
||||
scoreDistribution: { score: number; count: number }[]
|
||||
}>} />
|
||||
@@ -523,7 +523,7 @@ function CrossRoundTab() {
|
||||
|
||||
function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(
|
||||
@@ -551,7 +551,7 @@ function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
|
||||
|
||||
function DiversityTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
|
||||
const { data: diversity, isLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery(
|
||||
@@ -579,23 +579,23 @@ function DiversityTab({ selectedValue }: { selectedValue: string }) {
|
||||
export default function ObserverReportsPage() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({
|
||||
...r,
|
||||
const stages = programs?.flatMap(p =>
|
||||
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
|
||||
...s,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
||||
// Set default selected round
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
// Set default selected stage
|
||||
if (stages.length && !selectedValue) {
|
||||
setSelectedValue(stages[0].id)
|
||||
}
|
||||
|
||||
const hasSelection = !!selectedValue
|
||||
const selectedRound = rounds.find((r) => r.id === selectedValue)
|
||||
const selectedStage = stages.find((s) => s.id === selectedValue)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -607,31 +607,31 @@ export default function ObserverReportsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Round Selector */}
|
||||
{/* Stage Selector */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
{roundsLoading ? (
|
||||
<label className="text-sm font-medium">Select Stage:</label>
|
||||
{stagesLoading ? (
|
||||
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
||||
) : rounds.length > 0 ? (
|
||||
) : stages.length > 0 ? (
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-full sm: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>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No rounds available</p>
|
||||
<p className="text-sm text-muted-foreground">No stages available</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -647,7 +647,7 @@ export default function ObserverReportsPage() {
|
||||
<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>
|
||||
@@ -662,9 +662,9 @@ export default function ObserverReportsPage() {
|
||||
</TabsList>
|
||||
{selectedValue && !selectedValue.startsWith('all:') && (
|
||||
<ExportPdfButton
|
||||
roundId={selectedValue}
|
||||
roundName={selectedRound?.name}
|
||||
programName={selectedRound?.programName}
|
||||
stageId={selectedValue}
|
||||
roundName={selectedStage?.name}
|
||||
programName={selectedStage?.programName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -689,8 +689,8 @@ export default function ObserverReportsPage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cross-round">
|
||||
<CrossRoundTab />
|
||||
<TabsContent value="cross-stage">
|
||||
<CrossStageTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="consistency">
|
||||
|
||||
Reference in New Issue
Block a user