All checks were successful
Build and Push Docker Image / build (push) Successful in 12m17s
Mechanical sweep of 41 files via `perl -i -pe 's{\s+dark:[\w:/\[\]\.\-]+}{}g'`.
All dark: variants were paired with light-mode counterparts already; no
elements relied on a dark:-only style.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
6.4 KiB
TypeScript
165 lines
6.4 KiB
TypeScript
'use client'
|
|
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
|
import { Radio, Users, Trophy, Eye, EyeOff } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const SESSION_STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; pulse?: boolean }> = {
|
|
NOT_STARTED: { label: 'Not Started', color: 'text-slate-500', bg: 'bg-slate-100' },
|
|
IN_PROGRESS: { label: 'In Progress', color: 'text-emerald-600', bg: 'bg-emerald-50', pulse: true },
|
|
PAUSED: { label: 'Paused', color: 'text-amber-600', bg: 'bg-amber-50' },
|
|
COMPLETED: { label: 'Completed', color: 'text-blue-600', bg: 'bg-blue-50' },
|
|
}
|
|
|
|
export function LiveFinalPanel({ roundId }: { roundId: string }) {
|
|
const { data: liveDash, isLoading } = trpc.analytics.getLiveFinalDashboard.useQuery(
|
|
{ roundId },
|
|
{ refetchInterval: 10_000 },
|
|
)
|
|
|
|
const { data: roundStats } = trpc.analytics.getRoundTypeStats.useQuery(
|
|
{ roundId },
|
|
{ refetchInterval: 30_000 },
|
|
)
|
|
|
|
const stats = roundStats?.stats as {
|
|
sessionStatus: string
|
|
voteCount: number
|
|
} | undefined
|
|
|
|
const sessionStatus = liveDash?.sessionStatus ?? stats?.sessionStatus ?? 'NOT_STARTED'
|
|
const statusConfig = SESSION_STATUS_CONFIG[sessionStatus] ?? SESSION_STATUS_CONFIG.NOT_STARTED
|
|
|
|
const jurors = liveDash?.jurors ?? []
|
|
const votedCount = jurors.filter((j: any) => j.hasVoted).length
|
|
const standings = liveDash?.standings ?? []
|
|
const visibility = liveDash?.observerScoreVisibility ?? 'after_completion'
|
|
const scoresVisible = visibility === 'realtime'
|
|
|| (visibility === 'after_completion' && sessionStatus === 'COMPLETED')
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Session Status Card */}
|
|
{isLoading ? (
|
|
<Skeleton className="h-24 rounded-lg" />
|
|
) : (
|
|
<Card className={cn('p-5', statusConfig.bg)}>
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative">
|
|
<Radio className={cn('h-8 w-8', statusConfig.color)} />
|
|
{statusConfig.pulse && (
|
|
<span className="absolute -top-0.5 -right-0.5 h-3 w-3 rounded-full bg-emerald-500 animate-pulse" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className={cn('text-lg font-semibold', statusConfig.color)}>
|
|
{statusConfig.label}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{liveDash?.voteCount ?? stats?.voteCount ?? 0} votes cast
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Vote Count */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Card className="p-3 text-center">
|
|
<p className="text-2xl font-semibold tabular-nums">
|
|
{liveDash?.voteCount ?? stats?.voteCount ?? 0}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Total Votes</p>
|
|
</Card>
|
|
<Card className="p-3 text-center">
|
|
<p className="text-2xl font-semibold tabular-nums">
|
|
{votedCount}/{jurors.length}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Jurors Voted</p>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Juror Participation */}
|
|
{jurors.length > 0 && (
|
|
<AnimatedCard index={1}>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="flex items-center gap-2 text-sm">
|
|
<Users className="h-4 w-4 text-violet-500" />
|
|
Juror Participation
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-1.5 max-h-[250px] overflow-y-auto">
|
|
{jurors.map((j: any) => (
|
|
<div key={j.id} className="flex items-center justify-between text-sm py-1">
|
|
<span className="truncate">{j.name}</span>
|
|
<Badge
|
|
variant={j.hasVoted ? 'default' : 'outline'}
|
|
className={cn(
|
|
'text-xs',
|
|
j.hasVoted && 'bg-emerald-500 hover:bg-emerald-600',
|
|
)}
|
|
>
|
|
{j.hasVoted ? 'Voted' : 'Pending'}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</AnimatedCard>
|
|
)}
|
|
|
|
{/* Standings / Score Visibility */}
|
|
<AnimatedCard index={2}>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="flex items-center gap-2 text-sm">
|
|
<Trophy className="h-4 w-4 text-amber-500" />
|
|
Standings
|
|
{scoresVisible ? (
|
|
<Eye className="h-3.5 w-3.5 text-emerald-500 ml-auto" />
|
|
) : (
|
|
<EyeOff className="h-3.5 w-3.5 text-muted-foreground ml-auto" />
|
|
)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{scoresVisible && standings.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{standings.map((s: any, i: number) => (
|
|
<div key={s.projectId} className="flex items-center justify-between text-sm py-1">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-muted-foreground tabular-nums font-medium w-5 text-right">
|
|
{i + 1}.
|
|
</span>
|
|
<span className="truncate">{s.projectTitle}</span>
|
|
</div>
|
|
<Badge variant="secondary" className="tabular-nums shrink-0">
|
|
{typeof s.score === 'number' ? s.score.toFixed(1) : s.score}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-6">
|
|
<EyeOff className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{sessionStatus === 'COMPLETED'
|
|
? 'Scores are hidden by admin configuration.'
|
|
: 'Scores will be revealed when voting completes.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</AnimatedCard>
|
|
</div>
|
|
)
|
|
}
|