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>
</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
key={`${activeProject.id}-${sessionData?.userVote?.votedAt ?? 'fresh'}`}
projectId={activeProject.id}
votingMode={votingMode}
criteria={criteria}

View File

@@ -95,6 +95,7 @@ function ProjectReviewCard({
</p>
{finaleInputs?.session?.id ? (
<LiveVotingForm
key={`${project.id}-${myVote?.votedAt ?? 'fresh'}`}
projectId={project.id}
votingMode={votingMode}
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 saveReveal = trpc.liveVoting.saveReveal.useMutation({
onSuccess: () => {
setDraftSteps(null) // the saved copy is now canonical — unlocks Arm
invalidate()
toast.success('Reveal draft saved')
},
@@ -107,18 +108,21 @@ export function RevealPanel({ roundId, competitionId }: { roundId: string; compe
const composed: RevealStep[] = []
const categories: Array<'BUSINESS_CONCEPT' | 'STARTUP'> = ['BUSINESS_CONCEPT', 'STARTUP']
let usedDeliberation = false
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(
(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[] = []
// listSessions has no results — use jury results ordered by weightedTotal as base
const categoryResults = (results?.results ?? []).filter(
(r: any) => r.project?.id && categoryOf(r.project.id) === category
)
rankedProjectIds = categoryResults.map((r: any) => r.project.id)
void delib // deliberation results are applied via "adjust manually" — see note below
let rankedProjectIds: string[]
if (delib) {
rankedProjectIds = delib.results.map((r: any) => r.projectId)
usedDeliberation = true
} else {
rankedProjectIds = (results?.results ?? [])
.filter((r: any) => r.project?.id && categoryOf(r.project.id) === category)
.map((r: any) => r.project.id)
}
if (rankedProjectIds.length === 0) continue
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')
return
}
toast.success(
usedDeliberation
? 'Composed from locked deliberation results + audience tallies'
: 'Composed from jury scores + audience tallies (no locked deliberation yet)'
)
setDraftSteps(composed)
}
@@ -226,8 +235,9 @@ export function RevealPanel({ roundId, competitionId }: { roundId: string; compe
</Button>
</div>
<p className="text-xs text-muted-foreground">
Composed from jury scores (top 3 per category, revealed 3rd 1st) + audience
tallies. If deliberation changed the order, adjust the steps below before saving.
Locked deliberation results take precedence; otherwise jury scores (top 3 per
category, revealed 3rd 1st), plus audience tallies. Adjust the steps below
before saving if needed.
</p>
</>
)}

View File

@@ -90,7 +90,9 @@ export function LiveVotingForm({
} else {
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)
}

View File

@@ -320,6 +320,10 @@ export const deliberationRouter = router({
participants: {
select: { userId: true },
},
results: {
select: { projectId: true, finalRank: true, isAdminOverridden: true },
orderBy: { finalRank: 'asc' },
},
},
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
})
@@ -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({
prisma: ctx.prisma,
userId: ctx.user.id,