Mnemonic is spaced-repetition flashcards that run entirely in the browser. Decks, cards, and review history live in IndexedDB, reads are live queries, there is no state store, and sync to a self-hosted server is opt-in. The scheduling algorithm is FSRS-5, through the ts-fsrs library, and the decision this post is about is where the scheduler’s state lives.
why fsrs
Most flashcard apps run SM-2, the algorithm from the 1980s. It adjusts one number per card, an ease factor, and assumes memory decays the same way for all material. FSRS separates two things SM-2 conflates: stability, how long a memory lasts, and difficulty, how hard the card is to retain. An easy card can be pushed much further out than a hard one without both moving by the same multiplier, and the model is fitted to review data rather than tuned by hand.
the state lives on the card
Most apps hide scheduling behind one derived field, the next review date. That makes the algorithm unauditable: when an interval looks wrong, there is nothing to inspect.
Cards here carry the scheduler’s full state inline: stability, difficulty, elapsed days, scheduled days, repetitions, lapses, learning step, state, a due timestamp, and the last review time. The scheduler’s inputs and outputs are in the database, and a wrong interval can be traced to the fields that produced it.
Two things follow from that. All four ratings are previewed with their resulting interval before the user commits, which is only possible because the card carries enough state to run the scheduler four times locally. There is no separate new-card queue: due <= now sorted by due is the whole session query, snapshotted into component state once per session so the list does not reorder under the user as they review.
Review logs are a separate, immutable table written on every rating, because FSRS mutates the card row in place. The heatmap and the totals read the log, so history survives a reschedule or a deck edit.
where the library and the ui disagree
Storing the real state surfaces exactly where the library’s model of a card differs from what a truthful interface has to show.
The manual rating is zero and is excluded from the library’s preview type, so indexing the scheduling result by rating needs a cast. The type is correct and the ergonomics are not.
scheduled_days reads zero for any card in the learning phase, because learning steps are sub-day. An interval shown to the user has to be computed from the due timestamp minus now, or every learning card claims it is due in zero days. That is the kind of bug that ships when the UI trusts a derived field.
Review logs store a local calendar date string rather than a UTC timestamp, so a review at 11:30 at night lands in the heatmap square the user experienced it in rather than the next day’s. The grid is Monday-anchored.
what it is not
There are no tests. Continuous integration gates on lint and type-check only, and the case study on this site says so rather than implying otherwise. The scheduler is configured at a 0.9 retention target, a 365-day maximum interval, and fuzz on. Sixteen hundred lines of TypeScript, one of the two oldest repositories in my current set, and the app I use to learn things.