/* 문법 — the conjugation trainer, the seven irregular classes, and the 50-point reference. The trainer marks itself with lib/conjugation.js, and when an answer is wrong it names the rule via explain() rather than just saying "no". That is the whole point of it: "stem 바쁘 · last vowel neither → 어" is a lesson; a red cross is not. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useStore } from "../../state/store.js"; import { editMeta, editStudyLog } from "../../db/writes.js"; import { haeche, past, explain, irregularClass } from "@lib/conjugation.js"; import { Keyboard, useComposer } from "../keyboard/Keyboard.js"; import grammarJson from "@data/grammar.json"; import irregularsJson from "@data/irregulars.json"; import deckJson from "@data/deck.json"; import "./grammar.css"; interface Point { id: string; cat: string; form: string; name: string; why: string; ex: [string, string, string][]; read?: number; } const POINTS = (grammarJson as unknown as { points: Point[] }).points; interface IrregularClassEntry { k: string; n: string; p: string; ex: [string, string][]; } const IRREGULARS = (irregularsJson as unknown as { classes: IrregularClassEntry[] }).classes; /** Verbs and adjectives from the curated deck — the trainer's pool. */ const PREDICATES: { dict: string; en: string }[] = Object.values( (deckJson as unknown as { topics: Record }).topics, ) .flat() .filter(([ko, , , pos]) => (pos === "verb" || pos === "adj") && ko.endsWith("다")) .map(([ko, , en]) => ({ dict: ko, en })); const MODES = [ { id: "present", label: "현재 아/어" }, { id: "past", label: "과거 았/었어" }, { id: "irr", label: "불규칙만" }, ] as const; type Mode = (typeof MODES)[number]["id"]; function poolFor(mode: Mode) { if (mode !== "irr") return PREDICATES; return PREDICATES.filter((p) => irregularClass(p.dict) !== "regular"); } function expected(mode: Mode, dict: string): string | null { const present = haeche(dict); if (!present) return null; return mode === "past" ? past(present) : present; } function ConjugationTrainer() { const { db, today, invalidate } = useStore(); const [mode, setMode] = useState("present"); const [index, setIndex] = useState(0); const [value, setValue] = useState(""); const [verdict, setVerdict] = useState<{ ok: boolean; want: string; why: string } | null>(null); const [score, setScore] = useState({ n: 0, ok: 0 }); const [showKeyboard, setShowKeyboard] = useState(false); const composer = useComposer(); // Guards the write behind `verdict`, which is a render behind. const checking = useRef(false); const pool = useMemo(() => poolFor(mode), [mode]); const question = pool[index % Math.max(1, pool.length)]; const next = useCallback(() => { setIndex((i) => i + 1); setValue(""); composer.reset(); // clearing in code fires no input event setVerdict(null); }, [composer]); useEffect(() => { setIndex(0); setValue(""); composer.reset(); setVerdict(null); }, [composer, mode]); const check = async () => { if (checking.current) return; if (!question || verdict) { next(); return; } checking.current = true; const want = expected(mode, question.dict); if (!want) { next(); return; } const ok = value.trim() === want; setVerdict({ ok, want, why: explain(question.dict) }); const nextScore = { n: score.n + 1, ok: score.ok + (ok ? 1 : 0) }; setScore(nextScore); await editStudyLog(db, today, { drills: 1 }); await editMeta(db, "trainer.conjugation", JSON.stringify(nextScore)); invalidate(); checking.current = false; }; if (!question) return null; return (

활용 연습

{score.n ? `${Math.round((score.ok / score.n) * 100)}% · ${score.n} answered` : "conjugation trainer"}
{MODES.map((m) => ( ))}
{mode === "past" ? "past 반말" : "반말"} of
{question.dict}
{question.en}
{ composer.onExternalInput(); setValue(e.target.value); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void check(); } }} />
{showKeyboard && ( setShowKeyboard(false)} /> )} {verdict && (
{verdict.ok ? ( {verdict.want} — {verdict.why} ) : ( ✗ it is {verdict.want} — {verdict.why} )}
)}
); } function Irregulars() { return (

불규칙

The seven classes — and why no analyser is needed at runtime
{IRREGULARS.map((c) => (

{c.k} {c.n}

{c.p}

{c.ex.map(([dict, form]) => ( {dict} → {form} ))}
))}
); } export function GrammarTab() { const { db, prefs } = useStore(); const [cat, setCat] = useState(POINTS[0]?.cat ?? "all"); const [open, setOpen] = useState(null); const [learned, setLearned] = useState>({}); const cats = useMemo(() => [...new Set(POINTS.map((p) => p.cat))], []); useEffect(() => { let cancelled = false; (async () => { const row = await db.get<{ v: string }>("SELECT v FROM meta WHERE k = 'grammar.learned'"); if (cancelled || !row) return; try { setLearned(JSON.parse(row.v) as Record); } catch { /* a corrupt value is not worth failing the tab over */ } })(); return () => { cancelled = true; }; }, [db]); const toggleLearned = async (id: string) => { const next = { ...learned, [id]: !learned[id] }; setLearned(next); await editMeta(db, "grammar.learned", JSON.stringify(next)); }; const shown = POINTS.filter((p) => p.cat === cat); return ( <>

문법

{Object.values(learned).filter(Boolean).length} of {POINTS.length} marked learned
{cats.map((c) => ( ))}
{shown.map((p) => (
{open === p.id && (

{p.why}

{p.ex.map(([ko, ro, en], i) => (
{ko}
{prefs.romanization &&
{ro}
}
{en}
))}
)}
))}
); }