dc0e8f701c
Phase 5 — live-session & multi-device hardening: - connectivityStore + SyncStatusIndicator in the shell: shows "Offline" when the browser is offline and the cloud autosave state (saving / saved / failed) when signed in; useCloudAutosave now reports its status. Owns online/offline events. - server presence heartbeat: protocol-level ping/pong per socket terminates a vanished connection (~30s), so a player who closes their laptop drops out of the GM's roster instead of lingering. No client changes needed. Phase 6 — onboarding & friction: - navigation: surfaced the previously orphaned routes in the rail — a new "World" group (Notes, NPCs, Quests, Calendar) and Homebrew + Assistant under Reference. (Empty-state hints and map rename already existed.) - combat keyboard control: n/→ next turn, p/← previous turn, guarded so it never fires while typing; button tooltips document it. Test hygiene: scoped now-ambiguous nav links in e2e to the Primary landmark, and fixed pre-existing stale emoji selectors in the realtime suite (📡 Host → Host, 📨 Just for you → Just for you) left over from the Living Codex emoji purge. Gate green: tsc + 277 unit + 34 e2e + 2 realtime. App + server build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
50 lines
2.2 KiB
TypeScript
50 lines
2.2 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useLiveQuery } from 'dexie-react-hooks';
|
|
import { db } from '@/lib/db/db';
|
|
import { cloudUsername, pushBackup } from '@/lib/cloud/client';
|
|
import { useConnectivityStore } from '@/stores/connectivityStore';
|
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
|
|
|
/**
|
|
* Autosave to the cloud while signed in: whenever durable data changes, debounce
|
|
* a full backup push. A fingerprint of (id:updatedAt) over the durable tables is
|
|
* watched via liveQuery, so it reacts to edits, adds, and deletes alike. High-churn
|
|
* tables (dice rolls, the session recap) are excluded so a roll doesn't trigger a
|
|
* save. No-op when signed out. Mounted once in the app shell.
|
|
*/
|
|
export function useCloudAutosave(): void {
|
|
const fingerprint = useLiveQuery(async () => {
|
|
const stamp = async (rows: Promise<Array<{ id?: string; campaignId?: string; updatedAt?: string }>>) =>
|
|
(await rows).map((r) => `${r.id ?? r.campaignId ?? ''}:${r.updatedAt ?? ''}`).join(',');
|
|
const [c, ch, e, n, np, q, m, h, cal] = await Promise.all([
|
|
stamp(db.campaigns.toArray()),
|
|
stamp(db.characters.toArray()),
|
|
stamp(db.encounters.toArray()),
|
|
stamp(db.notes.toArray()),
|
|
stamp(db.npcs.toArray()),
|
|
stamp(db.quests.toArray()),
|
|
stamp(db.maps.toArray()),
|
|
stamp(db.homebrew.toArray()),
|
|
stamp(db.calendars.toArray()),
|
|
]);
|
|
return [c, ch, e, n, np, q, m, h, cal].join('|');
|
|
}, [], undefined);
|
|
|
|
const save = useDebouncedCallback(() => {
|
|
if (!cloudUsername()) return;
|
|
const conn = useConnectivityStore.getState();
|
|
conn.setCloudSync('saving');
|
|
void pushBackup()
|
|
.then(() => useConnectivityStore.getState().setCloudSync('saved'))
|
|
.catch(() => useConnectivityStore.getState().setCloudSync('error')); // offline / transient — next change retries
|
|
}, 8000);
|
|
|
|
// Skip the initial load; only push once data actually changes after mount.
|
|
const prev = useRef<string | undefined>(undefined);
|
|
useEffect(() => {
|
|
if (fingerprint === undefined) return;
|
|
if (prev.current === undefined) { prev.current = fingerprint; return; }
|
|
if (prev.current !== fingerprint) { prev.current = fingerprint; save(); }
|
|
}, [fingerprint, save]);
|
|
}
|