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? votingEndsAt DateTime?
projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order 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 // Audience & presentation settings
allowAudienceVotes Boolean @default(false) allowAudienceVotes Boolean @default(false)
audienceVoteWeight Float @default(0) // 0.0 to 1.0 audienceVoteWeight Float @default(0) // 0.0 to 1.0
tieBreakerMethod String @default("admin_decides") // 'admin_decides' | 'highest_individual' | 'revote' tieBreakerMethod String @default("admin_decides") // 'admin_decides' | 'highest_individual' | 'revote'
presentationSettingsJson Json? @db.JsonB 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
votes LiveVote[] votes LiveVote[]
audienceVoters AudienceVoter[]
@@index([status]) @@index([status])
} }
model LiveVote { model LiveVote {
id String @id @default(cuid()) id String @id @default(cuid())
sessionId String sessionId String
projectId String projectId String
userId String userId String? // Nullable for audience voters without accounts
score Int // 1-10 score Int // 1-10 (or weighted score for criteria mode)
isAudienceVote Boolean @default(false) isAudienceVote Boolean @default(false)
votedAt DateTime @default(now()) 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 // Relations
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], 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, userId])
@@unique([sessionId, projectId, audienceVoterId])
@@index([sessionId]) @@index([sessionId])
@@index([projectId]) @@index([projectId])
@@index([userId]) @@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, type Criterion,
} from '@/components/forms/evaluation-form-builder' } from '@/components/forms/evaluation-form-builder'
import { RoundTypeSettings } from '@/components/forms/round-type-settings' 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 { FileRequirementsEditor } from '@/components/admin/file-requirements-editor'
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar, LayoutTemplate } from 'lucide-react' import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar, LayoutTemplate } from 'lucide-react'
import { import {
@ -202,17 +203,18 @@ function EditRoundContent({ roundId }: { roundId: string }) {
}, [evaluationForm, loadingForm, criteriaInitialized]) }, [evaluationForm, loadingForm, criteriaInitialized])
const onSubmit = async (data: UpdateRoundForm) => { const onSubmit = async (data: UpdateRoundForm) => {
const visibility = ROUND_FIELD_VISIBILITY[roundType]
// Update round with type, settings, and notification // Update round with type, settings, and notification
await updateRound.mutateAsync({ await updateRound.mutateAsync({
id: roundId, id: roundId,
name: data.name, name: data.name,
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews, requiredReviews: visibility?.showRequiredReviews ? data.requiredReviews : 0,
minAssignmentsPerJuror: data.minAssignmentsPerJuror, minAssignmentsPerJuror: data.minAssignmentsPerJuror,
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror, maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
roundType, roundType,
settingsJson: roundSettings, settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? null, votingStartAt: visibility?.showVotingWindow ? (data.votingStartAt ?? null) : null,
votingEndAt: data.votingEndAt ?? null, votingEndAt: visibility?.showVotingWindow ? (data.votingEndAt ?? null) : null,
}) })
// Update evaluation form if criteria changed and no evaluations exist // 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 <FormField
control={form.control} control={form.control}
name="requiredReviews" 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"> <div className="grid gap-4 sm:grid-cols-2">
<FormField <FormField
control={form.control} control={form.control}
@ -379,6 +382,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
)} )}
/> />
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>

File diff suppressed because it is too large Load Diff

View File

