docs(finale): implementation plan for grand-finale ceremony system

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-10 18:01:12 +02:00
parent 3be1fcd24a
commit dcd85c9b13

View File

@@ -0,0 +1,592 @@
# Grand Finale Ceremony System Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the full Option-C grand-finale ceremony system (admin-driven presentation phases with real timers + overtime log, audience QR favorite-voting with per-category windows, persisted juror notes/comments, deliberation completion, big-screen ceremony view with cinematic results reveal) before the 2026-06-11 event.
**Architecture:** Extend the existing `LiveProgressCursor` with a per-project phase state machine and server-stamped timers; extend `LiveVotingSession` with audience-window state and a new `AudienceFavoriteVote` pick-one model; big screen is a pure derivation of existing state plus a small `RevealState` controller. No new session-level phase machine.
**Tech Stack:** Next.js 15 App Router, tRPC 11, Prisma 6/PostgreSQL, Tailwind 4 + shadcn/ui, `motion` v11 (already installed) for reveal animations, `qrcode.react` (new tiny dep), Vitest 4.
**Spec:** `docs/superpowers/specs/2026-06-10-grand-finale-ceremony-design.md`
**Critical context for the implementer:**
- Two parallel live systems exist: `live.ts` (LiveProgressCursor, cohort-based votes) and `live-voting.ts` (LiveVotingSession, jury criteria votes + audience tokens). The finale uses **cursor for presentation flow** and **LiveVotingSession for all voting**. The cohort-based `castVote`/`castStageVote` in `live.ts` are NOT used for the finale — leave them alone.
- Source of truth for presentation order: `round.configJson.projectOrder` (managed by `live.start`/`live.reorder`).
- Known bug: jury live page passes `params.roundId` as `sessionId` to `getSessionForVoting` → NOT_FOUND. Fixed in Task 7.
- Known bug: deliberation jury page has `juryMemberId: ''` and `hasVoted = false` hardcoded. Fixed in Task 10.
- `LiveVotingSession.roundId` is `@unique`, so by-round lookup is safe.
- Project category field is `competitionCategory` (`STARTUP | BUSINESS_CONCEPT`), nullable.
- **NEVER** run `prisma migrate dev` if `migrate status` shows drift (memory rule) — use the create-only + `db execute` + `migrate resolve` path in Task 2.
- Run tests with `npx vitest run tests/unit/<file>` (sequential forks pool). Build check: `npm run build`. Always build before push.
---
### Task 1: Public paths for audience + ceremony routes
**Files:**
- Modify: `src/lib/auth.config.ts:52-65`
- Test: `tests/unit/auth-public-paths.test.ts` (extend existing)
- [ ] **Step 1: Extend the existing public-paths test** — read `tests/unit/auth-public-paths.test.ts` first and follow its existing assertion style; add cases asserting `/vote/competition/abc`, `/vote/xyz`, `/live-scores/xyz`, `/live/ceremony/abc` are public and that `/live` alone (jury route prefix is `/jury/...` so no conflict) does not accidentally open admin routes (assert `/admin` still private).
- [ ] **Step 2: Run** `npx vitest run tests/unit/auth-public-paths.test.ts` — expect new cases FAIL.
- [ ] **Step 3: Implement** — in `src/lib/auth.config.ts` add to `publicPaths`:
```ts
'/vote', // audience QR voting (token-based, no account)
'/live-scores', // public live scoreboard
'/live/ceremony', // big-screen ceremony view (projector)
```
- [ ] **Step 4: Run test again** — expect PASS. Also `curl -sI http://localhost:3000/vote/competition/x | head -3` (dev server) → must NOT be a redirect to /login.
- [ ] **Step 5: Commit** `fix(auth): make audience vote, live-scores and ceremony routes public`
---
### Task 2: Schema migration
**Files:**
- Modify: `prisma/schema.prisma` (LiveProgressCursor ~2152, LiveVotingSession ~1165, LiveVote ~1202, AudienceVoter ~1230, Round, Project, User back-relations)
- Create: `prisma/migrations/<ts>_grand_finale_ceremony/migration.sql`
- [ ] **Step 1: Add enums** (near other enums):
```prisma
enum LivePhase {
ON_DECK
PRESENTING
QA
SCORING
}
enum AudiencePhase {
CLOSED
OPEN
}
```
- [ ] **Step 2: Extend `LiveProgressCursor`:**
```prisma
projectPhase LivePhase @default(ON_DECK)
phaseStartedAt DateTime?
phaseDurationSeconds Int?
phasePausedAt DateTime?
phasePausedAccumMs Int @default(0)
timingLogJson Json? @db.JsonB // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
overrideSlide String? // 'welcome' | 'break' | 'deliberation' | 'thanks'
```
- [ ] **Step 3: Extend `LiveVotingSession`:**
```prisma
// Audience favorite-vote window (grand finale)
audiencePhase AudiencePhase @default(CLOSED)
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
audienceWindowOpenedAt DateTime?
audienceWindowClosesAt DateTime?
allowOverallFavorite Boolean @default(false)
```
and relations `favoriteVotes AudienceFavoriteVote[]`, `revealState RevealState?`.
- [ ] **Step 4: Extend `LiveVote`** with `comment String? @db.Text`, and `AudienceVoter` with `favoriteVotes AudienceFavoriteVote[]`.
- [ ] **Step 5: New models** (after AudienceVoter):
```prisma
model AudienceFavoriteVote {
id String @id @default(cuid())
sessionId String
windowKey String // matches LiveVotingSession.audienceWindowKey at cast time
projectId String
audienceVoterId String
ipAddress String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
audienceVoter AudienceVoter @relation(fields: [audienceVoterId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([sessionId, windowKey, audienceVoterId])
@@index([sessionId, windowKey, ipAddress])
@@index([sessionId, windowKey, projectId])
}
model LiveNote {
id String @id @default(cuid())
roundId String
projectId String
userId String
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([roundId, projectId, userId])
@@index([userId])
}
model RevealState {
id String @id @default(cuid())
sessionId String @unique
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
stepsJson Json @db.JsonB // RevealStep[] — see Task 8
currentStepIndex Int @default(-1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
}
```
Add back-relations: `Project.audienceFavoriteVotes AudienceFavoriteVote[]`, `Project.liveNotes LiveNote[]`, `Round.liveNotes LiveNote[]`, `User.liveNotes LiveNote[]`.
- [ ] **Step 6: Migrate safely.** `npx prisma migrate status` first. If clean: `npx prisma migrate dev --name grand_finale_ceremony`. If drifted: `npx prisma migrate dev --create-only --name grand_finale_ceremony`, review SQL, then `npx prisma db execute --file prisma/migrations/<ts>_grand_finale_ceremony/migration.sql` and `npx prisma migrate resolve --applied <ts>_grand_finale_ceremony`. Then `npx prisma generate`.
- [ ] **Step 7: Verify** `npm run typecheck` passes (pre-existing errors aside). Commit `feat(finale): schema for phases, audience windows, favorite votes, notes, reveal`.
---
### Task 3: Timer helper `src/lib/live-timer.ts`
**Files:**
- Create: `src/lib/live-timer.ts`
- Test: `tests/unit/live-timer.test.ts`
- [ ] **Step 1: Write failing tests** (pure functions, no DB):
```ts
import { describe, it, expect } from 'vitest'
import { elapsedMs, remainingSeconds, formatClock } from '@/lib/live-timer'
const t0 = new Date('2026-06-11T10:00:00Z')
const at = (s: number) => new Date(t0.getTime() + s * 1000)
describe('live-timer', () => {
it('elapsedMs counts from phaseStartedAt', () => {
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 0 }, at(90))).toBe(90_000)
})
it('elapsedMs freezes while paused and subtracts accumulated pause', () => {
// paused at +60s, asked at +90s → frozen at 60s
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: at(60), phasePausedAccumMs: 0 }, at(90))).toBe(60_000)
// resumed with 30s pause accumulated, asked at +120s → 90s elapsed
expect(elapsedMs({ phaseStartedAt: t0, phaseDurationSeconds: 300, phasePausedAt: null, phasePausedAccumMs: 30_000 }, at(120))).toBe(90_000)
})
it('remainingSeconds goes negative on overtime', () => {
expect(remainingSeconds({ phaseStartedAt: t0, phaseDurationSeconds: 60, phasePausedAt: null, phasePausedAccumMs: 0 }, at(75))).toBe(-15)
})
it('remainingSeconds is null without timer', () => {
expect(remainingSeconds({ phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0 }, t0)).toBeNull()
})
it('formatClock renders mm:ss and overtime', () => {
expect(formatClock(305)).toBe('5:05')
expect(formatClock(0)).toBe('0:00')
expect(formatClock(-83)).toBe('+1:23')
})
})
```
- [ ] **Step 2: Run** — FAIL (module not found).
- [ ] **Step 3: Implement:**
```ts
export type PhaseTimerState = {
phaseStartedAt: Date | string | null
phaseDurationSeconds: number | null
phasePausedAt: Date | string | null
phasePausedAccumMs: number
}
export function elapsedMs(t: PhaseTimerState, now: Date = new Date()): number {
if (!t.phaseStartedAt) return 0
const start = new Date(t.phaseStartedAt).getTime()
const end = t.phasePausedAt ? new Date(t.phasePausedAt).getTime() : now.getTime()
return Math.max(0, end - start - t.phasePausedAccumMs)
}
export function remainingSeconds(t: PhaseTimerState, now: Date = new Date()): number | null {
if (!t.phaseStartedAt || t.phaseDurationSeconds == null) return null
return t.phaseDurationSeconds - Math.floor(elapsedMs(t, now) / 1000)
}
export function formatClock(seconds: number): string {
const over = seconds < 0
const abs = Math.abs(seconds)
const m = Math.floor(abs / 60)
const s = abs % 60
return `${over ? '+' : ''}${m}:${s.toString().padStart(2, '0')}`
}
```
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): server-stamped phase timer helper`.
---
### Task 4: Phase machine + notes in `live.ts`
**Files:**
- Modify: `src/server/routers/live.ts`
- Test: `tests/unit/live-phase.test.ts`
- [ ] **Step 1: Write failing tests.** Use `createCaller(liveRouter, adminUser)` + factories (`createTestProgram/Competition/Round/Project`, round status `ROUND_ACTIVE`, `live.start` with 2 projects). Cases:
- `sendToScreens` sets `projectPhase='ON_DECK'`, target project active, timer fields null, `overrideSlide` cleared.
- `startPresentation``PRESENTING`, `phaseStartedAt` set, `phaseDurationSeconds` from input (e.g. 120) else from `round.configJson.presentationDurationMinutes*60` else 300.
- `startQA` after PRESENTING appends a timing-log entry `{projectId, phase:'PRESENTING', configuredSeconds, overranSeconds}` and starts QA timer.
- `openScoring` appends QA entry, phase `SCORING`, timer cleared.
- `pausePhase`/`resumePhase`: after pause, `phasePausedAt` set; resume folds into `phasePausedAccumMs` and clears `phasePausedAt`; pausing twice errors; resuming unpaused errors.
- overtime: startPresentation with `durationSeconds: 1`, manipulate by directly `prisma.liveProgressCursor.update({phaseStartedAt: new Date(Date.now()-10_000)})`, then `startQA` → log entry `overranSeconds >= 9`.
- `setOverrideSlide` sets/clears.
- `saveNote` upserts by (roundId, projectId, userId); second save with same juror overwrites content; `getMyNotes` returns only caller's notes.
- [ ] **Step 2: Run** — FAIL (procedures missing).
- [ ] **Step 3: Implement in `live.ts`.** Shared helper at top of file:
```ts
type TimingEntry = {
projectId: string
phase: 'PRESENTING' | 'QA'
startedAt: string
endedAt: string
configuredSeconds: number | null
overranSeconds: number
}
function closedOutTiming(cursor: {
activeProjectId: string | null
projectPhase: string
phaseStartedAt: Date | null
phaseDurationSeconds: number | null
phasePausedAt: Date | null
phasePausedAccumMs: number
timingLogJson: unknown
}, now: Date): Prisma.InputJsonValue | undefined {
if (!cursor.phaseStartedAt || !cursor.activeProjectId) return undefined
if (cursor.projectPhase !== 'PRESENTING' && cursor.projectPhase !== 'QA') return undefined
const end = cursor.phasePausedAt ?? now
const elapsedSec = Math.max(0, Math.floor((end.getTime() - cursor.phaseStartedAt.getTime() - cursor.phasePausedAccumMs) / 1000))
const entry: TimingEntry = {
projectId: cursor.activeProjectId,
phase: cursor.projectPhase,
startedAt: cursor.phaseStartedAt.toISOString(),
endedAt: now.toISOString(),
configuredSeconds: cursor.phaseDurationSeconds,
overranSeconds: cursor.phaseDurationSeconds == null ? 0 : Math.max(0, elapsedSec - cursor.phaseDurationSeconds),
}
const log = Array.isArray(cursor.timingLogJson) ? (cursor.timingLogJson as TimingEntry[]) : []
return [...log, entry] as unknown as Prisma.InputJsonValue
}
async function getRoundDurations(prisma: PrismaClient, roundId: string) {
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId } })
const cfg = (round.configJson as Record<string, unknown>) ?? {}
return {
presentation: typeof cfg.presentationDurationMinutes === 'number' ? cfg.presentationDurationMinutes * 60 : 300,
qa: typeof cfg.qaDurationMinutes === 'number' ? cfg.qaDurationMinutes * 60 : 300,
projectOrder: (cfg.projectOrder as string[]) ?? [],
}
}
```
Mutations (all `adminProcedure`, all audit-logged following the file's existing `logAudit` pattern with actions `LIVE_SEND_TO_SCREENS`, `LIVE_PHASE_STARTED`, `LIVE_PHASE_PAUSED`, `LIVE_PHASE_RESUMED`, `LIVE_OVERRIDE_SLIDE`):
```ts
sendToScreens: input {roundId, projectId} cursor findUniqueOrThrow by roundId; durations+order;
index = order.indexOf(projectId) (BAD_REQUEST if -1);
update: { activeProjectId, activeOrderIndex: index, projectPhase: 'ON_DECK',
phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0,
overrideSlide: null, timingLogJson: closedOutTiming(cursor, now) }
startPresentation: input {roundId, durationSeconds?: z.number().int().min(10).max(7200).optional()}
update { projectPhase: 'PRESENTING', phaseStartedAt: now,
phaseDurationSeconds: input.durationSeconds ?? durations.presentation,
phasePausedAt: null, phasePausedAccumMs: 0, timingLogJson: closedOutTiming(cursor, now) }
startQA: same shape, phase 'QA', default durations.qa
openScoring: { projectPhase: 'SCORING', phaseStartedAt: null, phaseDurationSeconds: null,
phasePausedAt: null, phasePausedAccumMs: 0, timingLogJson: closedOutTiming(cursor, now) }
pausePhase: PRECONDITION_FAILED if !cursor.phaseStartedAt || cursor.phasePausedAt; set phasePausedAt: now
resumePhase: PRECONDITION_FAILED if !cursor.phasePausedAt;
set phasePausedAccumMs: cursor.phasePausedAccumMs + (now - cursor.phasePausedAt), phasePausedAt: null
setOverrideSlide: input {roundId, slide: z.enum(['welcome','break','deliberation','thanks']).nullable()}
update { overrideSlide: input.slide }
```
Notes procedures (`protectedProcedure`):
```ts
saveNote: input {roundId, projectId, content: z.string().max(20_000)}
prisma.liveNote.upsert({ where: { roundId_projectId_userId: { roundId, projectId, userId: ctx.user.id } },
create: {...}, update: { content } })
getMyNotes: input {roundId} prisma.liveNote.findMany({ where: { roundId, userId: ctx.user.id } })
```
Extend `getCursor` return: spread now includes the new cursor fields automatically (`...cursor`); additionally fetch `orderedProjects` (id, title, teamName, competitionCategory) for the whole `projectOrder` (one `findMany` + reorder in JS) and include `activeProject.competitionCategory` in its select.
- [ ] **Step 4: Run** `npx vitest run tests/unit/live-phase.test.ts` — PASS. Run `npx vitest run tests/unit/auth-public-paths.test.ts` too (regression).
- [ ] **Step 5: Commit** `feat(finale): per-project phase machine, server timers, overtime log, juror notes`.
---
### Task 5: Audience windows + favorite votes in `live-voting.ts`
**Files:**
- Modify: `src/server/routers/live-voting.ts`
- Test: `tests/unit/audience-window.test.ts`
- [ ] **Step 1: Write failing tests.** Setup: program/competition/round (LIVE_FINAL, ROUND_ACTIVE), 3 projects (2 STARTUP, 1 BUSINESS_CONCEPT via `prisma.project.update` setting `competitionCategory`), `round.configJson.projectOrder` set, LiveVotingSession created with `allowAudienceVotes: true`, two AudienceVoter rows (tokens A, B). Cases:
1. `openAudienceWindow({windowKey:'CATEGORY:STARTUP', durationMinutes:5})` → phase OPEN, closesAt ≈ now+5m. Opening again → CONFLICT.
2. `castFavoriteVote` token A for STARTUP project → row created. Re-cast token A other STARTUP project → same row updated (count still 1).
3. Cast for the BUSINESS_CONCEPT project while STARTUP window open → BAD_REQUEST.
4. Set `audienceWindowClosesAt` to past via prisma, cast → PRECONDITION_FAILED (server-side time check, no cron).
5. `closeAudienceWindow` then cast → PRECONDITION_FAILED. Re-open works (new window, key CATEGORY:BUSINESS_CONCEPT) → casting BUSINESS_CONCEPT project OK.
6. `openAudienceWindow({windowKey:'OVERALL'})` with `allowOverallFavorite:false` → FORBIDDEN; after `updateSessionConfig({allowOverallFavorite:true})` → OK; any ordered project castable.
7. IP cap: create 3 voters with ctx ip '1.2.3.4' casting in same window (use `createTestContext` with custom ip — check `tests/setup.ts` signature; if ip not injectable, set `ipAddress` on rows directly and cast the 4th via caller whose ctx.ip is '1.2.3.4') → 4th distinct voter from same IP → TOO_MANY_REQUESTS. A voter updating their own vote from that IP still succeeds.
8. `getFavoriteTallies` returns per-windowKey per-project counts.
9. `getAudienceWindow` (public) reports phase CLOSED once `closesAt` past even without an explicit close, includes eligible projects in order, and `myVote` for a token.
- [ ] **Step 2: Run** — FAIL.
- [ ] **Step 3: Implement.** Zod: `const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL'])`. Helper in file:
```ts
function windowIsOpen(s: { audiencePhase: string; audienceWindowClosesAt: Date | null }, now = new Date()) {
return s.audiencePhase === 'OPEN' && !!s.audienceWindowClosesAt && now <= s.audienceWindowClosesAt
}
function categoryForKey(key: string): 'STARTUP' | 'BUSINESS_CONCEPT' | null {
return key === 'CATEGORY:STARTUP' ? 'STARTUP' : key === 'CATEGORY:BUSINESS_CONCEPT' ? 'BUSINESS_CONCEPT' : null
}
async function getOrderedFinaleProjects(prisma: PrismaClient, session: { roundId: string | null; projectOrderJson: unknown }) {
let order: string[] = []
if (session.roundId) {
const round = await prisma.round.findUnique({ where: { id: session.roundId } })
order = ((round?.configJson as Record<string, unknown>)?.projectOrder as string[]) ?? []
}
if (order.length === 0) order = (session.projectOrderJson as string[]) ?? []
const projects = await prisma.project.findMany({
where: { id: { in: order } },
select: { id: true, title: true, teamName: true, competitionCategory: true },
})
const byId = new Map(projects.map((p) => [p.id, p]))
return order.map((id) => byId.get(id)).filter(Boolean) as typeof projects
}
```
Procedures:
```ts
openAudienceWindow (adminProcedure): input {sessionId, windowKey: windowKeySchema, durationMinutes: z.number().int().min(1).max(120).default(5)}
session findUniqueOrThrow; if windowIsOpen(session) CONFLICT 'An audience window is already open';
if windowKey==='OVERALL' && !session.allowOverallFavorite FORBIDDEN 'Overall favorite vote is not enabled';
update { audiencePhase:'OPEN', audienceWindowKey: windowKey, audienceWindowOpenedAt: now,
audienceWindowClosesAt: new Date(now + durationMinutes*60_000) }; audit 'AUDIENCE_WINDOW_OPENED'.
closeAudienceWindow (adminProcedure): update { audiencePhase:'CLOSED', audienceWindowKey:null,
audienceWindowOpenedAt:null, audienceWindowClosesAt:null }; audit 'AUDIENCE_WINDOW_CLOSED'.
getAudienceWindow (publicProcedure): input {sessionId, token: z.string().optional()}
session select audiencePhase/windowKey/closesAt/allowAudienceVotes/roundId/projectOrderJson;
const open = windowIsOpen(session); const key = open ? session.audienceWindowKey : null;
projects = open ? getOrderedFinaleProjects(...).filter(p => { const cat = categoryForKey(key!); return cat ? p.competitionCategory === cat : true }) : [];
myVote: if token && key voter by token favoriteVote findUnique by (sessionId, windowKey:key, audienceVoterId) projectId;
return { open, windowKey: key, closesAt: open ? session.audienceWindowClosesAt : null, projects, myVoteProjectId }
castFavoriteVote (publicProcedure): input {sessionId, token, projectId}
voter by token (UNAUTHORIZED if missing/mismatched session);
session findUniqueOrThrow; if !windowIsOpen PRECONDITION_FAILED 'Voting is not open right now';
const key = session.audienceWindowKey!; const cat = categoryForKey(key);
project findUniqueOrThrow select competitionCategory; ordered = getOrderedFinaleProjects(...);
if (!ordered.some(p => p.id === input.projectId)) BAD_REQUEST 'Project is not part of this vote';
if (cat && project.competitionCategory !== cat) BAD_REQUEST 'Project is not in the open category';
existing = favoriteVote findUnique (sessionId, windowKey:key, audienceVoterId: voter.id);
if (!existing && ctx.ip) { const ipCount = await prisma.audienceFavoriteVote.count({ where: { sessionId, windowKey: key, ipAddress: ctx.ip } });
if (ipCount >= 3) TOO_MANY_REQUESTS 'Vote limit reached for this network' }
upsert (update: { projectId, ipAddress: ctx.ip ?? existing?.ipAddress }); return { projectId }
getFavoriteTallies (adminProcedure): input {sessionId}
groupBy ['windowKey','projectId'] _count; plus projects (title/teamName) join; plus per-window total counts.
```
Add `allowOverallFavorite: z.boolean().optional()` to `updateSessionConfig` input (passes straight through to `data`).
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): audience favorite-vote windows with category gating + IP cap`.
---
### Task 6: Jury vote comment + by-round session resolution + my-votes query
**Files:**
- Modify: `src/server/routers/live-voting.ts`
- Test: `tests/unit/live-vote-comment.test.ts`
- [ ] **Step 1: Failing tests:** (a) `vote` with `comment: 'strong pitch'` persists it; re-vote updates it; (b) new `getSessionForVotingByRound({roundId})` returns the same payload shape as `getSessionForVoting` and creates nothing (null when no session); (c) new `getMyFinaleInputs({roundId})` returns caller's LiveVotes (score, criterionScoresJson, comment, projectId) and LiveNotes for the round.
- [ ] **Step 2: Run** — FAIL.
- [ ] **Step 3: Implement:**
- `vote` input gains `comment: z.string().max(5000).optional()`; include in upsert create/update (`comment: input.comment ?? undefined` on update so an omitted comment doesn't erase).
- `getSessionForVotingByRound` (protectedProcedure): `findUnique({ where: { roundId } })`; if null return null; else reuse the body of `getSessionForVoting` (extract a shared `buildVotingPayload(session, ctx)` helper used by both procedures — DRY).
- `getMyFinaleInputs` (protectedProcedure): input `{roundId}` → session by roundId (null-safe) → `liveVote.findMany({ where: { sessionId, userId: ctx.user.id } , select: { projectId, score, criterionScoresJson, comment, votedAt } })` + `liveNote.findMany({ where: { roundId, userId: ctx.user.id } })`. Return `{ votes, notes, session: { id, votingMode, criteriaJson } | null }`.
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): vote comments, by-round session lookup, my-finale-inputs query`.
---
### Task 7: Jury live page rework
**Files:**
- Modify: `src/app/(jury)/jury/competitions/[roundId]/live/page.tsx`
- Modify: `src/components/jury/live-voting-form.tsx` (add comment field — read it first; pass `comment` through `onVoteSubmit`)
No DB logic here; verified by build + Playwright in Task 13. Behaviors:
- [ ] **Step 1: Fix session wiring** — replace `getSessionForVoting({sessionId: params.roundId})` with `getSessionForVotingByRound({roundId: params.roundId})` (poll 2000ms). Keep `live.getCursor` poll at 2000ms (was 5000 — tighten for ceremony).
- [ ] **Step 2: Phase rendering** from `cursor.projectPhase`:
- `ON_DECK`: full-width banner card — "Up next" eyebrow, project title XL, team name; muted note "Presentation starting shortly". No scoring form.
- `PRESENTING` / `QA`: project card with phase badge (`Presentation` / `Q&A`) and live countdown chip using `remainingSeconds`/`formatClock` from `@/lib/live-timer` (tick via 1s `setInterval`, computed from server stamps — never local countdown state); red text when negative. Notes + scoring form below.
- `SCORING`: same but scoring card gets a highlighted ring (`ring-2 ring-[#de0f1e]`) and a "Scoring is open" badge.
- [ ] **Step 3: Persisted notes** — replace local `notes` state: load via `trpc.live.getMyNotes({roundId})`, keep a `Record<projectId, string>` local draft, debounce 800ms → `trpc.live.saveNote.mutate({roundId, projectId, content})`; show "Saved" / "Saving…" microcopy. Notes keyed per active project (switching project switches the note).
- [ ] **Step 4: Comment field** — add optional `Textarea` "Comment (visible to admins with your scores)" inside the voting form submission; include `comment` in `vote` mutation.
- [ ] **Step 5:** `npm run build` green. Commit `feat(finale): phase-aware jury live page with persisted notes + comments`.
---
### Task 8: Reveal controller (backend)
**Files:**
- Modify: `src/server/routers/live-voting.ts`
- Test: `tests/unit/reveal.test.ts`
- [ ] **Step 1: Failing tests:** (a) `saveReveal` upserts steps in DRAFT; (b) `armReveal` requires ≥1 step, DRAFT→ARMED; (c) `revealNext` ARMED→REVEALING idx 0, increments, last step → DONE (idx stays last); (d) `resetReveal` → DRAFT idx -1; (e) **no-leak:** `getCeremonyState` (public, Task 9 — write the test now against the procedure added there if sequencing demands; otherwise assert via a `getPublicReveal` helper) returns only steps `0..currentStepIndex`, empty when ARMED, none when DRAFT.
- [ ] **Step 2: Run** — FAIL.
- [ ] **Step 3: Implement.** Step schema:
```ts
const revealStepSchema = z.object({
kind: z.enum(['category-intro', 'place', 'audience-award', 'overall-favorite', 'thanks']),
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
place: z.number().int().min(1).max(10).optional(),
projectId: z.string().optional(),
title: z.string().max(200).optional(), // resolved display strings, stored denormalized
subtitle: z.string().max(300).optional(),
})
```
```ts
saveReveal (adminProcedure): {sessionId, steps: z.array(revealStepSchema).max(50)}
revealState upsert by sessionId { stepsJson: steps, status: 'DRAFT', currentStepIndex: -1 }
armReveal (adminProcedure): requires existing DRAFT with steps.length>0 status 'ARMED'
revealNext (adminProcedure): ARMED { status:'REVEALING', currentStepIndex: 0 };
REVEALING idx+1; if idx+1 === steps.length-1 also status 'DONE'
// careful: advance then check — newIndex = currentStepIndex + 1; clamp to steps.length-1;
// status = newIndex >= steps.length - 1 ? 'DONE' : 'REVEALING'
resetReveal (adminProcedure): { status:'DRAFT', currentStepIndex: -1 }
getRevealAdmin (adminProcedure): full state incl. all steps (for preview)
```
Audit-log arm/next/reset with action names `REVEAL_ARMED`, `REVEAL_ADVANCED`, `REVEAL_RESET`.
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): results reveal controller with step-through state`.
---
### Task 9: Public ceremony state endpoint
**Files:**
- Modify: `src/server/routers/live-voting.ts`
- Test: extend `tests/unit/reveal.test.ts` + `tests/unit/audience-window.test.ts` no-leak/shape cases
- [ ] **Step 1: Failing test:** `getCeremonyState({roundId})` (publicProcedure) returns `{ overrideSlide, phase: {projectPhase, phaseStartedAt, phaseDurationSeconds, phasePausedAt, phasePausedAccumMs}, activeProject: {title, teamName, competitionCategory} | null, audience: { open, windowKey, closesAt, voteCount }, reveal: { status, steps: <revealed only>, currentStepIndex } | null, programName }`. Assert: never includes scores, never includes un-revealed steps, includes audience voteCount for the open window only.
- [ ] **Step 2: Run** — FAIL.
- [ ] **Step 3: Implement** — compose from `liveProgressCursor.findUnique({roundId})`, session by roundId, `audienceFavoriteVote.count({sessionId, windowKey})` when open, `revealState` (slice steps `0..currentStepIndex` only when REVEALING/DONE; `[]` when ARMED; null when DRAFT/absent). One procedure, ~60 lines.
- [ ] **Step 4: Run** — PASS. Commit `feat(finale): public ceremony-state endpoint for big screen`.
---
### Task 10: Deliberation jury completion
**Files:**
- Modify: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx`
- Modify: `src/server/services/deliberation.ts` (only if `getSessionWithVotes` lacks project list — verify first)
- Modify: `src/server/routers/deliberation.ts` (add projects to getSession payload if needed)
- Read first: `src/components/jury/deliberation-ranking-form.tsx`
- Test: `tests/unit/deliberation-jury-wiring.test.ts`
- [ ] **Step 1: Investigate** `getSessionWithVotes` — confirm what `session.participants[].user` contains (expect JuryGroupMember incl. `user`), and where the rank-able project list comes from (`session.results` is empty before finalize — the form currently gets `[]`!). Decide: extend `getSessionWithVotes` to include `projects` = projects of the session's round + category (via `ProjectRoundState` where `roundId`, project `competitionCategory === session.category`), selecting id/title/teamName.
- [ ] **Step 2: Failing test:** caller = juror user who is a JuryGroupMember + DeliberationParticipant; `deliberation.getSession` exposes `projects` (non-empty pre-finalize) and participant rows that let the client resolve `juryMemberId`; `submitVote` with that `juryMemberId` succeeds and `getSession` then shows the vote (`hasVoted` derivable). Also assert a juror cannot submit with another member's `juryMemberId` (existing enforcement — pin it).
- [ ] **Step 3: Implement service/router change**, run test — PASS.
- [ ] **Step 4: Fix the page:**
```ts
const { data: me } = trpc.user.me.useQuery() // or useSession() — match codebase pattern (check src for existing usage)
const myParticipant = session?.participants?.find((p: any) => p.user?.user?.id === me?.id)
const juryMemberId = myParticipant?.user?.id ?? null // JuryGroupMember.id
const hasVoted = !!session?.votes?.some((v: any) => v.juryMember?.user?.id === me?.id)
```
Pass `projects={session.projects}` to `DeliberationRankingForm`. Submit all votes in ONE call sequence with `juryMemberId`; disable submit when `!juryMemberId` with explanatory text ("You are not a participant of this deliberation").
- [ ] **Step 5: Context panels** (below the ranking form, one collapsible card per project): my finale criteria scores + comment from `trpc.liveVoting.getMyFinaleInputs({roundId: session.roundId})` (criteria labels from `session` payload's criteriaJson), editable via the same `LiveVotingForm` in a dialog (submits `liveVoting.vote` — works because session status check is `IN_PROGRESS`; **verify**: if finale session will be COMPLETED by deliberation time, relax `vote`'s status guard to allow `IN_PROGRESS | PAUSED` and gate `currentProjectId` check to only apply when phase-voting — simplest: allow voting for any ordered project when `round.roundType === 'DELIBERATION'`-linked… **Decision:** add `allowRevote: true` behavior — `vote` accepts any `projectId` in the finale order when the session status is `IN_PROGRESS` or `PAUSED`; keep the `currentProjectId` equality check ONLY when `projectPhase` voting is live i.e. when the cursor's active project equals the voted project OR session.status === 'PAUSED'. Implement as: skip the `currentProjectId !== input.projectId` check when `input.projectId` is in the session's project order and the cursor for the round is in `SCORING` or session is `PAUSED`. Write a unit test for this relaxation.) Also show my `LiveNote` per project, and a link row to the finals documents page (route: check `src/app/(jury)` for the finals docs page added 2026-06-09 — link to it with the project preselected if supported, else plain link).
- [ ] **Step 6:** Tests + `npm run build` green. Commit `feat(finale): working jury deliberation flow with finale-score review and notes`.
---
### Task 11: Admin control panel revamp
**Files:**
- Modify: `src/components/admin/live/live-control-panel.tsx` (becomes orchestrator)
- Create: `src/components/admin/live/run-order-list.tsx`, `phase-controls.tsx`, `audience-window-panel.tsx`, `timing-log-card.tsx`, `reveal-panel.tsx`
- Modify: `package.json` (add `qrcode.react`)
- Find the admin page hosting `LiveControlPanel` (grep usage) — ensure it passes `roundId` + `competitionId` and has room for the new layout (2-col grid on lg).
Pure UI — verified via Playwright in Task 13. Behaviors per component:
- [ ] **Step 1:** `npm i qrcode.react`.
- [ ] **Step 2: `run-order-list.tsx`** — props `{roundId}`; uses `live.getCursor` data (`orderedProjects`, `activeProjectId`, `activeOrderIndex`). Groups rows under `BUSINESS_CONCEPT` / `STARTUP` headings (preserving global order); each row: index, title, teamName, category dot, ▲▼ buttons (swap in `projectOrder`, call `live.reorder`), and a "Send to screens" button (`live.sendToScreens`). Active row highlighted; ON_DECK row shows "on deck" badge.
- [ ] **Step 3: `phase-controls.tsx`** — props `{roundId}`. Shows active project + phase badge; one primary button for the next transition (ON_DECK→"Start presentation", PRESENTING→"Start Q&A", QA→"Open scoring", SCORING→"Send next project" which calls `sendToScreens` with the next project in order); secondary buttons for pause/resume; the big server-derived countdown (`remainingSeconds`/`formatClock`, 1s tick, `text-red-600 animate-pulse` when negative with "OVER" label); duration override `Input` (minutes, prefilled from round config) applied to the next start call. Keep legacy session pause/resume (cursor.isPaused) as a small row.
- [ ] **Step 4: `audience-window-panel.tsx`** — props `{roundId}`. Resolves session via `liveVoting.getSession({roundId})`. Buttons "Open vote — Business Concepts" / "Open vote — Startups" / "Open vote — Overall favorite" (last disabled unless `allowOverallFavorite`; a `Switch` toggles it via `updateSessionConfig`), shared duration `Input` (default 5). When open: countdown, live vote count (`getFavoriteTallies` poll 3s — render per-window totals; per-project tallies in a collapsible "Tallies (admin only)"), "Close now" `destructive` button. "Show QR" button → `Dialog` with `<QRCodeSVG value={origin + '/vote/competition/' + roundId} size={420}/>` + the URL printed beneath.
- [ ] **Step 5: `timing-log-card.tsx`** — renders `cursor.timingLogJson` rows: project title (lookup from orderedProjects), phase, configured vs actual, overran chip (red `+m:ss`) when `overranSeconds > 0`.
- [ ] **Step 6: `reveal-panel.tsx`** — props `{roundId}`. "Compose from results" button: pulls `liveVoting.getResults({sessionId})`, `getFavoriteTallies`, and `deliberation.listSessions({competitionId})` → for each category with a finalized deliberation use its results order, else fall back to jury `getResults` order filtered by category; builds default steps (category-intro → places 3,2,1 → audience-award per category → overall-favorite if tallies exist → thanks) with resolved `title` (team/project name) and `subtitle` ("3rd place — Business Concepts" etc.); shows editable preview list (delete/reorder steps); "Save draft" → `saveReveal`. Then "Arm" (confirm dialog: "Big screen will switch to Results mode"), "Reveal next" (primary, shows `currentStepIndex+1 / steps.length`), "Reset". Show current step preview text so the admin always knows what fires next.
- [ ] **Step 7:** Compose all into `live-control-panel.tsx` (left col: phase-controls + run-order-list; right col: audience-window-panel + timing-log-card + reveal-panel). `npm run build` green. Commit `feat(finale): admin ceremony control panel — phases, run order, audience windows, QR, reveal`.
---
### Task 12: Audience voting page + big-screen ceremony page
**Files:**
- Modify: `src/app/(public)/vote/competition/[roundId]/page.tsx` (read existing first; rework content)
- Create: `src/app/(public)/live/ceremony/[roundId]/page.tsx`
- Create: `src/components/public/ceremony/` (slides: `ceremony-shell.tsx`, `presentation-slide.tsx`, `audience-vote-slide.tsx`, `reveal-slide.tsx`, `static-slide.tsx`)
**Invoke the `frontend-design` skill before building these two surfaces** — the reveal especially must be projector-gorgeous (spec §9): Montserrat 700, dark-blue `#053d57` field, red `#de0f1e` accent, `motion` (v11, import from `'motion/react'`) AnimatePresence transitions, confetti-grade flourish on 1st place + audience award, 16:9-safe, high contrast, no text below ~32px effective.
- [ ] **Step 1: Audience page** — resolve session: add tiny public procedure `liveVoting.getAudienceContextByRound({roundId})` returning `{sessionId, allowAudienceVotes, programName, roundName}` (5 lines, include in Task 5's test file as a shape assertion). Page flow: on mount ensure token in `localStorage['mopc-audience-' + sessionId]` else `registerAudienceVoter` → store. Poll `getAudienceWindow({sessionId, token})` every 3s. States: **waiting** (brand header, "Voting opens after the presentations — keep this page open", subtle wave animation), **open** (windowKey title — "Pick your favorite Business Concept", big tappable project cards (title + team), selected ring, confirm button → `castFavoriteVote`, then **voted** state: green check, "Vote recorded — you can change it until voting closes", countdown chip, tap-again-to-change), **closed** ("Voting is closed — thanks!"). Friendly error toast for IP-cap rejection. Mobile-first, thumb-sized targets, zero instructions needed.
- [ ] **Step 2: Ceremony page**`'use client'`; poll `liveVoting.getCeremonyState({roundId})` every 2s; full-screen `ceremony-shell` (fixed inset-0, `bg-[#053d57]`, MOPC wordmark small top-left, no nav chrome). Render precedence exactly: overrideSlide → reveal (ARMED: "Results" splash; REVEALING/DONE: `reveal-slide` for `steps[currentStepIndex]` with AnimatePresence between steps) → audience window open (`audience-vote-slide`: giant centered QR (white tile, rounded), "Vote for your favorite …", mm:ss countdown, "N votes cast" ticker) → cursor phase (ON_DECK: "Up next" + team; PRESENTING/QA: team name hero + phase label + huge countdown `formatClock`, red glow when negative; SCORING: "The jury is scoring" interstitial) → welcome slide. 1s local tick for countdowns computed from server stamps.
- [ ] **Step 3: Reveal slide details**`place` step: eyebrow ("3rd place — Startups"), team name scales in (motion spring, `initial={{opacity:0, y:40, scale:0.9}}`), 1st place gets gold treatment + confetti burst (CSS/motion particles — ~40 absolutely-positioned animated divs, no new dep); `audience-award`/`overall-favorite`: red accent treatment "Audience Choice"; `category-intro` and `thanks`: typographic full-bleed statements.
- [ ] **Step 4:** `npm run build` green. Commit `feat(finale): audience voting page + big-screen ceremony view with animated reveal`.
---
### Task 13: End-to-end verification + tally audit
**Files:**
- Test: `tests/unit/live-results-tally.test.ts`
- All previous files (fixes as found)
- [ ] **Step 1: Tally audit tests**`getResults`: 2 jury voters scoring 2 projects (criteria mode: assert weighted normalization matches hand-computed values), audienceWeight 0 default keeps jury-only ordering; tie detection fires on equal totals; `getFavoriteTallies` counts match casts. Run — PASS (fix `getResults` if hand-computed values disagree; document any fix in the commit).
- [ ] **Step 2: Full suite** `npx vitest run` — all green. `npm run typecheck` and `npm run build` — green.
- [ ] **Step 3: Manual drive (Playwright MCP against dev server), screenshots at each stop:** seed/identify a LIVE_FINAL round with projects in both categories → admin: start live session, send project to screens, start presentation (1 min override), watch countdown go red, start Q&A, open scoring → jury (second context): see ON_DECK→phases follow along, write a note, refresh (note persists), submit criteria scores + comment → admin: open Business-Concepts audience window → **clean browser context** (NOT the logged-in profile): load `/vote/competition/[roundId]`, cast favorite, change vote, see voted state → ceremony page shows QR + count → close window → compose reveal from results, arm, step through all steps on ceremony page → deliberation: create session, open voting, juror ranks (verify the Task 10 fix), close, aggregate, adminDecide override, finalize.
- [ ] **Step 4: Public-route curl checks** for `/vote/competition/<id>`, `/live/ceremony/<id>`, `/live-scores/<id>` — 200, no login redirect.
- [ ] **Step 5: Fix everything found; re-run suite; commit** `test(finale): tally audit + e2e ceremony verification fixes`.
---
### Task 14 (STRETCH — only if all above is done and verified): Live ranking mode toggle
Skip unless time clearly permits. Admin toggle on the session (`votingMode: 'ranking'`), juror drag-rank of seen-so-far projects persisted to a new `LiveRank` model, results by Borda. **Do not start this before Task 13 is fully green.**
---
## Execution ground rules
- Commit after every task (or sub-step where marked); never push without `npm run build` green.
- Local dev DB only tonight; prod deploy is a separate explicit step with the user (memory: backup first, never `docker compose down -v`).
- If a step's investigation contradicts this plan (shapes, routes, component props), trust the code, adjust minimally, note the deviation in the commit message.