feat: /judge review + scoring + leaderboard — TDD

Final screen — replaces the last WorkshopStub; App.tsx now has zero stubs:
- SubmissionList: live review queue with scored markers + selection
- AddReview: read-only render of a submitted 5-layer ADD
- ScoreForm: 0-10 rubric per layer, auto-summed total, notes
- Leaderboard: ranked teams by average judge total
- Judge page (behind name+code AccessGate): queue + review/score + leaderboard,
  refreshes on scoring and on any collective event

21 new tests; suite 140/140 green, typecheck + lint clean, build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 19:31:36 -07:00
co-authored by Claude Opus 4.8
parent 6c07788fab
commit bd3250c9c7
11 changed files with 520 additions and 17 deletions
+2 -17
View File
@@ -7,23 +7,8 @@ import { Module1 } from '@/pages/Module1'
import { Module2 } from '@/pages/Module2' import { Module2 } from '@/pages/Module2'
import { AddBuilder } from '@/pages/AddBuilder' import { AddBuilder } from '@/pages/AddBuilder'
import { Admin } from '@/pages/Admin' import { Admin } from '@/pages/Admin'
import { PhaseStrip } from '@/components/PhaseStrip' import { Judge } from '@/pages/Judge'
import { useCollectiveSync } from '@/lib/useCollectiveSync' import { useCollectiveSync } from '@/lib/useCollectiveSync'
import type { PhaseKey } from '@/store/session'
function WorkshopStub({ title, phase }: { title: string; phase: PhaseKey }) {
return (
<main className="min-h-screen bg-background">
<PhaseStrip active={phase} />
<section className="px-8 py-16 max-w-3xl mx-auto">
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground mt-3">
Screen under construction — see PRD §5.1 for the full spec. Tests-first build in progress.
</p>
</section>
</main>
)
}
export default function App() { export default function App() {
useCollectiveSync() useCollectiveSync()
@@ -38,7 +23,7 @@ export default function App() {
<Route path="/workshop/add" element={<AddBuilder />} /> <Route path="/workshop/add" element={<AddBuilder />} />
<Route path="/lecture" element={<Lecture />} /> <Route path="/lecture" element={<Lecture />} />
<Route path="/admin" element={<Admin />} /> <Route path="/admin" element={<Admin />} />
<Route path="/judge" element={<WorkshopStub title="Judge review" phase="reg" />} /> <Route path="/judge" element={<Judge />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
) )
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { AddReview } from './AddReview'
import type { SubmissionDTO } from '@/types'
const sub: SubmissionDTO = {
teamId: 't1',
teamName: 'team_resonance',
code: 'KIT-03-ABC',
add: { L1: { goal: 'stay safe' }, L2: 'reason', L3: 'act', L4: 'fail', L5: 'redesign' },
submittedAt: '2026-07-27T18:00:00.000Z',
}
describe('AddReview', () => {
it('prompts when nothing is selected', () => {
render(<AddReview submission={null} />)
expect(screen.getByText(/select a submission/i)).toBeInTheDocument()
})
it('renders all five layers of the submitted ADD', () => {
render(<AddReview submission={sub} />)
const review = screen.getByTestId('add-review')
expect(review).toHaveTextContent('stay safe')
expect(review).toHaveTextContent('reason')
expect(review).toHaveTextContent('act')
expect(review).toHaveTextContent('fail')
expect(review).toHaveTextContent('redesign')
expect(review).toHaveTextContent('KIT-03-ABC')
})
it('handles a null L1 without crashing', () => {
render(<AddReview submission={{ ...sub, add: { ...sub.add, L1: null } }} />)
expect(screen.getByTestId('add-review')).toBeInTheDocument()
})
})
+47
View File
@@ -0,0 +1,47 @@
import type { SubmissionDTO } from '@/types'
const LAYERS: { key: 'L2' | 'L3' | 'L4' | 'L5'; title: string }[] = [
{ key: 'L2', title: 'Reasoning policy' },
{ key: 'L3', title: 'Action contract' },
{ key: 'L4', title: 'Failure modes' },
{ key: 'L5', title: 'AI-native redesign' },
]
function Block({ n, title, body }: { n: number; title: string; body: string }) {
return (
<div className="space-y-1">
<div className="font-mono text-[10px] uppercase tracking-widest text-primary">Layer {n} · {title}</div>
<div className="text-sm leading-relaxed whitespace-pre-wrap">{body || <em className="text-muted-foreground">—</em>}</div>
</div>
)
}
export interface AddReviewProps {
submission: SubmissionDTO | null
}
/** Read-only render of a submitted ADD for the judge. */
export function AddReview({ submission }: AddReviewProps) {
if (!submission) {
return <p className="text-sm text-muted-foreground">Select a submission to review.</p>
}
const l1 = (submission.add.L1 as Record<string, string> | null) ?? {}
return (
<article data-testid="add-review" className="space-y-4">
<header>
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{submission.code}</div>
<h3 className="text-lg font-bold tracking-tight">{submission.teamName || submission.teamId}</h3>
</header>
<Block
n={1}
title="Perception & goal"
body={Object.entries(l1)
.map(([k, v]) => `${k}: ${v}`)
.join('\n')}
/>
{LAYERS.map((l, i) => (
<Block key={l.key} n={i + 2} title={l.title} body={submission.add[l.key]} />
))}
</article>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Leaderboard } from './Leaderboard'
import type { LeaderboardRow } from '@/types'
describe('Leaderboard', () => {
it('shows an empty state with no rows', () => {
render(<Leaderboard rows={[]} />)
expect(screen.getByText(/no scores yet/i)).toBeInTheDocument()
})
it('renders rows in the given order with avg + count', () => {
const rows: LeaderboardRow[] = [
{ teamId: 'b', teamName: 'Bravo', avgTotal: 9, scoreCount: 1 },
{ teamId: 'a', teamName: 'Alpha', avgTotal: 8, scoreCount: 2 },
]
render(<Leaderboard rows={rows} />)
const lis = screen.getByTestId('leaderboard').querySelectorAll('li')
expect(lis[0]).toHaveTextContent('Bravo')
expect(lis[0]).toHaveTextContent('9')
expect(lis[1]).toHaveTextContent('Alpha')
expect(lis[1]).toHaveAttribute('data-rank', '2')
})
})
+32
View File
@@ -0,0 +1,32 @@
import type { LeaderboardRow } from '@/types'
export interface LeaderboardProps {
rows: LeaderboardRow[]
}
/** Ranked teams by average judge total. Pure presentational. */
export function Leaderboard({ rows }: LeaderboardProps) {
if (!rows.length) {
return <p className="text-sm text-muted-foreground">No scores yet.</p>
}
return (
<ol className="space-y-1" data-testid="leaderboard">
{rows.map((r, i) => (
<li
key={r.teamId}
data-rank={i + 1}
className="flex items-center justify-between gap-3 rounded-md border border-border px-3 py-2"
>
<div className="flex items-center gap-3 min-w-0">
<span className="font-mono text-xs text-muted-foreground w-5 shrink-0">{String(i + 1).padStart(2, '0')}</span>
<span className="text-sm font-medium truncate">{r.teamName || r.teamId}</span>
</div>
<div className="font-mono text-xs tabular-nums shrink-0">
<span className="font-bold">{r.avgTotal}</span>
<span className="text-muted-foreground"> · {r.scoreCount}×</span>
</div>
</li>
))}
</ol>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScoreForm } from './ScoreForm'
describe('ScoreForm', () => {
it('sums criteria into a total and submits rubric + notes', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<ScoreForm onSubmit={onSubmit} />)
const perception = screen.getByLabelText(/perception/i)
await user.clear(perception)
await user.type(perception, '8')
const reasoning = screen.getByLabelText(/reasoning/i)
await user.clear(reasoning)
await user.type(reasoning, '6')
await user.type(screen.getByLabelText(/notes/i), 'solid edge reasoning')
expect(screen.getByTestId('score-total')).toHaveTextContent('14')
await user.click(screen.getByRole('button', { name: /submit score/i }))
expect(onSubmit).toHaveBeenCalledTimes(1)
const arg = onSubmit.mock.calls[0][0]
expect(arg.total).toBe(14)
expect(arg.rubric.perception).toBe(8)
expect(arg.notes).toBe('solid edge reasoning')
})
it('clamps a criterion to the 0–10 range', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<ScoreForm onSubmit={onSubmit} />)
const action = screen.getByLabelText(/action/i)
await user.clear(action)
await user.type(action, '99')
await user.click(screen.getByRole('button', { name: /submit score/i }))
expect(onSubmit.mock.calls[0][0].rubric.action).toBe(10)
})
it('honours the disabled prop', () => {
render(<ScoreForm onSubmit={() => {}} disabled />)
expect(screen.getByRole('button', { name: /submit score/i })).toBeDisabled()
})
})
+82
View File
@@ -0,0 +1,82 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
const CRITERIA: { key: string; label: string }[] = [
{ key: 'perception', label: 'Perception (L1)' },
{ key: 'reasoning', label: 'Reasoning (L2)' },
{ key: 'action', label: 'Action (L3)' },
{ key: 'failure', label: 'Failure modes (L4)' },
{ key: 'redesign', label: 'Redesign (L5)' },
]
const MAX = 10
export interface ScoreSubmit {
rubric: Record<string, number>
total: number
notes: string
}
export interface ScoreFormProps {
onSubmit: (s: ScoreSubmit) => void
disabled?: boolean
}
/** Rubric scoring form — each criterion 0–10, total auto-summed. */
export function ScoreForm({ onSubmit, disabled }: ScoreFormProps) {
const [rubric, setRubric] = useState<Record<string, number>>(() =>
Object.fromEntries(CRITERIA.map((c) => [c.key, 0])),
)
const [notes, setNotes] = useState('')
const total = Object.values(rubric).reduce((a, b) => a + b, 0)
const set = (key: string, raw: string) => {
const n = Math.max(0, Math.min(MAX, Number(raw) || 0))
setRubric((r) => ({ ...r, [key]: n }))
}
return (
<form
data-testid="score-form"
onSubmit={(e) => {
e.preventDefault()
onSubmit({ rubric, total, notes })
}}
className="space-y-4"
>
<div className="space-y-3">
{CRITERIA.map((c) => (
<div key={c.key} className="flex items-center justify-between gap-3">
<label htmlFor={`score-${c.key}`} className="text-sm">{c.label}</label>
<Input
id={`score-${c.key}`}
type="number"
min={0}
max={MAX}
value={rubric[c.key]}
onChange={(e) => set(c.key, e.target.value)}
className="w-20 font-mono"
/>
</div>
))}
</div>
<div className="space-y-2">
<label htmlFor="score-notes" className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Notes
</label>
<Textarea id="score-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex items-center justify-between">
<div className="font-mono text-sm">
Total <span className="font-bold tabular-nums" data-testid="score-total">{total}</span> / {CRITERIA.length * MAX}
</div>
<Button type="submit" disabled={disabled}>Submit score</Button>
</div>
</form>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SubmissionList } from './SubmissionList'
import type { SubmissionSummary } from '@/types'
const items: SubmissionSummary[] = [
{ teamId: 'a', teamName: 'Alpha', submittedAt: 'x', scored: false },
{ teamId: 'b', teamName: 'Bravo', submittedAt: 'y', scored: true },
]
describe('SubmissionList', () => {
it('shows an empty state with no items', () => {
render(<SubmissionList items={[]} onSelect={() => {}} />)
expect(screen.getByText(/no submissions/i)).toBeInTheDocument()
})
it('renders rows, marks scored, and fires onSelect', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(<SubmissionList items={items} selectedId="a" onSelect={onSelect} />)
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText(/scored/i)).toBeInTheDocument()
expect(screen.getByRole('button', { current: true })).toHaveTextContent('Alpha')
await user.click(screen.getByText('Bravo'))
expect(onSelect).toHaveBeenCalledWith('b')
})
})
+38
View File
@@ -0,0 +1,38 @@
import type { SubmissionSummary } from '@/types'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
export interface SubmissionListProps {
items: SubmissionSummary[]
selectedId?: string
onSelect: (teamId: string) => void
}
/** Judge review queue. Pure presentational. */
export function SubmissionList({ items, selectedId, onSelect }: SubmissionListProps) {
if (!items.length) {
return <p className="text-sm text-muted-foreground">No submissions yet.</p>
}
return (
<div className="space-y-1" data-testid="submission-list">
{items.map((s) => (
<button
key={s.teamId}
type="button"
data-team={s.teamId}
aria-current={s.teamId === selectedId}
onClick={() => onSelect(s.teamId)}
className={cn(
'w-full text-left rounded-md border px-3 py-2 transition',
s.teamId === selectedId ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/40',
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{s.teamName || s.teamId}</span>
{s.scored && <Badge className="font-mono text-[8px] uppercase tracking-wider">scored</Badge>}
</div>
</button>
))}
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import type { SubmissionDTO } from '@/types'
const api = vi.hoisted(() => ({
getSubmissions: vi.fn(),
getSubmission: vi.fn(),
getLeaderboard: vi.fn(),
postScore: vi.fn(),
openCollective: vi.fn(() => () => {}),
}))
vi.mock('@/lib/api', () => api)
import { Judge } from './Judge'
const dto: SubmissionDTO = {
teamId: 'a',
teamName: 'Alpha',
code: 'KIT-01-AAA',
add: { L1: { goal: 'g' }, L2: 'two', L3: 'three', L4: 'four', L5: 'five' },
submittedAt: 'x',
}
function renderJudge() {
return render(
<MemoryRouter>
<Judge />
</MemoryRouter>,
)
}
describe('Judge', () => {
beforeEach(() => {
sessionStorage.clear()
sessionStorage.setItem('apess_judge_code', 'judge-code')
sessionStorage.setItem('apess_judge_code_name', 'Dr. D')
api.getSubmissions.mockResolvedValue([{ teamId: 'a', teamName: 'Alpha', submittedAt: 'x', scored: false }])
api.getLeaderboard.mockResolvedValue([{ teamId: 'a', teamName: 'Alpha', avgTotal: 0, scoreCount: 0 }])
api.getSubmission.mockResolvedValue(dto)
api.postScore.mockResolvedValue({ id: 1 })
})
it('lists the submission queue and leaderboard on load', async () => {
renderJudge()
await waitFor(() => expect(screen.getByTestId('submission-list')).toHaveTextContent('Alpha'))
expect(screen.getByTestId('leaderboard')).toBeInTheDocument()
})
it('loads a submission for review when selected', async () => {
const user = userEvent.setup()
renderJudge()
await waitFor(() => expect(screen.getByTestId('submission-list')).toBeInTheDocument())
await user.click(within(screen.getByTestId('submission-list')).getByText('Alpha'))
await waitFor(() => expect(screen.getByTestId('add-review')).toHaveTextContent('KIT-01-AAA'))
})
it('posts a score and refreshes', async () => {
const user = userEvent.setup()
renderJudge()
await waitFor(() => expect(screen.getByTestId('submission-list')).toBeInTheDocument())
await user.click(within(screen.getByTestId('submission-list')).getByText('Alpha'))
await waitFor(() => expect(screen.getByTestId('score-form')).toBeInTheDocument())
const perception = screen.getByLabelText(/perception/i)
await user.clear(perception)
await user.type(perception, '7')
await user.click(screen.getByRole('button', { name: /submit score/i }))
await waitFor(() => expect(api.postScore).toHaveBeenCalled())
const arg = api.postScore.mock.calls[0]
expect(arg[0]).toBe('judge-code')
expect(arg[1]).toMatchObject({ teamId: 'a', judge: 'Dr. D', total: 7 })
})
it('gates behind name + code when none stored', () => {
sessionStorage.clear()
renderJudge()
expect(screen.getByRole('button', { name: /unlock/i })).toBeInTheDocument()
})
})
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { AccessGate } from '@/components/AccessGate'
import { SubmissionList } from '@/components/SubmissionList'
import { AddReview } from '@/components/AddReview'
import { ScoreForm, type ScoreSubmit } from '@/components/ScoreForm'
import { Leaderboard } from '@/components/Leaderboard'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
getSubmissions,
getSubmission,
getLeaderboard,
postScore,
openCollective,
} from '@/lib/api'
import type { SubmissionSummary, SubmissionDTO, LeaderboardRow } from '@/types'
function JudgeDesk({ code, name }: { code: string; name: string }) {
const [queue, setQueue] = useState<SubmissionSummary[]>([])
const [board, setBoard] = useState<LeaderboardRow[]>([])
const [selected, setSelected] = useState<SubmissionDTO | null>(null)
const refresh = useCallback(async () => {
const [subs, lb] = await Promise.all([getSubmissions(code), getLeaderboard(code)]).catch(() => [
null,
null,
])
if (subs) setQueue(subs)
if (lb) setBoard(lb)
}, [code])
useEffect(() => {
// initial + live refresh both go through the async refresh (no sync setState)
void Promise.resolve().then(refresh)
const close = openCollective(code, () => void refresh())
return () => close()
}, [code, refresh])
const onSelect = async (teamId: string) => {
try {
setSelected(await getSubmission(teamId, code))
} catch {
setSelected(null)
}
}
const onScore = async (s: ScoreSubmit) => {
if (!selected) return
try {
await postScore(code, { teamId: selected.teamId, judge: name, rubric: s.rubric, total: s.total, notes: s.notes })
} catch {
/* best-effort; refresh reflects server truth */
}
await refresh()
}
return (
<main className="min-h-screen bg-background">
<header className="px-8 py-5 border-b border-border flex items-center justify-between">
<div className="font-mono text-xs tracking-widest uppercase">
APESS <span className="text-primary font-bold">2026</span>
<span className="text-muted-foreground"> · Judge</span>
</div>
<div className="flex items-center gap-3">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">{name}</span>
<Link to="/" className="font-mono text-[11px] text-muted-foreground hover:text-foreground tracking-widest uppercase">
← Landing
</Link>
</div>
</header>
<section className="px-8 py-8 max-w-6xl mx-auto grid lg:grid-cols-[260px_1fr_260px] gap-6">
<Card>
<CardHeader><CardTitle className="text-base">Submissions</CardTitle></CardHeader>
<CardContent>
<SubmissionList items={queue} selectedId={selected?.teamId} onSelect={onSelect} />
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">Review &amp; score</CardTitle></CardHeader>
<CardContent className="space-y-6">
<AddReview submission={selected} />
{selected && <ScoreForm onSubmit={onScore} />}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base">Leaderboard</CardTitle></CardHeader>
<CardContent>
<Leaderboard rows={board} />
</CardContent>
</Card>
</section>
</main>
)
}
export function Judge() {
return (
<AccessGate codeKey="apess_judge_code" title="Judge access" description="Enter your name and the judge access code." withName>
{({ code, name }) => <JudgeDesk code={code} name={name} />}
</AccessGate>
)
}