Files
apress/src/lib/useCollectiveSync.ts
T
Omar SobhandClaude Opus 4.8 c12f40837b fix(state): persist to localStorage + explicit Disconnect (survive reload)
The Zustand store persisted to sessionStorage, which is tab-volatile — a
hard reload / new tab / closing the tab dropped the whole session,
including the board binding (device.connected, nodeUrl, teamId). The
workshop runs on the team's own laptop, so switch the store (and the
offline outbox) to localStorage: the binding + progress now survive a
refresh, hard reload, navigation, and tab close.

Clearing is now explicit only: a new `disconnect()` action drops the board
binding (keeping team/domain/phases/ADD so they can re-bind and resume),
surfaced as a "Disconnect" button on the claimed-board card. `reset()`
still wipes everything.

Tests: add an in-memory localStorage to setupTests (Node 22's disabled
experimental localStorage shadows jsdom's) + clear it per test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 10:34:55 -07:00

128 lines
3.8 KiB
TypeScript

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,
domain: s.domain,
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(localStorage.getItem(OUTBOX_KEY) ?? '[]') as OutboxItem[]
} catch {
return []
}
}
function writeOutbox(items: OutboxItem[]): void {
localStorage.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 localStorage 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])
}