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:
co-authored by
Claude Opus 4.8
parent
66785601a2
commit
56324d7c89
@@ -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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,14 +63,21 @@ 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
@@ -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,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()
|
||||||
|
|||||||
@@ -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 & score</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Review & 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user