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
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user