diff --git a/docs/superpowers/specs/2026-06-10-grand-finale-ceremony-design.md b/docs/superpowers/specs/2026-06-10-grand-finale-ceremony-design.md new file mode 100644 index 0000000..633a358 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-grand-finale-ceremony-design.md @@ -0,0 +1,201 @@ +# 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) + +1. **Jury scoring mode:** criteria scores + optional comment (like prior evaluation rounds). Live ranking mode is a stretch goal only. +2. **Audience votes:** per-category favorite windows always; an **overall-favorite** window exists behind an admin toggle (decided day-of). +3. **Vote gating:** one vote per browser token per window AND max **3 votes per IP per window** (venue NAT tolerance). +4. **Deliberation:** per category (two sessions). Existing backend (FULL_RANKING/Borda, adminDecide override) is used as-is. +5. **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. +6. **During audience windows the big screen shows vote count only** ("147 votes cast"), never a live per-project tally. +7. **Big-screen results reveal must be visually outstanding** — projector-grade, brand identity (dark blue `#053d57` field, red `#de0f1e` accent, Montserrat), animated transitions. + +## Current foundation (verified 2026-06-10) + +- `LiveProgressCursor` (cursor: activeProjectId, activeOrderIndex, isPaused) + `live.ts` router (start/jump/reorder/pause/resume/getCursor). +- `LiveVotingSession` / `LiveVote` / `AudienceVoter` + `live-voting.ts` router (criteria voting, importCriteriaFromForm, getResults, registerAudienceVoter/castAudienceVote, public results). +- Jury live page `/jury/competitions/[roundId]/live` follows 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=''` and `hasVoted=false` hardcoded (jurors cannot vote). +- `publicPaths` in `auth.config.ts` does **not** include `/vote` or `/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) + +```prisma +// 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`, stamp `phaseStartedAt`, duration from round config `presentationDurationMinutes` unless overridden. +- `startQA({roundId, durationSeconds?})` — close out PRESENTING into timing log, start QA timer (`qaDurationMinutes` default). +- `openScoring({roundId})` — close QA into log, `phase=SCORING`, no timer. +- `pausePhase` / `resumePhase` — stamp `phasePausedAt` / fold into `phasePausedAccumMs`. +- `setOverrideSlide({roundId, slide})` — force big-screen slide or clear. +- `getCursor` extended 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 `reorder` mutation), tap-to-`sendToScreens` any 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 `allowOverallFavorite` toggle), duration picker (default 5 min), live countdown, **live vote count**, "Close now". QR button → full-screen dialog with giant QR (`qrcode.react` or 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**: `LiveNote` autosave (debounced ~800ms) via new `live.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; `OVERALL` requires `allowOverallFavorite`. +- `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: + 1. `audiencePhase === OPEN` + 2. `now <= audienceWindowClosesAt` (source of truth even if no cron closes it) + 3. project's category matches windowKey (or OVERALL → any finalist project) + 4. token valid for session + 5. unique (session, windowKey, voter) — re-vote within open window **updates** the row (change-your-mind allowed while open) + 6. 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 `juryMemberId` from `session.participants` matching current user; derive `hasVoted` from `session.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 `stepsJson` from 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 `currentStepIndex` one 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 beyond `currentStepIndex` are **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 `getResults` weighted aggregation + tie detection — dedicated tests. +- Audience awards are **counts from `AudienceFavoriteVote`**, kept separate from jury scores (separate awards). `audienceVoteWeight` blending 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) + +1. publicPaths fix + schema migration +2. Audience windows + favorite votes + IP cap + audience page + QR (audience system complete) +3. Phase model + server timers + admin panel revamp (ceremony operable) +4. Jury page phases + persisted notes + vote comments (jury complete) +5. Deliberation wiring fix + context panels (deliberation complete) +6. Big-screen ceremony view (derived states + override slides) +7. Reveal controller + reveal visuals +8. 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.