Files
MOPC-Portal/docs/plans/2026-02-25-advance-criterion-plan.md

30 KiB

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:

export type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'

to:

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:

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

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:

<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() => addCriterion('advance')}
  disabled={editingId !== null || criteria.some((c) => c.type === 'advance')}
  title={criteria.some((c) => c.type === 'advance') ? 'Only one advance criterion allowed per form' : undefined}
  className={cn(
    'border-brand-blue/40 text-brand-blue hover:bg-brand-blue/5',
    criteria.some((c) => c.type === 'advance') && 'opacity-50 cursor-not-allowed'
  )}
>
  <ArrowUpCircle className="mr-1 h-4 w-4" />
  Advance to Next Round?
</Button>

Step 3: Commit

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:

{(editDraft.type) === 'advance' && (
  <div className="grid gap-4 sm:grid-cols-2">
    <div className="space-y-2">
      <Label htmlFor={`trueLabel-${criterion.id}`}>Yes Label</Label>
      <Input
        id={`trueLabel-${criterion.id}`}
        value={editDraft.trueLabel || 'Yes'}
        onChange={(e) => updateDraft({ trueLabel: e.target.value })}
        placeholder="Yes"
      />
    </div>
    <div className="space-y-2">
      <Label htmlFor={`falseLabel-${criterion.id}`}>No Label</Label>
      <Input
        id={`falseLabel-${criterion.id}`}
        value={editDraft.falseLabel || 'No'}
        onChange={(e) => updateDraft({ falseLabel: e.target.value })}
        placeholder="No"
      />
    </div>
  </div>
)}

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:

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:

{type === 'advance' && (
  <div className="flex gap-4">
    <div className="flex-1 h-14 rounded-lg border-2 border-emerald-300 bg-emerald-50/50 flex items-center justify-center text-sm font-semibold text-emerald-700">
      <ThumbsUp className="mr-2 h-4 w-4" />
      {criterion.trueLabel || 'Yes'}
    </div>
    <div className="flex-1 h-14 rounded-lg border-2 border-red-300 bg-red-50/50 flex items-center justify-center text-sm font-semibold text-red-700">
      <ThumbsDown className="mr-2 h-4 w-4" />
      {criterion.falseLabel || 'No'}
    </div>
  </div>
)}

Step 4: Commit

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:

type: z.enum(['numeric', 'text', 'boolean', 'section_header']).optional(),

to:

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:

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:

// 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:

if (c.type === 'boolean') return typeof val !== 'boolean'

to:

if (c.type === 'boolean' || c.type === 'advance') return typeof val !== 'boolean'

Step 5: Commit

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:

if (criterion.type === 'advance') {
  const currentValue = criteriaValues[criterion.id]
  return (
    <div key={criterion.id} className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
      <div className="space-y-1">
        <Label className="text-base font-semibold text-brand-blue">
          {criterion.label}
          <span className="text-destructive ml-1">*</span>
        </Label>
        {criterion.description && (
          <p className="text-sm text-muted-foreground">{criterion.description}</p>
        )}
      </div>
      <div className="flex gap-4">
        <button
          type="button"
          onClick={() => handleCriterionChange(criterion.id, true)}
          className={cn(
            'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
            currentValue === true
              ? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
              : 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
          )}
        >
          <ThumbsUp className="mr-2 h-5 w-5" />
          {criterion.trueLabel || 'Yes'}
        </button>
        <button
          type="button"
          onClick={() => handleCriterionChange(criterion.id, false)}
          className={cn(
            'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
            currentValue === false
              ? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
              : 'border-border hover:border-red-300 hover:bg-red-50/50'
          )}
        >
          <ThumbsDown className="mr-2 h-5 w-5" />
          {criterion.falseLabel || 'No'}
        </button>
      </div>
    </div>
  )
}

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

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):

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:

<div className="flex items-center justify-between">
  <div>
    <Label htmlFor="showJurorProgressDashboard">Juror Progress Dashboard</Label>
    <p className="text-xs text-muted-foreground">Show jurors a dashboard with their past evaluations, scores, and advance decisions</p>
  </div>
  <Switch
    id="showJurorProgressDashboard"
    checked={(config.showJurorProgressDashboard as boolean) ?? false}
    onCheckedChange={(v) => update('showJurorProgressDashboard', v)}
  />
</div>

Step 3: Commit

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):

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<string, unknown>

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

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

