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>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
+14
-1
@@ -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); });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+11
-1
@@ -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() {
|
||||
</Button>
|
||||
{activeCampaign && <HandoutControl />}
|
||||
<SessionControl />
|
||||
<SyncStatusIndicator />
|
||||
<SignalsBell />
|
||||
<ThemeToggle />
|
||||
<Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings">
|
||||
|
||||
@@ -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 (
|
||||
<span className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-warning" title="You're offline — changes are saved locally and will sync when you reconnect." role="status">
|
||||
<WifiOff size={14} aria-hidden /> <span className="hidden sm:inline">Offline</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={cn('flex items-center gap-1 rounded-md px-2 py-1 text-xs', s.cls)} title={s.title} role="status" aria-live="polite">
|
||||
<s.Icon size={14} aria-hidden className={s.spin ? 'animate-spin' : undefined} />
|
||||
<span className="hidden sm:inline">{s.text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
{/* Control bar */}
|
||||
@@ -175,10 +194,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
)}
|
||||
{isActive && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
|
||||
<Button variant="secondary" onClick={() => mutate(previousTurn)} title="Previous turn (p or ←)">
|
||||
<ChevronLeft size={15} aria-hidden /> Prev
|
||||
</Button>
|
||||
<Button variant="primary" onClick={advanceTurn}>
|
||||
<Button variant="primary" onClick={advanceTurn} title="Next turn (n or →)">
|
||||
Next turn <ChevronRight size={15} aria-hidden />
|
||||
</Button>
|
||||
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type CloudSync = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
interface ConnectivityState {
|
||||
/** browser online/offline (navigator.onLine + events) */
|
||||
online: boolean;
|
||||
/** cloud autosave state, surfaced in the shell */
|
||||
cloudSync: CloudSync;
|
||||
lastSavedAt: number | null;
|
||||
setOnline: (online: boolean) => void;
|
||||
setCloudSync: (cloudSync: CloudSync) => void;
|
||||
}
|
||||
|
||||
/** Ephemeral connectivity + sync status (intentionally not persisted). */
|
||||
export const useConnectivityStore = create<ConnectivityState>((set) => ({
|
||||
online: typeof navigator === 'undefined' ? true : navigator.onLine,
|
||||
cloudSync: 'idle',
|
||||
lastSavedAt: null,
|
||||
setOnline: (online) => set({ online }),
|
||||
setCloudSync: (cloudSync) =>
|
||||
set((s) => ({ cloudSync, lastSavedAt: cloudSync === 'saved' ? Date.now() : s.lastSavedAt })),
|
||||
}));
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user