docs(finale): approved design spec for grand-finale ceremony system (option C)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
Reference in New Issue
Block a user