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 (
{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
+ })
+})