feat: /admin instructor dashboard — TDD

- AccessGate: shared code (+optional name) gate for /admin and /judge,
  sessionStorage-backed
- TeamCard: pure 15-grid tile — phase dots, stats line, submitted/judged
  badges, stale dimming
- useCollective: REST-seeded + WS-live reducer of all teams/submissions,
  degrades to polling when the socket is silent
- useNow: ticking clock hook (effect-driven) for stale dimming
- Admin page: aggregate strip + 15-card grid behind the access gate

19 new tests; suite 126/126 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:27:08 -07:00
co-authored by Claude Opus 4.8
parent b18551f120
commit 6c07788fab
10 changed files with 558 additions and 1 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ import { EnvSetup } from '@/pages/EnvSetup'
import { Module1 } from '@/pages/Module1' 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 { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { useCollectiveSync } from '@/lib/useCollectiveSync' import { useCollectiveSync } from '@/lib/useCollectiveSync'
import type { PhaseKey } from '@/store/session' import type { PhaseKey } from '@/store/session'
@@ -36,7 +37,7 @@ export default function App() {
<Route path="/workshop/module2" element={<Module2 />} /> <Route path="/workshop/module2" element={<Module2 />} />
<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={<WorkshopStub title="Instructor dashboard" phase="reg" />} /> <Route path="/admin" element={<Admin />} />
<Route path="/judge" element={<WorkshopStub title="Judge review" phase="reg" />} /> <Route path="/judge" element={<WorkshopStub title="Judge review" phase="reg" />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessGate } from './AccessGate'
describe('AccessGate', () => {
beforeEach(() => sessionStorage.clear())
it('hides children until a code is entered', async () => {
const user = userEvent.setup()
render(
<AccessGate codeKey="apess_admin_code" title="Instructor access">
{() => <div>secret dashboard</div>}
</AccessGate>,
)
expect(screen.queryByText('secret dashboard')).toBeNull()
await user.type(screen.getByLabelText(/access code/i), 'open-sesame')
await user.click(screen.getByRole('button', { name: /unlock/i }))
expect(screen.getByText('secret dashboard')).toBeInTheDocument()
expect(sessionStorage.getItem('apess_admin_code')).toBe('open-sesame')
})
it('passes the captured code and name to children', async () => {
const user = userEvent.setup()
render(
<AccessGate codeKey="apess_judge_code" title="Judge access" withName>
{({ code, name }) => <div>{name} · {code}</div>}
</AccessGate>,
)
await user.type(screen.getByLabelText(/your name/i), 'Dr. Demartino')
await user.type(screen.getByLabelText(/access code/i), 'gavel')
await user.click(screen.getByRole('button', { name: /unlock/i }))
expect(screen.getByText(/Dr\. Demartino · gavel/)).toBeInTheDocument()
})
it('renders straight through when a code is already stored', () => {
sessionStorage.setItem('apess_admin_code', 'preset')
render(
<AccessGate codeKey="apess_admin_code" title="Instructor access">
{({ code }) => <div>code is {code}</div>}
</AccessGate>,
)
expect(screen.getByText('code is preset')).toBeInTheDocument()
})
})
+79
View File
@@ -0,0 +1,79 @@
import { useState, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export interface AccessContext {
code: string
name: string
}
export interface AccessGateProps {
codeKey: string
title: string
description?: string
withName?: boolean
children: (ctx: AccessContext) => ReactNode
}
/**
* Lightweight shared access gate for /admin and /judge. Collects a shared code
* (and optionally a display name), persists to sessionStorage, then renders its
* children with that context. The backend is the real authority — a wrong code
* just makes the data requests 401.
*/
export function AccessGate({ codeKey, title, description, withName, children }: AccessGateProps) {
const nameKey = `${codeKey}_name`
const [code, setCode] = useState(() => sessionStorage.getItem(codeKey) ?? '')
const [name, setName] = useState(() => sessionStorage.getItem(nameKey) ?? '')
const [draftCode, setDraftCode] = useState('')
const [draftName, setDraftName] = useState('')
const unlocked = code.length > 0 && (!withName || name.length > 0)
const onUnlock = () => {
const c = draftCode.trim()
const n = draftName.trim()
if (!c || (withName && !n)) return
sessionStorage.setItem(codeKey, c)
if (withName) sessionStorage.setItem(nameKey, n)
setCode(c)
setName(n)
}
if (unlocked) return <>{children({ code, name })}</>
return (
<main className="min-h-screen bg-background flex items-center justify-center px-8">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
{description && <p className="text-xs text-muted-foreground leading-relaxed">{description}</p>}
</CardHeader>
<CardContent className="space-y-4">
{withName && (
<div className="space-y-2">
<label htmlFor="gate-name" className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Your name
</label>
<Input id="gate-name" value={draftName} onChange={(e) => setDraftName(e.target.value)} />
</div>
)}
<div className="space-y-2">
<label htmlFor="gate-code" className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Access code
</label>
<Input
id="gate-code"
type="password"
value={draftCode}
onChange={(e) => setDraftCode(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onUnlock()}
/>
</div>
<Button className="w-full" onClick={onUnlock}>Unlock</Button>
</CardContent>
</Card>
</main>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { TeamCard } from './TeamCard'
import type { TeamSnapshot } from '@/types'
const base: TeamSnapshot = {
id: 't1',
name: 'team_resonance',
kit: 'KIT-03',
members: ['a'],
phases: { reg: true, setup: true, m1: false, m2: false, add: false },
stats: { calls: 12, nominal: 8, anomalous: 3, critical: 1 },
deviceConnected: true,
updatedAt: new Date().toISOString(),
}
describe('TeamCard', () => {
it('renders name, kit and completed-phase markers', () => {
render(<TeamCard team={base} submitted={false} scored={false} />)
expect(screen.getByText('team_resonance')).toBeInTheDocument()
expect(screen.getByText('KIT-03')).toBeInTheDocument()
const dots = screen.getByTestId('phase-dots')
expect(dots.querySelector('[data-phase="reg"]')).toHaveAttribute('data-done', 'true')
expect(dots.querySelector('[data-phase="m1"]')).toHaveAttribute('data-done', 'false')
})
it('shows submitted and judged badges from flags', () => {
render(<TeamCard team={base} submitted scored />)
expect(screen.getByText(/submitted/i)).toBeInTheDocument()
expect(screen.getByText(/judged/i)).toBeInTheDocument()
})
it('dims a stale team', () => {
const old = { ...base, updatedAt: '2026-07-27T10:00:00.000Z' }
render(<TeamCard team={old} submitted={false} scored={false} now={new Date('2026-07-27T13:00:00.000Z').getTime()} />)
expect(screen.getByTestId('team-card').className).toContain('opacity-50')
})
})
+74
View File
@@ -0,0 +1,74 @@
import type { TeamSnapshot } from '@/types'
import type { PhaseKey } from '@/store/session'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
const PHASES: { key: PhaseKey; label: string }[] = [
{ key: 'reg', label: 'R' },
{ key: 'setup', label: 'S' },
{ key: 'm1', label: '1' },
{ key: 'm2', label: '2' },
{ key: 'add', label: 'A' },
]
const STALE_MS = 90_000
export interface TeamCardProps {
team: TeamSnapshot
submitted: boolean
scored: boolean
/** current wall-clock ms; 0 (default) disables stale dimming */
now?: number
}
/** One team's live tile in the instructor grid. Pure presentational. */
export function TeamCard({ team, submitted, scored, now = 0 }: TeamCardProps) {
const stale = now > 0 && now - new Date(team.updatedAt).getTime() > STALE_MS
return (
<div
data-testid="team-card"
data-team={team.id}
className={cn(
'rounded-md border border-border bg-card p-3 space-y-2 transition',
stale && 'opacity-50',
)}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{team.name || 'unnamed'}</div>
<div className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground">{team.kit}</div>
</div>
<span className={cn('w-2 h-2 rounded-full shrink-0', team.deviceConnected ? 'bg-teal' : 'bg-muted-foreground')} />
</div>
<div className="flex gap-1" data-testid="phase-dots">
{PHASES.map((p) => (
<span
key={p.key}
data-phase={p.key}
data-done={team.phases[p.key] ? 'true' : 'false'}
className={cn(
'flex-1 h-5 rounded-sm grid place-items-center font-mono text-[9px]',
team.phases[p.key] ? 'bg-teal/15 text-teal' : 'bg-secondary text-muted-foreground',
)}
>
{p.label}
</span>
))}
</div>
<div className="font-mono text-[10px] text-muted-foreground tabular-nums">
{team.stats.calls} · <span className="text-teal">{team.stats.nominal}</span>{' '}
<span className="text-amber">{team.stats.anomalous}</span>{' '}
<span className="text-rose">{team.stats.critical}</span>
</div>
{(submitted || scored) && (
<div className="flex gap-1">
{submitted && <Badge variant="outline" className="font-mono text-[8px] uppercase tracking-wider">submitted</Badge>}
{scored && <Badge className="font-mono text-[8px] uppercase tracking-wider">judged</Badge>}
</div>
)}
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { collectiveReducer, type CollectiveState } from './useCollective'
import type { TeamSnapshot } from '@/types'
const team = (id: string): TeamSnapshot => ({
id,
name: `team ${id}`,
kit: 'KIT-01',
members: [],
phases: { reg: true, setup: false, m1: false, m2: false, add: false },
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
deviceConnected: false,
updatedAt: '2026-07-27T13:00:00.000Z',
})
const empty: CollectiveState = { teams: {}, submissions: {} }
describe('collectiveReducer', () => {
it('seeds from a snapshot', () => {
const next = collectiveReducer(empty, {
type: 'snapshot',
teams: [team('a'), team('b')],
submissions: [{ teamId: 'a', teamName: 'team a', submittedAt: 'x', scored: false }],
})
expect(Object.keys(next.teams)).toEqual(['a', 'b'])
expect(next.submissions.a.scored).toBe(false)
})
it('patches a single team on team:update', () => {
const seeded = collectiveReducer(empty, { type: 'snapshot', teams: [team('a')], submissions: [] })
const next = collectiveReducer(seeded, { type: 'team:update', team: { ...team('a'), name: 'renamed' } })
expect(next.teams.a.name).toBe('renamed')
})
it('marks a submission scored on score:new', () => {
const seeded = collectiveReducer(empty, {
type: 'snapshot',
teams: [team('a')],
submissions: [{ teamId: 'a', teamName: 'team a', submittedAt: 'x', scored: false }],
})
const next = collectiveReducer(seeded, { type: 'score:new', teamId: 'a', total: 9 })
expect(next.submissions.a.scored).toBe(true)
})
it('ignores score:new for an unknown team', () => {
const next = collectiveReducer(empty, { type: 'score:new', teamId: 'ghost', total: 1 })
expect(next).toBe(empty)
})
})
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useReducer, useRef, useState } from 'react'
import { openCollective, getTeams, getSubmissions } from './api'
import type { TeamSnapshot, SubmissionSummary, WsEvent } from '@/types'
export interface CollectiveState {
teams: Record<string, TeamSnapshot>
submissions: Record<string, SubmissionSummary>
}
const empty: CollectiveState = { teams: {}, submissions: {} }
export function collectiveReducer(state: CollectiveState, event: WsEvent): CollectiveState {
switch (event.type) {
case 'snapshot': {
const teams: Record<string, TeamSnapshot> = {}
event.teams.forEach((t) => (teams[t.id] = t))
const submissions: Record<string, SubmissionSummary> = {}
event.submissions.forEach((s) => (submissions[s.teamId] = s))
return { teams, submissions }
}
case 'team:update':
return { ...state, teams: { ...state.teams, [event.team.id]: event.team } }
case 'submission:new':
return {
...state,
submissions: { ...state.submissions, [event.submission.teamId]: event.submission },
}
case 'score:new': {
const existing = state.submissions[event.teamId]
if (!existing) return state
return {
...state,
submissions: { ...state.submissions, [event.teamId]: { ...existing, scored: true } },
}
}
default:
return state
}
}
export type CollectiveStatus = 'connecting' | 'live' | 'polling'
export interface Collective {
teams: TeamSnapshot[]
submissions: Record<string, SubmissionSummary>
status: CollectiveStatus
}
/**
* Live view of the collective for admin/judge. Seeds from REST (so it works
* even if the WS never connects), then layers the WS feed on top. Degrades to
* REST polling if the socket drops.
*/
export function useCollective(code: string): Collective {
const [state, dispatch] = useReducer(collectiveReducer, empty)
const [status, setStatus] = useState<CollectiveStatus>('connecting')
const live = useRef(false)
useEffect(() => {
let cancelled = false
live.current = false
// REST seed/fallback — ignored once the authoritative WS feed is live
const poll = async () => {
try {
const [teams, submissions] = await Promise.all([getTeams(code), getSubmissions(code)])
if (!cancelled && !live.current) dispatch({ type: 'snapshot', teams, submissions })
} catch {
/* the WS snapshot may still arrive; leave state as-is */
}
}
void poll()
const close = openCollective(code, (e) => {
if (cancelled) return
live.current = true
dispatch(e)
setStatus('live')
})
// keep the grid moving only while the socket is silent
const interval = setInterval(() => {
if (live.current) return
setStatus('polling')
void poll()
}, 5000)
return () => {
cancelled = true
clearInterval(interval)
close()
}
}, [code])
const teams = Object.values(state.teams).sort((a, b) => a.name.localeCompare(b.name))
return { teams, submissions: state.submissions, status }
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect, useState } from 'react'
/** Ticking wall-clock timestamp (read in an effect, never during render). */
export function useNow(intervalMs = 15000): number {
const [now, setNow] = useState(0)
useEffect(() => {
const tick = () => setNow(Date.now())
const first = setTimeout(tick, 0)
const id = setInterval(tick, intervalMs)
return () => {
clearTimeout(first)
clearInterval(id)
}
}, [intervalMs])
return now
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, act, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import type { WsEvent, TeamSnapshot } from '@/types'
let emit: (e: WsEvent) => void = () => {}
vi.mock('@/lib/api', () => ({
openCollective: (_code: string, onEvent: (e: WsEvent) => void) => {
emit = onEvent
return () => {}
},
getTeams: vi.fn().mockResolvedValue([]),
getSubmissions: vi.fn().mockResolvedValue([]),
}))
import { Admin } from './Admin'
const team = (i: number): TeamSnapshot => ({
id: `t${i}`,
name: `team ${i}`,
kit: `KIT-${String(i).padStart(2, '0')}`,
members: [],
phases: { reg: true, setup: false, m1: false, m2: false, add: false },
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
deviceConnected: i % 2 === 0,
updatedAt: new Date().toISOString(),
})
function renderAdmin() {
return render(
<MemoryRouter>
<Admin />
</MemoryRouter>,
)
}
describe('Admin', () => {
beforeEach(() => {
sessionStorage.clear()
sessionStorage.setItem('apess_admin_code', 'admin-code')
})
it('renders a card per team from the snapshot', async () => {
renderAdmin()
const teams = Array.from({ length: 15 }, (_, i) => team(i + 1))
act(() => emit({ type: 'snapshot', teams, submissions: [] }))
await waitFor(() => expect(screen.getAllByTestId('team-card')).toHaveLength(15))
})
it('adds a submitted badge on submission:new', async () => {
renderAdmin()
act(() => emit({ type: 'snapshot', teams: [team(1)], submissions: [] }))
await waitFor(() => expect(screen.getByTestId('team-card')).toBeInTheDocument())
act(() => emit({ type: 'submission:new', submission: { teamId: 't1', teamName: 'team 1', submittedAt: 'x', scored: false } }))
await waitFor(() =>
expect(within(screen.getByTestId('team-card')).getByText(/submitted/i)).toBeInTheDocument(),
)
})
it('flips a phase dot on team:update', async () => {
renderAdmin()
act(() => emit({ type: 'snapshot', teams: [team(1)], submissions: [] }))
await waitFor(() => expect(screen.getByTestId('team-card')).toBeInTheDocument())
expect(screen.getByTestId('phase-dots').querySelector('[data-phase="m1"]')).toHaveAttribute('data-done', 'false')
act(() => emit({ type: 'team:update', team: { ...team(1), phases: { reg: true, setup: true, m1: true, m2: false, add: false } } }))
await waitFor(() =>
expect(screen.getByTestId('phase-dots').querySelector('[data-phase="m1"]')).toHaveAttribute('data-done', 'true'),
)
})
it('gates behind the access code when none is stored', () => {
sessionStorage.clear()
renderAdmin()
expect(screen.getByRole('button', { name: /unlock/i })).toBeInTheDocument()
expect(screen.queryByTestId('team-grid')).toBeNull()
})
})
+80
View File
@@ -0,0 +1,80 @@
import { Link } from 'react-router-dom'
import { AccessGate } from '@/components/AccessGate'
import { TeamCard } from '@/components/TeamCard'
import { useCollective } from '@/lib/useCollective'
import { useNow } from '@/lib/useNow'
import { cn } from '@/lib/utils'
function Stat({ label, value }: { label: string; value: number | string }) {
return (
<div className="bg-background p-3 text-center space-y-1">
<div className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground">{label}</div>
<div className="text-lg font-bold tabular-nums">{value}</div>
</div>
)
}
function AdminBoard({ code }: { code: string }) {
const { teams, submissions, status } = useCollective(code)
const now = useNow()
const connected = teams.filter((t) => t.deviceConnected).length
const submittedCount = Object.keys(submissions).length
const judgedCount = Object.values(submissions).filter((s) => s.scored).length
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"> · Instructor</span>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
<span className={cn('w-2 h-2 rounded-full', status === 'live' ? 'bg-teal animate-pulse' : 'bg-amber')} />
{status}
</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 space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-border rounded-md overflow-hidden">
<Stat label="Teams" value={teams.length} />
<Stat label="Connected" value={connected} />
<Stat label="Submitted" value={submittedCount} />
<Stat label="Judged" value={judgedCount} />
</div>
{teams.length === 0 ? (
<p className="text-sm text-muted-foreground">No teams have checked in yet.</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-4" data-testid="team-grid">
{teams.map((t) => (
<TeamCard
key={t.id}
team={t}
submitted={!!submissions[t.id]}
scored={!!submissions[t.id]?.scored}
now={now}
/>
))}
</div>
)}
</section>
</main>
)
}
export function Admin() {
return (
<AccessGate
codeKey="apess_admin_code"
title="Instructor dashboard"
description="Enter the instructor access code from the workshop slide."
>
{({ code }) => <AdminBoard code={code} />}
</AccessGate>
)
}