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:
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user