Round system redesign: criteria voting, audience voting, pipeline view, and admin UX improvements
Build and Push Docker Image / build (push) Successful in 10m53s Details

- 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:
Matt 2026-02-12 14:27:49 +01:00
parent b5d90d3c26
commit 2a5fa463b3
14 changed files with 2518 additions and 456 deletions

View File

@ -1059,39 +1059,77 @@ model LiveVotingSession {
votingEndsAt DateTime?
projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order
// Criteria-based voting
votingMode String @default("simple") // "simple" (1-10) | "criteria" (per-criterion scores)
criteriaJson Json? @db.JsonB // Array of { id, label, description, scale, weight }
// Audience & presentation settings
allowAudienceVotes Boolean @default(false)
audienceVoteWeight Float @default(0) // 0.0 to 1.0
tieBreakerMethod String @default("admin_decides") // 'admin_decides' | 'highest_individual' | 'revote'
presentationSettingsJson Json? @db.JsonB
// Audience voting configuration
audienceVotingMode String @default("disabled") // "disabled" | "per_project" | "per_category" | "favorites"
audienceMaxFavorites Int @default(3) // For "favorites" mode
audienceRequireId Boolean @default(false) // Require email/phone for audience
audienceVotingDuration Int? // Minutes (null = same as jury)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
votes LiveVote[]
audienceVoters AudienceVoter[]
@@index([status])
}
model LiveVote {
id String @id @default(cuid())
sessionId String
projectId String
userId String
score Int // 1-10
isAudienceVote Boolean @default(false)
votedAt DateTime @default(now())
id String @id @default(cuid())
sessionId String
projectId String
userId String? // Nullable for audience voters without accounts
score Int // 1-10 (or weighted score for criteria mode)
isAudienceVote Boolean @default(false)
votedAt DateTime @default(now())
// Criteria scores (used when votingMode="criteria")
criterionScoresJson Json? @db.JsonB // { [criterionId]: score } - null for simple mode
// Audience voter link
audienceVoterId String?
// Relations
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
audienceVoter AudienceVoter? @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade)
@@unique([sessionId, projectId, userId])
@@unique([sessionId, projectId, audienceVoterId])
@@index([sessionId])
@@index([projectId])
@@index([userId])
@@index([audienceVoterId])
}
model AudienceVoter {
id String @id @default(cuid())
sessionId String
token String @unique // Unique voting token (UUID)
identifier String? // Optional: email, phone, or name
identifierType String? // "email" | "phone" | "name" | "anonymous"
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
// Relations
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
votes LiveVote[]
@@index([sessionId])
@@index([token])
}
// =============================================================================

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

View File

