Turkish practice from the daily lesson files: a line under each answer, a band above the prompt, and a /tr drill pane

Turkish flashcards with spaced repetition, in the Claude Desktop Code tab. The cards come from my daily lessons, and every answer goes back to the lesson job, so the next lesson reacts to my mistakes.
It draws in the Desktop app only. In a terminal session it loads, but it draws nothing.
/tr opens a pane with two tabs. Practice shows card after card, translate cards included, with today's progress ring and counts. When the pane closes, a translate card with no answer goes back to wait. Lesson shows today's lesson, with the answers hidden until a press, and /tr lesson opens the pane at that tab.Each item (a word or a sentence) moves through card types from easy to hard:
| Card | What I do | Items |
|---|---|---|
| New word | Read the word, its meaning, and an example | words |
| What does it mean? | Pick the English meaning, A to D | words with wrong options |
| Fill the gap | Pick the missing word, A to D. The options look alike, or only the context tells them apart | items with a cloze |
| Translate into Turkish | Type the Turkish sentence | all |
A sentence gets no "What does it mean?" card, because with whole sentences as options the answer shows at a glance. Its choice card is "Fill the gap", with one word per option. A sentence from a cards file brings its own cloze. Two lesson sentences that differ in one word, such as the contrast pair gelmem / gelemem, give each other a cloze: the English is the hint, and the other word is a wrong option. Misspellings of the right word fill the options up to four, as a spelling check: one breaks the vowel harmony (gelmam), one has a Turkish letter without its dots or cedilla (icmem). A misspelling that is a real word in the lessons is never an option. A lesson sentence without such a partner goes straight to the translate card.
A right answer moves the item to the next card type, which comes back 10 minutes later. At the translate card, a right answer moves it to the next interval instead: 1, 3, 7, 21, then 60 days. A wrong answer moves it one card type back and brings it back in 3 minutes. Translate answers are graded word by word: missing Turkish letters (karanlik for karanlık) still count as right, and a different sentence asks me whether mine is right too, because Turkish word order is free.
The next card is the item that has been due longest. When nothing is due, a new item comes, at most 5 a day, only from the lessons and cards of the last 7 days: newest day first, in the order of its cards file.
The learning data stays in ~/.claude/turkish, outside this repository:
| File | Written by | What it holds |
|---|---|---|
lessons/<date>.md | the daily lesson job | The lesson. Its examples, contrast pair, drill with answers, and sentence of the day become sentence items. Their wrong meanings come from other sentences of the same lesson. |
cards/<date>.json | the daily lesson job | Words and sentences with checked wrong meanings and cloze gaps. A cards item replaces a lesson item with the same Turkish. |
practice-log.json | this mod | Every answer: the item, the card type, what I gave, what was expected, and whether it was right |
practice-state.json | this mod | Where each item stands: card type, interval, and wrong answers so far |
The lesson job reads the last two files to choose between a new lesson and a repeat, and to find my weak points. DAILY-LESSON-PROMPT.md in the data folder is the authority on the cards file format. In short:
{
"point": "4. Ability and inability",
"items": [
{ "kind": "word", "tr": "kira", "en": "rent", "example": { "tr": "Bu ay kirayı ödeyemem.", "en": "I can't pay the rent this month." }, "distractors": ["bill", "salary", "price"] },
{ "kind": "sentence", "tr": "Bu çayı içemem.", "en": "I can't drink this tea.", "distractors": ["I don't drink this tea.", "I can drink this tea.", "I didn't drink this tea."],
"cloze": { "text": "Bu çayı iç___.", "hint": "The tea is too hot.", "options": ["emem", "mem", "ebilirim", "medim"], "answer": "emem", "explain": "`iç-eme-m`: a limit." } }
]
}
The mod skips an item that has no tr or en, and drops a cloze without exactly one ___, with duplicate options, or without its answer among the options.
The scheduler state, the daily new-item count, and the log also live in the mod's own store, ~/.claude/plugins/store/turkish-practice_inline-*.json, which every session shares.
In ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/mnt/data/Dev/projects/linux/arslan-charyyev/claude-mods/turkish-practice",
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS turns mods on in Claude Code engines older than 2.1.287, such as 2.1.286 in Claude Desktop 2.19675.0. Version 2.1.287 and later ignore it, so remove it once the Desktop app ships such an engine. The Desktop app draws mods from 2.19675.0 on.
| File | What it does |
|---|---|
hooks/register.js | The hooks: loading, the band, the pane, the /tr command, and saving answers |
hooks/srs.js | The scheduler: items, card stages, intervals, and the next card |
hooks/lesson.js | The lesson parser and the translation grading |
hooks/svg.js | The progress ring |
tests/ | claude plugin test suites, with shared fixtures in fixtures.ts |
claude plugin validate .
capped claude plugin testhooks/register.js 650 lines1// turkish-practice: Turkish flashcards with spaced repetition, for the Desktop Code tab.
2// - The band above the prompt shows the card that is due; an answered card
3// moves on when Claude's next answer ends, or on Next. It shows only the
4// cards answered with one press: a typed card waits for the pane
5// - /tr opens a pane to practice card after card, and today's lesson
6// The learning data lives in ~/.claude/turkish: the lessons and cards the daily
7// lesson job writes, and the practice log and state this mod writes back.
8import { parseLesson, lessonBody, grade, localDate, shuffle } from './lesson.js'
9import { progressRing } from './svg.js'
10import { itemsFromLessons, itemsFromCards, mergeItems, pickCard, nextDue, answer, summary, itemId, stageFor, NEW_PER_DAY, DAY } from './srs.js'
11
12const PANE = 'turkish-drill'
13// New items come only from the lessons and cards of this many recent days
14const NEW_LOOKBACK = 7
15const LOG_LIMIT = 2000
16const RIGHT = '#3fa55a'
17const WRONG = '#e05252'
18const AMBER = '#c9962a'
19const LETTERS = ['A', 'B', 'C', 'D']
20
21let base = ''
22let loadedDate = ''
23let todayMd = ''
24let items = []
25let recentDates = new Set()
26let srs = {}
27let newToday = 0
28let dayStats = { right: 0, wrong: 0 }
29const skipped = new Set()
30
31// The card on show, shared by the band and the pane:
32// { item, stage, isNew, options?, result? }
33let card = null
34let tab = 'practice'
35let isAnswersShown = false
36// While the pane is closed, the band picks the cards: no typed cards then
37let isPaneOpen = false
38// The band folded to one line; every session shares the choice
39let isBandCollapsed = false
40
41async function load($) {
42 const home = await $.env.get('HOME')
43 base = home + '/.claude/turkish'
44 const now = await $.clock.now()
45 const today = localDate(now)
46
47 const lessonNames = (await $.fs.list(base + '/lessons'))
48 .filter((x) => x.kind === 'file' && /^\d{4}-\d{2}-\d{2}\.md$/.test(x.name) && x.name.slice(0, 10) <= today)
49 .map((x) => x.name)
50 .sort()
51 const lessons = []
52 for (const name of lessonNames) lessons.push(parseLesson(await $.fs.read(base + '/lessons/' + name), name.slice(0, 10)))
53 todayMd = lessonNames.length > 0 ? await $.fs.read(base + '/lessons/' + lessonNames[lessonNames.length - 1]) : ''
54
55 const cardFiles = []
56 if (await $.fs.exists(base + '/cards')) {
57 const cardNames = (await $.fs.list(base + '/cards'))
58 .filter((x) => x.kind === 'file' && /^\d{4}-\d{2}-\d{2}\.json$/.test(x.name) && x.name.slice(0, 10) <= today)
59 .map((x) => x.name)
60 for (const name of cardNames) {
61 try {
62 cardFiles.push({ ...JSON.parse(await $.fs.read(base + '/cards/' + name)), date: name.slice(0, 10) })
63 } catch (error) {
64 $.ui.log('skipped cards/' + name + ': ' + error.message)
65 }
66 }
67 }
68
69 items = mergeItems(itemsFromLessons(lessons), itemsFromCards(cardFiles))
70 const dates = [...new Set([...lessonNames.map((n) => n.slice(0, 10)), ...cardFiles.map((f) => f.date)])].sort()
71 recentDates = new Set(dates.slice(-NEW_LOOKBACK))
72
73 const saved = await $.store.get('srs')
74 srs = saved && typeof saved === 'object' ? saved : {}
75 // The prototype kept missed drill items in a list: they start as due translate cards
76 const missed = await $.store.get('missed')
77 if (Array.isArray(missed) && missed.length > 0) {
78 for (const m of missed) {
79 const id = itemId('sentence', m.tr)
80 if (!srs[id]) srs[id] = { stage: 'produce', box: 0, due: now, reps: 0, lapses: 1 }
81 }
82 await $.store.set('srs', srs)
83 await $.store.set('missed', [])
84 }
85
86 isBandCollapsed = (await $.store.get('bandCollapsed')) === true
87 const daily = await $.store.get('daily')
88 newToday = daily && daily.day === today ? daily.newCount : 0
89 const log = await $.store.get('log')
90 dayStats = statsFor(Array.isArray(log) ? log : [], today)
91 loadedDate = today
92 card = null
93 skipped.clear()
94 pickNext(now)
95}
96
97async function ensureFresh($) {
98 if (localDate(await $.clock.now()) !== loadedDate) await load($)
99}
100
101function statsFor(log, day) {
102 const todays = log.filter((x) => x.day === day && typeof x.isRight === 'boolean')
103 return { right: todays.filter((x) => x.isRight).length, wrong: todays.filter((x) => !x.isRight).length }
104}
105
106// The cards the band can show: the ones answered with one press
107function fitsBand(item, stage) {
108 return stageFor(item, stage) !== 'produce'
109}
110
111const fits = () => (isPaneOpen ? undefined : fitsBand)
112
113// Show a card: the options of a choice card are shuffled once, when it is shown
114function show(pick) {
115 if (!pick) {
116 card = null
117 return
118 }
119 const { item } = pick
120 const stage = stageFor(item, pick.stage)
121 const options =
122 stage === 'recognize' ? shuffle([item.en, ...item.distractors]) : stage === 'cloze' ? shuffle(item.cloze.options) : undefined
123 card = { item, stage, isNew: pick.isNew, options }
124}
125
126function pickNext(now) {
127 const open = items.filter((it) => !skipped.has(it.id))
128 show(pickCard(open, srs, now, newToday, (it) => recentDates.has(it.date), fits()))
129}
130
131// Practice ahead: the item that comes due next, at its own stage
132function practiceMore() {
133 const it = nextDue(items.filter((x) => !skipped.has(x.id)), srs, fits())
134 if (it) show({ item: it, stage: srs[it.id].stage, isNew: false })
135}
136
137// Save one answer: the item's new state, today's new-item count, and the log.
138// The store is read again right before each write, because other sessions share it.
139async function record($, c, isRight, given, verdict) {
140 const now = await $.clock.now()
141 const day = localDate(now)
142 const savedSrs = await $.store.get('srs')
143 const all = savedSrs && typeof savedSrs === 'object' ? savedSrs : {}
144 all[c.item.id] = answer(c.item, all[c.item.id], c.stage, isRight, now)
145 srs = all
146 await $.store.set('srs', all)
147
148 if (c.isNew) {
149 const daily = await $.store.get('daily')
150 newToday = (daily && daily.day === day ? daily.newCount : 0) + 1
151 await $.store.set('daily', { day, newCount: newToday })
152 }
153
154 const entry = {
155 at: new Date(now).toISOString(),
156 day,
157 id: c.item.id,
158 kind: c.item.kind,
159 card: c.stage,
160 lesson: c.item.date,
161 en: c.item.en,
162 expected: c.stage === 'cloze' ? wholeWordGap(c.item.cloze).word(c.item.cloze.answer) : c.stage === 'recognize' ? c.item.en : c.item.tr,
163 answer: given,
164 verdict,
165 ...(c.stage === 'intro' ? {} : { isRight }),
166 }
167 const savedLog = await $.store.get('log')
168 const log = [...(Array.isArray(savedLog) ? savedLog : []), entry].slice(-LOG_LIMIT)
169 await $.store.set('log', log)
170 dayStats = statsFor(log, day)
171
172 // Files beside the lessons, for the daily lesson job
173 await $.fs.write(base + '/practice-log.json', JSON.stringify(log, null, 2) + '\n')
174 const state = items
175 .filter((it) => all[it.id])
176 .map((it) => ({ id: it.id, kind: it.kind, tr: it.tr, en: it.en, lesson: it.date, ...all[it.id], due: new Date(all[it.id].due).toISOString() }))
177 await $.fs.write(base + '/practice-state.json', JSON.stringify(state, null, 2) + '\n')
178}
179
180async function openPane($) {
181 await $.ui.open({ id: PANE, title: 'Türkçe', focus: true, closeOnEscape: true })
182}
183
184// The pane has time for typed cards: once it is up, they come back into the picks,
185// and a card with no answer yet makes room for the item that has been due longest
186async function paneShown($) {
187 if (isPaneOpen) return
188 isPaneOpen = true
189 if (!card || !card.result) {
190 pickNext(await $.clock.now())
191 $.ui.invalidate('ui.render')
192 }
193}
194
195function untilText(ms) {
196 if (ms <= 0) return 'now'
197 const minutes = Math.round(ms / 60000)
198 if (minutes < 60) return 'in ' + minutes + ' min'
199 const hours = Math.round(minutes / 60)
200 if (hours < 24) return 'in ' + hours + ' h'
201 return 'in ' + Math.round(ms / DAY) + ' days'
202}
203
204// The line above a typed answer's result: [is it good news, the text]
205const HEADLINES = {
206 exact: [true, '✓ Doğru!'],
207 letters: [true, '≈ Right. Check the Turkish letters (ı ş ğ ç ö ü)'],
208 different: [false, '✗ Not quite. Compare the words'],
209 skipped: [false, 'The answer'],
210 variant: [true, '✓ Counted as right'],
211 wrong: [false, '✗ Counted as wrong. It comes back soon'],
212}
213
214const LABELS = { intro: 'New word', recognize: 'What does it mean?', cloze: 'Fill the gap', produce: 'Translate into Turkish' }
215
216// A cloze gap drawn as a whole word: a cards file may cut the gap inside a word
217// ("iç___." with the option "emem"), but each option shows as the whole word ("içemem")
218function wholeWordGap(z) {
219 const m = z.text.match(/([\p{L}'’]*)___([\p{L}'’]*)/u)
220 return { text: z.text.replace(m[0], '___'), word: (option) => m[1] + option + m[2] }
221}
222
223// The card itself, for the band (compact) and the pane (large).
224// Every control calls back into the module, so both drawings stay in step.
225function cardView($, el, isLarge) {
226 const { Box, Text, Button, Input, Markdown } = el
227 const redraw = () => $.ui.invalidate('ui.render')
228 const c = card
229 const it = c.item
230 const r = c.result
231 const gap = c.stage === 'cloze' ? wholeWordGap(it.cloze) : null
232 // The band's label line carries the flag already; the pane's does not
233 const flag = isLarge ? '🇹🇷 ' : ''
234 const heading = (text) => (isLarge ? Markdown({ key: 'tr-prompt', text: '### ' + text }) : Text({ bold: true, wrap: 'wrap', children: [text] }))
235 const row = (children) => Box({ flexDirection: 'row', columnGap: 1, children })
236 const next = () =>
237 Button({
238 key: 'tr-next',
239 label: '→ Next',
240 variant: 'primary',
241 autoFocus: true,
242 onPress: async () => {
243 pickNext(await $.clock.now())
244 redraw()
245 },
246 })
247 const note = () => (it.note ? [Markdown({ key: 'tr-note', text: it.note })] : [])
248 const verdictLine = (isRight, text) => Text({ bold: true, color: isRight ? RIGHT : WRONG, wrap: 'wrap', children: [text] })
249
250 // The options of a choice card, A to D; `shown` turns an option into its label
251 const choices = (correct, onChoose, shown = (option) => option) =>
252 Box({
253 flexDirection: isLarge ? 'column' : 'row',
254 flexWrap: 'wrap',
255 columnGap: 1,
256 rowGap: isLarge ? 1 : 0,
257 children: c.options.map((option, i) =>
258 Button({
259 key: 'tr-option-' + i,
260 label: LETTERS[i] + '. ' + shown(option),
261 variant: 'secondary',
262 onPress: async () => {
263 const isRight = option === correct
264 c.result = { isRight, given: option }
265 redraw()
266 await onChoose(option, isRight)
267 },
268 }),
269 ),
270 })
271
272 if (c.stage === 'intro') {
273 return [
274 heading(flag + it.tr + ' — ' + it.en),
275 ...(it.example ? [Text({ wrap: 'wrap', children: [it.example.tr] }), Text({ italic: true, dimColor: true, wrap: 'wrap', children: [it.example.en] })] : []),
276 ...note(),
277 row([
278 Button({
279 key: 'tr-got-it',
280 label: '✓ Got it',
281 variant: 'primary',
282 autoFocus: true,
283 onPress: async () => {
284 // Save first: the next pick must see this item as no longer new
285 await record($, c, true, '', 'seen')
286 pickNext(await $.clock.now())
287 redraw()
288 },
289 }),
290 ]),
291 ]
292 }
293
294 if (c.stage === 'recognize') {
295 if (!r) return [heading(flag + it.tr), choices(it.en, (option, isRight) => record($, c, isRight, option, isRight ? 'right' : 'wrong'))]
296 return [
297 heading(flag + it.tr),
298 verdictLine(r.isRight, r.isRight ? '✓ Doğru! ' + it.en : '✗ It means: ' + it.en),
299 ...note(),
300 row([next()]),
301 ]
302 }
303
304 if (c.stage === 'cloze') {
305 const z = it.cloze
306 const prompt = [
307 ...(z.hint ? [Text({ italic: true, dimColor: true, wrap: 'wrap', children: [z.hint] })] : []),
308 heading(gap.text),
309 ]
310 if (!r) return [...prompt, choices(z.answer, (option, isRight) => record($, c, isRight, gap.word(option), isRight ? 'right' : 'wrong'), gap.word)]
311 return [
312 ...prompt,
313 verdictLine(r.isRight, (r.isRight ? '✓ Doğru! ' : '✗ ') + z.text.replace('___', z.answer)),
314 ...(z.explain ? [Markdown({ key: 'tr-explain', text: z.explain })] : note()),
315 row([next()]),
316 ]
317 }
318
319 // produce: type the Turkish
320 const prompt = heading(it.en)
321 if (!r) {
322 return [
323 prompt,
324 Input({
325 key: 'tr-answer',
326 label: 'Türkçe',
327 placeholder: 'Type the Turkish sentence',
328 value: '',
329 submitLabel: 'Check',
330 ...(isLarge ? { autoFocus: true } : {}),
331 onSubmit: async (value) => {
332 if (!value.trim()) return
333 const g = grade(value, it.tr)
334 c.result = { given: value.trim(), ...g, isPending: g.verdict === 'different' }
335 redraw()
336 // A clear result is saved now; a different answer waits for the self-check
337 if (g.verdict !== 'different') await record($, c, true, value.trim(), g.verdict)
338 },
339 }),
340 row([
341 Button({
342 key: 'tr-show',
343 label: 'Show the answer',
344 variant: 'secondary',
345 onPress: async () => {
346 c.result = { given: '', verdict: 'skipped', words: grade('', it.tr).words, isRight: false }
347 redraw()
348 await record($, c, false, '', 'skipped')
349 },
350 }),
351 ]),
352 ]
353 }
354 const selfCheck = async (isRight) => {
355 c.result = { ...r, verdict: isRight ? 'variant' : 'wrong', isPending: false }
356 redraw()
357 await record($, c, isRight, r.given, isRight ? 'variant' : 'wrong')
358 }
359 // The expected sentence once, as written; after a typed answer the words that differ are colored
360 const sentence = Box({
361 key: 'tr-expected',
362 flexDirection: 'row',
363 flexWrap: 'wrap',
364 columnGap: 1,
365 children: r.words.map((w) =>
366 r.given && w.status !== 'ok'
367 ? Text({ bold: true, color: w.status === 'letters' ? AMBER : WRONG, children: [w.raw] })
368 : Text({ bold: true, children: [w.raw] }),
369 ),
370 })
371 const [isGood, headline] = HEADLINES[r.verdict]
372 return [
373 prompt,
374 verdictLine(isGood, headline),
375 ...(r.given ? [Text({ dimColor: true, wrap: 'wrap', children: ['You wrote: ' + r.given] })] : []),
376 sentence,
377 ...note(),
378 row(
379 r.isPending
380 ? [
381 Button({ key: 'tr-variant', label: '✓ Mine is right too', variant: 'secondary', onPress: () => selfCheck(true) }),
382 Button({ key: 'tr-wrong', label: '✗ Mine is wrong', variant: 'primary', autoFocus: true, onPress: () => selfCheck(false) }),
383 ]
384 : [next()],
385 ),
386 ]
387}
388
389export function register(on) {
390 on('session.start', async ($, e, next) => {
391 try {
392 await load($)
393 } catch (error) {
394 $.ui.log('could not read the lessons: ' + error.message)
395 }
396 await $.command.register({
397 name: 'tr',
398 description: 'Turkish flashcards and today\'s lesson',
399 argumentHint: '[lesson]',
400 immediate: true,
401 })
402 return next(e)
403 })
404
405 // When Claude's answer ends, an answered card makes room for the next one.
406 // An unanswered card stays, so no card is passed over unseen.
407 on('turn.complete', async ($, e, next) => {
408 const result = await next(e)
409 if (e.agentId || e.reason !== 'answer') return result
410 await ensureFresh($)
411 if (!card || (card.result && !card.result.isPending)) {
412 pickNext(await $.clock.now())
413 $.ui.invalidate('ui.render')
414 }
415 return result
416 })
417
418 on('command.run', { command: 'tr' }, async ($, e) => {
419 await ensureFresh($)
420 tab = e.args.trim() === 'lesson' ? 'lesson' : 'practice'
421 await openPane($)
422 await paneShown($)
423 $.ui.invalidate('ui.render')
424 return {}
425 })
426
427 // A typed card with no answer yet goes back to wait when the pane closes
428 on('ui.close', async ($, e, next) => {
429 const result = await next(e)
430 if (e.id !== PANE) return result
431 isPaneOpen = false
432 if (card && !fitsBand(card.item, card.stage) && !(card.result && card.result.isPending)) {
433 pickNext(await $.clock.now())
434 $.ui.invalidate('ui.render')
435 }
436 return result
437 })
438
439 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
440 const theirs = await next(e)
441 if (e.surface !== 'desktop' || items.length === 0) return theirs
442 const el = $.ui.resolve(e)
443 const { Box, Text, Button } = el
444 const redraw = () => $.ui.invalidate('ui.render')
445 const now = await $.clock.now()
446 // Small text buttons: a glyph and a word, as no Button has a tooltip
447 const icon = (key, label, onPress) => Button({ key, label, plain: true, dimColor: true, onPress })
448 const openIcon = icon('tr-open', '⤢ Open', async () => {
449 tab = 'practice'
450 await openPane($)
451 await paneShown($)
452 redraw()
453 })
454 const setCollapsed = async (value) => {
455 isBandCollapsed = value
456 redraw()
457 await $.store.set('bandCollapsed', value)
458 }
459 // Typed cards that are due: they wait for the pane
460 const typedDue = items.filter((it) => srs[it.id] && srs[it.id].due <= now && !fitsBand(it, srs[it.id].stage)).length
461
462 let band
463 if (!card || !fitsBand(card.item, card.stage)) {
464 // A typed card is on show only while the pane is up: the band points there
465 const soon = nextDue(items, srs, fitsBand)
466 const status = card
467 ? 'A translate card is open in the pane'
468 : typedDue > 0
469 ? typedDue + (typedDue === 1 ? ' translate card waits' : ' translate cards wait') + ' for the pane'
470 : soon
471 ? 'Next review ' + untilText(srs[soon.id].due - now)
472 : 'No cards yet'
473 band = Box({
474 flexDirection: 'row',
475 alignItems: 'center',
476 columnGap: 2,
477 children: [
478 Text({ bold: true, children: [card ? '🇹🇷 Translate into Turkish' : '🇹🇷 All caught up'] }),
479 Box({ flexGrow: 1, children: [Text({ dimColor: true, children: [status] })] }),
480 ...(soon && !card
481 ? [
482 Button({
483 key: 'tr-more',
484 label: 'Practice more',
485 variant: 'secondary',
486 onPress: () => {
487 practiceMore()
488 redraw()
489 },
490 }),
491 ]
492 : []),
493 openIcon,
494 ],
495 })
496 } else if (isBandCollapsed) {
497 band = Box({
498 flexDirection: 'row',
499 alignItems: 'center',
500 columnGap: 2,
501 children: [
502 Box({ flexGrow: 1, children: [Text({ dimColor: true, children: ['🇹🇷 ' + LABELS[card.stage] + (card.isNew ? ' · new' : '')] })] }),
503 icon('tr-expand', '▴ Show', () => setCollapsed(false)),
504 openIcon,
505 ],
506 })
507 } else {
508 band = Box({
509 flexDirection: 'column',
510 children: [
511 Box({
512 flexDirection: 'row',
513 alignItems: 'center',
514 justifyContent: 'space-between',
515 children: [
516 Text({ dimColor: true, children: ['🇹🇷 ' + LABELS[card.stage] + (card.isNew ? ' · new' : '')] }),
517 Box({
518 flexDirection: 'row',
519 columnGap: 2,
520 children: [
521 ...(card.result
522 ? []
523 : [
524 icon('tr-skip', '⏭ Skip', async () => {
525 skipped.add(card.item.id)
526 pickNext(await $.clock.now())
527 redraw()
528 }),
529 ]),
530 icon('tr-collapse', '▾ Hide', () => setCollapsed(true)),
531 openIcon,
532 ],
533 }),
534 ],
535 }),
536 ...cardView($, el, false),
537 ],
538 })
539 }
540 return theirs ? Box({ flexDirection: 'column', rowGap: 1, children: [band, theirs] }) : band
541 })
542
543 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
544 if (e.requestId !== PANE || e.surface !== 'desktop') return next(e)
545 // A pane that is up without /tr (a reload, a restore) counts as open too
546 await paneShown($)
547 const el = $.ui.resolve(e)
548 const { Box, Text, Button, Markdown, Svg } = el
549 const redraw = () => $.ui.invalidate('ui.render')
550 const now = await $.clock.now()
551 const counts = summary(items, srs, now)
552
553 const tabButton = (name, label) =>
554 Button({
555 key: 'tr-tab-' + name,
556 label,
557 variant: tab === name ? 'primary' : 'secondary',
558 onPress: () => {
559 tab = name
560 redraw()
561 },
562 })
563 const header = Box({
564 flexDirection: 'row',
565 alignItems: 'center',
566 justifyContent: 'space-between',
567 children: [
568 Box({ flexDirection: 'row', columnGap: 1, children: [tabButton('practice', 'Practice'), tabButton('lesson', 'Lesson')] }),
569 Box({
570 flexDirection: 'row',
571 alignItems: 'center',
572 columnGap: 1,
573 children: [
574 Svg({ source: progressRing(dayStats.right, dayStats.wrong), alt: 'Today: ' + dayStats.right + ' right, ' + dayStats.wrong + ' wrong', width: 44, height: 44 }),
575 Box({
576 flexDirection: 'column',
577 children: [
578 Text({ dimColor: true, children: [counts.due + ' due · ' + Math.max(0, NEW_PER_DAY - newToday) + ' new left'] }),
579 Text({ dimColor: true, children: [counts.learning + ' learning · ' + counts.known + ' known'] }),
580 ],
581 }),
582 ],
583 }),
584 ],
585 })
586
587 let body
588 if (tab === 'lesson') {
589 const i = todayMd.search(/^## Answers/m)
590 const answers = i === -1 ? '' : todayMd.slice(i)
591 body = [
592 Markdown({ key: 'tr-lesson', text: (lessonBody(todayMd) || 'No lesson file found.').slice(0, 10000) }),
593 ...(answers
594 ? [
595 Box({
596 flexDirection: 'row',
597 children: [
598 Button({
599 key: 'tr-answers',
600 label: isAnswersShown ? 'Hide answers' : 'Show answers',
601 variant: 'secondary',
602 onPress: () => {
603 isAnswersShown = !isAnswersShown
604 redraw()
605 },
606 }),
607 ],
608 }),
609 ]
610 : []),
611 ...(isAnswersShown && answers ? [Markdown({ key: 'tr-lesson-answers', text: answers.slice(0, 10000) })] : []),
612 ]
613 } else if (!card) {
614 const soon = nextDue(items, srs)
615 body = [
616 Markdown({
617 key: 'tr-done',
618 text: '### All caught up\n\n' + (soon ? 'The next review is due ' + untilText(srs[soon.id].due - now) + '.' : 'No cards yet. The lessons in `' + base + '` feed the cards.'),
619 }),
620 ...(soon
621 ? [
622 Box({
623 flexDirection: 'row',
624 children: [
625 Button({
626 key: 'tr-more',
627 label: 'Practice more',
628 variant: 'primary',
629 autoFocus: true,
630 onPress: () => {
631 practiceMore()
632 redraw()
633 },
634 }),
635 ],
636 }),
637 ]
638 : []),
639 ]
640 } else {
641 body = [
642 Text({ dimColor: true, children: [LABELS[card.stage] + (card.isNew ? ' · new' : '') + ' · from the ' + card.item.date + ' lesson'] }),
643 ...cardView($, el, true),
644 ]
645 }
646
647 return Box({ flexDirection: 'column', rowGap: 1, padding: 1, children: [header, ...body] })
648 })
649}
650hooks/lesson.js 165 lines1// Pure helpers: parse a daily lesson file and grade a drill answer.
2// No mods API here, so node and the test kit can both import this file.
3
4const SOTD = /🇹🇷\s*\*\*(.+?)\*\*\s*—\s*\*(.+?)\*/
5const BOLD_PAIR_NUMBERED = /^\d+\.\s+\*\*(.+?)\*\*\s+—\s+(.+)$/
6const BOLD_PAIR_BULLET = /^-\s+\*\*(.+?)\*\*\s+—\s+(.+)$/
7const NUMBERED = /^\d+\.\s+(.+)$/
8
9// Split a lesson into { heading: body } by its `## ` headings
10export function sections(md) {
11 const out = []
12 let current = { heading: '', lines: [] }
13 for (const line of md.split('\n')) {
14 if (line.startsWith('## ')) {
15 out.push(current)
16 current = { heading: line.slice(3).trim(), lines: [] }
17 } else {
18 current.lines.push(line)
19 }
20 }
21 out.push(current)
22 return out
23}
24
25// "Et yemem, sebze yerim. (`ye-me-m`: a choice.)" -> { tr, note }
26export function splitNote(text) {
27 const t = text.trim()
28 // Some lessons write the answer in bold: "**Bu ay kirayı ödeyemem.** (`kira-yı`: ...)"
29 const bold = t.match(/^\*\*(.+?)\*\*\s*(.*)$/)
30 if (bold) {
31 const rest = bold[2].trim()
32 return { tr: bold[1].trim(), note: rest.startsWith('(') && rest.endsWith(')') ? rest.slice(1, -1).trim() : rest }
33 }
34 if (!t.endsWith(')')) return { tr: t, note: '' }
35 let depth = 0
36 for (let i = t.length - 1; i >= 0; i--) {
37 if (t[i] === ')') depth++
38 else if (t[i] === '(') {
39 depth--
40 if (depth === 0) {
41 const tr = t.slice(0, i).trim()
42 // A parenthesis inside the sentence itself, not a trailing note
43 if (!/[.?!]$/.test(tr)) return { tr: t, note: '' }
44 return { tr, note: t.slice(i + 1, -1).trim() }
45 }
46 }
47 }
48 return { tr: t, note: '' }
49}
50
51// Read one lesson file into sentences (Turkish with English) and drill items
52export function parseLesson(md, date) {
53 const sentences = []
54 const drill = []
55 let prompts = []
56 let answers = []
57
58 for (const { heading, lines } of sections(md)) {
59 if (/^Five examples/i.test(heading)) {
60 for (const line of lines) {
61 const m = line.match(BOLD_PAIR_NUMBERED)
62 if (m) sentences.push({ tr: m[1].trim(), en: m[2].trim(), kind: 'example', date })
63 }
64 } else if (/^The contrast/i.test(heading)) {
65 for (const line of lines) {
66 const m = line.match(BOLD_PAIR_BULLET)
67 if (m) sentences.push({ tr: m[1].trim(), en: m[2].trim(), kind: 'contrast', date })
68 }
69 } else if (/^Sentence of the day/i.test(heading)) {
70 const m = lines.join('\n').match(SOTD)
71 if (m) sentences.push({ tr: m[1].trim(), en: m[2].trim(), kind: 'sotd', date })
72 } else if (/^Production drill/i.test(heading)) {
73 prompts = lines.map((l) => l.match(NUMBERED)).filter(Boolean).map((m) => m[1].trim())
74 } else if (/^Answers/i.test(heading)) {
75 // The drill answers sit between a "Drill:" line and the next "Xxx:" label line
76 let inDrill = false
77 for (const line of lines) {
78 const label = line.trim()
79 if (/^Drill:?$/i.test(label)) inDrill = true
80 else if (/^[A-Z][\w -]*:$/.test(label)) inDrill = false
81 else if (inDrill) {
82 const m = line.match(NUMBERED)
83 if (m) answers.push(splitNote(m[1]))
84 }
85 }
86 }
87 }
88
89 // Pair a prompt with its answer only when the counts agree, so no answer lands on the wrong prompt
90 if (prompts.length > 0 && prompts.length === answers.length) {
91 prompts.forEach((en, i) => drill.push({ en, tr: answers[i].tr, note: answers[i].note, kind: 'drill', date }))
92 }
93 return { date, sentences, drill }
94}
95
96// The lesson text a learner reads first: everything above the answers
97export function lessonBody(md) {
98 const i = md.search(/^## Answers/m)
99 return (i === -1 ? md : md.slice(0, i)).trim()
100}
101
102// Lowercase with Turkish rules (İ -> i, I -> ı), drop punctuation and extra spaces.
103// An apostrophe joins its word ("İstanbul'a" -> "istanbula"), so a word stays one word.
104export function normalize(text) {
105 return text
106 .toLocaleLowerCase('tr')
107 .replace(/['‘’`]/g, '')
108 .replace(/[.,!?;:"“”()]/g, ' ')
109 .replace(/\s+/g, ' ')
110 .trim()
111}
112
113// The words of a sentence as written, without the punctuation at their edges
114function displayWords(text) {
115 return text
116 .split(/\s+/)
117 .map((w) => w.replace(/^[.,!?;:"“”()`]+|[.,!?;:"“”()`]+$/g, ''))
118 .filter(Boolean)
119}
120
121// Fold the Turkish letters to their ASCII look-alikes: "karanlık" -> "karanlik"
122export function fold(text) {
123 return text.replace(/[ıİçÇğĞöÖşŞüÜâÂîÎûÛ]/g, (c) => ({ ı: 'i', İ: 'i', ç: 'c', Ç: 'c', ğ: 'g', Ğ: 'g', ö: 'o', Ö: 'o', ş: 's', Ş: 's', ü: 'u', Ü: 'u', â: 'a', Â: 'a', î: 'i', Î: 'i', û: 'u', Û: 'u' })[c])
124}
125
126// Compare an answer with the expected sentence, word by word.
127// verdict: 'exact', 'letters' (right apart from Turkish letters), or 'different'.
128// Each expected word is 'ok', 'letters', or 'missing' in the answer.
129export function grade(answer, expected) {
130 const a = normalize(answer)
131 const x = normalize(expected)
132 const given = new Set(a.split(' '))
133 const givenFolded = new Set(a.split(' ').map(fold))
134 const shown = displayWords(expected)
135 const raw = expected.trim().split(/\s+/)
136 const keys = x.split(' ')
137 // Show each word as written ("Oda", not "oda") when the splits agree;
138 // `raw` also keeps the punctuation, to draw the sentence itself
139 const isAligned = shown.length === keys.length && raw.length === keys.length
140 const words = keys.map((key, i) => ({
141 word: isAligned ? shown[i] : key,
142 raw: isAligned ? raw[i] : key,
143 status: given.has(key) ? 'ok' : givenFolded.has(fold(key)) ? 'letters' : 'missing',
144 }))
145 const verdict = a === x ? 'exact' : fold(a) === fold(x) ? 'letters' : 'different'
146 return { verdict, words }
147}
148
149// YYYY-MM-DD in local time
150export function localDate(ms) {
151 const d = new Date(ms)
152 const pad = (n) => String(n).padStart(2, '0')
153 return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
154}
155
156// Fisher-Yates on a copy
157export function shuffle(list) {
158 const out = [...list]
159 for (let i = out.length - 1; i > 0; i--) {
160 const j = Math.floor(Math.random() * (i + 1))
161 ;[out[i], out[j]] = [out[j], out[i]]
162 }
163 return out
164}
165hooks/svg.js 31 lines1// Pure SVG builders for the Desktop pane. The app draws an Svg as an isolated
2// image, so the markup cannot read the app's theme: every color here is a
3// mid-tone that reads on both a dark and a light background.
4
5const FONT = "Inter, 'Segoe UI', Ubuntu, Roboto, system-ui, sans-serif"
6const RIGHT = '#3fa55a'
7const WRONG = '#e05252'
8const TRACK = 'rgba(128, 128, 128, 0.45)'
9const LABEL = '#8f8f8f'
10
11// Today's progress: a ring split into right (green) and wrong (red), the
12// number of answers in the middle; an empty grey ring before the first answer
13export function progressRing(right, wrong, size = 44) {
14 const r = size / 2 - 4
15 const c = 2 * Math.PI * r
16 const total = right + wrong
17 const cx = size / 2
18 const arc = (length, offset, color) =>
19 `<circle cx="${cx}" cy="${cx}" r="${r}" fill="none" stroke="${color}" stroke-width="5" stroke-linecap="butt" ` +
20 `stroke-dasharray="${length} ${c - length}" stroke-dashoffset="${-offset}" transform="rotate(-90 ${cx} ${cx})"/>`
21 const rightLen = total === 0 ? 0 : (c * right) / total
22 return (
23 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" font-family="${FONT}">` +
24 `<circle cx="${cx}" cy="${cx}" r="${r}" fill="none" stroke="${TRACK}" stroke-width="5"/>` +
25 (right > 0 ? arc(rightLen, 0, RIGHT) : '') +
26 (wrong > 0 ? arc(c - rightLen, rightLen, WRONG) : '') +
27 `<text x="${cx}" y="${cx + 5}" text-anchor="middle" font-size="14" font-weight="700" fill="${LABEL}">${total}</text>` +
28 `</svg>`
29 )
30}
31hooks/srs.js 244 lines1// Pure spaced-repetition logic: items, card stages, intervals, and the next card.
2// No mods API here, so node and the test kit can both import this file.
3//
4// An item is one thing to learn: a word or a sentence. It moves through card
5// stages from easy to hard. A right answer moves it one stage on; at the last
6// stage (translate) it moves to a longer interval instead. A wrong answer moves
7// it one stage back and brings it back soon.
8//
9// word: intro -> (recognize) -> (cloze) -> produce
10// sentence: (cloze) -> produce
11//
12// recognize: pick the meaning of a word (A-D), only when it has wrong options
13// cloze: pick the missing word (A-D), only when the item has a cloze
14// produce: type the Turkish for the English
15//
16// A sentence gets no meaning card: with whole sentences as options, the
17// answer shows at a glance. Its choice card is the cloze, one word per option.
18
19import { normalize } from './lesson.js'
20
21export const MINUTE = 60 * 1000
22export const DAY = 24 * 60 * MINUTE
23// Days until the next review after each right answer at the translate stage
24export const INTERVALS = [1, 3, 7, 21, 60]
25// A learning step: the item comes back later in the same day
26export const LEARN_STEP = 10 * MINUTE
27// A wrong answer: the item comes back within the same sitting
28export const RETRY_STEP = 3 * MINUTE
29// New items a day
30export const NEW_PER_DAY = 5
31
32export function itemId(kind, tr) {
33 return (kind === 'word' ? 'w:' : 's:') + normalize(tr)
34}
35
36const ALL_STAGES = ['intro', 'recognize', 'cloze', 'produce']
37
38export function stagesOf(item) {
39 return [
40 ...(item.kind === 'word' ? ['intro'] : []),
41 ...(item.kind === 'word' && item.distractors.length > 0 ? ['recognize'] : []),
42 ...(item.cloze ? ['cloze'] : []),
43 'produce',
44 ]
45}
46
47// The stage an item shows when its saved stage is not one of its own (a
48// sentence saved at the meaning card that this item no longer has): the next one it has
49export function stageFor(item, stage) {
50 const stages = stagesOf(item)
51 const at = ALL_STAGES.indexOf(stage)
52 return stages.find((x) => ALL_STAGES.indexOf(x) >= at) ?? stages[stages.length - 1]
53}
54
55// Sentence items from parsed lessons. Two sentences that differ in one word
56// (the contrast pair is one) give each other a cloze: that word, with the
57// other sentence's word as the wrong option, and the English as the hint.
58// Misspellings of the answer fill the options up to four.
59export function itemsFromLessons(lessons) {
60 const raw = []
61 for (const l of lessons) {
62 for (const s of [...l.sentences, ...l.drill]) {
63 // Lesson items come after a day's cards items: their order starts at 1000
64 raw.push({ kind: 'sentence', tr: s.tr, en: s.en, note: s.note ?? '', date: l.date, source: 'lesson', order: 1000 + raw.length })
65 }
66 }
67 const byId = new Map()
68 for (const it of raw) {
69 const id = itemId(it.kind, it.tr)
70 if (!byId.has(id)) byId.set(id, { ...it, id })
71 }
72 const all = [...byId.values()]
73 // Every word of the lessons: a misspelling that is a real word is never an option
74 const vocab = new Set(all.flatMap((it) => normalize(it.tr).split(' ')))
75 for (const it of all) {
76 it.distractors = []
77 const cloze = pairCloze(it, all, vocab)
78 if (cloze) it.cloze = cloze
79 }
80 return all
81}
82
83const EDGE = /^([^\p{L}]*)(.*?)([^\p{L}]*)$/u
84
85// The cloze from the sentences that differ from `it` in one word only, at the
86// place where most of them differ
87function pairCloze(it, all, vocab) {
88 const words = it.tr.split(/\s+/)
89 const byPlace = new Map()
90 for (const o of all) {
91 if (o.id === it.id || o.en === it.en) continue
92 const other = o.tr.split(/\s+/)
93 if (other.length !== words.length) continue
94 const diff = words.flatMap((w, i) => (normalize(w) === normalize(other[i]) ? [] : [i]))
95 if (diff.length !== 1) continue
96 const option = other[diff[0]].match(EDGE)[2]
97 if (!option) continue
98 byPlace.set(diff[0], [...(byPlace.get(diff[0]) ?? []), option])
99 }
100 if (byPlace.size === 0) return undefined
101 const [place, wrong] = [...byPlace.entries()].sort((a, b) => b[1].length - a[1].length || a[0] - b[0])[0]
102 const [, before, answer, after] = words[place].match(EDGE)
103 const options = [...new Set([answer, ...wrong])].slice(0, 4)
104 if (options.length < 2) return undefined
105 const taken = (w) => vocab.has(normalize(w)) || options.some((o) => normalize(o) === normalize(w))
106 options.push(...misspellings(answer, taken).slice(0, 4 - options.length))
107 const text = words.map((w, i) => (i === place ? before + '___' + after : w)).join(' ')
108 return { text, hint: it.en, options, answer }
109}
110
111// A vowel that breaks the vowel harmony, and a Turkish letter without its dots
112// or its cedilla (or with them where none belong)
113const HARMONY_SWAPS = { e: 'a', a: 'e' }
114const LETTER_SWAPS = { ı: 'i', i: 'ı', ş: 's', s: 'ş', ç: 'c', c: 'ç', ğ: 'g', g: 'ğ', ö: 'o', o: 'ö', ü: 'u', u: 'ü' }
115
116// Misspellings of a word, one letter off each, for the wrong options of a cloze.
117// The end of a word carries the suffixes, so the changes start there; the first
118// letter stays. One of each kind comes first, then the rest. `taken` rules out
119// a real word and an option the cloze already has.
120export function misspellings(word, taken) {
121 const letters = Array.from(word)
122 const variants = (swaps) => {
123 const out = []
124 for (let i = letters.length - 1; i > 0; i--) {
125 const swap = swaps[letters[i]]
126 if (!swap) continue
127 const v = [...letters.slice(0, i), swap, ...letters.slice(i + 1)].join('')
128 if (!taken(v) && !out.includes(v)) out.push(v)
129 }
130 return out
131 }
132 const harmony = variants(HARMONY_SWAPS)
133 const letter = variants(LETTER_SWAPS)
134 const first = [harmony[0], letter[0]].filter(Boolean)
135 return [...new Set([...first, ...harmony.slice(1), ...letter.slice(1)])]
136}
137
138// Items from the daily cards files (cards/<date>.json), which the lesson job
139// writes with checked wrong options and cloze gaps
140export function itemsFromCards(files) {
141 const out = []
142 for (const f of files) {
143 for (const [order, c] of (f.items ?? []).entries()) {
144 if (!c || (c.kind !== 'word' && c.kind !== 'sentence') || !c.tr || !c.en) continue
145 const cloze = validCloze(c.cloze) ? c.cloze : undefined
146 out.push({
147 id: itemId(c.kind, c.tr),
148 kind: c.kind,
149 tr: c.tr,
150 en: c.en,
151 note: c.note ?? '',
152 example: c.example?.tr && c.example?.en ? c.example : undefined,
153 distractors: Array.isArray(c.distractors) ? c.distractors.filter((d) => typeof d === 'string' && d !== c.en).slice(0, 3) : [],
154 ...(cloze ? { cloze } : {}),
155 date: f.date,
156 source: 'cards',
157 order,
158 })
159 }
160 }
161 return out
162}
163
164function validCloze(c) {
165 return (
166 c &&
167 typeof c.text === 'string' &&
168 c.text.includes('___') &&
169 Array.isArray(c.options) &&
170 c.options.length >= 2 &&
171 c.options.includes(c.answer) &&
172 new Set(c.options).size === c.options.length
173 )
174}
175
176// A cards item replaces a lesson item with the same Turkish, and keeps the
177// lesson's cloze when it has none of its own
178export function mergeItems(lessonItems, cardItems) {
179 const byId = new Map(lessonItems.map((it) => [it.id, it]))
180 for (const c of cardItems) {
181 const old = byId.get(c.id)
182 byId.set(c.id, old && old.cloze && !c.cloze ? { ...c, cloze: old.cloze } : c)
183 }
184 return [...byId.values()]
185}
186
187// The card to show now: the item that has been due longest, then a new item
188// while today's budget lasts (newest day first; within a day, the cards file's
189// order, then the lesson's).
190// `newAllowed` limits new items to the recent lessons, and `fits(item, stage)`
191// limits the card types (the band shows no typed cards).
192export function pickCard(items, state, now, newToday, newAllowed = () => true, fits = () => true) {
193 const due = items
194 .filter((it) => state[it.id] && state[it.id].due <= now && fits(it, state[it.id].stage))
195 .sort((a, b) => state[a.id].due - state[b.id].due)
196 if (due.length > 0) return { item: due[0], stage: state[due[0].id].stage, isNew: false }
197 if (newToday >= NEW_PER_DAY) return null
198 const fresh = items
199 .filter((it) => !state[it.id] && newAllowed(it) && fits(it, stagesOf(it)[0]))
200 .sort((a, b) => (a.date === b.date ? a.order - b.order : a.date < b.date ? 1 : -1))
201 if (fresh.length === 0) return null
202 return { item: fresh[0], stage: stagesOf(fresh[0])[0], isNew: true }
203}
204
205// The next item to come due, for "Practice more" and the "next review" line
206export function nextDue(items, state, fits = () => true) {
207 return items.filter((it) => state[it.id] && fits(it, state[it.id].stage)).sort((a, b) => state[a.id].due - state[b.id].due)[0] ?? null
208}
209
210// The new state of an item after one answer at one stage
211export function answer(item, prev, stage, isRight, now) {
212 const stages = stagesOf(item)
213 const i = Math.max(0, stages.indexOf(stage))
214 const base = prev ?? { stage, box: 0, due: now, reps: 0, lapses: 0 }
215 const reps = base.reps + 1
216 if (!isRight) {
217 // One stage back, but never back to the intro
218 const back = Math.max(stages[0] === 'intro' ? 1 : 0, i - 1)
219 return { ...base, stage: stages[back], box: 0, due: now + RETRY_STEP, reps, lapses: base.lapses + 1 }
220 }
221 if (i < stages.length - 1) {
222 // Still learning: the next, harder card later today
223 return { ...base, stage: stages[i + 1], due: now + (stage === 'intro' ? MINUTE : LEARN_STEP), reps }
224 }
225 // Translate stage: the next interval
226 const box = Math.min(base.box + 1, INTERVALS.length)
227 return { ...base, stage, box, due: now + INTERVALS[box - 1] * DAY, reps }
228}
229
230// Counts for the header: due now, learning, known
231export function summary(items, state, now) {
232 let due = 0
233 let learning = 0
234 let known = 0
235 for (const it of items) {
236 const s = state[it.id]
237 if (!s) continue
238 if (s.due <= now) due++
239 if (s.box === 0) learning++
240 else known++
241 }
242 return { due, learning, known }
243}
244