Round system redesign: criteria voting, audience voting, pipeline view, and admin UX improvements

- Schema: Extend LiveVotingSession with votingMode, criteriaJson, audience fields;
  add AudienceVoter model; make LiveVote.userId nullable for audience voters
- Backend: Criteria-based voting with weighted scores, audience registration/voting
  with token-based dedup, configurable jury/audience weight in results
- Jury UI: Criteria scoring with per-criterion sliders alongside simple 1-10 mode
- Public audience voting page at /vote/[sessionId] with mobile-first design
- Admin live voting: Tabbed layout (Session/Config/Results), criteria config,
  audience settings, weight-adjustable results with tie detection
- Round type settings: Visual card selector replacing dropdown, feature tags
- Round detail page: Live event status section, type-specific stats and actions
- Round pipeline view: Horizontal visualization with bottleneck detection,
  List/Pipeline toggle on rounds page
- SSE: Separate jury/audience vote events, audience vote tracking
- Field visibility: Hide irrelevant fields per round type in create/edit forms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 14:27:49 +01:00
parent b5d90d3c26
commit 2a5fa463b3
14 changed files with 2518 additions and 456 deletions

View File

@@ -32,6 +32,7 @@ import {
type Criterion,
} from '@/components/forms/evaluation-form-builder'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ROUND_FIELD_VISIBILITY } from '@/types/round-settings'
import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor'
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar, LayoutTemplate } from 'lucide-react'
import {
@@ -202,17 +203,18 @@ function EditRoundContent({ roundId }: { roundId: string }) {
}, [evaluationForm, loadingForm, criteriaInitialized])
const onSubmit = async (data: UpdateRoundForm) => {
const visibility = ROUND_FIELD_VISIBILITY[roundType]
// Update round with type, settings, and notification
await updateRound.mutateAsync({
id: roundId,
name: data.name,
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
requiredReviews: visibility?.showRequiredReviews ? data.requiredReviews : 0,
minAssignmentsPerJuror: data.minAssignmentsPerJuror,
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
roundType,
settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? null,
votingEndAt: data.votingEndAt ?? null,
votingStartAt: visibility?.showVotingWindow ? (data.votingStartAt ?? null) : null,
votingEndAt: visibility?.showVotingWindow ? (data.votingEndAt ?? null) : null,
})
// Update evaluation form if criteria changed and no evaluations exist
@@ -301,7 +303,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
)}
/>
{roundType !== 'FILTERING' && (
{ROUND_FIELD_VISIBILITY[roundType]?.showRequiredReviews && (
<FormField
control={form.control}
name="requiredReviews"
@@ -328,6 +330,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
/>
)}
{ROUND_FIELD_VISIBILITY[roundType]?.showAssignmentLimits && (
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
@@ -379,6 +382,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
)}
/>
</div>
)}
</CardContent>
</Card>

File diff suppressed because it is too large Load Diff

View File

