Step 10K.1: device preferences store

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 22:57:11 -04:00
parent 5813fce556
commit c73c9a4116
5 changed files with 225 additions and 1 deletions

View File

@ -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). - **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). - **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`.

View File

@ -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<Preferences>;
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<Preferences>): 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<StoreApi<PreferencesStore>> {
return create<PreferencesStore>((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<Preferences, "reducedMotion">): boolean {
return prefs.reducedMotion ?? deviceReducesMotion();
}

View File

@ -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<string, string> } {
const data = new Map<string, string>();
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();
});
});

View File

@ -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/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/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/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/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
| `src/components/*` | `AwaySummary` dialog, `Countdown` | | `src/components/*` | `AwaySummary` dialog, `Countdown` |
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) | | `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |

View File

@ -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. 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.2 — Client: settings screen and entry point
- [ ] Step 10K.3 — Client: display options applied everywhere - [ ] Step 10K.3 — Client: display options applied everywhere
- [ ] Step 10K.4 — Client: reset map view and show tips again - [ ] Step 10K.4 — Client: reset map view and show tips again