SLOPSHOPPER

turkish-practice

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

newpanebandcommand
v0.1.0no licenseupdated 2026-10-05arslan-charyyev/claude-mods/turkish-practice
A shopper browsing a rack in a slop shop
README

turkish-practice

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.

What it shows

  • The band above the prompt shows the card that is due, but only a card that takes one press: a new word, or an A to D choice. A translate card needs typing, so it waits for the pane, and the band shows how many wait. An answered card makes room for the next one when Claude's next answer ends, or at once on → Next. An unanswered card stays, so no card goes by unseen. ⏭ Skip skips a card for this session, ▾ Hide folds the band to one line with the card type, ▴ Show unfolds it, and ⤢ Open opens the pane. The band stays folded or unfolded across sessions until the next press.
  • /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.

The cards

Each item (a word or a sentence) moves through card types from easy to hard:

CardWhat I doItems
New wordRead the word, its meaning, and an examplewords
What does it mean?Pick the English meaning, A to Dwords with wrong options
Fill the gapPick the missing word, A to D. The options look alike, or only the context tells them apartitems with a cloze
Translate into TurkishType the Turkish sentenceall

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.

Data

The learning data stays in ~/.claude/turkish, outside this repository:

FileWritten byWhat it holds
lessons/<date>.mdthe daily lesson jobThe 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>.jsonthe daily lesson jobWords and sentences with checked wrong meanings and cloze gaps. A cards item replaces a lesson item with the same Turkish.
practice-log.jsonthis modEvery answer: the item, the card type, what I gave, what was expected, and whether it was right
practice-state.jsonthis modWhere 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.

Setup

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.

Files

FileWhat it does
hooks/register.jsThe hooks: loading, the band, the pane, the /tr command, and saving answers
hooks/srs.jsThe scheduler: items, card stages, intervals, and the next card
hooks/lesson.jsThe lesson parser and the translation grading
hooks/svg.jsThe progress ring
tests/claude plugin test suites, with shared fixtures in fixtures.ts

Test

claude plugin validate .
capped claude plugin test
Source 4 files
hooks/register.js 650 lines
1// 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}
650
hooks/lesson.js 165 lines
1// 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}
165
hooks/svg.js 31 lines
1// 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}
31
hooks/srs.js 244 lines
1// 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