diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cdfedce..237968b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } // ============================================================================= diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx index 9b2cd0b..9c7169b 100644 --- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx @@ -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 && ( )} + {ROUND_FIELD_VISIBILITY[roundType]?.showAssignmentLimits && (
+ )} diff --git a/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx b/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx index 09d818a..476df49 100644 --- a/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/live-voting/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' 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 { Card, CardContent, @@ -24,6 +25,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs' import { toast } from 'sonner' import { ArrowLeft, @@ -40,7 +47,10 @@ import { QrCode, Settings2, Scale, - UserCheck, + Trophy, + BarChart3, + ListOrdered, + ClipboardCheck, } from 'lucide-react' import { DndContext, @@ -59,8 +69,9 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { useLiveVotingSSE, type VoteUpdate } from '@/hooks/use-live-voting-sse' +import { useLiveVotingSSE, type VoteUpdate, type AudienceVoteUpdate } from '@/hooks/use-live-voting-sse' import { QRCodeDisplay } from '@/components/shared/qr-code-display' +import type { LiveVotingCriterion } from '@/types/round-settings' interface PageProps { params: Promise<{ id: string }> @@ -136,6 +147,8 @@ function LiveVotingContent({ roundId }: { roundId: string }) { const [votingDuration, setVotingDuration] = useState(30) const [liveVoteCount, setLiveVoteCount] = useState(null) const [liveAvgScore, setLiveAvgScore] = useState(null) + const [liveAudienceVotes, setLiveAudienceVotes] = useState(null) + const [liveAudienceAvg, setLiveAudienceAvg] = useState(null) // Fetch session data - reduced polling since SSE handles real-time const { data: sessionData, isLoading, refetch } = trpc.liveVoting.getSession.useQuery( @@ -143,12 +156,23 @@ function LiveVotingContent({ roundId }: { roundId: string }) { { refetchInterval: 5000 } ) + // Audience voter stats + const { data: audienceStats } = trpc.liveVoting.getAudienceVoterStats.useQuery( + { sessionId: sessionData?.id || '' }, + { enabled: !!sessionData?.id && !!sessionData?.allowAudienceVotes } + ) + // SSE for real-time vote updates const onVoteUpdate = useCallback((data: VoteUpdate) => { setLiveVoteCount(data.totalVotes) setLiveAvgScore(data.averageScore) }, []) + const onAudienceVote = useCallback((data: AudienceVoteUpdate) => { + setLiveAudienceVotes(data.audienceVotes) + setLiveAudienceAvg(data.audienceAverage) + }, []) + const onSessionStatus = useCallback(() => { refetch() }, [refetch]) @@ -156,6 +180,8 @@ function LiveVotingContent({ roundId }: { roundId: string }) { const onProjectChange = useCallback(() => { setLiveVoteCount(null) setLiveAvgScore(null) + setLiveAudienceVotes(null) + setLiveAudienceAvg(null) refetch() }, [refetch]) @@ -163,6 +189,7 @@ function LiveVotingContent({ roundId }: { roundId: string }) { sessionData?.id || null, { onVoteUpdate, + onAudienceVote, onSessionStatus, onProjectChange, } @@ -218,6 +245,16 @@ function LiveVotingContent({ roundId }: { roundId: string }) { }, }) + const setVotingMode = trpc.liveVoting.setVotingMode.useMutation({ + onSuccess: () => { + toast.success('Voting mode updated') + refetch() + }, + onError: (error) => { + toast.error(error.message) + }, + }) + const updatePresentationSettings = trpc.liveVoting.updatePresentationSettings.useMutation({ onSuccess: () => { toast.success('Presentation settings updated') @@ -362,335 +399,747 @@ function LiveVotingContent({ roundId }: { roundId: string }) { -
- {/* Main control panel */} -
- {/* Voting status */} - {isVoting && ( - - -
-
-

- Currently Voting -

-

- {projects.find((p) => p.id === sessionData.currentProjectId)?.title} -

-
-
-
- {countdown !== null ? countdown : '--'}s + + + Session + Configuration + Results + + + {/* SESSION TAB */} + +
+ {/* Main control panel */} +
+ {/* Voting status */} + {isVoting && ( + + +
+
+

+ Currently Voting +

+

+ {projects.find((p) => p.id === sessionData.currentProjectId)?.title} +

+
+
+
+ {countdown !== null ? countdown : '--'}s +
+

remaining

+
+
+ {countdown !== null && ( + + )} +
+ +
+
+
+ )} + + {/* Project order */} + + + Presentation Order + + Drag to reorder projects. Click "Start Voting" to begin voting + for a project. + + + + {allProjects.length === 0 ? ( +

+ No finalist projects found for this round +

+ ) : ( + + p.id)} + strategy={verticalListSortingStrategy} + > +
+ {allProjects.map((project) => ( +
+ + +
+ ))} +
+
+
+ )} +
+
+
+ + {/* Sidebar */} +
+ {/* Controls */} + + + Controls + + +
+ +
+ + setVotingDuration(parseInt(e.target.value) || 30) + } + className="w-20 px-2 py-1 border rounded text-center" + disabled={isVoting} + /> + seconds
-

remaining

-
- {countdown !== null && ( - + +
+ + + + {/* Live stats */} + + + + + Live Stats + {isConnected && ( + + )} + + + + {(() => { + const voteCount = liveVoteCount ?? sessionData.currentVotes.length + const avgScore = liveAvgScore ?? ( + sessionData.currentVotes.length > 0 + ? sessionData.currentVotes.reduce((sum, v) => sum + v.score, 0) / sessionData.currentVotes.length + : null + ) + return ( +
+
+ Jury Votes + {voteCount} +
+
+ Jury Average + + {avgScore !== null ? avgScore.toFixed(1) : '--'} + +
+ {sessionData.allowAudienceVotes && ( + <> +
+
+ Audience Votes + {liveAudienceVotes ?? 0} +
+
+ Audience Average + + {liveAudienceAvg !== null ? liveAudienceAvg.toFixed(1) : '--'} + +
+
+ Registered Voters + {audienceStats?.voterCount ?? sessionData.audienceVoterCount ?? 0} +
+ + )} +
+ ) + })()} + + + + {/* QR Codes & Links */} + + + + + Voting Links + + + Share these links with participants + + + + - )} -
- + {sessionData.allowAudienceVotes && ( + + )} + +
+
+
+
+
+ + + {/* CONFIGURATION TAB */} + +
+ {/* Voting Mode */} + + + + + Voting Mode + + + Choose how jurors submit their scores + + + +
+ + +
+ + {sessionData.votingMode === 'criteria' && ( +
+

Current Criteria

+ {(() => { + const criteria = (sessionData.criteriaJson as LiveVotingCriterion[] | null) || [] + if (criteria.length === 0) { + return ( +

+ No criteria configured. Import from an evaluation form or add manually. +

+ ) + } + return ( +
+ {criteria.map((c) => ( +
+
+ {c.label} + {c.description && ( + ({c.description}) + )} +
+
+ 1-{c.scale}, {(c.weight * 100).toFixed(0)}% +
+
+ ))} +
+ ) + })()} +
+ )} +
+
+ + {/* Session Config */} + + + + + Session Settings + + + +
+ + +
+ +
+ +
- )} - {/* Project order */} - - - Presentation Order - - Drag to reorder projects. Click "Start Voting" to begin voting - for a project. - - - - {allProjects.length === 0 ? ( -

- No finalist projects found for this round -

- ) : ( - - p.id)} - strategy={verticalListSortingStrategy} - > -
- {allProjects.map((project) => ( -
- - -
- ))} -
-
-
- )} -
-
-
- - {/* Sidebar */} -
- {/* Controls */} - - - Controls - - -
- -
- - setVotingDuration(parseInt(e.target.value) || 30) - } - className="w-20 px-2 py-1 border rounded text-center" - disabled={isVoting} - /> - seconds -
-
- -
- -
-
-
- - {/* Live stats */} - - - - - Current Votes - {isConnected && ( - - )} - - - - {(() => { - const voteCount = liveVoteCount ?? sessionData.currentVotes.length - const avgScore = liveAvgScore ?? ( - sessionData.currentVotes.length > 0 - ? sessionData.currentVotes.reduce((sum, v) => sum + v.score, 0) / sessionData.currentVotes.length - : null - ) - if (voteCount === 0) { - return ( -

- No votes yet -

- ) - } - return ( -
-
- Total votes - {voteCount} -
-
- Average score - - {avgScore !== null ? avgScore.toFixed(1) : '--'} - -
-
- ) - })()} -
-
- - {/* Session Configuration */} - - - - - Session Config - - - -
- - { - updateSessionConfig.mutate({ - sessionId: sessionData.id, - allowAudienceVotes: checked, - }) - }} - disabled={isCompleted} - /> -
+ + + Configure how the audience participates in voting + + + +
+
+ +

