Software development flash cards on a side board: three cards that flip on a press, written by the model only while the board is open, with Got it / Again…

A Claude Code mod: software development flash cards on a side board, written by the model on demand, with light spaced repetition.
Learn a term or two while Claude works: a one-line counter sits under the prompt band, and one key opens a board of three cards that flip on a press. Grade each card got it or again and it comes back when it should. Pick any topic (Kubernetes, Rust lifetimes, OAuth…) or stay on general software development.
Mods are an early-access Claude Code API (function hooks). The API may change between releases.
At the prompt of a terminal session:
/plugin install flashcards --marketplace rquintino/claude-code-xtras
Answer y to add the marketplace, then pick a scope (user is the usual). Hooks start in that session right away.
To run it from a clone instead, for one session:
claude --plugin-dir ./mods/flashcards
📇 cards 2 due · 5/12 learned · Kubernetes board
Due count (highlighted when > 0), cards learned out of the deck, the current topic, and the way to the board (f). Before the first deal the button reads start learning. It draws under whatever band sits above the prompt (the statusline-hud band included), whichever order Claude Code runs the mods in.
f on that line, or /cards, opens a side pane:
██████████░░░░░░░░░░ 5/12 learned · 2 due
✓ CAP theorem · back in 3d
Topic [ Kubernetes ] learn this general
╭────────────────────────────────────────────╮
│ ▾ Pod disruption budget │
│ reliability ●●○○○ │
│ │
│ Caps how many pods of a workload can be │
│ down at once during voluntary evictions. │
│ e.g. minAvailable: 2 keeps two replicas up │
│ while a node drains. │
│ │
│ ✓ got it ↺ again │
╰────────────────────────────────────────────╯
╭────────────────────────────────────────────╮
│ ▸ Readiness probe │
│ health checks ○○○○○ │
╰────────────────────────────────────────────╯
╭────────────────────────────────────────────╮
│ ▸ Taints and tolerations │
│ scheduling ●○○○○ │
╰────────────────────────────────────────────╯
new set close
1–3) to show its definition and an example. Several cards can be open at once.✓ CAP theorem · back in 3d) and the slot refills.Couldn't write a card: api-error 529 overloaded) with try again.| Key | Where | Does |
|---|---|---|
f | line under the band | open the board |
1 2 3 | board | flip that card |
g | board | ✓ got it on the first card turned over |
a | board | ↺ again on the first card turned over |
n | board | new set: three fresh cards |
x | board | close the board |
A keyboard run is 1, g, 2, a, … Focus the band with ctrl+x tab (or a click) to use its hotkey.
/cards command| Command | Does |
|---|---|
/cards | open the board (deals one if it is empty) |
/cards <topic> | open the board on that topic, e.g. /cards Rust lifetimes |
Each card has a place on a five-step ladder, shown as mastery dots ●●○○○.
| Action | Card returns in | Ladder |
|---|---|---|
| ✓ got it | 1d → 3d → 7d → 21d → 60d | one step up |
| ↺ again | 10 minutes | back to the bottom |
| never graded | 1 day | unchanged |
The model option (Haiku 5.5 by default) writes cards three per call, and only while the board is open:
Each batch is one small request (≤ 1,200 output tokens) on your own account.
{term, category, definition, example}; a card missing a field fails its slot with the reason.The prompts live in hooks/cards.ts (CARD_SYSTEM, cardPrompt, CATEGORIES).
/config (or /plugin configure flashcards@claude-code-xtras):
| Option | Values | Default |
|---|---|---|
model | the model that writes the cards | claude-haiku-5-5 |
claude plugin validate ./mods/flashcards # what it hooks and calls, what the engine would refuse
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test ./mods/flashcards # 10 tests: parsing, ladder, prompts, board on terminal and desktop, topics, errors
Files: hooks/cards.ts (pure helpers: prompts, parsing, ladder, deck), hooks/register.tsx (hooks and drawing), types/index.d.ts ($.state contract), tests/.
hooks/register.tsx 339 lines1// flashcards: a side board of three software development flash cards. Each flips on a press of
2// its term and is graded on its back (Got it climbs a review ladder, Again brings it back soon).
3// The model writes cards three per call, and only while the board is open: no request is made
4// at session start, none is written ahead with the board closed, and closing it cancels one in flight.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, PluginOptions, Register } from 'claude-code'
8
9import type { FlashCard, FlashSlot, FlashView } from '../types'
10import {
11 boardOf,
12 CARD_SYSTEM,
13 cardPrompt,
14 cleanTopic,
15 DECK_KEY,
16 deckCounts,
17 EMPTY_FLASH,
18 emptySlot,
19 fmtIn,
20 grade,
21 masteryDots,
22 parseCards,
23 pickCategories,
24 pickDue,
25 progressBar,
26 sameCard,
27 SLOTS,
28 withCard,
29} from './cards'
30
31const BOARD = 'flashcards'
32const BOARD_TITLE = 'Flash cards'
33const TOPIC_KEY = 'flash:topic'
34
35const board = atom({ plugin: 'flashcards', key: 'board' } as const, EMPTY_FLASH)
36
37type Opts = { model: string }
38
39function readOptions(o: PluginOptions): Opts {
40 return { model: typeof o.model === 'string' && o.model ? o.model : 'claude-haiku-5-5' }
41}
42
43// The module's working copy of the stored deck, the cards written ahead and the call in flight;
44// session.start rebuilds them, and it runs again on every reload.
45let deck: FlashCard[] = []
46let ahead: FlashCard[] = []
47let writing: Promise<void> | null = null
48let cancel: AbortController | null = null
49/** Whether the board is open: the only time the model is asked for cards. */
50let isBoardOpen = false
51
52/** Writes a batch of new cards into the queue; one batch in the works at a time. */
53function writeBatch($: EngineInterface, opts: Opts): Promise<void> {
54 writing ??= (async () => {
55 const f = await read($, board)
56 const call = new AbortController()
57 cancel = call
58 const r = await $.model.complete(
59 {
60 model: opts.model,
61 system: CARD_SYSTEM,
62 prompt: cardPrompt(pickCategories(SLOTS), deck, [...boardOf(f), ...ahead].map(c => c.term), f.topic),
63 maxTokens: 1200,
64 timeoutMs: 45_000,
65 },
66 { signal: call.signal },
67 )
68 if (!r.isAnswered) throw new Error(r.reason === 'api-error' ? `api-error ${r.status ?? ''} ${r.error}`.trim() : r.reason)
69 // a batch for a topic the person has since changed is dropped
70 if ((await read($, board)).topic !== f.topic) return
71 const cards = parseCards(r.text, await $.clock.now(), f.topic)
72 ahead = [...ahead, ...cards.filter(c => !deck.some(d => sameCard(d, c)) && !ahead.some(a => sameCard(a, c)))]
73 })().finally(() => {
74 writing = null
75 cancel = null
76 })
77 return writing
78}
79
80/** The next card for a slot: a due one not on the board, then the queue, else a fresh batch. */
81async function takeCard($: EngineInterface, opts: Opts): Promise<FlashCard> {
82 const f = await read($, board)
83 const due = pickDue(deck, await $.clock.now(), boardOf(f), f.topic)
84 if (due) return due
85 // two slots can drain one batch between them: a second batch, then give up
86 for (let tries = 0; tries < 2 && ahead.length === 0; tries++) await writeBatch($, opts)
87 const card = ahead.shift()
88 if (!card) throw new Error('the model wrote no card that is new to the deck')
89 return card
90}
91
92async function saveCard($: EngineInterface, card: FlashCard): Promise<void> {
93 deck = withCard(deck, card)
94 await $.store.set(DECK_KEY, deck)
95}
96
97async function setSlot($: EngineInterface, i: number, slot: Partial<FlashSlot>): Promise<void> {
98 await update($, board, f => ({ ...f, slots: f.slots.map((s, j) => (j === i ? { ...s, ...slot } : s)) }))
99}
100
101/** Turns card `i` over, from the state as it is now, not as it was last drawn. */
102async function flipSlot($: EngineInterface, i: number): Promise<void> {
103 await update($, board, f => ({ ...f, slots: f.slots.map((s, j) => (j === i ? { ...s, isFlipped: !s.isFlipped } : s)) }))
104}
105
106async function recount($: EngineInterface): Promise<void> {
107 const now = await $.clock.now()
108 await update($, board, f => ({ ...f, ...deckCounts(deck, now, boardOf(f)) }))
109}
110
111/** Puts a new card in slot `i`: loading while it is written, the reason when it could not be. */
112async function fillSlot($: EngineInterface, opts: Opts, i: number): Promise<void> {
113 await setSlot($, i, { status: 'loading', error: undefined, isFlipped: false })
114 try {
115 const card = await takeCard($, opts)
116 const known = deck.find(c => sameCard(c, card))
117 if (!known) await saveCard($, card)
118 await setSlot($, i, { status: 'idle', card: known ?? card })
119 } catch (err) {
120 await setSlot($, i, { status: 'error', error: err instanceof Error ? err.message : String(err) })
121 }
122}
123
124/** While the board is open and nothing is due, keeps a batch written ahead so the next cards land at once. */
125async function afterChange($: EngineInterface, opts: Opts): Promise<void> {
126 await recount($)
127 const f = await read($, board)
128 if (!isBoardOpen || ahead.length > 0 || writing || pickDue(deck, await $.clock.now(), boardOf(f), f.topic)) return
129 // written in the background: a failure here resurfaces on the next fill, which writes in the open
130 await writeBatch($, opts).catch(() => undefined)
131}
132
133/** Deals a full board: every slot gets a new card. */
134async function dealBoard($: EngineInterface, opts: Opts): Promise<void> {
135 await update($, board, f => ({ ...f, slots: Array.from({ length: SLOTS }, (_, i) => f.slots[i] ?? emptySlot()) }))
136 for (let i = 0; i < SLOTS; i++) await fillSlot($, opts, i)
137 await afterChange($, opts)
138}
139
140/** Sets the topic new cards are written about (empty: general); a change drops the cards written for the old one. */
141async function applyTopic($: EngineInterface, text: string): Promise<boolean> {
142 const topic = cleanTopic(text)
143 if (topic === (await read($, board)).topic) return false
144 cancel?.abort()
145 ahead = []
146 await update($, board, f => ({ ...f, topic, last: topic ? `Topic: ${topic}` : 'Topic cleared: general software development' }))
147 if (topic) await $.store.set(TOPIC_KEY, topic)
148 else await $.store.delete(TOPIC_KEY)
149 return true
150}
151
152async function setTopic($: EngineInterface, opts: Opts, text: string): Promise<void> {
153 if (await applyTopic($, text)) await dealBoard($, opts)
154}
155
156/**
157 * Opens the board with the keyboard, so the first click lands on a card; deals when it is empty
158 * or the topic changed.
159 */
160async function openBoard($: EngineInterface, opts: Opts, topic?: string): Promise<boolean> {
161 const opened = await $.ui.open({ id: BOARD, title: BOARD_TITLE, focus: true })
162 isBoardOpen = true
163 const isNewTopic = topic !== undefined && (await applyTopic($, topic))
164 if (isNewTopic || boardOf(await read($, board)).length === 0) await dealBoard($, opts)
165 return opened.isPlaced
166}
167
168async function rateSlot($: EngineInterface, opts: Opts, i: number, isKnown: boolean): Promise<void> {
169 const card = (await read($, board)).slots[i]?.card
170 if (!card) return
171 const now = await $.clock.now()
172 const graded = grade(card, isKnown, now)
173 await saveCard($, graded)
174 await update($, board, f => ({ ...f, last: `${isKnown ? '✓' : '↺'} ${card.term} · back in ${fmtIn(graded.dueAt - now)}` }))
175 await fillSlot($, opts, i)
176 await afterChange($, opts)
177}
178
179type Els = ReturnType<EngineInterface['ui']['resolve']>
180
181/** The line under the band: the deck's counts and the way to the board. */
182async function CardsLine($: EngineInterface, els: Els, opts: Opts) {
183 const { Box, Button, Text } = els
184 const f = await read($, board)
185 return (
186 <Box flexDirection="row" gap={1}>
187 <Text color="cyan">📇 cards</Text>
188 <Text color={f.due > 0 ? 'warning' : undefined} dimColor={f.due === 0}>{`${f.due} due`}</Text>
189 <Text dimColor>{`· ${f.learned}/${f.total} learned${f.topic ? ` · ${f.topic}` : ''}`}</Text>
190 <Button key="flash-open" label={f.slots.length > 0 ? 'board' : 'start learning'} hotkey="f" plain onPress={() => void openBoard($, opts)} />
191 </Box>
192 )
193}
194
195/** The board: a topic field, three cards that flip on a press of their term, graded on their back. */
196async function Board($: EngineInterface, els: Els, opts: Opts, columns: number) {
197 const { Box, Button, Text } = els
198 const f = await read($, board)
199 const w = Math.max(10, Math.min(30, columns - 24))
200 const pct = f.total > 0 ? (f.learned / f.total) * 100 : 0
201 // g and a grade the first card turned over, so a keyboard run is: 1, g, 2, a, ...
202 const graded = f.slots.findIndex(s => s.isFlipped && s.status === 'idle' && s.card)
203 return (
204 <Box flexDirection="column" gap={1}>
205 <Box flexDirection="column">
206 <Box flexDirection="row" gap={1}>
207 <Text color="success">{progressBar(pct, w)}</Text>
208 <Text>{`${f.learned}/${f.total} learned`}</Text>
209 <Text color={f.due > 0 ? 'warning' : undefined} dimColor={f.due === 0}>{`· ${f.due} due`}</Text>
210 </Box>
211 <Text dimColor italic>
212 {f.last ?? 'Press a term to flip its card.'}
213 </Text>
214 </Box>
215 {'Input' in els && (
216 <Box flexDirection="row" gap={1}>
217 <els.Input
218 key="flash-topic"
219 label="Topic"
220 placeholder="anything: Kubernetes, Rust lifetimes, OAuth… (empty: general)"
221 value={f.topic ?? ''}
222 submitLabel="learn this"
223 onSubmit={text => setTopic($, opts, text)}
224 />
225 {f.topic && <Button key="flash-topic-clear" label="general" plain onPress={() => setTopic($, opts, '')} />}
226 </Box>
227 )}
228 {f.slots.length === 0 && <Text dimColor>No cards yet: press new set.</Text>}
229 {f.slots.map((s, i) => (
230 <Box
231 key={`flash-card-${i}`}
232 flexDirection="column"
233 borderStyle="round"
234 borderColor={s.status === 'error' ? 'error' : s.isFlipped ? 'cyan' : undefined}
235 borderDimColor={!s.isFlipped && s.status !== 'error'}
236 paddingX={1}
237 >
238 {s.status === 'loading' ? (
239 <Text dimColor italic>
240 ✎ writing a card…
241 </Text>
242 ) : s.status === 'error' || !s.card ? (
243 <Box flexDirection="column">
244 <Text color="error" wrap="wrap">{`Couldn't write a card: ${s.error ?? 'no card'}`}</Text>
245 <Button key={`flash-retry-${i}`} label="try again" onPress={() => fillSlot($, opts, i)} />
246 </Box>
247 ) : (
248 <Box flexDirection="column">
249 <Button
250 key={`flash-flip-${i}`}
251 label={`${s.isFlipped ? '▾' : '▸'} ${s.card.term}`}
252 hotkey={String(i + 1)}
253 plain
254 hover={{ color: 'cyan', underline: true }}
255 onPress={() => flipSlot($, i)}
256 />
257 <Box flexDirection="row" gap={1}>
258 <Text dimColor>{s.card.category}</Text>
259 <Text color={s.card.box > 0 ? 'success' : undefined} dimColor={s.card.box === 0}>
260 {masteryDots(s.card.box)}
261 </Text>
262 </Box>
263 {s.isFlipped && (
264 <Box flexDirection="column" marginTop={1}>
265 <Text wrap="wrap">{s.card.definition}</Text>
266 <Text color="cyan" italic wrap="wrap">{`e.g. ${s.card.example}`}</Text>
267 <Box flexDirection="row" gap={1} marginTop={1}>
268 <Button key={`flash-got-${i}`} label="✓ got it" {...(i === graded ? { hotkey: 'g' } : {})} variant="primary" onPress={() => rateSlot($, opts, i, true)} />
269 <Button key={`flash-again-${i}`} label="↺ again" {...(i === graded ? { hotkey: 'a' } : {})} onPress={() => rateSlot($, opts, i, false)} />
270 </Box>
271 </Box>
272 )}
273 </Box>
274 )}
275 </Box>
276 ))}
277 <Box flexDirection="row" gap={1}>
278 <Button key="flash-deal" label="new set" hotkey="n" onPress={() => dealBoard($, opts)} />
279 <Button key="flash-close" label="close" hotkey="x" role="dismiss" onPress={() => $.ui.close({ id: BOARD })} />
280 </Box>
281 </Box>
282 )
283}
284
285export const register: Register = (on, options) => {
286 const opts = readOptions(options)
287
288 on('session.start', async ($, e, next) => {
289 await $.command.register({ name: 'cards', description: 'Flash cards: open the board of software development terms', argumentHint: '[topic]' })
290 const [stored, topic] = await Promise.all([$.store.get(DECK_KEY), $.store.get(TOPIC_KEY)])
291 deck = Array.isArray(stored) ? (stored as FlashCard[]) : []
292 ahead = []
293 isBoardOpen = false
294 // a card being written when the module reloaded is written again on the next fill
295 await update($, board, f => ({
296 ...f,
297 topic: typeof topic === 'string' ? topic : undefined,
298 slots: (f.slots ?? []).map(s => (s.status === 'loading' ? { ...s, status: 'idle' as const } : s)),
299 }))
300 await recount($)
301 $.clock.every(60_000, () => {
302 void (async () => {
303 const [now, f] = await Promise.all([$.clock.now(), read($, board)])
304 if (deckCounts(deck, now, boardOf(f)).due !== f.due) await recount($)
305 })().catch(() => undefined)
306 })
307 return next(e)
308 })
309
310 on('command.run', { command: 'cards' }, async ($, e) => {
311 const topic = e.args.trim()
312 const isPlaced = await openBoard($, opts, topic || undefined)
313 return { text: isPlaced ? `Flash card board opened${topic ? ` on ${topic}` : ''}.` : 'Flash card board queued: widen the terminal to place it.' }
314 })
315
316 // Closing the board, by its button or the person's close mark, stops the model: the call in flight is cancelled.
317 on('ui.close', { id: BOARD }, async ($, e, next) => {
318 isBoardOpen = false
319 cancel?.abort()
320 return next(e)
321 })
322
323 // The line goes under whatever the band above it draws (another mod's band included).
324 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
325 const above = await next(e)
326 if (e.props.hasSurvey) return above
327 const els = $.ui.resolve(e)
328 const { Box } = els
329 return (
330 <Box flexDirection="column">
331 {above}
332 {await CardsLine($, els, opts)}
333 </Box>
334 )
335 })
336
337 on('ui.render', { component: 'Pane', requestId: BOARD }, async ($, e) => Board($, $.ui.resolve(e), opts, e.props.bodyColumns))
338}
339hooks/cards.ts 140 lines1// Flash cards on a side board: software development terms the model writes in batches,
2// with light spaced repetition (Got it pushes a card out, Again brings it back soon).
3// Pure helpers; register.tsx owns the engine calls.
4
5import type { FlashCard, FlashSlot, FlashView } from '../types'
6
7export const DECK_KEY = 'flash:deck'
8export const KEEP_CARDS = 500
9/** Cards on the board, and cards the model writes per call. */
10export const SLOTS = 3
11const RECENT = 60
12const MIN = 60_000
13const DAY = 86_400_000
14/** Again: back in ten minutes. Unrated: tomorrow. Got it: the next step of the ladder. */
15const AGAIN_MS = 10 * MIN
16const UNRATED_MS = DAY
17const LADDER_MS = [DAY, 3 * DAY, 7 * DAY, 21 * DAY, 60 * DAY]
18
19export const CATEGORIES = [
20 'design patterns', 'data structures', 'algorithms', 'git and version control', 'testing',
21 'distributed systems', 'databases', 'application security', 'networking', 'concurrency',
22 'CI/CD and DevOps', 'cloud infrastructure', 'frontend', 'API design', 'operating systems',
23 'programming languages and compilers', 'LLM and AI engineering', 'software architecture',
24 'performance', 'observability',
25]
26
27export const CARD_SYSTEM =
28 'You write flash cards that teach software development terms to an experienced developer. ' +
29 'Reply with a JSON array and nothing else (no prose, no code fence), one object per card: ' +
30 '{"term": string, "category": string, "definition": string, "example": string}. ' +
31 'definition: plain language, at most 220 characters. example: a concrete usage, snippet or analogy, at most 160 characters. ' +
32 'Every term must be different.'
33
34export const EMPTY_FLASH: FlashView = { slots: [], due: 0, learned: 0, total: 0 }
35
36/** A topic as typed, trimmed and capped; empty means general. */
37export const cleanTopic = (text: string): string | undefined => text.trim().replace(/\s+/g, ' ').slice(0, 120) || undefined
38
39export const emptySlot = (): FlashSlot => ({ isFlipped: false, status: 'idle' })
40
41/** `count` different categories, in random order. */
42export function pickCategories(count: number, random: () => number = Math.random): string[] {
43 const pool = [...CATEGORIES]
44 return Array.from({ length: Math.min(count, pool.length) }, () => pool.splice(Math.floor(random() * pool.length), 1)[0]!)
45}
46
47/**
48 * The request for a batch of new cards, clear of the terms already studied: on the person's
49 * topic when they gave one, else one per category.
50 */
51export function cardPrompt(categories: readonly string[], deck: readonly FlashCard[], extra: readonly string[], topic?: string): string {
52 const recent = [...deck].sort((a, b) => b.seenAt - a.seenAt).slice(0, RECENT).map(c => c.term)
53 const avoid = [...recent, ...extra]
54 const ask = topic
55 ? `Write ${categories.length} flash cards about this topic: ${topic}. Each card covers a different idea within it; set category to the sub-area. `
56 : `Write ${categories.length} flash cards, one for each of these categories, in order: ${categories.join('; ')}. `
57 return (
58 ask +
59 'Pick terms worth knowing, from fundamentals to advanced.' +
60 (avoid.length ? ` Do not use any of these terms: ${avoid.join('; ')}.` : '')
61 )
62}
63
64function cardFrom(o: Record<string, unknown>, now: number, topic?: string): FlashCard {
65 for (const k of ['term', 'category', 'definition', 'example'] as const)
66 if (typeof o[k] !== 'string' || !(o[k] as string).trim()) throw new Error(`a card in the reply has no ${k}`)
67 return {
68 term: (o.term as string).trim(),
69 category: (o.category as string).trim(),
70 definition: (o.definition as string).trim(),
71 example: (o.example as string).trim(),
72 box: 0,
73 dueAt: now + UNRATED_MS,
74 seenAt: now,
75 ...(topic ? { topic } : {}),
76 }
77}
78
79/** The cards of the reply as the model wrote them, held to the fields asked for; a lone object is one card. */
80export function parseCards(text: string, now: number, topic?: string): FlashCard[] {
81 const list = text.indexOf('[')
82 const obj = text.indexOf('{')
83 const isList = list >= 0 && (obj < 0 || list < obj)
84 const start = isList ? list : obj
85 const end = text.lastIndexOf(isList ? ']' : '}')
86 if (start < 0 || end < start) throw new Error('the card reply holds no JSON')
87 const parsed = JSON.parse(text.slice(start, end + 1)) as unknown
88 const items = Array.isArray(parsed) ? parsed : [parsed]
89 const cards = items.map(o => cardFrom(o as Record<string, unknown>, now, topic))
90 return cards.filter((c, i) => cards.findIndex(d => sameCard(c, d)) === i)
91}
92
93/** Got it climbs the ladder; Again drops to the bottom and returns in ten minutes. */
94export function grade(card: FlashCard, isKnown: boolean, now: number): FlashCard {
95 if (!isKnown) return { ...card, box: 0, dueAt: now + AGAIN_MS, seenAt: now }
96 const box = card.box + 1
97 return { ...card, box, dueAt: now + LADDER_MS[Math.min(box, LADDER_MS.length) - 1]!, seenAt: now }
98}
99
100/** A card's place on the ladder as five dots, filled per Got it. */
101export const masteryDots = (box: number): string => '●'.repeat(Math.min(box, 5)) + '○'.repeat(5 - Math.min(box, 5))
102
103/** How long until a card is back: minutes, hours or days. */
104export function fmtIn(ms: number): string {
105 if (ms < 3_600_000) return `${Math.max(1, Math.round(ms / MIN))}m`
106 if (ms < DAY) return `${Math.round(ms / 3_600_000)}h`
107 return `${Math.round(ms / DAY)}d`
108}
109
110export const sameCard = (a: FlashCard, b: FlashCard) => a.term.toLowerCase() === b.term.toLowerCase()
111
112/** The cards on the board. */
113export const boardOf = (f: FlashView): FlashCard[] => f.slots.flatMap(s => (s.card ? [s.card] : []))
114
115/** The most overdue card not on the board; with a topic, only that topic's cards. */
116export function pickDue(deck: readonly FlashCard[], now: number, board: readonly FlashCard[] = [], topic?: string): FlashCard | undefined {
117 return deck
118 .filter(c => c.dueAt <= now && !board.some(b => sameCard(b, c)) && (!topic || c.topic === topic))
119 .sort((a, b) => a.dueAt - b.dueAt)[0]
120}
121
122/** The deck with `card` in it, replacing any card of the same term, newest kept. */
123export function withCard(deck: readonly FlashCard[], card: FlashCard): FlashCard[] {
124 return [...deck.filter(c => !sameCard(c, card)), card].slice(-KEEP_CARDS)
125}
126
127export function deckCounts(deck: readonly FlashCard[], now: number, board: readonly FlashCard[] = []) {
128 return {
129 due: deck.filter(c => c.dueAt <= now && !board.some(b => sameCard(b, c))).length,
130 learned: deck.filter(c => c.box > 0).length,
131 total: deck.length,
132 }
133}
134
135/** A progress bar `width` cells wide. */
136export function progressBar(pct: number, width: number): string {
137 const full = Math.round((Math.max(0, Math.min(100, pct)) / 100) * width)
138 return '█'.repeat(full) + '░'.repeat(width - full)
139}
140types/index.d.ts 45 lines1// State contract for the flashcards mod: every value it keeps in $.state.
2
3/** One flash card: a term the model wrote, with its place on the review ladder. */
4export type FlashCard = {
5 term: string
6 category: string
7 definition: string
8 example: string
9 /** 0: new or marked Again; each Got it climbs one step (1d, 3d, 7d, 21d, 60d). */
10 box: number
11 /** When the card is due for review again. */
12 dueAt: number
13 seenAt: number
14 /** The topic the person asked for when the card was written; absent for general cards. */
15 topic?: string
16}
17
18/** One place on the board. */
19export type FlashSlot = {
20 card?: FlashCard
21 isFlipped: boolean
22 status: 'idle' | 'loading' | 'error'
23 error?: string
24}
25
26/** What the board and its line under the band draw. */
27export type FlashView = {
28 slots: FlashSlot[]
29 /** The topic new cards are written about; absent: general software development. */
30 topic?: string
31 /** What the last grade did, e.g. `✓ CAP theorem · back in 3d`. */
32 last?: string
33 due: number
34 learned: number
35 total: number
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 flashcards: {
41 board: FlashView
42 }
43 }
44}
45