diff --git a/src/app/(jury)/jury/competitions/[roundId]/live/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/live/page.tsx index d8ae3b7..e60c16d 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/live/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/live/page.tsx @@ -232,8 +232,11 @@ export default function JuryLivePage({ params: paramsPromise }: { params: Promis - {/* 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. */} {finaleInputs?.session?.id ? ( 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

- 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.

)} diff --git a/src/components/jury/live-voting-form.tsx b/src/components/jury/live-voting-form.tsx index f23e671..904b758 100644 --- a/src/components/jury/live-voting-form.tsx +++ b/src/components/jury/live-voting-form.tsx @@ -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) } diff --git a/src/server/routers/deliberation.ts b/src/server/routers/deliberation.ts index 5168c20..9493898 100644 --- a/src/server/routers/deliberation.ts +++ b/src/server/routers/deliberation.ts @@ -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' }, }) diff --git a/src/server/routers/live.ts b/src/server/routers/live.ts index f3a9290..cb8a9a3 100644 --- a/src/server/routers/live.ts +++ b/src/server/routers/live.ts @@ -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, diff --git a/tests/unit/live-phase.test.ts b/tests/unit/live-phase.test.ts index 7db6b4e..46e53fb 100644 --- a/tests/unit/live-phase.test.ts +++ b/tests/unit/live-phase.test.ts @@ -160,6 +160,19 @@ describe('voting session sync', () => { expect(updated.currentProjectId).toBe(p1.id) 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', () => {