+ Allow non-authenticated audience members to vote +

+
+ { + updateSessionConfig.mutate({ + sessionId: sessionData.id, + allowAudienceVotes: checked, + }) + }} + disabled={isCompleted} + /> +
- {sessionData.allowAudienceVotes && ( -
- -
- { - updateSessionConfig.mutate({ - sessionId: sessionData.id, - audienceVoteWeight: parseInt(e.target.value) / 100, - }) - }} - className="flex-1" - disabled={isCompleted} - /> - - {Math.round((sessionData.audienceVoteWeight || 0) * 100)}% + {sessionData.allowAudienceVotes && ( +
+
+ + +
+ +
+ +
+ { + updateSessionConfig.mutate({ + sessionId: sessionData.id, + audienceVoteWeight: parseInt(e.target.value) / 100, + }) + }} + className="flex-1" + disabled={isCompleted} + /> + + {Math.round((sessionData.audienceVoteWeight || 0) * 100)}% + +
+
+ +
+
+ +

+ Audience must provide email/name +

+
+ { + updateSessionConfig.mutate({ + sessionId: sessionData.id, + audienceRequireId: checked, + }) + }} + disabled={isCompleted} + /> +
+ + {sessionData.audienceVotingMode === 'favorites' && ( +
+ + { + updateSessionConfig.mutate({ + sessionId: sessionData.id, + audienceMaxFavorites: parseInt(e.target.value) || 3, + }) + }} + disabled={isCompleted} + /> +
+ )} +
+ )} + + +
+ + + {/* RESULTS TAB */} + + + + +
+ ) +} + +function ResultsPanel({ sessionId, isCompleted }: { sessionId: string; isCompleted: boolean }) { + const [juryWeight, setJuryWeight] = useState(80) + const [audienceWeight, setAudienceWeight] = useState(20) + + const { data: results, isLoading } = trpc.liveVoting.getResults.useQuery( + { + sessionId, + juryWeight: juryWeight / 100, + audienceWeight: audienceWeight / 100, + }, + { enabled: !!sessionId } + ) + + const handleWeightChange = (jury: number) => { + setJuryWeight(jury) + setAudienceWeight(100 - jury) + } + + if (isLoading) { + return ( + + +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+
+ ) + } + + if (!results || results.results.length === 0) { + return ( + + + +

No results yet. Start voting to see results.

+
+
+ ) + } + + return ( +
+ {/* Weight sliders */} + {results.weights.audience > 0 && ( + + + + + Score Weighting + + + Adjust the relative weight of jury and audience scores + + + +
+
+ Jury + handleWeightChange(parseInt(e.target.value))} + className="flex-1" + /> + {juryWeight}% +
+
+ Audience + handleWeightChange(100 - parseInt(e.target.value))} + className="flex-1" + /> + {audienceWeight}% +
+
+
+
+ )} + + {/* Results table */} + + + + + Rankings + + + +
+ {/* Header */} +
+
#
+
Project
+
Jury
+
Audience
+
Votes
+
Total
+
+ + {results.results.map((result, index) => ( +
+ {/* Desktop */} +
+
+ {index + 1} +
+
+

{result.project?.title}

+ {result.project?.teamName && ( +

{result.project.teamName}

+ )} +
+
+ {result.juryAverage.toFixed(1)} +
+
+ {result.audienceVoteCount > 0 ? result.audienceAverage.toFixed(1) : '--'} +
+
+ {result.juryVoteCount + result.audienceVoteCount} +
+
+ {result.weightedTotal.toFixed(1)} +
+
+ + {/* Mobile */} +
+
+
+ #{index + 1} +
+

{result.project?.title}

+ {result.project?.teamName && ( +

{result.project.teamName}

+ )} +
+
+ + {result.weightedTotal.toFixed(1)} + +
+
+ Jury: {result.juryAverage.toFixed(1)} + {result.audienceVoteCount > 0 && ( + Audience: {result.audienceAverage.toFixed(1)} + )} + + {result.juryVoteCount + result.audienceVoteCount} votes
- )} -
- - + {/* Criteria breakdown */} + {result.criteriaAverages && results.criteria && ( +
+ {results.criteria.map((c) => ( +
+ {c.label} + + {result.criteriaAverages?.[c.id]?.toFixed(1) || '--'}/{c.scale} + +
+ ))} +
+ )}
+ ))} +
-
- - -
- - - - {/* QR Codes & Links */} - - - - - Voting Links - - - Share these links with participants - - - - - -
- - -
-
-
-
-
+ {/* Ties */} + {results.ties.length > 0 && ( + + + Ties Detected + + {results.ties.length} tie(s) detected. Tie-breaker method: {results.tieBreakerMethod?.replace('_', ' ')} + + + )} +
+
) } diff --git a/src/app/(admin)/admin/rounds/[id]/page.tsx b/src/app/(admin)/admin/rounds/[id]/page.tsx index 24cf0b6..00eb74e 100644 --- a/src/app/(admin)/admin/rounds/[id]/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/page.tsx @@ -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 }) {

{round.name}

{getStatusBadge()} + + {roundTypeLabels[round.roundType] || round.roundType} +
@@ -577,6 +591,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) { + {visibility.showAssignmentLimits && ( Judge Assignments @@ -593,7 +608,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) { + )} + {visibility.showRequiredReviews && ( Required Reviews @@ -606,28 +623,48 @@ function RoundDetailContent({ roundId }: { roundId: string }) {

per project

+ )} - Completion + + {isLiveEvent ? 'Session' : 'Completion'} +
- + {isLiveEvent ? ( + + ) : ( + + )}
-
- {progress?.completionPercentage || 0}% -
-

- {progress?.completedAssignments || 0} of {progress?.totalAssignments || 0} -

+ {isLiveEvent && liveSession ? ( + <> +
+ {liveSession.status === 'IN_PROGRESS' ? 'Live' : liveSession.status.toLowerCase().replace('_', ' ')} +
+

+ {liveSession.audienceVoterCount} audience voters +

+ + ) : ( + <> +
+ {progress?.completionPercentage || 0}% +
+

+ {progress?.completedAssignments || 0} of {progress?.totalAssignments || 0} +

+ + )}
- {/* Progress */} - {progress && progress.totalAssignments > 0 && ( + {/* Progress - only for evaluation rounds */} + {visibility.showRequiredReviews && progress && progress.totalAssignments > 0 && ( @@ -662,7 +699,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) { )} - {/* Voting Window */} + {/* Voting Window - only for evaluation rounds */} + {visibility.showVotingWindow && ( @@ -759,6 +797,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
+ )} {/* Filtering Section (for FILTERING rounds) */} {isFilteringRound && ( @@ -1268,8 +1307,133 @@ function RoundDetailContent({ roundId }: { roundId: string }) { )} + {/* Live Event Section (for LIVE_EVENT rounds) */} + {isLiveEventRound && liveSession && ( + + + +
+
+ +
+ +
+ Live Voting Session +
+ + Real-time voting during project presentations + +
+ +
+
+ + {/* Session Status */} +
+
+
+ {liveSession.status === 'IN_PROGRESS' ? ( + + ) : liveSession.status === 'COMPLETED' ? ( + + ) : ( + + )} +
+
+

+ {liveSession.status === 'IN_PROGRESS' ? 'Live Now' : liveSession.status.toLowerCase().replace('_', ' ')} +

+

Status

+
+
+
+
+ +
+
+

{liveSession.round.projects.length}

+

Projects

+
+
+
+
+ +
+
+

{liveSession.currentVotes.length}

+

Jury Votes

+
+
+
+
+ +
+
+

{liveSession.audienceVoterCount}

+

Audience

+
+
+
+ + {/* Current Status Indicator */} + {liveSession.status === 'IN_PROGRESS' && liveSession.currentProjectId && ( +
+
+
+
+

+ Voting in progress +

+

+ Project {(liveSession.currentProjectIndex ?? 0) + 1} of {liveSession.round.projects.length} +

+
+
+
+ )} + + {liveSession.status === 'COMPLETED' && ( +
+
+ +

+ Voting session completed - view results in the dashboard +

+
+
+ )} + + {/* Quick Links */} +
+ + {liveSession.allowAudienceVotes && ( + + )} +
+ + + + )} + {/* Quick Actions */} - + @@ -1311,44 +1475,70 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
- {/* Round Management */} + {/* Type-Specific Management */}
-

Round Management

+

+ {isFilteringRound ? 'Filtering' : isLiveEvent ? 'Live Event' : 'Evaluation'} Management +

- - - - - - - - -

Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.

-
-
-
+ {/* Filtering-specific actions */} + {isFilteringRound && ( + <> + + + )} + + {/* Evaluation-specific actions */} + {visibility.showAssignmentLimits && ( + + )} + + {/* Live Event-specific actions */} + {isLiveEvent && ( + + )} + + {/* Evaluation-round-only: AI Summaries */} + {!isFilteringRound && !isLiveEvent && ( + + + + + + +

Uses AI to analyze all submitted evaluations for projects in this round and generate summary insights including strengths, weaknesses, and scoring patterns.

+
+
+
+ )} + +
+ + + {(program.rounds && program.rounds.length > 0) ? ( + + ) : ( +
+ +

No rounds created yet

+
+ )} +
+ + + ))} +
+ ) + } + return (
{programs.map((program, index) => ( @@ -669,6 +711,8 @@ function RoundsListSkeleton() { } export default function RoundsPage() { + const [viewMode, setViewMode] = useState<'list' | 'pipeline'>('list') + return (
{/* Header */} @@ -679,11 +723,31 @@ export default function RoundsPage() { Manage selection rounds and voting periods

+
+ + +
{/* Content */} }> - +
) diff --git a/src/app/(jury)/jury/live/[sessionId]/page.tsx b/src/app/(jury)/jury/live/[sessionId]/page.tsx index 7143699..bca552b 100644 --- a/src/app/(jury)/jury/live/[sessionId]/page.tsx +++ b/src/app/(jury)/jury/live/[sessionId]/page.tsx @@ -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(null) + const [criterionScores, setCriterionScores] = useState>({}) const [countdown, setCountdown] = useState(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) + } } 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 = {} + 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 } @@ -169,27 +223,83 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {

- {/* Score buttons */} -
-

Your Score

-
- {SCORE_OPTIONS.map((score) => ( - - ))} + {/* Voting UI - Simple mode */} + {votingMode === 'simple' && ( +
+

Your Score

+
+ {SCORE_OPTIONS.map((score) => ( + + ))} +
+

+ 1 = Low, 10 = Excellent +

-

- 1 = Low, 10 = Excellent -

-
+ )} + + {/* Voting UI - Criteria mode */} + {votingMode === 'criteria' && criteria.length > 0 && ( +
+

Score Each Criterion

+ {criteria.map((c) => ( +
+
+
+

{c.label}

+ {c.description && ( +

+ {c.description} +

+ )} +
+ + {criterionScores[c.id] || 1}/{c.scale} + +
+ { + setCriterionScores((prev) => ({ + ...prev, + [c.id]: val, + })) + }} + disabled={vote.isPending || countdown === 0} + /> +
+ ))} + + {/* Computed weighted score */} +
+

Weighted Score

+ + {computeWeightedScore().toFixed(1)} + +
+ + +
+ )} {/* Vote status */} {hasVoted && ( diff --git a/src/app/(public)/vote/[sessionId]/page.tsx b/src/app/(public)/vote/[sessionId]/page.tsx new file mode 100644 index 0000000..1f0a638 --- /dev/null +++ b/src/app/(public)/vote/[sessionId]/page.tsx @@ -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(null) + const [identifier, setIdentifier] = useState('') + const [selectedScore, setSelectedScore] = useState(null) + const [countdown, setCountdown] = useState(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 + } + + if (!data) { + return ( +
+ + + Session Not Found + + This voting session does not exist or has ended. + + +
+ ) + } + + if (!data.session.allowAudienceVotes) { + return ( +
+ + + +

Audience Voting Not Available

+

+ Audience voting is not enabled for this session. +

+
+
+
+ ) + } + + // Registration step + if (!token) { + return ( +
+ + +
+ + Audience Voting +
+ + {data.session.round.program.name} - {data.session.round.name} + +
+ +

+ Register to participate in audience voting +

+ + {data.session.audienceRequireId && ( +
+ + setIdentifier(e.target.value)} + /> +

+ Required for audience voting verification +

+
+ )} + + {!data.session.audienceRequireId && ( +
+ + setIdentifier(e.target.value)} + /> +
+ )} + + +
+
+
+ ) + } + + // Voting UI + const isVoting = data.session.status === 'IN_PROGRESS' + + return ( +
+ + +
+ + Audience Voting +
+ + {data.session.round.program.name} - {data.session.round.name} + +
+ + + {isVoting && data.currentProject ? ( + <> + {/* Current project */} +
+ + Now Presenting + +

+ {data.currentProject.title} +

+ {data.currentProject.teamName && ( +

+ {data.currentProject.teamName} +

+ )} +
+ + {/* Timer */} +
+
+ {countdown !== null ? `${countdown}s` : '--'} +
+ +

+ Time remaining to vote +

+
+ + {/* Score buttons */} +
+

Your Score

+
+ {SCORE_OPTIONS.map((score) => ( + + ))} +
+

+ 1 = Low, 10 = Excellent +

+
+ + {/* Vote status */} + {hasVotedForProject && ( + + + + Your vote has been recorded! You can change it before time runs out. + + + )} + + ) : ( + /* Waiting state */ +
+ +

+ Waiting for Next Project +

+

+ {data.session.status === 'COMPLETED' + ? 'The voting session has ended. Thank you for participating!' + : 'Voting will begin when the next project is presented.'} +

+ {data.session.status !== 'COMPLETED' && ( +

+ This page will update automatically. +

+ )} +
+ )} +
+
+ + {/* Connection status */} +
+ {isConnected ? ( + + ) : ( + + )} +

