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:
@@ -99,3 +99,16 @@
|
|||||||
color: var(--on-accent);
|
color: var(--on-accent);
|
||||||
font-weight: 700;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
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.
|
* 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
|
* 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 { useCallback, useEffect, useMemo, useReducer, useState } from "react";
|
||||||
import { DECKS, boxDistribution, deckPool, knownCount, pickCard } from "./domain/drill";
|
import { DECKS, boxDistribution, deckPool, knownCount, pickCard } from "./domain/drill";
|
||||||
|
import type { DeckId, Direction, InputMode } from "./domain/drill";
|
||||||
import { ENTRIES } from "./domain/entries";
|
import { ENTRIES } from "./domain/entries";
|
||||||
import { controlName } from "./domain/format";
|
import { controlName } from "./domain/format";
|
||||||
import { CTX, CTXHINT } from "./domain/labels";
|
import { CTX, CTXHINT } from "./domain/labels";
|
||||||
@@ -21,11 +24,14 @@ import { DrillCard } from "./ui/DrillCard";
|
|||||||
import { DrillStrip } from "./ui/DrillStrip";
|
import { DrillStrip } from "./ui/DrillStrip";
|
||||||
import { InfoCard } from "./ui/InfoCard";
|
import { InfoCard } from "./ui/InfoCard";
|
||||||
import { Panel } from "./ui/Panel";
|
import { Panel } from "./ui/Panel";
|
||||||
|
import { Sheet, SheetOption } from "./ui/Sheet";
|
||||||
import { drillReducer, initialDrillState } from "./ui/drillReducer";
|
import { drillReducer, initialDrillState } from "./ui/drillReducer";
|
||||||
|
import { loadState, saveState } from "./ui/persistedState";
|
||||||
import styles from "./App.module.css";
|
import styles from "./App.module.css";
|
||||||
|
|
||||||
const TABS = ["Explore", "Drill", "Find", "Rules"] as const;
|
const TABS = ["Explore", "Drill", "Find", "Rules"] as const;
|
||||||
type Tab = (typeof TABS)[number];
|
type Tab = (typeof TABS)[number];
|
||||||
|
type OpenSheet = null | "mode" | "layer" | "settings";
|
||||||
|
|
||||||
function Caret() {
|
function Caret() {
|
||||||
return (
|
return (
|
||||||
@@ -38,6 +44,8 @@ function Caret() {
|
|||||||
const layerLabel = (key: string): string =>
|
const layerLabel = (key: string): string =>
|
||||||
key === "" ? "NO MODIFIER" : key.split("+").map(controlName).join(" + ");
|
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. */
|
/** Three other names to choose between, preferring ones from the same mode. */
|
||||||
function optionsFor(card: Entry): string[] {
|
function optionsFor(card: Entry): string[] {
|
||||||
const others = ENTRIES.filter((e) => e.name !== card.name);
|
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);
|
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() {
|
export function App() {
|
||||||
const [tab, setTab] = useState<Tab>("Explore");
|
const [tab, setTab] = useState<Tab>("Explore");
|
||||||
|
const [sheet, setSheet] = useState<OpenSheet>(null);
|
||||||
|
|
||||||
// --- Explore ---
|
// --- Explore ---
|
||||||
const [ctx, setCtx] = useState<ContextCode>("S");
|
const [ctx, setCtx] = useState<ContextCode>(stored.ctx);
|
||||||
const [layer, setLayer] = useState("");
|
const [layer, setLayer] = useState("");
|
||||||
const [selected, setSelected] = useState<Entry | null>(null);
|
const [selected, setSelected] = useState<Entry | null>(null);
|
||||||
|
|
||||||
@@ -69,9 +81,8 @@ export function App() {
|
|||||||
const exploreLit = useMemo(() => entriesByTarget(onLayer), [onLayer]);
|
const exploreLit = useMemo(() => entriesByTarget(onLayer), [onLayer]);
|
||||||
|
|
||||||
// --- Drill ---
|
// --- Drill ---
|
||||||
const [drill, dispatch] = useReducer(
|
const [drill, dispatch] = useReducer(drillReducer, stored, (s) =>
|
||||||
drillReducer,
|
initialDrillState(s.deck, s.dir, s.input, s.boxes),
|
||||||
initialDrillState("shiftpads", "play", "tap", {}),
|
|
||||||
);
|
);
|
||||||
const pool = useMemo(
|
const pool = useMemo(
|
||||||
() => deckPool(ENTRIES, drill.deck, drill.direction),
|
() => deckPool(ENTRIES, drill.deck, drill.direction),
|
||||||
@@ -87,16 +98,26 @@ export function App() {
|
|||||||
});
|
});
|
||||||
}, [pool, drill.boxes, drill.card, drill.direction]);
|
}, [pool, drill.boxes, drill.card, drill.direction]);
|
||||||
|
|
||||||
// Deal the first card when Drill is opened. This has to be an effect, not a
|
// Dealing picks at random, and StrictMode renders twice, so this cannot
|
||||||
// call during render: dealing picks at random, and StrictMode renders twice.
|
// happen during render.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab === "Drill" && drill.card === null && pool.length > 0) deal();
|
if (tab === "Drill" && drill.card === null && pool.length > 0) deal();
|
||||||
}, [tab, drill.card, pool, 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(() => {
|
const drillLit = useMemo(() => {
|
||||||
if (!drill.card) return new Map<ControlId, Entry[]>();
|
if (!drill.card || !drill.answered) return new Map<ControlId, Entry[]>();
|
||||||
if (drill.answered) return entriesByTarget([drill.card]);
|
return entriesByTarget([drill.card]);
|
||||||
return new Map<ControlId, Entry[]>();
|
|
||||||
}, [drill.card, drill.answered]);
|
}, [drill.card, drill.answered]);
|
||||||
|
|
||||||
const isDrill = tab === "Drill";
|
const isDrill = tab === "Drill";
|
||||||
@@ -109,29 +130,15 @@ export function App() {
|
|||||||
known={knownCount(pool, drill.boxes)}
|
known={knownCount(pool, drill.boxes)}
|
||||||
total={pool.length}
|
total={pool.length}
|
||||||
distribution={boxDistribution(pool, drill.boxes)}
|
distribution={boxDistribution(pool, drill.boxes)}
|
||||||
onSettings={() => undefined}
|
onSettings={() => setSheet("settings")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.strip}>
|
<div className={styles.strip}>
|
||||||
<button
|
<button className={styles.picker} onClick={() => setSheet("mode")}>
|
||||||
className={styles.picker}
|
|
||||||
onClick={() => {
|
|
||||||
const order = Object.keys(CTX) as ContextCode[];
|
|
||||||
setCtx(order[(order.indexOf(ctx) + 1) % order.length]!);
|
|
||||||
setSelected(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<b>{CTX[ctx].toUpperCase()}</b>
|
<b>{CTX[ctx].toUpperCase()}</b>
|
||||||
<Caret />
|
<Caret />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className={styles.picker} onClick={() => setSheet("layer")}>
|
||||||
className={styles.picker}
|
|
||||||
onClick={() => {
|
|
||||||
const at = layers.indexOf(activeLayer);
|
|
||||||
setLayer(layers[(at + 1) % layers.length]!);
|
|
||||||
setSelected(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<b>{layerLabel(activeLayer)}</b>
|
<b>{layerLabel(activeLayer)}</b>
|
||||||
<span className={styles.count}>{onLayer.length}</span>
|
<span className={styles.count}>{onLayer.length}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -167,7 +174,7 @@ export function App() {
|
|||||||
: {
|
: {
|
||||||
top: CTX[ctx].toUpperCase(),
|
top: CTX[ctx].toUpperCase(),
|
||||||
right: layerLabel(activeLayer),
|
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),
|
chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,6 +204,89 @@ export function App() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
92
src/ui/Sheet.module.css
Normal file
92
src/ui/Sheet.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
72
src/ui/Sheet.tsx
Normal file
72
src/ui/Sheet.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className={styles.scrim} onClick={onClose} />
|
||||||
|
<div className={styles.sheet} role="dialog" aria-modal="true" aria-label={title}>
|
||||||
|
<div className={styles.head}>
|
||||||
|
<b className={styles.title}>{title.toUpperCase()}</b>
|
||||||
|
<button className={styles.close} onClick={onClose} aria-label="Close">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M1 1l10 10M11 1L1 11"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
className={selected ? styles.optionOn : styles.option}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<b className={styles.optionLabel}>{label}</b>
|
||||||
|
{hint ? <span className={styles.optionHint}>{hint}</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/ui/persistedState.test.ts
Normal file
105
src/ui/persistedState.test.ts
Normal file
@@ -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<Storage>) {
|
||||||
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
value: impl as Storage,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryStorage(seed: Record<string, string> = {}) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
27
src/ui/persistedState.ts
Normal file
27
src/ui/persistedState.ts
Normal file
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user