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).
|
||||
- **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/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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue