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