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:
co-authored by
Claude Opus 4.8
parent
6c07788fab
commit
bd3250c9c7
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user