From 2945a9219350e8e9f44baf5ad75844076664bbdf Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 10 Jun 2026 20:14:49 +0200 Subject: [PATCH] feat(finale): per-project presentation/Q&A durations in m:ss + config-save merge fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/components/admin/live/phase-controls.tsx | 26 +++---- src/components/admin/live/run-order-list.tsx | 80 +++++++++++++++++++- src/lib/live-timer.ts | 18 +++++ src/server/routers/live.ts | 65 +++++++++++++++- src/server/routers/round.ts | 11 ++- tests/unit/live-phase.test.ts | 52 +++++++++++++ tests/unit/live-timer.test.ts | 18 ++++- tests/unit/round-config-merge.test.ts | 58 ++++++++++++++ 8 files changed, 306 insertions(+), 22 deletions(-) create mode 100644 tests/unit/round-config-merge.test.ts diff --git a/src/components/admin/live/phase-controls.tsx b/src/components/admin/live/phase-controls.tsx index b916f0b..44ffa6b 100644 --- a/src/components/admin/live/phase-controls.tsx +++ b/src/components/admin/live/phase-controls.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { remainingSeconds, formatClock } from '@/lib/live-timer' +import { remainingSeconds, formatClock, parseClock } from '@/lib/live-timer' import { Mic2, MessageCircleQuestion, @@ -65,10 +65,7 @@ export function PhaseControls({ roundId }: { roundId: string }) { openScoring.isPending || sendToScreens.isPending - const durationSeconds = (raw: string) => { - const min = parseFloat(raw) - return Number.isFinite(min) && min > 0 ? Math.round(min * 60) : undefined - } + const durationSeconds = (raw: string) => parseClock(raw) ?? undefined const nextProject = (() => { const order = cursor.orderedProjects ?? [] @@ -197,26 +194,23 @@ export function PhaseControls({ roundId }: { roundId: string }) { ))} - {/* Duration overrides for the NEXT start */} + {/* One-off duration overrides for the NEXT start only (m:ss). + Per-project durations live in the Run Order list. */}
- + setPresentationMin(e.target.value)} />
- + setQaMin(e.target.value)} /> diff --git a/src/components/admin/live/run-order-list.tsx b/src/components/admin/live/run-order-list.tsx index 60ad7d5..7ba976d 100644 --- a/src/components/admin/live/run-order-list.tsx +++ b/src/components/admin/live/run-order-list.tsx @@ -1,10 +1,13 @@ '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 { ArrowDown, ArrowUp, MonitorUp } from 'lucide-react' +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 = { @@ -20,11 +23,32 @@ const CATEGORY_LABEL: Record = { 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>({}) 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 }) @@ -81,6 +105,9 @@ export function RunOrderList({ roundId }: { roundId: string }) { } 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 (
{project.teamName}

)} + {/* Per-project durations (m:ss) — empty = round default */} +
+ + + + m:ss +
{isActive && ( diff --git a/src/lib/live-timer.ts b/src/lib/live-timer.ts index 0d95625..5ec3284 100644 --- a/src/lib/live-timer.ts +++ b/src/lib/live-timer.ts @@ -34,3 +34,21 @@ export function formatClock(seconds: number): string { 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 +} diff --git a/src/server/routers/live.ts b/src/server/routers/live.ts index cb8a9a3..ad3b54a 100644 --- a/src/server/routers/live.ts +++ b/src/server/routers/live.ts @@ -45,6 +45,8 @@ function closedOutTiming(cursor: LiveProgressCursor, now: Date): Prisma.InputJso 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) ?? {} @@ -55,6 +57,7 @@ async function getRoundCeremonyConfig(prisma: PrismaClient, roundId: string) { : 300, qaSeconds: typeof cfg.qaDurationMinutes === 'number' ? cfg.qaDurationMinutes * 60 : 300, projectOrder: (cfg.projectOrder as string[]) ?? [], + timingOverrides: (cfg.projectTimingOverrides as Record) ?? {}, } } @@ -478,13 +481,15 @@ export const liveRouter = router({ 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 ?? cfg.presentationSeconds, + phaseDurationSeconds: + input.durationSeconds ?? projectOverride?.presentationSeconds ?? cfg.presentationSeconds, phasePausedAt: null, phasePausedAccumMs: 0, ...(closedOutTiming(cursor, now) !== undefined @@ -528,13 +533,14 @@ export const liveRouter = router({ 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 ?? cfg.qaSeconds, + phaseDurationSeconds: input.durationSeconds ?? projectOverride?.qaSeconds ?? cfg.qaSeconds, phasePausedAt: null, phasePausedAccumMs: 0, ...(closedOutTiming(cursor, now) !== undefined @@ -657,6 +663,59 @@ export const liveRouter = router({ 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) ?? {}) as Record + const overrides = { ...((cfg.projectTimingOverrides as Record) ?? {}) } + 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). */ @@ -817,6 +876,8 @@ export const liveRouter = router({ activeProject, projectOrder, orderedProjects, + projectTimingOverrides: + ((config.projectTimingOverrides as Record) ?? {}), totalProjects: projectOrder.length, openCohorts, } diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index 7576ca8..11e105d 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -159,11 +159,18 @@ export const roundRouter = router({ 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 if (configJson) { const parsed = validateRoundConfig(existing.roundType, configJson) - validatedConfig = parsed as unknown as Prisma.InputJsonValue + validatedConfig = { + ...((existing.configJson as Record) ?? {}), + ...(parsed as Record), + } as unknown as Prisma.InputJsonValue } const round = await ctx.prisma.round.update({ diff --git a/tests/unit/live-phase.test.ts b/tests/unit/live-phase.test.ts index 46e53fb..db5ce30 100644 --- a/tests/unit/live-phase.test.ts +++ b/tests/unit/live-phase.test.ts @@ -175,6 +175,58 @@ describe('voting session sync', () => { }) }) +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' }) diff --git a/tests/unit/live-timer.test.ts b/tests/unit/live-timer.test.ts index 374ea91..83ae0fe 100644 --- a/tests/unit/live-timer.test.ts +++ b/tests/unit/live-timer.test.ts @@ -3,7 +3,7 @@ * derives the same countdown from cursor timestamps — no client-local clocks. */ import { describe, it, expect } from 'vitest' -import { elapsedMs, remainingSeconds, formatClock } from '@/lib/live-timer' +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) @@ -72,4 +72,20 @@ describe('live-timer', () => { 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() + }) }) diff --git a/tests/unit/round-config-merge.test.ts b/tests/unit/round-config-merge.test.ts new file mode 100644 index 0000000..b2bb9f0 --- /dev/null +++ b/tests/unit/round-config-merge.test.ts @@ -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 + +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 + 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 + }) +})