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
+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])
}