feat: ranking in-progress indicator persists across all admin users
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m1s

- Create snapshot with status RUNNING before AI call starts
- Update to COMPLETED/FAILED when done
- Dashboard derives rankingInProgress from server snapshot status
- All admins see the spinner, not just the one who triggered it
- Poll snapshots every 3s so progress updates appear quickly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 19:57:11 +01:00
parent 5a3f8d9837
commit ac86e025e2
2 changed files with 48 additions and 28 deletions

View File

@@ -274,11 +274,17 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
// ─── tRPC queries ───────────────────────────────────────────────────────── // ─── tRPC queries ─────────────────────────────────────────────────────────
const { data: snapshots, isLoading: snapshotsLoading } = trpc.ranking.listSnapshots.useQuery( const { data: snapshots, isLoading: snapshotsLoading } = trpc.ranking.listSnapshots.useQuery(
{ roundId }, { roundId },
{ refetchInterval: 30_000 }, // Poll every 3s so all admins see ranking progress/completion quickly
{ refetchInterval: 3_000 },
) )
const latestSnapshotId = snapshots?.[0]?.id ?? null // Derive ranking-in-progress from server state (visible to ALL admins)
const latestSnapshot = snapshots?.[0] ?? null const rankingInProgress = snapshots?.[0]?.status === 'RUNNING'
// Find the latest COMPLETED snapshot (skip RUNNING/FAILED)
const latestCompleted = snapshots?.find((s) => s.status === 'COMPLETED')
const latestSnapshotId = latestCompleted?.id ?? null
const latestSnapshot = latestCompleted ?? null
const { data: snapshot, isLoading: snapshotLoading } = trpc.ranking.getSnapshot.useQuery( const { data: snapshot, isLoading: snapshotLoading } = trpc.ranking.getSnapshot.useQuery(
{ snapshotId: latestSnapshotId! }, { snapshotId: latestSnapshotId! },
@@ -322,20 +328,16 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran
onError: (err) => toast.error(`Failed to save: ${err.message}`), onError: (err) => toast.error(`Failed to save: ${err.message}`),
}) })
const [rankingInProgress, setRankingInProgress] = useState(false)
const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({ const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({
onMutate: () => setRankingInProgress(true),
onSuccess: () => { onSuccess: () => {
toast.success('Ranking complete!') toast.success('Ranking complete!')
initialized.current = false // allow re-init on next snapshot load initialized.current = false // allow re-init on next snapshot load
void utils.ranking.listSnapshots.invalidate({ roundId }) void utils.ranking.listSnapshots.invalidate({ roundId })
void utils.ranking.getSnapshot.invalidate() void utils.ranking.getSnapshot.invalidate()
setRankingInProgress(false)
}, },
onError: (err) => { onError: (err) => {
toast.error(err.message) toast.error(err.message)
setRankingInProgress(false) void utils.ranking.listSnapshots.invalidate({ roundId })
}, },
}) })

View File

@@ -278,37 +278,55 @@ export const rankingRouter = router({
}) })
} }
const result = await aiQuickRank(criteriaText, roundId, ctx.prisma, ctx.user.id) // Create a RUNNING snapshot so all admins see the in-progress indicator
// Embed weights alongside rules for audit
const criteriaWeights = config.criteriaWeights ?? undefined
const parsedRulesWithWeights = { rules: result.parsedRules, weights: criteriaWeights } as unknown as Prisma.InputJsonValue
const snapshot = await ctx.prisma.rankingSnapshot.create({ const snapshot = await ctx.prisma.rankingSnapshot.create({
data: { data: {
roundId, roundId,
triggeredById: ctx.user.id, triggeredById: ctx.user.id,
triggerType: 'MANUAL', triggerType: 'MANUAL',
criteriaText, criteriaText,
parsedRulesJson: parsedRulesWithWeights, parsedRulesJson: {} as Prisma.InputJsonValue,
startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue,
conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue,
mode: 'QUICK', mode: 'QUICK',
status: 'COMPLETED', status: 'RUNNING',
}, },
}) })
await logAudit({ try {
prisma: ctx.prisma, const result = await aiQuickRank(criteriaText, roundId, ctx.prisma, ctx.user.id)
userId: ctx.user.id,
action: 'RANKING_MANUAL_TRIGGERED',
entityType: 'RankingSnapshot',
entityId: snapshot.id,
detailsJson: { roundId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { snapshotId: snapshot.id, startup: result.startup, concept: result.concept } // Embed weights alongside rules for audit
const criteriaWeights = config.criteriaWeights ?? undefined
const parsedRulesWithWeights = { rules: result.parsedRules, weights: criteriaWeights } as unknown as Prisma.InputJsonValue
await ctx.prisma.rankingSnapshot.update({
where: { id: snapshot.id },
data: {
status: 'COMPLETED',
parsedRulesJson: parsedRulesWithWeights,
startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue,
conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue,
},
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'RANKING_MANUAL_TRIGGERED',
entityType: 'RankingSnapshot',
entityId: snapshot.id,
detailsJson: { roundId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { snapshotId: snapshot.id, startup: result.startup, concept: result.concept }
} catch (err) {
// Mark snapshot as FAILED so the indicator clears
await ctx.prisma.rankingSnapshot.update({
where: { id: snapshot.id },
data: { status: 'FAILED' },
})
throw err
}
}), }),
/** /**