From b431969243eac7a2aca227b67e2be909204d7998 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 18 Oct 2026 14:21:43 -0400 Subject: [PATCH] Step 28.2: client: presence tracking Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++ apps/web/src/GameRoot.tsx | 35 +++++++--- apps/web/src/client-config.ts | 21 ++++++ apps/web/src/platform/presence.ts | 97 +++++++++++++++++++++++++++ apps/web/test/presence.test.ts | 108 ++++++++++++++++++++++++++++++ memory-bank/architecture.md | 2 + memory-bank/progress.md | 2 +- 7 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/client-config.ts create mode 100644 apps/web/src/platform/presence.ts create mode 100644 apps/web/test/presence.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 330f0d1..abce76f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2052,3 +2052,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **`seen` is on the entry itself**, not a list of seen ids: it's pruned with the entry after 7 days, and catch-up writes entries with the same ids however it's split, so a replayed entry can't come back unseen. - **Marking seen takes ids** (what was toasted or shown), ignores unknown ones, and returns how many were newly seen; it's not a manual decision, so a toast doesn't wake the fleet. - **The away-period fields (`awaySince`, `lastAbsence`) stay for now**: Step 28.4 decides what the pop-up uses. + +## Step 28.2 + +- **Presence times come from the game clock**, not `Date.now()`: logbook entries are stamped with it, and test builds move it forward with the developer tools. +- **A cold start returns from the save's `lastTimeSeen`**: while the game is open the resolution loop runs a command every few seconds, so that's when the game was last on screen (or running in a background tab). +- **On iOS and Android, `appStateChange` decides**; the web build uses the Page Visibility API. A page opened in a background tab is away until it's shown. diff --git a/apps/web/src/GameRoot.tsx b/apps/web/src/GameRoot.tsx index 4625645..3428ea5 100644 --- a/apps/web/src/GameRoot.tsx +++ b/apps/web/src/GameRoot.tsx @@ -6,6 +6,7 @@ import { ApiProvider } from "./api/context"; import { CatchingUp } from "./components/PilotLogbook"; import { GameContext } from "./platform/context"; import { startResolutionLoop, type Game } from "./platform/game"; +import { platformPresence, startPresence } from "./platform/presence"; import { PLAYER_STATE_KEY } from "./state/player"; import { Button } from "./ui"; @@ -43,18 +44,34 @@ export function GameRoot({ game, children }: { game: Game; children: ReactNode } useEffect(() => { if (status !== "here") return; - const loop = startResolutionLoop( - game, - () => { - void queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY }); - void queryClient.invalidateQueries({ queryKey: ["board"] }); - }, - setCatchingUp, - ); + let cancelled = false; + let stop = () => {}; + // The save's last time seen, read before the loop moves it on: a cold start + // counts as coming back from then (decision 0.5.162). + void game.storage.load().then((loaded) => { + if (cancelled) return; + const savedAt = "save" in loaded ? loaded.save.lastTimeSeen : null; + const stopPresence = startPresence(platformPresence(), () => game.clock.now(), savedAt); + const loop = startResolutionLoop( + game, + () => { + void queryClient.invalidateQueries({ queryKey: PLAYER_STATE_KEY }); + void queryClient.invalidateQueries({ queryKey: ["board"] }); + }, + setCatchingUp, + ); + stop = () => { + loop.stop(); + stopPresence(); + }; + }); if (game.devTools) { window.deadhead = { call: (name, input) => game.engine.call(name, input as never) }; } - return () => loop.stop(); + return () => { + cancelled = true; + stop(); + }; }, [status, game, queryClient]); const useHere = async () => { diff --git a/apps/web/src/client-config.ts b/apps/web/src/client-config.ts new file mode 100644 index 0000000..193342a --- /dev/null +++ b/apps/web/src/client-config.ts @@ -0,0 +1,21 @@ +/** + * Client settings that aren't game content (CR-028 §3): how toasts behave and + * when coming back counts as a real absence. Tests may read them; nothing in + * the engine does. + */ +export const clientConfig = { + presence: { + /** Away for less than this: unseen events show as toasts, not the pop-up (decision 0.5.162). */ + shortAbsenceMs: 60_000, + }, + toasts: { + /** Each toast closes on its own after this (decision 0.5.161). */ + durationMs: 5_000, + /** At most this many at once; the rest become one "+N more". */ + maxVisible: 3, + /** Independent pilots' routine deliveries this close together share one toast. */ + batchWindowMs: 10_000, + /** From this viewport width up, toasts sit top-right; below, top-center. */ + desktopMinWidthPx: 768, + }, +} as const; diff --git a/apps/web/src/platform/presence.ts b/apps/web/src/platform/presence.ts new file mode 100644 index 0000000..f40e1f9 --- /dev/null +++ b/apps/web/src/platform/presence.ts @@ -0,0 +1,97 @@ +import { App } from "@capacitor/app"; +import { Capacitor } from "@capacitor/core"; +import { create } from "zustand"; + +/** A stretch of time the player was away: the game hidden, in the background, or closed. */ +export interface Absence { + from: number; + to: number; +} + +/** + * Whether the player is "here" (decision 0.5.160): the tab visible or the app + * in the foreground. `lastReturn` is the absence that ended most recently + * (a cold start counts as coming back from the save's last time seen); + * `returns` counts comebacks, so a component can react to each one. + */ +export interface PresenceState { + here: boolean; + awaySince: number | null; + lastReturn: Absence | null; + returns: number; +} + +/** The state when the game starts: back from `savedAt` (null for a new game) if it opens in front. */ +export function presenceAtStart(here: boolean, now: number, savedAt: number | null): PresenceState { + if (!here) return { here, awaySince: savedAt ?? now, lastReturn: null, returns: 0 }; + return { + here, + awaySince: null, + lastReturn: savedAt !== null && savedAt < now ? { from: savedAt, to: now } : null, + returns: savedAt !== null && savedAt < now ? 1 : 0, + }; +} + +/** Going away or coming back at `now`; anything else changes nothing. */ +export function presenceChange(state: PresenceState, here: boolean, now: number): PresenceState { + if (here === state.here) return state; + if (!here) return { ...state, here, awaySince: now }; + return { + here, + awaySince: null, + lastReturn: { from: state.awaySince ?? now, to: now }, + returns: state.returns + 1, + }; +} + +export const usePresence = create(() => ({ + here: true, + awaySince: null, + lastReturn: null, + returns: 0, +})); + +/** Where "here" comes from: the page's visibility, or the native app's state. */ +export interface PresenceHost { + isHere(): boolean; + /** Calls `fn` with each change; returns an unsubscribe. */ + onChange(fn: (here: boolean) => void): () => void; +} + +/** The browser tab (Page Visibility API). */ +export const browserPresence: PresenceHost = { + isHere: () => document.visibilityState === "visible", + onChange(fn) { + const listener = () => fn(document.visibilityState === "visible"); + document.addEventListener("visibilitychange", listener); + return () => document.removeEventListener("visibilitychange", listener); + }, +}; + +/** The native app (Capacitor app state): in the foreground or not. */ +export const nativePresence: PresenceHost = { + isHere: () => document.visibilityState === "visible", + onChange(fn) { + const handle = App.addListener("appStateChange", ({ isActive }) => fn(isActive)); + return () => void handle.then((h) => h.remove()); + }, +}; + +export function platformPresence(): PresenceHost { + return Capacitor.isNativePlatform() ? nativePresence : browserPresence; +} + +/** + * Tracks presence into `usePresence` (CR-028 Step 28.2), with times from the + * game's clock so they compare with logbook times. Returns a stop function. + */ +export function startPresence( + host: PresenceHost, + now: () => number, + savedAt: number | null, +): () => void { + usePresence.setState(presenceAtStart(host.isHere(), now(), savedAt)); + return host.onChange((here) => { + usePresence.setState((state) => presenceChange(state, here, now())); + }); +} diff --git a/apps/web/test/presence.test.ts b/apps/web/test/presence.test.ts new file mode 100644 index 0000000..8aa3811 --- /dev/null +++ b/apps/web/test/presence.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + browserPresence, + presenceAtStart, + presenceChange, + startPresence, + usePresence, + type PresenceHost, +} from "../src/platform/presence"; + +const T = 1_000_000; + +/** A host whose visibility the test flips. */ +function fakeHost(here = true) { + let listener: ((here: boolean) => void) | null = null; + const host: PresenceHost = { + isHere: () => here, + onChange(fn) { + listener = fn; + return () => (listener = null); + }, + }; + return { + host, + set(value: boolean) { + here = value; + listener?.(value); + }, + get listening() { + return listener !== null; + }, + }; +} + +let visibility: DocumentVisibilityState = "visible"; +Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibility, +}); + +afterEach(() => { + visibility = "visible"; +}); + +describe("presence (Step 28.2, decision 0.5.160)", () => { + it("records when the player went away and came back", () => { + let state = presenceAtStart(true, T, null); + expect(state).toEqual({ here: true, awaySince: null, lastReturn: null, returns: 0 }); + state = presenceChange(state, false, T + 1_000); + expect(state).toMatchObject({ here: false, awaySince: T + 1_000 }); + // Hidden again (a second event) changes nothing. + expect(presenceChange(state, false, T + 2_000)).toBe(state); + state = presenceChange(state, true, T + 301_000); + expect(state).toEqual({ + here: true, + awaySince: null, + lastReturn: { from: T + 1_000, to: T + 301_000 }, + returns: 1, + }); + }); + + it("counts a cold start as coming back from the save's last time seen", () => { + expect(presenceAtStart(true, T, T - 3_600_000)).toEqual({ + here: true, + awaySince: null, + lastReturn: { from: T - 3_600_000, to: T }, + returns: 1, + }); + // Opened in a background tab: away since then, until it's shown. + const hidden = presenceAtStart(false, T, T - 3_600_000); + expect(presenceChange(hidden, true, T + 5_000).lastReturn).toEqual({ + from: T - 3_600_000, + to: T + 5_000, + }); + }); + + it("follows the host into the store, with times from the game clock", () => { + const fake = fakeHost(); + let now = T; + const stop = startPresence(fake.host, () => now, T - 60_000); + expect(usePresence.getState().lastReturn).toEqual({ from: T - 60_000, to: T }); + now = T + 10_000; + fake.set(false); + expect(usePresence.getState()).toMatchObject({ here: false, awaySince: T + 10_000 }); + now = T + 40_000; + fake.set(true); + expect(usePresence.getState()).toMatchObject({ + here: true, + lastReturn: { from: T + 10_000, to: T + 40_000 }, + returns: 2, + }); + stop(); + expect(fake.listening).toBe(false); + }); + + it("reads the browser tab's visibility", () => { + const seen: boolean[] = []; + const stop = browserPresence.onChange((here) => seen.push(here)); + visibility = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + expect(browserPresence.isHere()).toBe(false); + visibility = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + stop(); + document.dispatchEvent(new Event("visibilitychange")); + expect(seen).toEqual([false, true]); + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 9bfcae1..2242f6c 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -167,3 +167,5 @@ Balance simulator events (CR-026 Step 26.9): `simulate` options `events` ("off" Catch-up and service (CR-027 Step 27.4): `decide` passes the ship's unstarted booking (`{ hqId }`) to `chooseAction`; `nextDecision` lets a pilot on a service trip (`onServiceTrip`: booked, reachable, not there yet) decide up to the 7-day cap even while the fleet is asleep. Away problems (CR-027 Step 27.6): `awayProblems` adds `service-waiting-funds` and `service-cant-reach` (ship and HQ) for unstarted bookings; the client words them from `awayWording.highlights.serviceWaitingFunds` / `serviceCantReach`. + +Presence (CR-028 Step 28.2): `apps/web/src/platform/presence.ts` keeps `usePresence` (here, `awaySince`, `lastReturn`, `returns`) from the tab's visibility or Capacitor's app state, in game-clock time. `GameRoot` starts it with the stored save's `lastTimeSeen` (read before the resolution loop's first command), so opening the game is a return from then. Client-only settings (toast timing, the short-absence threshold) live in `apps/web/src/client-config.ts`. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index fdda0ae..206c6e5 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -582,7 +582,7 @@ Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR- Spec: `memory-bank/change-requests/CR-028-live-toasts.md` (received as "CR-026 … Phase 26"). New decisions recorded as plan §0.5.160–0.5.163. - [x] **Step 28.1 — Engine: seen tracking.** Every logbook entry has a saved `seen` flag (schema, and the sim's `LogbookEntry`); `logEntry` writes new entries unseen. Command `markLogbookSeen` (`POST /api/logbook/seen`, `{ entryIds }` → `{ marked }`), not a manual decision, so it doesn't wake the fleet. Save version 13: the 12 → 13 migration marks every existing entry seen. Tests: `packages/engine/test/logbook.test.ts` (new entries unseen; marking one, unknown ids ignored, marking again changes nothing, the fleet not woken, kept through export and import); `packages/schema/test/save.test.ts` (12 → 13); the route catalog. -- [ ] Step 28.2 — Client: presence tracking +- [x] **Step 28.2 — Client: presence tracking.** `apps/web/src/platform/presence.ts`: `usePresence` (here, away since, the last absence that ended, a count of comebacks) fed by `startPresence` from the Page Visibility API (`browserPresence`) or Capacitor's `appStateChange` on iOS and Android (`nativePresence`), with times from the game clock so they compare with logbook times; pure `presenceAtStart` and `presenceChange`. `GameRoot` reads the save's `lastTimeSeen` before the resolution loop moves it on, so a cold start counts as coming back from then. New `apps/web/src/client-config.ts` holds the toast timings and the 60-second short absence (CR-028 §3). Tests (`apps/web/test/presence.test.ts`): going away and coming back give the right times, repeats ignored; a cold start (and one in a background tab); the store following a host with the game clock; the browser's `visibilitychange`. - [ ] Step 28.3 — Client: toasts - [ ] Step 28.4 — Client: pop-up only after being away - [ ] Step 28.5 — Regression