feat(learn): the reference screens as the reworked artifact has them

읽기 연습. Each mode opens with what the round is and a Start button; the
round is timed, and a finished one reports accuracy and time, keeps the
best per mode and says when it was beaten. In sound mode the written
spelling is among the options — reading it as written is the habit the
drill exists to break, so it is the distractor that matters. Answers stay
marked 450ms when right, 900ms when a rule is shown, 1400ms when wrong.
The best rounds sync (trainer.drill), merged mode by mode: the higher
accuracy, then the faster time.

문법. An All chip, a count on every category, 반말 selected first — it is
what manhwa speech is made of — and each row names its category, so the
list still reads under All.

활용 연습. Words come in random order, not the pool's; the running score
comes back after a reload; a wrong answer shows what was typed beside what
it should have been, then the rule. The pool adds the roadmap's own verbs
and adjectives to the deck's. A form lib/conjugation.js cannot build no
longer leaves the Check button stuck.

문장. The artifact's lesson on the ending word and the 서술어 legend, a count
on each level, and every sentence's place in review — as far along as its
least-known chunk, since the chunks are the cards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 22:12:05 +02:00
parent ede8f295a2
commit 57fd6e5087
10 changed files with 646 additions and 223 deletions

View File

@@ -23,3 +23,5 @@ export const writeJsonMeta = (db: Db, key: string, value: unknown): Promise<void
export const GRAMMAR_LEARNED = "grammar.learned"; export const GRAMMAR_LEARNED = "grammar.learned";
export const GRAMMAR_NOTES = "grammar.notes"; export const GRAMMAR_NOTES = "grammar.notes";
export const TRAINER_CONJUGATION = "trainer.conjugation"; export const TRAINER_CONJUGATION = "trainer.conjugation";
/** The reading drill's best round per mode: { [mode]: { acc, sec } }. */
export const TRAINER_DRILL = "trainer.drill";

View File

