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

@@ -8,13 +8,13 @@ import { Button } from '@/components/ui/button'
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
import { toast } from 'sonner'
export default function RoundApplyPage() {
export default function StageApplyPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ slug, mode: 'round' },
{ slug, mode: 'stage' },
{ retry: false }
)
@@ -30,7 +30,7 @@ export default function RoundApplyPage() {
)
}
if (error || !config || config.mode !== 'round') {
if (error || !config || config.mode !== 'stage') {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
<div className="text-center">
@@ -45,17 +45,17 @@ export default function RoundApplyPage() {
return (
<ApplyWizardDynamic
mode="round"
mode="stage"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
programName={config.program.name}
programYear={config.program.year}
roundId={config.round.id}
isOpen={config.round.isOpen}
submissionDeadline={config.round.submissionEndDate}
stageId={config.stage.id}
isOpen={config.stage.isOpen}
submissionDeadline={config.stage.submissionEndDate}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'round',
roundId: config.round.id,
mode: 'stage',
stageId: config.stage.id,
data: data as any,
})
}}

View File

@@ -0,0 +1,267 @@
'use client'
import { use, useState, useCallback } from 'react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Trophy,
Star,
RefreshCw,
Waves,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { trpc } from '@/lib/trpc/client'
export default function StageScoreboardPage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
// Fetch audience context for stage info and cohort data
const { data: context } = trpc.live.getAudienceContext.useQuery(
{ sessionId },
{ refetchInterval: 5000 }
)
const stageInfo = context?.stageInfo
// Fetch scores by querying cohort projects + their votes
// We use getAudienceContext.openCohorts to get project IDs, then aggregate
const openCohorts = context?.openCohorts ?? []
const allProjectIds = openCohorts.flatMap(
(c: { projectIds?: string[] }) => c.projectIds ?? []
)
const uniqueProjectIds = [...new Set(allProjectIds)]
// For live scores, we poll the audience context and compute from the cursor data
// The getAudienceContext returns projects with vote data when available
const projectScores = (context as Record<string, unknown>)?.projectScores as
| Array<{
projectId: string
title: string
teamName?: string | null
averageScore: number
voteCount: number
}>
| undefined
// Sort projects by average score descending
const sortedProjects = [...(projectScores ?? [])].sort(
(a, b) => b.averageScore - a.averageScore
)
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore), 1)
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/10 to-background">
<div className="mx-auto max-w-3xl px-4 py-8 space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-3">
<Waves className="h-10 w-10 text-brand-blue" />
<div>
<h1 className="text-3xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Scores
</h1>
{stageInfo && (
<p className="text-sm text-muted-foreground">{stageInfo.name}</p>
)}
</div>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<div className="flex items-center gap-2">
<Badge variant="destructive">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Reconnect
</Button>
</div>
)}
</div>
</div>
{/* Paused state */}
{isPaused && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex items-center justify-center gap-3 py-6">
<Pause className="h-8 w-8 text-amber-600" />
<p className="text-lg font-semibold text-amber-700 dark:text-amber-300">
Session Paused
</p>
</CardContent>
</Card>
)}
{/* Current project highlight */}
{activeProject && !isPaused && (
<Card className="overflow-hidden border-2 border-brand-blue/30 dark:border-brand-teal/30">
<div className="h-1.5 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center pb-2">
<div className="flex items-center justify-center gap-2 text-brand-teal text-xs uppercase tracking-wide mb-1">
<Clock className="h-3 w-3" />
Now Presenting
</div>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent className="text-center">
<p className="text-sm">{activeProject.description}</p>
</CardContent>
)}
</Card>
)}
{/* Leaderboard / Rankings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-amber-500" />
Rankings
</CardTitle>
</CardHeader>
<CardContent>
{sortedProjects.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">
{uniqueProjectIds.length === 0
? 'Waiting for presentations to begin...'
: 'No scores yet. Votes will appear here in real-time.'}
</p>
</div>
) : (
<div className="space-y-3">
{sortedProjects.map((project, index) => {
const isCurrent = project.projectId === activeProject?.id
return (
<div
key={project.projectId}
className={`rounded-lg p-4 transition-all duration-300 ${
isCurrent
? 'bg-brand-blue/5 border border-brand-blue/30 dark:bg-brand-teal/5 dark:border-brand-teal/30'
: 'bg-muted/50'
}`}
>
<div className="flex items-center gap-4">
{/* Rank */}
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
{index === 0 ? (
<Trophy className="h-4 w-4 text-amber-500" />
) : index === 1 ? (
<span className="font-bold text-gray-400">2</span>
) : index === 2 ? (
<span className="font-bold text-amber-600">3</span>
) : (
<span className="font-bold text-muted-foreground">
{index + 1}
</span>
)}
</div>
{/* Project info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{/* Score */}
<div className="shrink-0 text-right">
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-amber-500" />
<span className="text-xl font-bold tabular-nums">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
<p className="text-xs text-muted-foreground">
{project.voteCount} vote{project.voteCount !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Progress bar */}
<div className="mt-3">
<Progress
value={
project.averageScore
? (project.averageScore / maxScore) * 100
: 0
}
className="h-2"
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Waiting state */}
{!activeProject && !isPaused && sortedProjects.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Trophy className="h-16 w-16 text-amber-500/30 mb-4" />
<p className="text-xl font-semibold">Waiting for presentations</p>
<p className="text-sm text-muted-foreground mt-2">
Scores will appear here as projects are presented.
</p>
</CardContent>
</Card>
)}
{/* SSE error */}
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3 text-sm text-destructive">
{sseError}
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge &middot; Live Scoreboard
</p>
</div>
</div>
)
}

