feat(finale): per-project phase machine, server timers, overtime log, juror notes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-10 18:07:02 +02:00
parent 97ef3e59ac
commit 6eccfc694e
2 changed files with 545 additions and 1 deletions

View File

@@ -1,9 +1,61 @@
import { z } from 'zod'
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 { logAudit } from '@/server/utils/audit'
// ─── Grand-finale phase machine helpers ─────────────────────────────────────
type TimingEntry = {
projectId: string
phase: 'PRESENTING' | 'QA'
startedAt: string
endedAt: string
configuredSeconds: number | null
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,
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
}
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[]) ?? [],
}
}
export const liveRouter = router({
/**
* Start a live presentation session for a stage
@@ -344,6 +396,318 @@ export const liveRouter = router({
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) }
: {}),
},
})
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 now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
projectPhase: 'PRESENTING',
phaseStartedAt: now,
phaseDurationSeconds: input.durationSeconds ?? cfg.presentationSeconds,
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: '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 now = new Date()
const updated = await ctx.prisma.liveProgressCursor.update({
where: { id: cursor.id },
data: {
projectPhase: 'QA',
phaseStartedAt: now,
phaseDurationSeconds: input.durationSeconds ?? 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
}),
/**
* 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' },
})
}),
/**
* Get current cursor state (for all users, including audience)
*/
@@ -376,10 +740,22 @@ export const liveRouter = router({
teamName: true,
description: 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)
const openCohorts = await ctx.prisma.cohort.findMany({
where: { roundId: input.roundId, isOpen: true },
@@ -395,6 +771,7 @@ export const liveRouter = router({
...cursor,
activeProject,
projectOrder,
orderedProjects,
totalProjects: projectOrder.length,
openCohorts,
}