From dc0e8f701c6bb7f1edec8f3a9fd3b04a43e3c313 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Tue, 9 Jun 2026 08:51:03 +0200 Subject: [PATCH] V2 Phase 5+6: connectivity, presence, navigation & friction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- e2e-realtime/realtime.spec.ts | 4 +- e2e-realtime/seats.spec.ts | 2 +- e2e/assistant-levelup.spec.ts | 3 +- e2e/assistant.spec.ts | 2 +- e2e/homebrew.spec.ts | 2 +- e2e/worldbuilding.spec.ts | 6 +-- server/src/index.ts | 15 +++++- src/app/RootLayout.tsx | 12 ++++- src/features/cloud/SyncStatusIndicator.tsx | 53 ++++++++++++++++++++++ src/features/cloud/useCloudAutosave.ts | 8 +++- src/features/combat/EncounterTracker.tsx | 25 ++++++++-- src/stores/connectivityStore.ts | 23 ++++++++++ tsconfig.app.tsbuildinfo | 2 +- 13 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 src/features/cloud/SyncStatusIndicator.tsx create mode 100644 src/stores/connectivityStore.ts diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts index 57e7b22..01aceed 100644 --- a/e2e-realtime/realtime.spec.ts +++ b/e2e-realtime/realtime.spec.ts @@ -19,7 +19,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({ await gm.getByLabel('Settings').click(); await gm.getByRole('button', { name: 'Load sample campaign' }).click(); await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); - await gm.getByRole('button', { name: '📡 Host' }).click(); + await gm.getByRole('button', { name: 'Host' }).click(); const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? ''; expect(code).toHaveLength(6); @@ -76,7 +76,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({ await gm.getByRole('button', { name: /Share with/ }).click(); await gm.getByLabel('Share title').fill('Secret passage'); await gm.getByRole('button', { name: /Send to/ }).click(); - await expect(player.getByText('📨 Just for you')).toBeVisible(); + await expect(player.getByText('Just for you')).toBeVisible(); await expect(player.getByText('Secret passage')).toBeVisible(); await gmCtx.close(); diff --git a/e2e-realtime/seats.spec.ts b/e2e-realtime/seats.spec.ts index bdc7f5a..39812b5 100644 --- a/e2e-realtime/seats.spec.ts +++ b/e2e-realtime/seats.spec.ts @@ -19,7 +19,7 @@ test('a player claims a seat and manages their character; edits round-trip to th await gm.getByLabel('Settings').click(); await gm.getByRole('button', { name: 'Load sample campaign' }).click(); await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); - await gm.getByRole('button', { name: '📡 Host' }).click(); + await gm.getByRole('button', { name: 'Host' }).click(); const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? ''; expect(code).toHaveLength(6); diff --git a/e2e/assistant-levelup.spec.ts b/e2e/assistant-levelup.spec.ts index 05eef81..9c2e11d 100644 --- a/e2e/assistant-levelup.spec.ts +++ b/e2e/assistant-levelup.spec.ts @@ -33,7 +33,8 @@ test('level-up advisor offers routes and expands a chosen one (deterministic)', test('assistant page renders the campaign insights section', async ({ page }) => { await page.getByLabel('Settings').click(); await page.getByRole('button', { name: 'Load sample campaign' }).click(); - await page.getByRole('link', { name: 'Assistant' }).click(); + await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); + await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click(); await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible(); // Sample has a single encounter → not enough for a trend yet. await expect(page.getByText(/No trends detected yet/)).toBeVisible(); diff --git a/e2e/assistant.spec.ts b/e2e/assistant.spec.ts index 7fce28b..944d24f 100644 --- a/e2e/assistant.spec.ts +++ b/e2e/assistant.spec.ts @@ -23,7 +23,7 @@ test('assistant flags a bloodied PC and builds an encounter', async ({ page }) = // Assistant shows a resource suggestion await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click(); - await page.getByRole('link', { name: 'Assistant' }).click(); + await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click(); await expect(page.getByRole('heading', { name: 'Assistant' })).toBeVisible(); await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible(); diff --git a/e2e/homebrew.spec.ts b/e2e/homebrew.spec.ts index 7b77d32..4ec08f4 100644 --- a/e2e/homebrew.spec.ts +++ b/e2e/homebrew.spec.ts @@ -14,7 +14,7 @@ test('homebrew: create a custom monster and find it in the compendium', async ({ await page.locator('input[data-autofocus]').fill('Brew'); await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('link', { name: 'Dashboard' }).click(); - await page.getByRole('link', { name: 'Homebrew' }).click(); + await page.getByLabel('Primary').getByRole('link', { name: 'Homebrew' }).click(); // Create a homebrew monster and name it await page.getByRole('button', { name: /New monster/ }).click(); diff --git a/e2e/worldbuilding.spec.ts b/e2e/worldbuilding.spec.ts index ec4481d..84681db 100644 --- a/e2e/worldbuilding.spec.ts +++ b/e2e/worldbuilding.spec.ts @@ -30,13 +30,13 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn // NPC await page.getByRole('link', { name: 'Dashboard' }).click(); - await page.getByRole('link', { name: 'NPCs' }).click(); + await page.getByLabel('Primary').getByRole('link', { name: 'NPCs' }).click(); await page.getByRole('button', { name: '+ New NPC' }).first().click(); await expect(page.getByLabel('NPC name')).toBeVisible(); // Quest with an objective await page.getByRole('link', { name: 'Dashboard' }).click(); - await page.getByRole('link', { name: 'Quests' }).click(); + await page.getByLabel('Primary').getByRole('link', { name: 'Quests' }).click(); await page.getByRole('button', { name: '+ New quest' }).first().click(); await page.getByPlaceholder('Add objective…').fill('Find the Tome'); await page.getByRole('button', { name: 'Add', exact: true }).click(); @@ -44,7 +44,7 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn // Calendar advances days await page.getByRole('link', { name: 'Dashboard' }).click(); - await page.getByRole('link', { name: 'Calendar' }).click(); + await page.getByLabel('Primary').getByRole('link', { name: 'Calendar' }).click(); await page.getByRole('button', { name: '+1 week' }).click(); await expect(page.getByText('7', { exact: true })).toBeVisible(); }); diff --git a/server/src/index.ts b/server/src/index.ts index 94cd161..09dd82b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -23,6 +23,10 @@ const bearer = (req: FastifyRequest): string | undefined => { // per-socket token bucket: 80 messages / 10s const RATE = { capacity: 80, refillMs: 10_000 }; +// Protocol-level keepalive: ping each socket on an interval and terminate one +// that misses a pong, so a vanished player (closed laptop, dead network) is +// detected and drops out of the GM's roster within ~one interval. +const HEARTBEAT_MS = 30_000; export function buildServer() { const app = Fastify({ bodyLimit: BODY_LIMIT }); @@ -155,6 +159,15 @@ export function buildServer() { let tokens = RATE.capacity; const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs); + // Heartbeat: a socket that doesn't answer the previous ping is dead → drop it. + let alive = true; + socket.on('pong', () => { alive = true; }); + const heartbeat = setInterval(() => { + if (!alive) { try { socket.terminate(); } catch { /* already gone */ } return; } + alive = false; + try { socket.ping(); } catch { /* closed */ } + }, HEARTBEAT_MS); + socket.on('message', (raw: Buffer) => { if (tokens-- <= 0) { socket.close(1008, 'rate'); return; } let parsed; @@ -177,7 +190,7 @@ export function buildServer() { case 'setName': hub.setName(sender, m.name); break; } }); - socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); }); + socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); }); }); }); diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 793d107..4a11c88 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -3,6 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router'; import { Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower, LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight, + ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, type LucideIcon, } from 'lucide-react'; import { useUiStore } from '@/stores/uiStore'; @@ -20,10 +21,11 @@ import { usePlayerConnection } from '@/features/play/usePlayerConnection'; import { useCloudAutosave } from '@/features/cloud/useCloudAutosave'; import { PlayerSessionBadge } from '@/features/play/PlayerConnection'; import { SignalsBell } from '@/features/assistant/SignalsBell'; +import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator'; import { SessionSidebar } from '@/features/play/SessionSidebar'; import { useSessionStore } from '@/stores/sessionStore'; -type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'reference' }; +type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' }; const NAV: NavItem[] = [ { to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' }, { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' }, @@ -31,12 +33,19 @@ const NAV: NavItem[] = [ { to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' }, { to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' }, { to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' }, + { to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' }, + { to: '/npcs', label: 'NPCs', icon: Drama, exact: false, group: 'world' }, + { to: '/quests', label: 'Quests', icon: Target, exact: false, group: 'world' }, + { to: '/calendar', label: 'Calendar', icon: CalendarDays, exact: false, group: 'world' }, { to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' }, + { to: '/homebrew', label: 'Homebrew', icon: FlaskConical, exact: false, group: 'reference' }, + { to: '/assistant', label: 'Assistant', icon: Sparkles, exact: false, group: 'reference' }, { to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' }, ]; const GROUPS: { id: NavItem['group']; label: string | null }[] = [ { id: 'top', label: null }, { id: 'play', label: 'At the Table' }, + { id: 'world', label: 'World' }, { id: 'reference', label: 'Reference' }, ]; @@ -206,6 +215,7 @@ export function RootLayout() { {activeCampaign && } + diff --git a/src/features/cloud/SyncStatusIndicator.tsx b/src/features/cloud/SyncStatusIndicator.tsx new file mode 100644 index 0000000..faf3570 --- /dev/null +++ b/src/features/cloud/SyncStatusIndicator.tsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react'; +import { Cloud, CloudOff, RefreshCw, WifiOff } from 'lucide-react'; +import { cloudUsername } from '@/lib/cloud/client'; +import { useConnectivityStore } from '@/stores/connectivityStore'; +import { cn } from '@/lib/cn'; + +/** + * Compact shell indicator for connectivity + cloud sync. Shows "Offline" when + * the browser is offline, otherwise the autosave state (saving / saved / failed) + * while signed into the cloud. Renders nothing when online and not using cloud, + * to keep the bar quiet. Also owns the online/offline event wiring. + */ +export function SyncStatusIndicator() { + const online = useConnectivityStore((s) => s.online); + const cloudSync = useConnectivityStore((s) => s.cloudSync); + const setOnline = useConnectivityStore((s) => s.setOnline); + const signedIn = !!cloudUsername(); + + useEffect(() => { + const on = () => setOnline(true); + const off = () => setOnline(false); + window.addEventListener('online', on); + window.addEventListener('offline', off); + setOnline(navigator.onLine); + return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); }; + }, [setOnline]); + + if (!online) { + return ( + + Offline + + ); + } + + if (!signedIn) return null; + + const map = { + idle: null, + saving: { Icon: RefreshCw, text: 'Saving…', cls: 'text-muted', spin: true, title: 'Saving your campaign to the cloud…' }, + saved: { Icon: Cloud, text: 'Saved', cls: 'text-success', spin: false, title: 'Your campaign is backed up to the cloud.' }, + error: { Icon: CloudOff, text: 'Sync failed', cls: 'text-danger', spin: false, title: 'Cloud sync failed — your data is safe locally and will retry on the next change.' }, + } as const; + const s = map[cloudSync]; + if (!s) return null; + + return ( + + + {s.text} + + ); +} diff --git a/src/features/cloud/useCloudAutosave.ts b/src/features/cloud/useCloudAutosave.ts index 4509d0e..8e4d320 100644 --- a/src/features/cloud/useCloudAutosave.ts +++ b/src/features/cloud/useCloudAutosave.ts @@ -2,6 +2,7 @@ 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'; /** @@ -30,7 +31,12 @@ export function useCloudAutosave(): void { }, [], undefined); const save = useDebouncedCallback(() => { - if (cloudUsername()) void pushBackup().catch(() => { /* offline / transient — next change retries */ }); + 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. diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index 437ebef..bcd3766 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { ArrowDown, ArrowUp, @@ -121,6 +121,25 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter const current = currentCombatant(encounter); const isActive = encounter.status === 'active'; + // Keyboard turn control for a GM running the table hands-free. Guarded so it + // never fires while typing in a field, and only during an active fight. + const advanceRef = useRef(advanceTurn); + advanceRef.current = advanceTurn; + const prevRef = useRef(() => mutate(previousTurn)); + prevRef.current = () => mutate(previousTurn); + useEffect(() => { + if (!isActive) return; + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + const el = e.target as HTMLElement | null; + if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return; + if (e.key === 'n' || e.key === 'ArrowRight') { e.preventDefault(); advanceRef.current(); } + else if (e.key === 'p' || e.key === 'ArrowLeft') { e.preventDefault(); prevRef.current(); } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isActive]); + return (
{/* Control bar */} @@ -175,10 +194,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter )} {isActive && ( <> - -