diff --git a/docs/plans/2026-02-25-advance-criterion-plan.md b/docs/plans/2026-02-25-advance-criterion-plan.md new file mode 100644 index 0000000..80d80eb --- /dev/null +++ b/docs/plans/2026-02-25-advance-criterion-plan.md @@ -0,0 +1,844 @@ +# Advance Criterion & Juror Progress Dashboard — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an `advance` criterion type to the evaluation form system, a juror-facing progress dashboard showing past submissions with scores and advance decisions, and admin-facing summary card + table column for advancement votes. + +**Architecture:** The `advance` type is added to the existing criterion type union and flows through the same `criteriaJson`/`criterionScoresJson` JSON columns — no Prisma schema migration. A new `showJurorProgressDashboard` field in `EvaluationConfig` gates the juror view. A new tRPC query aggregates the juror's submissions. Admin components get an extra column and a summary card. + +**Tech Stack:** TypeScript, tRPC, Prisma (JSON columns), React, shadcn/ui, Tailwind CSS, Zod + +--- + +## Task 1: Add `advance` to CriterionType and Form Builder Types + +**Files:** +- Modify: `src/components/forms/evaluation-form-builder.tsx:57` (CriterionType union) +- Modify: `src/components/forms/evaluation-form-builder.tsx:96-114` (createDefaultCriterion) +- Modify: `src/components/forms/evaluation-form-builder.tsx:117-122` (CRITERION_TYPE_OPTIONS) + +**Step 1: Update the CriterionType union** + +In `evaluation-form-builder.tsx` line 57, change: +```ts +export type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header' +``` +to: +```ts +export type CriterionType = 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header' +``` + +**Step 2: Add default creation for `advance` type** + +In `createDefaultCriterion` (line 96), add a new case before `section_header`: +```ts +case 'advance': + return { ...base, label: 'Advance to next round?', trueLabel: 'Yes', falseLabel: 'No', required: true } +``` + +**Step 3: Add `advance` to the type options array** + +In `CRITERION_TYPE_OPTIONS` (line 117), add an import for a suitable icon (e.g., `ArrowUpCircle` from lucide-react) and add the entry. Note: this button will be rendered separately with disable logic, so do NOT add it to `CRITERION_TYPE_OPTIONS`. Instead, we'll add a standalone button in Task 2. + +Actually — to keep things clean, do NOT add `advance` to `CRITERION_TYPE_OPTIONS`. The advance button is rendered separately with one-per-form enforcement. See Task 2. + +**Step 4: Commit** +```bash +git add src/components/forms/evaluation-form-builder.tsx +git commit -m "feat: add advance criterion type to CriterionType union and defaults" +``` + +--- + +## Task 2: Add "Advance to Next Round?" Button in Form Builder + +**Files:** +- Modify: `src/components/forms/evaluation-form-builder.tsx:39-54` (imports — add ArrowUpCircle) +- Modify: `src/components/forms/evaluation-form-builder.tsx:671-690` (add buttons section) + +**Step 1: Add the `ArrowUpCircle` icon import** + +At line 39 in the lucide-react import block, add `ArrowUpCircle` to the imports. + +**Step 2: Add the advance button with one-per-form enforcement** + +After the `CRITERION_TYPE_OPTIONS.map(...)` buttons (around line 685), before the PreviewDialog, add: +```tsx + +``` + +**Step 3: Commit** +```bash +git add src/components/forms/evaluation-form-builder.tsx +git commit -m "feat: add advance criterion button with one-per-form enforcement" +``` + +--- + +## Task 3: Add Edit Mode and Preview for `advance` Criterion + +**Files:** +- Modify: `src/components/forms/evaluation-form-builder.tsx` — edit mode section (around lines 237-414) +- Modify: `src/components/forms/evaluation-form-builder.tsx` — preview dialog (around lines 787-798) +- Modify: `src/components/forms/evaluation-form-builder.tsx` — type badge display in list view + +**Step 1: Add edit mode fields for `advance` type** + +In the edit mode form (after the `boolean` block ending around line 414), add a block for `advance`: +```tsx +{(editDraft.type) === 'advance' && ( +
+
+ + updateDraft({ trueLabel: e.target.value })} + placeholder="Yes" + /> +
+
+ + updateDraft({ falseLabel: e.target.value })} + placeholder="No" + /> +
+
+)} +``` + +Note: No `required` toggle (always true), no `weight`, no `condition` fields for advance type. + +**Step 2: Add the type badge rendering** + +Find where the type badge is shown in list view (around line 237-240). The existing code uses `CRITERION_TYPE_OPTIONS.find(...)`. For `advance`, it won't find a match so will show nothing. Add a fallback or handle it. Where the badge text is resolved, add: +```ts +editDraft.type === 'advance' ? 'Advance to Next Round?' : CRITERION_TYPE_OPTIONS.find(...)?.label ?? 'Numeric Score' +``` + +**Step 3: Add preview rendering for `advance` type** + +In the PreviewDialog (around line 787), after the `boolean` rendering block, add: +```tsx +{type === 'advance' && ( +
+
+ + {criterion.trueLabel || 'Yes'} +
+
+ + {criterion.falseLabel || 'No'} +
+
+)} +``` + +**Step 4: Commit** +```bash +git add src/components/forms/evaluation-form-builder.tsx +git commit -m "feat: add edit mode and preview rendering for advance criterion type" +``` + +--- + +## Task 4: Server-Side — Accept `advance` in `upsertForm` and `submit` Validation + +**Files:** +- Modify: `src/server/routers/evaluation.ts:1230` (upsertForm Zod input — add 'advance' to type enum) +- Modify: `src/server/routers/evaluation.ts:1270-1304` (criteriaJson builder — add advance case) +- Modify: `src/server/routers/evaluation.ts:238-260` (submit validation — handle advance type) + +**Step 1: Add `advance` to the Zod type enum in upsertForm input** + +At line 1230, change: +```ts +type: z.enum(['numeric', 'text', 'boolean', 'section_header']).optional(), +``` +to: +```ts +type: z.enum(['numeric', 'text', 'boolean', 'advance', 'section_header']).optional(), +``` + +**Step 2: Add advance case in criteriaJson builder** + +After the `boolean` case (line 1295-1300), add: +```ts +if (type === 'advance') { + return { + ...base, + required: true, // always required, override any input + trueLabel: c.trueLabel || 'Yes', + falseLabel: c.falseLabel || 'No', + } +} +``` + +**Step 3: Add server-side one-per-form validation** + +In the `upsertForm` mutation, after line 1256 (`const { roundId, criteria } = input`), add: +```ts +// Enforce max one advance criterion per form +const advanceCount = criteria.filter((c) => c.type === 'advance').length +if (advanceCount > 1) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Only one advance criterion is allowed per evaluation form', + }) +} +``` + +**Step 4: Handle `advance` in submit validation** + +In the `requireAllCriteriaScored` block (line 242-252), the `scorableCriteria` filter excludes `section_header` and `text`. The `advance` type should be treated like `boolean` — it's a required boolean. Update the missing criteria check: + +At line 250, change: +```ts +if (c.type === 'boolean') return typeof val !== 'boolean' +``` +to: +```ts +if (c.type === 'boolean' || c.type === 'advance') return typeof val !== 'boolean' +``` + +**Step 5: Commit** +```bash +git add src/server/routers/evaluation.ts +git commit -m "feat: server-side support for advance criterion type in upsertForm and submit" +``` + +--- + +## Task 5: Juror Evaluation Page — Render `advance` Criterion + +**Files:** +- Modify: `src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx:660-703` (boolean rendering — add advance case) +- Modify: same file, client-side validation (around line 355-360) + +**Step 1: Add advance criterion rendering in the evaluation form** + +After the boolean rendering block (line 660-703), add a new block for `advance`. It should look similar to boolean but with larger, more prominent buttons and a colored border: + +```tsx +if (criterion.type === 'advance') { + const currentValue = criteriaValues[criterion.id] + return ( +
+
+ + {criterion.description && ( +

{criterion.description}

+ )} +
+
+ + +
+
+ ) +} +``` + +**Step 2: Update client-side validation** + +In the client-side submit validation (around line 355-360), where boolean required criteria are checked, ensure `advance` is also handled. Find the block that checks for boolean criteria values and add `|| c.type === 'advance'` to the condition. + +**Step 3: Commit** +```bash +git add "src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx" +git commit -m "feat: render advance criterion on juror evaluation page with prominent styling" +``` + +--- + +## Task 6: Add `showJurorProgressDashboard` to EvaluationConfig + +**Files:** +- Modify: `src/types/competition-configs.ts:90-141` (EvaluationConfigSchema — add field) +- Modify: `src/components/admin/rounds/config/evaluation-config.tsx` (add toggle) + +**Step 1: Add the field to the Zod schema** + +In `EvaluationConfigSchema` (line 90), add after line 103 (`peerReviewEnabled`): +```ts +showJurorProgressDashboard: z.boolean().default(false), +``` + +**Step 2: Add the toggle in the admin config UI** + +In `evaluation-config.tsx`, in the Feedback Requirements card (after the `peerReviewEnabled` switch, around line 176), add: +```tsx +
+
+ +

