Step 10K.1: device preferences store
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5813fce556
commit
c73c9a4116
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue