diff --git a/.gitignore b/.gitignore index 849fc51..c11c861 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,8 @@ build-output.txt private/ public/build-id.json .remember/ + +# Local tooling + session screenshots +.claude/ +.serena/ +/*.png diff --git a/package-lock.json b/package-lock.json index c585cbd..c3bc93e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "openai": "^6.16.0", "papaparse": "^5.4.1", "pdf-parse": "^2.4.5", + "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-day-picker": "^9.13.0", "react-dom": "^19.0.0", @@ -13417,6 +13418,15 @@ ], "license": "MIT" }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", diff --git a/package.json b/package.json index 98d69f7..7742a52 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "openai": "^6.16.0", "papaparse": "^5.4.1", "pdf-parse": "^2.4.5", + "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-day-picker": "^9.13.0", "react-dom": "^19.0.0", diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index c9bbcfd..6569e51 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -79,6 +79,8 @@ import { ListChecks, FileText, Languages, + MonitorPlay, + Scale, } from 'lucide-react' import { Tooltip, @@ -93,6 +95,8 @@ import { FileRequirementsEditor } from '@/components/admin/round/file-requiremen import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard' import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview' import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table' +import { LiveControlPanel } from '@/components/admin/live/live-control-panel' +import { DeliberationControlPanel } from '@/components/admin/deliberation/deliberation-control-panel' import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card' import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card' import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card' @@ -973,6 +977,10 @@ export default function RoundDetailPage() { ...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []), ...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []), ...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []), + ...(isGrandFinale ? [{ value: 'ceremony', label: 'Ceremony', icon: MonitorPlay }] : []), + ...(round?.roundType === 'DELIBERATION' + ? [{ value: 'deliberation', label: 'Deliberation', icon: Scale }] + : []), ...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []), ...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []), { value: 'config', label: 'Config', icon: Settings }, @@ -1662,6 +1670,20 @@ export default function RoundDetailPage() { )} + {/* ═══════════ CEREMONY TAB (LIVE_FINAL) ═══════════ */} + {isGrandFinale && ( + + + + )} + + {/* ═══════════ DELIBERATION TAB (DELIBERATION rounds) ═══════════ */} + {round?.roundType === 'DELIBERATION' && ( + + + + )} + {/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */} {hasJury && !isEvaluation && ( diff --git a/src/app/(jury)/jury/competitions/[roundId]/live/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/live/page.tsx index 9eaf612..d8ae3b7 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/live/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/live/page.tsx @@ -1,76 +1,142 @@ 'use client' -import { use, useState } from 'react' +import { use, useEffect, useMemo, useRef, useState } from 'react' import { trpc } from '@/lib/trpc/client' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Textarea } from '@/components/ui/textarea' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { ChevronDown, ChevronUp } from 'lucide-react' import { LiveVotingForm } from '@/components/jury/live-voting-form' +import { remainingSeconds, formatClock } from '@/lib/live-timer' +import { Clock, Mic2, MessageCircleQuestion, PenLine, Sparkles } from 'lucide-react' import { toast } from 'sonner' +const PHASE_META: Record = { + PRESENTING: { label: 'Presentation', icon: Mic2 }, + QA: { label: 'Q&A', icon: MessageCircleQuestion }, + SCORING: { label: 'Scoring open', icon: PenLine }, +} + +function PhaseCountdown({ phase }: { phase: { + phaseStartedAt: Date | string | null + phaseDurationSeconds: number | null + phasePausedAt: Date | string | null + phasePausedAccumMs: number +} }) { + const [, tick] = useState(0) + useEffect(() => { + const id = setInterval(() => tick((t) => t + 1), 1000) + return () => clearInterval(id) + }, []) + const remaining = remainingSeconds(phase) + if (remaining === null) return null + const over = remaining < 0 + return ( + + + {formatClock(remaining)} + {over && OVER} + {phase.phasePausedAt && · paused} + + ) +} + export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) { const params = use(paramsPromise) const utils = trpc.useUtils() - const [notes, setNotes] = useState('') - const [priorDataOpen, setPriorDataOpen] = useState(false) - const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId }) - - // Fetch live voting session data - const { data: sessionData } = trpc.liveVoting.getSessionForVoting.useQuery( - { sessionId: params.roundId }, - { enabled: !!params.roundId, refetchInterval: 2000 } + const { data: cursor } = trpc.live.getCursor.useQuery( + { roundId: params.roundId }, + { refetchInterval: 2000 } ) + const { data: sessionData } = trpc.liveVoting.getSessionForVotingByRound.useQuery( + { roundId: params.roundId }, + { refetchInterval: 2000 } + ) + const { data: myNotes } = trpc.live.getMyNotes.useQuery({ roundId: params.roundId }) - // Placeholder for prior data - this would need to be implemented in evaluation router - const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null - - const submitVoteMutation = trpc.liveVoting.vote.useMutation({ - onSuccess: () => { - utils.liveVoting.getSessionForVoting.invalidate() - toast.success('Vote submitted successfully') - }, - onError: (err: any) => { - toast.error(err.message) - }, + // ── Persisted notes (autosave, keyed per project) ──────────────────────── + const [noteDrafts, setNoteDrafts] = useState>({}) + const [noteStatus, setNoteStatus] = useState<'idle' | 'saving' | 'saved'>('idle') + const saveTimer = useRef | null>(null) + const saveNote = trpc.live.saveNote.useMutation({ + onSuccess: () => setNoteStatus('saved'), + onError: () => setNoteStatus('idle'), }) - const handleVoteSubmit = (vote: { score: number; criterionScores?: Record }) => { - const projectId = cursor?.activeProject?.id || sessionData?.currentProject?.id - if (!projectId) return + const activeProject = cursor?.activeProject ?? null + const activeProjectId = activeProject?.id ?? null - const sessionId = sessionData?.session?.id || params.roundId + const savedNoteFor = useMemo(() => { + const map: Record = {} + for (const n of myNotes ?? []) map[n.projectId] = n.content + return map + }, [myNotes]) + const currentDraft = + activeProjectId != null + ? noteDrafts[activeProjectId] ?? savedNoteFor[activeProjectId] ?? '' + : '' + + const handleNoteChange = (value: string) => { + if (!activeProjectId) return + setNoteDrafts((d) => ({ ...d, [activeProjectId]: value })) + setNoteStatus('saving') + if (saveTimer.current) clearTimeout(saveTimer.current) + const projectId = activeProjectId + saveTimer.current = setTimeout(() => { + saveNote.mutate({ roundId: params.roundId, projectId, content: value }) + }, 800) + } + + // ── Voting ─────────────────────────────────────────────────────────────── + const submitVoteMutation = trpc.liveVoting.vote.useMutation({ + onSuccess: () => { + utils.liveVoting.getSessionForVotingByRound.invalidate() + toast.success('Vote submitted') + }, + onError: (err) => toast.error(err.message), + }) + + const handleVoteSubmit = (vote: { + score: number + criterionScores?: Record + comment?: string + }) => { + if (!activeProjectId || !sessionData?.session?.id) return submitVoteMutation.mutate({ - sessionId, - projectId, + sessionId: sessionData.session.id, + projectId: activeProjectId, score: vote.score, criterionScores: vote.criterionScores, + comment: vote.comment, }) } - // Extract voting mode and criteria from session const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria' - const criteria = (sessionData?.session?.criteriaJson as Array<{ - id: string - label: string - description?: string - scale: number - weight: number - }> | undefined) + const criteria = sessionData?.session?.criteriaJson as + | Array<{ id: string; label: string; description?: string; scale: number; weight: number }> + | undefined - const activeProject = cursor?.activeProject || sessionData?.currentProject + const phase = cursor?.projectPhase ?? 'ON_DECK' + const categoryLabel = + activeProject?.competitionCategory === 'STARTUP' + ? 'Startup' + : activeProject?.competitionCategory === 'BUSINESS_CONCEPT' + ? 'Business Concept' + : null if (!activeProject) { return (
-

