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) andlive-voting.ts(LiveVotingSession, jury criteria votes + audience tokens). The finale uses cursor for presentation flow and LiveVotingSession for all voting. The cohort-basedcastVote/castStageVoteinlive.tsare NOT used for the finale — leave them alone. - Source of truth for presentation order:
round.configJson.projectOrder(managed bylive.start/live.reorder). - Known bug: jury live page passes
params.roundIdassessionIdtogetSessionForVoting→ NOT_FOUND. Fixed in Task 7. - Known bug: deliberation jury page has
juryMemberId: ''andhasVoted = falsehardcoded. Fixed in Task 10. LiveVotingSession.roundIdis@unique, so by-round lookup is safe.- Project category field is
competitionCategory(STARTUP | BUSINESS_CONCEPT), nullable. - NEVER run
prisma migrate devifmigrate statusshows drift (memory rule) — use the create-only +db execute+migrate resolvepath 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.tsfirst and follow its existing assertion style; add cases asserting/vote/competition/abc,/vote/xyz,/live-scores/xyz,/live/ceremony/abcare public and that/livealone (jury route prefix is/jury/...so no conflict) does not accidentally open admin routes (assert/adminstill 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.tsadd topublicPaths:
'/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
LiveVotewithcomment String? @db.Text, andAudienceVoterwithfavoriteVotes 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 statusfirst. If clean:npx prisma migrate dev --name grand_finale_ceremony. If drifted:npx prisma migrate dev --create-only --name grand_finale_ceremony, review SQL, thennpx prisma db execute --file prisma/migrations/<ts>_grand_finale_ceremony/migration.sqlandnpx prisma migrate resolve --applied <ts>_grand_finale_ceremony. Thennpx prisma generate. - Step 7: Verify
npm run typecheckpasses (pre-existing errors aside). Commitfeat(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 statusROUND_ACTIVE,live.startwith 2 projects). Cases:sendToScreenssetsprojectPhase='ON_DECK', target project active, timer fields null,overrideSlidecleared.startPresentation→PRESENTING,phaseStartedAtset,phaseDurationSecondsfrom input (e.g. 120) else fromround.configJson.presentationDurationMinutes*60else 300.startQAafter PRESENTING appends a timing-log entry{projectId, phase:'PRESENTING', configuredSeconds, overranSeconds}and starts QA timer.openScoringappends QA entry, phaseSCORING, timer cleared.pausePhase/resumePhase: after pause,phasePausedAtset; resume folds intophasePausedAccumMsand clearsphasePausedAt; pausing twice errors; resuming unpaused errors.- overtime: startPresentation with
durationSeconds: 1, manipulate by directlyprisma.liveProgressCursor.update({phaseStartedAt: new Date(Date.now()-10_000)}), thenstartQA→ log entryoverranSeconds >= 9. setOverrideSlidesets/clears.saveNoteupserts by (roundId, projectId, userId); second save with same juror overwrites content;getMyNotesreturns 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. Runnpx vitest run tests/unit/auth-public-paths.test.tstoo (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.updatesettingcompetitionCategory),round.configJson.projectOrderset, LiveVotingSession created withallowAudienceVotes: true, two AudienceVoter rows (tokens A, B). Cases:openAudienceWindow({windowKey:'CATEGORY:STARTUP', durationMinutes:5})→ phase OPEN, closesAt ≈ now+5m. Opening again → CONFLICT.castFavoriteVotetoken A for STARTUP project → row created. Re-cast token A other STARTUP project → same row updated (count still 1).- Cast for the BUSINESS_CONCEPT project while STARTUP window open → BAD_REQUEST.
- Set
audienceWindowClosesAtto past via prisma, cast → PRECONDITION_FAILED (server-side time check, no cron). closeAudienceWindowthen cast → PRECONDITION_FAILED. Re-open works (new window, key CATEGORY:BUSINESS_CONCEPT) → casting BUSINESS_CONCEPT project OK.openAudienceWindow({windowKey:'OVERALL'})withallowOverallFavorite:false→ FORBIDDEN; afterupdateSessionConfig({allowOverallFavorite:true})→ OK; any ordered project castable.- IP cap: create 3 voters with ctx ip '1.2.3.4' casting in same window (use
createTestContextwith custom ip — checktests/setup.tssignature; if ip not injectable, setipAddresson 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. getFavoriteTalliesreturns per-windowKey per-project counts.getAudienceWindow(public) reports phase CLOSED onceclosesAtpast even without an explicit close, includes eligible projects in order, andmyVotefor 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)
votewithcomment: 'strong pitch'persists it; re-vote updates it; (b) newgetSessionForVotingByRound({roundId})returns the same payload shape asgetSessionForVotingand creates nothing (null when no session); (c) newgetMyFinaleInputs({roundId})returns caller's LiveVotes (score, criterionScoresJson, comment, projectId) and LiveNotes for the round. -
Step 2: Run — FAIL.
-
Step 3: Implement:
voteinput gainscomment: z.string().max(5000).optional(); include in upsert create/update (comment: input.comment ?? undefinedon update so an omitted comment doesn't erase).getSessionForVotingByRound(protectedProcedure):findUnique({ where: { roundId } }); if null return null; else reuse the body ofgetSessionForVoting(extract a sharedbuildVotingPayload(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; passcommentthroughonVoteSubmit)
No DB logic here; verified by build + Playwright in Task 13. Behaviors:
- Step 1: Fix session wiring — replace
getSessionForVoting({sessionId: params.roundId})withgetSessionForVotingByRound({roundId: params.roundId})(poll 2000ms). Keeplive.getCursorpoll 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 usingremainingSeconds/formatClockfrom@/lib/live-timer(tick via 1ssetInterval, 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
notesstate: load viatrpc.live.getMyNotes({roundId}), keep aRecord<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; includecommentinvotemutation. - Step 5:
npm run buildgreen. Commitfeat(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)
saveRevealupserts steps in DRAFT; (b)armRevealrequires ≥1 step, DRAFT→ARMED; (c)revealNextARMED→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 agetPublicRevealhelper) returns only steps0..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.tsno-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 steps0..currentStepIndexonly 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 ifgetSessionWithVoteslacks 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 whatsession.participants[].usercontains (expect JuryGroupMember incl.user), and where the rank-able project list comes from (session.resultsis empty before finalize — the form currently gets[]!). Decide: extendgetSessionWithVotesto includeprojects= projects of the session's round + category (viaProjectRoundStatewhereroundId, projectcompetitionCategory === session.category), selecting id/title/teamName. -
Step 2: Failing test: caller = juror user who is a JuryGroupMember + DeliberationParticipant;
deliberation.getSessionexposesprojects(non-empty pre-finalize) and participant rows that let the client resolvejuryMemberId;submitVotewith thatjuryMemberIdsucceeds andgetSessionthen shows the vote (hasVotedderivable). Also assert a juror cannot submit with another member'sjuryMemberId(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 fromsessionpayload's criteriaJson), editable via the sameLiveVotingFormin a dialog (submitsliveVoting.vote— works because session status check isIN_PROGRESS; verify: if finale session will be COMPLETED by deliberation time, relaxvote's status guard to allowIN_PROGRESS | PAUSEDand gatecurrentProjectIdcheck to only apply when phase-voting — simplest: allow voting for any ordered project whenround.roundType === 'DELIBERATION'-linked… Decision: addallowRevote: truebehavior —voteaccepts anyprojectIdin the finale order when the session status isIN_PROGRESSorPAUSED; keep thecurrentProjectIdequality check ONLY whenprojectPhasevoting is live i.e. when the cursor's active project equals the voted project OR session.status === 'PAUSED'. Implement as: skip thecurrentProjectId !== input.projectIdcheck wheninput.projectIdis in the session's project order and the cursor for the round is inSCORINGor session isPAUSED. Write a unit test for this relaxation.) Also show myLiveNoteper project, and a link row to the finals documents page (route: checksrc/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 buildgreen. Commitfeat(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(addqrcode.react) - Find the admin page hosting
LiveControlPanel(grep usage) — ensure it passesroundId+competitionIdand 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}; useslive.getCursordata (orderedProjects,activeProjectId,activeOrderIndex). Groups rows underBUSINESS_CONCEPT/STARTUPheadings (preserving global order); each row: index, title, teamName, category dot, ▲▼ buttons (swap inprojectOrder, calllive.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 callssendToScreenswith the next project in order); secondary buttons for pause/resume; the big server-derived countdown (remainingSeconds/formatClock, 1s tick,text-red-600 animate-pulsewhen negative with "OVER" label); duration overrideInput(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 vialiveVoting.getSession({roundId}). Buttons "Open vote — Business Concepts" / "Open vote — Startups" / "Open vote — Overall favorite" (last disabled unlessallowOverallFavorite; aSwitchtoggles it viaupdateSessionConfig), shared durationInput(default 5). When open: countdown, live vote count (getFavoriteTalliespoll 3s — render per-window totals; per-project tallies in a collapsible "Tallies (admin only)"), "Close now"destructivebutton. "Show QR" button →Dialogwith<QRCodeSVG value={origin + '/vote/competition/' + roundId} size={420}/>+ the URL printed beneath. - Step 5:
timing-log-card.tsx— renderscursor.timingLogJsonrows: project title (lookup from orderedProjects), phase, configured vs actual, overran chip (red+m:ss) whenoverranSeconds > 0. - Step 6:
reveal-panel.tsx— props{roundId}. "Compose from results" button: pullsliveVoting.getResults({sessionId}),getFavoriteTallies, anddeliberation.listSessions({competitionId})→ for each category with a finalized deliberation use its results order, else fall back to jurygetResultsorder filtered by category; builds default steps (category-intro → places 3,2,1 → audience-award per category → overall-favorite if tallies exist → thanks) with resolvedtitle(team/project name) andsubtitle("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, showscurrentStepIndex+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 buildgreen. Commitfeat(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 inlocalStorage['mopc-audience-' + sessionId]elseregisterAudienceVoter→ store. PollgetAudienceWindow({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'; pollliveVoting.getCeremonyState({roundId})every 2s; full-screenceremony-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-slideforsteps[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 countdownformatClock, 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 —
placestep: 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-introandthanks: typographic full-bleed statements. - Step 4:
npm run buildgreen. Commitfeat(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;getFavoriteTalliescounts match casts. Run — PASS (fixgetResultsif hand-computed values disagree; document any fix in the commit). -
Step 2: Full suite
npx vitest run— all green.npm run typecheckandnpm 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 buildgreen. - 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.