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
@@ -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