Compare commits

...

21 Commits

Author SHA1 Message Date
Matt
2945a92193 feat(finale): per-project presentation/Q&A durations in m:ss + config-save merge fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s
- setProjectTiming stores per-project overrides in round config; phase starts
  resolve: explicit input > project override > round default
- Run Order rows get m:ss inputs per project; PhaseControls one-off overrides
  now also m:ss (shared parseClock: '7:30', '12:05', plain '7')
- CRITICAL: round.update now MERGES validated form config over the existing
  configJson — saving the Config tab was wiping projectOrder (would have
  destroyed a running ceremony) and the finals-docs upload toggle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:14:49 +02:00
Matt
9b56eb27fb fix(finale): live-verification fixes — session sync on start, honest vote form, reveal panel polish
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m43s
Found by driving the full ceremony end-to-end in the browser:
- live.start + startPresentation sync LiveVotingSession (status/currentProjectId)
  — jury votes silently failed when the admin never used sendToScreens
- LiveVotingForm no longer shows 'submitted' on a failed mutation; pages
  re-key on votedAt so async vote data renders the right state after refresh
- reveal compose prefers DELIB_LOCKED deliberation results over jury score
  order (listSessions now includes results); Arm unlocks right after save
- deliberation jury page review cards re-key on revision

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:24:45 +02:00
Matt
160333c2f9 fix(finale): ceremony-day rate-limit bucket — venue NAT would 429 audience polling
Requests consisting only of cheap public ceremony reads (+ token-gated vote
casts) get a 6000/min per-IP budget instead of 100/min. Vote integrity is
enforced by the token + AudienceFavoriteVote IP cap, not the rate limiter.
Found live: big-screen polling hit 429 within minutes in verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:45:15 +02:00
Matt
f7fdfdec9b test(finale): tally audit — weighted criteria math, ordering, ties, favorites separation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:35:44 +02:00
Matt
a2c6baf718 feat(finale): full ceremony UI — admin console, jury phases+notes, deliberation flow, audience voting, big-screen view
- Admin Ceremony tab: phase driver with real server timers + overtime, run
  order with category grouping + send-to-screens, audience windows + QR
  dialog, override slides, timing log, results reveal builder/stepper
- Admin Deliberation tab: per-category session creation from the round's
  jury group, open/close voting, tally/runoff/override/finalize
- Jury live page: ON_DECK/PRESENTING/QA/SCORING aware, autosaved notes,
  vote comments; LiveVotingForm fixed (criteria score >10 rejection bug,
  permanent lock-out after submit) and made revisable
- Jury deliberation page: identityless submitVote, review-before-rank
  context (finale scores editable, ceremony notes, docs link)
- Jury nav: Live Ceremony + per-category Deliberation links
- Public: rebuilt QR audience voting page (anonymous-safe, favorite-pick
  windows, change-until-close), new /live/ceremony/[roundId] projector view
  with animated reveal, confetti, audience QR slide, override slides

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:34:50 +02:00
Matt
c9dc1bfabd feat(finale): deliberation jury identity resolution, rankable projects, score-revision path, session sync
- submitVote resolves the caller's JuryGroupMember participant row server-side
  (was comparing JuryGroupMember id to User id — every juror got FORBIDDEN)
- getSessionWithVotes now includes category projects so the ranking form has
  data before finalize
- liveVoting.vote accepts any finale-ordered project (revision during
  deliberation); timed window still applies to the live project
- live.sendToScreens keeps LiveVotingSession.currentProjectId/status in sync

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:15:45 +02:00
Matt
4e6904fa12 feat(finale): reveal controller + public ceremony-state endpoint (no-leak guaranteed)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:12:00 +02:00
Matt
45b007334e feat(finale): vote comments, by-round session lookup, my-finale-inputs query
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:10:32 +02:00
Matt
6d2fa3369f feat(finale): audience favorite-vote windows with category gating + IP cap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:09:02 +02:00
Matt
6eccfc694e feat(finale): per-project phase machine, server timers, overtime log, juror notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:07:02 +02:00
Matt
97ef3e59ac feat(finale): server-stamped phase timer helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:04:51 +02:00
Matt
a66bd728cd feat(finale): schema for phases, audience windows, favorite votes, notes, reveal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:04:25 +02:00
Matt
64f88890f5 fix(auth): make audience vote, live-scores and ceremony routes public
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:02:08 +02:00
Matt
dcd85c9b13 docs(finale): implementation plan for grand-finale ceremony system
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:01:12 +02:00
Matt
3be1fcd24a docs(finale): approved design spec for grand-finale ceremony system (option C)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:54:31 +02:00
Matt
d38fe7887a feat(final-docs): optional-mode banner/panel + admin 'Documents shown to judges' picker
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m31s
- finalist banner/panel render an optional-uploads state (settle to green via
  hasRequired ? allRequiredUploaded : allUploaded; 'optional' copy when nothing required)
- ReviewDocsPicker admin card on the LIVE_FINAL round page to curate judge-visible docs
2026-06-10 15:02:19 +02:00
Matt
28ca7bb0a6 feat(final-docs): judge-doc curation — reviewVisibleRequirementIds filter, picker helper, admin procedures
- listFinalistDocumentsForReview filters prior-round files by the round's
  reviewVisibleRequirementIds (finale uploads always shown; null = show all)
- listReviewVisibilityOptions: distinct prior-round slots + file counts for the picker
- finalist.getReviewDocSettings / setReviewVisibleRequirements (adminProcedure, audited,
  preserves sibling configJson keys)
2026-06-10 15:00:49 +02:00
Matt
d89f67ba57 feat(final-docs): hasRequired/allUploaded on FinalDocumentStatus for optional-uploads mode 2026-06-10 14:58:39 +02:00
Matt
2c311bc65a feat(finals): prominent finalist-docs banner on jury dashboard + gate nav to finals jury
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m37s
Grand-Final jurors couldn't find the finalist documents review: the only entry
point was a top-nav text link (hidden in the hamburger on mobile), with nothing
on the dashboard. Add a prominent dashboard banner (shown only to finals-jury
members) linking to /jury/finals-documents, and gate the nav "Finalist Documents"
link to members so other jurors don't hit a dead "No access" page.

- finalist.canReviewDocuments: lightweight boolean procedure (self-resolves active
  program) so the nav can gate the link without fetching the full payload
- jury-nav: show "Finalist Documents" only when canReviewDocuments
- jury dashboard: FinalsJuryBanner server component, gated via userCanReviewFinals,
  rendered above content regardless of assignment state

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:56:02 +02:00
Matt
85937ec942 docs(final-docs): implementation plan for judge-doc curation + optional uploads
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:06:20 +02:00
Matt
81352d7bd2 docs(final-docs): spec for judge-doc curation + optional revised uploads
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:59:48 +02:00
51 changed files with 10982 additions and 3630 deletions

5
.gitignore vendored
View File

