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
+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>
)
}