From b875342864f00543bac7b5b5bc41c17f5b6d9736 Mon Sep 17 00:00:00 2001 From: ncode Date: Thu, 17 Sep 2026 10:46:42 +0300 Subject: [PATCH] Phase 2: persistence, and real pickers instead of cycling The stored blob is now read once at startup through useReducer's lazy initialiser and written whenever it changes, so deck, direction, answering mode, context and the Leitner boxes all survive a restart. persistedState.ts is the only part that touches the browser; the parsing stays in domain/storage.ts. Every access is guarded, because localStorage throws rather than returning null in a private window, with site data blocked, or in a WebView that has not finished starting. That guard is now tested, along with the claim this whole migration rests on: a payload copied verbatim out of CapacitorStorage.xml on the phone loads back with its boxes intact. If that test ever fails, the React app has orphaned real drill progress. Also covered: damaged JSON, storage that throws, localStorage absent entirely, and a refused quota on write. Explore's pickers open a bottom sheet rather than cycling blindly through options, and the drill settings button is no longer dead - deck, direction and answering mode, each showing how many cards it selects. Sheet is shared by all three. Also fixed a silent one: .sheetLabel was used three times and did not exist. CSS modules are index-typed, so that compiles clean and renders with no class at all. 141 tests, typecheck clean. Sheets verified by rendering them. Co-Authored-By: Claude Opus 5 --- src/App.module.css | 13 +++ src/App.tsx | 146 +++++++++++++++++++++++++++------- src/ui/Sheet.module.css | 92 +++++++++++++++++++++ src/ui/Sheet.tsx | 72 +++++++++++++++++ src/ui/persistedState.test.ts | 105 ++++++++++++++++++++++++ src/ui/persistedState.ts | 27 +++++++ 6 files changed, 427 insertions(+), 28 deletions(-) create mode 100644 src/ui/Sheet.module.css create mode 100644 src/ui/Sheet.tsx create mode 100644 src/ui/persistedState.test.ts create mode 100644 src/ui/persistedState.ts diff --git a/src/App.module.css b/src/App.module.css index 38a9dcf..d68b444 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -99,3 +99,16 @@ color: var(--on-accent); font-weight: 700; } + +/* Group headings inside a sheet: Deck, Direction, Answering. */ +.sheetLabel { + margin: 8px 0 2px; + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.12em; + color: var(--muted); +} + +.sheetLabel:first-child { + margin-top: 0; +} diff --git a/src/App.tsx b/src/App.tsx index 8c01409..8f0b4c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,10 +3,13 @@ * what is lit, what the screen says, and what fills the strip and the card box. * * Angular: useReducer is the closest thing to a service holding state with - * methods, except dispatch returns a new state rather than mutating one. + * methods, except dispatch returns a new state rather than mutating one. Its + * third argument is a lazy initialiser, which is how the stored blob is read + * once at startup instead of on every render. */ import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; import { DECKS, boxDistribution, deckPool, knownCount, pickCard } from "./domain/drill"; +import type { DeckId, Direction, InputMode } from "./domain/drill"; import { ENTRIES } from "./domain/entries"; import { controlName } from "./domain/format"; import { CTX, CTXHINT } from "./domain/labels"; @@ -21,11 +24,14 @@ import { DrillCard } from "./ui/DrillCard"; import { DrillStrip } from "./ui/DrillStrip"; import { InfoCard } from "./ui/InfoCard"; import { Panel } from "./ui/Panel"; +import { Sheet, SheetOption } from "./ui/Sheet"; import { drillReducer, initialDrillState } from "./ui/drillReducer"; +import { loadState, saveState } from "./ui/persistedState"; import styles from "./App.module.css"; const TABS = ["Explore", "Drill", "Find", "Rules"] as const; type Tab = (typeof TABS)[number]; +type OpenSheet = null | "mode" | "layer" | "settings"; function Caret() { return ( @@ -38,6 +44,8 @@ function Caret() { const layerLabel = (key: string): string => key === "" ? "NO MODIFIER" : key.split("+").map(controlName).join(" + "); +const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`; + /** Three other names to choose between, preferring ones from the same mode. */ function optionsFor(card: Entry): string[] { const others = ENTRIES.filter((e) => e.name !== card.name); @@ -51,11 +59,15 @@ function optionsFor(card: Entry): string[] { return [...names, card.name].sort(() => Math.random() - 0.5); } +/** Read once, at startup. Anything unrecognised falls back to a default. */ +const stored = loadState(); + export function App() { const [tab, setTab] = useState("Explore"); + const [sheet, setSheet] = useState(null); // --- Explore --- - const [ctx, setCtx] = useState("S"); + const [ctx, setCtx] = useState(stored.ctx); const [layer, setLayer] = useState(""); const [selected, setSelected] = useState(null); @@ -69,9 +81,8 @@ export function App() { const exploreLit = useMemo(() => entriesByTarget(onLayer), [onLayer]); // --- Drill --- - const [drill, dispatch] = useReducer( - drillReducer, - initialDrillState("shiftpads", "play", "tap", {}), + const [drill, dispatch] = useReducer(drillReducer, stored, (s) => + initialDrillState(s.deck, s.dir, s.input, s.boxes), ); const pool = useMemo( () => deckPool(ENTRIES, drill.deck, drill.direction), @@ -87,16 +98,26 @@ export function App() { }); }, [pool, drill.boxes, drill.card, drill.direction]); - // Deal the first card when Drill is opened. This has to be an effect, not a - // call during render: dealing picks at random, and StrictMode renders twice. + // Dealing picks at random, and StrictMode renders twice, so this cannot + // happen during render. useEffect(() => { if (tab === "Drill" && drill.card === null && pool.length > 0) deal(); }, [tab, drill.card, pool, deal]); + // Everything worth remembering, written whenever it changes. + useEffect(() => { + saveState({ + boxes: drill.boxes, + ctx, + deck: drill.deck, + dir: drill.direction, + input: drill.input, + }); + }, [drill.boxes, drill.deck, drill.direction, drill.input, ctx]); + const drillLit = useMemo(() => { - if (!drill.card) return new Map(); - if (drill.answered) return entriesByTarget([drill.card]); - return new Map(); + if (!drill.card || !drill.answered) return new Map(); + return entriesByTarget([drill.card]); }, [drill.card, drill.answered]); const isDrill = tab === "Drill"; @@ -109,29 +130,15 @@ export function App() { known={knownCount(pool, drill.boxes)} total={pool.length} distribution={boxDistribution(pool, drill.boxes)} - onSettings={() => undefined} + onSettings={() => setSheet("settings")} /> ) : (
- - @@ -167,7 +174,7 @@ export function App() { : { top: CTX[ctx].toUpperCase(), right: layerLabel(activeLayer), - main: selected ? selected.short : `${onLayer.length} functions`, + main: selected ? selected.short : plural(onLayer.length, "function"), chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short), } } @@ -197,6 +204,89 @@ export function App() { ))} + + setSheet(null)}> + {(Object.keys(CTX) as ContextCode[]).map((code) => ( + { + setCtx(code); + setSelected(null); + setSheet(null); + }} + /> + ))} + + + setSheet(null)} + > + {layers.map((key) => ( + { + setLayer(key); + setSelected(null); + setSheet(null); + }} + /> + ))} + + + setSheet(null)} + > +

Deck

+ {(Object.keys(DECKS) as DeckId[]).map((id) => ( + dispatch({ type: "setDeck", deck: id })} + /> + ))} + +

Direction

+ {( + [ + ["play", "Function → press it"], + ["name", "Combo → name it"], + ] as [Direction, string][] + ).map(([id, label]) => ( + dispatch({ type: "setDirection", direction: id })} + /> + ))} + +

Answering

+ {( + [ + ["hold", "Hold the combo together"], + ["tap", "Tap each, then check"], + ] as [InputMode, string][] + ).map(([id, label]) => ( + dispatch({ type: "setInput", input: id })} + /> + ))} +
); } diff --git a/src/ui/Sheet.module.css b/src/ui/Sheet.module.css new file mode 100644 index 0000000..2f99a35 --- /dev/null +++ b/src/ui/Sheet.module.css @@ -0,0 +1,92 @@ +.scrim { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 20; +} + +.sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 21; + max-height: 80dvh; + display: flex; + flex-direction: column; + background: var(--card); + border-top: var(--outline); + /* The hard shadow points up here, since the sheet rises from the bottom. */ + box-shadow: 0 -4px 0 var(--ink); +} + +.head { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px 8px; + border-bottom: 1px solid var(--rule); +} + +.title { + font-size: 12px; + letter-spacing: 0.08em; +} + +.close { + flex: 0 0 auto; + width: 32px; + height: 32px; + display: grid; + place-items: center; + background: none; + border: var(--outline); + color: var(--ink); +} + +.body { + overflow-y: auto; + padding: 10px 12px 16px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.option, +.optionOn { + display: block; + width: 100%; + min-height: 44px; + text-align: left; + padding: 7px 10px; + border: var(--outline); + background: var(--page); + color: var(--ink); +} + +.optionOn { + background: var(--accent); + color: var(--on-accent); +} + +.optionLabel { + display: block; + font-size: 13px; + font-weight: 700; + line-height: 1.15; +} + +.optionHint { + display: block; + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.06em; + color: var(--muted); +} + +.optionOn .optionHint { + color: var(--on-accent); + opacity: 0.75; +} diff --git a/src/ui/Sheet.tsx b/src/ui/Sheet.tsx new file mode 100644 index 0000000..fc0c646 --- /dev/null +++ b/src/ui/Sheet.tsx @@ -0,0 +1,72 @@ +/** + * Bottom sheet. Used by the drill settings and by Explore's two pickers. + * + * Angular: roughly a MatBottomSheet, except there is no service and no portal — + * it is just markup this component renders when open. + */ +import { useEffect } from "react"; +import type { ReactNode } from "react"; +import styles from "./Sheet.module.css"; + +export interface SheetProps { + readonly open: boolean; + readonly title: string; + readonly onClose: () => void; + readonly children: ReactNode; +} + +export function Sheet({ open, title, onClose, children }: SheetProps) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( + <> +
+
+
+ {title.toUpperCase()} + +
+
{children}
+
+ + ); +} + +export interface SheetOptionProps { + readonly label: string; + readonly hint?: string; + readonly selected: boolean; + readonly onSelect: () => void; +} + +/** One choice in a sheet: a full-width row, 44px minimum. */ +export function SheetOption({ label, hint, selected, onSelect }: SheetOptionProps) { + return ( + + ); +} diff --git a/src/ui/persistedState.test.ts b/src/ui/persistedState.test.ts new file mode 100644 index 0000000..97c3570 --- /dev/null +++ b/src/ui/persistedState.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULTS, STORAGE_KEY } from "../domain/storage"; +import { loadState, saveState } from "./persistedState"; + +/** Tests run in node, which has no localStorage, so one is installed per case. */ +function useStorage(impl: Partial) { + Object.defineProperty(globalThis, "localStorage", { + value: impl as Storage, + configurable: true, + writable: true, + }); +} + +function memoryStorage(seed: Record = {}) { + const map = new Map(Object.entries(seed)); + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + read: (k: string) => map.get(k) ?? null, + }; +} + +afterEach(() => { + Reflect.deleteProperty(globalThis, "localStorage"); +}); + +describe("loadState", () => { + it("returns defaults when nothing has been stored", () => { + useStorage(memoryStorage()); + expect(loadState()).toEqual(DEFAULTS); + }); + + it("reads the boxes already on the phone", () => { + // Copied verbatim out of CapacitorStorage.xml on the device during Phase 1. + // If this ever fails, the React app has orphaned real drill progress. + useStorage( + memoryStorage({ + [STORAGE_KEY]: '{"boxes":{"shift>p6@S#INIT":1,"shift>p8@S#MUTE GRP":2}}', + }), + ); + const state = loadState(); + expect(state.boxes["shift>p6@S#INIT"]).toBe(1); + expect(state.boxes["shift>p8@S#MUTE GRP"]).toBe(2); + }); + + it("keeps the rest of the blob", () => { + useStorage( + memoryStorage({ + [STORAGE_KEY]: '{"boxes":{},"ctx":"P","deck":"dj","dir":"name","input":"tap"}', + }), + ); + expect(loadState()).toEqual({ + boxes: {}, + ctx: "P", + deck: "dj", + dir: "name", + input: "tap", + }); + }); + + it("falls back to defaults on damaged JSON", () => { + useStorage(memoryStorage({ [STORAGE_KEY]: "{not json" })); + expect(loadState()).toEqual(DEFAULTS); + }); + + it("survives storage that throws, as a private window does", () => { + useStorage({ + getItem: () => { + throw new DOMException("denied", "SecurityError"); + }, + }); + expect(loadState()).toEqual(DEFAULTS); + }); + + it("survives localStorage being absent entirely", () => { + Reflect.deleteProperty(globalThis, "localStorage"); + expect(loadState()).toEqual(DEFAULTS); + }); +}); + +describe("saveState", () => { + it("writes under the key Phase 1 already uses", () => { + const store = memoryStorage(); + useStorage(store); + saveState({ ...DEFAULTS, boxes: { "shift>p1@S#FIX VEL": 3 } }); + expect(JSON.parse(store.read(STORAGE_KEY)!).boxes["shift>p1@S#FIX VEL"]).toBe(3); + }); + + it("round-trips through loadState", () => { + useStorage(memoryStorage()); + const state = { ...DEFAULTS, ctx: "D" as const, boxes: { a: 4 } }; + saveState(state); + expect(loadState()).toEqual(state); + }); + + it("does not throw when the quota is refused", () => { + useStorage({ + setItem: () => { + throw new DOMException("full", "QuotaExceededError"); + }, + }); + expect(() => saveState(DEFAULTS)).not.toThrow(); + }); +}); diff --git a/src/ui/persistedState.ts b/src/ui/persistedState.ts new file mode 100644 index 0000000..2913587 --- /dev/null +++ b/src/ui/persistedState.ts @@ -0,0 +1,27 @@ +/** + * Reading and writing the persisted blob. + * + * The parsing lives in domain/storage.ts; this is only the part that touches the + * browser. localStorage throws rather than returning null in a private window, + * with site data blocked, or in a WebView that has not finished starting, so + * every access is guarded and failure degrades to defaults. + */ +import { DEFAULTS, STORAGE_KEY, parseStored, serialize } from "../domain/storage"; +import type { PersistedState } from "../domain/storage"; + +export function loadState(): PersistedState { + try { + return parseStored(localStorage.getItem(STORAGE_KEY)); + } catch { + return DEFAULTS; + } +} + +export function saveState(state: PersistedState): void { + try { + localStorage.setItem(STORAGE_KEY, serialize(state)); + } catch { + // Nothing useful to do: the drill still works, it just will not be + // remembered. The backup box in settings is the way out. + } +}