@@ -91,6 +91,7 @@ import {
ExternalLink,
} from 'lucide-react'
import { toast } from 'sonner'
import { ROUND_FIELD_VISIBILITY, roundTypeLabels } from '@/types/round-settings'
import { AnimatedCard } from '@/components/shared/animated-container'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
@@ -148,8 +149,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
// Progress data is now included in round.get response (eliminates duplicate evaluation.groupBy)
const progress = round?.progress
// Check if this is a filtering round - roundType is stored directly on the round
// Check round type
const isFilteringRound = round?.roundType === 'FILTERING'
const isLiveEventRound = round?.roundType === 'LIVE_EVENT'
// Filtering queries (only fetch for FILTERING rounds)
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
@@ -165,6 +167,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
{ roundId },
{ enabled: isFilteringRound }
)
// Live voting session (only fetch for LIVE_EVENT rounds)
const { data: liveSession } = trpc.liveVoting.getSession.useQuery(
{ roundId },
{ enabled: isLiveEventRound, staleTime: 30_000 }
)
const { data: latestJob, refetch: refetchLatestJob } =
trpc.filtering.getLatestJob.useQuery(
{ roundId },
@@ -398,6 +406,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
)
}
const visibility = ROUND_FIELD_VISIBILITY[round.roundType] || ROUND_FIELD_VISIBILITY.EVALUATION
const isLiveEvent = isLiveEventRound
const now = new Date()
const isVotingOpen =
round.status === 'ACTIVE' &&
@@ -462,6 +473,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{round.name}</h1>
{getStatusBadge()}
<Badge variant="outline" className="text-xs">
{roundTypeLabels[round.roundType] || round.roundType}
</Badge>
</div>
</div>
@@ -577,6 +591,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
{visibility.showAssignmentLimits && (
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Judge Assignments</CardTitle>
@@ -593,7 +608,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</Button>
</CardContent>
</Card>
)}
{visibility.showRequiredReviews && (
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
@@ -606,28 +623,48 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
<p className="text-xs text-muted-foreground">per project</p>
</CardContent>
</Card>
)}
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completion</CardTitle>
<CardTitle className="text-sm font-medium">
{isLiveEvent ? 'Session' : 'Completion'}
</CardTitle>
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
{isLiveEvent ? (
<Zap className="h-4 w-4 text-brand-teal" />
) : (
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
)}
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{progress?.completionPercentage || 0}%
</div>
<p className="text-xs text-muted-foreground">
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0}
</p>
{isLiveEvent && liveSession ? (
<>
<div className="text-2xl font-bold capitalize">
{liveSession.status === 'IN_PROGRESS' ? 'Live' : liveSession.status.toLowerCase().replace('_', ' ')}
</div>
<p className="text-xs text-muted-foreground">
{liveSession.audienceVoterCount} audience voters
</p>
</>
) : (
<>
<div className="text-2xl font-bold">
{progress?.completionPercentage || 0}%
</div>
<p className="text-xs text-muted-foreground">
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0}
</p>
</>
)}
</CardContent>
</Card>
</div>
</AnimatedCard>
{/* Progress */}
{progress && progress.totalAssignments > 0 && (
{/* Progress - only for evaluation rounds */}
{visibility.showRequiredReviews && progress && progress.totalAssignments > 0 && (
<AnimatedCard index={1}>
<Card>
<CardHeader>
@@ -662,7 +699,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</AnimatedCard>
)}
{/* Voting Window */}
{/* Voting Window - only for evaluation rounds */}
{visibility.showVotingWindow && (
<AnimatedCard index={2}>
<Card>
<CardHeader>
@@ -759,6 +797,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Filtering Section (for FILTERING rounds) */}
{isFilteringRound && (
@@ -1268,8 +1307,133 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</AnimatedCard>
)}
{/* Live Event Section (for LIVE_EVENT rounds) */}
{isLiveEventRound && liveSession && (
<AnimatedCard index={3}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Zap className="h-4 w-4 text-violet-500" />
</div>
Live Voting Session
</CardTitle>
<CardDescription>
Real-time voting during project presentations
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/${round.id}/live-voting`}>
<ExternalLink className="mr-2 h-4 w-4" />
Open Dashboard
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Session Status */}
<div className="grid gap-4 sm:grid-cols-4">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
{liveSession.status === 'IN_PROGRESS' ? (
<Play className="h-5 w-5 text-green-600" />
) : liveSession.status === 'COMPLETED' ? (
<CheckCircle2 className="h-5 w-5 text-blue-600" />
) : (
<Clock className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div>
<p className="text-sm font-medium capitalize">
{liveSession.status === 'IN_PROGRESS' ? 'Live Now' : liveSession.status.toLowerCase().replace('_', ' ')}
</p>
<p className="text-xs text-muted-foreground">Status</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
<FileText className="h-5 w-5 text-emerald-500" />
</div>
<div>
<p className="text-2xl font-bold">{liveSession.round.projects.length}</p>
<p className="text-xs text-muted-foreground">Projects</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
<BarChart3 className="h-5 w-5 text-blue-500" />
</div>
<div>
<p className="text-2xl font-bold">{liveSession.currentVotes.length}</p>
<p className="text-xs text-muted-foreground">Jury Votes</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-background">
<Users className="h-5 w-5 text-violet-500" />
</div>
<div>
<p className="text-2xl font-bold">{liveSession.audienceVoterCount}</p>
<p className="text-xs text-muted-foreground">Audience</p>
</div>
</div>
</div>
{/* Current Status Indicator */}
{liveSession.status === 'IN_PROGRESS' && liveSession.currentProjectId && (
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900">
<div className="flex items-center gap-3">
<div className="h-3 w-3 rounded-full bg-green-500 animate-pulse" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">
Voting in progress
</p>
<p className="text-sm text-green-700 dark:text-green-300">
Project {(liveSession.currentProjectIndex ?? 0) + 1} of {liveSession.round.projects.length}
</p>
</div>
</div>
</div>
)}
{liveSession.status === 'COMPLETED' && (
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-blue-600" />
<p className="font-medium text-blue-900 dark:text-blue-100">
Voting session completed - view results in the dashboard
</p>
</div>
</div>
)}
{/* Quick Links */}
<div className="flex flex-wrap gap-3 pt-2 border-t">
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/live-voting`}>
<Zap className="mr-2 h-4 w-4" />
Session Dashboard
</Link>
</Button>
{liveSession.allowAudienceVotes && (
<Button variant="outline" asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={`/vote/${liveSession.id}` as any}>
<QrCode className="mr-2 h-4 w-4" />
Audience Voting Page
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Quick Actions */}
<AnimatedCard index={4}>
<AnimatedCard index={isFilteringRound || isLiveEventRound ? 5 : 4}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
@@ -1311,44 +1475,70 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</div>
</div>
{/* Round Management */}
{/* Type-Specific Management */}
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Round Management</p>
<p className="text-sm font-medium text-muted-foreground mb-2">
{isFilteringRound ? 'Filtering' : isLiveEvent ? 'Live Event' : 'Evaluation'} Management
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Jury Assignments
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${round.id}/live-voting`}>
<Zap className="mr-2 h-4 w-4" />
Live Voting
</Link>
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
disabled={bulkSummaries.isPending}
>
{bulkSummaries.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileSearch className="mr-2 h-4 w-4" />
)}
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Filtering-specific actions */}
{isFilteringRound && (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${round.id}/filtering/rules`}>
<ListChecks className="mr-2 h-4 w-4" />
View Rules
</Link>
</Button>
</>
)}
{/* Evaluation-specific actions */}
{visibility.showAssignmentLimits && (
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Jury Assignments
</Link>
</Button>
)}
{/* Live Event-specific actions */}
{isLiveEvent && (
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${round.id}/live-voting`}>
<Zap className="mr-2 h-4 w-4" />
Open Live Session
</Link>
</Button>
)}
{/* Evaluation-round-only: AI Summaries */}
{!isFilteringRound && !isLiveEvent && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
disabled={bulkSummaries.isPending}
>
{bulkSummaries.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileSearch className="mr-2 h-4 w-4" />
)}
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
variant="outline"
size="sm"

View File

@@ -34,6 +34,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ROUND_FIELD_VISIBILITY } from '@/types/round-settings'
import { ArrowLeft, Loader2, AlertCircle, Bell, LayoutTemplate } from 'lucide-react'
import { toast } from 'sonner'
import { DateTimePicker } from '@/components/ui/datetime-picker'
@@ -124,14 +125,15 @@ function CreateRoundContent() {
})
const onSubmit = async (data: CreateRoundForm) => {
const visibility = ROUND_FIELD_VISIBILITY[roundType]
await createRound.mutateAsync({
programId: data.programId,
name: data.name,
roundType,
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
requiredReviews: visibility?.showRequiredReviews ? data.requiredReviews : 0,
settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? undefined,
votingEndAt: data.votingEndAt ?? undefined,
votingStartAt: visibility?.showVotingWindow ? (data.votingStartAt ?? undefined) : undefined,
votingEndAt: visibility?.showVotingWindow ? (data.votingEndAt ?? undefined) : undefined,
entryNotificationType: entryNotificationType || undefined,
})
}
@@ -291,7 +293,7 @@ function CreateRoundContent() {
)}
/>
{roundType !== 'FILTERING' && (
{ROUND_FIELD_VISIBILITY[roundType]?.showRequiredReviews && (
<FormField
control={form.control}
name="requiredReviews"
@@ -326,6 +328,7 @@ function CreateRoundContent() {
onSettingsChange={setRoundSettings}
/>
{ROUND_FIELD_VISIBILITY[roundType]?.showVotingWindow && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
@@ -377,6 +380,7 @@ function CreateRoundContent() {
</p>
</CardContent>
</Card>
)}
{/* Team Notification */}
<Card>

View File

@@ -63,10 +63,13 @@ import {
Loader2,
GripVertical,
ArrowRight,
List,
GitBranchPlus,
} from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns'
import { cn } from '@/lib/utils'
import { AnimatedCard } from '@/components/shared/animated-container'
import { RoundPipeline } from '@/components/admin/round-pipeline'
type RoundData = {
id: string
@@ -81,7 +84,7 @@ type RoundData = {
}
}
function RoundsContent() {
function RoundsContent({ viewMode }: { viewMode: 'list' | 'pipeline' }) {
const { data: programs, isLoading } = trpc.program.list.useQuery({
includeRounds: true,
})
@@ -107,6 +110,45 @@ function RoundsContent() {
)
}
if (viewMode === 'pipeline') {
return (
<div className="space-y-6">
{programs.map((program, index) => (
<AnimatedCard key={program.id} index={index}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{program.year} Edition</CardTitle>
<CardDescription>
{program.name} - {program.status}
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?program=${program.id}`}>
<Plus className="mr-2 h-4 w-4" />
Add Round
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{(program.rounds && program.rounds.length > 0) ? (
<RoundPipeline rounds={program.rounds} programName={program.name} />
) : (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No rounds created yet</p>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
))}
</div>
)
}
return (
<div className="space-y-6">
{programs.map((program, index) => (
@@ -669,6 +711,8 @@ function RoundsListSkeleton() {
}
export default function RoundsPage() {
const [viewMode, setViewMode] = useState<'list' | 'pipeline'>('list')
return (
<div className="space-y-6">
{/* Header */}
@@ -679,11 +723,31 @@ export default function RoundsPage() {
Manage selection rounds and voting periods
</p>
</div>
<div className="flex items-center gap-1 rounded-lg border p-1">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className="h-8 px-3"
onClick={() => setViewMode('list')}
>
<List className="mr-1.5 h-4 w-4" />
List
</Button>
<Button
variant={viewMode === 'pipeline' ? 'default' : 'ghost'}
size="sm"
className="h-8 px-3"
onClick={() => setViewMode('pipeline')}
>
<GitBranchPlus className="mr-1.5 h-4 w-4" />
Pipeline
</Button>
</div>
</div>
{/* Content */}
<Suspense fallback={<RoundsListSkeleton />}>
<RoundsContent />
<RoundsContent viewMode={viewMode} />
</Suspense>
</div>
)