View File

@@ -20,7 +20,6 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { StatusTracker } from '@/components/shared/status-tracker'
import { MentorChat } from '@/components/shared/mentor-chat'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import {
ArrowLeft,
FileText,
@@ -151,7 +150,7 @@ export function SubmissionDetailClient() {
</Badge>
</div>
<p className="text-muted-foreground">
{project.round?.program?.year ? `${project.round.program.year} Edition` : ''}{project.round?.name ? ` - ${project.round.name}` : ''}
{project.program?.year ? `${project.program.year} Edition` : ''}{project.program?.name ? ` - ${project.program.name}` : ''}
</p>
</div>
</div>
@@ -322,17 +321,6 @@ export function SubmissionDetailClient() {
{/* Documents Tab */}
<TabsContent value="documents">
{/* File Requirements Upload Slots */}
{project.roundId && (
<div className="mb-4">
<RequirementUploadList
projectId={project.id}
roundId={project.roundId}
disabled={!isDraft}
/>
</div>
)}
<Card>
<CardHeader>
<CardTitle>Uploaded Documents</CardTitle>
@@ -349,7 +337,7 @@ export function SubmissionDetailClient() {
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
return (
<div

View File

@@ -27,7 +27,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { RequirementUploadList } from '@/components/shared/requirement-upload-slot'
import {
Dialog,
DialogContent,
@@ -410,23 +409,7 @@ export default function TeamManagementPage() {
</CardContent>
</Card>
{/* Team Documents */}
{teamData?.roundId && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Team Documents</CardTitle>
<CardDescription>
Upload required documents for your project. Any team member can upload files.
</CardDescription>
</CardHeader>
<CardContent>
<RequirementUploadList
projectId={projectId}
roundId={teamData.roundId}
/>
</CardContent>
</Card>
)}
{/* Team Documents - available via documents page */}
{/* Info Card */}
<Card className="bg-muted/50">

View File

@@ -133,8 +133,8 @@ export function MySubmissionClient() {
<div className="space-y-4">
{submissions.map((project) => {
const projectStatus = project.status ?? 'SUBMITTED'
const roundName = project.round?.name
const programYear = project.round?.program?.year
const programName = project.program?.name
const programYear = project.program?.year
return (
<Card key={project.id}>
@@ -143,7 +143,7 @@ export function MySubmissionClient() {
<div>
<CardTitle className="text-lg">{project.title}</CardTitle>
<CardDescription>
{programYear ? `${programYear} Edition` : ''}{roundName ? ` - ${roundName}` : ''}
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</CardDescription>
</div>
<Badge variant={statusColors[projectStatus] || 'secondary'}>

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>
)
}