feat(apess): real per-team activity counts + judge board evidence

Stage E deeper — derive real signal from the board event stream:

- Collective reducer tallies per-team {calls, flashes, errors} from
  node:activity (thinking→calls, flash→flashes, error→errors); exposed
  as `counts` alongside the existing activity feed.
- Admin gains a room-wide "Flashes" pulse stat.
- Judge review card shows a live "Board evidence" panel — streams the
  reviewed team's own board activity via its SSE feed, so scoring can
  reference real on-device work, not just the submitted code.

Tests: front-end 182, typecheck clean, prod build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-03 09:19:53 -07:00
co-authored by Claude Opus 4.8
parent 66785601a2
commit 56324d7c89
5 changed files with 83 additions and 6 deletions
+12 -1
View File
@@ -13,7 +13,7 @@ const team = (id: string): TeamSnapshot => ({
updatedAt: '2026-07-27T13:00:00.000Z', updatedAt: '2026-07-27T13:00:00.000Z',
}) })
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [] } const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {} }
describe('collectiveReducer', () => { describe('collectiveReducer', () => {
it('seeds from a snapshot', () => { it('seeds from a snapshot', () => {
@@ -77,4 +77,15 @@ describe('collectiveReducer', () => {
const next = collectiveReducer(withNode, { type: 'snapshot', teams: [team('a')], submissions: [] }) const next = collectiveReducer(withNode, { type: 'snapshot', teams: [team('a')], submissions: [] })
expect(next.nodes.a).toBe(true) expect(next.nodes.a).toBe(true)
}) })
it('tallies real calls/flashes/errors per team from node:activity', () => {
let s = empty
s = collectiveReducer(s, { type: 'node:activity', teamId: 'a', kind: 'thinking', label: 'Agent started', ts: '1' })
s = collectiveReducer(s, { type: 'node:activity', teamId: 'a', kind: 'tool', label: 'Writing…', ts: '2' })
s = collectiveReducer(s, { type: 'node:activity', teamId: 'a', kind: 'flash', label: 'Flashed', ts: '3' })
s = collectiveReducer(s, { type: 'node:activity', teamId: 'a', kind: 'thinking', label: 'Agent started', ts: '4' })
s = collectiveReducer(s, { type: 'node:activity', teamId: 'b', kind: 'error', label: 'compile error', ts: '5' })
expect(s.counts.a).toEqual({ calls: 2, flashes: 1, errors: 0 }) // 'tool' isn't tallied
expect(s.counts.b).toEqual({ calls: 0, flashes: 0, errors: 1 })
})
}) })
+34 -3
View File
@@ -9,6 +9,13 @@ export interface NodeActivityEntry {
ts: string ts: string
} }
/** Real per-team activity tallies derived from the board event stream. */
export interface NodeCounts {
calls: number
flashes: number
errors: number
}
export interface CollectiveState { export interface CollectiveState {
teams: Record<string, TeamSnapshot> teams: Record<string, TeamSnapshot>
submissions: Record<string, SubmissionSummary> submissions: Record<string, SubmissionSummary>
@@ -16,10 +23,19 @@ export interface CollectiveState {
nodes: Record<string, boolean> nodes: Record<string, boolean>
/** rolling board-activity feed, most-recent-first, bounded. */ /** rolling board-activity feed, most-recent-first, bounded. */
activity: NodeActivityEntry[] activity: NodeActivityEntry[]
/** teamId → tallies (calls/flashes/errors). */
counts: Record<string, NodeCounts>
} }
const MAX_ACTIVITY = 40 const MAX_ACTIVITY = 40
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [] } const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {} }
const ZERO: NodeCounts = { calls: 0, flashes: 0, errors: 0 }
const COUNT_KEY: Partial<Record<NodeActivityKind, keyof NodeCounts>> = {
thinking: 'calls',
flash: 'flashes',
error: 'errors',
}
export function collectiveReducer(state: CollectiveState, event: WsEvent): CollectiveState { export function collectiveReducer(state: CollectiveState, event: WsEvent): CollectiveState {
switch (event.type) { switch (event.type) {
@@ -47,13 +63,20 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
} }
case 'node:status': case 'node:status':
return { ...state, nodes: { ...state.nodes, [event.teamId]: event.online } } return { ...state, nodes: { ...state.nodes, [event.teamId]: event.online } }
case 'node:activity': case 'node:activity': {
const key = COUNT_KEY[event.kind]
const prev = state.counts[event.teamId] ?? ZERO
const counts = key
? { ...state.counts, [event.teamId]: { ...prev, [key]: prev[key] + 1 } }
: state.counts
return { return {
...state, ...state,
activity: [ activity: [
{ teamId: event.teamId, kind: event.kind, label: event.label, ts: event.ts }, { teamId: event.teamId, kind: event.kind, label: event.label, ts: event.ts },
...state.activity, ...state.activity,
].slice(0, MAX_ACTIVITY), ].slice(0, MAX_ACTIVITY),
counts,
}
} }
default: default:
return state return state
@@ -67,6 +90,7 @@ export interface Collective {
submissions: Record<string, SubmissionSummary> submissions: Record<string, SubmissionSummary>
nodes: Record<string, boolean> nodes: Record<string, boolean>
activity: NodeActivityEntry[] activity: NodeActivityEntry[]
counts: Record<string, NodeCounts>
status: CollectiveStatus status: CollectiveStatus
} }
@@ -117,5 +141,12 @@ export function useCollective(code: string): Collective {
}, [code]) }, [code])
const teams = Object.values(state.teams).sort((a, b) => a.name.localeCompare(b.name)) const teams = Object.values(state.teams).sort((a, b) => a.name.localeCompare(b.name))
return { teams, submissions: state.submissions, nodes: state.nodes, activity: state.activity, status } return {
teams,
submissions: state.submissions,
nodes: state.nodes,
activity: state.activity,
counts: state.counts,
status,
}
} }
+4 -2
View File
@@ -16,10 +16,11 @@ function Stat({ label, value }: { label: string; value: number | string }) {
} }
function AdminBoard({ code }: { code: string }) { function AdminBoard({ code }: { code: string }) {
const { teams, submissions, nodes, activity, status } = useCollective(code) const { teams, submissions, nodes, activity, counts, status } = useCollective(code)
const now = useNow() const now = useNow()
const connected = teams.filter((t) => t.deviceConnected).length const connected = teams.filter((t) => t.deviceConnected).length
const boardsLive = Object.values(nodes).filter(Boolean).length const boardsLive = Object.values(nodes).filter(Boolean).length
const totalFlashes = Object.values(counts).reduce((n, c) => n + c.flashes, 0)
const submittedCount = Object.keys(submissions).length const submittedCount = Object.keys(submissions).length
const judgedCount = Object.values(submissions).filter((s) => s.scored).length const judgedCount = Object.values(submissions).filter((s) => s.scored).length
const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id
@@ -43,10 +44,11 @@ function AdminBoard({ code }: { code: string }) {
</header> </header>
<section className="px-8 py-8 max-w-6xl mx-auto space-y-6"> <section className="px-8 py-8 max-w-6xl mx-auto space-y-6">
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-border rounded-md overflow-hidden"> <div className="grid grid-cols-2 md:grid-cols-6 gap-px bg-border rounded-md overflow-hidden">
<Stat label="Teams" value={teams.length} /> <Stat label="Teams" value={teams.length} />
<Stat label="Connected" value={connected} /> <Stat label="Connected" value={connected} />
<Stat label="Boards live" value={boardsLive} /> <Stat label="Boards live" value={boardsLive} />
<Stat label="Flashes" value={totalFlashes} />
<Stat label="Submitted" value={submittedCount} /> <Stat label="Submitted" value={submittedCount} />
<Stat label="Judged" value={judgedCount} /> <Stat label="Judged" value={judgedCount} />
</div> </div>
+10
View File
@@ -10,6 +10,7 @@ const api = vi.hoisted(() => ({
getLeaderboard: vi.fn(), getLeaderboard: vi.fn(),
postScore: vi.fn(), postScore: vi.fn(),
openCollective: vi.fn(() => () => {}), openCollective: vi.fn(() => () => {}),
openTeamActivity: vi.fn(() => () => {}),
})) }))
vi.mock('@/lib/api', () => api) vi.mock('@/lib/api', () => api)
@@ -56,6 +57,15 @@ describe('Judge', () => {
await waitFor(() => expect(screen.getByTestId('add-review')).toHaveTextContent('KIT-01-AAA')) await waitFor(() => expect(screen.getByTestId('add-review')).toHaveTextContent('KIT-01-AAA'))
}) })
it("opens the selected team's live board-evidence feed", 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.getByText(/board evidence/i)).toBeInTheDocument())
expect(api.openTeamActivity).toHaveBeenCalledWith('a', expect.any(Function))
})
it('posts a score and refreshes', async () => { it('posts a score and refreshes', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderJudge() renderJudge()
+23
View File
@@ -12,13 +12,17 @@ import {
getLeaderboard, getLeaderboard,
postScore, postScore,
openCollective, openCollective,
openTeamActivity,
} from '@/lib/api' } from '@/lib/api'
import { BoardActivity } from '@/components/BoardActivity'
import type { NodeActivityEntry } from '@/lib/useCollective'
import type { SubmissionSummary, SubmissionDTO, LeaderboardRow } from '@/types' import type { SubmissionSummary, SubmissionDTO, LeaderboardRow } from '@/types'
function JudgeDesk({ code, name }: { code: string; name: string }) { function JudgeDesk({ code, name }: { code: string; name: string }) {
const [queue, setQueue] = useState<SubmissionSummary[]>([]) const [queue, setQueue] = useState<SubmissionSummary[]>([])
const [board, setBoard] = useState<LeaderboardRow[]>([]) const [board, setBoard] = useState<LeaderboardRow[]>([])
const [selected, setSelected] = useState<SubmissionDTO | null>(null) const [selected, setSelected] = useState<SubmissionDTO | null>(null)
const [evidence, setEvidence] = useState<NodeActivityEntry[]>([])
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const [subs, lb] = await Promise.all([getSubmissions(code), getLeaderboard(code)]).catch(() => [ const [subs, lb] = await Promise.all([getSubmissions(code), getLeaderboard(code)]).catch(() => [
@@ -36,6 +40,17 @@ function JudgeDesk({ code, name }: { code: string; name: string }) {
return () => close() return () => close()
}, [code, refresh]) }, [code, refresh])
// Live board evidence for the team under review (its own SSE feed).
useEffect(() => {
setEvidence([])
const teamId = selected?.teamId
if (!teamId) return
return openTeamActivity(teamId, (ev) => {
if (ev.type !== 'node:activity') return
setEvidence((p) => [{ teamId: ev.teamId, kind: ev.kind, label: ev.label, ts: ev.ts }, ...p].slice(0, 20))
})
}, [selected?.teamId])
const onSelect = async (teamId: string) => { const onSelect = async (teamId: string) => {
try { try {
setSelected(await getSubmission(teamId, code)) setSelected(await getSubmission(teamId, code))
@@ -81,6 +96,14 @@ function JudgeDesk({ code, name }: { code: string; name: string }) {
<CardHeader><CardTitle className="text-base">Review &amp; score</CardTitle></CardHeader> <CardHeader><CardTitle className="text-base">Review &amp; score</CardTitle></CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<AddReview submission={selected} /> <AddReview submission={selected} />
{selected && (
<div className="space-y-2 border-t border-border pt-4">
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Board evidence (live)
</div>
<BoardActivity activity={evidence} nameFor={() => selected.teamName} />
</div>
)}
{selected && <ScoreForm onSubmit={onScore} />} {selected && <ScoreForm onSubmit={onScore} />}
</CardContent> </CardContent>
</Card> </Card>