feat(apess): real per-team activity counts + judge board evidence #3
@@ -13,7 +13,7 @@ const team = (id: string): TeamSnapshot => ({
|
||||
updatedAt: '2026-07-27T13:00:00.000Z',
|
||||
})
|
||||
|
||||
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [] }
|
||||
const empty: CollectiveState = { teams: {}, submissions: {}, nodes: {}, activity: [], counts: {} }
|
||||
|
||||
describe('collectiveReducer', () => {
|
||||
it('seeds from a snapshot', () => {
|
||||
@@ -77,4 +77,15 @@ describe('collectiveReducer', () => {
|
||||
const next = collectiveReducer(withNode, { type: 'snapshot', teams: [team('a')], submissions: [] })
|
||||
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
|
||||
}
|
||||
|
||||
/** Real per-team activity tallies derived from the board event stream. */
|
||||
export interface NodeCounts {
|
||||
calls: number
|
||||
flashes: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
export interface CollectiveState {
|
||||
teams: Record<string, TeamSnapshot>
|
||||
submissions: Record<string, SubmissionSummary>
|
||||
@@ -16,10 +23,19 @@ export interface CollectiveState {
|
||||
nodes: Record<string, boolean>
|
||||
/** rolling board-activity feed, most-recent-first, bounded. */
|
||||
activity: NodeActivityEntry[]
|
||||
/** teamId → tallies (calls/flashes/errors). */
|
||||
counts: Record<string, NodeCounts>
|
||||
}
|
||||
|
||||
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 {
|
||||
switch (event.type) {
|
||||
@@ -47,13 +63,20 @@ export function collectiveReducer(state: CollectiveState, event: WsEvent): Colle
|
||||
}
|
||||
case 'node:status':
|
||||
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 {
|
||||
...state,
|
||||
activity: [
|
||||
{ teamId: event.teamId, kind: event.kind, label: event.label, ts: event.ts },
|
||||
...state.activity,
|
||||
].slice(0, MAX_ACTIVITY),
|
||||
counts,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return state
|
||||
@@ -67,6 +90,7 @@ export interface Collective {
|
||||
submissions: Record<string, SubmissionSummary>
|
||||
nodes: Record<string, boolean>
|
||||
activity: NodeActivityEntry[]
|
||||
counts: Record<string, NodeCounts>
|
||||
status: CollectiveStatus
|
||||
}
|
||||
|
||||
@@ -117,5 +141,12 @@ export function useCollective(code: string): Collective {
|
||||
}, [code])
|
||||
|
||||
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 }) {
|
||||
const { teams, submissions, nodes, activity, status } = useCollective(code)
|
||||
const { teams, submissions, nodes, activity, counts, status } = useCollective(code)
|
||||
const now = useNow()
|
||||
const connected = teams.filter((t) => t.deviceConnected).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 judgedCount = Object.values(submissions).filter((s) => s.scored).length
|
||||
const nameFor = (id: string) => teams.find((t) => t.id === id)?.name || id
|
||||
@@ -43,10 +44,11 @@ function AdminBoard({ code }: { code: string }) {
|
||||
</header>
|
||||
|
||||
<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="Connected" value={connected} />
|
||||
<Stat label="Boards live" value={boardsLive} />
|
||||
<Stat label="Flashes" value={totalFlashes} />
|
||||
<Stat label="Submitted" value={submittedCount} />
|
||||
<Stat label="Judged" value={judgedCount} />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ const api = vi.hoisted(() => ({
|
||||
getLeaderboard: vi.fn(),
|
||||
postScore: vi.fn(),
|
||||
openCollective: vi.fn(() => () => {}),
|
||||
openTeamActivity: vi.fn(() => () => {}),
|
||||
}))
|
||||
vi.mock('@/lib/api', () => api)
|
||||
|
||||
@@ -56,6 +57,15 @@ describe('Judge', () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup()
|
||||
renderJudge()
|
||||
|
||||
@@ -12,13 +12,17 @@ import {
|
||||
getLeaderboard,
|
||||
postScore,
|
||||
openCollective,
|
||||
openTeamActivity,
|
||||
} from '@/lib/api'
|
||||
import { BoardActivity } from '@/components/BoardActivity'
|
||||
import type { NodeActivityEntry } from '@/lib/useCollective'
|
||||
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 [evidence, setEvidence] = useState<NodeActivityEntry[]>([])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
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()
|
||||
}, [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) => {
|
||||
try {
|
||||
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>
|
||||
<CardContent className="space-y-6">
|
||||
<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} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user