feat: collective client sync — api.ts + useCollectiveSync (B2)

- src/lib/api.ts: typed client for the collective — pushTeam/pushSubmission
  (public), getTeams/getSubmissions/getSubmission/postScore/getLeaderboard
  (code-gated), and openCollective WS with auto-reconnect
- src/lib/useCollectiveSync.ts: offline-first sync mounted once in App —
  projects the store to a TeamSnapshot, pushes on phase change + submission,
  throttles stat churn, and routes failures to a sessionStorage outbox that
  retries on interval + window 'online'. Never blocks the participant flow.

11 new tests (incl. backend-down path); suite 112/112 green, typecheck + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 19:21:41 -07:00
co-authored by Claude Opus 4.8
parent 9808e6958e
commit b18551f120
5 changed files with 424 additions and 0 deletions
+2
View File
@@ -7,6 +7,7 @@ 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 { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { useCollectiveSync } from '@/lib/useCollectiveSync'
import type { PhaseKey } from '@/store/session' import type { PhaseKey } from '@/store/session'
function WorkshopStub({ title, phase }: { title: string; phase: PhaseKey }) { function WorkshopStub({ title, phase }: { title: string; phase: PhaseKey }) {
@@ -24,6 +25,7 @@ function WorkshopStub({ title, phase }: { title: string; phase: PhaseKey }) {
} }
export default function App() { export default function App() {
useCollectiveSync()
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
API_BASE,
pushTeam,
pushSubmission,
getTeams,
postScore,
openCollective,
} from './api'
import type { TeamSnapshot } from '@/types'
const snapshot: TeamSnapshot = {
id: 'team-1',
name: 'team_x',
kit: 'KIT-01',
members: ['a'],
phases: { reg: true, setup: false, m1: false, m2: false, add: false },
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
deviceConnected: true,
updatedAt: '2026-07-27T13:00:00.000Z',
}
describe('api client', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => vi.unstubAllGlobals())
it('pushTeam PUTs to /teams/:id', async () => {
fetchMock.mockResolvedValue({ ok: true })
await pushTeam(snapshot)
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe(`${API_BASE}/teams/team-1`)
expect(init.method).toBe('PUT')
expect(JSON.parse(init.body).name).toBe('team_x')
})
it('pushTeam rejects on a non-ok response', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500 })
await expect(pushTeam(snapshot)).rejects.toThrow(/500/)
})
it('pushSubmission POSTs and returns the summary', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ teamId: 'team-1', scored: false }) })
const res = await pushSubmission({
teamId: 'team-1',
teamName: 'team_x',
code: 'KIT-01-AAA',
add: { L1: null, L2: '', L3: '', L4: '', L5: '' },
submittedAt: '2026-07-27T18:00:00.000Z',
})
expect(res.teamId).toBe('team-1')
expect(fetchMock.mock.calls[0][1].method).toBe('POST')
})
it('getTeams sends the access code header', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => [] })
await getTeams('admin-code')
expect(fetchMock.mock.calls[0][1].headers['x-access-code']).toBe('admin-code')
})
it('postScore sends code + body', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 1 }) })
await postScore('judge-code', { teamId: 't', judge: 'J', rubric: {}, total: 7, notes: '' })
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe(`${API_BASE}/scores`)
expect(init.headers['x-access-code']).toBe('judge-code')
expect(JSON.parse(init.body).total).toBe(7)
})
})
describe('openCollective', () => {
it('opens a ws to the code-scoped url and forwards parsed events', () => {
const instances: Array<{ url: string; onmessage?: (e: MessageEvent) => void; onclose?: () => void; close: () => void }> = []
class FakeWS {
url: string
onmessage?: (e: MessageEvent) => void
onclose?: () => void
constructor(url: string) {
this.url = url
instances.push(this)
}
close = vi.fn()
}
vi.stubGlobal('WebSocket', FakeWS as unknown as typeof WebSocket)
const events: unknown[] = []
const close = openCollective('admin-code', (e) => events.push(e))
expect(instances[0].url).toBe(`${API_BASE.replace(/^http/, 'ws')}/ws?code=admin-code`)
instances[0].onmessage?.({ data: JSON.stringify({ type: 'score:new', teamId: 't', total: 9 }) } as MessageEvent)
expect(events).toEqual([{ type: 'score:new', teamId: 't', total: 9 }])
close()
vi.unstubAllGlobals()
})
})
+109
View File
@@ -0,0 +1,109 @@
import type {
TeamSnapshot,
SubmissionDTO,
SubmissionSummary,
ScoreInput,
ScoreDTO,
LeaderboardRow,
WsEvent,
} from '@/types'
export const API_BASE =
(import.meta.env.VITE_API_BASE as string | undefined) ?? 'https://api.apess.redclaw.dev'
async function asJson<T>(res: Response, label: string): Promise<T> {
if (!res.ok) throw new Error(`${label} ${res.status}`)
return (await res.json()) as T
}
function authHeaders(code: string): HeadersInit {
return { 'x-access-code': code }
}
// --- participant pushes (public) ------------------------------------------
export async function pushTeam(snapshot: TeamSnapshot): Promise<void> {
const res = await fetch(`${API_BASE}/teams/${encodeURIComponent(snapshot.id)}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(snapshot),
})
if (!res.ok) throw new Error(`pushTeam ${res.status}`)
}
export type SubmissionPush = Omit<SubmissionDTO, never>
export async function pushSubmission(payload: SubmissionPush): Promise<SubmissionSummary> {
const res = await fetch(`${API_BASE}/submissions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
})
return asJson<SubmissionSummary>(res, 'pushSubmission')
}
// --- protected reads / scoring (admin or judge) ---------------------------
export async function getTeams(code: string): Promise<TeamSnapshot[]> {
return asJson(await fetch(`${API_BASE}/teams`, { headers: authHeaders(code) }), 'getTeams')
}
export async function getSubmissions(code: string): Promise<SubmissionSummary[]> {
return asJson(await fetch(`${API_BASE}/submissions`, { headers: authHeaders(code) }), 'getSubmissions')
}
export async function getSubmission(team: string, code: string): Promise<SubmissionDTO> {
return asJson(
await fetch(`${API_BASE}/submissions/${encodeURIComponent(team)}`, { headers: authHeaders(code) }),
'getSubmission',
)
}
export async function postScore(code: string, body: ScoreInput): Promise<ScoreDTO> {
const res = await fetch(`${API_BASE}/scores`, {
method: 'POST',
headers: { ...authHeaders(code), 'content-type': 'application/json' },
body: JSON.stringify(body),
})
return asJson<ScoreDTO>(res, 'postScore')
}
export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
return asJson(await fetch(`${API_BASE}/leaderboard`, { headers: authHeaders(code) }), 'getLeaderboard')
}
// --- live feed ------------------------------------------------------------
export interface OpenCollectiveOptions {
reconnectMs?: number
}
/** Open the WS feed; auto-reconnects. Returns a close function. */
export function openCollective(
code: string,
onEvent: (e: WsEvent) => void,
opts: OpenCollectiveOptions = {},
): () => void {
const wsBase = API_BASE.replace(/^http/, 'ws')
const url = `${wsBase}/ws?code=${encodeURIComponent(code)}`
let ws: WebSocket | null = null
let closed = false
let timer: ReturnType<typeof setTimeout> | undefined
const connect = () => {
ws = new WebSocket(url)
ws.onmessage = (ev: MessageEvent) => {
try {
onEvent(JSON.parse(ev.data) as WsEvent)
} catch {
/* ignore malformed frame */
}
}
ws.onclose = () => {
if (!closed) timer = setTimeout(connect, opts.reconnectMs ?? 2000)
}
}
connect()
return () => {
closed = true
if (timer) clearTimeout(timer)
ws?.close()
}
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import {
projectSnapshot,
safePushTeam,
flushOutbox,
useCollectiveSync,
} from './useCollectiveSync'
import { useSession } from '@/store/session'
const snapshot = () => projectSnapshot(useSession.getState())
describe('projectSnapshot', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
})
it('maps store identity + progress into a snapshot', () => {
useSession.getState().setTeam({ name: 'team_x', kit: 'KIT-02' })
useSession.getState().completePhase('reg')
const s = snapshot()
expect(s.id).toBe(useSession.getState().teamId)
expect(s.name).toBe('team_x')
expect(s.kit).toBe('KIT-02')
expect(s.phases.reg).toBe(true)
})
})
describe('offline outbox', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => vi.unstubAllGlobals())
it('queues a failed push and never throws', async () => {
fetchMock.mockRejectedValue(new Error('offline'))
await expect(safePushTeam(snapshot())).resolves.toBeUndefined()
expect(JSON.parse(sessionStorage.getItem('apess_outbox')!)).toHaveLength(1)
})
it('flushes the outbox when connectivity returns', async () => {
fetchMock.mockRejectedValueOnce(new Error('offline'))
await safePushTeam(snapshot())
expect(JSON.parse(sessionStorage.getItem('apess_outbox')!)).toHaveLength(1)
fetchMock.mockResolvedValue({ ok: true })
await flushOutbox()
expect(JSON.parse(sessionStorage.getItem('apess_outbox')!)).toHaveLength(0)
})
})
describe('useCollectiveSync', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => vi.unstubAllGlobals())
it('pushes the team snapshot when a phase completes', async () => {
renderHook(() => useCollectiveSync())
act(() => {
useSession.getState().completePhase('setup')
})
await waitFor(() => {
const putCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'PUT')
expect(putCall).toBeTruthy()
})
})
it('does not throw when the backend is down', async () => {
fetchMock.mockRejectedValue(new Error('offline'))
renderHook(() => useCollectiveSync())
expect(() =>
act(() => {
useSession.getState().completePhase('m1')
}),
).not.toThrow()
})
})
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useRef } from 'react'
import { useSession, type SessionState } from '@/store/session'
import { pushTeam, pushSubmission, type SubmissionPush } from './api'
import type { TeamSnapshot } from '@/types'
const OUTBOX_KEY = 'apess_outbox'
/** Project the live store into a collective team snapshot. */
export function projectSnapshot(s: SessionState): TeamSnapshot {
return {
id: s.teamId,
name: s.team.name,
kit: s.team.kit,
members: s.team.members,
phases: s.phases,
stats: s.stats,
deviceConnected: s.device.connected,
updatedAt: new Date().toISOString(),
}
}
type OutboxItem = { kind: 'team'; payload: TeamSnapshot } | { kind: 'submission'; payload: SubmissionPush }
function readOutbox(): OutboxItem[] {
try {
return JSON.parse(sessionStorage.getItem(OUTBOX_KEY) ?? '[]') as OutboxItem[]
} catch {
return []
}
}
function writeOutbox(items: OutboxItem[]): void {
sessionStorage.setItem(OUTBOX_KEY, JSON.stringify(items))
}
function enqueue(item: OutboxItem): void {
// collapse duplicate team pushes — only the latest snapshot matters
const items = readOutbox().filter((i) => i.kind !== item.kind || item.kind === 'submission')
items.push(item)
writeOutbox(items)
}
async function send(item: OutboxItem): Promise<void> {
if (item.kind === 'team') await pushTeam(item.payload)
else await pushSubmission(item.payload)
}
/** Retry everything queued while offline; survivors stay queued. */
export async function flushOutbox(): Promise<void> {
const items = readOutbox()
if (!items.length) return
const remaining: OutboxItem[] = []
for (const item of items) {
try {
await send(item)
} catch {
remaining.push(item)
}
}
writeOutbox(remaining)
}
export async function safePushTeam(snapshot: TeamSnapshot): Promise<void> {
try {
await pushTeam(snapshot)
} catch {
enqueue({ kind: 'team', payload: snapshot })
}
}
export async function safePushSubmission(payload: SubmissionPush): Promise<void> {
try {
await pushSubmission(payload)
} catch {
enqueue({ kind: 'submission', payload })
}
}
export interface UseCollectiveSyncOptions {
throttleMs?: number
}
/**
* Best-effort, offline-first sync of this team's state to the collective.
* Mounted once near the router root. Pushes on phase changes and submission,
* throttles stat-driven churn, and never blocks the participant flow — failed
* pushes go to a sessionStorage outbox and retry on interval / `online`.
*/
export function useCollectiveSync(opts: UseCollectiveSyncOptions = {}): void {
const throttleMs = opts.throttleMs ?? 8000
const lastPush = useRef(0)
const lastSubmission = useRef<string | null>(null)
useEffect(() => {
void flushOutbox()
const onOnline = () => void flushOutbox()
window.addEventListener('online', onOnline)
const retry = setInterval(() => void flushOutbox(), 20000)
const unsub = useSession.subscribe((state, prev) => {
// submission is the one push that matters most — fire immediately, once
const code = state.submission.code
if (code && code !== lastSubmission.current) {
lastSubmission.current = code
void safePushSubmission({
teamId: state.teamId,
teamName: state.team.name,
code,
add: state.add,
submittedAt: state.submission.submittedAt ?? new Date().toISOString(),
})
}
const phaseChanged = state.phases !== prev.phases
const now = Date.now()
if (phaseChanged || now - lastPush.current > throttleMs) {
lastPush.current = now
void safePushTeam(projectSnapshot(state))
}
})
return () => {
window.removeEventListener('online', onOnline)
clearInterval(retry)
unsub()
}
}, [throttleMs])
}