Show jurors a dashboard with their past evaluations, scores, and advance decisions

+
+ update('showJurorProgressDashboard', v)} + /> +
+``` + +**Step 3: Commit** +```bash +git add src/types/competition-configs.ts src/components/admin/rounds/config/evaluation-config.tsx +git commit -m "feat: add showJurorProgressDashboard toggle to EvaluationConfig" +``` + +--- + +## Task 7: New tRPC Query — `evaluation.getMyProgress` + +**Files:** +- Modify: `src/server/routers/evaluation.ts` (add new juryProcedure query at the end of the router) + +**Step 1: Add the query** + +Add this query to the `evaluationRouter` (before the closing `})` of the router): + +```ts +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, + } + }), +``` + +**Step 2: Commit** +```bash +git add src/server/routers/evaluation.ts +git commit -m "feat: add evaluation.getMyProgress tRPC query for juror dashboard" +``` + +--- + +## Task 8: Juror Progress Dashboard Component + +**Files:** +- Create: `src/components/jury/juror-progress-dashboard.tsx` + +**Step 1: Create the component** + +```tsx +'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' +import { cn } from '@/lib/utils' + +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' }) : '—'} +
+
+
+ )} +
+
+ ) +} +``` + +**Step 2: Commit** +```bash +git add src/components/jury/juror-progress-dashboard.tsx +git commit -m "feat: create JurorProgressDashboard component" +``` + +--- + +## Task 9: Wire Juror Progress Dashboard into Round Page + +**Files:** +- Modify: `src/app/(jury)/jury/competitions/[roundId]/page.tsx` + +**Step 1: Import the component and add it to the page** + +Add import at the top: +```ts +import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard' +``` + +**Step 2: Fetch round config and conditionally render** + +The page already fetches `round` via `trpc.round.getById.useQuery`. Use it to check the config: + +After the heading `
` (around line 53) and before the `` with "Assigned Projects" (line 56), add: +```tsx +{(() => { + const config = (round?.configJson as Record) ?? {} + if (config.showJurorProgressDashboard) { + return + } + return null +})()} +``` + +**Step 3: Commit** +```bash +git add "src/app/(jury)/jury/competitions/[roundId]/page.tsx" +git commit -m "feat: wire JurorProgressDashboard into jury round detail page" +``` + +--- + +## Task 10: Admin — Add "Advance" Column to Assignments Table + +**Files:** +- Modify: `src/components/admin/assignment/individual-assignments-table.tsx:315-319` (column header) +- Modify: `src/components/admin/assignment/individual-assignments-table.tsx:325-351` (row rendering) + +**Step 1: Add the column header** + +At line 315, change the grid from `grid-cols-[1fr_1fr_100px_70px]` to `grid-cols-[1fr_1fr_80px_80px_70px]` and add an "Advance" header: +```tsx +
+ Juror + Project + Status + Advance + Actions +
+``` + +**Step 2: Update row grid and add the advance cell** + +At line 325, update the grid class to match: `grid-cols-[1fr_1fr_80px_80px_70px]`. + +After the Status cell (line 351 `
`) and before the DropdownMenu (line 352), add: +```tsx +
+ {(() => { + const ev = a.evaluation + if (!ev || ev.status !== 'SUBMITTED') return + const criteria = (ev.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }> + const scores = (ev.criterionScoresJson ?? {}) as Record + const advCrit = criteria.find((c) => c.type === 'advance') + if (!advCrit) return + const val = scores[advCrit.id] + if (val === true) return YES + if (val === false) return NO + return + })()} +
+``` + +**Step 3: Ensure the query includes form data** + +Check that `trpc.assignment.listByStage` includes `evaluation.form` in its response. If it doesn't, we need to add `form: { select: { criteriaJson: true } }` to the evaluation include in the `listByStage` query in `src/server/routers/assignment.ts`. Look for the `listByStage` procedure and update its evaluation include. + +**Step 4: Commit** +```bash +git add src/components/admin/assignment/individual-assignments-table.tsx +git add src/server/routers/assignment.ts # if modified +git commit -m "feat: add Advance column to admin individual assignments table" +``` + +--- + +## Task 11: Admin — Advancement Summary Card + +**Files:** +- Create: `src/components/admin/round/advancement-summary-card.tsx` + +**Step 1: Create the component** + +```tsx +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { ThumbsUp, ThumbsDown, Clock } from 'lucide-react' + +export function AdvancementSummaryCard({ roundId }: { roundId: string }) { + const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery( + { roundId }, + { refetchInterval: 15_000 }, + ) + + if (isLoading) return + + if (!assignments || assignments.length === 0) return null + + // Check if form has an advance criterion + const firstSubmitted = assignments.find( + (a: any) => a.evaluation?.status === 'SUBMITTED' && a.evaluation?.form?.criteriaJson + ) + if (!firstSubmitted) return null + + const criteria = ((firstSubmitted as any).evaluation?.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }> + const advanceCriterion = criteria.find((c) => c.type === 'advance') + if (!advanceCriterion) return null + + let yesCount = 0 + let noCount = 0 + let pendingCount = 0 + + for (const a of assignments as any[]) { + const ev = a.evaluation + if (!ev || ev.status !== 'SUBMITTED') { + pendingCount++ + continue + } + const scores = (ev.criterionScoresJson ?? {}) as Record + const val = scores[advanceCriterion.id] + if (val === true) yesCount++ + else if (val === false) noCount++ + else pendingCount++ + } + + const total = yesCount + noCount + pendingCount + const yesPct = total > 0 ? Math.round((yesCount / total) * 100) : 0 + const noPct = total > 0 ? Math.round((noCount / total) * 100) : 0 + + return ( + + + Advancement Votes + + +
+
+
+ +
+
+

