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

@@ -194,7 +194,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.round.program.name} - {data.session.round.name}
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -257,7 +257,7 @@ function AudienceVotingContent({ sessionId }: { sessionId: string }) {
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.round.program.name} - {data.session.round.name}
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
</CardDescription>
</CardHeader>

View File

@@ -0,0 +1,215 @@
'use client'
import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Star,
CheckCircle2,
AlertCircle,
RefreshCw,
Waves,
} from 'lucide-react'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export default function StageAudienceVotePage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [hasVoted, setHasVoted] = useState(false)
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
const castVoteMutation = trpc.live.castStageVote.useMutation({
onSuccess: () => {
toast.success('Your vote has been recorded!')
setHasVoted(true)
setLastVotedProjectId(activeProject?.id ?? null)
setSelectedScore(null)
},
onError: (err) => {
toast.error(err.message)
},
})
// Reset vote state when project changes
if (activeProject?.id && activeProject.id !== lastVotedProjectId) {
if (hasVoted) {
setHasVoted(false)
setSelectedScore(null)
}
}
const handleVote = () => {
if (!activeProject || selectedScore === null) return
castVoteMutation.mutate({
sessionId,
projectId: activeProject.id,
score: selectedScore,
})
}
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
<div className="mx-auto max-w-lg px-4 py-8 space-y-6">
{/* MOPC branding header */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2">
<Waves className="h-8 w-8 text-brand-blue" />
<h1 className="text-2xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Vote
</h1>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success" className="text-xs">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
)}
</div>
</div>
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{sseError}</p>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Retry
</Button>
</CardContent>
</Card>
)}
{/* Paused state */}
{isPaused ? (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Pause className="h-16 w-16 text-amber-600 mb-4" />
<p className="text-xl font-semibold">Voting Paused</p>
<p className="text-sm text-muted-foreground mt-2">
Please wait for the next project...
</p>
</CardContent>
</Card>
) : activeProject ? (
<>
{/* Active project card */}
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm text-center">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Voting controls */}
<Card>
<CardContent className="py-6 space-y-6">
{hasVoted ? (
<div className="flex flex-col items-center py-8 text-center">
<CheckCircle2 className="h-16 w-16 text-emerald-600 mb-4" />
<p className="text-xl font-semibold">Thank you!</p>
<p className="text-sm text-muted-foreground mt-2">
Your vote has been recorded. Waiting for the next project...
</p>
</div>
) : (
<>
<p className="text-center text-sm font-medium text-muted-foreground">
Rate this project from 1 to 10
</p>
<div className="grid grid-cols-5 gap-3">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
className={cn(
'h-14 text-xl font-bold tabular-nums',
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light scale-110'
)}
onClick={() => setSelectedScore(score)}
>
{score}
</Button>
))}
</div>
<Button
className="w-full h-14 text-lg bg-brand-blue hover:bg-brand-blue-light"
disabled={selectedScore === null || castVoteMutation.isPending}
onClick={handleVote}
>
{castVoteMutation.isPending ? (
'Submitting...'
) : selectedScore !== null ? (
<>
<Star className="mr-2 h-5 w-5" />
Vote {selectedScore}/10
</>
) : (
'Select a score to vote'
)}
</Button>
</>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Star className="h-16 w-16 text-muted-foreground/30 mb-4" />
<p className="text-xl font-semibold">Waiting...</p>
<p className="text-sm text-muted-foreground mt-2">
The next project will appear here shortly.
</p>
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge
</p>
</div>
</div>
)
}