From 79bd4dbae76dc50b4c975c8d0b105a957cd9e1c5 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 25 Feb 2026 15:15:08 +0100 Subject: [PATCH] feat: add juror progress dashboard with evaluation.getMyProgress query - Add getMyProgress juryProcedure query to evaluationRouter: fetches all assignments for the current juror in a round, tallies completed/total, advance yes/no counts, per-project numeric scores and averages - Create JurorProgressDashboard client component with progress bar, advance badge summary, and collapsible per-submission score table - Wire dashboard into jury round page, gated by configJson.showJurorProgressDashboard Co-Authored-By: Claude Opus 4.6 --- .../jury/competitions/[roundId]/page.tsx | 9 ++ .../jury/juror-progress-dashboard.tsx | 116 ++++++++++++++++++ src/server/routers/evaluation.ts | 96 +++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 src/components/jury/juror-progress-dashboard.tsx diff --git a/src/app/(jury)/jury/competitions/[roundId]/page.tsx b/src/app/(jury)/jury/competitions/[roundId]/page.tsx index 5d36ef3..ab0e13c 100644 --- a/src/app/(jury)/jury/competitions/[roundId]/page.tsx +++ b/src/app/(jury)/jury/competitions/[roundId]/page.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react' import { toast } from 'sonner' +import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard' export default function JuryRoundDetailPage() { const params = useParams() @@ -53,6 +54,14 @@ export default function JuryRoundDetailPage() { + {(() => { + const config = (round?.configJson as Record) ?? {} + if (config.showJurorProgressDashboard) { + return + } + return null + })()} + Assigned Projects diff --git a/src/components/jury/juror-progress-dashboard.tsx b/src/components/jury/juror-progress-dashboard.tsx new file mode 100644 index 0000000..cc39b19 --- /dev/null +++ b/src/components/jury/juror-progress-dashboard.tsx @@ -0,0 +1,116 @@ +'use client' + +import { useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { ChevronDown, ChevronUp, ThumbsUp, ThumbsDown } from 'lucide-react' + +export function JurorProgressDashboard({ roundId }: { roundId: string }) { + const [expanded, setExpanded] = useState(true) + const { data, isLoading } = trpc.evaluation.getMyProgress.useQuery( + { roundId }, + { refetchInterval: 30_000 }, + ) + + if (isLoading) { + return + } + + if (!data || data.total === 0) return null + + const pct = Math.round((data.completed / data.total) * 100) + + return ( + + +
+ Your Progress + +
+
+ + {/* Progress bar */} +
+
+ + {data.completed} / {data.total} evaluated + + {pct}% +
+ +
+ + {/* Advance summary */} + {(data.advanceCounts.yes > 0 || data.advanceCounts.no > 0) && ( +
+ Advance: + + + {data.advanceCounts.yes} Yes + + + + {data.advanceCounts.no} No + +
+ )} + + {/* Submissions table */} + {expanded && data.submissions.length > 0 && ( +
+
+ + + + + + {data.submissions[0]?.criterionScores.map((cs, i) => ( + + ))} + + + + + + {data.submissions.map((s) => ( + + + + {s.criterionScores.map((cs, i) => ( + + ))} + + + + ))} + +
ProjectAvg Score + {cs.label} + AdvanceDate
{s.projectName} + {s.numericAverage != null ? ( + {s.numericAverage} + ) : '—'} + {cs.value} + {s.advanceDecision === true ? ( + YES + ) : s.advanceDecision === false ? ( + NO + ) : ( + + )} + + {s.submittedAt ? new Date(s.submittedAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '—'} +
+
+
+ )} +
+
+ ) +} diff --git a/src/server/routers/evaluation.ts b/src/server/routers/evaluation.ts index 303b2cb..7d216bc 100644 --- a/src/server/routers/evaluation.ts +++ b/src/server/routers/evaluation.ts @@ -1588,4 +1588,100 @@ export const evaluationRouter = router({ orderBy: { updatedAt: 'desc' }, }) }), + + /** + * Get the current juror's progress for a round + */ + getMyProgress: juryProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const { roundId } = input + const userId = ctx.user.id + + // Get all assignments for this juror in this round + const assignments = await ctx.prisma.assignment.findMany({ + where: { roundId, userId }, + include: { + project: { select: { id: true, title: true } }, + evaluation: { + include: { form: { select: { criteriaJson: true } } }, + }, + }, + }) + + const total = assignments.length + let completed = 0 + let advanceYes = 0 + let advanceNo = 0 + + const submissions: Array<{ + projectId: string + projectName: string + submittedAt: Date | null + advanceDecision: boolean | null + criterionScores: Array<{ label: string; value: number }> + numericAverage: number | null + }> = [] + + for (const a of assignments) { + const ev = a.evaluation + if (!ev || ev.status !== 'SUBMITTED') continue + completed++ + + const criteria = (ev.form?.criteriaJson ?? []) as Array<{ + id: string; label: string; type?: string; weight?: number + }> + const scores = (ev.criterionScoresJson ?? {}) as Record + + // Find the advance criterion + const advanceCriterion = criteria.find((c) => c.type === 'advance') + let advanceDecision: boolean | null = null + if (advanceCriterion) { + const val = scores[advanceCriterion.id] + if (typeof val === 'boolean') { + advanceDecision = val + if (val) advanceYes++ + else advanceNo++ + } + } + + // Collect numeric criterion scores + const numericScores: Array<{ label: string; value: number }> = [] + for (const c of criteria) { + if (c.type === 'numeric' || (!c.type && c.weight !== undefined)) { + const val = scores[c.id] + if (typeof val === 'number') { + numericScores.push({ label: c.label, value: val }) + } + } + } + + const numericAverage = numericScores.length > 0 + ? Math.round((numericScores.reduce((sum, s) => sum + s.value, 0) / numericScores.length) * 10) / 10 + : null + + submissions.push({ + projectId: a.project.id, + projectName: a.project.title, + submittedAt: ev.submittedAt, + advanceDecision, + criterionScores: numericScores, + numericAverage, + }) + } + + // Sort by most recent first + submissions.sort((a, b) => { + if (!a.submittedAt) return 1 + if (!b.submittedAt) return -1 + return b.submittedAt.getTime() - a.submittedAt.getTime() + }) + + return { + total, + completed, + advanceCounts: { yes: advanceYes, no: advanceNo }, + submissions, + } + }), })