Round system redesign: criteria voting, audience voting, pipeline view, and admin UX improvements
- Schema: Extend LiveVotingSession with votingMode, criteriaJson, audience fields; add AudienceVoter model; make LiveVote.userId nullable for audience voters - Backend: Criteria-based voting with weighted scores, audience registration/voting with token-based dedup, configurable jury/audience weight in results - Jury UI: Criteria scoring with per-criterion sliders alongside simple 1-10 mode - Public audience voting page at /vote/[sessionId] with mobile-first design - Admin live voting: Tabbed layout (Session/Config/Results), criteria config, audience settings, weight-adjustable results with tie detection - Round type settings: Visual card selector replacing dropdown, feature tags - Round detail page: Live event status section, type-specific stats and actions - Round pipeline view: Horizontal visualization with bottleneck detection, List/Pipeline toggle on rounds page - SSE: Separate jury/audience vote events, audience vote tracking - Field visibility: Hide irrelevant fields per round type in create/edit forms Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,9 +14,11 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { toast } from 'sonner'
|
||||
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff } from 'lucide-react'
|
||||
import { Clock, CheckCircle, AlertCircle, Zap, Wifi, WifiOff, Send } from 'lucide-react'
|
||||
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
|
||||
import type { LiveVotingCriterion } from '@/types/round-settings'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ sessionId: string }>
|
||||
@@ -26,6 +28,7 @@ const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
|
||||
function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
const [selectedScore, setSelectedScore] = useState<number | null>(null)
|
||||
const [criterionScores, setCriterionScores] = useState<Record<string, number>>({})
|
||||
const [countdown, setCountdown] = useState<number | null>(null)
|
||||
|
||||
// Fetch session data - reduced polling since SSE handles real-time
|
||||
@@ -34,6 +37,9 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
{ refetchInterval: 10000 }
|
||||
)
|
||||
|
||||
const votingMode = data?.session.votingMode || 'simple'
|
||||
const criteria = (data?.session.criteriaJson as LiveVotingCriterion[] | null) || []
|
||||
|
||||
// SSE for real-time updates
|
||||
const onSessionStatus = useCallback(() => {
|
||||
refetch()
|
||||
@@ -41,6 +47,7 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
|
||||
const onProjectChange = useCallback(() => {
|
||||
setSelectedScore(null)
|
||||
setCriterionScores({})
|
||||
setCountdown(null)
|
||||
refetch()
|
||||
}, [refetch])
|
||||
@@ -88,12 +95,28 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
useEffect(() => {
|
||||
if (data?.userVote) {
|
||||
setSelectedScore(data.userVote.score)
|
||||
// Restore criterion scores if available
|
||||
if (data.userVote.criterionScoresJson) {
|
||||
setCriterionScores(data.userVote.criterionScoresJson as Record<string, number>)
|
||||
}
|
||||
} else {
|
||||
setSelectedScore(null)
|
||||
setCriterionScores({})
|
||||
}
|
||||
}, [data?.userVote, data?.currentProject?.id])
|
||||
|
||||
const handleVote = (score: number) => {
|
||||
// Initialize criterion scores with mid-values when criteria change
|
||||
useEffect(() => {
|
||||
if (votingMode === 'criteria' && criteria.length > 0 && Object.keys(criterionScores).length === 0) {
|
||||
const initial: Record<string, number> = {}
|
||||
for (const c of criteria) {
|
||||
initial[c.id] = Math.ceil(c.scale / 2)
|
||||
}
|
||||
setCriterionScores(initial)
|
||||
}
|
||||
}, [votingMode, criteria, criterionScores])
|
||||
|
||||
const handleSimpleVote = (score: number) => {
|
||||
if (!data?.currentProject) return
|
||||
setSelectedScore(score)
|
||||
vote.mutate({
|
||||
@@ -103,6 +126,37 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
const handleCriteriaVote = () => {
|
||||
if (!data?.currentProject) return
|
||||
|
||||
// Compute a rough overall score for the `score` field
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const cScore = criterionScores[c.id] || 1
|
||||
const normalizedScore = (cScore / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
|
||||
|
||||
vote.mutate({
|
||||
sessionId,
|
||||
projectId: data.currentProject.id,
|
||||
score: computedScore,
|
||||
criterionScores,
|
||||
})
|
||||
}
|
||||
|
||||
const computeWeightedScore = (): number => {
|
||||
if (criteria.length === 0) return 0
|
||||
let weightedSum = 0
|
||||
for (const c of criteria) {
|
||||
const cScore = criterionScores[c.id] || 1
|
||||
const normalizedScore = (cScore / c.scale) * 10
|
||||
weightedSum += normalizedScore * c.weight
|
||||
}
|
||||
return Math.round(Math.min(10, Math.max(1, weightedSum)) * 10) / 10
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <JuryVotingSkeleton />
|
||||
}
|
||||
@@ -169,27 +223,83 @@ function JuryVotingContent({ sessionId }: { sessionId: string }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Score buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-center">Your Score</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{SCORE_OPTIONS.map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
className="h-14 text-xl font-bold"
|
||||
onClick={() => handleVote(score)}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
{/* Voting UI - Simple mode */}
|
||||
{votingMode === 'simple' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-center">Your Score</p>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{SCORE_OPTIONS.map((score) => (
|
||||
<Button
|
||||
key={score}
|
||||
variant={selectedScore === score ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
className="h-14 text-xl font-bold"
|
||||
onClick={() => handleSimpleVote(score)}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
>
|
||||
{score}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
1 = Low, 10 = Excellent
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
1 = Low, 10 = Excellent
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Voting UI - Criteria mode */}
|
||||
{votingMode === 'criteria' && criteria.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium text-center">Score Each Criterion</p>
|
||||
{criteria.map((c) => (
|
||||
<div key={c.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{c.label}</p>
|
||||
{c.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{c.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-lg font-bold text-primary ml-3 w-12 text-right">
|
||||
{criterionScores[c.id] || 1}/{c.scale}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={c.scale}
|
||||
step={1}
|
||||
value={[criterionScores[c.id] || 1]}
|
||||
onValueChange={([val]) => {
|
||||
setCriterionScores((prev) => ({
|
||||
...prev,
|
||||
[c.id]: val,
|
||||
}))
|
||||
}}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Computed weighted score */}
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<p className="text-sm font-medium">Weighted Score</p>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{computeWeightedScore().toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleCriteriaVote}
|
||||
disabled={vote.isPending || countdown === 0}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{hasVoted ? 'Update Vote' : 'Submit Vote'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vote status */}
|
||||
{hasVoted && (
|
||||
|
||||
Reference in New Issue
Block a user