@@ -63,3 +63,8 @@ build-output.txt
private/ private/
public/build-id.json public/build-id.json
.remember/ .remember/
# Local tooling + session screenshots
.claude/
.serena/
/*.png

View File

@@ -0,0 +1,745 @@
# Grand Final Judge-Doc Curation + Optional Uploads 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:** Let admins curate which previously-submitted documents finale judges see, and make the "optional revised uploads" mode render correctly for finalists.
**Architecture:** Everything builds on the existing `final-documents.ts` service + `finalist` tRPC router + the LIVE_FINAL round's `configJson` (same pattern as the shipped `allowFinalistRevisedUploads` toggle). A new configJson key `reviewVisibleRequirementIds` filters the judge review payload; new status fields `hasRequired`/`allUploaded` drive optional-mode rendering in the finalist banner/panel. No schema migration.
**Tech Stack:** Next.js 15 App Router, tRPC 11 + Zod, Prisma 6, Vitest 4 (sequential, real test DB), shadcn/ui (Card/Switch/Checkbox).
**Spec:** `docs/superpowers/specs/2026-06-09-finale-doc-curation-optional-uploads-design.md`
**Conventions for every task:** TypeScript strict, `type` over `interface`. Tests use the factories in `tests/helpers.ts` (`createTestProgram`, `createTestCompetition`, `createTestRound`, `createTestProject`, `createTestProjectRoundState`, `createTestUser`, `uid`) and clean up with `cleanupTestData(programId, userIds?)` in `afterAll`. Run a single file with `npx vitest run tests/unit/<file>.test.ts`.
---
### Task 1: `hasRequired` + `allUploaded` on `FinalDocumentStatus`
**Files:**
- Modify: `src/server/services/final-documents.ts` (type at ~line 14, computation at ~line 95)
- Test: `tests/unit/final-documents.test.ts` (extend existing file)
- [ ] **Step 1: Write the failing tests**
In `tests/unit/final-documents.test.ts`, first extend the `makeFinaleProgram` factory (top of file) with an `optionalRequirements` option — change the two `fileRequirement.create` calls to use it:
```ts
async function makeFinaleProgram(
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean; optionalRequirements?: boolean } = {},
) {
// ... existing body unchanged, except both requirement creates:
// isRequired: !opts.optionalRequirements
}
```
Then add inside the existing `describe('getFinalDocumentStatusForProject', ...)` block:
```ts
it('all-optional round: hasRequired false, allUploaded flips when every slot has a file', async () => {
const { program, round, reqPlan, reqVideo } = await makeFinaleProgram({ optionalRequirements: true })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const before = await getFinalDocumentStatusForProject(prisma, project.id)
expect(before!.hasRequired).toBe(false)
expect(before!.allUploaded).toBe(false)
expect(before!.allRequiredUploaded).toBe(false)
for (const req of [reqPlan!, reqVideo!]) {
await prisma.projectFile.create({
data: {
id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id,
fileType: 'SUPPORTING_DOC', fileName: `f-${req.id}`, mimeType: 'application/pdf', size: 10,
bucket: 'b', objectKey: uid('key'),
},
})
}
const after = await getFinalDocumentStatusForProject(prisma, project.id)
expect(after!.hasRequired).toBe(false)
expect(after!.allUploaded).toBe(true)
})
it('mixed round: hasRequired true; allUploaded only when optional slots are filled too', async () => {
const { program, round, reqPlan } = await makeFinaleProgram()
await prisma.fileRequirement.update({ where: { id: reqPlan!.id }, data: { isRequired: false } })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const status = await getFinalDocumentStatusForProject(prisma, project.id)
expect(status!.hasRequired).toBe(true) // reqVideo still required
expect(status!.allUploaded).toBe(false)
})
it('zero slots: allUploaded false (no vacuous completeness)', async () => {
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const status = await getFinalDocumentStatusForProject(prisma, project.id)
expect(status!.hasRequired).toBe(false)
expect(status!.allUploaded).toBe(false)
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents.test.ts`
Expected: the 3 new tests FAIL with TypeScript/undefined errors on `hasRequired` / `allUploaded` (fields don't exist yet); all pre-existing tests still pass.
- [ ] **Step 3: Implement**
In `src/server/services/final-documents.ts`, extend the type (~line 14):
```ts
export type FinalDocumentStatus = {
roundId: string
roundName: string
deadline: Date | null
deadlinePassed: boolean
requirements: FinalDocRequirement[]
allRequiredUploaded: boolean
hasRequired: boolean // any slot is marked required
allUploaded: boolean // every listed slot has a file (false when no slots exist)
}
```
And in `getFinalDocumentStatusForProject` (~line 95), replace the return-value computation:
```ts
const required = reqStatuses.filter((r) => r.isRequired)
const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded)
const hasRequired = required.length > 0
const allUploaded = reqStatuses.length > 0 && reqStatuses.every((r) => r.uploaded)
const deadline = round.windowCloseAt ?? null
return {
roundId: round.id,
roundName: round.name,
deadline,
deadlinePassed: deadline ? new Date() > deadline : false,
requirements: reqStatuses,
allRequiredUploaded,
hasRequired,
allUploaded,
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents.test.ts`
Expected: ALL tests pass (new + pre-existing).
- [ ] **Step 5: Commit**
```bash
git add src/server/services/final-documents.ts tests/unit/final-documents.test.ts
git commit -m "feat(final-docs): hasRequired/allUploaded on FinalDocumentStatus for optional-uploads mode"
```
---
### Task 2: Curation filter in `listFinalistDocumentsForReview`
**Files:**
- Modify: `src/server/services/final-documents.ts` (`listFinalistDocumentsForReview`, ~line 257; new helper next to `finalistUploadsEnabled` ~line 45)
- Test: Create `tests/unit/final-documents-curation.test.ts`
- [ ] **Step 1: Write the failing tests**
Create `tests/unit/final-documents-curation.test.ts`. The review service presigns every file via MinIO, so mock `getPresignedUrl` (partial module mock — keeps `BUCKET_NAME` etc. real):
```ts
import { describe, it, expect, afterAll, vi } from 'vitest'
vi.mock('@/lib/minio', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/minio')>()
return { ...actual, getPresignedUrl: vi.fn(async () => 'https://example.test/presigned') }
})
import { prisma } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
cleanupTestData,
uid,
} from '../helpers'
import { listFinalistDocumentsForReview } from '@/server/services/final-documents'
const programIds: string[] = []
afterAll(async () => {
for (const id of programIds) await cleanupTestData(id)
})
/**
* One finalist team with 4 files:
* - Business Plan (prior SUBMISSION round, via requirement reqBP)
* - Pitch Deck (prior SUBMISSION round, via requirement reqDeck)
* - loose.pdf (prior SUBMISSION round, NO requirement)
* - final.mp4 (uploaded directly to the LIVE_FINAL round, via reqFinal)
*/
async function setupCuration() {
const program = await createTestProgram()
programIds.push(program.id)
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
const priorRound = await createTestRound(comp.id, { roundType: 'SUBMISSION', status: 'ROUND_CLOSED', sortOrder: 2 })
const reqBP = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: priorRound.id, name: 'Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 },
})
const reqDeck = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: priorRound.id, name: 'Pitch Deck', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 2 },
})
const finale = await createTestRound(comp.id, {
roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6,
windowCloseAt: new Date(Date.now() + 86_400_000),
configJson: { allowFinalistRevisedUploads: true },
})
const reqFinal = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: finale.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: false, sortOrder: 1 },
})
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, finale.id)
const mkFile = (roundId: string, requirementId: string | null, fileName: string) =>
prisma.projectFile.create({
data: {
id: uid('file'), projectId: project.id, roundId, requirementId,
fileType: 'SUPPORTING_DOC', fileName, mimeType: 'application/pdf', size: 10,
bucket: 'b', objectKey: uid('key'),
},
})
await mkFile(priorRound.id, reqBP.id, 'bp.pdf')
await mkFile(priorRound.id, reqDeck.id, 'deck.pdf')
await mkFile(priorRound.id, null, 'loose.pdf')
await mkFile(finale.id, reqFinal.id, 'final.mp4')
return { program, priorRound, finale, reqBP, reqDeck, reqFinal, project }
}
async function setSelection(roundId: string, ids: string[] | null) {
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { configJson: true } })
const cfg = (round.configJson ?? {}) as Record<string, unknown>
if (ids === null) delete cfg.reviewVisibleRequirementIds
else cfg.reviewVisibleRequirementIds = ids
await prisma.round.update({ where: { id: roundId }, data: { configJson: cfg as object } })
}
describe('listFinalistDocumentsForReview curation', () => {
it('no selection key → all files visible (current behavior)', async () => {
const { program } = await setupCuration()
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams).toHaveLength(1)
expect(result.teams[0].files).toHaveLength(4)
})
it('selection → only matching prior files, finale uploads always visible', async () => {
const { program, finale, reqBP } = await setupCuration()
await setSelection(finale.id, [reqBP.id])
const result = await listFinalistDocumentsForReview(prisma, program.id)
const names = result.teams[0].files.map((f) => f.fileName).sort()
expect(names).toEqual(['bp.pdf', 'final.mp4']) // deck.pdf and loose.pdf hidden
})
it('empty selection → only finale uploads visible', async () => {
const { program, finale } = await setupCuration()
await setSelection(finale.id, [])
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams[0].files.map((f) => f.fileName)).toEqual(['final.mp4'])
})
it('prior file without a requirement is excluded under any selection', async () => {
const { program, finale, reqBP, reqDeck } = await setupCuration()
await setSelection(finale.id, [reqBP.id, reqDeck.id])
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams[0].files.map((f) => f.fileName)).not.toContain('loose.pdf')
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: the first test PASSES (current behavior), the other three FAIL (filter not implemented — they see all 4 files).
- [ ] **Step 3: Implement**
In `src/server/services/final-documents.ts`, add a config reader next to `finalistUploadsEnabled` (~line 45):
```ts
/**
* Which prior-round FileRequirement ids are visible to finale judges.
* null = no curation (show all prior files). Empty array = hide all prior
* files (Grand Final round uploads are always shown regardless).
*/
export function reviewVisibleRequirementIds(configJson: unknown): string[] | null {
const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null
}
```
In `listFinalistDocumentsForReview`:
1. After the `if (!round) return ...` guard, read the selection: `const visibleIds = reviewVisibleRequirementIds(round.configJson)`
2. Add `requirementId: true` to the `allFiles` select.
3. At the top of the `for (const f of allFiles)` loop, before building `rf`:
```ts
const isFinaleUpload = f.roundId === round.id
// Curated mode: prior-round files must match a selected requirement; finale uploads always pass.
if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue
```
4. Use the `isFinaleUpload` const in the `rf` object (replacing the inline `f.roundId === round.id`).
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: all 4 PASS.
Also run the neighbors to catch regressions: `npx vitest run tests/unit/final-documents.test.ts`
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts
git commit -m "feat(final-docs): filter judge review by reviewVisibleRequirementIds (finale uploads always shown)"
```
---
### Task 3: Picker options helper `listReviewVisibilityOptions`
**Files:**
- Modify: `src/server/services/final-documents.ts` (new exported function + type, after `listFinalistDocumentsForReview`)
- Test: `tests/unit/final-documents-curation.test.ts` (extend)
- [ ] **Step 1: Write the failing tests**
Add to `tests/unit/final-documents-curation.test.ts` (import `listReviewVisibilityOptions` from the same service module):
```ts
describe('listReviewVisibilityOptions', () => {
it('lists distinct prior-round slots with counts; excludes finale-round slots and requirement-less files', async () => {
const { program, reqBP, reqDeck } = await setupCuration()
const options = await listReviewVisibilityOptions(prisma, program.id)
expect(options.map((o) => o.requirementId).sort()).toEqual([reqBP.id, reqDeck.id].sort())
const bp = options.find((o) => o.requirementId === reqBP.id)!
expect(bp.name).toBe('Business Plan')
expect(bp.fileCount).toBe(1)
expect(bp.roundName).toBeTruthy()
})
it('returns [] when there is no open finale round', async () => {
const program = await createTestProgram()
programIds.push(program.id)
expect(await listReviewVisibilityOptions(prisma, program.id)).toEqual([])
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: FAIL — `listReviewVisibilityOptions` is not exported.
- [ ] **Step 3: Implement**
In `src/server/services/final-documents.ts`, after `listFinalistDocumentsForReview`:
```ts
export type ReviewDocSlot = {
requirementId: string
name: string
roundName: string
roundSort: number
fileCount: number
}
/**
* Distinct prior-round document slots (FileRequirements) that the finalist
* teams have files for — the options offered in the admin "documents shown to
* judges" picker. Excludes the finale round's own slots (those uploads are
* always visible to judges) and files without a requirement.
*/
export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise<ReviewDocSlot[]> {
const round = await getOpenFinaleRound(prisma, programId)
if (!round) return []
const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } })
const files = await prisma.projectFile.findMany({
where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } },
select: {
requirementId: true,
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
},
})
const slots = new Map<string, ReviewDocSlot>()
for (const f of files) {
if (!f.requirementId || !f.requirement) continue
const existing = slots.get(f.requirementId)
if (existing) existing.fileCount++
else slots.set(f.requirementId, {
requirementId: f.requirementId,
name: f.requirement.name.trim(),
roundName: f.requirement.round.name,
roundSort: f.requirement.round.sortOrder,
fileCount: 1,
})
}
return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name))
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add src/server/services/final-documents.ts tests/unit/final-documents-curation.test.ts
git commit -m "feat(final-docs): listReviewVisibilityOptions — distinct prior-round doc slots for the curation picker"
```
---
### Task 4: tRPC procedures `getReviewDocSettings` / `setReviewVisibleRequirements`
**Files:**
- Modify: `src/server/routers/finalist.ts` (add two procedures next to `getRevisedUploadSetting`/`setRevisedUploadSetting`, ~line 1695; extend the existing `@/server/services/final-documents` import with `listReviewVisibilityOptions` and `reviewVisibleRequirementIds`)
- Test: `tests/unit/final-documents-curation.test.ts` (extend)
- [ ] **Step 1: Write the failing tests**
Add to `tests/unit/final-documents-curation.test.ts`:
```ts
import * as finalistRouter from '@/server/routers/finalist'
import { createCaller } from '../setup'
import { createTestUser } from '../helpers' // merge into the existing helpers import
describe('finalist review-doc settings procedures', () => {
const userIds: string[] = []
afterAll(async () => {
// cleanupTestData of programIds already runs in the file-level afterAll;
// pass userIds through an extra cleanup for the admin users:
for (const id of programIds) await cleanupTestData(id, userIds)
})
it('round-trips a selection and preserves sibling configJson keys', async () => {
const { program, finale, reqBP } = await setupCuration()
const admin = await createTestUser('PROGRAM_ADMIN')
userIds.push(admin.id)
const caller = createCaller(finalistRouter.finalistRouter, admin)
const initial = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(initial.selectedIds).toBeNull()
expect(initial.options.length).toBe(2)
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: [reqBP.id] })
const curated = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(curated.selectedIds).toEqual([reqBP.id])
// sibling key from setupCuration must survive
const round = await prisma.round.findUniqueOrThrow({ where: { id: finale.id }, select: { configJson: true } })
expect((round.configJson as Record<string, unknown>).allowFinalistRevisedUploads).toBe(true)
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: null })
const cleared = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(cleared.selectedIds).toBeNull()
})
it('rejects a non-LIVE_FINAL round', async () => {
const { program, priorRound } = await setupCuration()
const admin = await createTestUser('PROGRAM_ADMIN')
userIds.push(admin.id)
const caller = createCaller(finalistRouter.finalistRouter, admin)
await expect(
caller.setReviewVisibleRequirements({ roundId: priorRound.id, requirementIds: [] }),
).rejects.toThrow()
void program
})
})
```
(Adjust the top-of-file `../helpers` import to include `createTestUser` rather than re-importing.)
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: FAIL — `getReviewDocSettings` does not exist on the router.
- [ ] **Step 3: Implement**
In `src/server/routers/finalist.ts`, extend the existing service import with `listReviewVisibilityOptions, reviewVisibleRequirementIds`, then add after `setRevisedUploadSetting`:
```ts
/** Options + current selection for the "documents shown to judges" picker. */
getReviewDocSettings: adminProcedure
.input(z.object({ programId: z.string(), roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
return {
options: await listReviewVisibilityOptions(ctx.prisma, input.programId),
selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null),
}
}),
/** Set which prior-round documents finale judges see. null = show all (clears curation). */
setReviewVisibleRequirements: adminProcedure
.input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
if (!round || round.roundType !== 'LIVE_FINAL') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
}
const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record<string, unknown>
const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds }
await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_REVIEW_DOCS_CURATED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { requirementIds: input.requirementIds },
})
return { ok: true }
}),
```
Note: `data: { configJson: next }` may need `next as Prisma.InputJsonValue` depending on inference — match how the file already imports/uses Prisma types if the typechecker complains.
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/unit/final-documents-curation.test.ts`
Expected: all PASS. Then `npm run typecheck` — clean.
- [ ] **Step 5: Commit**
```bash
git add src/server/routers/finalist.ts tests/unit/final-documents-curation.test.ts
git commit -m "feat(final-docs): admin procedures to read/set judge-visible document curation"
```
---
### Task 5: Optional-mode rendering — banner + panel
No component-test infrastructure exists in this repo (vitest covers server code only) — verify via typecheck + the manual smoke in Task 7.
**Files:**
- Modify: `src/components/applicant/final-documents-banner.tsx`
- Modify: `src/components/applicant/final-documents-panel.tsx`
- [ ] **Step 1: Update the banner**
In `final-documents-banner.tsx`:
1. Guard (line 11): `if (!status || status.requirements.length === 0) return null`
2. Replace the `done` computation (line 18) and add the mode flag:
```ts
const optionalMode = !status.hasRequired
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
```
3. Replace the title span (lines 26-28):
```tsx
<span className="font-semibold">
{done
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
</span>
```
Everything else (styling, checklist, deadline, button gated on `!done`) stays as is.
- [ ] **Step 2: Update the panel**
In `final-documents-panel.tsx`:
1. Guard (line 21): `if (!status || status.requirements.length === 0) return null`
2. Add after the guard: `const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded`
3. Replace both `status.allRequiredUploaded` usages (badge at line 29, team upload-button gate at line 51) with `done`.
4. Badge label: `{status.hasRequired ? 'Submitted' : 'Uploaded'}`
5. Description (line 38) — append the optional hint:
```tsx
<CardDescription>
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
{!status.hasRequired && ' These uploads are optional.'}
</CardDescription>
```
- [ ] **Step 3: Typecheck**
Run: `npm run typecheck`
Expected: clean. (The tRPC client types pick up `hasRequired`/`allUploaded` from Task 1 automatically.)
- [ ] **Step 4: Commit**
```bash
git add src/components/applicant/final-documents-banner.tsx src/components/applicant/final-documents-panel.tsx
git commit -m "feat(final-docs): optional-mode rendering for finalist banner + panel"
```
---
### Task 6: Admin "Documents shown to judges" card
**Files:**
- Create: `src/components/admin/grand-finale/review-docs-picker.tsx`
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (import near line 100; render near line 1535)
- [ ] **Step 1: Create the picker component**
`src/components/admin/grand-finale/review-docs-picker.tsx` (full file):
```tsx
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { Eye } from 'lucide-react'
/**
* Admin picker: which previously-submitted documents finale judges see on the
* review page. Default (switch off) shows everything; switching to curated
* mode starts with all slots ticked, and the admin unticks what to hide.
* Grand Final round uploads are always visible regardless.
*/
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
const utils = trpc.useUtils()
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
onError: (e) => toast.error(e.message),
})
if (!data || data.options.length === 0) return null
const curated = data.selectedIds !== null
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
const toggleSlot = (id: string, on: boolean) => {
const next = new Set(selected)
if (on) next.add(id)
else next.delete(id)
set.mutate({ roundId, requirementIds: [...next] })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Eye className="h-5 w-5" /> Documents shown to judges
</CardTitle>
<CardDescription>
Choose which previously submitted documents judges see on the finalist review page.
Documents uploaded directly to this Grand Final round are always visible.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Switch
id="curate-review-docs"
checked={curated}
disabled={set.isPending}
onCheckedChange={(v) =>
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
/>
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
</Label>
</div>
{curated && (
<div className="space-y-2">
{data.options.map((o) => (
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={selected.has(o.requirementId)}
disabled={set.isPending}
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
/>
<span>{o.name} {o.roundName}</span>
<span className="text-xs text-muted-foreground">
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
</span>
</label>
))}
</div>
)}
</CardContent>
</Card>
)
}
```
- [ ] **Step 2: Wire into the round admin page**
In `src/app/(admin)/admin/rounds/[roundId]/page.tsx`:
Import (next to the other grand-finale imports, ~line 100):
```tsx
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
```
Render inside the existing `isGrandFinale && programId` block, directly after the flex row containing `<FinalDocsUploadsToggle …>` (the `</div>` around line 1545):
```tsx
<ReviewDocsPicker programId={programId} roundId={roundId} />
```
- [ ] **Step 3: Typecheck**
Run: `npm run typecheck`
Expected: clean.
- [ ] **Step 4: Commit**
```bash
git add src/components/admin/grand-finale/review-docs-picker.tsx "src/app/(admin)/admin/rounds/[roundId]/page.tsx"
git commit -m "feat(final-docs): admin card to curate documents shown to finale judges"
```
---
### Task 7: Full verification
**Files:** none (verification only)
- [ ] **Step 1: Full test suite**
Run: `npx vitest run`
Expected: all tests pass (was 321 before this work; now more).
- [ ] **Step 2: Lint + typecheck + build**
Run: `npm run lint && npm run typecheck && npm run build`
Expected: all clean. (CLAUDE.md: always build before push.)
- [ ] **Step 3: Manual smoke (dev server + Playwright or browser)**
1. As admin, open the Grand Final round page → the "Documents shown to judges" card lists the prior-round slots with counts; flip to curated, untick one slot.
2. Open `/admin/finals-documents` → the unticked document type disappears from every team; any Grand Final uploads remain.
3. Flip the curation switch off → all documents reappear.
4. With `allowFinalistRevisedUploads` ON and all finale slots optional (set in dev data), check the applicant dashboard banner shows "Upload updated Grand Final documents (optional)" and turns green only when every slot is filled.
- [ ] **Step 4: Commit anything outstanding**
```bash
git status --short # should be clean except untracked screenshots/docs
```
---
## Self-Review (completed at planning time)
- **Spec coverage:** configJson key + semantics → Tasks 2/4; always-visible finale uploads → Task 2; requirement-less files excluded under selection → Task 2; picker options with counts → Task 3; admin UI next to toggle → Task 6; `hasRequired`/`allUploaded` + zero-slot edge → Tasks 1/5; sibling-key preservation + audit → Task 4; reminders unchanged → verified in spec, no task needed.
- **Placeholder scan:** none.
- **Type consistency:** `ReviewDocSlot`, `reviewVisibleRequirementIds(configJson)`, `getReviewDocSettings`/`setReviewVisibleRequirements`, `hasRequired`/`allUploaded` used consistently across tasks.

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.

View File

@@ -0,0 +1,93 @@
# Grand Final: judge-visible document curation + optional revised uploads
**Date:** 2026-06-09
**Status:** Approved (Matt, this session)
**Builds on:** `2026-06-09-grand-final-documents-design.md` and the same-day pivot (commits `f8f2d77`, `8a4184d`)
## Problem
Feedback from the other program admin:
> Jury actually need to see BP + Exec summary + 1min video — the ones they uploaded already. And candidates should be able to upload their PDF pres + video — optional, as some sent it another way.
Two gaps against what is deployed:
1. **Judges see too much.** `listFinalistDocumentsForReview` returns *every* file each finalist team ever submitted (57 per team on prod: Pitch Deck, Intro Video, Executive Summary, Business Plan, Promotional Video, plus any Grand Final uploads). The admin wants judges to see a curated subset (BP + exec summary + 1-min video). There is no way to choose which prior documents are surfaced.
2. **"Optional uploads" mode renders wrong.** The three modes the admin wants are: no new uploads (toggle OFF — works), mandatory uploads (toggle ON + slots required — works), and optional uploads (toggle ON + slots marked not-required). In the all-optional case, `FinalDocumentStatus.allRequiredUploaded` is hardcoded `false` when zero slots are required, so the finalist banner/panel never reach a settled state and the copy implies the docs are mandatory.
Prod facts (verified 2026-06-09 via read-only query): 9 finalist teams, 48 prior files + 3 Grand Final uploads. Every team has all 5 prior doc types. The Business Plan and the 1-minute promo video live under *two different* `FileRequirement` rows depending on the team's path (Semi-Finals Document Submission for 8 teams, Spotlight on Africa Submission Round for 1 team — Blue Fields Company).
## Part 1 — Admin curation of judge-visible documents
### Storage
New optional key on the LIVE_FINAL round's `configJson`:
```ts
reviewVisibleRequirementIds?: string[] // FileRequirement ids from prior rounds
```
Semantics:
- **absent / null** → show all prior files (current behavior; safe default, no migration needed)
- **non-empty array** → show only prior files whose `requirementId` is in the list
- **empty array** → hide all prior files (only Grand Final uploads remain visible)
- **Grand Final round uploads are always shown**, regardless of the selection — they are what the team explicitly submitted for the finale
- Prior files with no `requirementId` (fileType-only) are excluded whenever a selection is active. (All 48 prod files have a requirement, so nothing is lost in practice.)
### Service (`src/server/services/final-documents.ts`)
`listFinalistDocumentsForReview` adds `requirementId` to its file select and applies the filter above using the finale round's `configJson`. No signature change; `ReviewPayload` unchanged.
New helper to power the admin picker: list the distinct prior-round requirement slots referenced by the finalist teams' files — `{ requirementId, name, roundName, fileCount }`, ordered by round sort then name. Derived from the same file query, so the picker only offers slots that actually have files.
### tRPC (`src/server/routers/finalist.ts`, adminProcedure)
- `getReviewDocSettings``{ options: Slot[], selectedIds: string[] | null }` (null = "all" mode)
- `setReviewVisibleRequirements({ requirementIds: string[] | null })` → writes/clears the configJson key (null clears back to "show all"). Audited like `setRevisedUploadSetting`.
### Admin UI
New card "Documents shown to judges" placed next to the existing revised-uploads toggle (`src/components/admin/grand-finale/final-docs-uploads-toggle.tsx`, rendered on the LIVE_FINAL round admin page `src/app/(admin)/admin/rounds/[roundId]/page.tsx`):
- A "Show all submitted documents" master state (the default), and beneath it a checkbox per slot labeled `"<requirement name> — <round name>"` with the file count (e.g. "Business Plan — Semi-Finals Document Submission (8 files)").
- Unchecking the master switches to curated mode with all boxes ticked; the admin then unticks what judges shouldn't see. Re-checking the master clears the selection (back to null/"all").
- Copy notes that Grand Final uploads are always visible to judges.
For the admin's stated goal, they'd switch to curated mode and leave 5 boxes ticked: Executive Summary (Intake), Business Plan (Semi-Finals + Spotlight), Promotional Video (Semi-Finals) and 1 Minute Promotional Video (Spotlight) — judges then see exactly BP + exec summary + 1-min video per team, plus any finale uploads.
## Part 2 — All-optional upload mode fix
`FinalDocumentStatus` (in `final-documents.ts`) gains:
```ts
hasRequired: boolean // any slot with isRequired
allUploaded: boolean // requirements.length > 0 && every slot has a file, required or not
```
`allRequiredUploaded` keeps its current semantics (meaningful only when `hasRequired`). Edge case: if the toggle is ON but no slots are defined at all (`requirements.length === 0`), the banner and panel render nothing — no vacuous "(0 of 0)" complete state.
UI changes:
- **Banner** (`src/components/applicant/final-documents-banner.tsx`): when `hasRequired` is false — title "Upload updated Grand Final documents (optional)", same neutral blue styling, keep per-doc checklist/count/deadline/upload button; green settled state ("Grand Final documents uploaded") only when `allUploaded`.
- **Panel** (`src/components/applicant/final-documents-panel.tsx`, team + mentor variants): "Submitted" badge driven by `hasRequired ? allRequiredUploaded : allUploaded`; description gains "(optional)" when nothing is required.
Reminders need **no change** — verified: the cron and the untargeted manual blast already skip teams with no missing *required* docs, so all-optional mode never nags; an explicitly targeted manual reminder still sends (intentional admin override).
## Out of scope (admin actions in existing UI, not code)
- Flipping `allowFinalistRevisedUploads` ON
- Creating/adjusting the finale upload slots (PDF presentation + 1-min video, "Required" off) in the round's file-requirements editor
- Ticking the curation checkboxes
- Populating the Finals Jury group (still open from the previous ship)
## Testing
Vitest service tests (extend `tests/` final-documents coverage):
- Curation: null selection → all files; selection → only matching prior files + finale uploads always; empty array → finale uploads only; file without requirementId excluded under a selection.
- Picker helper returns distinct slots with correct counts.
- `setReviewVisibleRequirements` round-trips null/array through configJson without clobbering other keys (`allowFinalistRevisedUploads`).
- Status: `hasRequired`/`allUploaded` across mixed, all-optional (0 required), and fully-uploaded fixtures.
## Risks
- **configJson clobbering:** both toggles write the same JSON column — read-modify-write must preserve sibling keys (existing `setRevisedUploadSetting` pattern already does this; reuse it).
- **Stale selection:** if a selected requirement is later deleted, its files simply stop matching; "all" fallback never breaks. No cleanup needed.

View File

@@ -0,0 +1,201 @@
# Grand Finale Ceremony System — Design (Option C)
> **Status:** Approved 2026-06-10. Event is 2026-06-11 — build tonight in strict dependency order (see Build Order). Each layer must leave the system complete and operable if work stops there.
>
> Supersedes the operational scope of `2026-04-28-grand-finale-live-voting-rework.md` (the audience-window section of that spec is implemented here as designed).
## Decisions (locked with user)
1. **Jury scoring mode:** criteria scores + optional comment (like prior evaluation rounds). Live ranking mode is a stretch goal only.
2. **Audience votes:** per-category favorite windows always; an **overall-favorite** window exists behind an admin toggle (decided day-of).
3. **Vote gating:** one vote per browser token per window AND max **3 votes per IP per window** (venue NAT tolerance).
4. **Deliberation:** per category (two sessions). Existing backend (FULL_RANKING/Borda, adminDecide override) is used as-is.
5. **Architecture:** Option C = full B scope + big-screen ceremony view + results reveal controller. Big screen is **derived from existing state** — no new session-level phase machine. Only new state: reveal controller + display override slide.
6. **During audience windows the big screen shows vote count only** ("147 votes cast"), never a live per-project tally.
7. **Big-screen results reveal must be visually outstanding** — projector-grade, brand identity (dark blue `#053d57` field, red `#de0f1e` accent, Montserrat), animated transitions.
## Current foundation (verified 2026-06-10)
- `LiveProgressCursor` (cursor: activeProjectId, activeOrderIndex, isPaused) + `live.ts` router (start/jump/reorder/pause/resume/getCursor).
- `LiveVotingSession` / `LiveVote` / `AudienceVoter` + `live-voting.ts` router (criteria voting, importCriteriaFromForm, getResults, registerAudienceVoter/castAudienceVote, public results).
- Jury live page `/jury/competitions/[roundId]/live` follows cursor (poll 5s); notes textarea is **not persisted**; prior-data panel stubbed.
- Admin `live-control-panel.tsx`: prev/next/pause/resume + **client-local fake timer**.
- Public `/live-scores/[sessionId]` scoreboard with SSE.
- Deliberation backend + router 100% complete; jury deliberation page has `juryMemberId=''` and `hasVoted=false` hardcoded (jurors cannot vote).
- `publicPaths` in `auth.config.ts` does **not** include `/vote` or `/live-scores` → audience pages bounce to login. Launch blocker.
---
## 1. Public access (do first)
Add `/vote`, `/live-scores`, `/live/ceremony` to `publicPaths` in `src/lib/auth.config.ts` (wherever publicPaths lives). Verify with `curl -I` (not the logged-in Playwright profile).
## 2. Schema changes (one migration)
```prisma
// LiveProgressCursor — per-project phase + server-stamped timer
projectPhase LivePhase @default(ON_DECK) // ON_DECK | PRESENTING | QA | SCORING
phaseStartedAt DateTime?
phaseDurationSeconds Int?
phasePausedAt DateTime?
phasePausedAccumMs Int @default(0)
timingLogJson Json? // [{projectId, phase, startedAt, endedAt, configuredSeconds, overranSeconds}]
overrideSlide String? // 'welcome' | 'break' | 'deliberation' | 'thanks' | null
enum LivePhase { ON_DECK PRESENTING QA SCORING }
// LiveVotingSession — audience window (locked spec from 2026-04-28 + overall kind)
audiencePhase AudiencePhase @default(CLOSED) // CLOSED | OPEN
audienceWindowKey String? // 'CATEGORY:STARTUP' | 'CATEGORY:BUSINESS_CONCEPT' | 'OVERALL'
audienceWindowOpenedAt DateTime?
audienceWindowClosesAt DateTime?
allowOverallFavorite Boolean @default(false) // admin toggle, decided day-of
enum AudiencePhase { CLOSED OPEN }
// LiveVote — optional overall comment
comment String?
model AudienceFavoriteVote {
id String @id @default(cuid())
sessionId String
windowKey String // matches audienceWindowKey at cast time
projectId String
audienceVoterId String
ipAddress String?
createdAt DateTime @default(now())
@@unique([sessionId, windowKey, audienceVoterId])
@@index([sessionId, windowKey, ipAddress])
}
model LiveNote {
id String @id @default(cuid())
roundId String
projectId String
userId String
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([roundId, projectId, userId])
}
model RevealState {
id String @id @default(cuid())
sessionId String @unique
status String @default("DRAFT") // DRAFT | ARMED | REVEALING | DONE
stepsJson Json // ordered reveal steps, see §8
currentStepIndex Int @default(-1)
}
```
Timer math (shared client+server helper `src/lib/live-timer.ts`): `remaining = phaseDurationSeconds (now phaseStartedAt phasePausedAccumMs)`; negative = overtime, displayed `+m:ss` in red. On every phase transition the server appends a timing-log entry with `overranSeconds = max(0, elapsed configured)`.
## 3. Ceremony control — `live.ts` router + admin panel revamp
New adminProcedure mutations on `live`:
- `sendToScreens({roundId, projectId?})` — advance cursor to project (or next), `projectPhase=ON_DECK`, no timer. This is the "next up" grace.
- `startPresentation({roundId, durationSeconds?})``PRESENTING`, stamp `phaseStartedAt`, duration from round config `presentationDurationMinutes` unless overridden.
- `startQA({roundId, durationSeconds?})` — close out PRESENTING into timing log, start QA timer (`qaDurationMinutes` default).
- `openScoring({roundId})` — close QA into log, `phase=SCORING`, no timer.
- `pausePhase` / `resumePhase` — stamp `phasePausedAt` / fold into `phasePausedAccumMs`.
- `setOverrideSlide({roundId, slide})` — force big-screen slide or clear.
- `getCursor` extended to return phase, timer stamps, timing log, override slide.
Admin panel (`live-control-panel.tsx` revamp):
- Project order list **grouped by category**, current/next highlighted, reorder preserved (existing `reorder` mutation), tap-to-`sendToScreens` any project (handles schedule shuffles).
- Big phase buttons in flow order; live server-derived countdown, red + counting up when over; pause/resume.
- Per-project duration override inputs (pre-filled from config).
- Timing log table (per project: presentation over by X, Q&A over by Y).
- Audience section: window open/close buttons per category + overall (gated by `allowOverallFavorite` toggle), duration picker (default 5 min), live countdown, **live vote count**, "Close now". QR button → full-screen dialog with giant QR (`qrcode.react` or equivalent tiny dep) linking to `/vote/competition/[roundId]`.
- Override-slide buttons: Welcome / Break / Deliberation / Thank you / Clear.
- Reveal section: see §9.
## 4. Jury live page
- Phase-aware: ON_DECK → "Up next: Team X" banner; PRESENTING/QA → project details, same countdown the admin sees, persistent notes; SCORING → scoring form spotlighted (form already available from PRESENTING on — early scorers not blocked).
- **Notes**: `LiveNote` autosave (debounced ~800ms) via new `live.saveNote` / `live.getMyNotes` (juryProcedure). Notes resurface in deliberation.
- Scoring: existing criteria form + new optional **comment** textarea → stored on `LiveVote.comment`. Votes upsert-editable until session COMPLETED.
## 5. Audience voting
`live-voting.ts` additions:
- `openAudienceWindow({sessionId, windowKey, durationMinutes})` (admin) — errors if already OPEN; `OVERALL` requires `allowOverallFavorite`.
- `closeAudienceWindow({sessionId})` (admin) — early close allowed anytime.
- `getAudienceWindow({sessionId})` (public) — phase, windowKey, closesAt, eligible projects (category members or all), my-vote-for-this-window (by token).
- `castFavoriteVote({sessionId, token, projectId})` (public). Server-side gates, in order:
1. `audiencePhase === OPEN`
2. `now <= audienceWindowClosesAt` (source of truth even if no cron closes it)
3. project's category matches windowKey (or OVERALL → any finalist project)
4. token valid for session
5. unique (session, windowKey, voter) — re-vote within open window **updates** the row (change-your-mind allowed while open)
6. IP cap: ≥3 distinct-voter rows for (session, windowKey, ipAddress) → reject with friendly message
- `getFavoriteTallies({sessionId})` (admin) — counts per project per windowKey.
- `setAllowOverallFavorite({sessionId, allow})` (admin).
Audience page `/vote/competition/[roundId]` (rework): scan → auto-`registerAudienceVoter`, token in localStorage → states: **waiting** ("Voting opens after the presentations" + current presenting team name), **open** (category title, project cards, tap → confirm → done, countdown chip), **voted** ("Vote recorded — you can change it until the window closes"), **closed**. Mobile-first, zero-instruction usable.
No cron needed: vote-time + read-time checks enforce the close; window auto-renders as closed everywhere once `closesAt` passes.
## 6. Deliberation completion
- Fix jury page wiring: resolve `juryMemberId` from `session.participants` matching current user; derive `hasVoted` from `session.votes`.
- Add per-project context panels to the jury deliberation page: **my finale scores** (from `LiveVote`, editable inline — edits upsert the same vote, audit-logged; "keep" = do nothing), **my live notes** (`LiveNote`), **document links** (reuse the judge-docs components/links from the finals docs feature).
- Admin: existing create-session (per category), aggregate, runoff, `adminDecide` (manual rankings), finalize, result lock — verify end-to-end, no new build expected.
## 7. Big-screen ceremony view — `/live/ceremony/[roundId]` (public)
Full-screen, no chrome, dark-blue field, Montserrat, brand accents. **Pure derivation** of state (poll ~2s + reuse SSE hook where available):
| State | Display |
|---|---|
| `overrideSlide` set | That slide (Welcome / Break / Deliberation in progress / Thank you) |
| Reveal REVEALING/ARMED | Reveal mode (§9) |
| Audience window OPEN | Giant QR + "Vote for your favorite {category}" + countdown + **vote count only** |
| Cursor ON_DECK | "Up next: Team X" |
| Cursor PRESENTING/QA | Team name, category chip, phase label, large countdown (red overtime) |
| Cursor SCORING | "Jury is scoring" interstitial |
| Nothing active | Welcome slide |
## 8. Reveal controller (admin) — §3 panel section
- "Build reveal" generates `stepsJson` from deliberation results (per category 3rd→2nd→1st) + audience favorite tallies (per-category winners, overall if enabled): `[{kind:'category-intro',category}, {kind:'place',category,place,projectId}, …, {kind:'audience-award',windowKey,projectId}, {kind:'thanks'}]`. Editable/rebuildable while DRAFT (e.g., after adminDecide changes order).
- Admin previews all steps privately. "Arm" → big screen shows a Results splash. "Next" advances `currentStepIndex` one step at a time. "Reset" → back to DRAFT, screen leaves reveal mode.
- Router: `liveVoting.buildReveal`, `armReveal`, `revealNext`, `resetReveal` (admin) + reveal state included in the public ceremony-state query (steps beyond `currentStepIndex` are **never** sent to the public endpoint).
## 9. Reveal visuals (the gorgeous part)
Use the `frontend-design` skill when building. Requirements: cinematic step transitions (place card slides/fades up, 1st-place moment visibly bigger than 3rd/2nd), confetti or equivalent flourish on 1st place and audience award, team name in very large Montserrat 700, category chip, no scores shown unless step includes them, safe on 16:9 projector at distance (high contrast, no small text). Prefer CSS/tailwind animation; adding `framer-motion` is acceptable if it materially raises quality. Must degrade gracefully (refresh mid-reveal lands on current step).
## 10. Results tally audit
- Jury results: existing `getResults` weighted aggregation + tie detection — dedicated tests.
- Audience awards are **counts from `AudienceFavoriteVote`**, kept separate from jury scores (separate awards). `audienceVoteWeight` blending stays available but is NOT used in the reveal unless explicitly configured; default 0.
## Out of scope (explicit)
Live ranking mode for jurors (stretch only, after everything else), automated tie-breaker revotes (admin_decides stands), audience phones showing live scores (vote-only page), session-level ceremony phase machine.
## Build order (cut line moves down, never breaks)
1. publicPaths fix + schema migration
2. Audience windows + favorite votes + IP cap + audience page + QR (audience system complete)
3. Phase model + server timers + admin panel revamp (ceremony operable)
4. Jury page phases + persisted notes + vote comments (jury complete)
5. Deliberation wiring fix + context panels (deliberation complete)
6. Big-screen ceremony view (derived states + override slides)
7. Reveal controller + reveal visuals
8. Tally audit tests → stretch: live ranking toggle
Each layer: vitest tests + `npm run build` green before the next.
## Test matrix (vitest, follows tests/helpers.ts factory pattern)
- Window: open/cast OK; wrong-category reject; cast after closesAt reject (no cron); early close reject; re-open works; OVERALL requires toggle.
- Favorite votes: one per token per window; re-vote updates while open; IP cap at 3 distinct voters; tallies correct.
- Phases: transition sequence; timing log entries with correct overranSeconds; pause/resume accumulator math.
- Notes: upsert per (round, project, user).
- Deliberation: juryMemberId resolution, hasVoted, vote→aggregate→finalize with real juror identity.
- Reveal: build from results, step advance, public endpoint never leaks un-revealed steps.
- Results: weighted jury aggregation + tie detection regression tests.

10
package-lock.json generated
View File

@@ -65,6 +65,7 @@
"openai": "^6.16.0", "openai": "^6.16.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"qrcode.react": "^4.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@@ -13417,6 +13418,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",

View File

@@ -79,6 +79,7 @@
"openai": "^6.16.0", "openai": "^6.16.0",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"qrcode.react": "^4.2.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -0,0 +1,104 @@
-- CreateEnum
CREATE TYPE "LivePhase" AS ENUM ('ON_DECK', 'PRESENTING', 'QA', 'SCORING');
-- CreateEnum
CREATE TYPE "AudiencePhase" AS ENUM ('CLOSED', 'OPEN');
-- AlterTable
ALTER TABLE "LiveProgressCursor" ADD COLUMN "overrideSlide" TEXT,
ADD COLUMN "phaseDurationSeconds" INTEGER,
ADD COLUMN "phasePausedAccumMs" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "phasePausedAt" TIMESTAMP(3),
ADD COLUMN "phaseStartedAt" TIMESTAMP(3),
ADD COLUMN "projectPhase" "LivePhase" NOT NULL DEFAULT 'ON_DECK',
ADD COLUMN "timingLogJson" JSONB;
-- AlterTable
ALTER TABLE "LiveVote" ADD COLUMN "comment" TEXT;
-- AlterTable
ALTER TABLE "LiveVotingSession" ADD COLUMN "allowOverallFavorite" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "audiencePhase" "AudiencePhase" NOT NULL DEFAULT 'CLOSED',
ADD COLUMN "audienceWindowClosesAt" TIMESTAMP(3),
ADD COLUMN "audienceWindowKey" TEXT,
ADD COLUMN "audienceWindowOpenedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "AudienceFavoriteVote" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"windowKey" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"audienceVoterId" TEXT NOT NULL,
"ipAddress" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AudienceFavoriteVote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LiveNote" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LiveNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RevealState" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'DRAFT',
"stepsJson" JSONB NOT NULL,
"currentStepIndex" INTEGER NOT NULL DEFAULT -1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RevealState_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AudienceFavoriteVote_sessionId_windowKey_ipAddress_idx" ON "AudienceFavoriteVote"("sessionId", "windowKey", "ipAddress");
-- CreateIndex
CREATE INDEX "AudienceFavoriteVote_sessionId_windowKey_projectId_idx" ON "AudienceFavoriteVote"("sessionId", "windowKey", "projectId");
-- CreateIndex
CREATE UNIQUE INDEX "AudienceFavoriteVote_sessionId_windowKey_audienceVoterId_key" ON "AudienceFavoriteVote"("sessionId", "windowKey", "audienceVoterId");
-- CreateIndex
CREATE INDEX "LiveNote_userId_idx" ON "LiveNote"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "LiveNote_roundId_projectId_userId_key" ON "LiveNote"("roundId", "projectId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "RevealState_sessionId_key" ON "RevealState"("sessionId");
-- AddForeignKey
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_audienceVoterId_fkey" FOREIGN KEY ("audienceVoterId") REFERENCES "AudienceVoter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AudienceFavoriteVote" ADD CONSTRAINT "AudienceFavoriteVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LiveNote" ADD CONSTRAINT "LiveNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RevealState" ADD CONSTRAINT "RevealState_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "LiveVotingSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;

File diff suppressed because it is too large Load Diff

View File

@@ -79,6 +79,8 @@ import {
ListChecks, ListChecks,
FileText, FileText,
Languages, Languages,
MonitorPlay,
Scale,
} from 'lucide-react' } from 'lucide-react'
import { import {
Tooltip, Tooltip,
@@ -93,11 +95,14 @@ import { FileRequirementsEditor } from '@/components/admin/round/file-requiremen
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard' import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview' import { MentoringRoundOverview } from '@/components/admin/round/mentoring-round-overview'
import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table' import { MentoringProjectsTable } from '@/components/admin/round/mentoring-projects-table'
import { LiveControlPanel } from '@/components/admin/live/live-control-panel'
import { DeliberationControlPanel } from '@/components/admin/deliberation/deliberation-control-panel'
import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card' import { FinalistSlotsCard } from '@/components/admin/grand-finale/finalist-slots-card'
import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card' import { WaitlistCard } from '@/components/admin/grand-finale/waitlist-card'
import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card' import { FinalistEnrollmentCard } from '@/components/admin/grand-finale/finalist-enrollment-card'
import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button' import { FinalDocsReminderButton } from '@/components/admin/grand-finale/final-docs-reminder-button'
import { FinalDocsUploadsToggle } from '@/components/admin/grand-finale/final-docs-uploads-toggle' import { FinalDocsUploadsToggle } from '@/components/admin/grand-finale/final-docs-uploads-toggle'
import { ReviewDocsPicker } from '@/components/admin/grand-finale/review-docs-picker'
import { RankingDashboard } from '@/components/admin/round/ranking-dashboard' import { RankingDashboard } from '@/components/admin/round/ranking-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report' import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet' import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
@@ -972,6 +977,10 @@ export default function RoundDetailPage() {
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []), ...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []), ...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []), ...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
...(isGrandFinale ? [{ value: 'ceremony', label: 'Ceremony', icon: MonitorPlay }] : []),
...(round?.roundType === 'DELIBERATION'
? [{ value: 'deliberation', label: 'Deliberation', icon: Scale }]
: []),
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []), ...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []), ...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
{ value: 'config', label: 'Config', icon: Settings }, { value: 'config', label: 'Config', icon: Settings },
@@ -1543,6 +1552,7 @@ export default function RoundDetailPage() {
<FinalDocsReminderButton programId={programId} /> <FinalDocsReminderButton programId={programId} />
</div> </div>
</div> </div>
<ReviewDocsPicker programId={programId} roundId={roundId} />
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<FinalistSlotsCard programId={programId} /> <FinalistSlotsCard programId={programId} />
<WaitlistCard programId={programId} /> <WaitlistCard programId={programId} />
@@ -1660,6 +1670,20 @@ export default function RoundDetailPage() {
</TabsContent> </TabsContent>
)} )}
{/* ═══════════ CEREMONY TAB (LIVE_FINAL) ═══════════ */}
{isGrandFinale && (
<TabsContent value="ceremony" className="space-y-4">
<LiveControlPanel roundId={roundId} competitionId={competitionId} />
</TabsContent>
)}
{/* ═══════════ DELIBERATION TAB (DELIBERATION rounds) ═══════════ */}
{round?.roundType === 'DELIBERATION' && (
<TabsContent value="deliberation" className="space-y-4">
<DeliberationControlPanel roundId={roundId} competitionId={competitionId} />
</TabsContent>
)}
{/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */} {/* ═══════════ JURY TAB (non-EVALUATION jury rounds: LIVE_FINAL, DELIBERATION) ═══════════ */}
{hasJury && !isEvaluation && ( {hasJury && !isEvaluation && (
<TabsContent value="jury" className="space-y-6"> <TabsContent value="jury" className="space-y-6">

View File

@@ -1,76 +1,142 @@
'use client' 'use client'
import { use, useState } from 'react' import { use, useEffect, useMemo, useRef, useState } from 'react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { LiveVotingForm } from '@/components/jury/live-voting-form' import { LiveVotingForm } from '@/components/jury/live-voting-form'
import { remainingSeconds, formatClock } from '@/lib/live-timer'
import { Clock, Mic2, MessageCircleQuestion, PenLine, Sparkles } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
const PHASE_META: Record<string, { label: string; icon: typeof Mic2 }> = {
PRESENTING: { label: 'Presentation', icon: Mic2 },
QA: { label: 'Q&A', icon: MessageCircleQuestion },
SCORING: { label: 'Scoring open', icon: PenLine },
}
function PhaseCountdown({ phase }: { phase: {
phaseStartedAt: Date | string | null
phaseDurationSeconds: number | null
phasePausedAt: Date | string | null
phasePausedAccumMs: number
} }) {
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const remaining = remainingSeconds(phase)
if (remaining === null) return null
const over = remaining < 0
return (
<Badge
variant={over ? 'destructive' : 'secondary'}
className={`gap-1 tabular-nums text-sm ${over ? 'animate-pulse' : ''}`}
>
<Clock className="h-3.5 w-3.5" />
{formatClock(remaining)}
{over && <span className="font-semibold">OVER</span>}
{phase.phasePausedAt && <span>· paused</span>}
</Badge>
)
}
export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) { export default function JuryLivePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) {
const params = use(paramsPromise) const params = use(paramsPromise)
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [notes, setNotes] = useState('')
const [priorDataOpen, setPriorDataOpen] = useState(false)
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId }) const { data: cursor } = trpc.live.getCursor.useQuery(
{ roundId: params.roundId },
// Fetch live voting session data { refetchInterval: 2000 }
const { data: sessionData } = trpc.liveVoting.getSessionForVoting.useQuery(
{ sessionId: params.roundId },
{ enabled: !!params.roundId, refetchInterval: 2000 }
) )
const { data: sessionData } = trpc.liveVoting.getSessionForVotingByRound.useQuery(
{ roundId: params.roundId },
{ refetchInterval: 2000 }
)
const { data: myNotes } = trpc.live.getMyNotes.useQuery({ roundId: params.roundId })
// Placeholder for prior data - this would need to be implemented in evaluation router // ── Persisted notes (autosave, keyed per project) ────────────────────────
const priorData = null as { averageScore?: number; evaluationCount?: number; strengths?: string; weaknesses?: string } | null const [noteDrafts, setNoteDrafts] = useState<Record<string, string>>({})
const [noteStatus, setNoteStatus] = useState<'idle' | 'saving' | 'saved'>('idle')
const submitVoteMutation = trpc.liveVoting.vote.useMutation({ const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
onSuccess: () => { const saveNote = trpc.live.saveNote.useMutation({
utils.liveVoting.getSessionForVoting.invalidate() onSuccess: () => setNoteStatus('saved'),
toast.success('Vote submitted successfully') onError: () => setNoteStatus('idle'),
},
onError: (err: any) => {
toast.error(err.message)
},
}) })
const handleVoteSubmit = (vote: { score: number; criterionScores?: Record<string, number> }) => { const activeProject = cursor?.activeProject ?? null
const projectId = cursor?.activeProject?.id || sessionData?.currentProject?.id const activeProjectId = activeProject?.id ?? null
if (!projectId) return
const sessionId = sessionData?.session?.id || params.roundId const savedNoteFor = useMemo(() => {
const map: Record<string, string> = {}
for (const n of myNotes ?? []) map[n.projectId] = n.content
return map
}, [myNotes])
const currentDraft =
activeProjectId != null
? noteDrafts[activeProjectId] ?? savedNoteFor[activeProjectId] ?? ''
: ''
const handleNoteChange = (value: string) => {
if (!activeProjectId) return
setNoteDrafts((d) => ({ ...d, [activeProjectId]: value }))
setNoteStatus('saving')
if (saveTimer.current) clearTimeout(saveTimer.current)
const projectId = activeProjectId
saveTimer.current = setTimeout(() => {
saveNote.mutate({ roundId: params.roundId, projectId, content: value })
}, 800)
}
// ── Voting ───────────────────────────────────────────────────────────────
const submitVoteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
utils.liveVoting.getSessionForVotingByRound.invalidate()
toast.success('Vote submitted')
},
onError: (err) => toast.error(err.message),
})
const handleVoteSubmit = (vote: {
score: number
criterionScores?: Record<string, number>
comment?: string
}) => {
if (!activeProjectId || !sessionData?.session?.id) return
submitVoteMutation.mutate({ submitVoteMutation.mutate({
sessionId, sessionId: sessionData.session.id,
projectId, projectId: activeProjectId,
score: vote.score, score: vote.score,
criterionScores: vote.criterionScores, criterionScores: vote.criterionScores,
comment: vote.comment,
}) })
} }
// Extract voting mode and criteria from session
const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria' const votingMode = (sessionData?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
const criteria = (sessionData?.session?.criteriaJson as Array<{ const criteria = sessionData?.session?.criteriaJson as
id: string | Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
label: string | undefined
description?: string
scale: number
weight: number
}> | undefined)
const activeProject = cursor?.activeProject || sessionData?.currentProject const phase = cursor?.projectPhase ?? 'ON_DECK'
const categoryLabel =
activeProject?.competitionCategory === 'STARTUP'
? 'Startup'
: activeProject?.competitionCategory === 'BUSINESS_CONCEPT'
? 'Business Concept'
: null
if (!activeProject) { if (!activeProject) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">Waiting for ceremony to begin...</p> <Sparkles className="mb-3 h-8 w-8 text-brand-teal/60" />
<p className="font-medium">Waiting for the ceremony to begin</p>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
The admin will control which project is displayed Projects will appear here automatically as they take the stage
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -78,105 +144,116 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
) )
} }
// ── ON_DECK: "Up next" banner, no scoring yet ───────────────────────────
if (phase === 'ON_DECK') {
return (
<div className="space-y-6">
<Card className="overflow-hidden border-0 bg-gradient-to-r from-[#053d57] to-[#0a5a7c] text-white">
<CardContent className="py-12 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
Up next
</p>
<h1 className="mt-3 text-3xl font-bold sm:text-4xl">{activeProject.title}</h1>
{activeProject.teamName && (
<p className="mt-2 text-lg text-white/80">{activeProject.teamName}</p>
)}
{categoryLabel && (
<Badge className="mt-4 bg-white/15 text-white hover:bg-white/15">{categoryLabel}</Badge>
)}
<p className="mt-6 text-sm text-white/60">Presentation starting shortly</p>
</CardContent>
</Card>
{activeProject.description && (
<Card>
<CardHeader>
<CardTitle className="text-base">About this project</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{activeProject.description}</p>
</CardContent>
</Card>
)}
</div>
)
}
const phaseMeta = PHASE_META[phase] ?? PHASE_META.PRESENTING
const PhaseIcon = phaseMeta.icon
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Current Project Display */} {/* Current Project + phase */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle> <CardTitle className="text-2xl">{activeProject.title}</CardTitle>
<CardDescription className="mt-2"> <CardDescription className="mt-1">
Live project presentation {activeProject.teamName}
{categoryLabel ? ` · ${categoryLabel}` : ''}
</CardDescription> </CardDescription>
</div> </div>
{votingMode === 'criteria' && ( <div className="flex items-center gap-2">
<Badge variant="secondary">Criteria Voting</Badge> <Badge variant={phase === 'SCORING' ? 'default' : 'outline'} className="gap-1.5">
)} <PhaseIcon className="h-3.5 w-3.5" />
{phaseMeta.label}
</Badge>
{cursor && <PhaseCountdown phase={cursor} />}
</div>
</div>
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm text-muted-foreground">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Notes — persisted, autosaved */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Your Notes</CardTitle>
<CardDescription>Private resurfaced during deliberation</CardDescription>
</div>
<span className="text-xs text-muted-foreground">
{noteStatus === 'saving' ? 'Saving…' : noteStatus === 'saved' ? 'Saved' : ''}
</span>
</div> </div>
</CardHeader> </CardHeader>
<CardContent>
{activeProject.description && (
<p className="text-muted-foreground">{activeProject.description}</p>
)}
</CardContent>
</Card>
{/* Prior Jury Data (Collapsible) */}
{priorData && (
<Collapsible open={priorDataOpen} onOpenChange={setPriorDataOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Prior Evaluation Data</CardTitle>
{priorDataOpen ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">Average Score</p>
<p className="mt-1 text-2xl font-bold">
{priorData.averageScore?.toFixed(1) || 'N/A'}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="mt-1 text-2xl font-bold">{priorData.evaluationCount || 0}</p>
</div>
</div>
{priorData.strengths && (
<div>
<p className="text-sm font-medium text-muted-foreground">Key Strengths</p>
<p className="mt-1 text-sm">{priorData.strengths}</p>
</div>
)}
{priorData.weaknesses && (
<div>
<p className="text-sm font-medium text-muted-foreground">Areas for Improvement</p>
<p className="mt-1 text-sm">{priorData.weaknesses}</p>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)}
{/* Notes Section */}
<Card>
<CardHeader>
<CardTitle>Your Notes</CardTitle>
<CardDescription>Optional notes for this project</CardDescription>
</CardHeader>
<CardContent> <CardContent>
<Textarea <Textarea
value={notes} value={currentDraft}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => handleNoteChange(e.target.value)}
placeholder="Add your observations and comments..." placeholder="Observations during the presentation and Q&A…"
rows={4} rows={4}
/> />
</CardContent> </CardContent>
</Card> </Card>
{/* Voting Form */} {/* Scoring — available from presentation start, spotlighted at SCORING.
Keyed on vote presence: the form initializes its editing state from
existingVote, which arrives async after mount. */}
<LiveVotingForm <LiveVotingForm
key={`${activeProject.id}-${sessionData?.userVote?.votedAt ?? 'fresh'}`}
projectId={activeProject.id} projectId={activeProject.id}
votingMode={votingMode} votingMode={votingMode}
criteria={criteria} criteria={criteria}
existingVote={sessionData?.userVote ? { existingVote={
score: sessionData.userVote.score, sessionData?.userVote
criterionScoresJson: sessionData.userVote.criterionScoresJson as Record<string, number> | undefined ? {
} : null} score: sessionData.userVote.score,
criterionScoresJson: sessionData.userVote.criterionScoresJson as
| Record<string, number>
| undefined,
comment: sessionData.userVote.comment,
}
: null
}
onVoteSubmit={handleVoteSubmit} onVoteSubmit={handleVoteSubmit}
disabled={submitVoteMutation.isPending} disabled={submitVoteMutation.isPending}
highlighted={phase === 'SCORING'}
/> />
</div> </div>
) )

View File

@@ -1,150 +1,326 @@
'use client'; 'use client'
import { use } from 'react'; import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'; import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form'; import { Badge } from '@/components/ui/badge'
import { CheckCircle2 } from 'lucide-react'; import { Button } from '@/components/ui/button'
import { toast } from 'sonner'; import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { DeliberationRankingForm } from '@/components/jury/deliberation-ranking-form'
import { LiveVotingForm } from '@/components/jury/live-voting-form'
import { CheckCircle2, ChevronDown, FileText, PenLine, StickyNote } from 'lucide-react'
import { toast } from 'sonner'
export default function JuryDeliberationPage({ params: paramsPromise }: { params: Promise<{ sessionId: string }> }) { const CATEGORY_LABEL: Record<string, string> = {
const params = use(paramsPromise); BUSINESS_CONCEPT: 'Business Concepts',
const utils = trpc.useUtils(); STARTUP: 'Startups',
}
/**
* Per-project review context during deliberation: the juror's finale scores
* (revisable in place — "keep" is simply not touching them), their ceremony
* notes, and a pointer to the project documents.
*/
function ProjectReviewCard({
project,
roundId,
finaleInputs,
votingMode,
criteria,
}: {
project: { id: string; title: string; teamName?: string | null }
roundId: string
finaleInputs: any
votingMode: 'simple' | 'criteria'
criteria?: Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
}) {
const utils = trpc.useUtils()
const [open, setOpen] = useState(false)
const myVote = finaleInputs?.votes?.find((v: any) => v.projectId === project.id)
const myNote = finaleInputs?.notes?.find((n: any) => n.projectId === project.id)
const voteMutation = trpc.liveVoting.vote.useMutation({
onSuccess: () => {
utils.liveVoting.getMyFinaleInputs.invalidate({ roundId })
toast.success('Score updated')
},
onError: (err) => toast.error(err.message),
})
return (
<Collapsible open={open} onOpenChange={setOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer py-4 hover:bg-muted/40">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{project.title}</CardTitle>
{project.teamName && (
<CardDescription className="mt-0.5">{project.teamName}</CardDescription>
)}
</div>
<div className="flex items-center gap-2">
{myVote ? (
<Badge variant="secondary" className="tabular-nums">
My score: {myVote.score}/10
</Badge>
) : (
<Badge variant="outline">Not scored</Badge>
)}
<ChevronDown className={`h-4 w-4 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 border-t pt-4">
{myNote?.content && (
<div className="rounded-lg bg-muted/40 p-3">
<p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<StickyNote className="h-3.5 w-3.5" />
Your ceremony notes
</p>
<p className="whitespace-pre-wrap text-sm">{myNote.content}</p>
</div>
)}
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<PenLine className="h-3.5 w-3.5" />
Your grand-finale score edit to revise, or leave as-is to keep it
</p>
{finaleInputs?.session?.id ? (
<LiveVotingForm
key={`${project.id}-${myVote?.votedAt ?? 'fresh'}`}
projectId={project.id}
votingMode={votingMode}
criteria={criteria}
existingVote={
myVote
? {
score: myVote.score,
criterionScoresJson: myVote.criterionScoresJson as
| Record<string, number>
| undefined,
comment: myVote.comment,
}
: null
}
onVoteSubmit={(vote) =>
voteMutation.mutate({
sessionId: finaleInputs.session.id,
projectId: project.id,
score: vote.score,
criterionScores: vote.criterionScores,
comment: vote.comment,
})
}
disabled={voteMutation.isPending}
/>
) : (
<p className="text-sm text-muted-foreground">No finale voting session found.</p>
)}
</div>
<Button asChild variant="outline" size="sm">
<Link href="/jury/finals-documents">
<FileText className="mr-2 h-3.5 w-3.5" />
Open project documents
</Link>
</Button>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}
export default function JuryDeliberationPage({
params: paramsPromise,
}: {
params: Promise<{ sessionId: string }>
}) {
const params = use(paramsPromise)
const utils = trpc.useUtils()
const { data: me } = trpc.user.me.useQuery()
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery( const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
{ sessionId: params.sessionId }, { sessionId: params.sessionId },
{ refetchInterval: 10_000 }, { refetchInterval: 10_000 }
); )
// The deliberation session points at its round; finale inputs live on the
// LIVE_FINAL round's voting session — resolve via my ceremony context.
const { data: ceremony } = trpc.live.getMyCeremonyContext.useQuery()
const finaleRoundId = ceremony?.liveRoundId ?? null
const { data: finaleInputs } = trpc.liveVoting.getMyFinaleInputs.useQuery(
{ roundId: finaleRoundId ?? '' },
{ enabled: !!finaleRoundId }
)
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({ const [submitting, setSubmitting] = useState(false)
onSuccess: () => { const submitVoteMutation = trpc.deliberation.submitVote.useMutation()
utils.deliberation.getSession.invalidate();
toast.success('Vote submitted successfully'); const handleSubmitVote = async (
}, votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>
onError: (err) => { ) => {
toast.error(err.message); setSubmitting(true)
try {
for (const vote of votes) {
await submitVoteMutation.mutateAsync({
sessionId: params.sessionId,
projectId: vote.projectId,
rank: vote.rank,
isWinnerPick: vote.isWinnerPick,
})
}
toast.success('Your ranking has been submitted')
utils.deliberation.getSession.invalidate({ sessionId: params.sessionId })
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to submit vote')
} finally {
setSubmitting(false)
} }
}); }
const handleSubmitVote = (votes: Array<{ projectId: string; rank?: number; isWinnerPick?: boolean }>) => { if (isLoading || !me) {
votes.forEach((vote) => {
submitVoteMutation.mutate({
sessionId: params.sessionId,
juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
projectId: vote.projectId,
rank: vote.rank,
isWinnerPick: vote.isWinnerPick
});
});
};
if (isLoading) {
return ( return (
<div className="space-y-6"> <Card>
<Card> <CardContent className="flex items-center justify-center py-12">
<CardContent className="flex items-center justify-center py-12"> <p className="text-muted-foreground">Loading session</p>
<p className="text-muted-foreground">Loading session...</p> </CardContent>
</CardContent> </Card>
</Card> )
</div>
);
} }
if (!session) { if (!session) {
return ( return (
<div className="space-y-6"> <Card>
<Card> <CardContent className="flex items-center justify-center py-12">
<CardContent className="flex items-center justify-center py-12"> <p className="text-muted-foreground">Session not found</p>
<p className="text-muted-foreground">Session not found</p> </CardContent>
</CardContent> </Card>
</Card> )
</div>
);
} }
const hasVoted = false; // TODO: check if current user has voted in this session const isParticipant = (session.participants ?? []).some(
(p: any) => p.user?.user?.id === me.id
)
const hasVoted = (session.votes ?? []).some(
(v: any) => v.juryMember?.user?.id === me.id && v.runoffRound === 0
)
const projects = ((session as any).projects ?? []) as Array<{
id: string
title: string
teamName?: string | null
}>
const votingMode = (finaleInputs?.session?.votingMode ?? 'simple') as 'simple' | 'criteria'
const criteria = finaleInputs?.session?.criteriaJson as
| Array<{ id: string; label: string; description?: string; scale: number; weight: number }>
| undefined
if (session.status !== 'VOTING') { const header = (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation {CATEGORY_LABEL[session.category] ?? session.category}</CardTitle>
<CardDescription className="mt-1">{session.round?.name}</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
</Card>
)
const reviewSection = projects.length > 0 && finaleRoundId && (
<div className="space-y-3">
<div>
<h2 className="text-lg font-semibold">Review Before You Rank</h2>
<p className="text-sm text-muted-foreground">
Your grand-finale scores, notes and the project documents revise a score or keep it.
</p>
</div>
{projects.map((p) => (
<ProjectReviewCard
key={p.id}
project={p}
roundId={finaleRoundId}
finaleInputs={finaleInputs}
votingMode={votingMode}
criteria={criteria}
/>
))}
</div>
)
if (session.status !== 'VOTING' && session.status !== 'RUNOFF') {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{header}
<Card> <Card>
<CardHeader> <CardContent className="flex flex-col items-center justify-center py-10">
<CardTitle>Deliberation Session</CardTitle>
<CardDescription>
{session.round?.name} - {session.category}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{session.status === 'DELIB_OPEN' {session.status === 'DELIB_OPEN'
? 'Voting has not started yet. Please wait for the admin to open voting.' ? 'Voting has not started yet — you can already review the projects below.'
: session.status === 'TALLYING' : session.status === 'TALLYING'
? 'Voting is closed. Results are being tallied.' ? 'Voting is closed. Results are being tallied.'
: 'This session is locked.'} : 'This session is locked.'}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{session.status === 'DELIB_OPEN' && reviewSection}
</div> </div>
); )
} }
if (hasVoted) { if (!isParticipant) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{header}
<Card> <Card>
<CardHeader> <CardContent className="flex flex-col items-center justify-center py-10">
<div className="flex items-start justify-between"> <p className="text-muted-foreground">
<div> You are not a participant of this deliberation session.
<CardTitle>Deliberation Session</CardTitle>
<CardDescription className="mt-1">
{session.round?.name} - {session.category}
</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p>
<p className="mt-1 text-sm text-muted-foreground">
Thank you for your participation in this deliberation
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); )
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> {header}
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Deliberation Session</CardTitle>
<CardDescription className="mt-1">
{session.round?.name} - {session.category}
</CardDescription>
</div>
<Badge>{session.status}</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
{session.mode === 'SINGLE_WINNER_VOTE'
? 'Select your top choice for this category.'
: 'Rank all projects from best to least preferred.'}
</p>
</CardContent>
</Card>
<DeliberationRankingForm {hasVoted ? (
projects={session.results?.map((r) => r.project) ?? []} <Card>
mode={session.mode} <CardContent className="flex flex-col items-center justify-center py-10">
onSubmit={handleSubmitVote} <CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
disabled={submitVoteMutation.isPending} <p className="font-medium">Ranking Submitted</p>
/> <p className="mt-1 text-sm text-muted-foreground">
Thank you the chair will review the collective result.
</p>
</CardContent>
</Card>
) : (
<>
{reviewSection}
<div>
<h2 className="mb-2 text-lg font-semibold">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Pick Your Winner' : 'Your Ranking'}
</h2>
<DeliberationRankingForm
projects={projects}
mode={session.mode}
onSubmit={handleSubmitVote}
disabled={submitting}
/>
</div>
</>
)}
</div> </div>
); )
} }

View File

@@ -28,7 +28,9 @@ import {
Waves, Waves,
Send, Send,
Trophy, Trophy,
FileText,
} from 'lucide-react' } from 'lucide-react'
import { userCanReviewFinals, getOpenFinaleRound } from '@/server/services/final-documents'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { CountdownTimer } from '@/components/shared/countdown-timer' import { CountdownTimer } from '@/components/shared/countdown-timer'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
@@ -42,6 +44,70 @@ function getGreeting(): string {
return 'Good evening' return 'Good evening'
} }
/**
* Prominent entry point to the finalist documents review, shown only to
* Grand-Final jury members (and admins). Rendered at the top of the dashboard
* regardless of whether the juror has individual assignments, so finals jurors
* can always find the teams' files in one obvious place.
*/
async function FinalsJuryBanner() {
const session = await auth()
const userId = session?.user?.id
if (!userId) return null
const program = await prisma.program.findFirst({
where: { status: 'ACTIVE' },
orderBy: { year: 'desc' },
select: { id: true },
})
if (!program) return null
const canReview = await userCanReviewFinals(prisma, userId, session.user.role, program.id)
if (!canReview) return null
const round = await getOpenFinaleRound(prisma, program.id)
const teamCount = round
? await prisma.projectRoundState.count({ where: { roundId: round.id } })
: 0
return (
<AnimatedCard index={0}>
<Card className="overflow-hidden border-0 shadow-lg">
<div className="rounded-lg bg-gradient-to-r from-brand-blue to-brand-teal p-[1px]">
<CardContent className="flex flex-col gap-4 rounded-[7px] bg-background p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-4">
<div className="shrink-0 rounded-xl bg-gradient-to-br from-brand-blue to-brand-teal p-3 shadow-sm">
<Trophy className="h-6 w-6 text-white" />
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-brand-teal">
Grand Final
</p>
<h2 className="text-lg font-bold text-brand-blue">Finalist Documents</h2>
<p className="mt-0.5 max-w-md text-sm text-muted-foreground">
{teamCount > 0 ? `All ${teamCount} finalist teams ` : 'Every finalist teams '}
pitch decks, business plans, executive summaries and videos in one place.
</p>
</div>
</div>
<Button
asChild
size="lg"
className="w-full shrink-0 bg-brand-blue shadow-md hover:bg-brand-blue-light sm:w-auto"
>
<Link href="/jury/finals-documents">
<FileText className="mr-2 h-4 w-4" />
Review Finalist Documents
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</CardContent>
</div>
</Card>
</AnimatedCard>
)
}
async function JuryDashboardContent() { async function JuryDashboardContent() {
const session = await auth() const session = await auth()
const userId = session?.user?.id const userId = session?.user?.id
@@ -863,6 +929,11 @@ export default async function JuryDashboardPage() {
{/* Preferences banner (shown when juror has unconfirmed preferences) */} {/* Preferences banner (shown when juror has unconfirmed preferences) */}
<JuryPreferencesBanner /> <JuryPreferencesBanner />
{/* Grand-Final finalist documents — prominent entry for finals jurors */}
<Suspense fallback={null}>
<FinalsJuryBanner />
</Suspense>
{/* Content */} {/* Content */}
<Suspense fallback={<DashboardSkeleton />}> <Suspense fallback={<DashboardSkeleton />}>
<JuryDashboardContent /> <JuryDashboardContent />

View File

@@ -0,0 +1,535 @@
'use client'
/**
* Big-screen ceremony view — projected on stage at the grand finale.
* Award-night broadcast aesthetic: deep layered ocean field, extreme
* Montserrat scale contrast, red as a scalpel accent, gold reserved for the
* winner moment. Pure derivation of server state (poll 2s), full-bleed over
* the public layout, no interactive chrome.
*/
import { use, useEffect, useMemo, useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { QRCodeSVG } from 'qrcode.react'
import { trpc } from '@/lib/trpc/client'
import { remainingSeconds, formatClock } from '@/lib/live-timer'
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
const WINDOW_TITLE: Record<string, string> = {
'CATEGORY:BUSINESS_CONCEPT': 'Vote for your favorite Business Concept',
'CATEGORY:STARTUP': 'Vote for your favorite Startup',
OVERALL: 'Vote for your favorite project of the night',
}
function useTick() {
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
}
// ─── Atmosphere ──────────────────────────────────────────────────────────────
function OceanField({ children }: { children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 overflow-hidden bg-[#021f2e] font-[Montserrat,sans-serif] text-white">
{/* Layered ocean-light gradients */}
<div
className="absolute inset-0"
style={{
background:
'radial-gradient(120% 90% at 50% 110%, #0a5a7c 0%, #053d57 45%, #021f2e 100%)',
}}
/>
<motion.div
className="absolute -inset-x-1/4 top-[-40%] h-[80%] opacity-25"
style={{
background:
'radial-gradient(50% 100% at 50% 0%, rgba(85,127,140,0.9) 0%, transparent 70%)',
}}
animate={{ x: ['-8%', '8%', '-8%'] }}
transition={{ repeat: Infinity, duration: 18, ease: 'easeInOut' }}
/>
{/* Grain for projector richness */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.05] mix-blend-overlay"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
}}
/>
{children}
</div>
)
}
function StatusBar({ programName, label }: { programName: string | null; label?: string | null }) {
return (
<div className="absolute inset-x-0 top-0 flex items-center justify-between px-12 py-8">
<p className="text-sm font-semibold uppercase tracking-[0.4em] text-white/50">
{programName ?? 'Monaco Ocean Protection Challenge'}
</p>
{label && (
<p className="flex items-center gap-3 text-sm font-semibold uppercase tracking-[0.4em] text-white/50">
<span className="inline-block h-2.5 w-2.5 animate-pulse rounded-full bg-[#de0f1e]" />
{label}
</p>
)}
</div>
)
}
function SignatureRule() {
return (
<div className="mx-auto flex w-48 items-center gap-0">
<div className="h-px flex-1 bg-white/25" />
<div className="h-[3px] w-10 bg-[#de0f1e]" />
<div className="h-px flex-1 bg-white/25" />
</div>
)
}
const slideIn = {
initial: { opacity: 0, y: 36, scale: 0.985, filter: 'blur(6px)' },
animate: { opacity: 1, y: 0, scale: 1, filter: 'blur(0px)' },
exit: { opacity: 0, y: -24, scale: 0.99, filter: 'blur(4px)' },
transition: { duration: 0.6, ease: [0.22, 1, 0.36, 1] as const },
}
// ─── Slides ──────────────────────────────────────────────────────────────────
function StaticSlide({ kind, programName }: { kind: string; programName: string | null }) {
const copy: Record<string, { eyebrow: string; title: string; sub?: string }> = {
welcome: {
eyebrow: programName ?? 'Monaco Ocean Protection Challenge',
title: 'Grand Finale',
sub: 'Welcome',
},
break: { eyebrow: 'Intermission', title: 'Back shortly', sub: 'Enjoy the break' },
deliberation: {
eyebrow: 'The jury has retired',
title: 'Deliberation in progress',
sub: 'Results follow shortly',
},
thanks: { eyebrow: programName ?? 'Grand Finale', title: 'Thank you', sub: 'See you next year' },
}
const c = copy[kind] ?? copy.welcome
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">{c.eyebrow}</p>
<h1 className="text-[clamp(4rem,10vw,9rem)] font-extrabold leading-none tracking-tight">
{c.title}
</h1>
<SignatureRule />
{c.sub && <p className="text-2xl font-light text-white/70">{c.sub}</p>}
</div>
)
}
function PhaseSlide({
state,
}: {
state: {
phase: {
projectPhase: string
phaseStartedAt: string | Date | null
phaseDurationSeconds: number | null
phasePausedAt: string | Date | null
phasePausedAccumMs: number
} | null
activeProject: { title: string; teamName: string | null; competitionCategory: string | null } | null
}
}) {
useTick()
const phase = state.phase
const project = state.activeProject
if (!phase || !project) return <StaticSlide kind="welcome" programName={null} />
const remaining = remainingSeconds(phase)
const over = remaining !== null && remaining < 0
const category = project.competitionCategory
? CATEGORY_LABEL[project.competitionCategory]
: null
if (phase.projectPhase === 'ON_DECK') {
return (
<div className="flex h-full flex-col items-center justify-center gap-10 px-16 text-center">
<motion.p
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
className="text-xl font-semibold uppercase tracking-[0.5em] text-[#557f8c]"
>
Up next
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 30, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 80, damping: 16, delay: 0.15 }}
className="max-w-[90vw] text-[clamp(3.5rem,9vw,8rem)] font-extrabold leading-[1.02] tracking-tight"
>
{project.teamName ?? project.title}
</motion.h1>
<SignatureRule />
<div className="space-y-2">
{project.teamName && <p className="text-3xl font-light text-white/80">{project.title}</p>}
{category && (
<p className="text-lg font-semibold uppercase tracking-[0.35em] text-white/45">
{category}
</p>
)}
</div>
</div>
)
}
if (phase.projectPhase === 'SCORING') {
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">
{project.teamName ?? project.title}
</p>
<h1 className="text-[clamp(3rem,7vw,6rem)] font-extrabold tracking-tight">
The jury is scoring
</h1>
<SignatureRule />
<motion.div
className="flex gap-3"
initial="hidden"
animate="visible"
variants={{ visible: { transition: { staggerChildren: 0.25 } } }}
>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="h-3 w-3 rounded-full bg-[#557f8c]"
animate={{ opacity: [0.25, 1, 0.25] }}
transition={{ repeat: Infinity, duration: 1.6, delay: i * 0.3 }}
/>
))}
</motion.div>
</div>
)
}
// PRESENTING / QA
const phaseLabel = phase.projectPhase === 'QA' ? 'Q&A' : 'Presentation'
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<div className="space-y-3">
{category && (
<p className="text-base font-semibold uppercase tracking-[0.4em] text-white/45">
{category}
</p>
)}
<h1 className="max-w-[92vw] text-[clamp(3rem,8vw,7rem)] font-extrabold leading-[1.03] tracking-tight">
{project.teamName ?? project.title}
</h1>
{project.teamName && (
<p className="text-2xl font-light text-white/70">{project.title}</p>
)}
</div>
<SignatureRule />
<div className="space-y-2">
<p className="text-lg font-semibold uppercase tracking-[0.45em] text-[#557f8c]">
{phaseLabel}
</p>
{remaining !== null && (
<motion.p
className={`text-[clamp(4rem,9vw,8rem)] font-bold tabular-nums leading-none ${
over ? 'text-[#de0f1e]' : 'text-white'
}`}
animate={over ? { opacity: [1, 0.55, 1] } : {}}
transition={over ? { repeat: Infinity, duration: 1.2 } : {}}
style={over ? { textShadow: '0 0 60px rgba(222,15,30,0.55)' } : {}}
>
{formatClock(remaining)}
</motion.p>
)}
{phase.phasePausedAt && (
<p className="text-base font-semibold uppercase tracking-[0.35em] text-white/45">
paused
</p>
)}
</div>
</div>
)
}
function AudienceVoteSlide({
windowKey,
closesAt,
voteCount,
voteUrl,
}: {
windowKey: string | null
closesAt: string | Date | null
voteCount: number
voteUrl: string
}) {
useTick()
const secondsLeft = closesAt
? Math.max(0, Math.floor((new Date(closesAt).getTime() - Date.now()) / 1000))
: null
return (
<div className="flex h-full items-center justify-center gap-24 px-20">
<motion.div
animate={{ scale: [1, 1.015, 1] }}
transition={{ repeat: Infinity, duration: 4, ease: 'easeInOut' }}
className="shrink-0 rounded-[2.5rem] bg-white p-10 shadow-[0_0_120px_rgba(85,127,140,0.45)]"
>
{voteUrl && <QRCodeSVG value={voteUrl} size={400} />}
</motion.div>
<div className="max-w-3xl space-y-8">
<p className="text-lg font-semibold uppercase tracking-[0.45em] text-[#de0f1e]">
Audience vote open now
</p>
<h1 className="text-[clamp(2.5rem,5.5vw,4.5rem)] font-extrabold leading-tight tracking-tight">
{WINDOW_TITLE[windowKey ?? ''] ?? 'Vote for your favorite'}
</h1>
<p className="text-2xl font-light text-white/70">
Scan the code with your phone one vote each
</p>
<div className="flex items-end gap-14 pt-2">
{secondsLeft !== null && (
<div>
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/45">
Closes in
</p>
<p
className={`text-7xl font-bold tabular-nums ${
secondsLeft <= 30 ? 'text-[#de0f1e]' : ''
}`}
>
{formatClock(secondsLeft)}
</p>
</div>
)}
<div>
<p className="text-sm font-semibold uppercase tracking-[0.35em] text-white/45">
Votes cast
</p>
<motion.p
key={voteCount}
initial={{ scale: 1.25, color: '#de0f1e' }}
animate={{ scale: 1, color: '#ffffff' }}
className="text-7xl font-bold tabular-nums"
>
{voteCount}
</motion.p>
</div>
</div>
</div>
</div>
)
}
// ─── Reveal ──────────────────────────────────────────────────────────────────
function Confetti({ gold }: { gold?: boolean }) {
const pieces = useMemo(
() =>
Array.from({ length: 56 }, (_, i) => ({
left: ((i * 137.5) % 100),
delay: (i % 14) * 0.09,
duration: 2.6 + ((i * 7) % 10) / 6,
size: 7 + ((i * 13) % 9),
rotate: (i * 73) % 360,
color: gold
? ['#e8c34a', '#de0f1e', '#ffffff', '#f0d98c'][i % 4]
: ['#de0f1e', '#557f8c', '#ffffff', '#9fc3cf'][i % 4],
})),
[gold]
)
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{pieces.map((p, i) => (
<motion.span
key={i}
className="absolute top-[-5%] block"
style={{
left: `${p.left}%`,
width: p.size,
height: p.size * 0.45,
backgroundColor: p.color,
borderRadius: 1,
}}
initial={{ y: '-10vh', rotate: p.rotate, opacity: 0 }}
animate={{ y: '115vh', rotate: p.rotate + 540, opacity: [0, 1, 1, 0.8] }}
transition={{ duration: p.duration, delay: p.delay, ease: 'easeIn' }}
/>
))}
</div>
)
}
type RevealStep = {
kind: string
category?: string
place?: number
title?: string
subtitle?: string
}
function RevealSlide({ step }: { step: RevealStep }) {
const isWinner = step.kind === 'place' && step.place === 1
const isAudience = step.kind === 'audience-award' || step.kind === 'overall-favorite'
if (step.kind === 'category-intro') {
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<p className="text-lg font-semibold uppercase tracking-[0.5em] text-white/50">Results</p>
<h1 className="text-[clamp(4rem,9vw,8rem)] font-extrabold tracking-tight">
{step.title ?? CATEGORY_LABEL[step.category ?? ''] ?? ''}
</h1>
<SignatureRule />
</div>
)
}
if (step.kind === 'thanks') {
return <StaticSlide kind="thanks" programName={null} />
}
return (
<div className="relative flex h-full flex-col items-center justify-center gap-10 px-16 text-center">
{(isWinner || isAudience) && <Confetti gold={isWinner} />}
{isWinner && (
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(55% 45% at 50% 52%, rgba(232,195,74,0.16) 0%, transparent 70%)',
}}
/>
)}
<motion.p
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className={`text-xl font-semibold uppercase tracking-[0.5em] ${
isAudience ? 'text-[#de0f1e]' : isWinner ? 'text-[#e8c34a]' : 'text-[#557f8c]'
}`}
>
{step.subtitle ?? ''}
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 60, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 70, damping: 14, delay: 0.35 }}
className={`max-w-[92vw] font-extrabold leading-[1.02] tracking-tight ${
isWinner
? 'text-[clamp(4.5rem,11vw,10rem)]'
: 'text-[clamp(3.5rem,8vw,7.5rem)]'
}`}
style={isWinner ? { textShadow: '0 0 90px rgba(232,195,74,0.35)' } : undefined}
>
{step.title ?? ''}
</motion.h1>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
>
<SignatureRule />
</motion.div>
</div>
)
}
function ResultsSplash() {
return (
<div className="flex h-full flex-col items-center justify-center gap-8 px-16 text-center">
<motion.p
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ repeat: Infinity, duration: 2.4 }}
className="text-lg font-semibold uppercase tracking-[0.5em] text-[#de0f1e]"
>
The moment has come
</motion.p>
<h1 className="text-[clamp(4rem,10vw,9rem)] font-extrabold tracking-tight">Results</h1>
<SignatureRule />
</div>
)
}
// ─── Page ────────────────────────────────────────────────────────────────────
export default function CeremonyPage({
params: paramsPromise,
}: {
params: Promise<{ roundId: string }>
}) {
const params = use(paramsPromise)
const { data: state } = trpc.liveVoting.getCeremonyState.useQuery(
{ roundId: params.roundId },
{ refetchInterval: 2000 }
)
const voteUrl =
typeof window !== 'undefined'
? `${window.location.origin}/vote/competition/${params.roundId}`
: ''
if (!state) {
return (
<OceanField>
<div className="flex h-full items-center justify-center" />
</OceanField>
)
}
// Display precedence: override → reveal → audience window → phase → welcome
const reveal = state.reveal
const revealStep =
reveal && (reveal.status === 'REVEALING' || reveal.status === 'DONE')
? ((reveal.steps[reveal.currentStepIndex] ?? null) as RevealStep | null)
: null
let slideKey: string
let slide: React.ReactNode
let statusLabel: string | null = null
if (state.overrideSlide) {
slideKey = `override-${state.overrideSlide}`
slide = <StaticSlide kind={state.overrideSlide} programName={state.programName} />
} else if (reveal && reveal.status === 'ARMED') {
slideKey = 'reveal-armed'
slide = <ResultsSplash />
statusLabel = 'Results'
} else if (revealStep) {
slideKey = `reveal-${reveal!.currentStepIndex}`
slide = <RevealSlide step={revealStep} />
statusLabel = 'Results'
} else if (state.audience.open) {
slideKey = `audience-${state.audience.windowKey}`
slide = (
<AudienceVoteSlide
windowKey={state.audience.windowKey}
closesAt={state.audience.closesAt}
voteCount={state.audience.voteCount}
voteUrl={voteUrl}
/>
)
statusLabel = 'Live'
} else if (state.phase && state.activeProject) {
slideKey = `phase-${state.phase.projectPhase}-${state.activeProject.title}`
slide = <PhaseSlide state={state} />
statusLabel = 'Live'
} else {
slideKey = 'welcome'
slide = <StaticSlide kind="welcome" programName={state.programName} />
}
return (
<OceanField>
<StatusBar programName={state.programName} label={statusLabel} />
<AnimatePresence mode="wait">
<motion.div key={slideKey} className="absolute inset-0" {...slideIn}>
{slide}
</motion.div>
</AnimatePresence>
</OceanField>
)
}

View File

@@ -1,88 +1,274 @@
'use client'; 'use client'
import { use, useEffect, useState } from 'react'; /**
import { trpc } from '@/lib/trpc/client'; * Audience voting page — reached by scanning the QR code on the big screen.
import { Card, CardContent } from '@/components/ui/card'; * Zero-instruction flow: scan → (auto token) → wait → tap your favorite →
import { AudienceVoteCard } from '@/components/public/audience-vote-card'; * done. Votes can be changed until the window closes. Uses ONLY public
import { toast } from 'sonner'; * procedures: attendees have no account.
*/
export default function AudienceVotePage({ params: paramsPromise }: { params: Promise<{ roundId: string }> }) { import { use, useEffect, useState } from 'react'
const params = use(paramsPromise); import { motion, AnimatePresence } from 'motion/react'
const utils = trpc.useUtils(); import { trpc } from '@/lib/trpc/client'
const [hasVoted, setHasVoted] = useState(false); import { formatClock } from '@/lib/live-timer'
import { Check, Heart, Hourglass, Vote } from 'lucide-react'
import { toast } from 'sonner'
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId: params.roundId }); const WINDOW_TITLE: Record<string, string> = {
'CATEGORY:BUSINESS_CONCEPT': 'Pick your favorite Business Concept',
'CATEGORY:STARTUP': 'Pick your favorite Startup',
OVERALL: 'Pick your favorite project of the night',
}
const submitVoteMutation = trpc.liveVoting.castAudienceVote.useMutation({ function useCountdown(closesAt: string | Date | null | undefined) {
onSuccess: () => { const [, tick] = useState(0)
setHasVoted(true);
// Store in localStorage to prevent duplicate votes
if (cursor?.activeProject?.id) {
localStorage.setItem(`voted-${params.roundId}-${cursor.activeProject.id}`, 'true');
}
toast.success('Vote submitted! Thank you for participating.');
},
onError: (err) => {
toast.error(err.message);
}
});
// Check localStorage on mount
useEffect(() => { useEffect(() => {
if (cursor?.activeProject?.id) { const id = setInterval(() => tick((t) => t + 1), 1000)
const voted = localStorage.getItem(`voted-${params.roundId}-${cursor.activeProject.id}`); return () => clearInterval(id)
if (voted === 'true') { }, [])
setHasVoted(true); if (!closesAt) return null
} return Math.max(0, Math.floor((new Date(closesAt).getTime() - Date.now()) / 1000))
}
export default function AudienceVotePage({
params: paramsPromise,
}: {
params: Promise<{ roundId: string }>
}) {
const params = use(paramsPromise)
const utils = trpc.useUtils()
const { data: context, isLoading: contextLoading } =
trpc.liveVoting.getAudienceContextByRound.useQuery({ roundId: params.roundId })
const sessionId = context?.sessionId ?? null
// ── Anonymous voter token, persisted per session in this browser ─────────
const [token, setToken] = useState<string | null>(null)
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
onSuccess: (res) => {
if (sessionId) localStorage.setItem(`mopc-audience-${sessionId}`, res.token)
setToken(res.token)
},
})
useEffect(() => {
if (!sessionId || !context?.allowAudienceVotes) return
const stored = localStorage.getItem(`mopc-audience-${sessionId}`)
if (stored) {
setToken(stored)
} else if (!register.isPending && !token) {
register.mutate({ sessionId })
} }
}, [cursor?.activeProject?.id, params.roundId]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, context?.allowAudienceVotes])
const handleVote = () => { const { data: win } = trpc.liveVoting.getAudienceWindow.useQuery(
if (!cursor?.activeProject?.id) return; { sessionId: sessionId ?? '', token: token ?? undefined },
{ enabled: !!sessionId, refetchInterval: 3000 }
)
submitVoteMutation.mutate({ const [selected, setSelected] = useState<string | null>(null)
projectId: cursor.activeProject.id, const cast = trpc.liveVoting.castFavoriteVote.useMutation({
sessionId: params.roundId, onSuccess: () => {
score: 1, utils.liveVoting.getAudienceWindow.invalidate()
token: `audience-${Date.now()}` setSelected(null)
}); toast.success('Vote recorded!')
}; },
onError: (err) => toast.error(err.message),
})
if (!cursor?.activeProject) { const secondsLeft = useCountdown(win?.closesAt)
const open = !!win?.open && (secondsLeft === null || secondsLeft > 0)
const myVote = win?.myVoteProjectId ?? null
// Reset local selection when a new window opens
useEffect(() => {
setSelected(null)
}, [win?.windowKey])
if (contextLoading) {
return <CenteredState icon={Hourglass} title="Loading…" />
}
if (!context) {
return ( return (
<div className="container mx-auto flex min-h-screen items-center justify-center p-4"> <CenteredState
<Card className="w-full max-w-2xl"> icon={Vote}
<CardContent className="flex flex-col items-center justify-center py-12"> title="No vote here yet"
<p className="text-center text-lg text-muted-foreground"> subtitle="This voting link isn't active. Keep an eye on the big screen!"
No project is currently being presented />
</p> )
<p className="mt-2 text-center text-sm text-muted-foreground"> }
Please wait for the ceremony to begin if (!context.allowAudienceVotes) {
</p> return (
</CardContent> <CenteredState
</Card> icon={Vote}
</div> title="Audience voting is not open"
); subtitle="Voting will be enabled during the event."
/>
)
} }
return ( return (
<div className="container mx-auto flex min-h-screen items-center justify-center p-4"> <div className="mx-auto max-w-lg">
<div className="w-full"> {/* Gala header */}
<div className="mb-8 text-center"> <div className="-mx-4 -mt-8 mb-6 bg-gradient-to-br from-[#021f2e] via-[#053d57] to-[#0a5a7c] px-6 py-8 text-center text-white sm:rounded-b-2xl">
<h1 className="text-4xl font-bold text-[#053d57]">Monaco Ocean Protection Challenge</h1> <p className="text-[11px] font-semibold uppercase tracking-[0.35em] text-white/60">
<p className="mt-2 text-lg text-muted-foreground">Live Audience Voting</p> {context.programName ?? 'Grand Finale'}
</div>
<AudienceVoteCard
project={cursor.activeProject}
onVote={handleVote}
hasVoted={hasVoted}
/>
<p className="mt-6 text-center text-sm text-muted-foreground">
Live voting in progress
</p> </p>
<h1 className="mt-2 text-2xl font-bold">Audience Vote</h1>
{open && secondsLeft !== null && (
<div className="mx-auto mt-3 inline-flex items-center gap-2 rounded-full bg-white/10 px-4 py-1.5 text-sm tabular-nums">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[#de0f1e]" />
closes in {formatClock(secondsLeft)}
</div>
)}
</div> </div>
<AnimatePresence mode="wait">
{!open ? (
<motion.div
key="waiting"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="py-10 text-center"
>
<motion.div
animate={{ y: [0, -6, 0] }}
transition={{ repeat: Infinity, duration: 2.4, ease: 'easeInOut' }}
className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#053d57]/8"
>
<Hourglass className="h-7 w-7 text-[#053d57]" />
</motion.div>
<h2 className="text-lg font-semibold text-[#053d57]">
Voting opens after the presentations
</h2>
<p className="mx-auto mt-2 max-w-xs text-sm text-muted-foreground">
Keep this page open the ballot appears here the moment voting starts.
</p>
</motion.div>
) : (
<motion.div
key={`open-${win?.windowKey}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="space-y-3 pb-28"
>
<h2 className="text-center text-lg font-semibold text-[#053d57]">
{WINDOW_TITLE[win?.windowKey ?? ''] ?? 'Pick your favorite'}
</h2>
<p className="text-center text-xs text-muted-foreground">
One vote you can change it until voting closes
</p>
<div className="space-y-2.5 pt-2">
{(win?.projects ?? []).map((project) => {
const isPicked = (selected ?? myVote) === project.id
return (
<button
key={project.id}
onClick={() => setSelected(project.id)}
className={`w-full rounded-2xl border-2 p-4 text-left transition-all active:scale-[0.99] ${
isPicked
? 'border-[#de0f1e] bg-[#053d57] text-white shadow-lg'
: 'border-border bg-card hover:border-[#557f8c]'
}`}
>
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
isPicked ? 'bg-[#de0f1e]' : 'bg-[#053d57]/8'
}`}
>
{isPicked ? (
<Check className="h-5 w-5 text-white" />
) : (
<Heart className="h-4 w-4 text-[#557f8c]" />
)}
</div>
<div className="min-w-0">
<p className="truncate font-semibold">
{project.teamName ?? project.title}
</p>
{project.teamName && (
<p
className={`truncate text-xs ${
isPicked ? 'text-white/70' : 'text-muted-foreground'
}`}
>
{project.title}
</p>
)}
</div>
</div>
</button>
)
})}
</div>
{/* Pinned confirm bar */}
<AnimatePresence>
{selected && selected !== myVote && (
<motion.div
initial={{ y: 80 }}
animate={{ y: 0 }}
exit={{ y: 80 }}
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 p-4 pb-[max(1rem,env(safe-area-inset-bottom))] backdrop-blur"
>
<div className="mx-auto max-w-lg">
<button
onClick={() =>
sessionId &&
token &&
cast.mutate({ sessionId, token, projectId: selected })
}
disabled={cast.isPending || !token}
className="w-full rounded-xl bg-[#de0f1e] py-3.5 font-semibold text-white shadow-lg transition-transform active:scale-[0.98] disabled:opacity-60"
>
{cast.isPending
? 'Submitting…'
: myVote
? 'Change my vote'
: 'Confirm my vote'}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{myVote && (!selected || selected === myVote) && (
<div className="pt-2 text-center">
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-600/10 px-4 py-1.5 text-sm font-medium text-green-700">
<Check className="h-4 w-4" />
Vote recorded tap another to change it
</span>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div> </div>
); )
}
function CenteredState({
icon: Icon,
title,
subtitle,
}: {
icon: typeof Vote
title: string
subtitle?: string
}) {
return (
<div className="py-16 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-[#053d57]/8">
<Icon className="h-7 w-7 text-[#053d57]" />
</div>
<h2 className="text-lg font-semibold text-[#053d57]">{title}</h2>
{subtitle && (
<p className="mx-auto mt-2 max-w-xs text-sm text-muted-foreground">{subtitle}</p>
)}
</div>
)
} }

View File

@@ -1,7 +1,7 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch' import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app' import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/context' import { createContext } from '@/server/context'
import { checkRateLimit } from '@/lib/rate-limit' import { checkRateLimit, isCeremonyTraffic } from '@/lib/rate-limit'
// Allow long-running operations (AI filtering, bulk imports) // Allow long-running operations (AI filtering, bulk imports)
// This affects Next.js serverless functions; for self-hosted, Nginx timeout also matters // This affects Next.js serverless functions; for self-hosted, Nginx timeout also matters
@@ -9,6 +9,9 @@ export const maxDuration = 300 // 5 minutes
const RATE_LIMIT = 100 // requests per window const RATE_LIMIT = 100 // requests per window
const RATE_WINDOW_MS = 60 * 1000 // 1 minute const RATE_WINDOW_MS = 60 * 1000 // 1 minute
// Ceremony-day polling: whole venues share one IP (NAT) and every screen
// polls a few cheap public reads — see CEREMONY_PROCEDURES in lib/rate-limit.
const CEREMONY_RATE_LIMIT = 6000
function getClientIp(req: Request): string { function getClientIp(req: Request): string {
return ( return (
@@ -20,7 +23,10 @@ function getClientIp(req: Request): string {
const handler = (req: Request) => { const handler = (req: Request) => {
const ip = getClientIp(req) const ip = getClientIp(req)
const { success, remaining, resetAt } = checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS) const ceremony = isCeremonyTraffic(new URL(req.url).pathname)
const { success, remaining, resetAt } = ceremony
? checkRateLimit(`trpc-ceremony:${ip}`, CEREMONY_RATE_LIMIT, RATE_WINDOW_MS)
: checkRateLimit(`trpc:${ip}`, RATE_LIMIT, RATE_WINDOW_MS)
if (!success) { if (!success) {
return new Response(JSON.stringify({ error: 'Too many requests' }), { return new Response(JSON.stringify({ error: 'Too many requests' }), {

View File

@@ -91,7 +91,9 @@ export function AdminOverrideDialog({
<Label>Project Rankings</Label> <Label>Project Rankings</Label>
<div className="space-y-2"> <div className="space-y-2">
{projectIds.map((projectId) => { {projectIds.map((projectId) => {
const project = session?.results?.find((r) => r.project.id === projectId)?.project; const project =
(session as any)?.projects?.find((p: any) => p.id === projectId) ??
session?.results?.find((r) => r.project.id === projectId)?.project;
return ( return (
<div key={projectId} className="flex items-center gap-3"> <div key={projectId} className="flex items-center gap-3">
<Input <Input

View File

@@ -0,0 +1,260 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ResultsPanel } from './results-panel'
import { AdminOverrideDialog } from './admin-override-dialog'
import { Gavel, Lock, Play, Plus, Square, Users } from 'lucide-react'
import { toast } from 'sonner'
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
const STATUS_BADGE: Record<string, { label: string; variant: 'secondary' | 'default' | 'destructive' | 'outline' }> = {
DELIB_OPEN: { label: 'Open — voting not started', variant: 'secondary' },
VOTING: { label: 'Voting', variant: 'default' },
TALLYING: { label: 'Tallying', variant: 'outline' },
RUNOFF: { label: 'Runoff', variant: 'destructive' },
DELIB_LOCKED: { label: 'Locked', variant: 'secondary' },
}
function SessionCard({ session, competitionId }: { session: any; competitionId: string }) {
const utils = trpc.useUtils()
const [overrideOpen, setOverrideOpen] = useState(false)
const { data: detail } = trpc.deliberation.getSession.useQuery(
{ sessionId: session.id },
{ refetchInterval: 10_000 }
)
const invalidate = () => {
utils.deliberation.getSession.invalidate({ sessionId: session.id })
utils.deliberation.listSessions.invalidate({ competitionId })
}
const onError = (err: { message: string }) => toast.error(err.message)
const openVoting = trpc.deliberation.openVoting.useMutation({
onSuccess: () => {
invalidate()
toast.success('Deliberation voting opened — jurors can now rank')
},
onError,
})
const closeVoting = trpc.deliberation.closeVoting.useMutation({
onSuccess: () => {
invalidate()
toast.success('Voting closed — tallying')
},
onError,
})
const status = detail?.status ?? session.status
const badge = STATUS_BADGE[status] ?? { label: status, variant: 'outline' as const }
const voteCount = detail?.votes?.length ?? session._count?.votes ?? 0
const participantCount = detail?.participants?.length ?? session._count?.participants ?? 0
const votedJurors = new Set(
(detail?.votes ?? []).map((v: any) => v.juryMember?.id ?? v.juryMemberId)
).size
const projectIds: string[] =
detail?.projects?.map((p: any) => p.id) ??
detail?.results?.map((r: any) => r.project.id) ??
[]
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<CardTitle className="text-lg">
{CATEGORY_LABEL[session.category] ?? session.category}
</CardTitle>
<CardDescription className="mt-0.5 flex items-center gap-3">
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{votedJurors}/{participantCount} jurors voted
</span>
<span>{voteCount} ballots</span>
<span>{session.mode === 'FULL_RANKING' ? 'Full ranking' : 'Single winner'}</span>
</CardDescription>
</div>
<Badge variant={badge.variant}>{badge.label}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{status === 'DELIB_OPEN' && (
<Button
onClick={() => openVoting.mutate({ sessionId: session.id })}
disabled={openVoting.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open voting
</Button>
)}
{(status === 'VOTING' || status === 'RUNOFF') && (
<Button
variant="outline"
onClick={() => closeVoting.mutate({ sessionId: session.id })}
disabled={closeVoting.isPending}
>
<Square className="mr-2 h-4 w-4" />
Close voting & tally
</Button>
)}
{status !== 'DELIB_LOCKED' && (
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
<Gavel className="mr-2 h-4 w-4" />
Set rankings manually
</Button>
)}
{status === 'DELIB_LOCKED' && (
<Badge variant="secondary" className="gap-1 py-1.5">
<Lock className="h-3.5 w-3.5" />
Results locked
</Badge>
)}
</div>
{/* Aggregated results + runoff/finalize controls */}
{(status === 'TALLYING' || status === 'RUNOFF' || status === 'DELIB_LOCKED') && (
<ResultsPanel sessionId={session.id} />
)}
<AdminOverrideDialog
sessionId={session.id}
open={overrideOpen}
onOpenChange={setOverrideOpen}
projectIds={projectIds}
/>
</CardContent>
</Card>
)
}
/**
* Admin deliberation console for a DELIBERATION round: create the per-category
* sessions from the round's jury group, drive voting open/close, tally,
* resolve ties, override manually (the "jury went analog" path) and finalize.
*/
export function DeliberationControlPanel({
roundId,
competitionId,
}: {
roundId: string
competitionId: string
}) {
const utils = trpc.useUtils()
const { data: round } = trpc.round.getById.useQuery({ id: roundId })
const juryGroupId = round?.juryGroupId ?? ''
const { data: juryGroup } = trpc.juryGroup.getById.useQuery(
{ id: juryGroupId },
{ enabled: !!juryGroupId }
)
const { data: sessions } = trpc.deliberation.listSessions.useQuery(
{ competitionId },
{ refetchInterval: 15_000 }
)
const [mode, setMode] = useState<'FULL_RANKING' | 'SINGLE_WINNER_VOTE'>('FULL_RANKING')
const createSession = trpc.deliberation.createSession.useMutation({
onSuccess: () => {
utils.deliberation.listSessions.invalidate({ competitionId })
toast.success('Deliberation session created')
},
onError: (err) => toast.error(err.message),
})
const roundSessions = (sessions ?? []).filter((s: any) => s.roundId === roundId)
const existingCategories = new Set(roundSessions.map((s: any) => s.category))
const votingMembers = (juryGroup?.members ?? []).filter((m: any) => m.role !== 'OBSERVER')
const handleCreate = (category: 'STARTUP' | 'BUSINESS_CONCEPT') => {
if (votingMembers.length === 0) {
toast.error('The round has no jury group members to deliberate')
return
}
createSession.mutate({
competitionId,
roundId,
category,
mode,
tieBreakMethod: 'TIE_ADMIN_DECIDES',
showPriorJuryData: true,
participantUserIds: votingMembers.map((m: any) => m.id),
})
}
return (
<div className="space-y-4">
{(['BUSINESS_CONCEPT', 'STARTUP'] as const).some((c) => !existingCategories.has(c)) && (
<Card>
<CardHeader>
<CardTitle>Create Deliberation Sessions</CardTitle>
<CardDescription>
One session per category · participants come from the round&apos;s jury group (
{votingMembers.length} voting member{votingMembers.length === 1 ? '' : 's'})
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-end gap-3">
<div className="space-y-1">
<Label className="text-xs">Mode</Label>
<Select value={mode} onValueChange={(v) => setMode(v as typeof mode)}>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="FULL_RANKING">Full ranking (Borda)</SelectItem>
<SelectItem value="SINGLE_WINNER_VOTE">Single winner pick</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
{(['BUSINESS_CONCEPT', 'STARTUP'] as const)
.filter((c) => !existingCategories.has(c))
.map((category) => (
<Button
key={category}
variant="outline"
onClick={() => handleCreate(category)}
disabled={createSession.isPending || !juryGroupId}
>
<Plus className="mr-2 h-4 w-4" />
{CATEGORY_LABEL[category]}
</Button>
))}
</div>
</div>
{!juryGroupId && (
<p className="text-xs text-destructive">
Assign a jury group to this round first (Config tab).
</p>
)}
</CardContent>
</Card>
)}
{roundSessions.map((s: any) => (
<SessionCard key={s.id} session={s} competitionId={competitionId} />
))}
{roundSessions.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
No deliberation sessions yet create one per category above.
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,79 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { Eye } from 'lucide-react'
/**
* Admin picker: which previously-submitted documents finale judges see on the
* review page. Default (switch off) shows everything; switching to curated
* mode starts with all slots ticked, and the admin unticks what to hide.
* Grand Final round uploads are always visible regardless.
*/
export function ReviewDocsPicker({ programId, roundId }: { programId: string; roundId: string }) {
const utils = trpc.useUtils()
const { data } = trpc.finalist.getReviewDocSettings.useQuery({ programId, roundId })
const set = trpc.finalist.setReviewVisibleRequirements.useMutation({
onSuccess: () => utils.finalist.getReviewDocSettings.invalidate({ programId, roundId }),
onError: (e) => toast.error(e.message),
})
if (!data || data.options.length === 0) return null
const curated = data.selectedIds !== null
const selected = new Set(data.selectedIds ?? data.options.map((o) => o.requirementId))
const toggleSlot = (id: string, on: boolean) => {
const next = new Set(selected)
if (on) next.add(id)
else next.delete(id)
set.mutate({ roundId, requirementIds: [...next] })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Eye className="h-5 w-5" /> Documents shown to judges
</CardTitle>
<CardDescription>
Choose which previously submitted documents judges see on the finalist review page.
Documents uploaded directly to this Grand Final round are always visible.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<Switch
id="curate-review-docs"
checked={curated}
disabled={set.isPending}
onCheckedChange={(v) =>
set.mutate({ roundId, requirementIds: v ? data.options.map((o) => o.requirementId) : null })}
/>
<Label htmlFor="curate-review-docs" className="text-sm text-muted-foreground cursor-pointer">
{curated ? 'Curated — judges see only the checked documents' : 'Showing all submitted documents'}
</Label>
</div>
{curated && (
<div className="space-y-2">
{data.options.map((o) => (
<label key={o.requirementId} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={selected.has(o.requirementId)}
disabled={set.isPending}
onCheckedChange={(v) => toggleSlot(o.requirementId, v === true)}
/>
<span>{o.name} {o.roundName}</span>
<span className="text-xs text-muted-foreground">
({o.fileCount} file{o.fileCount === 1 ? '' : 's'})
</span>
</label>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,278 @@
'use client'
import { useEffect, useState } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { formatClock } from '@/lib/live-timer'
import { ChevronDown, QrCode, Users, Vote, XCircle } from 'lucide-react'
import { toast } from 'sonner'
const WINDOW_LABEL: Record<string, string> = {
'CATEGORY:BUSINESS_CONCEPT': 'Business Concepts',
'CATEGORY:STARTUP': 'Startups',
OVERALL: 'Overall favorite',
}
/**
* Audience favorite-vote control: open a per-category (or overall) window for
* N minutes, watch the live vote count, close early, and project the QR code.
*/
export function AudienceWindowPanel({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: session } = trpc.liveVoting.getSession.useQuery(
{ roundId },
{ refetchInterval: 3000 }
)
const { data: tallies } = trpc.liveVoting.getFavoriteTallies.useQuery(
{ sessionId: session?.id ?? '' },
{ enabled: !!session?.id, refetchInterval: 3000 }
)
const [durationMin, setDurationMin] = useState('5')
const [talliesOpen, setTalliesOpen] = useState(false)
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const invalidate = () => {
utils.liveVoting.getSession.invalidate({ roundId })
if (session?.id) utils.liveVoting.getFavoriteTallies.invalidate({ sessionId: session.id })
}
const onError = (err: { message: string }) => toast.error(err.message)
const openWindow = trpc.liveVoting.openAudienceWindow.useMutation({
onSuccess: invalidate,
onError,
})
const closeWindow = trpc.liveVoting.closeAudienceWindow.useMutation({
onSuccess: () => {
invalidate()
toast.success('Audience voting closed')
},
onError,
})
const updateConfig = trpc.liveVoting.updateSessionConfig.useMutation({
onSuccess: invalidate,
onError,
})
if (!session) return null
const closesAt = session.audienceWindowClosesAt ? new Date(session.audienceWindowClosesAt) : null
const secondsLeft = closesAt ? Math.floor((closesAt.getTime() - Date.now()) / 1000) : null
const isOpen = session.audiencePhase === 'OPEN' && secondsLeft !== null && secondsLeft > 0
const openKey = isOpen ? session.audienceWindowKey : null
const currentWindow = tallies?.windows.find((w) => w.windowKey === openKey)
const voteUrl =
typeof window !== 'undefined' ? `${window.location.origin}/vote/competition/${roundId}` : ''
const duration = Math.max(1, parseInt(durationMin, 10) || 5)
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Vote className="h-5 w-5" />
Audience Vote
</CardTitle>
<CardDescription>
{session.allowAudienceVotes
? 'Favorite-pick windows, one vote per phone per window'
: 'Audience voting is disabled in session config'}
</CardDescription>
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<QrCode className="mr-2 h-4 w-4" />
Show QR
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-center">Scan to vote</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
<div className="rounded-3xl bg-white p-6 shadow-lg">
{voteUrl && <QRCodeSVG value={voteUrl} size={420} />}
</div>
<p className="select-all break-all text-center text-sm text-muted-foreground">
{voteUrl}
</p>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isOpen ? (
<div className="space-y-3 rounded-lg border border-[#de0f1e]/30 bg-[#de0f1e]/5 p-4">
<div className="flex items-center justify-between">
<Badge className="bg-[#de0f1e] hover:bg-[#de0f1e]">
OPEN {WINDOW_LABEL[openKey ?? ''] ?? openKey}
</Badge>
<span className="text-2xl font-bold tabular-nums">
{formatClock(Math.max(0, secondsLeft ?? 0))}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
<span className="font-semibold text-foreground">
{currentWindow?.totalVotes ?? 0}
</span>
votes cast
</div>
<Button
variant="destructive"
className="w-full"
onClick={() => closeWindow.mutate({ sessionId: session.id })}
disabled={closeWindow.isPending}
>
<XCircle className="mr-2 h-4 w-4" />
Close voting now
</Button>
</div>
) : (
<div className="space-y-3">
<div className="flex items-end gap-3">
<div className="space-y-1">
<Label className="text-xs">Duration (min)</Label>
<Input
type="number"
min="1"
max="120"
value={durationMin}
onChange={(e) => setDurationMin(e.target.value)}
className="w-24"
/>
</div>
<p className="pb-2 text-xs text-muted-foreground">
Voting closes automatically server-enforced
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<Button
variant="outline"
disabled={openWindow.isPending || !session.allowAudienceVotes}
onClick={() =>
openWindow.mutate({
sessionId: session.id,
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
durationMinutes: duration,
})
}
>
Open vote Business Concepts
</Button>
<Button
variant="outline"
disabled={openWindow.isPending || !session.allowAudienceVotes}
onClick={() =>
openWindow.mutate({
sessionId: session.id,
windowKey: 'CATEGORY:STARTUP',
durationMinutes: duration,
})
}
>
Open vote Startups
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Overall favorite (across both categories)</p>
<p className="text-xs text-muted-foreground">Decide day-of off by default</p>
</div>
<div className="flex items-center gap-3">
<Switch
checked={!!session.allowOverallFavorite}
onCheckedChange={(checked) =>
updateConfig.mutate({ sessionId: session.id, allowOverallFavorite: checked })
}
/>
<Button
variant="outline"
size="sm"
disabled={
openWindow.isPending || !session.allowOverallFavorite || !session.allowAudienceVotes
}
onClick={() =>
openWindow.mutate({
sessionId: session.id,
windowKey: 'OVERALL',
durationMinutes: duration,
})
}
>
Open
</Button>
</div>
</div>
{!session.allowAudienceVotes && (
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() =>
updateConfig.mutate({ sessionId: session.id, allowAudienceVotes: true })
}
disabled={updateConfig.isPending}
>
Enable audience voting for this session
</Button>
)}
</div>
)}
{/* Tallies — admin eyes only */}
{tallies && tallies.windows.length > 0 && (
<Collapsible open={talliesOpen} onOpenChange={setTalliesOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full justify-between">
Tallies (admin only)
<ChevronDown
className={`h-4 w-4 transition-transform ${talliesOpen ? 'rotate-180' : ''}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
{tallies.windows.map((w) => (
<div key={w.windowKey} className="rounded-lg border p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">
{WINDOW_LABEL[w.windowKey] ?? w.windowKey}
</p>
<span className="text-xs text-muted-foreground">{w.totalVotes} votes</span>
</div>
<div className="space-y-1">
{w.projects.map((p) => (
<div key={p.projectId} className="flex items-center justify-between text-sm">
<span className="truncate">{p.teamName ?? p.title}</span>
<span className="font-semibold tabular-nums">{p.count}</span>
</div>
))}
</div>
</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,238 +1,159 @@
'use client'; 'use client'
import { useState, useEffect } from 'react'; import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'; import Link from 'next/link'
import { Button } from '@/components/ui/button'; import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ChevronLeft, ChevronRight, Play, Square, Pause, Timer } from 'lucide-react'; import { Badge } from '@/components/ui/badge'
import { toast } from 'sonner'; import { PhaseControls } from './phase-controls'
import { RunOrderList } from './run-order-list'
import { AudienceWindowPanel } from './audience-window-panel'
import { TimingLogCard } from './timing-log-card'
import { RevealPanel } from './reveal-panel'
import { Coffee, ExternalLink, Hand, MonitorPlay, PartyPopper, Play, Scale, X } from 'lucide-react'
import { toast } from 'sonner'
interface LiveControlPanelProps { interface LiveControlPanelProps {
roundId: string; roundId: string
competitionId: string; competitionId: string
} }
const OVERRIDE_SLIDES = [
{ value: 'welcome', label: 'Welcome', icon: Hand },
{ value: 'break', label: 'Break', icon: Coffee },
{ value: 'deliberation', label: 'Deliberation', icon: Scale },
{ value: 'thanks', label: 'Thank you', icon: PartyPopper },
] as const
/**
* Grand-finale ceremony console. Everything an admin touches during the
* event lives here: run order, phase driver with real timers, audience vote
* windows + QR, big-screen override slides, timing log, and the results
* reveal stepper.
*/
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) { export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
const utils = trpc.useUtils(); const utils = trpc.useUtils()
const [timerSeconds, setTimerSeconds] = useState(300); const { data: cursor, isLoading } = trpc.live.getCursor.useQuery(
const [isTimerRunning, setIsTimerRunning] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 5000 } { refetchInterval: 2000 }
); )
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId },
{ enabled: !cursor && !isLoading }
)
const [starting, setStarting] = useState(false)
const jumpMutation = trpc.live.jump.useMutation({ const startMutation = trpc.live.start.useMutation({
onSuccess: () => { onSuccess: () => {
utils.live.getCursor.invalidate({ roundId }); utils.live.getCursor.invalidate({ roundId })
toast.success('Ceremony session started')
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}); onSettled: () => setStarting(false),
})
const pauseMutation = trpc.live.pause.useMutation({ const overrideMutation = trpc.live.setOverrideSlide.useMutation({
onSuccess: () => { onSuccess: () => utils.live.getCursor.invalidate({ roundId }),
utils.live.getCursor.invalidate({ roundId });
toast.success('Live session paused');
},
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}); })
const resumeMutation = trpc.live.resume.useMutation({ const handleStart = () => {
onSuccess: () => { // Default run order: Business Concepts block first, then Startups
utils.live.getCursor.invalidate({ roundId }); const projects = (projectStates ?? [])
toast.success('Live session resumed'); .map((ps: any) => ps.project)
}, .filter(Boolean)
onError: (err) => toast.error(err.message), const order = [
}); ...projects.filter((p: any) => p.competitionCategory === 'BUSINESS_CONCEPT'),
...projects.filter((p: any) => p.competitionCategory === 'STARTUP'),
useEffect(() => { ...projects.filter(
if (!isTimerRunning) return; (p: any) => p.competitionCategory !== 'BUSINESS_CONCEPT' && p.competitionCategory !== 'STARTUP'
),
const interval = setInterval(() => { ].map((p: any) => p.id)
setTimerSeconds((prev) => { if (order.length === 0) {
if (prev <= 1) { toast.error('No projects in this round yet')
setIsTimerRunning(false); return
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [isTimerRunning]);
const currentIndex = cursor?.activeOrderIndex ?? 0;
const totalProjects = cursor?.totalProjects ?? 0;
const isNavigating = jumpMutation.isPending;
const handlePrevious = () => {
if (currentIndex <= 0) {
toast.info('Already at the first project');
return;
} }
jumpMutation.mutate({ roundId, index: currentIndex - 1 }); setStarting(true)
}; startMutation.mutate({ roundId, projectOrder: order })
}
const handleNext = () => { // ── Not started yet ───────────────────────────────────────────────────────
if (currentIndex >= totalProjects - 1) { if (!cursor) {
toast.info('Already at the last project'); return (
return;
}
jumpMutation.mutate({ roundId, index: currentIndex + 1 });
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="space-y-6">
{/* Current Project Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Current Project</CardTitle>
<div className="flex items-center gap-2">
{cursor && (
<span className="text-sm text-muted-foreground tabular-nums">
{currentIndex + 1} / {totalProjects}
</span>
)}
<Button
variant="outline"
size="icon"
onClick={handlePrevious}
disabled={isNavigating || currentIndex <= 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleNext}
disabled={isNavigating || currentIndex >= totalProjects - 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{cursor?.activeProject ? (
<div className="space-y-4">
<div>
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
{cursor.activeProject.teamName && (
<p className="text-muted-foreground">{cursor.activeProject.teamName}</p>
)}
</div>
{cursor.activeProject.tags && (cursor.activeProject.tags as string[]).length > 0 && (
<div className="flex flex-wrap gap-1">
{(cursor.activeProject.tags as string[]).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</div>
) : (
<p className="text-muted-foreground">
{cursor ? 'No project selected' : 'No live session active for this round'}
</p>
)}
</CardContent>
</Card>
{/* Timer Card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Timer className="h-5 w-5" /> <MonitorPlay className="h-5 w-5" />
Timer Ceremony Console
</CardTitle> </CardTitle>
<CardDescription>
Start the live session when the event begins it creates the presentation cursor
every screen follows. The set start time is indicative; nothing moves until you click.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-3">
<div className="text-center"> <Button size="lg" className="w-full" onClick={handleStart} disabled={isLoading || starting}>
<div className="text-5xl font-bold tabular-nums">{formatTime(timerSeconds)}</div> <Play className="mr-2 h-4 w-4" />
</div> {starting ? 'Starting…' : 'Start ceremony session'}
<div className="flex flex-col gap-2 sm:flex-row"> </Button>
{!isTimerRunning ? ( <p className="text-center text-xs text-muted-foreground">
Run order defaults to Business Concepts Startups; reorder anytime after starting.
</p>
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{/* Big-screen quick bar */}
<Card>
<CardContent className="flex flex-wrap items-center gap-2 py-3">
<span className="mr-1 text-sm font-medium">Big screen:</span>
{OVERRIDE_SLIDES.map((slide) => {
const active = cursor.overrideSlide === slide.value
const SlideIcon = slide.icon
return (
<Button <Button
className="flex-1" key={slide.value}
onClick={() => setIsTimerRunning(true)} variant={active ? 'default' : 'outline'}
disabled={timerSeconds === 0} size="sm"
onClick={() =>
overrideMutation.mutate({ roundId, slide: active ? null : slide.value })
}
disabled={overrideMutation.isPending}
> >
<Play className="mr-2 h-4 w-4" /> <SlideIcon className="mr-1.5 h-3.5 w-3.5" />
Start Timer {slide.label}
{active && <X className="ml-1.5 h-3 w-3" />}
</Button> </Button>
) : ( )
<Button className="flex-1" onClick={() => setIsTimerRunning(false)} variant="destructive"> })}
<Square className="mr-2 h-4 w-4" /> {cursor.overrideSlide && (
Stop Timer <Badge variant="destructive" className="ml-auto">
</Button> Override active live content hidden
)} </Badge>
<Button )}
variant="outline" <Button asChild variant="ghost" size="sm" className={cursor.overrideSlide ? '' : 'ml-auto'}>
onClick={() => { <Link href={`/live/ceremony/${roundId}`} target="_blank">
setTimerSeconds(300); <ExternalLink className="mr-1.5 h-3.5 w-3.5" />
setIsTimerRunning(false); Open big screen
}} </Link>
> </Button>
Reset (5:00)
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Session Controls */} <div className="grid gap-4 lg:grid-cols-2">
<Card> <div className="space-y-4">
<CardHeader> <PhaseControls roundId={roundId} />
<CardTitle>Session Controls</CardTitle> <RunOrderList roundId={roundId} />
<CardDescription>Pause or resume the live presentation</CardDescription> </div>
</CardHeader> <div className="space-y-4">
<CardContent className="space-y-3"> <AudienceWindowPanel roundId={roundId} />
{cursor?.isPaused ? ( <RevealPanel roundId={roundId} competitionId={competitionId} />
<Button <TimingLogCard roundId={roundId} />
className="w-full" </div>
onClick={() => resumeMutation.mutate({ roundId })} </div>
disabled={resumeMutation.isPending}
>
<Play className="mr-2 h-4 w-4" />
{resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
</Button>
) : (
<Button
className="w-full"
variant="outline"
onClick={() => pauseMutation.mutate({ roundId })}
disabled={pauseMutation.isPending || !cursor}
>
<Pause className="mr-2 h-4 w-4" />
{pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
</Button>
)}
{cursor?.isPaused && (
<Badge variant="destructive" className="w-full justify-center py-1">
Session Paused
</Badge>
)}
{cursor?.openCohorts && cursor.openCohorts.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium mb-2">Open Voting Windows</p>
{cursor.openCohorts.map((cohort: any) => (
<div key={cohort.id} className="flex items-center justify-between text-sm">
<span>{cohort.name}</span>
<Badge variant="outline">{cohort.votingMode}</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div> </div>
); )
} }

View File

@@ -0,0 +1,222 @@
'use client'
import { useEffect, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { remainingSeconds, formatClock, parseClock } from '@/lib/live-timer'
import {
Mic2,
MessageCircleQuestion,
PenLine,
Pause,
Play,
SkipForward,
MonitorUp,
} from 'lucide-react'
import { toast } from 'sonner'
const PHASE_LABEL: Record<string, string> = {
ON_DECK: 'On deck',
PRESENTING: 'Presenting',
QA: 'Q&A',
SCORING: 'Scoring',
}
/**
* The ceremony driver: one primary button for the next phase transition, a
* server-derived countdown that goes red past zero, pause/resume, and
* per-run duration overrides.
*/
export function PhaseControls({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 2000 })
const [presentationMin, setPresentationMin] = useState<string>('')
const [qaMin, setQaMin] = useState<string>('')
const [, tick] = useState(0)
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 1000)
return () => clearInterval(id)
}, [])
const invalidate = () => utils.live.getCursor.invalidate({ roundId })
const onError = (err: { message: string }) => toast.error(err.message)
const startPresentation = trpc.live.startPresentation.useMutation({ onSuccess: invalidate, onError })
const startQA = trpc.live.startQA.useMutation({ onSuccess: invalidate, onError })
const openScoring = trpc.live.openScoring.useMutation({ onSuccess: invalidate, onError })
const sendToScreens = trpc.live.sendToScreens.useMutation({ onSuccess: invalidate, onError })
const pausePhase = trpc.live.pausePhase.useMutation({ onSuccess: invalidate, onError })
const resumePhase = trpc.live.resumePhase.useMutation({ onSuccess: invalidate, onError })
if (!cursor) {
return null
}
const phase = cursor.projectPhase
const remaining = remainingSeconds(cursor)
const over = remaining !== null && remaining < 0
const paused = !!cursor.phasePausedAt
const busy =
startPresentation.isPending ||
startQA.isPending ||
openScoring.isPending ||
sendToScreens.isPending
const durationSeconds = (raw: string) => parseClock(raw) ?? undefined
const nextProject = (() => {
const order = cursor.orderedProjects ?? []
const idx = order.findIndex((p) => p.id === cursor.activeProjectId)
return idx >= 0 && idx < order.length - 1 ? order[idx + 1] : null
})()
const primaryAction = (() => {
switch (phase) {
case 'ON_DECK':
return {
label: 'Start presentation',
icon: Mic2,
run: () =>
startPresentation.mutate({
roundId,
durationSeconds: durationSeconds(presentationMin),
}),
disabled: !cursor.activeProjectId,
}
case 'PRESENTING':
return {
label: 'Start Q&A',
icon: MessageCircleQuestion,
run: () => startQA.mutate({ roundId, durationSeconds: durationSeconds(qaMin) }),
disabled: false,
}
case 'QA':
return {
label: 'Open scoring',
icon: PenLine,
run: () => openScoring.mutate({ roundId }),
disabled: false,
}
case 'SCORING':
default:
return nextProject
? {
label: `Send next: ${nextProject.teamName ?? nextProject.title}`,
icon: MonitorUp,
run: () => sendToScreens.mutate({ roundId, projectId: nextProject.id }),
disabled: false,
}
: {
label: 'End of run order',
icon: SkipForward,
run: () => undefined,
disabled: true,
}
}
})()
const PrimaryIcon = primaryAction.icon
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Ceremony Control</CardTitle>
<CardDescription>
{cursor.activeProject
? `${cursor.activeProject.title}${cursor.activeProject.teamName ? `${cursor.activeProject.teamName}` : ''}`
: 'No project on screens yet'}
</CardDescription>
</div>
<Badge variant={phase === 'SCORING' ? 'default' : 'secondary'}>
{PHASE_LABEL[phase] ?? phase}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
{/* Server-derived countdown */}
<div className="text-center">
<div
className={`text-6xl font-bold tabular-nums ${
over ? 'animate-pulse text-[#de0f1e]' : remaining === null ? 'text-muted-foreground/40' : ''
}`}
>
{remaining === null ? ':' : formatClock(remaining)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{remaining === null
? 'No timer running'
: over
? `Over time${paused ? ' · paused' : ''} — noted, not penalized`
: paused
? 'Paused'
: phase === 'PRESENTING'
? 'Presentation time remaining'
: 'Q&A time remaining'}
</p>
</div>
{/* Primary transition + pause */}
<div className="flex gap-2">
<Button
className="flex-1"
size="lg"
onClick={primaryAction.run}
disabled={primaryAction.disabled || busy}
>
<PrimaryIcon className="mr-2 h-4 w-4" />
{primaryAction.label}
</Button>
{remaining !== null &&
(paused ? (
<Button
variant="outline"
size="lg"
onClick={() => resumePhase.mutate({ roundId })}
disabled={resumePhase.isPending}
>
<Play className="mr-2 h-4 w-4" />
Resume
</Button>
) : (
<Button
variant="outline"
size="lg"
onClick={() => pausePhase.mutate({ roundId })}
disabled={pausePhase.isPending}
>
<Pause className="mr-2 h-4 w-4" />
Pause
</Button>
))}
</div>
{/* One-off duration overrides for the NEXT start only (m:ss).
Per-project durations live in the Run Order list. */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Presentation override (m:ss, next start only)</Label>
<Input
placeholder="e.g. 7:30"
className="tabular-nums"
value={presentationMin}
onChange={(e) => setPresentationMin(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Q&A override (m:ss, next start only)</Label>
<Input
placeholder="e.g. 2:00"
className="tabular-nums"
value={qaMin}
onChange={(e) => setQaMin(e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,342 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ArrowDown, ArrowUp, PartyPopper, Play, RotateCcw, Sparkles, Trash2, Wand2 } from 'lucide-react'
import { toast } from 'sonner'
type RevealStep = {
kind: 'category-intro' | 'place' | 'audience-award' | 'overall-favorite' | 'thanks'
category?: 'STARTUP' | 'BUSINESS_CONCEPT'
place?: number
projectId?: string
title?: string
subtitle?: string
}
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
const PLACE_LABEL: Record<number, string> = { 1: 'Winner', 2: '2nd place', 3: '3rd place' }
function describeStep(step: RevealStep): string {
switch (step.kind) {
case 'category-intro':
return `${CATEGORY_LABEL[step.category ?? ''] ?? 'Category'}`
case 'place':
return `${PLACE_LABEL[step.place ?? 0] ?? `${step.place}th`} · ${step.title ?? '?'} (${CATEGORY_LABEL[step.category ?? ''] ?? ''})`
case 'audience-award':
return `Audience Choice (${CATEGORY_LABEL[step.category ?? ''] ?? ''}) · ${step.title ?? '?'}`
case 'overall-favorite':
return `Audience Favorite Overall · ${step.title ?? '?'}`
case 'thanks':
return 'Thank-you slide'
}
}
/**
* Results reveal builder + stepper. Compose privately from deliberation
* results / jury scores / audience tallies, preview every step, then arm the
* big screen and fire one step at a time. Nothing reaches the projector
* before "Arm".
*/
export function RevealPanel({ roundId, competitionId }: { roundId: string; competitionId: string }) {
const utils = trpc.useUtils()
const { data: session } = trpc.liveVoting.getSession.useQuery({ roundId })
const sessionId = session?.id ?? ''
const { data: reveal } = trpc.liveVoting.getRevealAdmin.useQuery(
{ sessionId },
{ enabled: !!sessionId, refetchInterval: 3000 }
)
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId })
const { data: results } = trpc.liveVoting.getResults.useQuery(
{ sessionId },
{ enabled: !!sessionId }
)
const { data: tallies } = trpc.liveVoting.getFavoriteTallies.useQuery(
{ sessionId },
{ enabled: !!sessionId }
)
const { data: delibSessions } = trpc.deliberation.listSessions.useQuery({ competitionId })
const [draftSteps, setDraftSteps] = useState<RevealStep[] | null>(null)
const invalidate = () => utils.liveVoting.getRevealAdmin.invalidate({ sessionId })
const onError = (err: { message: string }) => toast.error(err.message)
const saveReveal = trpc.liveVoting.saveReveal.useMutation({
onSuccess: () => {
setDraftSteps(null) // the saved copy is now canonical — unlocks Arm
invalidate()
toast.success('Reveal draft saved')
},
onError,
})
const armReveal = trpc.liveVoting.armReveal.useMutation({ onSuccess: invalidate, onError })
const revealNext = trpc.liveVoting.revealNext.useMutation({ onSuccess: invalidate, onError })
const resetReveal = trpc.liveVoting.resetReveal.useMutation({ onSuccess: invalidate, onError })
if (!session) return null
const savedSteps = (reveal?.stepsJson as RevealStep[] | undefined) ?? []
const steps = draftSteps ?? savedSteps
const status = reveal?.status ?? 'DRAFT'
const currentIndex = reveal?.currentStepIndex ?? -1
const categoryOf = (projectId: string) =>
cursor?.orderedProjects?.find((p) => p.id === projectId)?.competitionCategory ?? null
const displayName = (projectId: string) => {
const p = cursor?.orderedProjects?.find((p) => p.id === projectId)
return p?.teamName ?? p?.title ?? 'Unknown'
}
const compose = () => {
const composed: RevealStep[] = []
const categories: Array<'BUSINESS_CONCEPT' | 'STARTUP'> = ['BUSINESS_CONCEPT', 'STARTUP']
let usedDeliberation = false
for (const category of categories) {
// Locked deliberation results take precedence; jury score order is the fallback
const delib = (delibSessions ?? []).find(
(s: any) => s.category === category && s.status === 'DELIB_LOCKED' && s.results?.length > 0
)
let rankedProjectIds: string[]
if (delib) {
rankedProjectIds = delib.results.map((r: any) => r.projectId)
usedDeliberation = true
} else {
rankedProjectIds = (results?.results ?? [])
.filter((r: any) => r.project?.id && categoryOf(r.project.id) === category)
.map((r: any) => r.project.id)
}
if (rankedProjectIds.length === 0) continue
composed.push({
kind: 'category-intro',
category,
title: CATEGORY_LABEL[category],
})
const top = rankedProjectIds.slice(0, 3)
// Reverse order: 3rd → 2nd → 1st
top
.map((projectId, idx) => ({ projectId, place: idx + 1 }))
.reverse()
.forEach(({ projectId, place }) => {
composed.push({
kind: 'place',
category,
place,
projectId,
title: displayName(projectId),
subtitle: `${PLACE_LABEL[place] ?? `${place}th place`}${CATEGORY_LABEL[category]}`,
})
})
const audienceWindow = tallies?.windows.find((w) => w.windowKey === `CATEGORY:${category}`)
const audienceTop = audienceWindow?.projects[0]
if (audienceTop) {
composed.push({
kind: 'audience-award',
category,
projectId: audienceTop.projectId,
title: audienceTop.teamName ?? audienceTop.title,
subtitle: `Audience Choice — ${CATEGORY_LABEL[category]}`,
})
}
}
const overallWindow = tallies?.windows.find((w) => w.windowKey === 'OVERALL')
const overallTop = overallWindow?.projects[0]
if (overallTop) {
composed.push({
kind: 'overall-favorite',
projectId: overallTop.projectId,
title: overallTop.teamName ?? overallTop.title,
subtitle: 'Audience Favorite — Overall',
})
}
composed.push({ kind: 'thanks', title: 'Thank you' })
if (composed.length <= 1) {
toast.info('No results to compose from yet — scores and votes are still empty')
return
}
toast.success(
usedDeliberation
? 'Composed from locked deliberation results + audience tallies'
: 'Composed from jury scores + audience tallies (no locked deliberation yet)'
)
setDraftSteps(composed)
}
const moveStep = (index: number, delta: -1 | 1) => {
const next = [...steps]
const target = index + delta
if (target < 0 || target >= next.length) return
;[next[index], next[target]] = [next[target], next[index]]
setDraftSteps(next)
}
const removeStep = (index: number) => {
setDraftSteps(steps.filter((_, i) => i !== index))
}
const isDraft = status === 'DRAFT'
const isLive = status === 'REVEALING' || status === 'DONE'
const nextStep = steps[currentIndex + 1]
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<PartyPopper className="h-5 w-5" />
Results Reveal
</CardTitle>
<CardDescription>
Compose privately, arm the big screen, reveal step by step
</CardDescription>
</div>
<Badge
variant={isDraft ? 'secondary' : 'default'}
className={isLive ? 'bg-[#de0f1e] hover:bg-[#de0f1e]' : undefined}
>
{status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isDraft && (
<>
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={compose}>
<Wand2 className="mr-2 h-4 w-4" />
Compose from results
</Button>
<Button
className="flex-1"
disabled={!draftSteps || saveReveal.isPending}
onClick={() => sessionId && saveReveal.mutate({ sessionId, steps })}
>
Save draft
</Button>
</div>
<p className="text-xs text-muted-foreground">
Locked deliberation results take precedence; otherwise jury scores (top 3 per
category, revealed 3rd 1st), plus audience tallies. Adjust the steps below
before saving if needed.
</p>
</>
)}
{steps.length > 0 && (
<div className="space-y-1">
{steps.map((step, i) => {
const revealed = i <= currentIndex
return (
<div
key={i}
className={`flex items-center gap-2 rounded-lg border p-2 text-sm ${
revealed
? 'border-green-600/30 bg-green-600/5'
: i === currentIndex + 1 && !isDraft
? 'border-[#de0f1e]/40 bg-[#de0f1e]/5'
: ''
}`}
>
<span className="w-5 text-center text-xs tabular-nums text-muted-foreground">
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate">{describeStep(step)}</span>
{revealed && <Badge variant="outline" className="text-xs">revealed</Badge>}
{isDraft && (
<div className="flex shrink-0 gap-0.5">
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === 0} onClick={() => moveStep(i, -1)}>
<ArrowUp className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" disabled={i === steps.length - 1} onClick={() => moveStep(i, 1)}>
<ArrowDown className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeStep(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
)
})}
</div>
)}
{isDraft && savedSteps.length > 0 && !draftSteps && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full" size="lg">
<Sparkles className="mr-2 h-4 w-4" />
Arm reveal
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Arm the results reveal?</AlertDialogTitle>
<AlertDialogDescription>
The big screen switches to the Results splash immediately. Nothing is revealed
until you press Reveal next.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => sessionId && armReveal.mutate({ sessionId })}>
Arm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{!isDraft && (
<div className="space-y-2">
<Button
className="w-full bg-[#de0f1e] hover:bg-[#c00d1a]"
size="lg"
disabled={revealNext.isPending || status === 'DONE'}
onClick={() => sessionId && revealNext.mutate({ sessionId })}
>
<Play className="mr-2 h-4 w-4" />
{status === 'DONE'
? 'All revealed'
: `Reveal next (${currentIndex + 2}/${steps.length})`}
</Button>
{nextStep && status !== 'DONE' && (
<p className="text-center text-xs text-muted-foreground">
Next on screen: <span className="font-medium">{describeStep(nextStep)}</span>
</p>
)}
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={() => sessionId && resetReveal.mutate({ sessionId })}
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
Reset to draft (leaves reveal mode)
</Button>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,219 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { ArrowDown, ArrowUp, MonitorUp, Timer } from 'lucide-react'
import { formatClock, parseClock } from '@/lib/live-timer'
import { toast } from 'sonner'
const CATEGORY_LABEL: Record<string, string> = {
BUSINESS_CONCEPT: 'Business Concepts',
STARTUP: 'Startups',
}
/**
* The ceremony run order, grouped by category, with quick reorder (▲▼) and a
* "Send to screens" action per project — built for last-minute schedule
* shuffles without leaving the console.
*/
export function RunOrderList({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 5000 })
// Local drafts for the per-project minute inputs (committed on blur)
const [timingDrafts, setTimingDrafts] = useState<Record<string, string>>({})
const reorderMutation = trpc.live.reorder.useMutation({
onSuccess: () => utils.live.getCursor.invalidate({ roundId }),
onError: (err) => toast.error(err.message),
})
const timingMutation = trpc.live.setProjectTiming.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId })
toast.success('Project timing saved')
},
onError: (err) => toast.error(err.message),
})
const commitTiming = (projectId: string, field: 'presentationSeconds' | 'qaSeconds', raw: string) => {
const trimmed = raw.trim()
const seconds = trimmed === '' ? null : parseClock(trimmed)
if (trimmed !== '' && seconds === null) {
toast.error('Use minutes:seconds, e.g. 7:30')
return
}
const current = cursor?.projectTimingOverrides?.[projectId]?.[field] ?? null
if (seconds === current) return
timingMutation.mutate({ roundId, projectId, [field]: seconds })
}
const sendMutation = trpc.live.sendToScreens.useMutation({
onSuccess: (_d, vars) => {
utils.live.getCursor.invalidate({ roundId })
const p = cursor?.orderedProjects?.find((p) => p.id === vars.projectId)
toast.success(`${p?.teamName ?? p?.title ?? 'Project'} is now on screens (up next)`)
},
onError: (err) => toast.error(err.message),
})
const projects = cursor?.orderedProjects ?? []
if (!cursor || projects.length === 0) {
return null
}
const move = (index: number, delta: -1 | 1) => {
const order = projects.map((p) => p.id)
const target = index + delta
if (target < 0 || target >= order.length) return
;[order[index], order[target]] = [order[target], order[index]]
reorderMutation.mutate({ roundId, projectOrder: order })
}
// Group rows under category headings while preserving the global order
const rows: Array<{ type: 'heading'; label: string } | { type: 'project'; index: number }> = []
let lastCategory: string | null = null
projects.forEach((p, index) => {
const cat = p.competitionCategory ?? 'OTHER'
if (cat !== lastCategory) {
rows.push({ type: 'heading', label: CATEGORY_LABEL[cat] ?? 'Other' })
lastCategory = cat
}
rows.push({ type: 'project', index })
})
return (
<Card>
<CardHeader>
<CardTitle>Run Order</CardTitle>
<CardDescription>
Reorder presentations on the fly · Send to screens puts a team up next everywhere
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
{rows.map((row, i) => {
if (row.type === 'heading') {
return (
<p
key={`h-${i}`}
className="pt-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground first:pt-0"
>
{row.label}
</p>
)
}
const project = projects[row.index]
const isActive = project.id === cursor.activeProjectId
const override = cursor.projectTimingOverrides?.[project.id]
const presKey = `${project.id}:pres`
const qaKey = `${project.id}:qa`
return (
<div
key={project.id}
className={`flex items-center gap-2 rounded-lg border p-2.5 ${
isActive ? 'border-[#de0f1e]/40 bg-[#de0f1e]/5' : 'border-transparent hover:bg-muted/40'
}`}
>
<span className="w-6 text-center text-sm tabular-nums text-muted-foreground">
{row.index + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{project.title}</p>
{project.teamName && (
<p className="truncate text-xs text-muted-foreground">{project.teamName}</p>
)}
{/* Per-project durations (m:ss) — empty = round default */}
<div className="mt-1 flex items-center gap-2">
<Timer className="h-3 w-3 shrink-0 text-muted-foreground" />
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
Pres
<Input
className="h-6 w-16 px-1.5 text-center text-xs tabular-nums"
placeholder="default"
value={
timingDrafts[presKey] ??
(override?.presentationSeconds != null
? formatClock(override.presentationSeconds)
: '')
}
onChange={(e) =>
setTimingDrafts((d) => ({ ...d, [presKey]: e.target.value }))
}
onBlur={(e) => {
commitTiming(project.id, 'presentationSeconds', e.target.value)
setTimingDrafts((d) => {
const next = { ...d }
delete next[presKey]
return next
})
}}
/>
</label>
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
Q&A
<Input
className="h-6 w-16 px-1.5 text-center text-xs tabular-nums"
placeholder="default"
value={
timingDrafts[qaKey] ??
(override?.qaSeconds != null ? formatClock(override.qaSeconds) : '')
}
onChange={(e) =>
setTimingDrafts((d) => ({ ...d, [qaKey]: e.target.value }))
}
onBlur={(e) => {
commitTiming(project.id, 'qaSeconds', e.target.value)
setTimingDrafts((d) => {
const next = { ...d }
delete next[qaKey]
return next
})
}}
/>
</label>
<span className="text-[10px] text-muted-foreground/70">m:ss</span>
</div>
</div>
{isActive && (
<Badge className="shrink-0 bg-[#de0f1e] hover:bg-[#de0f1e]">
{cursor.projectPhase === 'ON_DECK' ? 'on deck' : 'live'}
</Badge>
)}
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={reorderMutation.isPending || row.index === 0}
onClick={() => move(row.index, -1)}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={reorderMutation.isPending || row.index === projects.length - 1}
onClick={() => move(row.index, 1)}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2 text-xs"
disabled={sendMutation.isPending || isActive}
onClick={() => sendMutation.mutate({ roundId, projectId: project.id })}
>
<MonitorUp className="h-3.5 w-3.5" />
Send to screens
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { formatClock } from '@/lib/live-timer'
import { Timer } from 'lucide-react'
type TimingEntry = {
projectId: string
phase: 'PRESENTING' | 'QA'
startedAt: string
endedAt: string
configuredSeconds: number | null
elapsedSeconds: number
overranSeconds: number
}
/**
* Factual per-project timing record: configured vs actual, with overruns
* highlighted (noted, never penalized).
*/
export function TimingLogCard({ roundId }: { roundId: string }) {
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId }, { refetchInterval: 5000 })
const log = (cursor?.timingLogJson as TimingEntry[] | null) ?? []
if (!cursor || log.length === 0) return null
const titleFor = (projectId: string) => {
const p = cursor.orderedProjects?.find((p) => p.id === projectId)
return p?.teamName ?? p?.title ?? 'Unknown'
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Timer className="h-5 w-5" />
Timing Log
</CardTitle>
<CardDescription>Configured vs actual overruns are noted, not penalized</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{log.map((entry, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border p-2.5 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{titleFor(entry.projectId)}</p>
<p className="text-xs text-muted-foreground">
{entry.phase === 'PRESENTING' ? 'Presentation' : 'Q&A'}
</p>
</div>
<span className="text-xs tabular-nums text-muted-foreground">
{entry.configuredSeconds != null ? formatClock(entry.configuredSeconds) : ''} planned
{' · '}
{formatClock(entry.elapsedSeconds ?? 0)} actual
</span>
{entry.overranSeconds > 0 ? (
<Badge variant="destructive" className="shrink-0 tabular-nums">
+{formatClock(entry.overranSeconds).replace('+', '')} over
</Badge>
) : (
<Badge variant="secondary" className="shrink-0">
on time
</Badge>
)}
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -8,14 +8,15 @@ import { FileText, Video, CheckCircle2, Circle, Clock, Upload } from 'lucide-rea
export function FinalDocumentsBanner() { export function FinalDocumentsBanner() {
const { data: status } = trpc.applicant.getFinalDocumentStatus.useQuery() const { data: status } = trpc.applicant.getFinalDocumentStatus.useQuery()
if (!status) return null if (!status || status.requirements.length === 0) return null
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' }) const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
const zone = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' }) const zone = new Intl.DateTimeFormat(undefined, { timeZoneName: 'short' })
.formatToParts(new Date()).find((p) => p.type === 'timeZoneName')?.value .formatToParts(new Date()).find((p) => p.type === 'timeZoneName')?.value
const uploadedCount = status.requirements.filter((r) => r.uploaded).length const uploadedCount = status.requirements.filter((r) => r.uploaded).length
const total = status.requirements.length const total = status.requirements.length
const done = status.allRequiredUploaded const optionalMode = !status.hasRequired
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
return ( return (
<Card className={done ? 'border-emerald-200 bg-emerald-50/50' : 'border-brand-blue/30 bg-brand-blue/5'}> <Card className={done ? 'border-emerald-200 bg-emerald-50/50' : 'border-brand-blue/30 bg-brand-blue/5'}>
@@ -24,7 +25,9 @@ export function FinalDocumentsBanner() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{done ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <Upload className="h-5 w-5 text-brand-blue" />} {done ? <CheckCircle2 className="h-5 w-5 text-emerald-600" /> : <Upload className="h-5 w-5 text-brand-blue" />}
<span className="font-semibold"> <span className="font-semibold">
{done ? 'Grand Final documents submitted' : 'Upload your Grand Final documents'} {done
? optionalMode ? 'Grand Final documents uploaded' : 'Grand Final documents submitted'
: optionalMode ? 'Upload updated Grand Final documents (optional)' : 'Upload your Grand Final documents'}
</span> </span>
<span className="text-sm text-muted-foreground">({uploadedCount} of {total})</span> <span className="text-sm text-muted-foreground">({uploadedCount} of {total})</span>
</div> </div>

View File

@@ -18,7 +18,8 @@ export function FinalDocumentsPanel(props: Props) {
{ enabled: props.variant === 'mentor' }, { enabled: props.variant === 'mentor' },
) )
const status = props.variant === 'team' ? teamQuery.data : mentorQuery.data const status = props.variant === 'team' ? teamQuery.data : mentorQuery.data
if (!status) return null if (!status || status.requirements.length === 0) return null
const done = status.hasRequired ? status.allRequiredUploaded : status.allUploaded
const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' }) const fmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' })
return ( return (
@@ -26,8 +27,8 @@ export function FinalDocumentsPanel(props: Props) {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between flex-wrap gap-2"> <div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="flex items-center gap-2 text-lg"><FileCheck2 className="h-5 w-5" /> Final Documents</CardTitle> <CardTitle className="flex items-center gap-2 text-lg"><FileCheck2 className="h-5 w-5" /> Final Documents</CardTitle>
{status.allRequiredUploaded {done
? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">Submitted</Badge> ? <Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">{status.hasRequired ? 'Submitted' : 'Uploaded'}</Badge>
: status.deadline && ( : status.deadline && (
<span className={`flex items-center gap-1.5 text-sm ${status.deadlinePassed ? 'text-destructive' : 'text-muted-foreground'}`}> <span className={`flex items-center gap-1.5 text-sm ${status.deadlinePassed ? 'text-destructive' : 'text-muted-foreground'}`}>
<Clock className="h-4 w-4" /> Due {fmt.format(new Date(status.deadline))} <Clock className="h-4 w-4" /> Due {fmt.format(new Date(status.deadline))}
@@ -36,6 +37,7 @@ export function FinalDocumentsPanel(props: Props) {
</div> </div>
<CardDescription> <CardDescription>
{props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'} {props.variant === 'team' ? 'Your final deliverables for the Grand Finale.' : 'This team\'s final deliverables for the Grand Finale.'}
{!status.hasRequired && ' These uploads are optional.'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
@@ -48,7 +50,7 @@ export function FinalDocumentsPanel(props: Props) {
<span className="text-xs text-muted-foreground truncate max-w-[50%]">{r.file?.fileName ?? 'Not yet uploaded'}</span> <span className="text-xs text-muted-foreground truncate max-w-[50%]">{r.file?.fileName ?? 'Not yet uploaded'}</span>
</div> </div>
))} ))}
{props.variant === 'team' && !status.allRequiredUploaded && ( {props.variant === 'team' && !done && (
<Button asChild size="sm" className="mt-2 bg-brand-blue hover:bg-brand-blue-light"> <Button asChild size="sm" className="mt-2 bg-brand-blue hover:bg-brand-blue-light">
<Link href="/applicant/documents"><Upload className="mr-2 h-4 w-4" /> Upload documents</Link> <Link href="/applicant/documents"><Upload className="mr-2 h-4 w-4" /> Upload documents</Link>
</Button> </Button>

View File

@@ -1,11 +1,12 @@
'use client' 'use client'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -16,7 +17,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2, Pencil } from 'lucide-react'
interface LiveVotingCriterion { interface LiveVotingCriterion {
id: string id: string
@@ -30,12 +31,19 @@ interface LiveVotingFormProps {
projectId: string projectId: string
votingMode?: 'simple' | 'criteria' votingMode?: 'simple' | 'criteria'
criteria?: LiveVotingCriterion[] criteria?: LiveVotingCriterion[]
onVoteSubmit: (vote: { score: number; criterionScores?: Record<string, number> }) => void onVoteSubmit: (vote: {
score: number
criterionScores?: Record<string, number>
comment?: string
}) => void
disabled?: boolean disabled?: boolean
existingVote?: { existingVote?: {
score: number score: number
criterionScoresJson?: Record<string, number> criterionScoresJson?: Record<string, number>
comment?: string | null
} | null } | null
/** Visual emphasis when the admin opens the scoring phase */
highlighted?: boolean
} }
export function LiveVotingForm({ export function LiveVotingForm({
@@ -45,73 +53,115 @@ export function LiveVotingForm({
onVoteSubmit, onVoteSubmit,
disabled = false, disabled = false,
existingVote, existingVote,
highlighted = false,
}: LiveVotingFormProps) { }: LiveVotingFormProps) {
const [score, setScore] = useState(existingVote?.score ?? 50) const [score, setScore] = useState(existingVote?.score ?? 5)
const [criterionScores, setCriterionScores] = useState<Record<string, number>>( const [criterionScores, setCriterionScores] = useState<Record<string, number>>(
existingVote?.criterionScoresJson ?? {} existingVote?.criterionScoresJson ?? {}
) )
const [comment, setComment] = useState(existingVote?.comment ?? '')
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
const [hasSubmitted, setHasSubmitted] = useState(!!existingVote) const [editing, setEditing] = useState(!existingVote)
const handleSubmit = () => { // When the ceremony cursor moves to a new project, reset the form state
setConfirmDialogOpen(true) useEffect(() => {
} setScore(existingVote?.score ?? 5)
setCriterionScores(existingVote?.criterionScoresJson ?? {})
setComment(existingVote?.comment ?? '')
setEditing(!existingVote)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId])
const handleConfirm = () => { const handleConfirm = () => {
if (votingMode === 'criteria' && criteria) { if (votingMode === 'criteria' && criteria) {
// Compute weighted score for display // The server recomputes the weighted score from criterionScores; this
// 1-10 value is only a fallback and MUST stay within the API's range.
let weightedSum = 0 let weightedSum = 0
for (const c of criteria) { for (const c of criteria) {
const normalizedScore = (criterionScores[c.id] / c.scale) * 10 const normalizedScore = ((criterionScores[c.id] ?? 0) / c.scale) * 10
weightedSum += normalizedScore * c.weight weightedSum += normalizedScore * c.weight
} }
const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum))) * 10 // Scale to 100 for display const computedScore = Math.round(Math.min(10, Math.max(1, weightedSum)))
onVoteSubmit({ onVoteSubmit({
score: computedScore, score: computedScore,
criterionScores, criterionScores,
comment: comment.trim() || undefined,
}) })
} else { } else {
onVoteSubmit({ score }) onVoteSubmit({ score, comment: comment.trim() || undefined })
} }
// NOTE: editing is NOT flipped here — the parent re-keys this component
setHasSubmitted(true) // when the persisted vote actually lands, so a failed mutation leaves the
// form editable instead of lying with a "submitted" state.
setConfirmDialogOpen(false) setConfirmDialogOpen(false)
} }
if (hasSubmitted || disabled) { if (!editing) {
return ( return (
<Card> <Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-10">
<CheckCircle2 className="mb-4 h-12 w-12 text-green-600" /> <CheckCircle2 className="mb-3 h-12 w-12 text-green-600" />
<p className="font-medium">Vote Submitted</p> <p className="font-medium">Vote Submitted</p>
{votingMode === 'simple' && ( {votingMode === 'criteria' && criteria ? (
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/100</p>
)}
{votingMode === 'criteria' && criteria && (
<div className="mt-3 text-sm text-muted-foreground space-y-1"> <div className="mt-3 text-sm text-muted-foreground space-y-1">
{criteria.map((c) => ( {criteria.map((c) => (
<div key={c.id} className="flex justify-between gap-4"> <div key={c.id} className="flex justify-between gap-6">
<span>{c.label}:</span> <span>{c.label}:</span>
<span className="font-medium">{criterionScores[c.id] ?? 0}/{c.scale}</span> <span className="font-medium">
{criterionScores[c.id] ?? 0}/{c.scale}
</span>
</div> </div>
))} ))}
</div> </div>
) : (
<p className="mt-1 text-sm text-muted-foreground">Score: {score}/10</p>
)} )}
{comment.trim() && (
<p className="mt-3 max-w-md text-center text-xs italic text-muted-foreground">
{comment.trim()}
</p>
)}
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setEditing(true)}
disabled={disabled}
>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit vote
</Button>
<p className="mt-2 text-xs text-muted-foreground">
You can revise your vote until the session closes
</p>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
const commentField = (
<div className="space-y-2">
<Label className="text-sm">Comment (optional)</Label>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Visible to admins alongside your scores…"
rows={2}
/>
</div>
)
// Criteria-based voting // Criteria-based voting
if (votingMode === 'criteria' && criteria && criteria.length > 0) { if (votingMode === 'criteria' && criteria && criteria.length > 0) {
const allScored = criteria.every((c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0) const allScored = criteria.every(
(c) => criterionScores[c.id] !== undefined && criterionScores[c.id] > 0
)
return ( return (
<> <>
<Card> <Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardHeader> <CardHeader>
<CardTitle>Criteria-Based Voting</CardTitle> <CardTitle>Score This Project</CardTitle>
<CardDescription>Score each criterion individually</CardDescription> <CardDescription>Score each criterion individually</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@@ -153,7 +203,7 @@ export function LiveVotingForm({
onValueChange={(values) => onValueChange={(values) =>
setCriterionScores({ setCriterionScores({
...criterionScores, ...criterionScores,
[criterion.id]: values[0], [criterion.id]: Math.max(1, values[0]),
}) })
} }
min={0} min={0}
@@ -164,13 +214,15 @@ export function LiveVotingForm({
</div> </div>
))} ))}
{commentField}
<Button <Button
onClick={handleSubmit} onClick={() => setConfirmDialogOpen(true)}
disabled={!allScored} disabled={!allScored || disabled}
className="w-full" className="w-full"
size="lg" size="lg"
> >
Submit Vote {existingVote ? 'Update Vote' : 'Submit Vote'}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -179,17 +231,19 @@ export function LiveVotingForm({
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle> <AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription asChild>
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
<p className="font-medium">Your scores:</p> <p className="font-medium">Your scores:</p>
{criteria.map((c) => ( {criteria.map((c) => (
<div key={c.id} className="flex justify-between text-sm"> <div key={c.id} className="flex justify-between text-sm">
<span>{c.label}:</span> <span>{c.label}:</span>
<span className="font-semibold">{criterionScores[c.id]}/{c.scale}</span> <span className="font-semibold">
{criterionScores[c.id]}/{c.scale}
</span>
</div> </div>
))} ))}
<p className="text-xs text-muted-foreground mt-3"> <p className="text-xs text-muted-foreground mt-3">
This action cannot be undone. Are you sure? You can still revise your vote until the session closes.
</p> </p>
</div> </div>
</AlertDialogDescription> </AlertDialogDescription>
@@ -204,13 +258,13 @@ export function LiveVotingForm({
) )
} }
// Simple voting (0-100 slider) // Simple voting (1-10 slider — matches the API contract)
return ( return (
<> <>
<Card> <Card className={highlighted ? 'ring-2 ring-[#de0f1e]' : undefined}>
<CardHeader> <CardHeader>
<CardTitle>Live Voting</CardTitle> <CardTitle>Score This Project</CardTitle>
<CardDescription>Rate this project on a scale of 0-100</CardDescription> <CardDescription>Rate this project on a scale of 1-10</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-4"> <div className="space-y-4">
@@ -219,10 +273,12 @@ export function LiveVotingForm({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Input <Input
type="number" type="number"
min="0" min="1"
max="100" max="10"
value={score} value={score}
onChange={(e) => setScore(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))} onChange={(e) =>
setScore(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))
}
className="w-20 text-center" className="w-20 text-center"
/> />
<span className="text-2xl font-bold text-primary">{score}</span> <span className="text-2xl font-bold text-primary">{score}</span>
@@ -231,34 +287,35 @@ export function LiveVotingForm({
<Slider <Slider
value={[score]} value={[score]}
onValueChange={(values) => setScore(values[0])} onValueChange={(values) => setScore(Math.max(1, values[0]))}
min={0} min={1}
max={100} max={10}
step={1} step={1}
className="w-full" className="w-full"
/> />
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span>Poor (0)</span> <span>Poor (1)</span>
<span>Average (50)</span> <span>Average (5)</span>
<span>Excellent (100)</span> <span>Excellent (10)</span>
</div> </div>
</div> </div>
<Button onClick={handleSubmit} className="w-full" size="lg"> {commentField}
Submit Vote
<Button onClick={() => setConfirmDialogOpen(true)} className="w-full" size="lg" disabled={disabled}>
{existingVote ? 'Update Vote' : 'Submit Vote'}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
{/* Confirmation Dialog */}
<AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}> <AlertDialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Your Vote</AlertDialogTitle> <AlertDialogTitle>Confirm Your Vote</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
You are about to submit a score of <strong>{score}/100</strong>. This action cannot You are about to submit a score of <strong>{score}/10</strong>. You can still revise
be undone. Are you sure? it until the session closes.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { BookOpen, Home, Trophy, ClipboardList, FileText } from 'lucide-react' import { BookOpen, Home, Trophy, ClipboardList, FileText, Radio, Scale } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav' import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -47,6 +47,14 @@ export function JuryNav({ user }: JuryNavProps) {
{ refetchInterval: 60000 } { refetchInterval: 60000 }
) )
const { data: flags } = trpc.settings.getFeatureFlags.useQuery() const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
// Only Grand-Final jury members (and admins) can open the finalist documents
// review — hide the link from everyone else so they don't hit a dead "No access" page.
const { data: canReviewFinals } = trpc.finalist.canReviewDocuments.useQuery()
// Ceremony-day links: live scoring page while a ceremony cursor is active,
// plus any deliberation sessions the juror participates in.
const { data: ceremony } = trpc.live.getMyCeremonyContext.useQuery(undefined, {
refetchInterval: 30000,
})
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
@@ -61,11 +69,29 @@ export function JuryNav({ user }: JuryNavProps) {
href: '/jury/competitions', href: '/jury/competitions',
icon: ClipboardList, icon: ClipboardList,
}, },
{ ...(canReviewFinals
name: 'Finalist Documents', ? [
href: '/jury/finals-documents', {
icon: FileText, name: 'Finalist Documents',
}, href: '/jury/finals-documents',
icon: FileText,
},
]
: []),
...(ceremony?.liveRoundId
? [
{
name: 'Live Ceremony',
href: `/jury/competitions/${ceremony.liveRoundId}/live`,
icon: Radio,
},
]
: []),
...(ceremony?.deliberations ?? []).map((d) => ({
name: `Deliberation — ${d.category === 'STARTUP' ? 'Startups' : 'Business Concepts'}`,
href: `/jury/competitions/deliberation/${d.id}`,
icon: Scale,
})),
...(myAwards && myAwards.length > 0 ...(myAwards && myAwards.length > 0
? [ ? [
{ {

View File

@@ -59,6 +59,9 @@ export const authConfig: NextAuthConfig = {
'/reset-password', '/reset-password',
'/apply', '/apply',
'/lunch/pick', // external attendees pick a dish via signed token (no account) '/lunch/pick', // external attendees pick a dish via signed token (no account)
'/vote', // audience QR voting at the grand finale (token-based, no account)
'/live-scores', // public live scoreboard
'/live/ceremony', // big-screen ceremony view (projector, no account)
'/api/auth', '/api/auth',
'/api/trpc', // tRPC handles its own auth via procedures '/api/trpc', // tRPC handles its own auth via procedures
'/api/cron', // cron endpoints self-guard via x-cron-secret (CRON_SECRET) '/api/cron', // cron endpoints self-guard via x-cron-secret (CRON_SECRET)

54
src/lib/live-timer.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* Server-stamped phase timer math for the grand-finale ceremony.
*
* The cursor stores `phaseStartedAt` + `phaseDurationSeconds` plus a pause
* accumulator; every client derives the countdown locally from those stamps,
* so all screens agree and overtime is just a negative remainder.
*/
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)
}
/** Seconds left on the phase timer; negative = overtime; null = no timer running. */
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)
}
/** `5:05` for positive seconds, `+1:23` for overtime. */
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')}`
}
/**
* Parse an admin duration input: `m:ss` / `mm:ss`, or plain minutes (`7`).
* Returns total seconds, or null for anything unparseable.
*/
export function parseClock(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) return null
const colonMatch = /^(\d{1,3}):([0-5]\d)$/.exec(trimmed)
if (colonMatch) {
return parseInt(colonMatch[1], 10) * 60 + parseInt(colonMatch[2], 10)
}
const plainMatch = /^(\d{1,3})$/.exec(trimmed)
if (plainMatch) {
return parseInt(plainMatch[1], 10) * 60
}
return null
}

View File

@@ -46,6 +46,39 @@ export function checkRateLimit(
return { success: true, remaining: limit - entry.count, resetAt: entry.resetAt } return { success: true, remaining: limit - entry.count, resetAt: entry.resetAt }
} }
/**
* Ceremony-day procedures: cheap, read-mostly public endpoints polled by every
* screen in the venue. Hundreds of phones share one IP behind the venue NAT,
* so the standard 100/min-per-IP budget would 429 the audience mid-vote.
* Requests consisting ONLY of these procedures get a separate, much larger
* per-IP bucket. Vote integrity is enforced server-side (token + IP cap on
* AudienceFavoriteVote), not by the rate limiter.
*/
const CEREMONY_PROCEDURES = new Set([
'liveVoting.getCeremonyState',
'liveVoting.getAudienceWindow',
'liveVoting.getAudienceContextByRound',
'liveVoting.registerAudienceVoter',
'liveVoting.castFavoriteVote',
'liveVoting.getSessionForVotingByRound',
'liveVoting.getPublicResults',
'liveVoting.getAudienceSession',
'liveVoting.getSessionForVoting',
'live.getCursor',
'live.getMyNotes',
'live.saveNote',
'live.getMyCeremonyContext',
])
/** True when every (possibly batched) procedure in the tRPC URL is ceremony traffic. */
export function isCeremonyTraffic(pathname: string): boolean {
const path = pathname.replace(/^\/api\/trpc\/?/, '')
if (!path) return false
return path
.split(',')
.every((p) => CEREMONY_PROCEDURES.has(decodeURIComponent(p)))
}
// Clean up stale entries every 5 minutes to prevent memory leaks // Clean up stale entries every 5 minutes to prevent memory leaks
if (typeof setInterval !== 'undefined') { if (typeof setInterval !== 'undefined') {
setInterval(() => { setInterval(() => {

View File

@@ -110,7 +110,6 @@ export const deliberationRouter = router({
.input( .input(
z.object({ z.object({
sessionId: z.string(), sessionId: z.string(),
juryMemberId: z.string(),
projectId: z.string(), projectId: z.string(),
rank: z.number().int().min(1).optional(), rank: z.number().int().min(1).optional(),
isWinnerPick: z.boolean().optional(), isWinnerPick: z.boolean().optional(),
@@ -118,15 +117,26 @@ export const deliberationRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Enforce that jury members can only vote as themselves // Resolve the caller's participant row server-side: DeliberationParticipant
if (input.juryMemberId !== ctx.user.id) { // and DeliberationVote both reference JuryGroupMember ids, which the
// client has no business knowing. A juror can only ever vote as themself.
const participant = await ctx.prisma.deliberationParticipant.findFirst({
where: {
sessionId: input.sessionId,
user: { userId: ctx.user.id },
},
})
if (!participant) {
throw new TRPCError({ throw new TRPCError({
code: 'FORBIDDEN', code: 'FORBIDDEN',
message: 'You can only submit votes as yourself', message: 'You are not a participant in this deliberation',
}) })
} }
const vote = await submitVote(input, ctx.prisma) const vote = await submitVote(
{ ...input, juryMemberId: participant.userId },
ctx.prisma
)
await logAudit({ await logAudit({
prisma: ctx.prisma, prisma: ctx.prisma,
@@ -310,6 +320,10 @@ export const deliberationRouter = router({
participants: { participants: {
select: { userId: true }, select: { userId: true },
}, },
results: {
select: { projectId: true, finalRank: true, isAdminOverridden: true },
orderBy: { finalRank: 'asc' },
},
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })

View File

@@ -19,7 +19,7 @@ import {
resetOrCreatePendingConfirmation, resetOrCreatePendingConfirmation,
confirmAttendanceInTx, confirmAttendanceInTx,
} from '../services/finalist-enrollment' } from '../services/finalist-enrollment'
import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals } from '../services/final-documents' import { sendManualFinalDocReminders, listFinalistDocumentsForReview, userCanReviewFinals, listReviewVisibilityOptions, reviewVisibleRequirementIds } from '../services/final-documents'
export const finalistRouter = router({ export const finalistRouter = router({
/** List all per-category finalist slot quotas for a program. */ /** List all per-category finalist slot quotas for a program. */
@@ -1683,6 +1683,17 @@ export const finalistRouter = router({
}), }),
/** Read-only review of all finalists' grand-final documents (admins + finale jury). */ /** Read-only review of all finalists' grand-final documents (admins + finale jury). */
/** Lightweight boolean — may the current user open the finalist documents review? Self-resolves the active program so the nav can gate the link without fetching the full payload. */
canReviewDocuments: protectedProcedure.query(async ({ ctx }) => {
const program = await ctx.prisma.program.findFirst({
where: { status: 'ACTIVE' },
orderBy: { year: 'desc' },
select: { id: true },
})
if (!program) return false
return userCanReviewFinals(ctx.prisma, ctx.user.id, ctx.user.role, program.id)
}),
listReviewDocuments: protectedProcedure listReviewDocuments: protectedProcedure
.input(z.object({ programId: z.string() })) .input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -1723,4 +1734,37 @@ export const finalistRouter = router({
}) })
return { ok: true, enabled: input.enabled } return { ok: true, enabled: input.enabled }
}), }),
/** Options + current selection for the "documents shown to judges" picker. */
getReviewDocSettings: adminProcedure
.input(z.object({ programId: z.string(), roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true } })
return {
options: await listReviewVisibilityOptions(ctx.prisma, input.programId),
selectedIds: reviewVisibleRequirementIds(round?.configJson ?? null),
}
}),
/** Set which prior-round documents finale judges see. null = show all (clears curation). */
setReviewVisibleRequirements: adminProcedure
.input(z.object({ roundId: z.string(), requirementIds: z.array(z.string()).nullable() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUnique({ where: { id: input.roundId }, select: { configJson: true, roundType: true } })
if (!round || round.roundType !== 'LIVE_FINAL') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a grand-final round' })
}
const { reviewVisibleRequirementIds: _omit, ...rest } = (round.configJson ?? {}) as Record<string, unknown>
const next = input.requirementIds === null ? rest : { ...rest, reviewVisibleRequirementIds: input.requirementIds }
await ctx.prisma.round.update({ where: { id: input.roundId }, data: { configJson: next as object } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FINALIST_REVIEW_DOCS_CURATED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { requirementIds: input.requirementIds },
})
return { ok: true }
}),
}) })

View File

@@ -1,6 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import type { PrismaClient } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit' import { logAudit } from '../utils/audit'
interface LiveVotingCriterion { interface LiveVotingCriterion {
@@ -11,6 +12,114 @@ interface LiveVotingCriterion {
weight: number weight: number
} }
// ─── Grand-finale audience favorite-vote windows ─────────────────────────────
const windowKeySchema = z.enum(['CATEGORY:STARTUP', 'CATEGORY:BUSINESS_CONCEPT', 'OVERALL'])
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(),
subtitle: z.string().max(300).optional(),
})
const MAX_FAVORITE_VOTERS_PER_IP = 3
/** Server-side window check — the source of truth even if no one closed the window. */
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 {
if (key === 'CATEGORY:STARTUP') return 'STARTUP'
if (key === 'CATEGORY:BUSINESS_CONCEPT') return 'BUSINESS_CONCEPT'
return null
}
/**
* Finale project order: the cursor system's round.configJson.projectOrder is
* the source of truth; session.projectOrderJson is the fallback.
*/
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[]) ?? []
if (order.length === 0) return []
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((p): p is NonNullable<typeof p> => !!p)
}
/** Shared jury-voting payload for getSessionForVoting / getSessionForVotingByRound. */
async function buildVotingPayload(
prisma: PrismaClient,
session: {
id: string
status: string
currentProjectId: string | null
votingStartedAt: Date | null
votingEndsAt: Date | null
votingMode: string
criteriaJson: unknown
round: unknown
},
userId: string
) {
let currentProject = null
if (session.currentProjectId && session.status === 'IN_PROGRESS') {
currentProject = await prisma.project.findUnique({
where: { id: session.currentProjectId },
select: { id: true, title: true, teamName: true, description: true },
})
}
let userVote = null
if (session.currentProjectId) {
userVote = await prisma.liveVote.findFirst({
where: {
sessionId: session.id,
projectId: session.currentProjectId,
userId,
},
})
}
let timeRemaining = null
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
timeRemaining = Math.max(0, Math.floor(remaining / 1000))
}
return {
session: {
id: session.id,
status: session.status,
votingStartedAt: session.votingStartedAt,
votingEndsAt: session.votingEndsAt,
votingMode: session.votingMode,
criteriaJson: session.criteriaJson,
},
round: session.round,
currentProject,
userVote,
timeRemaining,
}
}
export const liveVotingRouter = router({ export const liveVotingRouter = router({
/** /**
* Get or create a live voting session for a round * Get or create a live voting session for a round
@@ -97,46 +206,63 @@ export const liveVotingRouter = router({
}, },
}, },
}) })
return buildVotingPayload(ctx.prisma, session, ctx.user.id)
}),
let currentProject = null /**
if (session.currentProjectId && session.status === 'IN_PROGRESS') { * Same payload, resolved from a roundId (the jury live page only knows the
currentProject = await ctx.prisma.project.findUnique({ * round). Returns null — and creates nothing — when no session exists yet.
where: { id: session.currentProjectId }, */
select: { id: true, title: true, teamName: true, description: true }, getSessionForVotingByRound: protectedProcedure
}) .input(z.object({ roundId: z.string() }))
} .query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUnique({
let userVote = null where: { roundId: input.roundId },
if (session.currentProjectId) { include: {
userVote = await ctx.prisma.liveVote.findFirst({ round: {
where: { include: {
sessionId: session.id, competition: {
projectId: session.currentProjectId, include: {
userId: ctx.user.id, program: { select: { name: true, year: true } },
},
},
},
}, },
})
}
let timeRemaining = null
if (session.votingEndsAt && session.status === 'IN_PROGRESS') {
const remaining = new Date(session.votingEndsAt).getTime() - Date.now()
timeRemaining = Math.max(0, Math.floor(remaining / 1000))
}
return {
session: {
id: session.id,
status: session.status,
votingStartedAt: session.votingStartedAt,
votingEndsAt: session.votingEndsAt,
votingMode: session.votingMode,
criteriaJson: session.criteriaJson,
}, },
round: session.round, })
currentProject, if (!session) return null
userVote, return buildVotingPayload(ctx.prisma, session, ctx.user.id)
timeRemaining, }),
}
/**
* A juror's own finale inputs (votes incl. comments + ceremony notes) for a
* round — resurfaced during deliberation so they can review or revise.
*/
getMyFinaleInputs: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
select: { id: true, status: true, votingMode: true, criteriaJson: true },
})
const [votes, notes] = await Promise.all([
session
? ctx.prisma.liveVote.findMany({
where: { sessionId: session.id, userId: ctx.user.id },
select: {
projectId: true,
score: true,
criterionScoresJson: true,
comment: true,
votedAt: true,
},
})
: Promise.resolve([]),
ctx.prisma.liveNote.findMany({
where: { roundId: input.roundId, userId: ctx.user.id },
}),
])
return { session: session ?? null, votes, notes }
}), }),
/** /**
@@ -447,6 +573,7 @@ export const liveVotingRouter = router({
criterionScores: z criterionScores: z
.record(z.string(), z.number()) .record(z.string(), z.number())
.optional(), .optional(),
comment: z.string().max(5000).optional(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -489,22 +616,29 @@ export const liveVotingRouter = router({
} }
} }
if (session.status !== 'IN_PROGRESS') { if (session.status !== 'IN_PROGRESS' && session.status !== 'PAUSED') {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Voting is not currently active', message: 'Voting is not currently active',
}) })
} }
if (session.currentProjectId !== input.projectId) { const isCurrentProject = session.currentProjectId === input.projectId
throw new TRPCError({ if (!isCurrentProject) {
code: 'BAD_REQUEST', // Revision path (deliberation / catching up): any project in the
message: 'Cannot vote for this project right now', // finale run order may be (re)scored while the session is open.
}) const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
if (!ordered.some((p) => p.id === input.projectId)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot vote for this project right now',
})
}
} }
// Check if voting window is still open // The timed voting window only applies to the live flow for the
if (session.votingEndsAt && new Date() > session.votingEndsAt) { // currently presented project
if (isCurrentProject && session.votingEndsAt && new Date() > session.votingEndsAt) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Voting window has closed', message: 'Voting window has closed',
@@ -568,11 +702,14 @@ export const liveVotingRouter = router({
score: finalScore, score: finalScore,
isAudienceVote: false, isAudienceVote: false,
criterionScoresJson: criterionScoresJson ?? undefined, criterionScoresJson: criterionScoresJson ?? undefined,
comment: input.comment ?? undefined,
}, },
update: { update: {
score: finalScore, score: finalScore,
isAudienceVote: false, isAudienceVote: false,
criterionScoresJson: criterionScoresJson ?? undefined, criterionScoresJson: criterionScoresJson ?? undefined,
// An omitted comment leaves the existing one untouched
...(input.comment !== undefined ? { comment: input.comment } : {}),
votedAt: new Date(), votedAt: new Date(),
}, },
}) })
@@ -759,6 +896,7 @@ export const liveVotingRouter = router({
audienceMaxFavorites: z.number().int().min(1).max(20).optional(), audienceMaxFavorites: z.number().int().min(1).max(20).optional(),
audienceRequireId: z.boolean().optional(), audienceRequireId: z.boolean().optional(),
audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(), audienceVotingDuration: z.number().int().min(1).max(600).nullable().optional(),
allowOverallFavorite: z.boolean().optional(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -917,6 +1055,506 @@ export const liveVotingRouter = router({
return vote return vote
}), }),
// ───────────────────────────────────────────────────────────────────────────
// Grand-finale audience favorite-vote windows
// ───────────────────────────────────────────────────────────────────────────
/**
* Open an audience voting window ("favorite Startup", "favorite Business
* Concept", or — if enabled — "overall favorite") for a fixed duration.
*/
openAudienceWindow: adminProcedure
.input(
z.object({
sessionId: z.string(),
windowKey: windowKeySchema,
durationMinutes: z.number().int().min(1).max(120).default(5),
})
)
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
if (windowIsOpen(session)) {
throw new TRPCError({
code: 'CONFLICT',
message: 'An audience voting window is already open — close it first',
})
}
if (input.windowKey === 'OVERALL' && !session.allowOverallFavorite) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'The overall favorite vote is not enabled for this session',
})
}
const now = new Date()
const updated = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
audiencePhase: 'OPEN',
audienceWindowKey: input.windowKey,
audienceWindowOpenedAt: now,
audienceWindowClosesAt: new Date(now.getTime() + input.durationMinutes * 60_000),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'AUDIENCE_WINDOW_OPENED',
entityType: 'LiveVotingSession',
entityId: input.sessionId,
detailsJson: { windowKey: input.windowKey, durationMinutes: input.durationMinutes },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Close the audience voting window early (allowed at any time).
*/
closeAudienceWindow: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
const updated = await ctx.prisma.liveVotingSession.update({
where: { id: input.sessionId },
data: {
audiencePhase: 'CLOSED',
audienceWindowKey: null,
audienceWindowOpenedAt: null,
audienceWindowClosesAt: null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'AUDIENCE_WINDOW_CLOSED',
entityType: 'LiveVotingSession',
entityId: input.sessionId,
detailsJson: { windowKey: session.audienceWindowKey },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Public window state for the audience voting page: open/closed, eligible
* projects (in run order), closing time, and the caller's current pick.
*/
getAudienceWindow: publicProcedure
.input(z.object({ sessionId: z.string(), token: z.string().optional() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
select: {
id: true,
roundId: true,
projectOrderJson: true,
audiencePhase: true,
audienceWindowKey: true,
audienceWindowClosesAt: true,
allowAudienceVotes: true,
},
})
const open = windowIsOpen(session)
const windowKey = open ? session.audienceWindowKey : null
let projects: Awaited<ReturnType<typeof getOrderedFinaleProjects>> = []
if (open && windowKey) {
const cat = categoryForKey(windowKey)
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
projects = cat ? ordered.filter((p) => p.competitionCategory === cat) : ordered
}
let myVoteProjectId: string | null = null
if (input.token && windowKey) {
const voter = await ctx.prisma.audienceVoter.findUnique({
where: { token: input.token },
})
if (voter && voter.sessionId === input.sessionId) {
const existing = await ctx.prisma.audienceFavoriteVote.findUnique({
where: {
sessionId_windowKey_audienceVoterId: {
sessionId: input.sessionId,
windowKey,
audienceVoterId: voter.id,
},
},
})
myVoteProjectId = existing?.projectId ?? null
}
}
return {
open,
windowKey,
closesAt: open ? session.audienceWindowClosesAt : null,
projects,
myVoteProjectId,
}
}),
/**
* Cast (or change) a pick-one-favorite vote in the open window.
* Gates, in order: token, window open, time, eligibility, category, IP cap.
*/
castFavoriteVote: publicProcedure
.input(z.object({ sessionId: z.string(), token: z.string(), projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const voter = await ctx.prisma.audienceVoter.findUnique({
where: { token: input.token },
})
if (!voter || voter.sessionId !== input.sessionId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid voting token' })
}
const session = await ctx.prisma.liveVotingSession.findUniqueOrThrow({
where: { id: input.sessionId },
})
if (!windowIsOpen(session) || !session.audienceWindowKey) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Voting is not open right now',
})
}
const windowKey = session.audienceWindowKey
const ordered = await getOrderedFinaleProjects(ctx.prisma, session)
const project = ordered.find((p) => p.id === input.projectId)
if (!project) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not part of this vote' })
}
const cat = categoryForKey(windowKey)
if (cat && project.competitionCategory !== cat) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Project is not in the category currently open for voting',
})
}
const existing = await ctx.prisma.audienceFavoriteVote.findUnique({
where: {
sessionId_windowKey_audienceVoterId: {
sessionId: input.sessionId,
windowKey,
audienceVoterId: voter.id,
},
},
})
// IP cap only gates NEW voters — an existing voter may always update.
if (!existing && ctx.ip) {
const ipCount = await ctx.prisma.audienceFavoriteVote.count({
where: { sessionId: input.sessionId, windowKey, ipAddress: ctx.ip },
})
if (ipCount >= MAX_FAVORITE_VOTERS_PER_IP) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Vote limit reached for this network connection',
})
}
}
const vote = await ctx.prisma.audienceFavoriteVote.upsert({
where: {
sessionId_windowKey_audienceVoterId: {
sessionId: input.sessionId,
windowKey,
audienceVoterId: voter.id,
},
},
create: {
sessionId: input.sessionId,
windowKey,
projectId: input.projectId,
audienceVoterId: voter.id,
ipAddress: ctx.ip ?? null,
},
update: {
projectId: input.projectId,
ipAddress: ctx.ip ?? existing?.ipAddress ?? null,
},
})
return { projectId: vote.projectId, windowKey }
}),
/**
* Per-window per-project favorite-vote tallies (admin only — the big screen
* shows only the total count).
*/
getFavoriteTallies: adminProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const grouped = await ctx.prisma.audienceFavoriteVote.groupBy({
by: ['windowKey', 'projectId'],
where: { sessionId: input.sessionId },
_count: { _all: true },
})
const projectIds = [...new Set(grouped.map((g) => g.projectId))]
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, title: true, teamName: true },
})
const byId = new Map(projects.map((p) => [p.id, p]))
const windowKeys = [...new Set(grouped.map((g) => g.windowKey))]
const windows = windowKeys.map((windowKey) => {
const rows = grouped.filter((g) => g.windowKey === windowKey)
return {
windowKey,
totalVotes: rows.reduce((sum, r) => sum + r._count._all, 0),
projects: rows
.map((r) => ({
projectId: r.projectId,
title: byId.get(r.projectId)?.title ?? 'Unknown',
teamName: byId.get(r.projectId)?.teamName ?? null,
count: r._count._all,
}))
.sort((a, b) => b.count - a.count),
}
})
return { windows }
}),
/**
* Resolve the live voting session for a round (public — the audience page
* only knows the roundId from the QR code URL).
*/
getAudienceContextByRound: publicProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
select: {
id: true,
allowAudienceVotes: true,
round: {
select: {
name: true,
competition: {
select: { program: { select: { name: true, year: true } } },
},
},
},
},
})
if (!session) return null
return {
sessionId: session.id,
allowAudienceVotes: session.allowAudienceVotes,
roundName: session.round?.name ?? null,
programName: session.round?.competition?.program?.name ?? null,
}
}),
// ───────────────────────────────────────────────────────────────────────────
// Results reveal controller (big screen)
// ───────────────────────────────────────────────────────────────────────────
/**
* Save (or replace) the reveal step list as a private DRAFT.
* Display strings are denormalized into the steps at compose time so the
* public endpoint needs no joins.
*/
saveReveal: adminProcedure
.input(
z.object({
sessionId: z.string(),
steps: z.array(revealStepSchema).max(50),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.revealState.upsert({
where: { sessionId: input.sessionId },
create: {
sessionId: input.sessionId,
stepsJson: input.steps,
status: 'DRAFT',
currentStepIndex: -1,
},
update: { stepsJson: input.steps, status: 'DRAFT', currentStepIndex: -1 },
})
}),
/**
* Arm the reveal: the big screen switches to the Results splash, nothing
* is revealed yet.
*/
armReveal: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const reveal = await ctx.prisma.revealState.findUniqueOrThrow({
where: { sessionId: input.sessionId },
})
const steps = (reveal.stepsJson as unknown[]) ?? []
if (steps.length === 0) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No reveal steps composed' })
}
const updated = await ctx.prisma.revealState.update({
where: { sessionId: input.sessionId },
data: { status: 'ARMED', currentStepIndex: -1 },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REVEAL_ARMED',
entityType: 'RevealState',
entityId: updated.id,
detailsJson: { stepCount: steps.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Reveal the next step. Clamps at the final step and flips to DONE.
*/
revealNext: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const reveal = await ctx.prisma.revealState.findUniqueOrThrow({
where: { sessionId: input.sessionId },
})
if (reveal.status !== 'ARMED' && reveal.status !== 'REVEALING' && reveal.status !== 'DONE') {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Reveal is not armed' })
}
const steps = (reveal.stepsJson as unknown[]) ?? []
const newIndex = Math.min(reveal.currentStepIndex + 1, steps.length - 1)
const updated = await ctx.prisma.revealState.update({
where: { sessionId: input.sessionId },
data: {
currentStepIndex: newIndex,
status: newIndex >= steps.length - 1 ? 'DONE' : 'REVEALING',
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REVEAL_ADVANCED',
entityType: 'RevealState',
entityId: updated.id,
detailsJson: { stepIndex: newIndex },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Reset the reveal to DRAFT — the big screen leaves reveal mode.
*/
resetReveal: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const updated = await ctx.prisma.revealState.update({
where: { sessionId: input.sessionId },
data: { status: 'DRAFT', currentStepIndex: -1 },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REVEAL_RESET',
entityType: 'RevealState',
entityId: updated.id,
detailsJson: {},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/** Full reveal state incl. all steps — admin preview only. */
getRevealAdmin: adminProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.revealState.findUnique({ where: { sessionId: input.sessionId } })
}),
/**
* Everything the big-screen ceremony view needs, derived from existing
* state. Public. NEVER includes scores or un-revealed steps.
*/
getCeremonyState: publicProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const [cursor, session] = await Promise.all([
ctx.prisma.liveProgressCursor.findUnique({ where: { roundId: input.roundId } }),
ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
select: {
id: true,
audiencePhase: true,
audienceWindowKey: true,
audienceWindowClosesAt: true,
round: {
select: {
name: true,
competition: {
select: { program: { select: { name: true, year: true } } },
},
},
},
},
}),
])
let activeProject = null
if (cursor?.activeProjectId) {
activeProject = await ctx.prisma.project.findUnique({
where: { id: cursor.activeProjectId },
select: { id: true, title: true, teamName: true, competitionCategory: true },
})
}
const audienceOpen = session ? windowIsOpen(session) : false
let voteCount = 0
if (session && audienceOpen && session.audienceWindowKey) {
voteCount = await ctx.prisma.audienceFavoriteVote.count({
where: { sessionId: session.id, windowKey: session.audienceWindowKey },
})
}
let reveal: { status: string; currentStepIndex: number; steps: unknown[] } | null = null
if (session) {
const revealState = await ctx.prisma.revealState.findUnique({
where: { sessionId: session.id },
})
if (revealState && revealState.status !== 'DRAFT') {
const steps = (revealState.stepsJson as unknown[]) ?? []
reveal = {
status: revealState.status,
currentStepIndex: revealState.currentStepIndex,
// ONLY steps revealed so far — never the full list
steps:
revealState.status === 'ARMED' ? [] : steps.slice(0, revealState.currentStepIndex + 1),
}
}
}
return {
overrideSlide: cursor?.overrideSlide ?? null,
phase: cursor
? {
projectPhase: cursor.projectPhase,
phaseStartedAt: cursor.phaseStartedAt,
phaseDurationSeconds: cursor.phaseDurationSeconds,
phasePausedAt: cursor.phasePausedAt,
phasePausedAccumMs: cursor.phasePausedAccumMs,
}
: null,
activeProject,
audience: {
open: audienceOpen,
windowKey: audienceOpen ? session?.audienceWindowKey ?? null : null,
closesAt: audienceOpen ? session?.audienceWindowClosesAt ?? null : null,
voteCount,
},
reveal,
programName: session?.round?.competition?.program?.name ?? null,
roundName: session?.round?.name ?? null,
}
}),
/** /**
* Get audience voter stats (admin) * Get audience voter stats (admin)
*/ */

View File

@@ -1,9 +1,66 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client' import { Prisma, type PrismaClient, type LiveProgressCursor } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, audienceProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
// ─── Grand-finale phase machine helpers ─────────────────────────────────────
type TimingEntry = {
projectId: string
phase: 'PRESENTING' | 'QA'
startedAt: string
endedAt: string
configuredSeconds: number | null
elapsedSeconds: number // pause-adjusted actual duration
overranSeconds: number
}
/**
* When leaving a timed phase (PRESENTING/QA), compute the actual elapsed time
* (pause-adjusted) and append it to the cursor's timing log. Overtime is
* recorded as fact — never as a penalty.
*/
function closedOutTiming(cursor: LiveProgressCursor, 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,
elapsedSeconds: elapsedSec,
overranSeconds:
cursor.phaseDurationSeconds == null
? 0
: Math.max(0, elapsedSec - cursor.phaseDurationSeconds),
}
const log = Array.isArray(cursor.timingLogJson) ? (cursor.timingLogJson as unknown as TimingEntry[]) : []
return [...log, entry] as unknown as Prisma.InputJsonValue
}
type ProjectTimingOverride = { presentationSeconds?: number; qaSeconds?: number }
async function getRoundCeremonyConfig(prisma: PrismaClient, roundId: string) {
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId } })
const cfg = (round.configJson as Record<string, unknown>) ?? {}
return {
presentationSeconds:
typeof cfg.presentationDurationMinutes === 'number'
? cfg.presentationDurationMinutes * 60
: 300,
qaSeconds: typeof cfg.qaDurationMinutes === 'number' ? cfg.qaDurationMinutes * 60 : 300,
projectOrder: (cfg.projectOrder as string[]) ?? [],
timingOverrides: (cfg.projectTimingOverrides as Record<string, ProjectTimingOverride>) ?? {},
}
}
export const liveRouter = router({ export const liveRouter = router({
/** /**
* Start a live presentation session for a stage * Start a live presentation session for a stage
@@ -72,6 +129,13 @@ export const liveRouter = router({
}, },
}) })
// Keep the voting session in lockstep from the very start so jury
// votes target the active project (a session may not exist yet).
await tx.liveVotingSession.updateMany({
where: { roundId: input.roundId },
data: { currentProjectId: input.projectOrder[0], status: 'IN_PROGRESS' },
})
return created return created
}) })
@@ -344,6 +408,410 @@ export const liveRouter = router({
return updated return updated
}), }),
// ───────────────────────────────────────────────────────────────────────────
// Grand-finale phase machine (ON_DECK → PRESENTING → QA → SCORING)
// ───────────────────────────────────────────────────────────────────────────
/**
* Put a project on every screen as "Up next" — the grace period before the
* presentation actually starts. Clears any timer and override slide.
*/
sendToScreens: adminProcedure
.input(z.object({ roundId: z.string(), projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
const { projectOrder } = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
const index = projectOrder.indexOf(input.projectId)
if (index === -1) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Project is not in the session order' })
}
const now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
activeProjectId: input.projectId,
activeOrderIndex: index,
projectPhase: 'ON_DECK',
phaseStartedAt: null,
phaseDurationSeconds: null,
phasePausedAt: null,
phasePausedAccumMs: 0,
overrideSlide: null,
...(closedOutTiming(cursor, now) !== undefined
? { timingLogJson: closedOutTiming(cursor, now) }
: {}),
},
})
// Keep the voting session in lockstep so jury votes target this project
// (updateMany: a session may not exist yet — that's fine).
await ctx.prisma.liveVotingSession.updateMany({
where: { roundId: input.roundId },
data: { currentProjectId: input.projectId, status: 'IN_PROGRESS' },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_SEND_TO_SCREENS',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { projectId: input.projectId, orderIndex: index },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Start the presentation timer for the on-deck project.
*/
startPresentation: adminProcedure
.input(
z.object({
roundId: z.string(),
durationSeconds: z.number().int().min(10).max(7200).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (!cursor.activeProjectId) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No project is on screen' })
}
const cfg = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
const projectOverride = cfg.timingOverrides[cursor.activeProjectId]
const now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
projectPhase: 'PRESENTING',
phaseStartedAt: now,
phaseDurationSeconds:
input.durationSeconds ?? projectOverride?.presentationSeconds ?? cfg.presentationSeconds,
phasePausedAt: null,
phasePausedAccumMs: 0,
...(closedOutTiming(cursor, now) !== undefined
? { timingLogJson: closedOutTiming(cursor, now) }
: {}),
},
})
// Sync the voting session even when the admin skips sendToScreens
await ctx.prisma.liveVotingSession.updateMany({
where: { roundId: input.roundId },
data: { currentProjectId: cursor.activeProjectId, status: 'IN_PROGRESS' },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PHASE_STARTED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { phase: 'PRESENTING', projectId: cursor.activeProjectId, durationSeconds: updated.phaseDurationSeconds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Close out the presentation (logging overtime) and start the Q&A timer.
*/
startQA: adminProcedure
.input(
z.object({
roundId: z.string(),
durationSeconds: z.number().int().min(10).max(7200).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (!cursor.activeProjectId) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'No project is on screen' })
}
const cfg = await getRoundCeremonyConfig(ctx.prisma, input.roundId)
const projectOverride = cfg.timingOverrides[cursor.activeProjectId]
const now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
projectPhase: 'QA',
phaseStartedAt: now,
phaseDurationSeconds: input.durationSeconds ?? projectOverride?.qaSeconds ?? cfg.qaSeconds,
phasePausedAt: null,
phasePausedAccumMs: 0,
...(closedOutTiming(cursor, now) !== undefined
? { timingLogJson: closedOutTiming(cursor, now) }
: {}),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PHASE_STARTED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { phase: 'QA', projectId: cursor.activeProjectId, durationSeconds: updated.phaseDurationSeconds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Close out Q&A (logging overtime) and open jury scoring (no timer).
*/
openScoring: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
const now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
projectPhase: 'SCORING',
phaseStartedAt: null,
phaseDurationSeconds: null,
phasePausedAt: null,
phasePausedAccumMs: 0,
...(closedOutTiming(cursor, now) !== undefined
? { timingLogJson: closedOutTiming(cursor, now) }
: {}),
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PHASE_STARTED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { phase: 'SCORING', projectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Pause the running phase timer (e.g. technical difficulties).
*/
pausePhase: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (!cursor.phaseStartedAt || cursor.phasePausedAt) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: cursor.phasePausedAt ? 'Timer is already paused' : 'No timer is running',
})
}
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: { phasePausedAt: new Date() },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PHASE_PAUSED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { phase: cursor.projectPhase, projectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Resume the paused phase timer, folding paused time into the accumulator.
*/
resumePhase: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
if (!cursor.phasePausedAt) {
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Timer is not paused' })
}
const now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
phasePausedAccumMs:
cursor.phasePausedAccumMs + (now.getTime() - cursor.phasePausedAt.getTime()),
phasePausedAt: null,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PHASE_RESUMED',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { phase: cursor.projectPhase, projectId: cursor.activeProjectId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Per-project presentation/Q&A durations. Precedence at phase start:
* explicit durationSeconds input > this override > round config default.
* Passing null clears a field.
*/
setProjectTiming: adminProcedure
.input(
z.object({
roundId: z.string(),
projectId: z.string(),
presentationSeconds: z.number().int().min(10).max(7200).nullable().optional(),
qaSeconds: z.number().int().min(10).max(7200).nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
const cfg = ((round.configJson as Record<string, unknown>) ?? {}) as Record<string, unknown>
const overrides = { ...((cfg.projectTimingOverrides as Record<string, ProjectTimingOverride>) ?? {}) }
const entry: ProjectTimingOverride = { ...(overrides[input.projectId] ?? {}) }
if (input.presentationSeconds !== undefined) {
if (input.presentationSeconds === null) delete entry.presentationSeconds
else entry.presentationSeconds = input.presentationSeconds
}
if (input.qaSeconds !== undefined) {
if (input.qaSeconds === null) delete entry.qaSeconds
else entry.qaSeconds = input.qaSeconds
}
if (Object.keys(entry).length === 0) delete overrides[input.projectId]
else overrides[input.projectId] = entry
await ctx.prisma.round.update({
where: { id: input.roundId },
data: {
configJson: { ...cfg, projectTimingOverrides: overrides } as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_PROJECT_TIMING_SET',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { projectId: input.projectId, ...entry },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { projectId: input.projectId, ...entry }
}),
/**
* Force a static slide on the big screen (or clear it).
*/
setOverrideSlide: adminProcedure
.input(
z.object({
roundId: z.string(),
slide: z.enum(['welcome', 'break', 'deliberation', 'thanks']).nullable(),
})
)
.mutation(async ({ ctx, input }) => {
const cursor = await ctx.prisma.liveProgressCursor.findUniqueOrThrow({
where: { roundId: input.roundId },
})
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: { overrideSlide: input.slide },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'LIVE_OVERRIDE_SLIDE',
entityType: 'LiveProgressCursor',
entityId: cursor.id,
detailsJson: { slide: input.slide },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return updated
}),
/**
* Persisted per-juror per-project ceremony notes (autosaved by the UI;
* resurfaced during deliberation).
*/
saveNote: protectedProcedure
.input(
z.object({
roundId: z.string(),
projectId: z.string(),
content: z.string().max(20_000),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.prisma.liveNote.upsert({
where: {
roundId_projectId_userId: {
roundId: input.roundId,
projectId: input.projectId,
userId: ctx.user.id,
},
},
create: {
roundId: input.roundId,
projectId: input.projectId,
userId: ctx.user.id,
content: input.content,
},
update: { content: input.content },
})
}),
getMyNotes: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.liveNote.findMany({
where: { roundId: input.roundId, userId: ctx.user.id },
orderBy: { updatedAt: 'desc' },
})
}),
/**
* Ceremony navigation context for jurors: the active live round (if a
* ceremony cursor exists) and any deliberation sessions they participate in.
*/
getMyCeremonyContext: protectedProcedure.query(async ({ ctx }) => {
const cursor = await ctx.prisma.liveProgressCursor.findFirst({
where: { round: { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' } },
select: { roundId: true },
orderBy: { updatedAt: 'desc' },
})
const participations = await ctx.prisma.deliberationParticipant.findMany({
where: {
user: { userId: ctx.user.id },
session: { status: { in: ['DELIB_OPEN', 'VOTING', 'RUNOFF'] } },
},
select: {
session: { select: { id: true, category: true, status: true } },
},
})
return {
liveRoundId: cursor?.roundId ?? null,
deliberations: participations.map((p) => p.session),
}
}),
/** /**
* Get current cursor state (for all users, including audience) * Get current cursor state (for all users, including audience)
*/ */
@@ -376,10 +844,22 @@ export const liveRouter = router({
teamName: true, teamName: true,
description: true, description: true,
tags: true, tags: true,
competitionCategory: true,
}, },
}) })
} }
// Full run order with categories (for the admin run-order list and
// category grouping on every surface)
const orderProjects = await ctx.prisma.project.findMany({
where: { id: { in: projectOrder } },
select: { id: true, title: true, teamName: true, competitionCategory: true },
})
const orderById = new Map(orderProjects.map((p) => [p.id, p]))
const orderedProjects = projectOrder
.map((id) => orderById.get(id))
.filter((p): p is NonNullable<typeof p> => !!p)
// Get open cohorts for this round (if any) // Get open cohorts for this round (if any)
const openCohorts = await ctx.prisma.cohort.findMany({ const openCohorts = await ctx.prisma.cohort.findMany({
where: { roundId: input.roundId, isOpen: true }, where: { roundId: input.roundId, isOpen: true },
@@ -395,6 +875,9 @@ export const liveRouter = router({
...cursor, ...cursor,
activeProject, activeProject,
projectOrder, projectOrder,
orderedProjects,
projectTimingOverrides:
((config.projectTimingOverrides as Record<string, { presentationSeconds?: number; qaSeconds?: number }>) ?? {}),
totalProjects: projectOrder.length, totalProjects: projectOrder.length,
openCohorts, openCohorts,
} }

View File

@@ -159,11 +159,18 @@ export const roundRouter = router({
const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id } }) const existing = await ctx.prisma.round.findUniqueOrThrow({ where: { id } })
// If configJson provided, validate it against the round type // If configJson provided, validate it against the round type, then MERGE
// over the existing config: the validator strips keys it doesn't know,
// but configJson also carries operational state written outside this
// form (ceremony projectOrder, projectTimingOverrides, finals-docs
// upload toggle). Replacing wholesale would wipe a running ceremony.
let validatedConfig: Prisma.InputJsonValue | undefined let validatedConfig: Prisma.InputJsonValue | undefined
if (configJson) { if (configJson) {
const parsed = validateRoundConfig(existing.roundType, configJson) const parsed = validateRoundConfig(existing.roundType, configJson)
validatedConfig = parsed as unknown as Prisma.InputJsonValue validatedConfig = {
...((existing.configJson as Record<string, unknown>) ?? {}),
...(parsed as Record<string, unknown>),
} as unknown as Prisma.InputJsonValue
} }
const round = await ctx.prisma.round.update({ const round = await ctx.prisma.round.update({

View File

@@ -615,7 +615,7 @@ export async function getSessionWithVotes(
sessionId: string, sessionId: string,
prisma: PrismaClient, prisma: PrismaClient,
) { ) {
return prisma.deliberationSession.findUnique({ const session = await prisma.deliberationSession.findUnique({
where: { id: sessionId }, where: { id: sessionId },
include: { include: {
votes: { votes: {
@@ -648,6 +648,23 @@ export async function getSessionWithVotes(
round: { select: { id: true, name: true, roundType: true } }, round: { select: { id: true, name: true, roundType: true } },
}, },
}) })
if (!session) return null
// Rankable projects: the round's projects in this session's category.
// (results are empty until finalize — the jury ranking form needs this list.)
const roundStates = await prisma.projectRoundState.findMany({
where: {
roundId: session.roundId,
project: { competitionCategory: session.category },
},
include: {
project: {
select: { id: true, title: true, teamName: true, competitionCategory: true },
},
},
})
return { ...session, projects: roundStates.map((rs) => rs.project) }
} }
// ─── Internal Helpers ─────────────────────────────────────────────────────── // ─── Internal Helpers ───────────────────────────────────────────────────────

View File

@@ -18,6 +18,8 @@ export type FinalDocumentStatus = {
deadlinePassed: boolean deadlinePassed: boolean
requirements: FinalDocRequirement[] requirements: FinalDocRequirement[]
allRequiredUploaded: boolean allRequiredUploaded: boolean
hasRequired: boolean // any slot is marked required
allUploaded: boolean // every listed slot has a file (false when no slots exist)
} }
// A LIVE_FINAL round is "open for documents" during the lead-up — while it is // A LIVE_FINAL round is "open for documents" during the lead-up — while it is
@@ -45,6 +47,16 @@ export function finalistUploadsEnabled(configJson: unknown): boolean {
return !!(configJson as { allowFinalistRevisedUploads?: boolean } | null)?.allowFinalistRevisedUploads return !!(configJson as { allowFinalistRevisedUploads?: boolean } | null)?.allowFinalistRevisedUploads
} }
/**
* Which prior-round FileRequirement ids are visible to finale judges.
* null = no curation (show all prior files). Empty array = hide all prior
* files (Grand Final round uploads are always shown regardless).
*/
export function reviewVisibleRequirementIds(configJson: unknown): string[] | null {
const v = (configJson as { reviewVisibleRequirementIds?: unknown } | null)?.reviewVisibleRequirementIds
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : null
}
/** /**
* Per-project grand-final document status. Returns null unless the project is * Per-project grand-final document status. Returns null unless the project is
* enrolled (ProjectRoundState) in the program's active LIVE_FINAL round. * enrolled (ProjectRoundState) in the program's active LIVE_FINAL round.
@@ -94,6 +106,8 @@ export async function getFinalDocumentStatusForProject(
const required = reqStatuses.filter((r) => r.isRequired) const required = reqStatuses.filter((r) => r.isRequired)
const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded) const allRequiredUploaded = required.length > 0 && required.every((r) => r.uploaded)
const hasRequired = required.length > 0
const allUploaded = reqStatuses.length > 0 && reqStatuses.every((r) => r.uploaded)
const deadline = round.windowCloseAt ?? null const deadline = round.windowCloseAt ?? null
return { return {
roundId: round.id, roundId: round.id,
@@ -102,6 +116,8 @@ export async function getFinalDocumentStatusForProject(
deadlinePassed: deadline ? new Date() > deadline : false, deadlinePassed: deadline ? new Date() > deadline : false,
requirements: reqStatuses, requirements: reqStatuses,
allRequiredUploaded, allRequiredUploaded,
hasRequired,
allUploaded,
} }
} }
@@ -258,6 +274,9 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr
const round = await getOpenFinaleRound(prisma, programId) const round = await getOpenFinaleRound(prisma, programId)
if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, teams: [] } if (!round) return { round: { id: '', name: '', deadline: null }, totalCount: 0, teams: [] }
// Admin curation: which prior-round documents judges may see (null = all).
const visibleIds = reviewVisibleRequirementIds(round.configJson)
const states = await prisma.projectRoundState.findMany({ const states = await prisma.projectRoundState.findMany({
where: { roundId: round.id }, where: { roundId: round.id },
select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } }, select: { project: { select: { id: true, title: true, teamName: true, competitionCategory: true } } },
@@ -269,7 +288,7 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr
where: { projectId: { in: projectIds } }, where: { projectId: { in: projectIds } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { select: {
id: true, projectId: true, fileName: true, mimeType: true, fileType: true, id: true, projectId: true, fileName: true, mimeType: true, fileType: true, requirementId: true,
bucket: true, objectKey: true, createdAt: true, roundId: true, bucket: true, objectKey: true, createdAt: true, roundId: true,
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } }, requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
}, },
@@ -284,6 +303,9 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr
const filesByProject = new Map<string, ReviewFile[]>() const filesByProject = new Map<string, ReviewFile[]>()
for (const f of allFiles) { for (const f of allFiles) {
const isFinaleUpload = f.roundId === round.id
// Curated mode: prior-round files must match a selected requirement; finale uploads always pass.
if (!isFinaleUpload && visibleIds !== null && (!f.requirementId || !visibleIds.includes(f.requirementId))) continue
const r = f.requirement?.round ?? (f.roundId ? roundById.get(f.roundId) : null) const r = f.requirement?.round ?? (f.roundId ? roundById.get(f.roundId) : null)
const rf: ReviewFile = { const rf: ReviewFile = {
id: f.id, id: f.id,
@@ -293,7 +315,7 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr
docLabel: f.requirement?.name?.trim() || humanizeFileType(f.fileType), docLabel: f.requirement?.name?.trim() || humanizeFileType(f.fileType),
roundLabel: r?.name ?? '—', roundLabel: r?.name ?? '—',
roundSort: r?.sortOrder ?? -1, roundSort: r?.sortOrder ?? -1,
isFinaleUpload: f.roundId === round.id, isFinaleUpload,
createdAt: f.createdAt, createdAt: f.createdAt,
} }
const list = filesByProject.get(f.projectId) const list = filesByProject.get(f.projectId)
@@ -314,6 +336,47 @@ export async function listFinalistDocumentsForReview(prisma: PrismaClient, progr
return { round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null }, totalCount: teams.length, teams } return { round: { id: round.id, name: round.name, deadline: round.windowCloseAt ?? null }, totalCount: teams.length, teams }
} }
export type ReviewDocSlot = {
requirementId: string
name: string
roundName: string
roundSort: number
fileCount: number
}
/**
* Distinct prior-round document slots (FileRequirements) that the finalist
* teams have files for — the options offered in the admin "documents shown to
* judges" picker. Excludes the finale round's own slots (those uploads are
* always visible to judges) and files without a requirement.
*/
export async function listReviewVisibilityOptions(prisma: PrismaClient, programId: string): Promise<ReviewDocSlot[]> {
const round = await getOpenFinaleRound(prisma, programId)
if (!round) return []
const states = await prisma.projectRoundState.findMany({ where: { roundId: round.id }, select: { projectId: true } })
const files = await prisma.projectFile.findMany({
where: { projectId: { in: states.map((s) => s.projectId) }, requirement: { roundId: { not: round.id } } },
select: {
requirementId: true,
requirement: { select: { name: true, round: { select: { name: true, sortOrder: true } } } },
},
})
const slots = new Map<string, ReviewDocSlot>()
for (const f of files) {
if (!f.requirementId || !f.requirement) continue
const existing = slots.get(f.requirementId)
if (existing) existing.fileCount++
else slots.set(f.requirementId, {
requirementId: f.requirementId,
name: f.requirement.name.trim(),
roundName: f.requirement.round.name,
roundSort: f.requirement.round.sortOrder,
fileCount: 1,
})
}
return [...slots.values()].sort((a, b) => a.roundSort - b.roundSort || a.name.localeCompare(b.name))
}
/** True if user is admin or a member of the program's open LIVE_FINAL jury group (DRAFT or ACTIVE). */ /** True if user is admin or a member of the program's open LIVE_FINAL jury group (DRAFT or ACTIVE). */
export async function userCanReviewFinals(prisma: PrismaClient, userId: string, userRole: string, programId: string): Promise<boolean> { export async function userCanReviewFinals(prisma: PrismaClient, userId: string, userRole: string, programId: string): Promise<boolean> {
if (userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN') return true if (userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN') return true

View File

@@ -0,0 +1,277 @@
/**
* Grand-finale audience favorite-vote windows:
* - admin opens a per-category (or overall) window with a duration
* - votes are gated server-side: phase OPEN, before closesAt, category match
* - one vote per voter token per window (re-vote updates), max 3 voters per IP
* - no cron required: the closesAt check at vote/read time is the source of truth
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { liveVotingRouter } from '@/server/routers/live-voting'
let program: any
let round: any
let session: any
let startup1: any
let startup2: any
let concept1: any
let admin: any
let adminCaller: ReturnType<typeof createCaller>
let publicCaller: ReturnType<typeof createCaller>
let tokenA: string
let tokenB: string
async function makeVoter() {
const res = await publicCaller.registerAudienceVoter({ sessionId: session.id })
return res.token as string
}
beforeAll(async () => {
program = await createTestProgram()
const competition = await createTestCompetition(program.id)
round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
status: 'ROUND_ACTIVE',
})
startup1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
startup2 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
concept1 = await createTestProject(program.id, { competitionCategory: 'BUSINESS_CONCEPT' })
await prisma.round.update({
where: { id: round.id },
data: { configJson: { projectOrder: [concept1.id, startup1.id, startup2.id] } },
})
session = await prisma.liveVotingSession.create({
data: { roundId: round.id, allowAudienceVotes: true },
})
admin = await createTestUser('SUPER_ADMIN')
adminCaller = createCaller(liveVotingRouter, admin)
publicCaller = createCaller(liveVotingRouter, admin) // public procedures ignore the session user
tokenA = await makeVoter()
tokenB = await makeVoter()
})
afterAll(async () => {
await cleanupTestData(program.id, [admin.id])
})
describe('window lifecycle', () => {
it('opens a category window with a closing time', async () => {
const s = await adminCaller.openAudienceWindow({
sessionId: session.id,
windowKey: 'CATEGORY:STARTUP',
durationMinutes: 5,
})
expect(s.audiencePhase).toBe('OPEN')
expect(s.audienceWindowKey).toBe('CATEGORY:STARTUP')
const msLeft = new Date(s.audienceWindowClosesAt!).getTime() - Date.now()
expect(msLeft).toBeGreaterThan(4 * 60_000)
expect(msLeft).toBeLessThanOrEqual(5 * 60_000)
})
it('rejects opening a second window while one is open', async () => {
await expect(
adminCaller.openAudienceWindow({
sessionId: session.id,
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
durationMinutes: 5,
})
).rejects.toThrow()
})
})
describe('casting favorite votes', () => {
it('accepts a vote for an in-category project and re-vote updates in place', async () => {
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenA,
projectId: startup1.id,
})
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenA,
projectId: startup2.id,
})
const rows = await prisma.audienceFavoriteVote.findMany({
where: { sessionId: session.id, windowKey: 'CATEGORY:STARTUP' },
})
expect(rows).toHaveLength(1)
expect(rows[0].projectId).toBe(startup2.id)
})
it('rejects a vote for a project outside the open category', async () => {
await expect(
publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenB,
projectId: concept1.id,
})
).rejects.toThrow(/category/i)
})
it('rejects an invalid token', async () => {
await expect(
publicCaller.castFavoriteVote({
sessionId: session.id,
token: 'bogus',
projectId: startup1.id,
})
).rejects.toThrow()
})
it('rejects votes after closesAt even without an explicit close (no cron)', async () => {
await prisma.liveVotingSession.update({
where: { id: session.id },
data: { audienceWindowClosesAt: new Date(Date.now() - 1000) },
})
await expect(
publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenB,
projectId: startup1.id,
})
).rejects.toThrow()
// getAudienceWindow also reports closed
const win = await publicCaller.getAudienceWindow({ sessionId: session.id })
expect(win.open).toBe(false)
})
it('close + re-open works and votes flow again in the new category', async () => {
await adminCaller.closeAudienceWindow({ sessionId: session.id })
await expect(
publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenB,
projectId: startup1.id,
})
).rejects.toThrow()
await adminCaller.openAudienceWindow({
sessionId: session.id,
windowKey: 'CATEGORY:BUSINESS_CONCEPT',
durationMinutes: 5,
})
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenB,
projectId: concept1.id,
})
const rows = await prisma.audienceFavoriteVote.findMany({
where: { sessionId: session.id, windowKey: 'CATEGORY:BUSINESS_CONCEPT' },
})
expect(rows).toHaveLength(1)
})
})
describe('overall favorite window', () => {
it('requires the admin toggle before opening', async () => {
await adminCaller.closeAudienceWindow({ sessionId: session.id })
await expect(
adminCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'OVERALL', durationMinutes: 5 })
).rejects.toThrow()
await adminCaller.updateSessionConfig({ sessionId: session.id, allowOverallFavorite: true })
const s = await adminCaller.openAudienceWindow({
sessionId: session.id,
windowKey: 'OVERALL',
durationMinutes: 5,
})
expect(s.audienceWindowKey).toBe('OVERALL')
})
it('accepts any ordered project in OVERALL mode', async () => {
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenA,
projectId: concept1.id,
})
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenB,
projectId: startup1.id,
})
const rows = await prisma.audienceFavoriteVote.findMany({
where: { sessionId: session.id, windowKey: 'OVERALL' },
})
expect(rows).toHaveLength(2)
})
})
describe('IP cap', () => {
it('rejects a 4th distinct voter from the same IP in one window', async () => {
// tokenA + tokenB already voted in OVERALL from ctx ip 127.0.0.1, but their
// stored ipAddress comes from ctx — normalize the rows to be explicit:
await prisma.audienceFavoriteVote.updateMany({
where: { sessionId: session.id, windowKey: 'OVERALL' },
data: { ipAddress: '127.0.0.1' },
})
const tokenC = await makeVoter()
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenC,
projectId: startup2.id,
})
const tokenD = await makeVoter()
await expect(
publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenD,
projectId: startup1.id,
})
).rejects.toThrow(/limit/i)
})
it('an existing voter on a capped IP can still change their vote', async () => {
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: tokenA,
projectId: startup2.id,
})
const row = await prisma.audienceFavoriteVote.findFirst({
where: { sessionId: session.id, windowKey: 'OVERALL', audienceVoter: { token: tokenA } },
})
expect(row?.projectId).toBe(startup2.id)
})
})
describe('reads', () => {
it('getAudienceWindow lists eligible projects in order and the callers vote', async () => {
const win = await publicCaller.getAudienceWindow({ sessionId: session.id, token: tokenA })
expect(win.open).toBe(true)
expect(win.windowKey).toBe('OVERALL')
expect(win.projects.map((p: any) => p.id)).toEqual([concept1.id, startup1.id, startup2.id])
expect(win.myVoteProjectId).toBe(startup2.id)
})
it('getFavoriteTallies aggregates per window per project', async () => {
const tallies = await adminCaller.getFavoriteTallies({ sessionId: session.id })
const overall = tallies.windows.find((w: any) => w.windowKey === 'OVERALL')
expect(overall.totalVotes).toBe(3)
const startup2Count = overall.projects.find((p: any) => p.projectId === startup2.id)?.count
expect(startup2Count).toBe(2)
})
it('getAudienceContextByRound resolves the session publicly', async () => {
const ctx = await publicCaller.getAudienceContextByRound({ roundId: round.id })
expect(ctx?.sessionId).toBe(session.id)
expect(ctx?.allowAudienceVotes).toBe(true)
})
it('non-admins cannot open windows or read tallies', async () => {
const juror = await createTestUser('JURY_MEMBER')
const jurorCaller = createCaller(liveVotingRouter, juror)
await expect(
jurorCaller.openAudienceWindow({ sessionId: session.id, windowKey: 'OVERALL', durationMinutes: 5 })
).rejects.toThrow()
await expect(jurorCaller.getFavoriteTallies({ sessionId: session.id })).rejects.toThrow()
await prisma.user.delete({ where: { id: juror.id } })
})
})

View File

@@ -22,4 +22,24 @@ describe('middleware public paths', () => {
it('blocks a protected page without a session', () => { it('blocks a protected page without a session', () => {
expect(authorized('/admin/logistics', null)).toBe(false) expect(authorized('/admin/logistics', null)).toBe(false)
}) })
// Grand finale: audience QR voting, public scoreboard, and the big-screen
// ceremony view are all reached by attendees with NO account.
it('allows audience voting pages without a session', () => {
expect(authorized('/vote/competition/round123', null)).toBe(true)
expect(authorized('/vote/session456', null)).toBe(true)
})
it('allows the public live scoreboard without a session', () => {
expect(authorized('/live-scores/session456', null)).toBe(true)
})
it('allows the big-screen ceremony view without a session', () => {
expect(authorized('/live/ceremony/round123', null)).toBe(true)
})
it('keeps jury and admin live surfaces private', () => {
expect(authorized('/jury/competitions/round123/live', null)).toBe(false)
expect(authorized('/admin', null)).toBe(false)
})
}) })

View File

@@ -0,0 +1,39 @@
/**
* Ceremony traffic classifier — these requests bypass the 100/min-per-IP tRPC
* budget (whole venues share one IP). A single non-ceremony procedure in a
* batch must fall back to the strict bucket.
*/
import { describe, it, expect } from 'vitest'
import { isCeremonyTraffic } from '@/lib/rate-limit'
describe('isCeremonyTraffic', () => {
it('accepts single ceremony procedures', () => {
expect(isCeremonyTraffic('/api/trpc/liveVoting.getCeremonyState')).toBe(true)
expect(isCeremonyTraffic('/api/trpc/liveVoting.getAudienceWindow')).toBe(true)
expect(isCeremonyTraffic('/api/trpc/liveVoting.castFavoriteVote')).toBe(true)
expect(isCeremonyTraffic('/api/trpc/live.getCursor')).toBe(true)
})
it('accepts batched requests of only ceremony procedures', () => {
expect(
isCeremonyTraffic('/api/trpc/live.getCursor,liveVoting.getSessionForVotingByRound')
).toBe(true)
})
it('rejects any batch containing a non-ceremony procedure', () => {
expect(isCeremonyTraffic('/api/trpc/live.getCursor,user.me')).toBe(false)
expect(isCeremonyTraffic('/api/trpc/liveVoting.getResults')).toBe(false)
expect(isCeremonyTraffic('/api/trpc/deliberation.submitVote')).toBe(false)
})
it('rejects admin/state-changing live procedures', () => {
expect(isCeremonyTraffic('/api/trpc/live.startPresentation')).toBe(false)
expect(isCeremonyTraffic('/api/trpc/liveVoting.openAudienceWindow')).toBe(false)
expect(isCeremonyTraffic('/api/trpc/liveVoting.revealNext')).toBe(false)
})
it('rejects empty and malformed paths', () => {
expect(isCeremonyTraffic('/api/trpc/')).toBe(false)
expect(isCeremonyTraffic('/api/trpc')).toBe(false)
})
})

View File

@@ -0,0 +1,113 @@
/**
* Deliberation jury wiring — regression for two launch blockers:
* 1. submitVote compared the JuryGroupMember id against User.id (FORBIDDEN for
* every legitimate juror). The server now resolves the caller's participant
* row itself; the client never sends an identity.
* 2. getSession had no project list before finalize (the ranking form rendered
* empty). It now includes the round's projects filtered by session category.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
cleanupTestData,
uid,
} from '../helpers'
import { deliberationRouter } from '@/server/routers/deliberation'
let program: any
let competition: any
let delibRound: any
let startup1: any
let startup2: any
let concept1: any
let juror: any
let outsiderJuror: any
let admin: any
let sessionId: string
let adminCaller: ReturnType<typeof createCaller>
let jurorCaller: ReturnType<typeof createCaller>
beforeAll(async () => {
program = await createTestProgram()
competition = await createTestCompetition(program.id)
delibRound = await createTestRound(competition.id, {
roundType: 'DELIBERATION',
status: 'ROUND_ACTIVE',
})
startup1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
startup2 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
concept1 = await createTestProject(program.id, { competitionCategory: 'BUSINESS_CONCEPT' })
await createTestProjectRoundState(startup1.id, delibRound.id)
await createTestProjectRoundState(startup2.id, delibRound.id)
await createTestProjectRoundState(concept1.id, delibRound.id)
const juryGroup = await prisma.juryGroup.create({
data: { competitionId: competition.id, name: 'Finals Jury', slug: uid('jg') },
})
juror = await createTestUser('JURY_MEMBER')
outsiderJuror = await createTestUser('JURY_MEMBER')
admin = await createTestUser('SUPER_ADMIN')
const member = await prisma.juryGroupMember.create({
data: { juryGroupId: juryGroup.id, userId: juror.id, role: 'MEMBER' },
})
adminCaller = createCaller(deliberationRouter, admin)
jurorCaller = createCaller(deliberationRouter, juror)
const session = await adminCaller.createSession({
competitionId: competition.id,
roundId: delibRound.id,
category: 'STARTUP',
mode: 'FULL_RANKING',
tieBreakMethod: 'TIE_ADMIN_DECIDES',
participantUserIds: [member.id], // JuryGroupMember IDs
})
sessionId = session.id
await adminCaller.openVoting({ sessionId })
})
afterAll(async () => {
await cleanupTestData(program.id, [juror.id, outsiderJuror.id, admin.id])
})
describe('getSession projects', () => {
it('exposes the category projects before any results exist', async () => {
const session = await jurorCaller.getSession({ sessionId })
const ids = (session.projects ?? []).map((p: any) => p.id).sort()
expect(ids).toEqual([startup1.id, startup2.id].sort())
// off-category project excluded
expect(ids).not.toContain(concept1.id)
})
})
describe('submitVote identity resolution', () => {
it('lets a participant juror vote without sending any identity', async () => {
await jurorCaller.submitVote({ sessionId, projectId: startup1.id, rank: 1 })
await jurorCaller.submitVote({ sessionId, projectId: startup2.id, rank: 2 })
const session = await jurorCaller.getSession({ sessionId })
const myVotes = (session.votes ?? []).filter(
(v: any) => v.juryMember?.user?.id === juror.id
)
expect(myVotes).toHaveLength(2)
})
it('rejects a juror who is not a participant', async () => {
const outsiderCaller = createCaller(deliberationRouter, outsiderJuror)
await expect(
outsiderCaller.submitVote({ sessionId, projectId: startup1.id, rank: 1 })
).rejects.toThrow(/participant/i)
})
it('aggregates the resolved votes (sanity end-to-end)', async () => {
const agg = await adminCaller.aggregate({ sessionId })
expect(agg.rankings.length).toBeGreaterThan(0)
expect(agg.rankings[0].projectId).toBe(startup1.id) // rank 1 → top Borda points
})
})

View File

@@ -0,0 +1,171 @@
import { describe, it, expect, afterAll, vi } from 'vitest'
vi.mock('@/lib/minio', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/minio')>()
return { ...actual, getPresignedUrl: vi.fn(async () => 'https://example.test/presigned') }
})
import { prisma } from '../setup'
import {
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
createTestProjectRoundState,
createTestUser,
cleanupTestData,
uid,
} from '../helpers'
import { listFinalistDocumentsForReview, listReviewVisibilityOptions } from '@/server/services/final-documents'
const programIds: string[] = []
afterAll(async () => {
for (const id of programIds) await cleanupTestData(id)
})
/**
* One finalist team with 4 files:
* - Business Plan (prior SUBMISSION round, via requirement reqBP)
* - Pitch Deck (prior SUBMISSION round, via requirement reqDeck)
* - loose.pdf (prior SUBMISSION round, NO requirement)
* - final.mp4 (uploaded directly to the LIVE_FINAL round, via reqFinal)
*/
async function setupCuration() {
const program = await createTestProgram()
programIds.push(program.id)
const comp = await createTestCompetition(program.id, { status: 'ACTIVE' })
const priorRound = await createTestRound(comp.id, { roundType: 'SUBMISSION', status: 'ROUND_CLOSED', sortOrder: 2 })
const reqBP = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: priorRound.id, name: 'Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 },
})
const reqDeck = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: priorRound.id, name: 'Pitch Deck', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 2 },
})
const finale = await createTestRound(comp.id, {
roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE', sortOrder: 6,
windowCloseAt: new Date(Date.now() + 86_400_000),
configJson: { allowFinalistRevisedUploads: true },
})
const reqFinal = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: finale.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: false, sortOrder: 1 },
})
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, finale.id)
const mkFile = (roundId: string, requirementId: string | null, fileName: string) =>
prisma.projectFile.create({
data: {
id: uid('file'), projectId: project.id, roundId, requirementId,
fileType: 'SUPPORTING_DOC', fileName, mimeType: 'application/pdf', size: 10,
bucket: 'b', objectKey: uid('key'),
},
})
await mkFile(priorRound.id, reqBP.id, 'bp.pdf')
await mkFile(priorRound.id, reqDeck.id, 'deck.pdf')
await mkFile(priorRound.id, null, 'loose.pdf')
await mkFile(finale.id, reqFinal.id, 'final.mp4')
return { program, priorRound, finale, reqBP, reqDeck, reqFinal, project }
}
async function setSelection(roundId: string, ids: string[] | null) {
const round = await prisma.round.findUniqueOrThrow({ where: { id: roundId }, select: { configJson: true } })
const cfg = (round.configJson ?? {}) as Record<string, unknown>
if (ids === null) delete cfg.reviewVisibleRequirementIds
else cfg.reviewVisibleRequirementIds = ids
await prisma.round.update({ where: { id: roundId }, data: { configJson: cfg as object } })
}
describe('listFinalistDocumentsForReview curation', () => {
it('no selection key → all files visible (current behavior)', async () => {
const { program } = await setupCuration()
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams).toHaveLength(1)
expect(result.teams[0].files).toHaveLength(4)
})
it('selection → only matching prior files, finale uploads always visible', async () => {
const { program, finale, reqBP } = await setupCuration()
await setSelection(finale.id, [reqBP.id])
const result = await listFinalistDocumentsForReview(prisma, program.id)
const names = result.teams[0].files.map((f) => f.fileName).sort()
expect(names).toEqual(['bp.pdf', 'final.mp4']) // deck.pdf and loose.pdf hidden
})
it('empty selection → only finale uploads visible', async () => {
const { program, finale } = await setupCuration()
await setSelection(finale.id, [])
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams[0].files.map((f) => f.fileName)).toEqual(['final.mp4'])
})
it('prior file without a requirement is excluded under any selection', async () => {
const { program, finale, reqBP, reqDeck } = await setupCuration()
await setSelection(finale.id, [reqBP.id, reqDeck.id])
const result = await listFinalistDocumentsForReview(prisma, program.id)
expect(result.teams[0].files.map((f) => f.fileName)).not.toContain('loose.pdf')
})
})
describe('listReviewVisibilityOptions', () => {
it('lists distinct prior-round slots with counts; excludes finale-round slots and requirement-less files', async () => {
const { program, reqBP, reqDeck } = await setupCuration()
const options = await listReviewVisibilityOptions(prisma, program.id)
expect(options.map((o) => o.requirementId).sort()).toEqual([reqBP.id, reqDeck.id].sort())
const bp = options.find((o) => o.requirementId === reqBP.id)!
expect(bp.name).toBe('Business Plan')
expect(bp.fileCount).toBe(1)
expect(bp.roundName).toBeTruthy()
})
it('returns [] when there is no open finale round', async () => {
const program = await createTestProgram()
programIds.push(program.id)
expect(await listReviewVisibilityOptions(prisma, program.id)).toEqual([])
})
})
describe('finalist review-doc settings procedures', () => {
const userIds: string[] = []
afterAll(async () => {
for (const id of programIds) await cleanupTestData(id, userIds)
})
it('round-trips a selection and preserves sibling configJson keys', async () => {
const { program, finale, reqBP } = await setupCuration()
const admin = await createTestUser('PROGRAM_ADMIN')
userIds.push(admin.id)
const { finalistRouter } = await import('@/server/routers/finalist')
const { createCaller } = await import('../setup')
const caller = createCaller(finalistRouter, admin)
const initial = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(initial.selectedIds).toBeNull()
expect(initial.options.length).toBe(2)
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: [reqBP.id] })
const curated = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(curated.selectedIds).toEqual([reqBP.id])
// sibling key from setupCuration must survive
const round = await prisma.round.findUniqueOrThrow({ where: { id: finale.id }, select: { configJson: true } })
expect((round.configJson as Record<string, unknown>).allowFinalistRevisedUploads).toBe(true)
await caller.setReviewVisibleRequirements({ roundId: finale.id, requirementIds: null })
const cleared = await caller.getReviewDocSettings({ programId: program.id, roundId: finale.id })
expect(cleared.selectedIds).toBeNull()
})
it('rejects a non-LIVE_FINAL round', async () => {
const { program, priorRound } = await setupCuration()
const admin = await createTestUser('PROGRAM_ADMIN')
userIds.push(admin.id)
const { finalistRouter } = await import('@/server/routers/finalist')
const { createCaller } = await import('../setup')
const caller = createCaller(finalistRouter, admin)
await expect(
caller.setReviewVisibleRequirements({ roundId: priorRound.id, requirementIds: [] }),
).rejects.toThrow()
void program
})
})

View File

@@ -25,7 +25,7 @@ import { BUCKET_NAME, generateObjectKey } from '@/lib/minio'
const programIds: string[] = [] const programIds: string[] = []
async function makeFinaleProgram( async function makeFinaleProgram(
opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean } = {}, opts: { roundStatus?: 'ROUND_ACTIVE' | 'ROUND_DRAFT' | 'ROUND_CLOSED'; closeAt?: Date; skipRequirements?: boolean; uploadsEnabled?: boolean; optionalRequirements?: boolean } = {},
) { ) {
const program = await createTestProgram() const program = await createTestProgram()
programIds.push(program.id) programIds.push(program.id)
@@ -41,10 +41,10 @@ async function makeFinaleProgram(
return { program, comp, round, reqPlan: undefined, reqVideo: undefined } return { program, comp, round, reqPlan: undefined, reqVideo: undefined }
} }
const reqPlan = await prisma.fileRequirement.create({ const reqPlan = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: round.id, name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: true, sortOrder: 1 }, data: { id: uid('req'), roundId: round.id, name: 'Final Business Plan', acceptedMimeTypes: ['application/pdf'], isRequired: !opts.optionalRequirements, sortOrder: 1 },
}) })
const reqVideo = await prisma.fileRequirement.create({ const reqVideo = await prisma.fileRequirement.create({
data: { id: uid('req'), roundId: round.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: true, sortOrder: 2 }, data: { id: uid('req'), roundId: round.id, name: '1-minute Video', acceptedMimeTypes: ['video/*'], isRequired: !opts.optionalRequirements, sortOrder: 2 },
}) })
return { program, comp, round, reqPlan, reqVideo } return { program, comp, round, reqPlan, reqVideo }
} }
@@ -128,6 +128,49 @@ describe('getFinalDocumentStatusForProject', () => {
expect(status!.requirements).toHaveLength(0) expect(status!.requirements).toHaveLength(0)
expect(status!.allRequiredUploaded).toBe(false) expect(status!.allRequiredUploaded).toBe(false)
}) })
it('all-optional round: hasRequired false, allUploaded flips when every slot has a file', async () => {
const { program, round, reqPlan, reqVideo } = await makeFinaleProgram({ optionalRequirements: true })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const before = await getFinalDocumentStatusForProject(prisma, project.id)
expect(before!.hasRequired).toBe(false)
expect(before!.allUploaded).toBe(false)
expect(before!.allRequiredUploaded).toBe(false)
for (const req of [reqPlan!, reqVideo!]) {
await prisma.projectFile.create({
data: {
id: uid('file'), projectId: project.id, roundId: round.id, requirementId: req.id,
fileType: 'SUPPORTING_DOC', fileName: `f-${req.id}`, mimeType: 'application/pdf', size: 10,
bucket: 'b', objectKey: uid('key'),
},
})
}
const after = await getFinalDocumentStatusForProject(prisma, project.id)
expect(after!.hasRequired).toBe(false)
expect(after!.allUploaded).toBe(true)
})
it('mixed round: hasRequired true; allUploaded only when optional slots are filled too', async () => {
const { program, round, reqPlan } = await makeFinaleProgram()
await prisma.fileRequirement.update({ where: { id: reqPlan!.id }, data: { isRequired: false } })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const status = await getFinalDocumentStatusForProject(prisma, project.id)
expect(status!.hasRequired).toBe(true) // reqVideo still required
expect(status!.allUploaded).toBe(false)
})
it('zero slots: allUploaded false (no vacuous completeness)', async () => {
const { program, round } = await makeFinaleProgram({ skipRequirements: true })
const project = await createTestProject(program.id)
await createTestProjectRoundState(project.id, round.id)
const status = await getFinalDocumentStatusForProject(prisma, project.id)
expect(status!.hasRequired).toBe(false)
expect(status!.allUploaded).toBe(false)
})
}) })
describe('applicant.getFinalDocumentStatus', () => { describe('applicant.getFinalDocumentStatus', () => {

View File

@@ -0,0 +1,246 @@
/**
* Grand-finale per-project phase machine on LiveProgressCursor:
* ON_DECK → PRESENTING → QA → SCORING, with server-stamped timers,
* pause/resume accumulator math, an overtime timing log, big-screen
* override slides, and persisted juror notes.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
cleanupTestData,
} from '../helpers'
import { liveRouter } from '@/server/routers/live'
let program: any
let round: any
let p1: any
let p2: any
let admin: any
let juror: any
let adminCaller: ReturnType<typeof createCaller>
let jurorCaller: ReturnType<typeof createCaller>
beforeAll(async () => {
program = await createTestProgram()
const competition = await createTestCompetition(program.id)
round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
status: 'ROUND_ACTIVE',
configJson: { presentationDurationMinutes: 2, qaDurationMinutes: 1 },
})
p1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
p2 = await createTestProject(program.id, { competitionCategory: 'BUSINESS_CONCEPT' })
admin = await createTestUser('SUPER_ADMIN')
juror = await createTestUser('JURY_MEMBER')
adminCaller = createCaller(liveRouter, admin)
jurorCaller = createCaller(liveRouter, juror)
await adminCaller.start({ roundId: round.id, projectOrder: [p1.id, p2.id] })
})
afterAll(async () => {
await cleanupTestData(program.id, [admin.id, juror.id])
})
describe('phase transitions', () => {
it('sendToScreens puts a project ON_DECK with no timer', async () => {
const cursor = await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
expect(cursor.projectPhase).toBe('ON_DECK')
expect(cursor.activeProjectId).toBe(p1.id)
expect(cursor.phaseStartedAt).toBeNull()
expect(cursor.phaseDurationSeconds).toBeNull()
expect(cursor.overrideSlide).toBeNull()
})
it('rejects sendToScreens for a project outside the order', async () => {
await expect(
adminCaller.sendToScreens({ roundId: round.id, projectId: 'nope' })
).rejects.toThrow()
})
it('startPresentation stamps the timer with config default duration', async () => {
const cursor = await adminCaller.startPresentation({ roundId: round.id })
expect(cursor.projectPhase).toBe('PRESENTING')
expect(cursor.phaseStartedAt).not.toBeNull()
expect(cursor.phaseDurationSeconds).toBe(120) // presentationDurationMinutes: 2
expect(cursor.phasePausedAccumMs).toBe(0)
})
it('pause/resume folds pause time into the accumulator', async () => {
const paused = await adminCaller.pausePhase({ roundId: round.id })
expect(paused.phasePausedAt).not.toBeNull()
// pausing twice errors
await expect(adminCaller.pausePhase({ roundId: round.id })).rejects.toThrow()
// backdate the pause so the accumulator visibly grows
await prisma.liveProgressCursor.update({
where: { roundId: round.id },
data: { phasePausedAt: new Date(Date.now() - 5_000) },
})
const resumed = await adminCaller.resumePhase({ roundId: round.id })
expect(resumed.phasePausedAt).toBeNull()
expect(resumed.phasePausedAccumMs).toBeGreaterThanOrEqual(5_000)
// resuming while not paused errors
await expect(adminCaller.resumePhase({ roundId: round.id })).rejects.toThrow()
})
it('startQA logs the presentation with overtime and starts the QA timer', async () => {
// Backdate the presentation start so it overran its 120s budget
await prisma.liveProgressCursor.update({
where: { roundId: round.id },
data: { phaseStartedAt: new Date(Date.now() - 200_000), phasePausedAccumMs: 0 },
})
const cursor = await adminCaller.startQA({ roundId: round.id, durationSeconds: 30 })
expect(cursor.projectPhase).toBe('QA')
expect(cursor.phaseDurationSeconds).toBe(30)
const log = cursor.timingLogJson as Array<any>
expect(log).toHaveLength(1)
expect(log[0].projectId).toBe(p1.id)
expect(log[0].phase).toBe('PRESENTING')
expect(log[0].configuredSeconds).toBe(120)
expect(log[0].overranSeconds).toBeGreaterThanOrEqual(79) // ~200s elapsed vs 120s budget
})
it('openScoring logs the QA phase and clears the timer', async () => {
const cursor = await adminCaller.openScoring({ roundId: round.id })
expect(cursor.projectPhase).toBe('SCORING')
expect(cursor.phaseStartedAt).toBeNull()
const log = cursor.timingLogJson as Array<any>
expect(log).toHaveLength(2)
expect(log[1].phase).toBe('QA')
expect(log[1].configuredSeconds).toBe(30)
})
it('sending the next project keeps the timing log', async () => {
const cursor = await adminCaller.sendToScreens({ roundId: round.id, projectId: p2.id })
expect(cursor.activeProjectId).toBe(p2.id)
expect(cursor.projectPhase).toBe('ON_DECK')
expect((cursor.timingLogJson as Array<any>).length).toBe(2)
})
it('setOverrideSlide sets and clears the big-screen override', async () => {
const set = await adminCaller.setOverrideSlide({ roundId: round.id, slide: 'break' })
expect(set.overrideSlide).toBe('break')
const cleared = await adminCaller.setOverrideSlide({ roundId: round.id, slide: null })
expect(cleared.overrideSlide).toBeNull()
})
it('getCursor exposes phase fields and ordered projects with categories', async () => {
const cursor = await jurorCaller.getCursor({ roundId: round.id })
expect(cursor?.projectPhase).toBe('ON_DECK')
expect(cursor?.orderedProjects?.map((p: any) => p.id)).toEqual([p1.id, p2.id])
expect(cursor?.orderedProjects?.[0]?.competitionCategory).toBe('STARTUP')
expect(cursor?.activeProject?.competitionCategory).toBe('BUSINESS_CONCEPT')
})
it('phase mutations are admin-only', async () => {
await expect(
jurorCaller.startPresentation({ roundId: round.id })
).rejects.toThrow()
})
})
describe('voting session sync', () => {
it('sendToScreens points the voting session at the project and activates it', async () => {
const session = await prisma.liveVotingSession.create({
data: { roundId: round.id, status: 'NOT_STARTED' },
})
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
const updated = await prisma.liveVotingSession.findUniqueOrThrow({
where: { id: session.id },
})
expect(updated.currentProjectId).toBe(p1.id)
expect(updated.status).toBe('IN_PROGRESS')
})
it('startPresentation re-syncs a session that drifted out of band', async () => {
await prisma.liveVotingSession.update({
where: { roundId: round.id },
data: { status: 'NOT_STARTED', currentProjectId: null },
})
await adminCaller.startPresentation({ roundId: round.id, durationSeconds: 60 })
const updated = await prisma.liveVotingSession.findUniqueOrThrow({
where: { roundId: round.id },
})
expect(updated.currentProjectId).toBe(p1.id)
expect(updated.status).toBe('IN_PROGRESS')
})
})
describe('per-project timing overrides', () => {
it('setProjectTiming stores per-project durations in round config', async () => {
await adminCaller.setProjectTiming({
roundId: round.id,
projectId: p1.id,
presentationSeconds: 480,
qaSeconds: 90,
})
const r = await prisma.round.findUniqueOrThrow({ where: { id: round.id } })
const overrides = (r.configJson as any).projectTimingOverrides
expect(overrides[p1.id]).toEqual({ presentationSeconds: 480, qaSeconds: 90 })
})
it('startPresentation/startQA use the project override over the config default', async () => {
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
const pres = await adminCaller.startPresentation({ roundId: round.id })
expect(pres.phaseDurationSeconds).toBe(480) // override, not the 120s config default
const qa = await adminCaller.startQA({ roundId: round.id })
expect(qa.phaseDurationSeconds).toBe(90)
})
it('an explicit durationSeconds input still wins over the project override', async () => {
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
const pres = await adminCaller.startPresentation({ roundId: round.id, durationSeconds: 33 })
expect(pres.phaseDurationSeconds).toBe(33)
})
it('projects without an override keep the config default', async () => {
await adminCaller.sendToScreens({ roundId: round.id, projectId: p2.id })
const pres = await adminCaller.startPresentation({ roundId: round.id })
expect(pres.phaseDurationSeconds).toBe(120)
})
it('clearing an override falls back to defaults', async () => {
await adminCaller.setProjectTiming({
roundId: round.id,
projectId: p1.id,
presentationSeconds: null,
qaSeconds: null,
})
await adminCaller.sendToScreens({ roundId: round.id, projectId: p1.id })
const pres = await adminCaller.startPresentation({ roundId: round.id })
expect(pres.phaseDurationSeconds).toBe(120)
})
it('getCursor exposes the overrides for the admin UI', async () => {
await adminCaller.setProjectTiming({ roundId: round.id, projectId: p2.id, qaSeconds: 240 })
const cursor = await adminCaller.getCursor({ roundId: round.id })
expect(cursor?.projectTimingOverrides?.[p2.id]?.qaSeconds).toBe(240)
})
})
describe('juror notes', () => {
it('saveNote upserts one note per (round, project, juror)', async () => {
await jurorCaller.saveNote({ roundId: round.id, projectId: p1.id, content: 'first draft' })
await jurorCaller.saveNote({ roundId: round.id, projectId: p1.id, content: 'revised' })
await jurorCaller.saveNote({ roundId: round.id, projectId: p2.id, content: 'other project' })
const notes = await jurorCaller.getMyNotes({ roundId: round.id })
expect(notes).toHaveLength(2)
const n1 = notes.find((n: any) => n.projectId === p1.id)
expect(n1?.content).toBe('revised')
})
it('getMyNotes only returns the callers notes', async () => {
const adminNotes = await adminCaller.getMyNotes({ roundId: round.id })
expect(adminNotes).toHaveLength(0)
})
})

View File

@@ -0,0 +1,203 @@
/**
* Tally audit — these numbers go on a projector. Pins:
* - criteria-mode weighted score computation on vote submit (hand-computed)
* - getResults jury aggregation + ordering
* - tie detection
* - audience favorite tallies stay separate from jury scores (separate awards)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { liveVotingRouter } from '@/server/routers/live-voting'
let program: any
let round: any
let session: any
let p1: any
let p2: any
let jurorA: any
let jurorB: any
let admin: any
let callerA: ReturnType<typeof createCaller>
let callerB: ReturnType<typeof createCaller>
let adminCaller: ReturnType<typeof createCaller>
const CRITERIA = [
{ id: 'impact', label: 'Impact', scale: 10, weight: 0.6 },
{ id: 'feasibility', label: 'Feasibility', scale: 5, weight: 0.4 },
]
beforeAll(async () => {
program = await createTestProgram()
const competition = await createTestCompetition(program.id)
const juryGroup = await prisma.juryGroup.create({
data: { competitionId: competition.id, name: 'Finals Jury', slug: uid('jg') },
})
round = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' })
await prisma.round.update({ where: { id: round.id }, data: { juryGroupId: juryGroup.id } })
p1 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
p2 = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
await prisma.round.update({
where: { id: round.id },
data: {
configJson: {
projectOrder: [p1.id, p2.id],
presentationDurationMinutes: 5,
},
},
})
jurorA = await createTestUser('JURY_MEMBER')
jurorB = await createTestUser('JURY_MEMBER')
admin = await createTestUser('SUPER_ADMIN')
for (const j of [jurorA, jurorB]) {
await prisma.juryGroupMember.create({
data: { juryGroupId: juryGroup.id, userId: j.id, role: 'MEMBER' },
})
}
session = await prisma.liveVotingSession.create({
data: {
roundId: round.id,
status: 'IN_PROGRESS',
currentProjectId: p1.id,
votingMode: 'criteria',
criteriaJson: CRITERIA,
},
})
callerA = createCaller(liveVotingRouter, jurorA)
callerB = createCaller(liveVotingRouter, jurorB)
adminCaller = createCaller(liveVotingRouter, admin)
})
afterAll(async () => {
await cleanupTestData(program.id, [jurorA.id, jurorB.id, admin.id])
})
describe('criteria-mode weighted score', () => {
it('computes the documented weighted normalization on submit', async () => {
// impact 8/10 → 8.0 normalized ·0.6 = 4.8 ; feasibility 4/5 → 8.0 ·0.4 = 3.2
// weighted sum = 8.0 → stored score 8
const vote = await callerA.vote({
sessionId: session.id,
projectId: p1.id,
score: 1, // ignored in criteria mode — recomputed server-side
criterionScores: { impact: 8, feasibility: 4 },
})
expect(vote.score).toBe(8)
})
it('rejects an out-of-scale criterion score', async () => {
await expect(
callerA.vote({
sessionId: session.id,
projectId: p1.id,
score: 1,
criterionScores: { impact: 11, feasibility: 4 },
})
).rejects.toThrow(/between 1 and/)
})
it('rejects a missing criterion', async () => {
await expect(
callerA.vote({
sessionId: session.id,
projectId: p1.id,
score: 1,
criterionScores: { impact: 8 },
})
).rejects.toThrow(/Missing score/)
})
})
describe('getResults aggregation', () => {
it('averages jury votes per project and orders by weighted total', async () => {
// jurorB scores p1: impact 4/10 → 2.4, feasibility 2/5 → 1.6 → 4
await callerB.vote({
sessionId: session.id,
projectId: p1.id,
score: 1,
criterionScores: { impact: 4, feasibility: 2 },
})
// both jurors score p2 (revision path: p2 is in the order)
// jurorA: impact 10/10 → 6.0, feasibility 5/5 → 4.0 → 10
await callerA.vote({
sessionId: session.id,
projectId: p2.id,
score: 1,
criterionScores: { impact: 10, feasibility: 5 },
})
// jurorB: impact 6/10 → 3.6, feasibility 3/5 → 2.4 → 6
await callerB.vote({
sessionId: session.id,
projectId: p2.id,
score: 1,
criterionScores: { impact: 6, feasibility: 3 },
})
const res = await adminCaller.getResults({ sessionId: session.id })
// p1: (8+4)/2 = 6 ; p2: (10+6)/2 = 8 → p2 first
expect(res.results[0].project?.id).toBe(p2.id)
expect(res.results[0].juryAverage).toBe(8)
expect(res.results[0].juryVoteCount).toBe(2)
expect(res.results[1].project?.id).toBe(p1.id)
expect(res.results[1].juryAverage).toBe(6)
// audience weight defaults to 0 → weighted total equals jury average
expect(res.results[0].weightedTotal).toBe(8)
expect(res.weights.audience).toBe(0)
})
it('detects ties', async () => {
// pull p1 up to match p2's average: change jurorB's p1 vote to 10 → (8+10)/2 = 9? no.
// make both averages equal: set jurorA p1 → 10 (impact 10, feas 5) → p1 avg (10+4)/2 = 7
// and jurorA p2 → 8 (impact 8, feas 4) → p2 avg (8+6)/2 = 7
await callerA.vote({
sessionId: session.id,
projectId: p1.id,
score: 1,
criterionScores: { impact: 10, feasibility: 5 },
})
await callerA.vote({
sessionId: session.id,
projectId: p2.id,
score: 1,
criterionScores: { impact: 8, feasibility: 4 },
})
const res = await adminCaller.getResults({ sessionId: session.id })
expect(res.results[0].weightedTotal).toBe(res.results[1].weightedTotal)
expect(res.ties.length).toBeGreaterThan(0)
})
})
describe('audience favorites stay separate', () => {
it('favorite tallies do not touch jury weighted totals', async () => {
await prisma.liveVotingSession.update({
where: { id: session.id },
data: { allowAudienceVotes: true },
})
await adminCaller.openAudienceWindow({
sessionId: session.id,
windowKey: 'CATEGORY:STARTUP',
durationMinutes: 5,
})
const reg = await adminCaller.registerAudienceVoter({ sessionId: session.id })
await adminCaller.castFavoriteVote({
sessionId: session.id,
token: reg.token,
projectId: p1.id,
})
const tallies = await adminCaller.getFavoriteTallies({ sessionId: session.id })
expect(tallies.windows[0].totalVotes).toBe(1)
const res = await adminCaller.getResults({ sessionId: session.id })
// favorite votes are NOT LiveVotes — jury aggregation unchanged
expect(res.results.every((r: any) => r.audienceVoteCount === 0)).toBe(true)
})
})

View File

@@ -0,0 +1,91 @@
/**
* Server-stamped phase timer math: every screen (admin, jury, big screen)
* derives the same countdown from cursor timestamps — no client-local clocks.
*/
import { describe, it, expect } from 'vitest'
import { elapsedMs, remainingSeconds, formatClock, parseClock } 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('elapsedMs accepts ISO strings (serialized cursor)', () => {
expect(
elapsedMs(
{
phaseStartedAt: t0.toISOString(),
phaseDurationSeconds: 300,
phasePausedAt: null,
phasePausedAccumMs: 0,
},
at(45)
)
).toBe(45_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 a running timer', () => {
expect(
remainingSeconds(
{ phaseStartedAt: null, phaseDurationSeconds: null, phasePausedAt: null, phasePausedAccumMs: 0 },
t0
)
).toBeNull()
})
it('formatClock renders m:ss and overtime as +m:ss', () => {
expect(formatClock(305)).toBe('5:05')
expect(formatClock(0)).toBe('0:00')
expect(formatClock(-83)).toBe('+1:23')
})
it('parseClock accepts m:ss, mm:ss and plain minutes', () => {
expect(parseClock('7:30')).toBe(450)
expect(parseClock('12:05')).toBe(725)
expect(parseClock('0:45')).toBe(45)
expect(parseClock('7')).toBe(420) // plain minutes
expect(parseClock(' 3:00 ')).toBe(180) // tolerant of whitespace
})
it('parseClock rejects garbage', () => {
expect(parseClock('')).toBeNull()
expect(parseClock('abc')).toBeNull()
expect(parseClock('5:75')).toBeNull() // seconds must be 0-59
expect(parseClock('-2:00')).toBeNull()
expect(parseClock('1:2:3')).toBeNull()
})
})

View File

@@ -0,0 +1,170 @@
/**
* Grand-finale juror voting extras:
* - optional overall comment stored with the LiveVote (and updatable)
* - getSessionForVotingByRound: the jury page only knows the roundId
* - getMyFinaleInputs: a juror's own finale votes + ceremony notes,
* resurfaced during deliberation
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
cleanupTestData,
uid,
} from '../helpers'
import { liveVotingRouter } from '@/server/routers/live-voting'
let program: any
let round: any
let emptyRound: any
let session: any
let project: any
let juror: any
let jurorCaller: ReturnType<typeof createCaller>
beforeAll(async () => {
program = await createTestProgram()
const competition = await createTestCompetition(program.id)
const juryGroup = await prisma.juryGroup.create({
data: { competitionId: competition.id, name: 'Finals Jury', slug: uid('jg') },
})
round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
status: 'ROUND_ACTIVE',
})
await prisma.round.update({ where: { id: round.id }, data: { juryGroupId: juryGroup.id } })
emptyRound = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', sortOrder: 1 })
project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
juror = await createTestUser('JURY_MEMBER')
await prisma.juryGroupMember.create({
data: { juryGroupId: juryGroup.id, userId: juror.id, role: 'MEMBER' },
})
session = await prisma.liveVotingSession.create({
data: {
roundId: round.id,
status: 'IN_PROGRESS',
currentProjectId: project.id,
votingMode: 'simple',
},
})
jurorCaller = createCaller(liveVotingRouter, juror)
})
afterAll(async () => {
await cleanupTestData(program.id, [juror.id])
})
describe('vote comments', () => {
it('persists an optional comment with the vote', async () => {
const vote = await jurorCaller.vote({
sessionId: session.id,
projectId: project.id,
score: 8,
comment: 'Strong pitch, weak unit economics',
})
expect(vote.comment).toBe('Strong pitch, weak unit economics')
})
it('re-voting updates the comment and keeps it when omitted', async () => {
const updated = await jurorCaller.vote({
sessionId: session.id,
projectId: project.id,
score: 9,
comment: 'Revised after Q&A',
})
expect(updated.score).toBe(9)
expect(updated.comment).toBe('Revised after Q&A')
const again = await jurorCaller.vote({
sessionId: session.id,
projectId: project.id,
score: 7,
})
expect(again.comment).toBe('Revised after Q&A') // omitted comment is not erased
})
})
describe('deliberation-time revision', () => {
it('allows voting for an ordered project that is not currently presenting', async () => {
const otherProject = await createTestProject(program.id, {
competitionCategory: 'BUSINESS_CONCEPT',
})
await prisma.round.update({
where: { id: round.id },
data: { configJson: { projectOrder: [project.id, otherProject.id] } },
})
// otherProject is NOT currentProjectId — revision path must accept it
const vote = await jurorCaller.vote({
sessionId: session.id,
projectId: otherProject.id,
score: 6,
comment: 'revised during deliberation',
})
expect(vote.projectId).toBe(otherProject.id)
})
it('still rejects projects outside the finale order', async () => {
const stranger = await createTestProject(program.id)
await expect(
jurorCaller.vote({ sessionId: session.id, projectId: stranger.id, score: 5 })
).rejects.toThrow()
})
it('allows revision while the session is PAUSED', async () => {
await prisma.liveVotingSession.update({
where: { id: session.id },
data: { status: 'PAUSED' },
})
const vote = await jurorCaller.vote({
sessionId: session.id,
projectId: project.id,
score: 10,
})
expect(vote.score).toBe(10)
await prisma.liveVotingSession.update({
where: { id: session.id },
data: { status: 'IN_PROGRESS' },
})
})
})
describe('getSessionForVotingByRound', () => {
it('resolves the session from a roundId', async () => {
const data = await jurorCaller.getSessionForVotingByRound({ roundId: round.id })
expect(data?.session.id).toBe(session.id)
expect(data?.currentProject?.id).toBe(project.id)
expect(data?.userVote?.score).toBe(10) // last revision in the PAUSED test
})
it('returns null when the round has no session (creates nothing)', async () => {
const data = await jurorCaller.getSessionForVotingByRound({ roundId: emptyRound.id })
expect(data).toBeNull()
const count = await prisma.liveVotingSession.count({ where: { roundId: emptyRound.id } })
expect(count).toBe(0)
})
})
describe('getMyFinaleInputs', () => {
it('returns the callers votes and notes for the round', async () => {
await prisma.liveNote.create({
data: { roundId: round.id, projectId: project.id, userId: juror.id, content: 'ceremony note' },
})
const inputs = await jurorCaller.getMyFinaleInputs({ roundId: round.id })
expect(inputs.session?.id).toBe(session.id)
expect(inputs.votes).toHaveLength(2) // original + deliberation-time revision
const mainVote = inputs.votes.find((v: any) => v.projectId === project.id)
expect(mainVote?.comment).toBe('Revised after Q&A')
expect(inputs.notes).toHaveLength(1)
expect(inputs.notes[0].content).toBe('ceremony note')
})
it('is empty-safe for a round without a session', async () => {
const inputs = await jurorCaller.getMyFinaleInputs({ roundId: emptyRound.id })
expect(inputs.session).toBeNull()
expect(inputs.votes).toHaveLength(0)
})
})

160
tests/unit/reveal.test.ts Normal file
View File

@@ -0,0 +1,160 @@
/**
* Results reveal controller: admin composes steps privately (DRAFT), arms the
* big screen (ARMED), then steps through one reveal at a time (REVEALING →
* DONE). The public ceremony-state endpoint must NEVER leak un-revealed steps.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
createTestProject,
cleanupTestData,
} from '../helpers'
import { liveVotingRouter } from '@/server/routers/live-voting'
import { liveRouter } from '@/server/routers/live'
let program: any
let round: any
let session: any
let project: any
let admin: any
let adminCaller: ReturnType<typeof createCaller>
let publicCaller: ReturnType<typeof createCaller>
const steps = [
{ kind: 'category-intro' as const, category: 'STARTUP' as const, title: 'Startups' },
{ kind: 'place' as const, category: 'STARTUP' as const, place: 3, title: 'Team Gamma', subtitle: '3rd place — Startups' },
{ kind: 'place' as const, category: 'STARTUP' as const, place: 2, title: 'Team Beta', subtitle: '2nd place — Startups' },
{ kind: 'place' as const, category: 'STARTUP' as const, place: 1, title: 'Team Alpha', subtitle: 'Winner — Startups' },
{ kind: 'thanks' as const, title: 'Thank you' },
]
beforeAll(async () => {
program = await createTestProgram()
const competition = await createTestCompetition(program.id)
round = await createTestRound(competition.id, { roundType: 'LIVE_FINAL', status: 'ROUND_ACTIVE' })
project = await createTestProject(program.id, { competitionCategory: 'STARTUP' })
await prisma.round.update({
where: { id: round.id },
data: { configJson: { projectOrder: [project.id] } },
})
session = await prisma.liveVotingSession.create({ data: { roundId: round.id } })
admin = await createTestUser('SUPER_ADMIN')
adminCaller = createCaller(liveVotingRouter, admin)
publicCaller = createCaller(liveVotingRouter, admin)
// a cursor so getCeremonyState has phase data
const liveCaller = createCaller(liveRouter, admin)
await liveCaller.start({ roundId: round.id, projectOrder: [project.id] })
})
afterAll(async () => {
await cleanupTestData(program.id, [admin.id])
})
describe('reveal lifecycle', () => {
it('saveReveal upserts a DRAFT', async () => {
const r1 = await adminCaller.saveReveal({ sessionId: session.id, steps: steps.slice(0, 2) })
expect(r1.status).toBe('DRAFT')
const r2 = await adminCaller.saveReveal({ sessionId: session.id, steps })
expect((r2.stepsJson as any[]).length).toBe(5)
expect(r2.currentStepIndex).toBe(-1)
})
it('armReveal requires steps and moves DRAFT → ARMED', async () => {
const r = await adminCaller.armReveal({ sessionId: session.id })
expect(r.status).toBe('ARMED')
})
it('revealNext steps through and lands on DONE at the last step', async () => {
let r = await adminCaller.revealNext({ sessionId: session.id })
expect(r.status).toBe('REVEALING')
expect(r.currentStepIndex).toBe(0)
r = await adminCaller.revealNext({ sessionId: session.id })
expect(r.currentStepIndex).toBe(1)
r = await adminCaller.revealNext({ sessionId: session.id })
r = await adminCaller.revealNext({ sessionId: session.id })
r = await adminCaller.revealNext({ sessionId: session.id })
expect(r.currentStepIndex).toBe(4)
expect(r.status).toBe('DONE')
// advancing past the end stays clamped at the final step
r = await adminCaller.revealNext({ sessionId: session.id })
expect(r.currentStepIndex).toBe(4)
expect(r.status).toBe('DONE')
})
it('public ceremony state only exposes revealed steps', async () => {
// wind back mid-reveal
await prisma.revealState.update({
where: { sessionId: session.id },
data: { status: 'REVEALING', currentStepIndex: 1 },
})
const state = await publicCaller.getCeremonyState({ roundId: round.id })
expect(state.reveal?.status).toBe('REVEALING')
expect(state.reveal?.steps).toHaveLength(2) // steps 0 and 1 only
expect(state.reveal?.steps?.some((s: any) => s.title === 'Team Alpha')).toBe(false)
})
it('public ceremony state shows no steps when ARMED and no reveal when DRAFT', async () => {
await prisma.revealState.update({
where: { sessionId: session.id },
data: { status: 'ARMED', currentStepIndex: -1 },
})
let state = await publicCaller.getCeremonyState({ roundId: round.id })
expect(state.reveal?.status).toBe('ARMED')
expect(state.reveal?.steps).toHaveLength(0)
await adminCaller.resetReveal({ sessionId: session.id })
state = await publicCaller.getCeremonyState({ roundId: round.id })
expect(state.reveal).toBeNull()
})
it('reveal mutations are admin-only', async () => {
const juror = await createTestUser('JURY_MEMBER')
const jurorCaller = createCaller(liveVotingRouter, juror)
await expect(jurorCaller.armReveal({ sessionId: session.id })).rejects.toThrow()
await expect(jurorCaller.revealNext({ sessionId: session.id })).rejects.toThrow()
await prisma.user.delete({ where: { id: juror.id } })
})
})
describe('ceremony state composition', () => {
it('exposes phase, active project, audience window and never any scores', async () => {
const state = await publicCaller.getCeremonyState({ roundId: round.id })
expect(state.phase?.projectPhase).toBe('ON_DECK')
expect(state.activeProject?.title).toBe(project.title)
expect(state.audience.open).toBe(false)
expect(JSON.stringify(state)).not.toMatch(/score/i)
})
it('includes the live vote count while a window is open', async () => {
await adminCaller.updateSessionConfig({ sessionId: session.id, allowAudienceVotes: true })
await adminCaller.openAudienceWindow({
sessionId: session.id,
windowKey: 'CATEGORY:STARTUP',
durationMinutes: 5,
})
const reg = await publicCaller.registerAudienceVoter({ sessionId: session.id })
await publicCaller.castFavoriteVote({
sessionId: session.id,
token: reg.token,
projectId: project.id,
})
const state = await publicCaller.getCeremonyState({ roundId: round.id })
expect(state.audience.open).toBe(true)
expect(state.audience.windowKey).toBe('CATEGORY:STARTUP')
expect(state.audience.voteCount).toBe(1)
})
it('reports the override slide', async () => {
const liveCaller = createCaller(liveRouter, admin)
await liveCaller.setOverrideSlide({ roundId: round.id, slide: 'break' })
const state = await publicCaller.getCeremonyState({ roundId: round.id })
expect(state.overrideSlide).toBe('break')
})
})

View File

@@ -0,0 +1,58 @@
/**
* Regression: saving the round Config form must NOT wipe operational keys that
* live outside the form schema (ceremony projectOrder, per-project timing
* overrides, finals-docs upload toggle). Found live: a Config save mid-test
* destroyed the running ceremony's run order.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma, createCaller } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
cleanupTestData,
} from '../helpers'
import { roundRouter } from '@/server/routers/round'
let program: any
let round: any
let admin: any
let adminCaller: ReturnType<typeof createCaller>
beforeAll(async () => {
program = await createTestProgram()
const competition = await createTestCompetition(program.id)
round = await createTestRound(competition.id, {
roundType: 'LIVE_FINAL',
status: 'ROUND_ACTIVE',
configJson: {
presentationDurationMinutes: 5,
projectOrder: ['p-one', 'p-two'],
projectTimingOverrides: { 'p-one': { presentationSeconds: 480 } },
allowFinalistRevisedUploads: true,
},
})
admin = await createTestUser('SUPER_ADMIN')
adminCaller = createCaller(roundRouter, admin)
})
afterAll(async () => {
await cleanupTestData(program.id, [admin.id])
})
describe('round.update configJson merge', () => {
it('updates form fields without wiping operational keys', async () => {
await adminCaller.update({
id: round.id,
configJson: { presentationDurationMinutes: 10, qaDurationMinutes: 3 },
})
const updated = await prisma.round.findUniqueOrThrow({ where: { id: round.id } })
const cfg = updated.configJson as Record<string, unknown>
expect(cfg.presentationDurationMinutes).toBe(10) // form field applied
expect(cfg.qaDurationMinutes).toBe(3)
expect(cfg.projectOrder).toEqual(['p-one', 'p-two']) // ceremony state survives
expect((cfg.projectTimingOverrides as any)['p-one'].presentationSeconds).toBe(480)
expect(cfg.allowFinalistRevisedUploads).toBe(true) // finals-docs toggle survives
})
})