feat(finale): full ceremony UI — admin console, jury phases+notes, deliberation flow, audience voting, big-screen view

- Admin Ceremony tab: phase driver with real server timers + overtime, run
  order with category grouping + send-to-screens, audience windows + QR
  dialog, override slides, timing log, results reveal builder/stepper
- Admin Deliberation tab: per-category session creation from the round's
  jury group, open/close voting, tally/runoff/override/finalize
- Jury live page: ON_DECK/PRESENTING/QA/SCORING aware, autosaved notes,
  vote comments; LiveVotingForm fixed (criteria score >10 rejection bug,
  permanent lock-out after submit) and made revisable
- Jury deliberation page: identityless submitVote, review-before-rank
  context (finale scores editable, ceremony notes, docs link)
- Jury nav: Live Ceremony + per-category Deliberation links
- Public: rebuilt QR audience voting page (anonymous-safe, favorite-pick
  windows, change-until-close), new /live/ceremony/[roundId] projector view
  with animated reveal, confetti, audience QR slide, override slides

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-10 18:34:50 +02:00
parent c9dc1bfabd
commit a2c6baf718
19 changed files with 2903 additions and 559 deletions

View File

@@ -12,6 +12,7 @@ type TimingEntry = {
startedAt: string
endedAt: string
configuredSeconds: number | null
elapsedSeconds: number // pause-adjusted actual duration
overranSeconds: number
}
@@ -34,6 +35,7 @@ function closedOutTiming(cursor: LiveProgressCursor, now: Date): Prisma.InputJso
startedAt: cursor.phaseStartedAt.toISOString(),
endedAt: now.toISOString(),
configuredSeconds: cursor.phaseDurationSeconds,
elapsedSeconds: elapsedSec,
overranSeconds:
cursor.phaseDurationSeconds == null
? 0
@@ -714,6 +716,31 @@ export const liveRouter = router({
})
}),
/**
* Ceremony navigation context for jurors: the active live round (if a
* ceremony cursor exists) and any deliberation sessions they participate in.
*/
getMyCeremonyContext: protectedProcedure.query(async ({ ctx }) => {
const cursor = await ctx.prisma.liveProgressCursor.findFirst({
where: { round: { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' } },
select: { roundId: true },
orderBy: { updatedAt: 'desc' },
})
const participations = await ctx.prisma.deliberationParticipant.findMany({
where: {
user: { userId: ctx.user.id },
session: { status: { in: ['DELIB_OPEN', 'VOTING', 'RUNOFF'] } },
},
select: {
session: { select: { id: true, category: true, status: true } },
},
})
return {
liveRoundId: cursor?.roundId ?? null,
deliberations: participations.map((p) => p.session),
}
}),
/**
* Get current cursor state (for all users, including audience)
*/