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]>
128 lines
3.8 KiB
TypeScript
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])
|
|
}
|