@ -91,6 +91,7 @@ import {
ExternalLink, ExternalLink,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ROUND_FIELD_VISIBILITY, roundTypeLabels } from '@/types/round-settings'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog' import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
import { AdvanceProjectsDialog } from '@/components/admin/advance-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) // Progress data is now included in round.get response (eliminates duplicate evaluation.groupBy)
const progress = round?.progress 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 isFilteringRound = round?.roundType === 'FILTERING'
const isLiveEventRound = round?.roundType === 'LIVE_EVENT'
// Filtering queries (only fetch for FILTERING rounds) // Filtering queries (only fetch for FILTERING rounds)
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } = const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
@ -165,6 +167,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
{ roundId }, { roundId },
{ enabled: isFilteringRound } { 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 } = const { data: latestJob, refetch: refetchLatestJob } =
trpc.filtering.getLatestJob.useQuery( trpc.filtering.getLatestJob.useQuery(
{ roundId }, { 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 now = new Date()
const isVotingOpen = const isVotingOpen =
round.status === 'ACTIVE' && round.status === 'ACTIVE' &&
@ -462,6 +473,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{round.name}</h1> <h1 className="text-2xl font-semibold tracking-tight">{round.name}</h1>
{getStatusBadge()} {getStatusBadge()}
<Badge variant="outline" className="text-xs">
{roundTypeLabels[round.roundType] || round.roundType}
</Badge>
</div> </div>
</div> </div>
@ -577,6 +591,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent> </CardContent>
</Card> </Card>
{visibility.showAssignmentLimits && (
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"> <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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Judge Assignments</CardTitle> <CardTitle className="text-sm font-medium">Judge Assignments</CardTitle>
@ -593,7 +608,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
)}
{visibility.showRequiredReviews && (
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"> <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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle> <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> <p className="text-xs text-muted-foreground">per project</p>
</CardContent> </CardContent>
</Card> </Card>
)}
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"> <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"> <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"> <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> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> {isLiveEvent && liveSession ? (
{progress?.completionPercentage || 0}% <>
</div> <div className="text-2xl font-bold capitalize">
<p className="text-xs text-muted-foreground"> {liveSession.status === 'IN_PROGRESS' ? 'Live' : liveSession.status.toLowerCase().replace('_', ' ')}
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0} </div>
</p> <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> </CardContent>
</Card> </Card>
</div> </div>
</AnimatedCard> </AnimatedCard>
{/* Progress */} {/* Progress - only for evaluation rounds */}
{progress && progress.totalAssignments > 0 && ( {visibility.showRequiredReviews && progress && progress.totalAssignments > 0 && (
<AnimatedCard index={1}> <AnimatedCard index={1}>
<Card> <Card>
<CardHeader> <CardHeader>
@ -662,7 +699,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</AnimatedCard> </AnimatedCard>
)} )}
{/* Voting Window */} {/* Voting Window - only for evaluation rounds */}
{visibility.showVotingWindow && (
<AnimatedCard index={2}> <AnimatedCard index={2}>
<Card> <Card>
<CardHeader> <CardHeader>
@ -759,6 +797,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>
)}
{/* Filtering Section (for FILTERING rounds) */} {/* Filtering Section (for FILTERING rounds) */}
{isFilteringRound && ( {isFilteringRound && (
@ -1268,8 +1307,133 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</AnimatedCard> </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 */} {/* Quick Actions */}
<AnimatedCard index={4}> <AnimatedCard index={isFilteringRound || isLiveEventRound ? 5 : 4}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg"> <CardTitle className="flex items-center gap-2.5 text-lg">
@ -1311,44 +1475,70 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</div> </div>
</div> </div>
{/* Round Management */} {/* Type-Specific Management */}
<div> <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"> <div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild> {/* Filtering-specific actions */}
<Link href={`/admin/rounds/${round.id}/assignments`}> {isFilteringRound && (
<Users className="mr-2 h-4 w-4" /> <>
Jury Assignments <Button variant="outline" size="sm" asChild>
</Link> <Link href={`/admin/rounds/${round.id}/filtering/rules`}>
</Button> <ListChecks className="mr-2 h-4 w-4" />
<Button variant="outline" size="sm" asChild> View Rules
<Link href={`/admin/rounds/${round.id}/live-voting`}> </Link>
<Zap className="mr-2 h-4 w-4" /> </Button>
Live Voting </>
</Link> )}
</Button>
<TooltipProvider> {/* Evaluation-specific actions */}
<Tooltip> {visibility.showAssignmentLimits && (
<TooltipTrigger asChild> <Button variant="outline" size="sm" asChild>
<Button <Link href={`/admin/rounds/${round.id}/assignments`}>
variant="outline" <Users className="mr-2 h-4 w-4" />
size="sm" Jury Assignments
onClick={() => bulkSummaries.mutate({ roundId: round.id })} </Link>
disabled={bulkSummaries.isPending} </Button>
> )}
{bulkSummaries.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {/* Live Event-specific actions */}
) : ( {isLiveEvent && (
<FileSearch className="mr-2 h-4 w-4" /> <Button variant="outline" size="sm" asChild>
)} <Link href={`/admin/rounds/${round.id}/live-voting`}>
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'} <Zap className="mr-2 h-4 w-4" />
</Button> Open Live Session
</TooltipTrigger> </Link>
<TooltipContent side="bottom" className="max-w-xs"> </Button>
<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> {/* Evaluation-round-only: AI Summaries */}
</TooltipProvider> {!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 <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

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

View File

@ -63,10 +63,13 @@ import {
Loader2, Loader2,
GripVertical, GripVertical,
ArrowRight, ArrowRight,
List,
GitBranchPlus,
} from 'lucide-react' } from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns' import { format, isPast, isFuture } from 'date-fns'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { RoundPipeline } from '@/components/admin/round-pipeline'
type RoundData = { type RoundData = {
id: string id: string
@ -81,7 +84,7 @@ type RoundData = {
} }
} }
function RoundsContent() { function RoundsContent({ viewMode }: { viewMode: 'list' | 'pipeline' }) {
const { data: programs, isLoading } = trpc.program.list.useQuery({ const { data: programs, isLoading } = trpc.program.list.useQuery({
includeRounds: true, 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{programs.map((program, index) => ( {programs.map((program, index) => (
@ -669,6 +711,8 @@ function RoundsListSkeleton() {
} }
export default function RoundsPage() { export default function RoundsPage() {
const [viewMode, setViewMode] = useState<'list' | 'pipeline'>('list')
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@ -679,11 +723,31 @@ export default function RoundsPage() {
Manage selection rounds and voting periods Manage selection rounds and voting periods
</p> </p>
</div> </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> </div>
{/* Content */} {/* Content */}
<Suspense fallback={<RoundsListSkeleton />}> <Suspense fallback={<RoundsListSkeleton />}>
<RoundsContent /> <RoundsContent viewMode={viewMode} />
</Suspense> </Suspense>
</div> </div>
) )

View File

@ -14,9 +14,11 @@ import {
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Slider } from '@/components/ui/slider'
import { toast } from 'sonner' 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 { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
import type { LiveVotingCriterion } from '@/types/round-settings'
interface PageProps { interface PageProps {
params: Promise<{ sessionId: string }> 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 }) { function JuryVotingContent({ sessionId }: { sessionId: string }) {
const [selectedScore, setSelectedScore] = useState<number | null>(null) const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [criterionScores, setCriterionScores] = useState<Record<string, number>>({})
const [countdown, setCountdown] = useState<number | null>(null) const [countdown, setCountdown] = useState<number | null>(null)
// Fetch session data - reduced polling since SSE handles real-time // Fetch session data - reduced polling since SSE handles real-time
@ -34,6 +37,9 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
{ refetchInterval: 10000 } { refetchInterval: 10000 }
) )
const votingMode = data?.session.votingMode || 'simple'
const criteria = (data?.session.criteriaJson as LiveVotingCriterion[] | null) || []
// SSE for real-time updates // SSE for real-time updates
const onSessionStatus = useCallback(() => { const onSessionStatus = useCallback(() => {
refetch() refetch()
@ -41,6 +47,7 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
const onProjectChange = useCallback(() => { const onProjectChange = useCallback(() => {
setSelectedScore(null) setSelectedScore(null)
setCriterionScores({})
setCountdown(null) setCountdown(null)
refetch() refetch()
}, [refetch]) }, [refetch])
@ -88,12 +95,28 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
useEffect(() => { useEffect(() => {
if (data?.userVote) { if (data?.userVote) {
setSelectedScore(data.userVote.score) setSelectedScore(data.userVote.score)
// Restore criterion scores if available
if (data.userVote.criterionScoresJson) {
setCriterionScores(data.userVote.criterionScoresJson as Record<string, number>)
}
} else { } else {
setSelectedScore(null) setSelectedScore(null)
setCriterionScores({})
} }
}, [data?.userVote, data?.currentProject?.id]) }, [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 if (!data?.currentProject) return
setSelectedScore(score) setSelectedScore(score)
vote.mutate({ 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) { if (isLoading) {
return <JuryVotingSkeleton /> return <JuryVotingSkeleton />
} }
@ -169,27 +223,83 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
</p> </p>
</div> </div>
{/* Score buttons */} {/* Voting UI - Simple mode */}
<div className="space-y-2"> {votingMode === 'simple' && (
<p className="text-sm font-medium text-center">Your Score</p> <div className="space-y-2">
<div className="grid grid-cols-5 gap-2"> <p className="text-sm font-medium text-center">Your Score</p>
{SCORE_OPTIONS.map((score) => ( <div className="grid grid-cols-5 gap-2">
<Button {SCORE_OPTIONS.map((score) => (
key={score} <Button
variant={selectedScore === score ? 'default' : 'outline'} key={score}
size="lg" variant={selectedScore === score ? 'default' : 'outline'}
className="h-14 text-xl font-bold" size="lg"
onClick={() => handleVote(score)} className="h-14 text-xl font-bold"
disabled={vote.isPending || countdown === 0} onClick={() => handleSimpleVote(score)}
> disabled={vote.isPending || countdown === 0}
{score} >
</Button> {score}
))} </Button>
))}
</div>
<p className="text-xs text-muted-foreground text-center">
1 = Low, 10 = Excellent
</p>
</div> </div>
<p className="text-xs text-muted-foreground text-center"> )}
1 = Low, 10 = Excellent
</p> {/* Voting UI - Criteria mode */}
</div> {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 */} {/* Vote status */}
{hasVoted && ( {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) { async start(controller) {
// Track state for change detection // Track state for change detection
let lastVoteCount = -1 let lastVoteCount = -1
let lastAudienceVoteCount = -1
let lastProjectId: string | null = null let lastProjectId: string | null = null
let lastStatus: string | null = null let lastStatus: string | null = null
@ -53,6 +54,7 @@ export async function GET(request: NextRequest): Promise<Response> {
currentProjectId: true, currentProjectId: true,
currentProjectIndex: true, currentProjectIndex: true,
votingEndsAt: 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 // Check for vote updates on the current project
if (currentSession.currentProjectId) { if (currentSession.currentProjectId) {
const voteCount = await prisma.liveVote.count({ // Jury votes
const juryVoteCount = await prisma.liveVote.count({
where: { where: {
sessionId, sessionId,
projectId: currentSession.currentProjectId, projectId: currentSession.currentProjectId,
isAudienceVote: false,
}, },
}) })
if (lastVoteCount !== -1 && voteCount !== lastVoteCount) { if (lastVoteCount !== -1 && juryVoteCount !== lastVoteCount) {
// Get the latest vote info
const latestVotes = await prisma.liveVote.findMany({ const latestVotes = await prisma.liveVote.findMany({
where: { where: {
sessionId, sessionId,
projectId: currentSession.currentProjectId, projectId: currentSession.currentProjectId,
isAudienceVote: false,
}, },
select: { select: {
score: true, score: true,
@ -113,6 +117,7 @@ export async function GET(request: NextRequest): Promise<Response> {
where: { where: {
sessionId, sessionId,
projectId: currentSession.currentProjectId, projectId: currentSession.currentProjectId,
isAudienceVote: false,
}, },
_avg: { score: true }, _avg: { score: true },
_count: true, _count: true,
@ -120,13 +125,43 @@ export async function GET(request: NextRequest): Promise<Response> {
sendEvent('vote_update', { sendEvent('vote_update', {
projectId: currentSession.currentProjectId, projectId: currentSession.currentProjectId,
totalVotes: voteCount, totalVotes: juryVoteCount,
averageScore: avgScore._avg.score, averageScore: avgScore._avg.score,
latestVote: latestVotes[0] || null, latestVote: latestVotes[0] || null,
timestamp: new Date().toISOString(), 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 // 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, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Alert, AlertDescription } from '@/components/ui/alert' 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 { import {
type FilteringRoundSettings, type FilteringRoundSettings,
type EvaluationRoundSettings, type EvaluationRoundSettings,
@ -43,6 +43,12 @@ const roundTypeIcons = {
LIVE_EVENT: Zap, 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({ export function RoundTypeSettings({
roundType, roundType,
onRoundTypeChange, onRoundTypeChange,
@ -67,13 +73,6 @@ export function RoundTypeSettings({
...(settings as Partial<LiveEventRoundSettings>), ...(settings as Partial<LiveEventRoundSettings>),
}) })
const updateSetting = <T extends Record<string, unknown>>(
key: keyof T,
value: T[keyof T]
) => {
onSettingsChange({ ...settings, [key]: value })
}
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -86,30 +85,52 @@ export function RoundTypeSettings({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Round Type Selector */} {/* Round Type Selector - Visual Cards */}
<div className="space-y-2"> <div className="space-y-3">
<Label>Round Type</Label> <Label>Round Type</Label>
<Select value={roundType} onValueChange={(v) => onRoundTypeChange(v as typeof roundType)}> <div className="grid gap-3 sm:grid-cols-3">
<SelectTrigger> {(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => {
<SelectValue /> const TypeIcon = roundTypeIcons[type]
</SelectTrigger> const isSelected = roundType === type
<SelectContent> const features = roundTypeFeatures[type]
{(['FILTERING', 'EVALUATION', 'LIVE_EVENT'] as const).map((type) => { return (
const TypeIcon = roundTypeIcons[type] <button
return ( key={type}
<SelectItem key={type} value={type}> type="button"
<div className="flex items-center gap-2"> onClick={() => onRoundTypeChange(type)}
<TypeIcon className="h-4 w-4" /> className={`relative flex flex-col items-start gap-3 rounded-lg border-2 p-4 text-left transition-all duration-200 hover:shadow-md ${
{roundTypeLabels[type]} 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> </div>
</SelectItem> )}
) <div className={`rounded-lg p-2 ${isSelected ? 'bg-primary/10' : 'bg-muted'}`}>
})} <TypeIcon className={`h-5 w-5 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`} />
</SelectContent> </div>
</Select> <div>
<p className="text-sm text-muted-foreground"> <p className={`font-medium ${isSelected ? 'text-primary' : ''}`}>
{roundTypeDescriptions[roundType]} {roundTypeLabels[type]}
</p> </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> </div>
{/* Type-specific settings */} {/* Type-specific settings */}
@ -440,6 +461,39 @@ function LiveEventSettings({
</p> </p>
</div> </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 className="flex items-center justify-between">
<div> <div>
<Label>Allow Vote Change</Label> <Label>Allow Vote Change</Label>
@ -456,6 +510,105 @@ function LiveEventSettings({
</div> </div>
</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 */} {/* Display */}
<div className="space-y-4"> <div className="space-y-4">
<h5 className="text-sm font-medium">Display</h5> <h5 className="text-sm font-medium">Display</h5>
@ -504,7 +657,7 @@ function LiveEventSettings({
<Alert> <Alert>
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertDescription> <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. is activated.
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View File

@ -10,6 +10,13 @@ export interface VoteUpdate {
timestamp: string timestamp: string
} }
export interface AudienceVoteUpdate {
projectId: string
audienceVotes: number
audienceAverage: number | null
timestamp: string
}
export interface SessionStatusUpdate { export interface SessionStatusUpdate {
status: string status: string
timestamp: string timestamp: string
@ -23,6 +30,7 @@ export interface ProjectChangeUpdate {
interface SSECallbacks { interface SSECallbacks {
onVoteUpdate?: (data: VoteUpdate) => void onVoteUpdate?: (data: VoteUpdate) => void
onAudienceVote?: (data: AudienceVoteUpdate) => void
onSessionStatus?: (data: SessionStatusUpdate) => void onSessionStatus?: (data: SessionStatusUpdate) => void
onProjectChange?: (data: ProjectChangeUpdate) => void onProjectChange?: (data: ProjectChangeUpdate) => void
onConnected?: () => 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) => { es.addEventListener('session_status', (event) => {
try { try {
const data = JSON.parse(event.data) as SessionStatusUpdate const data = JSON.parse(event.data) as SessionStatusUpdate

View File

@ -1,7 +1,9 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { randomUUID } from 'crypto'
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit' import { logAudit } from '../utils/audit'
import type { LiveVotingCriterion } from '@/types/round-settings'
export const liveVotingRouter = router({ export const liveVotingRouter = router({
/** /**
@ -46,7 +48,7 @@ export const liveVotingRouter = router({
} }
// Get current votes if voting is in progress // Get current votes if voting is in progress
let currentVotes: { userId: string; score: number }[] = [] let currentVotes: { userId: string | null; score: number }[] = []
if (session.currentProjectId) { if (session.currentProjectId) {
const votes = await ctx.prisma.liveVote.findMany({ const votes = await ctx.prisma.liveVote.findMany({
where: { where: {
@ -58,9 +60,15 @@ export const liveVotingRouter = router({
currentVotes = votes currentVotes = votes
} }
// Get audience voter count
const audienceVoterCount = await ctx.prisma.audienceVoter.count({
where: { sessionId: session.id },
})
return { return {
...session, ...session,
currentVotes, currentVotes,
audienceVoterCount,
} }
}), }),
@ -115,6 +123,8 @@ export const liveVotingRouter = router({
status: session.status, status: session.status,
votingStartedAt: session.votingStartedAt, votingStartedAt: session.votingStartedAt,
votingEndsAt: session.votingEndsAt, votingEndsAt: session.votingEndsAt,
votingMode: session.votingMode,
criteriaJson: session.criteriaJson,
}, },
round: session.round, round: session.round,
currentProject, currentProject,
@ -202,6 +212,132 @@ export const liveVotingRouter = router({
return session 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 * 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 vote: protectedProcedure
.input( .input(
@ -296,6 +432,9 @@ export const liveVotingRouter = router({
sessionId: z.string(), sessionId: z.string(),
projectId: z.string(), projectId: z.string(),
score: z.number().int().min(1).max(10), score: z.number().int().min(1).max(10),
criterionScores: z
.record(z.string(), z.number())
.optional(),
}) })
) )
.mutation(async ({ ctx, input }) => { .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) // Upsert vote (allow vote change during window)
const vote = await ctx.prisma.liveVote.upsert({ const vote = await ctx.prisma.liveVote.upsert({
where: { where: {
@ -339,10 +518,12 @@ export const liveVotingRouter = router({
sessionId: input.sessionId, sessionId: input.sessionId,
projectId: input.projectId, projectId: input.projectId,
userId: ctx.user.id, userId: ctx.user.id,
score: input.score, score: finalScore,
criterionScoresJson: criterionScoresJson ?? undefined,
}, },
update: { update: {
score: input.score, score: finalScore,
criterionScoresJson: criterionScoresJson ?? undefined,
votedAt: new Date(), votedAt: new Date(),
}, },
}) })
@ -354,7 +535,13 @@ export const liveVotingRouter = router({
* Get results for a session (with weighted jury + audience scoring) * Get results for a session (with weighted jury + audience scoring)
*/ */
getResults: protectedProcedure 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 }) => { .query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId }, where: { id: input.sessionId },
@ -367,8 +554,9 @@ export const liveVotingRouter = router({
}, },
}) })
const audienceWeight = session.audienceVoteWeight || 0 // Use custom weights if provided, else session defaults
const juryWeight = 1 - audienceWeight const audienceWeightVal = input.audienceWeight ?? session.audienceVoteWeight ?? 0
const juryWeightVal = input.juryWeight ?? (1 - audienceWeightVal)
// Get jury votes grouped by project // Get jury votes grouped by project
const juryScores = await ctx.prisma.liveVote.groupBy({ 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])) 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 // Combine and calculate weighted scores
const results = juryScores const results = juryScores
.map((jurySc) => { .map((jurySc) => {
@ -407,8 +628,8 @@ export const liveVotingRouter = router({
const audienceSc = audienceMap.get(jurySc.projectId) const audienceSc = audienceMap.get(jurySc.projectId)
const juryAvg = jurySc._avg?.score || 0 const juryAvg = jurySc._avg?.score || 0
const audienceAvg = audienceSc?._avg?.score || 0 const audienceAvg = audienceSc?._avg?.score || 0
const weightedTotal = audienceWeight > 0 && audienceSc const weightedTotal = audienceWeightVal > 0 && audienceSc
? juryAvg * juryWeight + audienceAvg * audienceWeight ? juryAvg * juryWeightVal + audienceAvg * audienceWeightVal
: juryAvg : juryAvg
return { return {
@ -418,6 +639,7 @@ export const liveVotingRouter = router({
audienceAverage: audienceAvg, audienceAverage: audienceAvg,
audienceVoteCount: audienceSc?._count || 0, audienceVoteCount: audienceSc?._count || 0,
weightedTotal, weightedTotal,
criteriaAverages: criteriaBreakdown?.[jurySc.projectId] || null,
} }
}) })
.sort((a, b) => b.weightedTotal - a.weightedTotal) .sort((a, b) => b.weightedTotal - a.weightedTotal)
@ -436,6 +658,9 @@ export const liveVotingRouter = router({
results, results,
ties, ties,
tieBreakerMethod: session.tieBreakerMethod, 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(), allowAudienceVotes: z.boolean().optional(),
audienceVoteWeight: z.number().min(0).max(1).optional(), audienceVoteWeight: z.number().min(0).max(1).optional(),
tieBreakerMethod: z.enum(['admin_decides', 'highest_individual', 'revote']).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 }) => { .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( .input(
z.object({ z.object({
sessionId: z.string(), sessionId: z.string(),
projectId: z.string(), projectId: z.string(),
score: z.number().int().min(1).max(10), score: z.number().int().min(1).max(10),
token: z.string(),
}) })
) )
.mutation(async ({ ctx, input }) => { .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 // Verify session is in progress and allows audience votes
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({ const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId }, 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({ const vote = await ctx.prisma.liveVote.upsert({
where: { where: {
sessionId_projectId_userId: { sessionId_projectId_audienceVoterId: {
sessionId: input.sessionId, sessionId: input.sessionId,
projectId: input.projectId, projectId: input.projectId,
userId: ctx.user.id, audienceVoterId: voter.id,
}, },
}, },
create: { create: {
sessionId: input.sessionId, sessionId: input.sessionId,
projectId: input.projectId, projectId: input.projectId,
userId: ctx.user.id, audienceVoterId: voter.id,
score: input.score, score: input.score,
isAudienceVote: true, isAudienceVote: true,
}, },
@ -576,6 +864,70 @@ export const liveVotingRouter = router({
return vote 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) * Get public results for a live voting session (no auth required)
*/ */

View File

@ -38,6 +38,13 @@ export interface LiveEventRoundSettings {
votingWindowSeconds: number votingWindowSeconds: number
showLiveScores: boolean showLiveScores: boolean
allowVoteChange: boolean allowVoteChange: boolean
votingMode: 'simple' | 'criteria'
// Audience voting
audienceVotingMode: 'disabled' | 'per_project' | 'per_category' | 'favorites'
audienceMaxFavorites: number
audienceRequireId: boolean
audienceVotingDuration: number | null
// Display // Display
displayMode: 'SCORES' | 'RANKING' | 'NONE' displayMode: 'SCORES' | 'RANKING' | 'NONE'
@ -74,6 +81,11 @@ export const defaultLiveEventSettings: LiveEventRoundSettings = {
votingWindowSeconds: 30, votingWindowSeconds: 30,
showLiveScores: true, showLiveScores: true,
allowVoteChange: false, allowVoteChange: false,
votingMode: 'simple',
audienceVotingMode: 'disabled',
audienceMaxFavorites: 3,
audienceRequireId: false,
audienceVotingDuration: null,
displayMode: 'RANKING', displayMode: 'RANKING',
} }
@ -90,3 +102,43 @@ export const roundTypeDescriptions: Record<string, string> = {
EVALUATION: 'In-depth evaluation with detailed criteria and feedback', EVALUATION: 'In-depth evaluation with detailed criteria and feedback',
LIVE_EVENT: 'Real-time voting during presentations', 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
}