From ac86e025e21cabddb856c46973bf1f6a87f3c700 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 2 Mar 2026 19:57:11 +0100 Subject: [PATCH] feat: ranking in-progress indicator persists across all admin users - 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 --- .../admin/round/ranking-dashboard.tsx | 18 +++--- src/server/routers/ranking.ts | 58 ++++++++++++------- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/components/admin/round/ranking-dashboard.tsx b/src/components/admin/round/ranking-dashboard.tsx index 5421123..34ee06d 100644 --- a/src/components/admin/round/ranking-dashboard.tsx +++ b/src/components/admin/round/ranking-dashboard.tsx @@ -274,11 +274,17 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran // ─── tRPC queries ───────────────────────────────────────────────────────── const { data: snapshots, isLoading: snapshotsLoading } = trpc.ranking.listSnapshots.useQuery( { roundId }, - { refetchInterval: 30_000 }, + // Poll every 3s so all admins see ranking progress/completion quickly + { refetchInterval: 3_000 }, ) - const latestSnapshotId = snapshots?.[0]?.id ?? null - const latestSnapshot = snapshots?.[0] ?? null + // Derive ranking-in-progress from server state (visible to ALL admins) + 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( { snapshotId: latestSnapshotId! }, @@ -322,20 +328,16 @@ export function RankingDashboard({ competitionId: _competitionId, roundId }: Ran onError: (err) => toast.error(`Failed to save: ${err.message}`), }) - const [rankingInProgress, setRankingInProgress] = useState(false) - const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({ - onMutate: () => setRankingInProgress(true), onSuccess: () => { toast.success('Ranking complete!') initialized.current = false // allow re-init on next snapshot load void utils.ranking.listSnapshots.invalidate({ roundId }) void utils.ranking.getSnapshot.invalidate() - setRankingInProgress(false) }, onError: (err) => { toast.error(err.message) - setRankingInProgress(false) + void utils.ranking.listSnapshots.invalidate({ roundId }) }, }) diff --git a/src/server/routers/ranking.ts b/src/server/routers/ranking.ts index 9fa57e1..acc3c76 100644 --- a/src/server/routers/ranking.ts +++ b/src/server/routers/ranking.ts @@ -278,37 +278,55 @@ export const rankingRouter = router({ }) } - const result = await aiQuickRank(criteriaText, roundId, ctx.prisma, ctx.user.id) - - // Embed weights alongside rules for audit - const criteriaWeights = config.criteriaWeights ?? undefined - const parsedRulesWithWeights = { rules: result.parsedRules, weights: criteriaWeights } as unknown as Prisma.InputJsonValue + // Create a RUNNING snapshot so all admins see the in-progress indicator const snapshot = await ctx.prisma.rankingSnapshot.create({ data: { roundId, triggeredById: ctx.user.id, triggerType: 'MANUAL', criteriaText, - parsedRulesJson: parsedRulesWithWeights, - startupRankingJson: result.startup.rankedProjects as unknown as Prisma.InputJsonValue, - conceptRankingJson: result.concept.rankedProjects as unknown as Prisma.InputJsonValue, + parsedRulesJson: {} as Prisma.InputJsonValue, mode: 'QUICK', - status: 'COMPLETED', + status: 'RUNNING', }, }) - 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, - }) + try { + const result = await aiQuickRank(criteriaText, roundId, ctx.prisma, ctx.user.id) - 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 + } }), /**