fix(finale): live-verification fixes — session sync on start, honest vote form, reveal panel polish
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m43s

Found by driving the full ceremony end-to-end in the browser:
- live.start + startPresentation sync LiveVotingSession (status/currentProjectId)
  — jury votes silently failed when the admin never used sendToScreens
- LiveVotingForm no longer shows 'submitted' on a failed mutation; pages
  re-key on votedAt so async vote data renders the right state after refresh
- reveal compose prefers DELIB_LOCKED deliberation results over jury score
  order (listSessions now includes results); Arm unlocks right after save
- deliberation jury page review cards re-key on revision

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-06-10 19:24:45 +02:00
parent 160333c2f9
commit 9b56eb27fb
7 changed files with 58 additions and 13 deletions

View File

@@ -232,8 +232,11 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis
</CardContent> </CardContent>
</Card> </Card>
{/* Scoring — available from presentation start, spotlighted at SCORING */} {/* Scoring — available from presentation start, spotlighted at SCORING.
Keyed on vote presence: the form initializes its editing state from
existingVote, which arrives async after mount. */}
<LiveVotingForm <LiveVotingForm
key={`${activeProject.id}-${sessionData?.userVote?.votedAt ?? 'fresh'}`}
projectId={activeProject.id} projectId={activeProject.id}
votingMode={votingMode} votingMode={votingMode}
criteria={criteria} criteria={criteria}

View File

@@ -95,6 +95,7 @@ function ProjectReviewCard({
</p> </p>
{finaleInputs?.session?.id ? ( {finaleInputs?.session?.id ? (
<LiveVotingForm <LiveVotingForm
key={`${project.id}-${myVote?.votedAt ?? 'fresh'}`}
projectId={project.id} projectId={project.id}
votingMode={votingMode} votingMode={votingMode}
criteria={criteria} criteria={criteria}

View File

@@ -80,6 +80,7 @@ export function RevealPanel({ roundId, competitionId }: { roundId: string; compe
const onError = (err: { message: string }) => toast.error(err.message) const onError = (err: { message: string }) => toast.error(err.message)
const saveReveal = trpc.liveVoting.saveReveal.useMutation({ const saveReveal = trpc.liveVoting.saveReveal.useMutation({
onSuccess: () => { onSuccess: () => {
setDraftSteps(null) // the saved copy is now canonical — unlocks Arm
invalidate() invalidate()
toast.success('Reveal draft saved') toast.success('Reveal draft saved')
}, },
@@ -107,18 +108,21 @@ export function RevealPanel({ roundId, competitionId }: { roundId: string; compe
const composed: RevealStep[] = [] const composed: RevealStep[] = []
const categories: Array<'BUSINESS_CONCEPT' | 'STARTUP'> = ['BUSINESS_CONCEPT', 'STARTUP'] const categories: Array<'BUSINESS_CONCEPT' | 'STARTUP'> = ['BUSINESS_CONCEPT', 'STARTUP']
let usedDeliberation = false
for (const category of categories) { for (const category of categories) {
// Prefer finalized deliberation results; fall back to jury score order // Locked deliberation results take precedence; jury score order is the fallback
const delib = (delibSessions ?? []).find( const delib = (delibSessions ?? []).find(
(s: any) => s.category === category && s.roundId && s._count?.votes !== undefined (s: any) => s.category === category && s.status === 'DELIB_LOCKED' && s.results?.length > 0
) )
let rankedProjectIds: string[] = [] let rankedProjectIds: string[]
// listSessions has no results — use jury results ordered by weightedTotal as base if (delib) {
const categoryResults = (results?.results ?? []).filter( rankedProjectIds = delib.results.map((r: any) => r.projectId)
(r: any) => r.project?.id && categoryOf(r.project.id) === category usedDeliberation = true
) } else {
rankedProjectIds = categoryResults.map((r: any) => r.project.id) rankedProjectIds = (results?.results ?? [])
void delib // deliberation results are applied via "adjust manually" — see note below .filter((r: any) => r.project?.id && categoryOf(r.project.id) === category)
.map((r: any) => r.project.id)
}
if (rankedProjectIds.length === 0) continue if (rankedProjectIds.length === 0) continue
composed.push({ composed.push({
@@ -170,6 +174,11 @@ export function RevealPanel({ roundId, competitionId }: { roundId: string; compe
toast.info('No results to compose from yet — scores and votes are still empty') toast.info('No results to compose from yet — scores and votes are still empty')
return return
} }
toast.success(
usedDeliberation
? 'Composed from locked deliberation results + audience tallies'
: 'Composed from jury scores + audience tallies (no locked deliberation yet)'
)
setDraftSteps(composed) setDraftSteps(composed)
} }
@@ -226,8 +235,9 @@ export function RevealPanel({ roundId, competitionId }: { roundId: string; compe
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Composed from jury scores (top 3 per category, revealed 3rd 1st) + audience Locked deliberation results take precedence; otherwise jury scores (top 3 per
tallies. If deliberation changed the order, adjust the steps below before saving. category, revealed 3rd 1st), plus audience tallies. Adjust the steps below
before saving if needed.
</p> </p>
</> </>
)} )}

View File

@@ -90,7 +90,9 @@ export function LiveVotingForm({
} else { } else {
onVoteSubmit({ score, comment: comment.trim() || undefined }) onVoteSubmit({ score, comment: comment.trim() || undefined })
} }
setEditing(false) // NOTE: editing is NOT flipped here — the parent re-keys this component
// when the persisted vote actually lands, so a failed mutation leaves the
// form editable instead of lying with a "submitted" state.
setConfirmDialogOpen(false) setConfirmDialogOpen(false)
} }

View File

@@ -320,6 +320,10 @@ export const deliberationRouter = router({
participants: { participants: {
select: { userId: true }, select: { userId: true },
}, },
results: {
select: { projectId: true, finalRank: true, isAdminOverridden: true },
orderBy: { finalRank: 'asc' },
},
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })

View File

@@ -126,6 +126,13 @@ export const liveRouter = router({
}, },
}) })
// Keep the voting session in lockstep from the very start so jury
// votes target the active project (a session may not exist yet).
await tx.liveVotingSession.updateMany({
where: { roundId: input.roundId },
data: { currentProjectId: input.projectOrder[0], status: 'IN_PROGRESS' },
})
return created return created
}) })
@@ -485,6 +492,11 @@ export const liveRouter = router({
: {}), : {}),
}, },
}) })
// Sync the voting session even when the admin skips sendToScreens
await ctx.prisma.liveVotingSession.updateMany({
where: { roundId: input.roundId },
data: { currentProjectId: cursor.activeProjectId, status: 'IN_PROGRESS' },
})
await logAudit({ await logAudit({
prisma: ctx.prisma, prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,

View File

@@ -160,6 +160,19 @@ describe('voting session sync', () => {
expect(updated.currentProjectId).toBe(p1.id) expect(updated.currentProjectId).toBe(p1.id)
expect(updated.status).toBe('IN_PROGRESS') expect(updated.status).toBe('IN_PROGRESS')
}) })
it('startPresentation re-syncs a session that drifted out of band', async () => {
await prisma.liveVotingSession.update({
where: { roundId: round.id },
data: { status: 'NOT_STARTED', currentProjectId: null },
})
await adminCaller.startPresentation({ roundId: round.id, durationSeconds: 60 })
const updated = await prisma.liveVotingSession.findUniqueOrThrow({
where: { roundId: round.id },
})
expect(updated.currentProjectId).toBe(p1.id)
expect(updated.status).toBe('IN_PROGRESS')
})
}) })
describe('juror notes', () => { describe('juror notes', () => {