'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 <Skeleton className="h-32 w-full" />
  }

  if (!data || data.total === 0) return null

  const pct = Math.round((data.completed / data.total) * 100)

  return (
    <Card>
      <CardHeader className="pb-3">
        <div className="flex items-center justify-between">
          <CardTitle className="text-base">Your Progress</CardTitle>
          <Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)}>
            {expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
          </Button>
        </div>
      </CardHeader>
      <CardContent className="space-y-4">
        {/* Progress bar */}
        <div className="space-y-2">
          <div className="flex items-center justify-between text-sm">
            <span className="text-muted-foreground">
              {data.completed} / {data.total} evaluated
            </span>
            <span className="font-medium">{pct}%</span>
          </div>
          <Progress value={pct} className="h-2" />
        </div>

        {/* Advance summary */}
        {(data.advanceCounts.yes > 0 || data.advanceCounts.no > 0) && (
          <div className="flex items-center gap-3">
            <span className="text-sm text-muted-foreground">Advance:</span>
            <Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200">
              <ThumbsUp className="mr-1 h-3 w-3" />
              {data.advanceCounts.yes} Yes
            </Badge>
            <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
              <ThumbsDown className="mr-1 h-3 w-3" />
              {data.advanceCounts.no} No
            </Badge>
          </div>
        )}

        {/* Submissions table */}
        {expanded && data.submissions.length > 0 && (
          <div className="border rounded-lg overflow-hidden">
            <div className="overflow-x-auto">
              <table className="w-full text-sm">
                <thead>
                  <tr className="border-b bg-muted/30">
                    <th className="text-left px-3 py-2 font-medium">Project</th>
                    <th className="text-center px-3 py-2 font-medium">Avg Score</th>
                    {data.submissions[0]?.criterionScores.map((cs, i) => (
                      <th key={i} className="text-center px-2 py-2 font-medium text-xs max-w-[80px] truncate" title={cs.label}>
                        {cs.label}
                      </th>
                    ))}
                    <th className="text-center px-3 py-2 font-medium">Advance</th>
                    <th className="text-right px-3 py-2 font-medium">Date</th>
                  </tr>
                </thead>
                <tbody>
                  {data.submissions.map((s) => (
                    <tr key={s.projectId} className="border-b last:border-0 hover:bg-muted/20">
                      <td className="px-3 py-2 font-medium truncate max-w-[200px]">{s.projectName}</td>
                      <td className="text-center px-3 py-2">
                        {s.numericAverage != null ? (
                          <span className="font-semibold">{s.numericAverage}</span>
                        ) : '—'}
                      </td>
                      {s.criterionScores.map((cs, i) => (
                        <td key={i} className="text-center px-2 py-2 text-muted-foreground">{cs.value}</td>
                      ))}
                      <td className="text-center px-3 py-2">
                        {s.advanceDecision === true ? (
                          <Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200 text-xs">YES</Badge>
                        ) : s.advanceDecision === false ? (
                          <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 text-xs">NO</Badge>
                        ) : (
                          <span className="text-muted-foreground"></span>
                        )}
                      </td>
                      <td className="text-right px-3 py-2 text-muted-foreground text-xs whitespace-nowrap">
                        {s.submittedAt ? new Date(s.submittedAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '—'}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </div>
        )}
      </CardContent>
    </Card>
  )
}

Step 2: Commit

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:

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 <div> (around line 53) and before the <Card> with "Assigned Projects" (line 56), add:

{(() => {
  const config = (round?.configJson as Record<string, unknown>) ?? {}
  if (config.showJurorProgressDashboard) {
    return <JurorProgressDashboard roundId={roundId} />
  }
  return null
})()}

Step 3: Commit

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:

<div className="grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
  <span>Juror</span>
  <span>Project</span>
  <span>Status</span>
  <span>Advance</span>
  <span>Actions</span>
</div>

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 </div>) and before the DropdownMenu (line 352), add:

<div className="flex items-center justify-center">
  {(() => {
    const ev = a.evaluation
    if (!ev || ev.status !== 'SUBMITTED') return <span className="text-muted-foreground text-xs"></span>
    const criteria = (ev.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
    const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
    const advCrit = criteria.find((c) => c.type === 'advance')
    if (!advCrit) return <span className="text-muted-foreground text-xs"></span>
    const val = scores[advCrit.id]
    if (val === true) return <Badge variant="outline" className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-200">YES</Badge>
    if (val === false) return <Badge variant="outline" className="text-[10px] bg-red-50 text-red-700 border-red-200">NO</Badge>
    return <span className="text-muted-foreground text-xs"></span>
  })()}
</div>

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

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

'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 <Skeleton className="h-40 w-full" />

  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<string, unknown>
    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 (
    <Card>
      <CardHeader className="pb-3">
        <CardTitle className="text-base">Advancement Votes</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="flex items-center gap-6">
          <div className="flex items-center gap-2">
            <div className="h-10 w-10 rounded-full bg-emerald-100 flex items-center justify-center">
              <ThumbsUp className="h-5 w-5 text-emerald-700" />
            </div>
            <div>
              <p className="text-2xl font-bold text-emerald-700">{yesCount}</p>
              <p className="text-xs text-muted-foreground">Yes ({yesPct}%)</p>
            </div>
          </div>
          <div className="flex items-center gap-2">
            <div className="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center">
              <ThumbsDown className="h-5 w-5 text-red-700" />
            </div>
            <div>
              <p className="text-2xl font-bold text-red-700">{noCount}</p>
              <p className="text-xs text-muted-foreground">No ({noPct}%)</p>
            </div>
          </div>
          <div className="flex items-center gap-2">
            <div className="h-10 w-10 rounded-full bg-gray-100 flex items-center justify-center">
              <Clock className="h-5 w-5 text-gray-500" />
            </div>
            <div>
              <p className="text-2xl font-bold text-gray-600">{pendingCount}</p>
              <p className="text-xs text-muted-foreground">Pending</p>
            </div>
          </div>
        </div>

        {/* Stacked bar */}
        <div className="mt-4 h-3 rounded-full bg-gray-100 overflow-hidden flex">
          {yesPct > 0 && <div className="bg-emerald-500 transition-all" style={{ width: `${yesPct}%` }} />}
          {noPct > 0 && <div className="bg-red-500 transition-all" style={{ width: `${noPct}%` }} />}
        </div>
      </CardContent>
    </Card>
  )
}

Step 2: Commit

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:

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:

{isEvaluation && <AdvancementSummaryCard roundId={roundId} />}

Where isEvaluation is the existing variable that checks round.roundType === 'EVALUATION'.

Step 3: Commit

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

npm run typecheck

Expected: No errors (fix any that appear).

Step 2: Run build

npm run build

Expected: Successful build.

Step 3: Fix any issues and commit

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.