@@ -70,6 +70,24 @@ function resolveMeta(local: Data, remote: Data, ctx: ResolveContext): Resolution
if (k === "trainer.conjugation") { if (k === "trainer.conjugation") {
return heavier(num(parseObject(local.v)?.n as Value), num(parseObject(remote.v)?.n as Value)); return heavier(num(parseObject(local.v)?.n as Value), num(parseObject(remote.v)?.n as Value));
} }
if (k === "trainer.drill") {
// A best per reading-drill mode. The better round wins, mode by mode:
// higher accuracy, then the faster time.
const a = parseObject(local.v);
const b = parseObject(remote.v);
if (!a || !b) return ADOPT;
const out: Record<string, unknown> = { ...a, ...b };
for (const mode of Object.keys(a)) {
const mine = a[mode] as { acc?: number; sec?: number } | undefined;
const theirs = b[mode] as { acc?: number; sec?: number } | undefined;
if (!theirs || !mine) continue;
const better =
Number(mine.acc) > Number(theirs.acc) ||
(Number(mine.acc) === Number(theirs.acc) && Number(mine.sec) < Number(theirs.sec));
if (better) out[mode] = mine;
}
return settle(local, remote, { ...remote, v: JSON.stringify(out) });
}
if (k === "grammar.learned" || k === "grammar.notes") { if (k === "grammar.learned" || k === "grammar.notes") {
const a = parseObject(local.v); const a = parseObject(local.v);
const b = parseObject(remote.v); const b = parseObject(remote.v);

View File

@@ -6,8 +6,9 @@
is the whole point of it: "stem 바쁘 · last vowel neither → 어" is a is the whole point of it: "stem 바쁘 · last vowel neither → 어" is a
lesson; a red cross is not. */ lesson; a red cross is not. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "../../state/store.js"; import { useStore } from "../../state/store.js";
import type { Db } from "../../db/types.js";
import { editStudyLog } from "../../db/writes.js"; import { editStudyLog } from "../../db/writes.js";
import { import {
GRAMMAR_LEARNED, GRAMMAR_LEARNED,
@@ -42,14 +43,33 @@ interface IrregularClassEntry {
} }
const IRREGULARS = (irregularsJson as unknown as { classes: IrregularClassEntry[] }).classes; const IRREGULARS = (irregularsJson as unknown as { classes: IrregularClassEntry[] }).classes;
/** Verbs and adjectives from the curated deck — the trainer's pool. */ interface Predicate {
const PREDICATES: { dict: string; en: string }[] = Object.values( dict: string;
en: string;
}
/** Verbs and adjectives from the curated deck. */
const DECK_PREDICATES: Predicate[] = Object.values(
(deckJson as unknown as { topics: Record<string, [string, string, string, string][]> }).topics, (deckJson as unknown as { topics: Record<string, [string, string, string, string][]> }).topics,
) )
.flat() .flat()
.filter(([ko, , , pos]) => (pos === "verb" || pos === "adj") && ko.endsWith("다")) .filter(([ko, , , pos]) => (pos === "verb" || pos === "adj") && ko.endsWith("다"))
.map(([ko, , en]) => ({ dict: ko, en })); .map(([ko, , en]) => ({ dict: ko, en }));
/**
* The trainer's words: the deck's verbs and adjectives, and every one the
* roadmap introduces — the verbs of the unit he is on are the ones worth
* drilling. The artifact's pool was the deck alone.
*/
async function readPredicates(db: Db): Promise<Predicate[]> {
const rows = await db.all<Predicate>(
`SELECT headword AS dict, gloss_en AS en FROM lemma
WHERE unit_id IS NOT NULL AND pos IN ('verb', 'adj') AND headword LIKE '%다' AND gloss_en <> ''`,
);
const seen = new Set(DECK_PREDICATES.map((p) => p.dict));
return [...DECK_PREDICATES, ...rows.filter((r) => !seen.has(r.dict) && haeche(r.dict) !== null)];
}
const MODES = [ const MODES = [
{ id: "present", label: "현재 아/어" }, { id: "present", label: "현재 아/어" },
{ id: "past", label: "과거 았/었어" }, { id: "past", label: "과거 았/었어" },
@@ -57,9 +77,9 @@ const MODES = [
] as const; ] as const;
type Mode = (typeof MODES)[number]["id"]; type Mode = (typeof MODES)[number]["id"];
function poolFor(mode: Mode) { function poolFor(all: Predicate[], mode: Mode) {
if (mode !== "irr") return PREDICATES; if (mode !== "irr") return all;
return PREDICATES.filter((p) => irregularClass(p.dict) !== "regular"); return all.filter((p) => irregularClass(p.dict) !== "regular");
} }
function expected(mode: Mode, dict: string): string | null { function expected(mode: Mode, dict: string): string | null {
@@ -68,35 +88,63 @@ function expected(mode: Mode, dict: string): string | null {
return mode === "past" ? past(present) : present; return mode === "past" ? past(present) : present;
} }
/** A word at random — never the one just asked, while there is another. */
function draw(pool: Predicate[], last: Predicate | null): Predicate | null {
if (!pool.length) return null;
const choices = pool.length > 1 && last ? pool.filter((p) => p.dict !== last.dict) : pool;
return choices[Math.floor(Math.random() * choices.length)]!;
}
interface Verdict {
ok: boolean;
given: string;
want: string;
why: string;
}
/** 활용 연습 — its own page, reached from 학습 or 문법's ⚙. */ /** 활용 연습 — its own page, reached from 학습 or 문법's ⚙. */
export function ConjugationTab() { export function ConjugationTab() {
const { db, today, invalidate } = useStore(); const { db, today, invalidate } = useStore();
const [mode, setMode] = useState<Mode>("present"); const [mode, setMode] = useState<Mode>("present");
const [index, setIndex] = useState(0); const [all, setAll] = useState<Predicate[]>(DECK_PREDICATES);
const [question, setQuestion] = useState<Predicate | null>(null);
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [verdict, setVerdict] = useState<{ ok: boolean; want: string; why: string } | null>(null); const [verdict, setVerdict] = useState<Verdict | null>(null);
const [score, setScore] = useState({ n: 0, ok: 0 }); const [score, setScore] = useState({ n: 0, ok: 0 });
const [showKeyboard, setShowKeyboard] = useState(false); const [showKeyboard, setShowKeyboard] = useState(false);
const composer = useComposer(); const composer = useComposer();
// Guards the write behind `verdict`, which is a render behind. // Guards the write behind `verdict`, which is a render behind.
const checking = useRef(false); const checking = useRef(false);
const pool = useMemo(() => poolFor(mode), [mode]); const pool = useMemo(() => poolFor(all, mode), [all, mode]);
const question = pool[index % Math.max(1, pool.length)];
const next = useCallback(() => { useEffect(() => {
setIndex((i) => i + 1); let cancelled = false;
void readPredicates(db).then((p) => {
if (!cancelled) setAll(p);
});
// The running score survives a reload, as the artifact's did.
void readJsonMeta(db, TRAINER_CONJUGATION, { n: 0, ok: 0 }).then((saved) => {
if (!cancelled && Number.isFinite(saved.n)) setScore(saved);
});
return () => {
cancelled = true;
};
}, [db]);
const next = () => {
setQuestion((last) => draw(pool, last));
setValue(""); setValue("");
composer.reset(); // clearing in code fires no input event composer.reset(); // clearing in code fires no input event
setVerdict(null); setVerdict(null);
}, [composer]); };
// A new mode, or the pool growing to include the roadmap: a fresh question.
useEffect(() => { useEffect(() => {
setIndex(0); setQuestion((last) => draw(pool, last));
setValue(""); setValue("");
composer.reset();
setVerdict(null); setVerdict(null);
}, [composer, mode]); }, [pool]);
const check = async () => { const check = async () => {
if (checking.current) return; if (checking.current) return;
@@ -104,80 +152,121 @@ export function ConjugationTab() {
next(); next();
return; return;
} }
checking.current = true;
const want = expected(mode, question.dict); const want = expected(mode, question.dict);
const given = value.trim();
if (!want) { if (!want) {
next(); next();
return; return;
} }
const ok = value.trim() === want; if (!given) return;
setVerdict({ ok, want, why: explain(question.dict) }); checking.current = true;
try {
const ok = given === want;
setVerdict({ ok, given, want, why: explain(question.dict) });
const nextScore = { n: score.n + 1, ok: score.ok + (ok ? 1 : 0) }; const nextScore = { n: score.n + 1, ok: score.ok + (ok ? 1 : 0) };
setScore(nextScore); setScore(nextScore);
await editStudyLog(db, today, { drills: 1 }); await editStudyLog(db, today, { drills: 1 });
await writeJsonMeta(db, TRAINER_CONJUGATION, nextScore); await writeJsonMeta(db, TRAINER_CONJUGATION, nextScore);
invalidate(); invalidate();
} finally {
checking.current = false; checking.current = false;
}
}; };
if (!question) return null;
return ( return (
<div className="panel"> <div className="panel">
<div className="panel-h"> <div className="panel-h">
<h2> </h2> <h2> </h2>
<span className="note tnum"> <span className="note tnum">
{score.n ? `${Math.round((score.ok / score.n) * 100)}% · ${score.n} answered` : "conjugation trainer"} {score.n ? `${score.ok} of ${score.n} right so far` : "conjugation, marked by the app"}
</span> </span>
</div> </div>
<div className="panel-b"> <div className="panel-b">
<div className="topics"> <p className="cj-intro">
Stem, then the / split by vowel harmony: a stem whose last vowel is or takes ,
everything else takes , and becomes . Type the form the app builds the same
answer from the rule, so it can tell you <em>which</em> rule you missed.
</p>
<div className="topics cj-modes">
{MODES.map((m) => ( {MODES.map((m) => (
<button key={m.id} aria-pressed={mode === m.id} onClick={() => setMode(m.id)}> <button key={m.id} className="ko" aria-pressed={mode === m.id} onClick={() => setMode(m.id)}>
{m.label} {m.label}
</button> </button>
))} ))}
</div> </div>
{!question ? (
<p className="empty">No verbs in this set yet.</p>
) : (
<>
<div className="cj-prompt"> <div className="cj-prompt">
<span className="eyebrow"> <span className="cj-ask">{mode === "past" ? "past · 았/었어" : "present · 아/어"}</span>
{mode === "past" ? "past 반말" : "반말"} of
</span>
<div className="cj-dict ko serif">{question.dict}</div> <div className="cj-dict ko serif">{question.dict}</div>
<div className="cj-en">{question.en}</div> <div className="cj-en">{question.en}</div>
</div> </div>
{!verdict ? (
<div className="cj-in"> <div className="cj-in">
<input <input
className="ko" className="ko"
value={value} value={value}
placeholder="" placeholder="?"
autoComplete="off"
spellCheck={false}
inputMode={showKeyboard ? "none" : undefined}
aria-label={`Conjugate ${question.dict}${mode === "past" ? "past 반말" : "반말"}`} aria-label={`Conjugate ${question.dict}${mode === "past" ? "past 반말" : "반말"}`}
onChange={(e) => { onChange={(e) => {
composer.onExternalInput(); composer.onExternalInput();
setValue(e.target.value); setValue(e.target.value);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault(); e.preventDefault();
void check(); void check();
} }
}} }}
/> />
<button <button
className="btn ko" className="kb-toggle ko"
aria-pressed={showKeyboard} aria-pressed={showKeyboard}
aria-label="한글 keyboard"
onPointerDown={(e) => e.preventDefault()}
onClick={() => setShowKeyboard((k) => !k)} onClick={() => setShowKeyboard((k) => !k)}
> >
</button> </button>
<button className="btn primary" onClick={() => void check()}> <button className="btn primary" disabled={!value.trim()} onClick={() => void check()}>
{verdict ? "Next" : "Check"} Check
</button> </button>
</div> </div>
) : (
<div className="cj-fb" aria-live="polite">
{verdict.ok ? (
<>
<b className="ok ko">{verdict.want}</b>
<span className="ok ko"></span>
</>
) : (
<>
{/* What he typed, beside what it should have been — the
artifact showed only the answer, so the slip itself
was gone the moment it was marked. */}
<b className="no ko">
{verdict.given} {verdict.want}
</b>
<span className="no">not quite</span>
</>
)}
<span className="rule ko">{verdict.why}</span>
<button className="btn primary" autoFocus onClick={next}>
Next
</button>
</div>
)}
{showKeyboard && ( {showKeyboard && !verdict && (
<Keyboard <Keyboard
composer={composer} composer={composer}
onChange={setValue} onChange={setValue}
@@ -185,20 +274,18 @@ export function ConjugationTab() {
onDismiss={() => setShowKeyboard(false)} onDismiss={() => setShowKeyboard(false)}
/> />
)} )}
</>
)}
{verdict && ( <div className="cj-score tnum">
<div className={`cj-fb ${verdict.ok ? "ok" : "no"}`}>
{verdict.ok ? (
<span> <span>
<b className="ko">{verdict.want}</b> {verdict.why} Answered <b>{score.n}</b>
</span> </span>
) : (
<span> <span>
it is <b className="ko">{verdict.want}</b> {verdict.why} Correct <b>{score.ok}</b>
</span> </span>
)} {score.n > 0 && <span>{Math.round((score.ok / score.n) * 100)}%</span>}
</div> </div>
)}
</div> </div>
</div> </div>
); );
@@ -236,7 +323,10 @@ function Irregulars() {
export function GrammarTab() { export function GrammarTab() {
const { db, prefs } = useStore(); const { db, prefs } = useStore();
const [cat, setCat] = useState<string>(POINTS[0]?.cat ?? "all"); // 반말 first, as the artifact opens — it is what manhwa speech is made of.
const [cat, setCat] = useState<string>(
POINTS.some((p) => p.cat.startsWith("반말")) ? POINTS.find((p) => p.cat.startsWith("반말"))!.cat : "all",
);
const [open, setOpen] = useState<string | null>(null); const [open, setOpen] = useState<string | null>(null);
const [learned, setLearned] = useState<Record<string, boolean>>({}); const [learned, setLearned] = useState<Record<string, boolean>>({});
const [notes, setNotes] = useState<Record<string, string>>({}); const [notes, setNotes] = useState<Record<string, string>>({});
@@ -275,7 +365,7 @@ export function GrammarTab() {
await writeJsonMeta(db, GRAMMAR_NOTES, next); await writeJsonMeta(db, GRAMMAR_NOTES, next);
}; };
const shown = POINTS.filter((p) => p.cat === cat); const shown = POINTS.filter((p) => cat === "all" || p.cat === cat);
return ( return (
<> <>
@@ -291,9 +381,9 @@ export function GrammarTab() {
<div className="panel-b"> <div className="panel-b">
<div className="topics"> <div className="topics">
{cats.map((c) => ( {["all", ...cats].map((c) => (
<button key={c} aria-pressed={cat === c} onClick={() => setCat(c)}> <button key={c} className="ko tnum" aria-pressed={cat === c} onClick={() => setCat(c)}>
{c} {c === "all" ? "전체 All" : c} · {c === "all" ? POINTS.length : POINTS.filter((p) => p.cat === c).length}
</button> </button>
))} ))}
</div> </div>
@@ -310,7 +400,12 @@ export function GrammarTab() {
<span className="g-dot" data-on={learned[p.id] ? "1" : undefined} /> <span className="g-dot" data-on={learned[p.id] ? "1" : undefined} />
<span className="g-form ko">{p.form}</span> <span className="g-form ko">{p.form}</span>
<span className="g-name">{p.name}</span> <span className="g-name">{p.name}</span>
{p.read ? <span className="state review">reading</span> : null} {p.read ? (
<span className="state review">reading</span>
) : (
// Its category, so a row makes sense under 전체 as well.
<span className="eyebrow g-cat ko">{p.cat.split(" ")[0]}</span>
)}
</button> </button>
{open === p.id && ( {open === p.id && (

View File

@@ -4,9 +4,10 @@
The drill is reading-only by design: written form → spoken form, word → The drill is reading-only by design: written form → spoken form, word →
meaning, sentence → meaning. Nothing here asks him to produce a sound. */ meaning, sentence → meaning. Nothing here asks him to produce a sound. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "../../state/store.js"; import { useStore } from "../../state/store.js";
import { editStudyLog } from "../../db/writes.js"; import { editStudyLog } from "../../db/writes.js";
import { TRAINER_DRILL, readJsonMeta, writeJsonMeta } from "../../domain/notes.js";
import { compose, decompose } from "@lib/hangul.js"; import { compose, decompose } from "@lib/hangul.js";
import hangulJson from "@data/hangul.json"; import hangulJson from "@data/hangul.json";
import deckJson from "@data/deck.json"; import deckJson from "@data/deck.json";
@@ -38,15 +39,34 @@ type Mode = (typeof MODES)[number]["id"];
const ROUND = 12; const ROUND = 12;
/** The artifact's words for each mode, before a round starts. */
const INTRO: Record<Mode, string> = {
sound:
"Twelve words written one way and said another. Pick the pronunciation, not the spelling — the rule that did it is named after each answer.",
speed: "Twelve words, straight to meaning, against the clock. No romanization, no thinking in letters.",
sentence: "Twelve lines pulled from the 문장 set. Read to the ending word, then choose.",
};
/** How long an answer stays marked: long enough to read a rule, longer after a miss. */
const SHOW_RIGHT_MS = 450;
const SHOW_RULE_MS = 900;
const SHOW_WRONG_MS = 1400;
interface Question { interface Question {
prompt: string; prompt: string;
answer: string; answer: string;
options: string[]; options: string[];
after?: string; after?: string;
hint?: string;
big?: boolean; big?: boolean;
wide?: boolean; wide?: boolean;
} }
interface Best {
acc: number;
sec: number;
}
function pick<T>(items: T[], n: number, exclude: (t: T) => boolean): T[] { function pick<T>(items: T[], n: number, exclude: (t: T) => boolean): T[] {
const pool = items.filter((t) => !exclude(t)); const pool = items.filter((t) => !exclude(t));
const out: T[] = []; const out: T[] = [];
@@ -96,13 +116,17 @@ function buildQuestion(mode: Mode): Question | null {
if (mode === "sound") { if (mode === "sound") {
const pair = H.soundPairs[Math.floor(Math.random() * H.soundPairs.length)]; const pair = H.soundPairs[Math.floor(Math.random() * H.soundPairs.length)];
if (!pair) return null; if (!pair) return null;
const wrong = distractors(pair.spoken, 3); // The spelling itself is the most tempting wrong answer — reading it
if (!wrong.length) return null; // as written is exactly the habit the drill is breaking.
const written = pair.written !== pair.spoken ? [pair.written] : [];
const wrong = distractors(pair.spoken, 3 - written.length).filter((w) => w !== pair.written);
if (!wrong.length && !written.length) return null;
return { return {
prompt: pair.written, prompt: pair.written,
answer: pair.spoken, answer: pair.spoken,
options: shuffle([pair.spoken, ...wrong]), options: shuffle([pair.spoken, ...written, ...wrong]),
after: pair.rule, after: pair.rule,
hint: "How is it actually pronounced?",
big: true, big: true,
}; };
} }
@@ -133,86 +157,125 @@ function buildQuestion(mode: Mode): Question | null {
/** 읽기 연습 — its own page, reached from 학습 or 한글's ⏱. */ /** 읽기 연습 — its own page, reached from 학습 or 한글's ⏱. */
export function DrillTab() { export function DrillTab() {
const { db, today, invalidate } = useStore(); const { db, today, invalidate } = useStore();
// `picked` drives the UI; this guards the write. State is a render behind,
// so a fast double-tap would otherwise log two answers for one question.
const answering = useRef(false);
const [mode, setMode] = useState<Mode>("sound"); const [mode, setMode] = useState<Mode>("sound");
const [question, setQuestion] = useState<Question | null>(null); const [question, setQuestion] = useState<Question | null>(null);
const [picked, setPicked] = useState<string | null>(null); const [picked, setPicked] = useState<string | null>(null);
const [round, setRound] = useState({ n: 0, correct: 0 }); /** The round in progress; null before one starts. */
const [round, setRound] = useState<{ n: number; correct: number; startedAt: number } | null>(null);
const [summary, setSummary] = useState<{ acc: number; sec: number; correct: number; best: boolean } | null>(null);
const [best, setBest] = useState<Partial<Record<Mode, Best>>>({});
const [elapsed, setElapsed] = useState(0);
// `picked` drives the UI; this guards the write. State is a render behind,
// so a fast double-tap would otherwise log two answers for one question.
const answering = useRef(false);
const advance = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const nextQuestion = useCallback(() => { useEffect(() => {
setQuestion(buildQuestion(mode)); void readJsonMeta<Partial<Record<Mode, Best>>>(db, TRAINER_DRILL, {}).then(setBest);
}, [db]);
// A new mode starts from its introduction.
useEffect(() => {
clearTimeout(advance.current);
answering.current = false;
setRound(null);
setSummary(null);
setQuestion(null);
setPicked(null); setPicked(null);
}, [mode]); }, [mode]);
useEffect(() => () => clearTimeout(advance.current), []);
// The clock, while a round runs.
useEffect(() => { useEffect(() => {
setRound({ n: 0, correct: 0 }); if (!round) return;
nextQuestion(); const tick = () => setElapsed(Math.round((performance.now() - round.startedAt) / 1000));
}, [mode, nextQuestion]); tick();
const timer = setInterval(tick, 1000);
return () => clearInterval(timer);
}, [round]);
const start = () => {
setSummary(null);
setRound({ n: 1, correct: 0, startedAt: performance.now() });
setQuestion(buildQuestion(mode));
setPicked(null);
};
const finish = async (correct: number, startedAt: number) => {
const sec = Math.round((performance.now() - startedAt) / 1000);
const acc = Math.round((correct / ROUND) * 100);
const prior = best[mode];
const isBest = !prior || acc > prior.acc || (acc === prior.acc && sec < prior.sec);
setRound(null);
setQuestion(null);
setSummary({ acc, sec, correct, best: isBest });
if (isBest) {
const next = { ...best, [mode]: { acc, sec } };
setBest(next);
await writeJsonMeta(db, TRAINER_DRILL, next);
}
};
const answer = async (option: string) => { const answer = async (option: string) => {
if (answering.current || picked || !question) return; if (answering.current || picked || !question || !round) return;
answering.current = true; answering.current = true;
setPicked(option); setPicked(option);
const ok = option === question.answer; const ok = option === question.answer;
setRound((r) => ({ n: r.n + 1, correct: r.correct + (ok ? 1 : 0) })); const correct = round.correct + (ok ? 1 : 0);
await editStudyLog(db, today, { drills: 1 }); await editStudyLog(db, today, { drills: 1 });
invalidate(); invalidate();
setTimeout(() => { advance.current = setTimeout(
() => {
answering.current = false; answering.current = false;
nextQuestion(); if (round.n >= ROUND) {
}, ok ? 500 : 1300); void finish(correct, round.startedAt);
return;
}
setRound({ ...round, n: round.n + 1, correct });
setQuestion(buildQuestion(mode));
setPicked(null);
},
ok ? (question.after ? SHOW_RULE_MS : SHOW_RIGHT_MS) : SHOW_WRONG_MS,
);
}; };
const finished = round.n >= ROUND; const record = best[mode];
return ( return (
<div className="panel"> <div className="panel">
<div className="panel-h"> <div className="panel-h">
<h2> </h2> <h2> </h2>
<span className="note tnum"> <span className="note tnum">
{round.n ? `${round.correct} / ${round.n} correct` : "reading drill"} {record ? `best ${record.acc}% · ${record.sec}s` : "no round finished yet"}
</span> </span>
</div> </div>
<div className="panel-b"> <div className="panel-b">
<div className="topics"> <div className="topics drill-modes">
{MODES.map((m) => ( {MODES.map((m) => (
<button key={m.id} aria-pressed={mode === m.id} onClick={() => setMode(m.id)}> <button key={m.id} className="ko" aria-pressed={mode === m.id} onClick={() => setMode(m.id)}>
{m.label} {m.label}
</button> </button>
))} ))}
</div> </div>
{finished ? ( {round && question ? (
<div className="drill-done">
<p className="big tnum">{Math.round((round.correct / round.n) * 100)}%</p>
<button
className="btn primary"
onClick={() => {
setRound({ n: 0, correct: 0 });
nextQuestion();
}}
>
Again
</button>
</div>
) : (
question && (
<div className="drill"> <div className="drill">
<div className="drill-meta"> <div className="drill-meta tnum">
<span className="eyebrow"> <span>
Question {round.n + 1} of {ROUND} Question <b>{round.n}</b> / {ROUND}
</span>
<span>
Correct <b>{round.correct + (picked === question.answer ? 1 : 0)}</b>
</span>
<span>
<b>{elapsed}</b>s
</span> </span>
{picked && question.after && (
<span className="eyebrow"> · {question.after}</span>
)}
</div> </div>
<div className={`prompt ko serif${question.big ? " word" : " line"}`}> <div className={`prompt ko${question.big ? " word" : " line"}`}>{question.prompt}</div>
{question.prompt} {question.hint && <div className="drill-hint">{question.hint}</div>}
</div>
<div className={`opts${question.wide ? " wide" : ""}`}> <div className={`opts${question.wide ? " wide" : ""}`}>
{question.options.map((o) => ( {question.options.map((o) => (
@@ -220,13 +283,7 @@ export function DrillTab() {
key={o} key={o}
className={/[-]/.test(o) ? "ko" : undefined} className={/[-]/.test(o) ? "ko" : undefined}
data-mark={ data-mark={
picked picked ? (o === question.answer ? "ok" : o === picked ? "no" : undefined) : undefined
? o === question.answer
? "ok"
: o === picked
? "no"
: undefined
: undefined
} }
disabled={Boolean(picked)} disabled={Boolean(picked)}
onClick={() => void answer(o)} onClick={() => void answer(o)}
@@ -235,8 +292,34 @@ export function DrillTab() {
</button> </button>
))} ))}
</div> </div>
<div className="drill-after ko">{picked && question.after ? `규칙 · ${question.after}` : ""}</div>
</div>
) : (
<div className="drill">
{summary ? (
<div className="drill-done">
<p className="big tnum">{summary.acc}%</p>
<div className="sum-row tnum">
<span>
<b>
{summary.correct} / {ROUND}
</b>{" "}
correct
</span>
<span>
<b>{summary.sec}s</b> time
</span>
</div>
{summary.best && <span className="best">New best for this mode</span>}
</div>
) : (
<p className="drill-intro">{INTRO[mode]}</p>
)}
<button className="btn primary big" onClick={start}>
{summary ? "Another round" : "Start round"}
</button>
</div> </div>
)
)} )}
</div> </div>
</div> </div>

View File

@@ -5,8 +5,10 @@
appears. Korean puts the verb at the end, and that is the single habit a appears. Korean puts the verb at the end, and that is the single habit a
reader coming from English has to build. */ reader coming from English has to build. */
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useReview } from "../review/useReview.js"; import { useReview } from "../review/useReview.js";
import { useStore } from "../../state/store.js";
import { deck, type CardStatus } from "../../domain/cards.js";
import sentencesJson from "@data/sentences.json"; import sentencesJson from "@data/sentences.json";
import sfxJson from "@data/sfx.json"; import sfxJson from "@data/sfx.json";
import "./sentences.css"; import "./sentences.css";
@@ -56,10 +58,44 @@ function Chunks({ s }: { s: Sentence }) {
); );
} }
const STATUS_LABEL: Record<CardStatus, string> = {
new: "New",
learning: "Learning",
review: "In review",
secure: "Secure",
};
const RANK: Record<CardStatus, number> = { new: 0, learning: 1, review: 2, secure: 3 };
/**
* Where a sentence stands in review. Its cards are its chunks — 나 and
* 배고파 — so a sentence is as far along as its least-known chunk.
*/
function sentenceStatus(s: Sentence, chunks: Map<string, CardStatus>): CardStatus {
let weakest: CardStatus = "secure";
for (const [ko] of s.parts) {
const st = chunks.get(ko) ?? "new";
if (RANK[st] < RANK[weakest]) weakest = st;
}
return weakest;
}
export function SentencesTab() { export function SentencesTab() {
const { db, revision } = useStore();
const { start } = useReview(); const { start } = useReview();
const [level, setLevel] = useState<string>("all"); const [level, setLevel] = useState<string>("all");
const [open, setOpen] = useState<string | null>(null); const [open, setOpen] = useState<string | null>(null);
const [chunks, setChunks] = useState<Map<string, CardStatus>>(new Map());
useEffect(() => {
let cancelled = false;
void deck(db, { only: "sentences" }).then((rows) => {
if (!cancelled) setChunks(new Map(rows.map((r) => [r.headword, r.status])));
});
return () => {
cancelled = true;
};
}, [db, revision]);
const shown = useMemo( const shown = useMemo(
() => FILE.sentences.filter((s) => level === "all" || s.lvl === level), () => FILE.sentences.filter((s) => level === "all" || s.lvl === level),
@@ -73,46 +109,61 @@ export function SentencesTab() {
<> <>
<div className="panel"> <div className="panel">
<div className="panel-h"> <div className="panel-h">
<h2 className="ko"></h2> <h2>The ending word</h2>
<span className="note">The ending word the predicate</span> <span className="note ko"> the predicate</span>
</div> </div>
<div className="panel-b lesson"> <div className="panel-b lesson">
<div> <div>
<p> <p>
Korean puts the predicate last. Whatever a sentence is about, the word that Korean saves the verdict for last. Everything before the final word is <b>setup</b>
says what <em>happens</em> or what something <em>is</em> comes at the end, who, what, where, when and the <b>last word carries the action or the state</b>.
and everything else leans on it. Read to the end of the bubble first, then work backwards.
</p> </p>
<p style={{ marginTop: 10 }}> <p>
Read to the end first, then work backwards. That one habit does more for This is also why so much can vanish. <b className="ko"></b> and{" "}
reading manhwa than any amount of vocabulary. <b className="ko"></b> get dropped constantly once the situation is obvious; the
ending word survives, because without it there is no sentence. A single word like{" "}
<b className="ko"></b> or <b className="ko"></b> is a complete line.
</p>
<p>
Questions look identical to statements Korean marks them with intonation, which on
the page means a question mark and the shape of the panel. <b className="ko">?</b>{" "}
and <b className="ko">.</b> differ by one dot.
</p> </p>
</div> </div>
<div className="diagram"> <div className="diagram">
<span className="eyebrow">One sentence, read to the end</span>
{demo.map((s, i) => ( {demo.map((s, i) => (
<div key={i}> <div key={i}>
<Chunks s={s} /> <Chunks s={s} />
<div className="dg-en">{s.en}</div> <div className="dg-note">
<b></b>
<span>{s.en}</span>
</div>
</div> </div>
))} ))}
<div className="dg-note dg-legend">
<b className="ko"></b>
<span>the boxed word always last, always the one that decides what the sentence says</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="panel"> <div className="panel">
<div className="panel-h"> <div className="panel-h">
<h2></h2> <h2 className="ko"></h2>
<span className="note tnum">{shown.length} sentences</span> <span className="note tnum">{shown.length} sentences</span>
</div> </div>
<div className="panel-b"> <div className="panel-b">
<div className="toolbar"> <div className="toolbar">
<div className="topics"> <div className="topics">
<button aria-pressed={level === "all"} onClick={() => setLevel("all")}> <button className="ko tnum" aria-pressed={level === "all"} onClick={() => setLevel("all")}>
All All · {FILE.sentences.length}
</button> </button>
{levels.map((l) => ( {levels.map((l) => (
<button key={l} aria-pressed={level === l} onClick={() => setLevel(l)}> <button key={l} className="tnum" aria-pressed={level === l} onClick={() => setLevel(l)}>
{l} · {FILE.levels[l]} {l} · {FILE.levels[l]} · {FILE.sentences.filter((s) => s.lvl === l).length}
</button> </button>
))} ))}
</div> </div>
@@ -127,7 +178,9 @@ export function SentencesTab() {
</div> </div>
<div className="s-list"> <div className="s-list">
{shown.map((s) => ( {shown.map((s) => {
const status = sentenceStatus(s, chunks);
return (
<div className="s-item" key={s.ko}> <div className="s-item" key={s.ko}>
<button <button
className="s-head" className="s-head"
@@ -138,6 +191,9 @@ export function SentencesTab() {
<span className="s-ko"> <span className="s-ko">
<KoWithPredicate s={s} /> <KoWithPredicate s={s} />
</span> </span>
<span className="s-st">
<span className={`state ${status}`}>{STATUS_LABEL[status]}</span>
</span>
</button> </button>
{open === s.ko && ( {open === s.ko && (
<div className="s-body"> <div className="s-body">
@@ -149,8 +205,13 @@ export function SentencesTab() {
</div> </div>
)} )}
</div> </div>
))} );
})}
</div> </div>
<p className="s-count">
{shown.length} sentences · the boxed word is the <span className="ko"></span>, the one that
decides the sentence
</p>
</div> </div>
<div className="panel"> <div className="panel">

View File

@@ -32,28 +32,84 @@
max-width: 240px; max-width: 240px;
} }
.cj-fb { .cj-intro {
margin-top: 13px; max-width: 64ch;
padding: 10px 12px; margin-bottom: 12px;
font-size: 14px; font-size: 14px;
border-left: 3px solid var(--line2); color: var(--ink2);
background: var(--raise);
} }
.cj-fb.ok { .cj-modes {
border-left-color: var(--jade); justify-content: center;
background: var(--jade-soft);
color: var(--jade-ink);
} }
.cj-fb.no { .cj-ask {
border-left-color: var(--jeok); font-size: 12px;
background: var(--jeok-soft); font-weight: 500;
color: var(--jeok); letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--jade);
}
.cj-fb {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-height: 44px;
text-align: center;
font-size: 14px;
} }
.cj-fb b { .cj-fb b {
font-size: 17px; font-size: 22px;
font-weight: 500;
}
.cj-fb .ok {
color: var(--jade);
}
.cj-fb .no {
color: var(--jeok);
}
.cj-fb .rule {
margin-bottom: 8px;
font-size: 12px;
color: var(--ink3);
}
.cj-score {
display: flex;
justify-content: center;
gap: 18px;
margin-top: 14px;
font-size: 12.5px;
color: var(--ink3);
}
.cj-score b {
color: var(--ink);
font-weight: 500;
}
.g-cat {
white-space: nowrap;
}
@media (max-width: 599px) {
.cj-in {
flex-wrap: wrap;
}
.cj-in input {
flex: 1 1 100%;
max-width: none;
font-size: 22px;
}
.cj-dict {
font-size: 36px;
}
} }
/* ── irregulars ──────────────────────────────────────────────────── */ /* ── irregulars ──────────────────────────────────────────────────── */

View File

@@ -1,4 +1,4 @@
/* 한글 — drill, syllable diagram, jamo tables, sound rules. */ /* 한글 — the reading drill, the syllable diagram, jamo tables, sound rules. */
.drill { .drill {
display: flex; display: flex;
@@ -8,11 +8,41 @@
padding: 16px 0 6px; padding: 16px 0 6px;
} }
.drill-modes {
justify-content: center;
}
.drill-meta { .drill-meta {
display: flex; display: flex;
gap: 16px; justify-content: center;
width: 100%; gap: 18px;
justify-content: space-between; font-size: 12.5px;
color: var(--ink3);
}
.drill-meta b {
color: var(--ink);
font-weight: 500;
}
.drill-intro {
max-width: 52ch;
text-align: center;
font-size: 14px;
color: var(--ink3);
}
.drill-hint {
margin-top: -8px;
font-size: 12.5px;
color: var(--ink3);
}
/* Reserved, so the options do not jump when a rule appears under them. */
.drill-after {
min-height: 19px;
font-size: 12.5px;
color: var(--jade);
} }
.prompt { .prompt {
@@ -82,6 +112,27 @@
color: var(--jade); color: var(--jade);
} }
.drill-done .sum-row {
display: flex;
gap: 22px;
font-size: 13px;
color: var(--ink3);
}
.drill-done .sum-row b {
color: var(--ink);
font-weight: 600;
}
.drill-done .best {
padding: 3px 10px;
border: 1px solid var(--hwang);
border-radius: 12px;
background: var(--hwang-soft);
font-size: 12px;
color: var(--hwang);
}
/* ── syllable anatomy ────────────────────────────────────────────── */ /* ── syllable anatomy ────────────────────────────────────────────── */
.block-demo { .block-demo {

View File

@@ -48,11 +48,33 @@
padding: 0 4px 2px; padding: 0 4px 2px;
} }
.dg-en { .lesson p + p {
margin-top: 11px;
}
.lesson b {
color: var(--ink);
font-weight: 600;
}
.dg-note {
display: flex;
align-items: baseline;
gap: 7px;
margin-top: 9px; margin-top: 9px;
font-family: var(--serif); font-size: 12.5px;
font-size: 14px; color: var(--ink3);
color: var(--ink2); }
.dg-note b {
color: var(--jade);
font-weight: 500;
}
.dg-legend {
margin-top: 2px;
padding-top: 11px;
border-top: 1px solid var(--line);
} }
/* ── the list ────────────────────────────────────────────────────── */ /* ── the list ────────────────────────────────────────────────────── */
@@ -78,6 +100,22 @@
background: var(--raise); background: var(--raise);
} }
.s-ko {
flex: 1;
min-width: 0;
}
.s-st {
flex: none;
margin-left: auto;
}
.s-count {
padding: 9px 16px 13px;
font-size: 12.5px;
color: var(--ink3);
}
.s-lvl { .s-lvl {
font-family: var(--mono); font-family: var(--mono);
font-size: 11px; font-size: 11px;

View File

@@ -98,7 +98,12 @@ export const decodePk = (pk) => JSON.parse(pk);
* `server.*` holds this device's endpoint and bearer token, and `sync.*` * `server.*` holds this device's endpoint and bearer token, and `sync.*`
* is this device's own position in the conversation with the server. * is this device's own position in the conversation with the server.
*/ */
const SYNCABLE_META_EXACT = new Set(["grammar.learned", "grammar.notes", "trainer.conjugation"]); const SYNCABLE_META_EXACT = new Set([
"grammar.learned",
"grammar.notes",
"trainer.conjugation",
"trainer.drill",
]);
const SYNCABLE_META_PREFIX = ["prefs.", "road.", "learner.", "reset."]; const SYNCABLE_META_PREFIX = ["prefs.", "road.", "learner.", "reset."];
export function isSyncableMetaKey(key) { export function isSyncableMetaKey(key) {

View File

@@ -97,6 +97,20 @@ describe("meta", () => {
expect(mergedValue(r)).toEqual({ a: true, b: true }); expect(mergedValue(r)).toEqual({ a: true, b: true });
}); });
it("keeps the better reading-drill round for each mode", () => {
const r = resolve(
"meta",
live({ k: "trainer.drill", v: JSON.stringify({ sound: { acc: 92, sec: 40 }, speed: { acc: 75, sec: 30 } }) }),
live({ k: "trainer.drill", v: JSON.stringify({ sound: { acc: 92, sec: 55 }, sentence: { acc: 58, sec: 70 } }) }),
ctx,
);
expect(mergedValue(r)).toEqual({
sound: { acc: 92, sec: 40 }, // the same accuracy, faster
speed: { acc: 75, sec: 30 },
sentence: { acc: 58, sec: 70 },
});
});
it("keeps the longer of two versions of one note", () => { it("keeps the longer of two versions of one note", () => {
const r = resolve( const r = resolve(
"meta", "meta",