Files
ttrpg_manager/src/features/cloud/useCloudAutosave.ts
T
NilsBriggen dc0e8f701c V2 Phase 5+6: connectivity, presence, navigation & friction
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>
2026-06-09 08:51:03 +02:00

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