+ {isConnected ? 'Connected' : 'Reconnecting...'} +

+
+
+ ) +} + +function AudienceVotingSkeleton() { + return ( +
+ + + + + + + + +
+ {[...Array(10)].map((_, i) => ( + + ))} +
+
+
+
+ ) +} + +export default function AudienceVotingPage({ params }: PageProps) { + const { sessionId } = use(params) + + return +} diff --git a/src/app/api/live-voting/stream/route.ts b/src/app/api/live-voting/stream/route.ts index b8c164a..f04f0ea 100644 --- a/src/app/api/live-voting/stream/route.ts +++ b/src/app/api/live-voting/stream/route.ts @@ -33,6 +33,7 @@ export async function GET(request: NextRequest): Promise { 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 { currentProjectId: true, currentProjectIndex: true, votingEndsAt: true, + allowAudienceVotes: true, }, }) @@ -86,19 +88,21 @@ export async function GET(request: NextRequest): Promise { // 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 { where: { sessionId, projectId: currentSession.currentProjectId, + isAudienceVote: false, }, _avg: { score: true }, _count: true, @@ -120,13 +125,43 @@ export async function GET(request: NextRequest): Promise { 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 diff --git a/src/components/admin/round-pipeline.tsx b/src/components/admin/round-pipeline.tsx new file mode 100644 index 0000000..59a417a --- /dev/null +++ b/src/components/admin/round-pipeline.tsx @@ -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 = { + FILTERING: Filter, + EVALUATION: ClipboardCheck, + LIVE_EVENT: Zap, +} + +const typeColors: Record = { + 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 = { + 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 ( +
+
+ {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 ( +
+ {/* Round Card */} + + + + + {/* Status indicator dot */} +
+
+
+ + {/* Type Icon */} +
+ +
+ + {/* Round Name */} +

+ {round.name} +

+ + {/* Stats Row */} +
+ + + {projectCount} + + {round._count?.assignments !== undefined && round._count.assignments > 0 && ( + + + {round._count.assignments} + + )} +
+ + {/* Status Badge */} + + + {status.label} + + + {/* Bottleneck indicator */} + {isBottleneck && ( +
+ +
+ )} + + + +
+

{round.name}

+

+ {round.roundType.toLowerCase().replace('_', ' ')} · {round.status.toLowerCase()} +

+

+ {projectCount} projects + {round._count?.assignments ? `, ${round._count.assignments} assignments` : ''} +

+ {isBottleneck && ( +

+ {dropRate}% drop from previous round +

+ )} +
+
+ + + + {/* Arrow connector */} + {index < rounds.length - 1 && ( +
+ + {prevCount > 0 && index > 0 && dropRate > 0 && ( + + -{dropRate}% + + )} + {index === 0 && projectCounts[0] > 0 && projectCounts[1] !== undefined && ( + + {projectCounts[0]} → {projectCounts[1] || '?'} + + )} +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/src/components/forms/round-type-settings.tsx b/src/components/forms/round-type-settings.tsx index 1af51ed..34d8b31 100644 --- a/src/components/forms/round-type-settings.tsx +++ b/src/components/forms/round-type-settings.tsx @@ -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 = { + 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), }) - const updateSetting = >( - key: keyof T, - value: T[keyof T] - ) => { - onSettingsChange({ ...settings, [key]: value }) - } - return ( @@ -86,30 +85,52 @@ export function RoundTypeSettings({ - {/* Round Type Selector */} -
+ {/* Round Type Selector - Visual Cards */} +
- -

- {roundTypeDescriptions[roundType]} -

+ )} +
+ +
+
+

+ {roundTypeLabels[type]} +

+

+ {roundTypeDescriptions[type]} +

+
+
+ {features.map((f) => ( + + {f} + + ))} +
+ + ) + })} +
{/* Type-specific settings */} @@ -440,6 +461,39 @@ function LiveEventSettings({

+
+ + +

+ {settings.votingMode === 'simple' + ? 'Jurors give a single 1-10 score per project' + : 'Jurors score each criterion separately, weighted into a final score'} +

+
+
@@ -456,6 +510,105 @@ function LiveEventSettings({
+ {/* Audience Voting */} +
+
+ + Audience Voting +
+ +
+ + +

+ How audience members can participate in voting +

+
+ + {settings.audienceVotingMode !== 'disabled' && ( +
+
+
+ +

+ Audience must provide email or name to vote +

+
+ + onChange({ ...settings, audienceRequireId: v }) + } + /> +
+ + {settings.audienceVotingMode === 'favorites' && ( +
+ + + onChange({ + ...settings, + audienceMaxFavorites: parseInt(e.target.value) || 3, + }) + } + /> +

+ Number of favorites each audience member can select +

+
+ )} + +
+ + { + const val = parseInt(e.target.value) + onChange({ + ...settings, + audienceVotingDuration: isNaN(val) ? null : val, + }) + }} + /> +

+ Leave empty to use the same window as jury voting +

+
+
+ )} +
+ {/* Display */}
Display
@@ -504,7 +657,7 @@ function LiveEventSettings({ - 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. diff --git a/src/hooks/use-live-voting-sse.ts b/src/hooks/use-live-voting-sse.ts index 8b46a7a..652a10d 100644 --- a/src/hooks/use-live-voting-sse.ts +++ b/src/hooks/use-live-voting-sse.ts @@ -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 diff --git a/src/server/routers/live-voting.ts b/src/server/routers/live-voting.ts index 1d5e286..0d2fb13 100644 --- a/src/server/routers/live-voting.ts +++ b/src/server/routers/live-voting.ts @@ -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> | 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 + 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) */ diff --git a/src/types/round-settings.ts b/src/types/round-settings.ts index 8d788a7..4e83bb1 100644 --- a/src/types/round-settings.ts +++ b/src/types/round-settings.ts @@ -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 = { 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 = { + 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 +}