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:
146
src/App.tsx
146
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<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user