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 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:46:42 +03:00
parent 9e1ffa0c85
commit b875342864
6 changed files with 427 additions and 28 deletions

View File

@@ -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<Tab>("Explore");
const [sheet, setSheet] = useState<OpenSheet>(null);
// --- Explore ---
const [ctx, setCtx] = useState<ContextCode>("S");
const [ctx, setCtx] = useState<ContextCode>(stored.ctx);
const [layer, setLayer] = useState("");
const [selected, setSelected] = useState<Entry | null>(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<ControlId, Entry[]>();
if (drill.answered) return entriesByTarget([drill.card]);
return new Map<ControlId, Entry[]>();
if (!drill.card || !drill.answered) return new Map<ControlId, Entry[]>();
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")}
/>
) : (
<div className={styles.strip}>
<button
className={styles.picker}
onClick={() => {
const order = Object.keys(CTX) as ContextCode[];
setCtx(order[(order.indexOf(ctx) + 1) % order.length]!);
setSelected(null);
}}
>
<button className={styles.picker} onClick={() => setSheet("mode")}>
<b>{CTX[ctx].toUpperCase()}</b>
<Caret />
</button>
<button
className={styles.picker}
onClick={() => {
const at = layers.indexOf(activeLayer);
setLayer(layers[(at + 1) % layers.length]!);
setSelected(null);
}}
>
<button className={styles.picker} onClick={() => setSheet("layer")}>
<b>{layerLabel(activeLayer)}</b>
<span className={styles.count}>{onLayer.length}</span>
</button>
@@ -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() {
</button>
))}
</nav>
<Sheet open={sheet === "mode"} title="Where you are" onClose={() => setSheet(null)}>
{(Object.keys(CTX) as ContextCode[]).map((code) => (
<SheetOption
key={code}
label={CTX[code]}
hint={CTXHINT[code]}
selected={code === ctx}
onSelect={() => {
setCtx(code);
setSelected(null);
setSheet(null);
}}
/>
))}
</Sheet>
<Sheet
open={sheet === "layer"}
title={`Layers in ${CTX[ctx]}`}
onClose={() => setSheet(null)}
>
{layers.map((key) => (
<SheetOption
key={key || "none"}
label={layerLabel(key)}
hint={plural(counts.get(key) ?? 0, "function")}
selected={key === activeLayer}
onSelect={() => {
setLayer(key);
setSelected(null);
setSheet(null);
}}
/>
))}
</Sheet>
<Sheet
open={sheet === "settings"}
title="Drill settings"
onClose={() => setSheet(null)}
>
<p className={styles.sheetLabel}>Deck</p>
{(Object.keys(DECKS) as DeckId[]).map((id) => (
<SheetOption
key={id}
label={DECKS[id].name}
hint={plural(deckPool(ENTRIES, id, drill.direction).length, "card")}
selected={id === drill.deck}
onSelect={() => dispatch({ type: "setDeck", deck: id })}
/>
))}
<p className={styles.sheetLabel}>Direction</p>
{(
[
["play", "Function → press it"],
["name", "Combo → name it"],
] as [Direction, string][]
).map(([id, label]) => (
<SheetOption
key={id}
label={label}
selected={id === drill.direction}
onSelect={() => dispatch({ type: "setDirection", direction: id })}
/>
))}
<p className={styles.sheetLabel}>Answering</p>
{(
[
["hold", "Hold the combo together"],
["tap", "Tap each, then check"],
] as [InputMode, string][]
).map(([id, label]) => (
<SheetOption
key={id}
label={label}
selected={id === drill.input}
onSelect={() => dispatch({ type: "setInput", input: id })}
/>
))}
</Sheet>
</div>
);
}