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:
co-authored by
Claude Opus 4.8
parent
b18551f120
commit
6c07788fab
+2
-1
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user