@ -14,9 +14,11 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { Slider } from '@/components/ui/slider'
import { toast } from 'sonner'
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff } from 'lucide-react'
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff, Send } from 'lucide-react'
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
import type { LiveVotingCriterion } from '@/types/round-settings'
interface PageProps {
params: Promise<{ sessionId: string }>
@ -26,6 +28,7 @@ const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
function JuryVotingContent({ sessionId }: { sessionId: string }) {
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [criterionScores, setCriterionScores] = useState<Record<string, number>>({})
const [countdown, setCountdown] = useState<number | null>(null)
// Fetch session data - reduced polling since SSE handles real-time
@ -34,6 +37,9 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
{ refetchInterval: 10000 }
)
const votingMode = data?.session.votingMode || 'simple'
const criteria = (data?.session.criteriaJson as LiveVotingCriterion[] | null) || []
// SSE for real-time updates
const onSessionStatus = useCallback(() => {
refetch()
@ -41,6 +47,7 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
const onProjectChange = useCallback(() => {
setSelectedScore(null)
setCriterionScores({})
setCountdown(null)
refetch()
}, [refetch])
@ -88,12 +95,28 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
useEffect(() => {
if (data?.userVote) {
setSelectedScore(data.userVote.score)
// Restore criterion scores if available
if (data.userVote.criterionScoresJson) {
setCriterionScores(data.userVote.criterionScoresJson as Record<string, number>)
}
} else {
setSelectedScore(null)
setCriterionScores({})
}
}, [data?.userVote, data?.currentProject?.id])
const handleVote = (score: number) => {
// Initialize criterion scores with mid-values when criteria change
useEffect(() => {
if (votingMode === 'criteria' && criteria.length > 0 && Object.keys(criterionScores).length === 0) {
const initial: Record<string, number> = {}
for (const c of criteria) {
initial[c.id] = Math.ceil(c.scale / 2)
}
setCriterionScores(initial)
}
}, [votingMode, criteria, criterionScores])
const handleSimpleVote = (score: number) => {
if (!data?.currentProject) return
setSelectedScore(score)
vote.mutate({
@ -103,6 +126,37 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
})
}
const handleCriteriaVote = () => {
if (!data?.currentProject) return
// Compute a rough overall score for the `score` field
let weightedSum = 0
for (const c of criteria) {
const cScore = criterionScores[c.id] || 1
const normalizedScore = (cScore / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
vote.mutate({
sessionId,
projectId: data.currentProject.id,
score: computedScore,
criterionScores,
})
}
const computeWeightedScore = (): number => {
if (criteria.length === 0) return 0
let weightedSum = 0
for (const c of criteria) {
const cScore = criterionScores[c.id] || 1
const normalizedScore = (cScore / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
return Math.round(Math.min(10, Math.max(1, weightedSum)) * 10) / 10
}
if (isLoading) {
return <JuryVotingSkeleton />
}
@ -169,27 +223,83 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
</p>
</div>
{/* Score buttons */}
<div className="space-y-2">
<p className="text-sm font-medium text-center">Your Score</p>
<div className="grid grid-cols-5 gap-2">
{SCORE_OPTIONS.map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
size="lg"
className="h-14 text-xl font-bold"
onClick={() => handleVote(score)}
disabled={vote.isPending || countdown === 0}
>
{score}
</Button>
))}
{/* Voting UI - Simple mode */}
{votingMode === 'simple' && (
<div className="space-y-2">
<p className="text-sm font-medium text-center">Your Score</p>
<div className="grid grid-cols-5 gap-2">
{SCORE_OPTIONS.map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
size="lg"
className="h-14 text-xl font-bold"
onClick={() => handleSimpleVote(score)}
disabled={vote.isPending || countdown === 0}
>
{score}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground text-center">
1 = Low, 10 = Excellent
</p>
</div>
<p className="text-xs text-muted-foreground text-center">
1 = Low, 10 = Excellent
</p>
</div>
)}
{/* Voting UI - Criteria mode */}
{votingMode === 'criteria' && criteria.length > 0 && (
<div className="space-y-4">
<p className="text-sm font-medium text-center">Score Each Criterion</p>
{criteria.map((c) => (
<div key={c.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{c.label}</p>
{c.description && (
<p className="text-xs text-muted-foreground truncate">
{c.description}
</p>
)}
</div>
<span className="text-lg font-bold text-primary ml-3 w-12 text-right">
{criterionScores[c.id] || 1}/{c.scale}
</span>
</div>
<Slider
min={1}
max={c.scale}
step={1}
value={[criterionScores[c.id] || 1]}
onValueChange={([val]) => {
setCriterionScores((prev) => ({
...prev,
[c.id]: val,
}))
}}
disabled={vote.isPending || countdown === 0}
/>
</div>
))}
{/* Computed weighted score */}
<div className="flex items-center justify-between border-t pt-3">
<p className="text-sm font-medium">Weighted Score</p>
<span className="text-2xl font-bold text-primary">
{computeWeightedScore().toFixed(1)}
</span>
</div>
<Button
className="w-full"
onClick={handleCriteriaVote}
disabled={vote.isPending || countdown === 0}
>
<Send className="mr-2 h-4 w-4" />
{hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button>
</div>
)}
{/* Vote status */}
{hasVoted && (

View File

@ -0,0 +1,391 @@
'use client'
import { use, useState, useEffect, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { toast } from 'sonner'
import {
Clock,
CheckCircle,
AlertCircle,
Users,
Wifi,
WifiOff,
Vote,
} from 'lucide-react'
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
interface PageProps {
params: Promise<{ sessionId: string }>
}
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const TOKEN_KEY = 'mopc_audience_token_'
function AudienceVotingContent({ sessionId }: { sessionId: string }) {
const [token, setToken] = useState<string | null>(null)
const [identifier, setIdentifier] = useState('')
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [countdown, setCountdown] = useState<number | null>(null)
const [hasVotedForProject, setHasVotedForProject] = useState(false)
// Check for saved token on mount
useEffect(() => {
const saved = localStorage.getItem(TOKEN_KEY + sessionId)
if (saved) {
setToken(saved)
}
}, [sessionId])
// Fetch session data
const { data, isLoading, refetch } = trpc.liveVoting.getAudienceSession.useQuery(
{ sessionId },
{ refetchInterval: 5000 }
)
// SSE for real-time updates
const onSessionStatus = useCallback(() => {
refetch()
}, [refetch])
const onProjectChange = useCallback(() => {
setSelectedScore(null)
setHasVotedForProject(false)
setCountdown(null)
refetch()
}, [refetch])
const { isConnected } = useLiveVotingSSE(sessionId, {
onSessionStatus,
onProjectChange,
})
// Register mutation
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
onSuccess: (result) => {
setToken(result.token)
localStorage.setItem(TOKEN_KEY + sessionId, result.token)
toast.success('Registered! You can now vote.')
},
onError: (error) => {
toast.error(error.message)
},
})
// Vote mutation
const castVote = trpc.liveVoting.castAudienceVote.useMutation({
onSuccess: () => {
toast.success('Vote recorded!')
setHasVotedForProject(true)
},
onError: (error) => {
toast.error(error.message)
},
})
// Update countdown
useEffect(() => {
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
setCountdown(data.timeRemaining)
} else {
setCountdown(null)
}
}, [data?.timeRemaining])
// Countdown timer
useEffect(() => {
if (countdown === null || countdown <= 0) return
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev === null || prev <= 0) return 0
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [countdown])
// Reset vote state when project changes
useEffect(() => {
setSelectedScore(null)
setHasVotedForProject(false)
}, [data?.currentProject?.id])
const handleRegister = () => {
register.mutate({
sessionId,
identifier: identifier.trim() || undefined,
identifierType: identifier.includes('@')
? 'email'
: identifier.trim()
? 'name'
: 'anonymous',
})
}
const handleVote = (score: number) => {
if (!token || !data?.currentProject) return
setSelectedScore(score)
castVote.mutate({
sessionId,
projectId: data.currentProject.id,
score,
token,
})
}
if (isLoading) {
return <AudienceVotingSkeleton />
}
if (!data) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Session Not Found</AlertTitle>
<AlertDescription>
This voting session does not exist or has ended.
</AlertDescription>
</Alert>
</div>
)
}
if (!data.session.allowAudienceVotes) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="max-w-md w-full">
<CardContent className="py-12 text-center">
<Users className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Audience Voting Not Available</h2>
<p className="text-muted-foreground">
Audience voting is not enabled for this session.
</p>
</CardContent>
</Card>
</div>
)
}
// Registration step
if (!token) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="max-w-md w-full">
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Vote className="h-6 w-6 text-primary" />
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.round.program.name} - {data.session.round.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-muted-foreground">
Register to participate in audience voting
</p>
{data.session.audienceRequireId && (
<div className="space-y-2">
<Label htmlFor="identifier">Your Email or Name</Label>
<Input
id="identifier"
placeholder="email@example.com or your name"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Required for audience voting verification
</p>
</div>
)}
{!data.session.audienceRequireId && (
<div className="space-y-2">
<Label htmlFor="identifier">Your Name (optional)</Label>
<Input
id="identifier"
placeholder="Enter your name (optional)"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
/>
</div>
)}
<Button
className="w-full"
onClick={handleRegister}
disabled={
register.isPending ||
(data.session.audienceRequireId && !identifier.trim())
}
>
{register.isPending ? 'Registering...' : 'Join Voting'}
</Button>
</CardContent>
</Card>
</div>
)
}
// Voting UI
const isVoting = data.session.status === 'IN_PROGRESS'
return (
<div className="max-w-md mx-auto space-y-6">
<Card>
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Vote className="h-6 w-6 text-primary" />
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.round.program.name} - {data.session.round.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isVoting && data.currentProject ? (
<>
{/* Current project */}
<div className="text-center space-y-2">
<Badge variant="default" className="mb-2">
Now Presenting
</Badge>
<h2 className="text-xl font-semibold">
{data.currentProject.title}
</h2>
{data.currentProject.teamName && (
<p className="text-muted-foreground">
{data.currentProject.teamName}
</p>
)}
</div>
{/* Timer */}
<div className="text-center">
<div className="text-4xl font-bold text-primary mb-2">
{countdown !== null ? `${countdown}s` : '--'}
</div>
<Progress
value={countdown !== null ? (countdown / 30) * 100 : 0}
className="h-2"
/>
<p className="text-sm text-muted-foreground mt-1">
Time remaining to vote
</p>
</div>
{/* Score buttons */}
<div className="space-y-2">
<p className="text-sm font-medium text-center">Your Score</p>
<div className="grid grid-cols-5 gap-2">
{SCORE_OPTIONS.map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
size="lg"
className="h-14 text-xl font-bold"
onClick={() => handleVote(score)}
disabled={castVote.isPending || countdown === 0}
>
{score}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground text-center">
1 = Low, 10 = Excellent
</p>
</div>
{/* Vote status */}
{hasVotedForProject && (
<Alert className="bg-green-500/10 border-green-500">
<CheckCircle className="h-4 w-4 text-green-500" />
<AlertDescription>
Your vote has been recorded! You can change it before time runs out.
</AlertDescription>
</Alert>
)}
</>
) : (
/* Waiting state */
<div className="text-center py-12">
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
<h2 className="text-xl font-semibold mb-2">
Waiting for Next Project
</h2>
<p className="text-muted-foreground">
{data.session.status === 'COMPLETED'
? 'The voting session has ended. Thank you for participating!'
: 'Voting will begin when the next project is presented.'}
</p>
{data.session.status !== 'COMPLETED' && (
<p className="text-sm text-muted-foreground mt-4">
This page will update automatically.
</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Connection status */}
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-500" />
) : (
<WifiOff className="h-3 w-3 text-red-500" />
)}
<p className="text-muted-foreground text-sm">
{isConnected ? 'Connected' : 'Reconnecting...'}
</p>
</div>
</div>
)
}
function AudienceVotingSkeleton() {
return (
<div className="max-w-md mx-auto">
<Card>
<CardHeader className="text-center">
<Skeleton className="h-6 w-40 mx-auto" />
<Skeleton className="h-4 w-56 mx-auto mt-2" />
</CardHeader>
<CardContent className="space-y-6">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<div className="grid grid-cols-5 gap-2">
{[...Array(10)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function AudienceVotingPage({ params }: PageProps) {
const { sessionId } = use(params)
return <AudienceVotingContent sessionId={sessionId} />
}

View File

@ -33,6 +33,7 @@ export async function GET(request: NextRequest): Promise<Response> {
async start(controller) {
// Track state for change detection
let lastVoteCount = -1
let lastAudienceVoteCount = -1
let lastProjectId: string | null = null
let lastStatus: string | null = null
@ -53,6 +54,7 @@ export async function GET(request: NextRequest): Promise<Response> {
currentProjectId: true,
currentProjectIndex: true,
votingEndsAt: true,
allowAudienceVotes: true,
},
})
@ -86,19 +88,21 @@ export async function GET(request: NextRequest): Promise<Response> {
// Check for vote updates on the current project
if (currentSession.currentProjectId) {
const voteCount = await prisma.liveVote.count({
// Jury votes
const juryVoteCount = await prisma.liveVote.count({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: false,
},
})
if (lastVoteCount !== -1 && voteCount !== lastVoteCount) {
// Get the latest vote info
if (lastVoteCount !== -1 && juryVoteCount !== lastVoteCount) {
const latestVotes = await prisma.liveVote.findMany({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: false,
},
select: {
score: true,
@ -113,6 +117,7 @@ export async function GET(request: NextRequest): Promise<Response> {
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: false,
},
_avg: { score: true },
_count: true,
@ -120,13 +125,43 @@ export async function GET(request: NextRequest): Promise<Response> {
sendEvent('vote_update', {
projectId: currentSession.currentProjectId,
totalVotes: voteCount,
totalVotes: juryVoteCount,
averageScore: avgScore._avg.score,
latestVote: latestVotes[0] || null,
timestamp: new Date().toISOString(),
})
}
lastVoteCount = voteCount
lastVoteCount = juryVoteCount
// Audience votes (separate event)
if (currentSession.allowAudienceVotes) {
const audienceVoteCount = await prisma.liveVote.count({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: true,
},
})
if (lastAudienceVoteCount !== -1 && audienceVoteCount !== lastAudienceVoteCount) {
const audienceAvg = await prisma.liveVote.aggregate({
where: {
sessionId,
projectId: currentSession.currentProjectId,
isAudienceVote: true,
},
_avg: { score: true },
})
sendEvent('audience_vote', {
projectId: currentSession.currentProjectId,
audienceVotes: audienceVoteCount,
audienceAverage: audienceAvg._avg.score,
timestamp: new Date().toISOString(),
})
}
lastAudienceVoteCount = audienceVoteCount
}
}
// Stop polling if session is completed

View File

@ -0,0 +1,203 @@
'use client'
import Link from 'next/link'
import { Badge } from '@/components/ui/badge'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
Filter,
ClipboardCheck,
Zap,
CheckCircle2,
Clock,
Archive,
ChevronRight,
FileText,
Users,
AlertTriangle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type PipelineRound = {
id: string
name: string
status: string
roundType: string
_count?: {
projects: number
assignments: number
}
}
interface RoundPipelineProps {
rounds: PipelineRound[]
programName?: string
}
const typeIcons: Record<string, typeof Filter> = {
FILTERING: Filter,
EVALUATION: ClipboardCheck,
LIVE_EVENT: Zap,
}
const typeColors: Record<string, { bg: string; text: string; border: string }> = {
FILTERING: {
bg: 'bg-amber-50 dark:bg-amber-950/30',
text: 'text-amber-700 dark:text-amber-300',
border: 'border-amber-200 dark:border-amber-800',
},
EVALUATION: {
bg: 'bg-blue-50 dark:bg-blue-950/30',
text: 'text-blue-700 dark:text-blue-300',
border: 'border-blue-200 dark:border-blue-800',
},
LIVE_EVENT: {
bg: 'bg-violet-50 dark:bg-violet-950/30',
text: 'text-violet-700 dark:text-violet-300',
border: 'border-violet-200 dark:border-violet-800',
},
}
const statusConfig: Record<string, { color: string; icon: typeof CheckCircle2; label: string }> = {
DRAFT: { color: 'text-muted-foreground', icon: Clock, label: 'Draft' },
ACTIVE: { color: 'text-green-600', icon: CheckCircle2, label: 'Active' },
CLOSED: { color: 'text-amber-600', icon: Archive, label: 'Closed' },
ARCHIVED: { color: 'text-muted-foreground', icon: Archive, label: 'Archived' },
}
export function RoundPipeline({ rounds }: RoundPipelineProps) {
if (rounds.length === 0) return null
// Detect bottlenecks: rounds with many more incoming projects than outgoing
const projectCounts = rounds.map((r) => r._count?.projects || 0)
return (
<div className="w-full overflow-x-auto pb-2">
<div className="flex items-stretch gap-1 min-w-max px-1 py-2">
{rounds.map((round, index) => {
const TypeIcon = typeIcons[round.roundType] || ClipboardCheck
const colors = typeColors[round.roundType] || typeColors.EVALUATION
const status = statusConfig[round.status] || statusConfig.DRAFT
const StatusIcon = status.icon
const projectCount = round._count?.projects || 0
const prevCount = index > 0 ? projectCounts[index - 1] : 0
const dropRate = prevCount > 0 ? Math.round(((prevCount - projectCount) / prevCount) * 100) : 0
const isBottleneck = dropRate > 50 && index > 0
return (
<div key={round.id} className="flex items-center">
{/* Round Card */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/admin/rounds/${round.id}`}
className={cn(
'group relative flex flex-col items-center gap-2 rounded-xl border-2 px-5 py-4 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 min-w-[140px]',
colors.bg,
colors.border,
round.status === 'ACTIVE' && 'ring-2 ring-green-500/30'
)}
>
{/* Status indicator dot */}
<div className="absolute -top-1.5 -right-1.5">
<div className={cn(
'h-3.5 w-3.5 rounded-full border-2 border-background',
round.status === 'ACTIVE' ? 'bg-green-500' :
round.status === 'CLOSED' ? 'bg-amber-500' :
round.status === 'DRAFT' ? 'bg-muted-foreground/40' :
'bg-muted-foreground/20'
)} />
</div>
{/* Type Icon */}
<div className={cn(
'rounded-lg p-2',
round.status === 'ACTIVE' ? 'bg-green-100 dark:bg-green-900/30' : 'bg-background'
)}>
<TypeIcon className={cn('h-5 w-5', colors.text)} />
</div>
{/* Round Name */}
<p className="text-sm font-medium text-center line-clamp-2 leading-tight max-w-[120px]">
{round.name}
</p>
{/* Stats Row */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{projectCount}
</span>
{round._count?.assignments !== undefined && round._count.assignments > 0 && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{round._count.assignments}
</span>
)}
</div>
{/* Status Badge */}
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', status.color)}
>
<StatusIcon className="mr-1 h-2.5 w-2.5" />
{status.label}
</Badge>
{/* Bottleneck indicator */}
{isBottleneck && (
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2">
<AlertTriangle className="h-4 w-4 text-amber-500" />
</div>
)}
</Link>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="space-y-1">
<p className="font-medium">{round.name}</p>
<p className="text-xs capitalize">
{round.roundType.toLowerCase().replace('_', ' ')} &middot; {round.status.toLowerCase()}
</p>
<p className="text-xs">
{projectCount} projects
{round._count?.assignments ? `, ${round._count.assignments} assignments` : ''}
</p>
{isBottleneck && (
<p className="text-xs text-amber-600">
{dropRate}% drop from previous round
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Arrow connector */}
{index < rounds.length - 1 && (
<div className="flex flex-col items-center px-2">
<ChevronRight className="h-5 w-5 text-muted-foreground/40" />
{prevCount > 0 && index > 0 && dropRate > 0 && (
<span className="text-[10px] text-muted-foreground/60 -mt-0.5">
-{dropRate}%
</span>
)}
{index === 0 && projectCounts[0] > 0 && projectCounts[1] !== undefined && (
<span className="text-[10px] text-muted-foreground/60 -mt-0.5">
{projectCounts[0]} &rarr; {projectCounts[1] || '?'}
</span>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@ -18,7 +18,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Filter, ClipboardCheck, Zap, Info } from 'lucide-react'
import { Filter, ClipboardCheck, Zap, Info, Users, ListOrdered } from 'lucide-react'
import {
type FilteringRoundSettings,
type EvaluationRoundSettings,
@ -43,6 +43,12 @@ const roundTypeIcons = {
LIVE_EVENT: Zap,
}
const roundTypeFeatures: Record<string, string[]> = {
FILTERING: ['AI screening', 'Auto-elimination', 'Batch processing'],
EVALUATION: ['Jury reviews', 'Criteria scoring', 'Voting window'],
LIVE_EVENT: ['Real-time voting', 'Audience votes', 'Presentations'],
}
export function RoundTypeSettings({
roundType,
onRoundTypeChange,
@ -67,13 +73,6 @@ export function RoundTypeSettings({
...(settings as Partial<LiveEventRoundSettings>),
})
const updateSetting = <T extends Record<string, unknown>>(
key: keyof T,
value: T[keyof T]
) => {
onSettingsChange({ ...settings, [key]: value })
}
return (
<Card>
<CardHeader>
@ -86,30 +85,52 @@ export function RoundTypeSettings({
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Round Type Selector */}
<div className="space-y-2">
{/* Round Type Selector - Visual Cards */}
<div className="space-y-3">
<Label>Round Type</Label>
<Select value={roundType} onValueChange={(v) => onRoundTypeChange(v as typeof roundType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
const TypeIcon = roundTypeIcons[type]
return (
<SelectItem key={type} value={type}>
<div className="flex items-center gap-2">
<TypeIcon className="h-4 w-4" />
{roundTypeLabels[type]}
<div className="grid gap-3 sm:grid-cols-3">
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
const TypeIcon = roundTypeIcons[type]
const isSelected = roundType === type
const features = roundTypeFeatures[type]
return (
<button
key={type}
type="button"
onClick={() => onRoundTypeChange(type)}
className={`relative flex flex-col items-start gap-3 rounded-lg border-2 p-4 text-left transition-all duration-200 hover:shadow-md ${
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'border-muted hover:border-muted-foreground/30'
}`}
>
{isSelected && (
<div className="absolute top-2 right-2">
<div className="h-2 w-2 rounded-full bg-primary" />
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{roundTypeDescriptions[roundType]}
</p>
)}
<div className={`rounded-lg p-2 ${isSelected ? 'bg-primary/10' : 'bg-muted'}`}>
<TypeIcon className={`h-5 w-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
<div>
<p className={`font-medium ${isSelected ? 'text-primary' : ''}`}>
{roundTypeLabels[type]}
</p>
<p className="text-xs text-muted-foreground mt-1">
{roundTypeDescriptions[type]}
</p>
</div>
<div className="flex flex-wrap gap-1 mt-auto">
{features.map((f) => (
<span key={f} className="text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
{f}
</span>
))}
</div>
</button>
)
})}
</div>
</div>
{/* Type-specific settings */}
@ -440,6 +461,39 @@ function LiveEventSettings({
</p>
</div>
<div className="space-y-2">
<Label>Voting Mode</Label>
<Select
value={settings.votingMode}
onValueChange={(v) =>
onChange({ ...settings, votingMode: v as 'simple' | 'criteria' })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="simple">
<div className="flex items-center gap-2">
<ListOrdered className="h-4 w-4" />
Simple (1-10 score)
</div>
</SelectItem>
<SelectItem value="criteria">
<div className="flex items-center gap-2">
<ClipboardCheck className="h-4 w-4" />
Criteria-Based (per-criterion scoring)
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{settings.votingMode === 'simple'
? 'Jurors give a single 1-10 score per project'
: 'Jurors score each criterion separately, weighted into a final score'}
</p>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Allow Vote Change</Label>
@ -456,6 +510,105 @@ function LiveEventSettings({
</div>
</div>
{/* Audience Voting */}
<div className="space-y-4">
<h5 className="text-sm font-medium flex items-center gap-2">
<Users className="h-4 w-4" />
Audience Voting
</h5>
<div className="space-y-2">
<Label>Audience Voting Mode</Label>
<Select
value={settings.audienceVotingMode}
onValueChange={(v) =>
onChange({
...settings,
audienceVotingMode: v as 'disabled' | 'per_project' | 'per_category' | 'favorites',
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="disabled">Disabled</SelectItem>
<SelectItem value="per_project">Per Project (1-10 score)</SelectItem>
<SelectItem value="per_category">Per Category (vote best-in-category)</SelectItem>
<SelectItem value="favorites">Favorites (pick top N)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
How audience members can participate in voting
</p>
</div>
{settings.audienceVotingMode !== 'disabled' && (
<div className="ml-6 space-y-4 border-l-2 pl-4">
<div className="flex items-center justify-between">
<div>
<Label>Require Identification</Label>
<p className="text-sm text-muted-foreground">
Audience must provide email or name to vote
</p>
</div>
<Switch
checked={settings.audienceRequireId}
onCheckedChange={(v) =>
onChange({ ...settings, audienceRequireId: v })
}
/>
</div>
{settings.audienceVotingMode === 'favorites' && (
<div className="space-y-2">
<Label htmlFor="maxFavorites">Max Favorites</Label>
<Input
id="maxFavorites"
type="number"
min="1"
max="20"
value={settings.audienceMaxFavorites}
onChange={(e) =>
onChange({
...settings,
audienceMaxFavorites: parseInt(e.target.value) || 3,
})
}
/>
<p className="text-xs text-muted-foreground">
Number of favorites each audience member can select
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="audienceDuration">
Audience Voting Duration (minutes)
</Label>
<Input
id="audienceDuration"
type="number"
min="1"
max="600"
value={settings.audienceVotingDuration || ''}
placeholder="Same as jury"
onChange={(e) => {
const val = parseInt(e.target.value)
onChange({
...settings,
audienceVotingDuration: isNaN(val) ? null : val,
})
}}
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the same window as jury voting
</p>
</div>
</div>
)}
</div>
{/* Display */}
<div className="space-y-4">
<h5 className="text-sm font-medium">Display</h5>
@ -504,7 +657,7 @@ function LiveEventSettings({
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Presentation order can be configured in the Live Voting section once the round
Presentation order and criteria can be configured in the Live Voting section once the round
is activated.
</AlertDescription>
</Alert>

View File

@ -10,6 +10,13 @@ export interface VoteUpdate {
timestamp: string
}
export interface AudienceVoteUpdate {
projectId: string
audienceVotes: number
audienceAverage: number | null
timestamp: string
}
export interface SessionStatusUpdate {
status: string
timestamp: string
@ -23,6 +30,7 @@ export interface ProjectChangeUpdate {
interface SSECallbacks {
onVoteUpdate?: (data: VoteUpdate) => void
onAudienceVote?: (data: AudienceVoteUpdate) => void
onSessionStatus?: (data: SessionStatusUpdate) => void
onProjectChange?: (data: ProjectChangeUpdate) => void
onConnected?: () => void
@ -65,6 +73,15 @@ export function useLiveVotingSSE(
}
})
es.addEventListener('audience_vote', (event) => {
try {
const data = JSON.parse(event.data) as AudienceVoteUpdate
callbacksRef.current.onAudienceVote?.(data)
} catch {
// Ignore parse errors
}
})
es.addEventListener('session_status', (event) => {
try {
const data = JSON.parse(event.data) as SessionStatusUpdate

View File

@ -1,7 +1,9 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { randomUUID } from 'crypto'
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import type { LiveVotingCriterion } from '@/types/round-settings'
export const liveVotingRouter = router({
/**
@ -46,7 +48,7 @@ export const liveVotingRouter = router({
}
// Get current votes if voting is in progress
let currentVotes: { userId: string; score: number }[] = []
let currentVotes: { userId: string | null; score: number }[] = []
if (session.currentProjectId) {
const votes = await ctx.prisma.liveVote.findMany({
where: {
@ -58,9 +60,15 @@ export const liveVotingRouter = router({
currentVotes = votes
}
// Get audience voter count
const audienceVoterCount = await ctx.prisma.audienceVoter.count({
where: { sessionId: session.id },
})
return {
...session,
currentVotes,
audienceVoterCount,
}
}),
@ -115,6 +123,8 @@ export const liveVotingRouter = router({
status: session.status,
votingStartedAt: session.votingStartedAt,
votingEndsAt: session.votingEndsAt,
votingMode: session.votingMode,
criteriaJson: session.criteriaJson,
},
round: session.round,
currentProject,
@ -202,6 +212,132 @@ export const liveVotingRouter = router({
return session
}),
/**
* Set voting mode (simple vs criteria)
*/
setVotingMode: adminProcedure
.input(
z.object({
sessionId: z.string(),
votingMode: z.enum(['simple', 'criteria']),
})
)
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: { votingMode: input.votingMode },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SET_VOTING_MODE',
entityType: 'LiveVotingSession',
entityId: session.id,
detailsJson: { votingMode: input.votingMode },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return session
}),
/**
* Set criteria for criteria-based voting
*/
setCriteria: adminProcedure
.input(
z.object({
sessionId: z.string(),
criteria: z.array(
z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
scale: z.number().int().min(1).max(100),
weight: z.number().min(0).max(1),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Validate weights sum approximately to 1
const weightSum = input.criteria.reduce((sum, c) => sum + c.weight, 0)
if (Math.abs(weightSum - 1) > 0.01) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Criteria weights must sum to 1.0 (currently ${weightSum.toFixed(2)})`,
})
}
const session = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
criteriaJson: input.criteria,
votingMode: 'criteria',
},
})
return session
}),
/**
* Import criteria from an existing evaluation form
*/
importCriteriaFromForm: adminProcedure
.input(
z.object({
sessionId: z.string(),
formId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const form = await ctx.prisma.evaluationForm.findUniqueOrThrow({
where: { id: input.formId },
})
const formCriteria = form.criteriaJson as Array<{
id: string
label: string
description?: string
scale: number
weight: number
type?: string
}>
// Filter out section headers and convert
const scoringCriteria = formCriteria.filter(
(c) => !c.type || c.type === 'numeric'
)
if (scoringCriteria.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No numeric criteria found in this evaluation form',
})
}
// Normalize weights to sum to 1
const totalWeight = scoringCriteria.reduce((sum, c) => sum + (c.weight || 1), 0)
const criteria: LiveVotingCriterion[] = scoringCriteria.map((c) => ({
id: c.id,
label: c.label,
description: c.description,
scale: c.scale || 10,
weight: (c.weight || 1) / totalWeight,
}))
const session = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
criteriaJson: criteria as unknown as import('@prisma/client').Prisma.InputJsonValue,
votingMode: 'criteria',
},
})
return session
}),
/**
* Start voting for a project
*/
@ -288,7 +424,7 @@ export const liveVotingRouter = router({
}),
/**
* Submit a vote
* Submit a vote (supports both simple and criteria modes)
*/
vote: protectedProcedure
.input(
@ -296,6 +432,9 @@ export const liveVotingRouter = router({
sessionId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
criterionScores: z
.record(z.string(), z.number())
.optional(),
})
)
.mutation(async ({ ctx, input }) => {
@ -326,6 +465,46 @@ export const liveVotingRouter = router({
})
}
// For criteria mode, validate and compute weighted score
let finalScore = input.score
let criterionScoresJson = null
if (session.votingMode === 'criteria' && input.criterionScores) {
const criteria = session.criteriaJson as LiveVotingCriterion[] | null
if (!criteria || criteria.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No criteria configured for this session',
})
}
// Validate all required criteria have scores
for (const c of criteria) {
if (input.criterionScores[c.id] === undefined) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Missing score for criterion: ${c.label}`,
})
}
const cScore = input.criterionScores[c.id]
if (cScore < 1 || cScore > c.scale) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Score for ${c.label} must be between 1 and ${c.scale}`,
})
}
}
// Compute weighted score normalized to 1-10
let weightedSum = 0
for (const c of criteria) {
const normalizedScore = (input.criterionScores[c.id] / c.scale) * 10
weightedSum += normalizedScore * c.weight
}
finalScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
criterionScoresJson = input.criterionScores
}
// Upsert vote (allow vote change during window)
const vote = await ctx.prisma.liveVote.upsert({
where: {
@ -339,10 +518,12 @@ export const liveVotingRouter = router({
sessionId: input.sessionId,
projectId: input.projectId,
userId: ctx.user.id,
score: input.score,
score: finalScore,
criterionScoresJson: criterionScoresJson ?? undefined,
},
update: {
score: input.score,
score: finalScore,
criterionScoresJson: criterionScoresJson ?? undefined,
votedAt: new Date(),
},
})
@ -354,7 +535,13 @@ export const liveVotingRouter = router({
* Get results for a session (with weighted jury + audience scoring)
*/
getResults: protectedProcedure
.input(z.object({ sessionId: z.string() }))
.input(
z.object({
sessionId: z.string(),
juryWeight: z.number().min(0).max(1).optional(),
audienceWeight: z.number().min(0).max(1).optional(),
})
)
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
@ -367,8 +554,9 @@ export const liveVotingRouter = router({
},
})
const audienceWeight = session.audienceVoteWeight || 0
const juryWeight = 1 - audienceWeight
// Use custom weights if provided, else session defaults
const audienceWeightVal = input.audienceWeight ?? session.audienceVoteWeight ?? 0
const juryWeightVal = input.juryWeight ?? (1 - audienceWeightVal)
// Get jury votes grouped by project
const juryScores = await ctx.prisma.liveVote.groupBy({
@ -400,6 +588,39 @@ export const liveVotingRouter = router({
const audienceMap = new Map(audienceScores.map((s) => [s.projectId, s]))
// For criteria mode, get per-criterion breakdowns
let criteriaBreakdown: Record<string, Record<string, number>> | null = null
if (session.votingMode === 'criteria') {
const allJuryVotes = await ctx.prisma.liveVote.findMany({
where: { sessionId: input.sessionId, isAudienceVote: false },
select: { projectId: true, criterionScoresJson: true },
})
criteriaBreakdown = {}
for (const vote of allJuryVotes) {
if (!vote.criterionScoresJson) continue
const scores = vote.criterionScoresJson as Record<string, number>
if (!criteriaBreakdown[vote.projectId]) {
criteriaBreakdown[vote.projectId] = {}
}
for (const [criterionId, score] of Object.entries(scores)) {
if (!criteriaBreakdown[vote.projectId][criterionId]) {
criteriaBreakdown[vote.projectId][criterionId] = 0
}
criteriaBreakdown[vote.projectId][criterionId] += score
}
}
// Average the scores
for (const projectId of Object.keys(criteriaBreakdown)) {
const projectVoteCount = allJuryVotes.filter((v) => v.projectId === projectId).length
if (projectVoteCount > 0) {
for (const criterionId of Object.keys(criteriaBreakdown[projectId])) {
criteriaBreakdown[projectId][criterionId] /= projectVoteCount
}
}
}
}
// Combine and calculate weighted scores
const results = juryScores
.map((jurySc) => {
@ -407,8 +628,8 @@ export const liveVotingRouter = router({
const audienceSc = audienceMap.get(jurySc.projectId)
const juryAvg = jurySc._avg?.score || 0
const audienceAvg = audienceSc?._avg?.score || 0
const weightedTotal = audienceWeight > 0 && audienceSc
? juryAvg * juryWeight + audienceAvg * audienceWeight
const weightedTotal = audienceWeightVal > 0 && audienceSc
? juryAvg * juryWeightVal + audienceAvg * audienceWeightVal
: juryAvg
return {
@ -418,6 +639,7 @@ export const liveVotingRouter = router({
audienceAverage: audienceAvg,
audienceVoteCount: audienceSc?._count || 0,
weightedTotal,
criteriaAverages: criteriaBreakdown?.[jurySc.projectId] || null,
}
})
.sort((a, b) => b.weightedTotal - a.weightedTotal)
@ -436,6 +658,9 @@ export const liveVotingRouter = router({
results,
ties,
tieBreakerMethod: session.tieBreakerMethod,
votingMode: session.votingMode,
criteria: session.criteriaJson as LiveVotingCriterion[] | null,
weights: { jury: juryWeightVal, audience: audienceWeightVal },
}
}),
@ -477,6 +702,10 @@ export const liveVotingRouter = router({
allowAudienceVotes: z.boolean().optional(),
audienceVoteWeight: z.number().min(0).max(1).optional(),
tieBreakerMethod: z.enum(['admin_decides', 'highest_individual', 'revote']).optional(),
audienceVotingMode: z.enum(['disabled', 'per_project', 'per_category', 'favorites']).optional(),
audienceMaxFavorites: z.number().int().min(1).max(20).optional(),
audienceRequireId: z.boolean().optional(),
audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@ -507,17 +736,76 @@ export const liveVotingRouter = router({
}),
/**
* Cast an audience vote
* Register an audience voter (public, no auth required)
*/
castAudienceVote: protectedProcedure
registerAudienceVoter: publicProcedure
.input(
z.object({
sessionId: z.string(),
identifier: z.string().optional(),
identifierType: z.enum(['email', 'phone', 'name', 'anonymous']).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
if (!session.allowAudienceVotes) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Audience voting is not enabled for this session',
})
}
if (session.audienceRequireId && !input.identifier) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Identification is required for audience voting',
})
}
const token = randomUUID()
const voter = await ctx.prisma.audienceVoter.create({
data: {
sessionId: input.sessionId,
token,
identifier: input.identifier || null,
identifierType: input.identifierType || 'anonymous',
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { token: voter.token, voterId: voter.id }
}),
/**
* Cast an audience vote (token-based, no auth required)
*/
castAudienceVote: publicProcedure
.input(
z.object({
sessionId: z.string(),
projectId: z.string(),
score: z.number().int().min(1).max(10),
token: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify voter token
const voter = await ctx.prisma.audienceVoter.findUnique({
where: { token: input.token },
})
if (!voter || voter.sessionId !== input.sessionId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid voting token',
})
}
// Verify session is in progress and allows audience votes
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
@ -551,19 +839,19 @@ export const liveVotingRouter = router({
})
}
// Upsert audience vote
// Upsert audience vote (dedup by audienceVoterId)
const vote = await ctx.prisma.liveVote.upsert({
where: {
sessionId_projectId_userId: {
sessionId_projectId_audienceVoterId: {
sessionId: input.sessionId,
projectId: input.projectId,
userId: ctx.user.id,
audienceVoterId: voter.id,
},
},
create: {
sessionId: input.sessionId,
projectId: input.projectId,
userId: ctx.user.id,
audienceVoterId: voter.id,
score: input.score,
isAudienceVote: true,
},
@ -576,6 +864,70 @@ export const liveVotingRouter = router({
return vote
}),
/**
* Get audience voter stats (admin)
*/
getAudienceVoterStats: adminProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const voterCount = await ctx.prisma.audienceVoter.count({
where: { sessionId: input.sessionId },
})
const voteCount = await ctx.prisma.liveVote.count({
where: { sessionId: input.sessionId, isAudienceVote: true },
})
return { voterCount, voteCount }
}),
/**
* Get public session info for audience voting page
*/
getAudienceSession: publicProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
select: {
id: true,
status: true,
currentProjectId: true,
votingEndsAt: true,
allowAudienceVotes: true,
audienceVotingMode: true,
audienceRequireId: true,
audienceMaxFavorites: true,
round: {
select: {
name: true,
program: { select: { name: true, year: true } },
},
},
},
})
let currentProject = null
if (session.currentProjectId && session.status === 'IN_PROGRESS') {
currentProject = await ctx.prisma.project.findUnique({
where: { id: session.currentProjectId },
select: { id: true, title: true, teamName: true },
})
}
let timeRemaining = null
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
timeRemaining = Math.max(0, Math.floor(remaining / 1000))
}
return {
session,
currentProject,
timeRemaining,
}
}),
/**
* Get public results for a live voting session (no auth required)
*/

View File

@ -38,6 +38,13 @@ export interface LiveEventRoundSettings {
votingWindowSeconds: number
showLiveScores: boolean
allowVoteChange: boolean
votingMode: 'simple' | 'criteria'
// Audience voting
audienceVotingMode: 'disabled' | 'per_project' | 'per_category' | 'favorites'
audienceMaxFavorites: number
audienceRequireId: boolean
audienceVotingDuration: number | null
// Display
displayMode: 'SCORES' | 'RANKING' | 'NONE'
@ -74,6 +81,11 @@ export const defaultLiveEventSettings: LiveEventRoundSettings = {
votingWindowSeconds: 30,
showLiveScores: true,
allowVoteChange: false,
votingMode: 'simple',
audienceVotingMode: 'disabled',
audienceMaxFavorites: 3,
audienceRequireId: false,
audienceVotingDuration: null,
displayMode: 'RANKING',
}
@ -90,3 +102,43 @@ export const roundTypeDescriptions: Record<string, string> = {
EVALUATION: 'In-depth evaluation with detailed criteria and feedback',
LIVE_EVENT: 'Real-time voting during presentations',
}
// Field visibility per round type
export const ROUND_FIELD_VISIBILITY: Record<string, {
showRequiredReviews: boolean
showAssignmentLimits: boolean
showVotingWindow: boolean
showSubmissionDates: boolean
showEvaluationForm: boolean
}> = {
FILTERING: {
showRequiredReviews: false,
showAssignmentLimits: false,
showVotingWindow: false,
showSubmissionDates: true,
showEvaluationForm: false,
},
EVALUATION: {
showRequiredReviews: true,
showAssignmentLimits: true,
showVotingWindow: true,
showSubmissionDates: true,
showEvaluationForm: true,
},
LIVE_EVENT: {
showRequiredReviews: false,
showAssignmentLimits: false,
showVotingWindow: false,
showSubmissionDates: false,
showEvaluationForm: false,
},
}
// Live voting criterion type
export interface LiveVotingCriterion {
id: string
label: string
description?: string
scale: number // max score (e.g. 10)
weight: number // 0-1, weights must sum to 1
}