Files
MOPC-Portal/docs/superpowers/plans/2026-06-10-grand-finale-ceremony.md
2026-06-10 18:01:12 +02:00

42 KiB

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:

        '/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):

enum LivePhase {
  ON_DECK
  PRESENTING
  QA
  SCORING
}

enum AudiencePhase {
  CLOSED
  OPEN
}
  • Step 2: Extend LiveProgressCursor:
  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:
  // 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):
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):

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:
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.
    • startPresentationPRESENTING, 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:

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):

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):

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:

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:

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:

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(),
})
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:

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 detailsplace 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 testsgetResults: 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.