Waiting for ceremony to begin...

+ +

Waiting for the ceremony to begin…

- The admin will control which project is displayed + Projects will appear here automatically as they take the stage

@@ -78,105 +144,113 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis ) } + // ── ON_DECK: "Up next" banner, no scoring yet ─────────────────────────── + if (phase === 'ON_DECK') { + return ( +
+ + +

+ Up next +

+

{activeProject.title}

+ {activeProject.teamName && ( +

{activeProject.teamName}

+ )} + {categoryLabel && ( + {categoryLabel} + )} +

Presentation starting shortly

+
+
+ {activeProject.description && ( + + + About this project + + +

{activeProject.description}

+
+
+ )} +
+ ) + } + + const phaseMeta = PHASE_META[phase] ?? PHASE_META.PRESENTING + const PhaseIcon = phaseMeta.icon + return (
- {/* Current Project Display */} + {/* Current Project + phase */} -
+
{activeProject.title} - - Live project presentation + + {activeProject.teamName} + {categoryLabel ? ` · ${categoryLabel}` : ''}
- {votingMode === 'criteria' && ( - Criteria Voting - )} +
+ + + {phaseMeta.label} + + {cursor && } +
+
+ + {activeProject.description && ( + +

{activeProject.description}

+
+ )} + + + {/* Notes — persisted, autosaved */} + + +
+
+ Your Notes + Private — resurfaced during deliberation +
+ + {noteStatus === 'saving' ? 'Saving…' : noteStatus === 'saved' ? 'Saved' : ''} +
- - {activeProject.description && ( -

{activeProject.description}

- )} -
-
- - {/* Prior Jury Data (Collapsible) */} - {priorData && ( - - - - -
- Prior Evaluation Data - {priorDataOpen ? ( - - ) : ( - - )} -
-
-
- - -
-
-

Average Score

-

- {priorData.averageScore?.toFixed(1) || 'N/A'} -

-
-
-

Evaluations

-

{priorData.evaluationCount || 0}

-
-
- {priorData.strengths && ( -
-

Key Strengths

-

{priorData.strengths}

-
- )} - {priorData.weaknesses && ( -
-

Areas for Improvement

-

{priorData.weaknesses}

-
- )} -
-
-
-
- )} - - {/* Notes Section */} - - - Your Notes - Optional notes for this project -