Files
MOPC-Portal/docs/superpowers/specs/2026-06-10-grand-finale-ceremony-design.md
2026-06-10 17:54:31 +02:00

202 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.