Spaced repetition: what the Leitner algorithm teaches about data structure

Kortex for Kotlin, Kafkex for Kafka, Vocably for English, Lingopack for Chinese: four apps with different content, but a single engine underneath, Leitner-style spaced repetition. What makes this problem interesting isn't the educational content, it's the data structure it imposes.
The principle, in one sentence
Each card belongs to a box (often numbered 1 to 5 or 7). A correct answer moves the card up a box; an incorrect one sends it back to box 1. The more advanced a box, the longer the interval before the next review. The system exploits gradual forgetting instead of fighting it: you only see a concept again just before you're about to forget it.
Box 1 Box 2 Box 3 Box 4 Box 5
(1 day) ──▶ (3 days) ──▶ (7 days) ──▶ (14 days) ──▶ (30 days)
▲ │ │ │ │
└───────────────┴───────────────┴───────────────┴───────────────┘
an incorrect answer always sends the card back to box 1
The further right a card progresses, the longer the interval before its next review. A single mistake is enough to send it back to zero: that mechanism, not the content, does all the work.
Why it isn't just a counter
The temptation, when modeling this naively, is to store just a box number per card. The problem shows up as soon as you want to answer simple product questions: how many cards are due today? what's a user's actual progress on a given topic? how do you handle several decks with different review rhythms without recalculating everything on every render?
The structure that works well is a dated event per card (last review, result, computed next due date), rather than a simple mutable state. That turns an in-place update problem into a timeline query problem, much easier to evolve: adding progress statistics, or changing the interval calculation algorithm, doesn't require migrating existing data.
Concretely, that produces a structure close to this, rather than a plain
box: number:
type ReviewEvent = {
cardId: string;
reviewedAt: Date;
result: "correct" | "incorrect";
boxAfter: number;
dueAt: Date; // computed from boxAfter
};
With this shape, "how many cards are due today" becomes a simple query
(dueAt <= now), and changing interval lengths only requires modifying the
calculation function, never the data already recorded.
Naive counter vs dated event, what each question costs
| Product question | With box: number | With a dated event per review |
|---|---|---|
| How many cards are due today? | Full recalculation on every render | A direct query (dueAt <= now) |
| What's the actual progress on a topic? | Requires recounting every time | Reading from the already-stored history |
| Changing interval lengths | Migrating every existing card | Editing the calculation function only |
| Adding progress statistics | No historical data available | Already present in the event history |
That last row is the one that justifies the choice in hindsight: a statistic nobody thought to expose on day one stays possible with an event history, while it's unrecoverable with a plain counter that overwrites its previous state on every review.
The real challenge: gamification without over-justification
On the apps built for my kids (in the same product family), the added constraint is motivating without a real reward: no exchangeable points, no badge worth anything outside the app. Spaced repetition helps here indirectly: seeing a card you thought you'd forgotten get answered right on the first try is intrinsic reward enough, if the algorithm is well calibrated to present cards at the right moment. A poorly calculated interval (too short, too long) ruins that effect and turns the app into a chore.
What generalizes from one app to the next
Kortex's revision engine, once extracted from its Kotlin-specific content, turned out to be directly reusable for Kafkex, then for Vocably and Lingopack. What changes from one app to the next is only the content (the cards) and the surface (language, visual theme): the progression logic is a problem solved once and for all. It's a good reminder that a product's real complexity isn't always where you expect it: here, it wasn't the educational content that was hard, it was the underlying data structure.