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>
This commit is contained in:
Matt
2026-06-10 20:14:49 +02:00
parent 9b56eb27fb
commit 2945a92193
8 changed files with 306 additions and 22 deletions

View File

@@ -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<string, unknown>) ?? {}
@@ -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<string, ProjectTimingOverride>) ?? {},
}
}
@@ -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<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).
*/
@@ -817,6 +876,8 @@ export const liveRouter = router({
activeProject,
projectOrder,
orderedProjects,
projectTimingOverrides:
((config.projectTimingOverrides as Record<string, { presentationSeconds?: number; qaSeconds?: number }>) ?? {}),
totalProjects: projectOrder.length,
openCohorts,
}

View File

@@ -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<string, unknown>) ?? {}),
...(parsed as Record<string, unknown>),
} as unknown as Prisma.InputJsonValue
}
const round = await ctx.prisma.round.update({