14 KiB
Grand Finale Ceremony System — Design (Option C)
Status: Approved 2026-06-10. Event is 2026-06-11 — build tonight in strict dependency order (see Build Order). Each layer must leave the system complete and operable if work stops there.
Supersedes the operational scope of
2026-04-28-grand-finale-live-voting-rework.md(the audience-window section of that spec is implemented here as designed).
Decisions (locked with user)
- Jury scoring mode: criteria scores + optional comment (like prior evaluation rounds). Live ranking mode is a stretch goal only.
- Audience votes: per-category favorite windows always; an overall-favorite window exists behind an admin toggle (decided day-of).
- Vote gating: one vote per browser token per window AND max 3 votes per IP per window (venue NAT tolerance).
- Deliberation: per category (two sessions). Existing backend (FULL_RANKING/Borda, adminDecide override) is used as-is.
- Architecture: Option C = full B scope + big-screen ceremony view + results reveal controller. Big screen is derived from existing state — no new session-level phase machine. Only new state: reveal controller + display override slide.
- During audience windows the big screen shows vote count only ("147 votes cast"), never a live per-project tally.
- Big-screen results reveal must be visually outstanding — projector-grade, brand identity (dark blue
#053d57field, red#de0f1eaccent, Montserrat), animated transitions.
Current foundation (verified 2026-06-10)
LiveProgressCursor(cursor: activeProjectId, activeOrderIndex, isPaused) +live.tsrouter (start/jump/reorder/pause/resume/getCursor).LiveVotingSession/LiveVote/AudienceVoter+live-voting.tsrouter (criteria voting, importCriteriaFromForm, getResults, registerAudienceVoter/castAudienceVote, public results).- Jury live page
/jury/competitions/[roundId]/livefollows cursor (poll 5s); notes textarea is not persisted; prior-data panel stubbed. - Admin
live-control-panel.tsx: prev/next/pause/resume + client-local fake timer. - Public
/live-scores/[sessionId]scoreboard with SSE. - Deliberation backend + router 100% complete; jury deliberation page has
juryMemberId=''andhasVoted=falsehardcoded (jurors cannot vote). publicPathsinauth.config.tsdoes not include/voteor/live-scores→ audience pages bounce to login. Launch blocker.
1. Public access (do first)
Add /vote, /live-scores, /live/ceremony to publicPaths in src/lib/auth.config.ts (wherever publicPaths lives). Verify with curl -I (not the logged-in Playwright profile).
2. Schema changes (one migration)
// LiveProgressCursor — per-project phase + server-stamped timer
projectPhase LivePhase @default(ON_DECK) // ON_DECK | PRESENTING | QA | SCORING
phaseStartedAt DateTime?
phaseDurationSeconds Int?
phasePausedAt DateTime?
phasePausedAccumMs Int @default(0)
timingLogJson Json? // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
overrideSlide String? // 'welcome' | 'break' | 'deliberation' | 'thanks' | null
enum LivePhase { ON_DECK PRESENTING QA SCORING }
// LiveVotingSession — audience window (locked spec from 2026-04-28 + overall kind)
audiencePhase AudiencePhase @default(CLOSED) // CLOSED | OPEN
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
audienceWindowOpenedAt DateTime?
audienceWindowClosesAt DateTime?
allowOverallFavorite Boolean @default(false) // admin toggle, decided day-of
enum AudiencePhase { CLOSED OPEN }
// LiveVote — optional overall comment
comment String?
model AudienceFavoriteVote {
id String @id @default(cuid())
sessionId String
windowKey String // matches audienceWindowKey at cast time
projectId String
audienceVoterId String
ipAddress String?
createdAt DateTime @default(now())
@@unique([sessionId, windowKey, audienceVoterId])
@@index([sessionId, windowKey, ipAddress])
}
model LiveNote {
id String @id @default(cuid())
roundId String
projectId String
userId String
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([roundId, projectId, userId])
}
model RevealState {
id String @id @default(cuid())
sessionId String @unique
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
stepsJson Json // ordered reveal steps, see §8
currentStepIndex Int @default(-1)
}
Timer math (shared client+server helper src/lib/live-timer.ts): remaining = phaseDurationSeconds − (now − phaseStartedAt − phasePausedAccumMs); negative = overtime, displayed +m:ss in red. On every phase transition the server appends a timing-log entry with overranSeconds = max(0, elapsed − configured).
3. Ceremony control — live.ts router + admin panel revamp
New adminProcedure mutations on live:
sendToScreens({roundId, projectId?})— advance cursor to project (or next),projectPhase=ON_DECK, no timer. This is the "next up" grace.startPresentation({roundId, durationSeconds?})—PRESENTING, stampphaseStartedAt, duration from round configpresentationDurationMinutesunless overridden.startQA({roundId, durationSeconds?})— close out PRESENTING into timing log, start QA timer (qaDurationMinutesdefault).openScoring({roundId})— close QA into log,phase=SCORING, no timer.pausePhase/resumePhase— stampphasePausedAt/ fold intophasePausedAccumMs.setOverrideSlide({roundId, slide})— force big-screen slide or clear.getCursorextended to return phase, timer stamps, timing log, override slide.
Admin panel (live-control-panel.tsx revamp):
- Project order list grouped by category, current/next highlighted, reorder preserved (existing
reordermutation), tap-to-sendToScreensany project (handles schedule shuffles). - Big phase buttons in flow order; live server-derived countdown, red + counting up when over; pause/resume.
- Per-project duration override inputs (pre-filled from config).
- Timing log table (per project: presentation over by X, Q&A over by Y).
- Audience section: window open/close buttons per category + overall (gated by
allowOverallFavoritetoggle), duration picker (default 5 min), live countdown, live vote count, "Close now". QR button → full-screen dialog with giant QR (qrcode.reactor equivalent tiny dep) linking to/vote/competition/[roundId]. - Override-slide buttons: Welcome / Break / Deliberation / Thank you / Clear.
- Reveal section: see §9.
4. Jury live page
- Phase-aware: ON_DECK → "Up next: Team X" banner; PRESENTING/QA → project details, same countdown the admin sees, persistent notes; SCORING → scoring form spotlighted (form already available from PRESENTING on — early scorers not blocked).
- Notes:
LiveNoteautosave (debounced ~800ms) via newlive.saveNote/live.getMyNotes(juryProcedure). Notes resurface in deliberation. - Scoring: existing criteria form + new optional comment textarea → stored on
LiveVote.comment. Votes upsert-editable until session COMPLETED.
5. Audience voting
live-voting.ts additions:
openAudienceWindow({sessionId, windowKey, durationMinutes})(admin) — errors if already OPEN;OVERALLrequiresallowOverallFavorite.closeAudienceWindow({sessionId})(admin) — early close allowed anytime.getAudienceWindow({sessionId})(public) — phase, windowKey, closesAt, eligible projects (category members or all), my-vote-for-this-window (by token).castFavoriteVote({sessionId, token, projectId})(public). Server-side gates, in order:audiencePhase === OPENnow <= audienceWindowClosesAt(source of truth even if no cron closes it)- project's category matches windowKey (or OVERALL → any finalist project)
- token valid for session
- unique (session, windowKey, voter) — re-vote within open window updates the row (change-your-mind allowed while open)
- IP cap: ≥3 distinct-voter rows for (session, windowKey, ipAddress) → reject with friendly message
getFavoriteTallies({sessionId})(admin) — counts per project per windowKey.setAllowOverallFavorite({sessionId, allow})(admin).
Audience page /vote/competition/[roundId] (rework): scan → auto-registerAudienceVoter, token in localStorage → states: waiting ("Voting opens after the presentations" + current presenting team name), open (category title, project cards, tap → confirm → done, countdown chip), voted ("Vote recorded — you can change it until the window closes"), closed. Mobile-first, zero-instruction usable.
No cron needed: vote-time + read-time checks enforce the close; window auto-renders as closed everywhere once closesAt passes.
6. Deliberation completion
- Fix jury page wiring: resolve
juryMemberIdfromsession.participantsmatching current user; derivehasVotedfromsession.votes. - Add per-project context panels to the jury deliberation page: my finale scores (from
LiveVote, editable inline — edits upsert the same vote, audit-logged; "keep" = do nothing), my live notes (LiveNote), document links (reuse the judge-docs components/links from the finals docs feature). - Admin: existing create-session (per category), aggregate, runoff,
adminDecide(manual rankings), finalize, result lock — verify end-to-end, no new build expected.
7. Big-screen ceremony view — /live/ceremony/[roundId] (public)
Full-screen, no chrome, dark-blue field, Montserrat, brand accents. Pure derivation of state (poll ~2s + reuse SSE hook where available):
| State | Display |
|---|---|
overrideSlide set |
That slide (Welcome / Break / Deliberation in progress / Thank you) |
| Reveal REVEALING/ARMED | Reveal mode (§9) |
| Audience window OPEN | Giant QR + "Vote for your favorite {category}" + countdown + vote count only |
| Cursor ON_DECK | "Up next: Team X" |
| Cursor PRESENTING/QA | Team name, category chip, phase label, large countdown (red overtime) |
| Cursor SCORING | "Jury is scoring" interstitial |
| Nothing active | Welcome slide |
8. Reveal controller (admin) — §3 panel section
- "Build reveal" generates
stepsJsonfrom deliberation results (per category 3rd→2nd→1st) + audience favorite tallies (per-category winners, overall if enabled):[{kind:'category-intro',category}, {kind:'place',category,place,projectId}, …, {kind:'audience-award',windowKey,projectId}, {kind:'thanks'}]. Editable/rebuildable while DRAFT (e.g., after adminDecide changes order). - Admin previews all steps privately. "Arm" → big screen shows a Results splash. "Next" advances
currentStepIndexone step at a time. "Reset" → back to DRAFT, screen leaves reveal mode. - Router:
liveVoting.buildReveal,armReveal,revealNext,resetReveal(admin) + reveal state included in the public ceremony-state query (steps beyondcurrentStepIndexare never sent to the public endpoint).
9. Reveal visuals (the gorgeous part)
Use the frontend-design skill when building. Requirements: cinematic step transitions (place card slides/fades up, 1st-place moment visibly bigger than 3rd/2nd), confetti or equivalent flourish on 1st place and audience award, team name in very large Montserrat 700, category chip, no scores shown unless step includes them, safe on 16:9 projector at distance (high contrast, no small text). Prefer CSS/tailwind animation; adding framer-motion is acceptable if it materially raises quality. Must degrade gracefully (refresh mid-reveal lands on current step).
10. Results tally audit
- Jury results: existing
getResultsweighted aggregation + tie detection — dedicated tests. - Audience awards are counts from
AudienceFavoriteVote, kept separate from jury scores (separate awards).audienceVoteWeightblending stays available but is NOT used in the reveal unless explicitly configured; default 0.
Out of scope (explicit)
Live ranking mode for jurors (stretch only, after everything else), automated tie-breaker revotes (admin_decides stands), audience phones showing live scores (vote-only page), session-level ceremony phase machine.
Build order (cut line moves down, never breaks)
- publicPaths fix + schema migration
- Audience windows + favorite votes + IP cap + audience page + QR (audience system complete)
- Phase model + server timers + admin panel revamp (ceremony operable)
- Jury page phases + persisted notes + vote comments (jury complete)
- Deliberation wiring fix + context panels (deliberation complete)
- Big-screen ceremony view (derived states + override slides)
- Reveal controller + reveal visuals
- Tally audit tests → stretch: live ranking toggle
Each layer: vitest tests + npm run build green before the next.
Test matrix (vitest, follows tests/helpers.ts factory pattern)
- Window: open/cast OK; wrong-category reject; cast after closesAt reject (no cron); early close reject; re-open works; OVERALL requires toggle.
- Favorite votes: one per token per window; re-vote updates while open; IP cap at 3 distinct voters; tallies correct.
- Phases: transition sequence; timing log entries with correct overranSeconds; pause/resume accumulator math.
- Notes: upsert per (round, project, user).
- Deliberation: juryMemberId resolution, hasVoted, vote→aggregate→finalize with real juror identity.
- Reveal: build from results, step advance, public endpoint never leaks un-revealed steps.
- Results: weighted jury aggregation + tie detection regression tests.