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

14 KiB
Raw Blame History

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)

// 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.