From c73c9a41169570411979b0552b42b7e040690ada Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 22:57:11 -0400 Subject: [PATCH] Step 10K.1: device preferences store Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ apps/web/src/state/preferences.ts | 134 ++++++++++++++++++++++++++++++ apps/web/test/preferences.test.ts | 82 ++++++++++++++++++ memory-bank/architecture.md | 1 + memory-bank/progress.md | 2 +- 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/state/preferences.ts create mode 100644 apps/web/test/preferences.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index e1774e0..ffd11fd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1038,3 +1038,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Renumbered on arrival:** titled "CR-008 … Phase 10H"; CR-008 to CR-010 and Phases 10H–10J already exist, so it is **CR-011**, done as **Phase 10K** (its 10H.n are 10K.n). - **Decisions** 0.5.53–0.5.58 are recorded as **0.5.71–0.5.76**; "account linking" is added to §0.3 (deferred). + +## Step 10K.1 + +- **Reduced motion is stored as an override** (`null` = follow the device), so a player who never touches it keeps tracking their OS setting, as decision 0.5.76 asks. +- **Clock default is `locale`** (the device's own 12/24-hour habit) rather than a fixed format. +- **Saved data is validated field by field** on load; anything unknown or broken falls back to that field's default instead of discarding everything. +- **The store is built by a factory** (`createPreferencesStore(storage)`) so tests can simulate a reload or a throwing storage; the app uses one instance on `localStorage`. diff --git a/apps/web/src/state/preferences.ts b/apps/web/src/state/preferences.ts new file mode 100644 index 0000000..1dcd380 --- /dev/null +++ b/apps/web/src/state/preferences.ts @@ -0,0 +1,134 @@ +import { create, type StoreApi, type UseBoundStore } from "zustand"; +import type { Camera } from "../map/camera"; + +/** + * Device preferences (CR-011, decision 0.5.76): display and help settings kept + * on this device only. Every storage access is wrapped, so the app runs on + * defaults when storage is blocked, full or broken. + */ + +export type ClockFormat = "locale" | "12h" | "24h"; +export type CountdownFormat = "real" | "game" | "both"; + +export interface Preferences { + clock: ClockFormat; + countdown: CountdownFormat; + /** Reduced motion override; null follows the device setting. */ + reducedMotion: boolean | null; + /** One-time tips the player has dismissed, by id. */ + dismissedTips: string[]; + /** The map view to restore; null for the default view. */ + mapCamera: Camera | null; +} + +export const DEFAULT_PREFERENCES: Preferences = { + clock: "locale", + countdown: "real", + reducedMotion: null, + dismissedTips: [], + mapCamera: null, +}; + +/** The part of `Storage` the store needs (a fake in tests). */ +export interface PreferenceStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export const PREFERENCES_KEY = "deadhead:preferences:v1"; + +/** localStorage if this device allows it; null when it doesn't. */ +function deviceStorage(): PreferenceStorage | null { + try { + return typeof localStorage === "undefined" ? null : localStorage; + } catch { + return null; + } +} + +/** Saved preferences merged over the defaults; the defaults on any problem. */ +export function loadPreferences(storage: PreferenceStorage | null): Preferences { + try { + const raw = storage?.getItem(PREFERENCES_KEY); + if (!raw) return DEFAULT_PREFERENCES; + const saved = JSON.parse(raw) as Partial; + return { + clock: ["locale", "12h", "24h"].includes(saved.clock as string) + ? (saved.clock as ClockFormat) + : DEFAULT_PREFERENCES.clock, + countdown: ["real", "game", "both"].includes(saved.countdown as string) + ? (saved.countdown as CountdownFormat) + : DEFAULT_PREFERENCES.countdown, + reducedMotion: typeof saved.reducedMotion === "boolean" ? saved.reducedMotion : null, + dismissedTips: Array.isArray(saved.dismissedTips) + ? saved.dismissedTips.filter((t): t is string => typeof t === "string") + : [], + mapCamera: + saved.mapCamera && + [saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite) + ? saved.mapCamera + : null, + }; + } catch { + return DEFAULT_PREFERENCES; + } +} + +function savePreferences(storage: PreferenceStorage | null, prefs: Preferences): void { + try { + storage?.setItem(PREFERENCES_KEY, JSON.stringify(prefs)); + } catch { + // Storage blocked or full: keep going in memory. + } +} + +/** Whether the device asks for reduced motion. */ +export function deviceReducesMotion(): boolean { + try { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } catch { + return false; + } +} + +export interface PreferencesStore extends Preferences { + set(change: Partial): void; + dismissTip(id: string): void; + showTipsAgain(): void; +} + +/** A preferences store backed by `storage` (device localStorage by default). */ +export function createPreferencesStore( + storage: PreferenceStorage | null = deviceStorage(), +): UseBoundStore> { + return create((setState, getState) => { + const persist = () => { + const { clock, countdown, reducedMotion, dismissedTips, mapCamera } = getState(); + savePreferences(storage, { clock, countdown, reducedMotion, dismissedTips, mapCamera }); + }; + return { + ...loadPreferences(storage), + set(change) { + setState(change); + persist(); + }, + dismissTip(id) { + if (!getState().dismissedTips.includes(id)) { + setState({ dismissedTips: [...getState().dismissedTips, id] }); + persist(); + } + }, + showTipsAgain() { + setState({ dismissedTips: [] }); + persist(); + }, + }; + }); +} + +export const usePreferences = createPreferencesStore(); + +/** Reduced motion in effect: the player's override, else the device setting. */ +export function reducedMotionOn(prefs: Pick): boolean { + return prefs.reducedMotion ?? deviceReducesMotion(); +} diff --git a/apps/web/test/preferences.test.ts b/apps/web/test/preferences.test.ts new file mode 100644 index 0000000..a3af7a4 --- /dev/null +++ b/apps/web/test/preferences.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createPreferencesStore, + DEFAULT_PREFERENCES, + PREFERENCES_KEY, + reducedMotionOn, + type PreferenceStorage, +} from "../src/state/preferences"; + +/** An in-memory storage, like localStorage. */ +function memoryStorage(): PreferenceStorage & { data: Map } { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => void data.set(key, value), + }; +} + +/** jsdom has no matchMedia: stand one in that answers the reduced-motion query. */ +const mockOsReducedMotion = (reduce: boolean) => + vi.stubGlobal("matchMedia", (query: string) => ({ matches: reduce, media: query })); + +describe("device preferences (Step 10K.1)", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("starts with the defaults: locale clock, real-time countdowns, the device's reduced motion", () => { + const store = createPreferencesStore(memoryStorage()); + expect(store.getState()).toMatchObject(DEFAULT_PREFERENCES); + + mockOsReducedMotion(true); + expect(reducedMotionOn(store.getState())).toBe(true); + mockOsReducedMotion(false); + expect(reducedMotionOn(store.getState())).toBe(false); + // An override wins over the device. + store.getState().set({ reducedMotion: true }); + expect(reducedMotionOn(store.getState())).toBe(true); + }); + + it("keeps saved values across a reload", () => { + const storage = memoryStorage(); + const first = createPreferencesStore(storage); + first.getState().set({ clock: "24h", countdown: "both" }); + first.getState().dismissTip("tutorial-intro"); + first.getState().set({ mapCamera: { zoom: 3, panX: 10, panY: -4 } }); + + const reloaded = createPreferencesStore(storage); + expect(reloaded.getState()).toMatchObject({ + clock: "24h", + countdown: "both", + dismissedTips: ["tutorial-intro"], + mapCamera: { zoom: 3, panX: 10, panY: -4 }, + }); + }); + + it("ignores broken saved data", () => { + const storage = memoryStorage(); + storage.setItem(PREFERENCES_KEY, "{not json"); + expect(createPreferencesStore(storage).getState()).toMatchObject(DEFAULT_PREFERENCES); + storage.setItem( + PREFERENCES_KEY, + JSON.stringify({ clock: "sundial", mapCamera: { zoom: "x" } }), + ); + expect(createPreferencesStore(storage).getState()).toMatchObject(DEFAULT_PREFERENCES); + }); + + it("works in memory when storage throws, without errors", () => { + const broken: PreferenceStorage = { + getItem: () => { + throw new Error("SecurityError"); + }, + setItem: () => { + throw new Error("QuotaExceededError"); + }, + }; + const store = createPreferencesStore(broken); + expect(store.getState()).toMatchObject(DEFAULT_PREFERENCES); + expect(() => store.getState().set({ clock: "12h" })).not.toThrow(); + expect(store.getState().clock).toBe("12h"); + expect(() => createPreferencesStore(null).getState().dismissTip("x")).not.toThrow(); + }); +}); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 8a072e2..61e53fb 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -465,6 +465,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen | `src/map/overlays.ts` | Per-location state overlays (lock, desaturate/dim, HQ marker, idle badge), applied by the renderer | | `src/map/ticker.ts` | `runWhileVisible`: pause the map's ticker while the page is hidden; stop it on unmount | | `src/map/renderer.ts` | `MapRenderer`: all PixiJS drawing for the map, layer by layer; `MapCanvas` owns the app, input and loading state | +| `src/state/preferences.ts` | Device preferences (CR-011): clock and countdown format, reduced-motion override, dismissed tips, saved map camera; local storage, every access wrapped | | `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen | | `src/components/*` | `AwaySummary` dialog, `Countdown` | | `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 3a794a7..66419ab 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -252,7 +252,7 @@ Spec: `memory-bank/change-requests/CR-010-veteran-pilot-profit-share.md` (receiv Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-008 … Phase 10H"). New decisions recorded as plan §0.5.71–0.5.76. -- [ ] Step 10K.1 — Client: device preferences store +- [x] **Step 10K.1 — Client: device preferences store.** `state/preferences.ts`: `usePreferences` (Zustand) with clock (`locale`/`12h`/`24h`), countdown (`real`/`game`/`both`), reduced-motion override (null = follow the device), dismissed tips and saved map camera; `set`, `dismissTip`, `showTipsAgain`. Saved under `deadhead:preferences:v1`; loading validates each field and falls back to defaults; every read and write is wrapped. `reducedMotionOn(prefs)`. Test: `preferences.test.ts` (defaults incl. mocked OS reduced motion, survives a reload, broken data ignored, throwing storage works in memory). - [ ] Step 10K.2 — Client: settings screen and entry point - [ ] Step 10K.3 — Client: display options applied everywhere - [ ] Step 10K.4 — Client: reset map view and show tips again