/** * The shell. It owns the panel — there is only ever one — and each tab supplies * 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. 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"; import { entriesByTarget, entriesForLayer, layerCounts, orderLayerKeys, } from "./domain/layers"; import type { ContextCode, ControlId, Entry } from "./domain/types"; import { searchEntries } from "./domain/search"; import { DrillCard } from "./ui/DrillCard"; import { DrillStrip } from "./ui/DrillStrip"; import { FindPanel } from "./ui/FindPanel"; import { InfoCard } from "./ui/InfoCard"; import { Panel } from "./ui/Panel"; import { RulesPanel } from "./ui/RulesPanel"; 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 ( ); } 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); const near = others.filter((e) => e.ctx.some((c) => card.ctx.includes(c))); const pool = near.length >= 3 ? near : others; const names: string[] = []; for (const e of [...pool].sort(() => Math.random() - 0.5)) { if (!names.includes(e.name)) names.push(e.name); if (names.length === 3) break; } 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(stored.ctx); const [layer, setLayer] = useState(""); const [selected, setSelected] = useState(null); // --- Find --- const [query, setQuery] = useState(""); const results = useMemo(() => searchEntries(ENTRIES, query), [query]); /** Jump from a search result to that combo, lit on the panel. */ function goTo(entry: Entry) { setCtx(entry.ctx[0] ?? "S"); setLayer(entry.key); setSelected(entry); setTab("Explore"); } const counts = useMemo(() => layerCounts(ENTRIES, ctx), [ctx]); const layers = useMemo(() => orderLayerKeys(counts.keys()), [counts]); const activeLayer = counts.has(layer) ? layer : ""; const onLayer = useMemo( () => entriesForLayer(ENTRIES, ctx, activeLayer), [ctx, activeLayer], ); const exploreLit = useMemo(() => entriesByTarget(onLayer), [onLayer]); // --- Drill --- 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), [drill.deck, drill.direction], ); const deal = useCallback(() => { const card = pickCard(pool, drill.boxes, drill.card); dispatch({ type: "deal", card, ...(card && drill.direction === "name" ? { options: optionsFor(card) } : {}), }); }, [pool, drill.boxes, drill.card, drill.direction]); // 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 || !drill.answered) return new Map(); return entriesByTarget([drill.card]); }, [drill.card, drill.answered]); const isDrill = tab === "Drill"; // Find and Rules take the whole screen: the reference hides the unit on both, // and a list or a page of prose wants the room more than the panel does. const isPanelTab = tab === "Explore" || isDrill; if (!isPanelTab) { return (
{tab === "Find" ? ( ) : ( )}
); } return (
{isDrill ? ( setSheet("settings")} /> ) : (
)} { if (isDrill) { if (drill.answered) deal(); else dispatch({ type: "press", id }); return; } const here = exploreLit.get(id); if (!here || here.length === 0) { setSelected(null); return; } const at = selected ? here.indexOf(selected) : -1; setSelected(here[(at + 1) % here.length]!); }} screen={ isDrill ? { top: DECKS[drill.deck].name.toUpperCase(), right: drill.answered ? (drill.lastCorrect ? "CORRECT" : "MISS") : "", main: drill.card ? drill.card.name : "—", chips: [], } : { top: CTX[ctx].toUpperCase(), right: layerLabel(activeLayer), main: selected ? selected.short : plural(onLayer.length, "function"), chips: selected ? [] : onLayer.slice(0, 3).map((e) => e.short), } } /> {isDrill ? ( dispatch({ type: "check" })} onClear={() => dispatch({ type: "clear" })} onReveal={() => dispatch({ type: "reveal" })} onNext={deal} onPickOption={(name) => dispatch({ type: "pickOption", name })} /> ) : ( )} 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 })} /> ))}
); }