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_NOTES = "grammar.notes";
|
||||
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") {
|
||||
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") {
|
||||
const a = parseObject(local.v);
|
||||
const b = parseObject(remote.v);
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
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 { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStore } from "../../state/store.js";
|
||||
import type { Db } from "../../db/types.js";
|
||||
import { editStudyLog } from "../../db/writes.js";
|
||||
import {
|
||||
GRAMMAR_LEARNED,
|
||||
@@ -42,14 +43,33 @@ interface IrregularClassEntry {
|
||||
}
|
||||
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(
|
||||
interface Predicate {
|
||||
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,
|
||||
)
|
||||
.flat()
|
||||
.filter(([ko, , , pos]) => (pos === "verb" || pos === "adj") && ko.endsWith("다"))
|
||||
.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 = [
|
||||
{ id: "present", label: "현재 아/어" },
|
||||
{ id: "past", label: "과거 았/었어" },
|
||||
@@ -57,9 +77,9 @@ const MODES = [
|
||||
] 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 poolFor(all: Predicate[], mode: Mode) {
|
||||
if (mode !== "irr") return all;
|
||||
return all.filter((p) => irregularClass(p.dict) !== "regular");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 ⚙. */
|
||||
export function ConjugationTab() {
|
||||
const { db, today, invalidate } = useStore();
|
||||
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 [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 [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 pool = useMemo(() => poolFor(all, mode), [all, mode]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setIndex((i) => i + 1);
|
||||
useEffect(() => {
|
||||
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("");
|
||||
composer.reset(); // clearing in code fires no input event
|
||||
setVerdict(null);
|
||||
}, [composer]);
|
||||
};
|
||||
|
||||
// A new mode, or the pool growing to include the roadmap: a fresh question.
|
||||
useEffect(() => {
|
||||
setIndex(0);
|
||||
setQuestion((last) => draw(pool, last));
|
||||
setValue("");
|
||||
composer.reset();
|
||||
setVerdict(null);
|
||||
}, [composer, mode]);
|
||||
}, [pool]);
|
||||
|
||||
const check = async () => {
|
||||
if (checking.current) return;
|
||||
@@ -104,101 +152,140 @@ export function ConjugationTab() {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
checking.current = true;
|
||||
const want = expected(mode, question.dict);
|
||||
const given = value.trim();
|
||||
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 writeJsonMeta(db, TRAINER_CONJUGATION, nextScore);
|
||||
invalidate();
|
||||
checking.current = false;
|
||||
if (!given) return;
|
||||
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) };
|
||||
setScore(nextScore);
|
||||
await editStudyLog(db, today, { drills: 1 });
|
||||
await writeJsonMeta(db, TRAINER_CONJUGATION, nextScore);
|
||||
invalidate();
|
||||
} finally {
|
||||
checking.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!question) return null;
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>활용 연습</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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) => (
|
||||
<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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cj-prompt">
|
||||
<span className="eyebrow">
|
||||
{mode === "past" ? "past 반말" : "반말"} of
|
||||
</span>
|
||||
<div className="cj-dict ko serif">{question.dict}</div>
|
||||
<div className="cj-en">{question.en}</div>
|
||||
</div>
|
||||
{!question ? (
|
||||
<p className="empty">No verbs in this set yet.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="cj-prompt">
|
||||
<span className="cj-ask">{mode === "past" ? "past · 았/었어" : "present · 아/어"}</span>
|
||||
<div className="cj-dict ko serif">{question.dict}</div>
|
||||
<div className="cj-en">{question.en}</div>
|
||||
</div>
|
||||
|
||||
<div className="cj-in">
|
||||
<input
|
||||
className="ko"
|
||||
value={value}
|
||||
placeholder="…"
|
||||
aria-label={`Conjugate ${question.dict} — ${mode === "past" ? "past 반말" : "반말"}`}
|
||||
onChange={(e) => {
|
||||
composer.onExternalInput();
|
||||
setValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void check();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn ko"
|
||||
aria-pressed={showKeyboard}
|
||||
onClick={() => setShowKeyboard((k) => !k)}
|
||||
>
|
||||
한
|
||||
</button>
|
||||
<button className="btn primary" onClick={() => void check()}>
|
||||
{verdict ? "Next" : "Check"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showKeyboard && (
|
||||
<Keyboard
|
||||
composer={composer}
|
||||
onChange={setValue}
|
||||
target="answer"
|
||||
onDismiss={() => setShowKeyboard(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{verdict && (
|
||||
<div className={`cj-fb ${verdict.ok ? "ok" : "no"}`}>
|
||||
{verdict.ok ? (
|
||||
<span>
|
||||
✓ <b className="ko">{verdict.want}</b> — {verdict.why}
|
||||
</span>
|
||||
{!verdict ? (
|
||||
<div className="cj-in">
|
||||
<input
|
||||
className="ko"
|
||||
value={value}
|
||||
placeholder="?"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
inputMode={showKeyboard ? "none" : undefined}
|
||||
aria-label={`Conjugate ${question.dict} — ${mode === "past" ? "past 반말" : "반말"}`}
|
||||
onChange={(e) => {
|
||||
composer.onExternalInput();
|
||||
setValue(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
void check();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="kb-toggle ko"
|
||||
aria-pressed={showKeyboard}
|
||||
aria-label="한글 keyboard"
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShowKeyboard((k) => !k)}
|
||||
>
|
||||
한
|
||||
</button>
|
||||
<button className="btn primary" disabled={!value.trim()} onClick={() => void check()}>
|
||||
Check
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span>
|
||||
✗ it is <b className="ko">{verdict.want}</b> — {verdict.why}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showKeyboard && !verdict && (
|
||||
<Keyboard
|
||||
composer={composer}
|
||||
onChange={setValue}
|
||||
target="answer"
|
||||
onDismiss={() => setShowKeyboard(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="cj-score tnum">
|
||||
<span>
|
||||
Answered <b>{score.n}</b>
|
||||
</span>
|
||||
<span>
|
||||
Correct <b>{score.ok}</b>
|
||||
</span>
|
||||
{score.n > 0 && <span>{Math.round((score.ok / score.n) * 100)}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -236,7 +323,10 @@ function Irregulars() {
|
||||
|
||||
export function GrammarTab() {
|
||||
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 [learned, setLearned] = useState<Record<string, boolean>>({});
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
@@ -275,7 +365,7 @@ export function GrammarTab() {
|
||||
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 (
|
||||
<>
|
||||
@@ -291,9 +381,9 @@ export function GrammarTab() {
|
||||
|
||||
<div className="panel-b">
|
||||
<div className="topics">
|
||||
{cats.map((c) => (
|
||||
<button key={c} aria-pressed={cat === c} onClick={() => setCat(c)}>
|
||||
{c}
|
||||
{["all", ...cats].map((c) => (
|
||||
<button key={c} className="ko tnum" aria-pressed={cat === c} onClick={() => setCat(c)}>
|
||||
{c === "all" ? "전체 All" : c} · {c === "all" ? POINTS.length : POINTS.filter((p) => p.cat === c).length}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -310,7 +400,12 @@ export function GrammarTab() {
|
||||
<span className="g-dot" data-on={learned[p.id] ? "1" : undefined} />
|
||||
<span className="g-form ko">{p.form}</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>
|
||||
|
||||
{open === p.id && (
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
The drill is reading-only by design: written form → spoken form, word →
|
||||
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 { editStudyLog } from "../../db/writes.js";
|
||||
import { TRAINER_DRILL, readJsonMeta, writeJsonMeta } from "../../domain/notes.js";
|
||||
import { compose, decompose } from "@lib/hangul.js";
|
||||
import hangulJson from "@data/hangul.json";
|
||||
import deckJson from "@data/deck.json";
|
||||
@@ -38,15 +39,34 @@ type Mode = (typeof MODES)[number]["id"];
|
||||
|
||||
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 {
|
||||
prompt: string;
|
||||
answer: string;
|
||||
options: string[];
|
||||
after?: string;
|
||||
hint?: string;
|
||||
big?: boolean;
|
||||
wide?: boolean;
|
||||
}
|
||||
|
||||
interface Best {
|
||||
acc: number;
|
||||
sec: number;
|
||||
}
|
||||
|
||||
function pick<T>(items: T[], n: number, exclude: (t: T) => boolean): T[] {
|
||||
const pool = items.filter((t) => !exclude(t));
|
||||
const out: T[] = [];
|
||||
@@ -96,13 +116,17 @@ function buildQuestion(mode: Mode): Question | null {
|
||||
if (mode === "sound") {
|
||||
const pair = H.soundPairs[Math.floor(Math.random() * H.soundPairs.length)];
|
||||
if (!pair) return null;
|
||||
const wrong = distractors(pair.spoken, 3);
|
||||
if (!wrong.length) return null;
|
||||
// The spelling itself is the most tempting wrong answer — reading it
|
||||
// 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 {
|
||||
prompt: pair.written,
|
||||
answer: pair.spoken,
|
||||
options: shuffle([pair.spoken, ...wrong]),
|
||||
options: shuffle([pair.spoken, ...written, ...wrong]),
|
||||
after: pair.rule,
|
||||
hint: "How is it actually pronounced?",
|
||||
big: true,
|
||||
};
|
||||
}
|
||||
@@ -133,110 +157,169 @@ function buildQuestion(mode: Mode): Question | null {
|
||||
/** 읽기 연습 — its own page, reached from 학습 or 한글's ⏱. */
|
||||
export function DrillTab() {
|
||||
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 [question, setQuestion] = useState<Question | 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(() => {
|
||||
setQuestion(buildQuestion(mode));
|
||||
useEffect(() => {
|
||||
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);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => () => clearTimeout(advance.current), []);
|
||||
|
||||
// The clock, while a round runs.
|
||||
useEffect(() => {
|
||||
setRound({ n: 0, correct: 0 });
|
||||
nextQuestion();
|
||||
}, [mode, nextQuestion]);
|
||||
if (!round) return;
|
||||
const tick = () => setElapsed(Math.round((performance.now() - round.startedAt) / 1000));
|
||||
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) => {
|
||||
if (answering.current || picked || !question) return;
|
||||
if (answering.current || picked || !question || !round) return;
|
||||
answering.current = true;
|
||||
setPicked(option);
|
||||
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 });
|
||||
invalidate();
|
||||
setTimeout(() => {
|
||||
answering.current = false;
|
||||
nextQuestion();
|
||||
}, ok ? 500 : 1300);
|
||||
advance.current = setTimeout(
|
||||
() => {
|
||||
answering.current = false;
|
||||
if (round.n >= ROUND) {
|
||||
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 (
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>읽기 연습</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="panel-b">
|
||||
<div className="topics">
|
||||
<div className="topics drill-modes">
|
||||
{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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{finished ? (
|
||||
<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>
|
||||
{round && question ? (
|
||||
<div className="drill">
|
||||
<div className="drill-meta tnum">
|
||||
<span>
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className={`prompt ko${question.big ? " word" : " line"}`}>{question.prompt}</div>
|
||||
{question.hint && <div className="drill-hint">{question.hint}</div>}
|
||||
|
||||
<div className={`opts${question.wide ? " wide" : ""}`}>
|
||||
{question.options.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
className={/[가-힣]/.test(o) ? "ko" : undefined}
|
||||
data-mark={
|
||||
picked ? (o === question.answer ? "ok" : o === picked ? "no" : undefined) : undefined
|
||||
}
|
||||
disabled={Boolean(picked)}
|
||||
onClick={() => void answer(o)}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="drill-after ko">{picked && question.after ? `규칙 · ${question.after}` : ""}</div>
|
||||
</div>
|
||||
) : (
|
||||
question && (
|
||||
<div className="drill">
|
||||
<div className="drill-meta">
|
||||
<span className="eyebrow">
|
||||
Question {round.n + 1} of {ROUND}
|
||||
</span>
|
||||
{picked && question.after && (
|
||||
<span className="eyebrow">규칙 · {question.after}</span>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<div className={`prompt ko serif${question.big ? " word" : " line"}`}>
|
||||
{question.prompt}
|
||||
</div>
|
||||
|
||||
<div className={`opts${question.wide ? " wide" : ""}`}>
|
||||
{question.options.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
className={/[가-힣]/.test(o) ? "ko" : undefined}
|
||||
data-mark={
|
||||
picked
|
||||
? o === question.answer
|
||||
? "ok"
|
||||
: o === picked
|
||||
? "no"
|
||||
: undefined
|
||||
: undefined
|
||||
}
|
||||
disabled={Boolean(picked)}
|
||||
onClick={() => void answer(o)}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="drill-intro">{INTRO[mode]}</p>
|
||||
)}
|
||||
<button className="btn primary big" onClick={start}>
|
||||
{summary ? "Another round" : "Start round"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
appears. Korean puts the verb at the end, and that is the single habit a
|
||||
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 { useStore } from "../../state/store.js";
|
||||
import { deck, type CardStatus } from "../../domain/cards.js";
|
||||
import sentencesJson from "@data/sentences.json";
|
||||
import sfxJson from "@data/sfx.json";
|
||||
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() {
|
||||
const { db, revision } = useStore();
|
||||
const { start } = useReview();
|
||||
const [level, setLevel] = useState<string>("all");
|
||||
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(
|
||||
() => FILE.sentences.filter((s) => level === "all" || s.lvl === level),
|
||||
@@ -73,46 +109,61 @@ export function SentencesTab() {
|
||||
<>
|
||||
<div className="panel">
|
||||
<div className="panel-h">
|
||||
<h2 className="ko">서술어</h2>
|
||||
<span className="note">The ending word — the predicate</span>
|
||||
<h2>The ending word</h2>
|
||||
<span className="note ko">서술어 — the predicate</span>
|
||||
</div>
|
||||
<div className="panel-b lesson">
|
||||
<div>
|
||||
<p>
|
||||
Korean puts the predicate last. Whatever a sentence is about, the word that
|
||||
says what <em>happens</em> — or what something <em>is</em> — comes at the end,
|
||||
and everything else leans on it.
|
||||
Korean saves the verdict for last. Everything before the final word is <b>setup</b> —
|
||||
who, what, where, when — and the <b>last word carries the action or the state</b>.
|
||||
Read to the end of the bubble first, then work backwards.
|
||||
</p>
|
||||
<p style={{ marginTop: 10 }}>
|
||||
Read to the end first, then work backwards. That one habit does more for
|
||||
reading manhwa than any amount of vocabulary.
|
||||
<p>
|
||||
This is also why so much can vanish. <b className="ko">나</b> and{" "}
|
||||
<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>
|
||||
</div>
|
||||
<div className="diagram">
|
||||
<span className="eyebrow">One sentence, read to the end</span>
|
||||
{demo.map((s, i) => (
|
||||
<div key={i}>
|
||||
<Chunks s={s} />
|
||||
<div className="dg-en">{s.en}</div>
|
||||
<div className="dg-note">
|
||||
<b>→</b>
|
||||
<span>{s.en}</span>
|
||||
</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 className="panel">
|
||||
<div className="panel-h">
|
||||
<h2>문장</h2>
|
||||
<h2 className="ko">문장</h2>
|
||||
<span className="note tnum">{shown.length} sentences</span>
|
||||
</div>
|
||||
<div className="panel-b">
|
||||
<div className="toolbar">
|
||||
<div className="topics">
|
||||
<button aria-pressed={level === "all"} onClick={() => setLevel("all")}>
|
||||
전체 All
|
||||
<button className="ko tnum" aria-pressed={level === "all"} onClick={() => setLevel("all")}>
|
||||
전체 All · {FILE.sentences.length}
|
||||
</button>
|
||||
{levels.map((l) => (
|
||||
<button key={l} aria-pressed={level === l} onClick={() => setLevel(l)}>
|
||||
{l} · {FILE.levels[l]}
|
||||
<button key={l} className="tnum" aria-pressed={level === l} onClick={() => setLevel(l)}>
|
||||
{l} · {FILE.levels[l]} · {FILE.sentences.filter((s) => s.lvl === l).length}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -127,30 +178,40 @@ export function SentencesTab() {
|
||||
</div>
|
||||
|
||||
<div className="s-list">
|
||||
{shown.map((s) => (
|
||||
<div className="s-item" key={s.ko}>
|
||||
<button
|
||||
className="s-head"
|
||||
aria-expanded={open === s.ko}
|
||||
onClick={() => setOpen(open === s.ko ? null : s.ko)}
|
||||
>
|
||||
<span className="s-lvl">{s.lvl}</span>
|
||||
<span className="s-ko">
|
||||
<KoWithPredicate s={s} />
|
||||
</span>
|
||||
</button>
|
||||
{open === s.ko && (
|
||||
<div className="s-body">
|
||||
<Chunks s={s} />
|
||||
<div className="s-en">
|
||||
<span className="eyebrow">Meaning</span>
|
||||
{s.en}
|
||||
{shown.map((s) => {
|
||||
const status = sentenceStatus(s, chunks);
|
||||
return (
|
||||
<div className="s-item" key={s.ko}>
|
||||
<button
|
||||
className="s-head"
|
||||
aria-expanded={open === s.ko}
|
||||
onClick={() => setOpen(open === s.ko ? null : s.ko)}
|
||||
>
|
||||
<span className="s-lvl">{s.lvl}</span>
|
||||
<span className="s-ko">
|
||||
<KoWithPredicate s={s} />
|
||||
</span>
|
||||
<span className="s-st">
|
||||
<span className={`state ${status}`}>{STATUS_LABEL[status]}</span>
|
||||
</span>
|
||||
</button>
|
||||
{open === s.ko && (
|
||||
<div className="s-body">
|
||||
<Chunks s={s} />
|
||||
<div className="s-en">
|
||||
<span className="eyebrow">Meaning</span>
|
||||
{s.en}
|
||||
</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 className="panel">
|
||||
|
||||
@@ -32,28 +32,84 @@
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.cj-fb {
|
||||
margin-top: 13px;
|
||||
padding: 10px 12px;
|
||||
.cj-intro {
|
||||
max-width: 64ch;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
border-left: 3px solid var(--line2);
|
||||
background: var(--raise);
|
||||
color: var(--ink2);
|
||||
}
|
||||
|
||||
.cj-fb.ok {
|
||||
border-left-color: var(--jade);
|
||||
background: var(--jade-soft);
|
||||
color: var(--jade-ink);
|
||||
.cj-modes {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cj-fb.no {
|
||||
border-left-color: var(--jeok);
|
||||
background: var(--jeok-soft);
|
||||
color: var(--jeok);
|
||||
.cj-ask {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
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 {
|
||||
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 ──────────────────────────────────────────────────── */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 한글 — drill, syllable diagram, jamo tables, sound rules. */
|
||||
/* 한글 — the reading drill, the syllable diagram, jamo tables, sound rules. */
|
||||
|
||||
.drill {
|
||||
display: flex;
|
||||
@@ -8,11 +8,41 @@
|
||||
padding: 16px 0 6px;
|
||||
}
|
||||
|
||||
.drill-modes {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.drill-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
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 {
|
||||
@@ -82,6 +112,27 @@
|
||||
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 ────────────────────────────────────────────── */
|
||||
|
||||
.block-demo {
|
||||
|
||||
@@ -48,11 +48,33 @@
|
||||
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;
|
||||
font-family: var(--serif);
|
||||
font-size: 14px;
|
||||
color: var(--ink2);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink3);
|
||||
}
|
||||
|
||||
.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 ────────────────────────────────────────────────────── */
|
||||
@@ -78,6 +100,22 @@
|
||||
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 {
|
||||
font-family: var(--mono);
|
||||
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.*`
|
||||
* 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."];
|
||||
|
||||
export function isSyncableMetaKey(key) {
|
||||
|
||||
@@ -97,6 +97,20 @@ describe("meta", () => {
|
||||
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", () => {
|
||||
const r = resolve(
|
||||
"meta",
|
||||
|
||||
Reference in New Issue
Block a user