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:
co-authored by
Claude Opus 4.8
parent
9808e6958e
commit
b18551f120
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user