{yesCount}

+

Yes ({yesPct}%)

+
+
+
+
+ +
+
+

{noCount}

+

No ({noPct}%)

+
+
+
+
+ +
+
+

{pendingCount}

+

Pending

+
+
+
+ + {/* Stacked bar */} +
+ {yesPct > 0 &&
} + {noPct > 0 &&
} +
+ + + ) +} +``` + +**Step 2: Commit** +```bash +git add src/components/admin/round/advancement-summary-card.tsx +git commit -m "feat: create AdvancementSummaryCard admin component" +``` + +--- + +## Task 12: Wire Advancement Summary Card into Admin Round Detail + +**Files:** +- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (overview tab, around line 871) + +**Step 1: Import the component** + +Add at the imports section: +```ts +import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card' +``` + +**Step 2: Add it to the overview tab** + +In the overview tab content (after the Launch Readiness card, around line 943), add: +```tsx +{isEvaluation && } +``` + +Where `isEvaluation` is the existing variable that checks `round.roundType === 'EVALUATION'`. + +**Step 3: Commit** +```bash +git add "src/app/(admin)/admin/rounds/[roundId]/page.tsx" +git commit -m "feat: wire AdvancementSummaryCard into admin round overview tab" +``` + +--- + +## Task 13: Build and Typecheck + +**Step 1: Run typecheck** +```bash +npm run typecheck +``` +Expected: No errors (fix any that appear). + +**Step 2: Run build** +```bash +npm run build +``` +Expected: Successful build. + +**Step 3: Fix any issues and commit** +```bash +git add -A +git commit -m "fix: resolve any type or build errors from advance criterion feature" +``` + +--- + +## Task 14: Manual QA Checklist + +Run `npm run dev` and verify: + +1. **Form builder**: Admin can add "Advance to Next Round?" criterion. Button disables after one is added. Edit mode shows trueLabel/falseLabel. Preview renders correctly. +2. **Juror evaluation**: Advance criterion renders with prominent green/red buttons. Required validation works. Autosave works. Submit stores value in `criterionScoresJson`. +3. **Juror dashboard**: When `showJurorProgressDashboard` is enabled in round config, the progress card appears with progress bar, YES/NO counts, and submissions table sorted by date. +4. **Admin config**: The "Juror Progress Dashboard" toggle appears in the Evaluation round config. +5. **Admin assignments table**: "Advance" column appears with YES/NO/— badges. +6. **Admin overview**: `AdvancementSummaryCard` renders with correct counts and stacked bar.