Loading post
Jul 21, 2026

I used to finish a technical article, nod along with every paragraph, and be unable to explain a single claim from it two weeks later. Not roughly unable — completely unable, as if the reading had happened to someone else.
This isn't a personal defect; it's the forgetting curve doing exactly what Ebbinghaus documented in 1885. Memory for new material decays exponentially, and passive re-reading barely dents the decay. What does dent it is well established: retrieve the material from your own head, at increasing intervals, right around the moment you're about to lose it. The science has been settled for decades. The problem is logistics — who has the discipline to hand-schedule hundreds of tiny review appointments with their own brain?
I don't. I have ADHD-ish traits, an evening study habit that competes with a payments dayjob, and a graveyard of abandoned Anki decks whose maintenance cost exceeded my willpower budget. So I did what I always do when willpower fails: I built a system. My personal study platform (Next.js + Firebase Functions in TypeScript + Firestore + LLM APIs) now runs a full learning loop — I capture what I learn, an LLM turns it into flashcards and dictionary terms, an SM-2 scheduler decides when I see each card again, and periodically a quiz generator ambushes me with questions synthesized from weeks of accumulated notes.
This post is the engineering write-up: the actual scheduling algorithm, the Firestore data model, the prompt design for card generation, and an honest section on what didn't work.
┌────────────────────────────────────────────┐
│ LEARNING ENTRY │
│ (notes from a book / video / article / │
│ work incident, in markdown) │
└───────┬──────────────┬─────────────────────┘
│ LLM │ LLM
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ FLASHCARDS │ │ DICTIONARY TERMS │
│ front / back / │ │ term / definition│
│ hint / tags │ │ / examples │
└────────┬────────┘ └────────┬─────────┘
│ │
▼ ▼
┌─────────────────────────────────────┐
│ REVIEW QUEUE (SM-2) │
│ nextReviewDate <= now → due │
│ [Again] [Hard] [Good] [Easy] │
└────────────────┬────────────────────┘
│ ratings update easeFactor,
│ interval, nextReviewDate
▼
┌─────────────────────────────────────┐
│ QUIZZES (from accumulated entries)│
│ multiple choice / short answer / │
│ code completion │
└────────────────┬────────────────────┘
│ weak spots
└──────▶ back to new cards / entries
Everything enters as a learning entry — my notes on whatever I just read, watched, or debugged. Entries fan out into flashcards and dictionary terms via LLM generation. Cards and terms flow into a review queue governed by spaced repetition. Quizzes cut across entries to test integration, not just recall. The loop closes when a failed quiz question tells me which entry needs new cards.
Three effects, each boring and enormously well-replicated:
The design consequences fall right out: the system must store per-item schedule state (spacing), the review UI must show a question before it shows the answer (recall), and the system should generate tests, not summaries (testing effect). Learning science is unusually kind to engineers: the theory translates directly into a schema and two endpoints.
Each flashcard is a Firestore document in a top-level flashcards collection, keyed by a userId field. The interesting part is that the SM-2 scheduling state is denormalized straight onto the card:
interface FlashcardDoc {
userId: string;
deckId?: string;
front: string; // question / prompt
back: string; // answer
frontType: 'text' | 'code';
backType: 'text' | 'code';
codeLanguage?: string;
hint?: string;
explanation?: string;
tags: string[];
// provenance — every card knows where it came from
sourceEntryId?: string; // learning entry that spawned it
dictionaryTermId?: string; // if generated from a glossary term
articleId?: string; // if generated from an article
// spaced repetition state (SM-2)
easeFactor: number; // starts at 2.5
interval: number; // days until next review
repetitions: number; // consecutive successful reviews
nextReviewDate: Timestamp;
reviewHistory: { date: Timestamp; difficulty: string; timeTaken: number }[];
createdAt: Timestamp;
updatedAt: Timestamp;
}
Three deliberate choices:
sourceEntryId, articleId, dictionaryTermId). When a card confuses me during review, one tap takes me back to my original notes with full context. This also enables dedup during generation, which I'll get to.reviewHistory embedded as an array. Every review appends {date, difficulty, timeTaken} via arrayUnion. For a personal tool the array stays small (even 200 reviews of one card is nothing), and having history on the document makes debugging the scheduler trivial: I can look at any card and replay why its interval is what it is.Decks are a thin organizational layer (name, flashcardIds, cached cardsDue counts), and dictionary terms are a parallel collection that shares the same scheduling fields, so terms and cards can flow through one review pipeline.
"What should I study right now?" compiles to:
const now = Timestamp.now();
let query = db.collection('flashcards')
.where('userId', '==', uid)
.where('nextReviewDate', '<=', now)
.limit(20);
if (deckId) query = query.where('deckId', '==', deckId);
if (categoryId) query = query.where('categoryId', '==', categoryId);
The endpoint runs the same query shape against the dictionary-terms collection and returns both, so a "mixed" session interleaves concept cards with vocabulary. New cards are born with interval: 0 and nextReviewDate: now, which means "new" is not a special case — a brand-new card is simply a card that is already due. I like this a lot: one query, one code path, no isNew flag to keep consistent.
The limit(20) is quietly load-bearing. It's not (only) a performance guard — it's a UX decision about session size. More on that in the failure section.
After I flip a card and grade myself, the client calls submitReview with one of four ratings — Again, Hard, Good, Easy — plus how long I took. The server maps the rating onto SM-2's 0–5 quality scale and updates the schedule:
const qualityMap = { again: 0, hard: 2, good: 4, easy: 5 };
function calculateSM2(
easeFactor: number, // how "easy" this card has proven historically
interval: number, // current interval in days
repetitions: number, // consecutive successes
difficulty: 'again' | 'hard' | 'good' | 'easy',
) {
const quality = qualityMap[difficulty];
// Ease drifts down on hard reviews, up slightly on easy ones.
// Floor of 1.3 keeps a card from entering an ever-shrinking death spiral.
let newEaseFactor =
easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
newEaseFactor = Math.max(1.3, newEaseFactor);
if (quality < 3) {
// Failure: reset the ladder. Card comes back today.
return { newEaseFactor, newInterval: 0, newRepetitions: 0 };
}
const newRepetitions = repetitions + 1;
const newInterval =
newRepetitions === 1 ? 1 : // first success: tomorrow
newRepetitions === 2 ? 6 : // second: in 6 days
Math.round(interval * newEaseFactor); // then multiply out
return { newEaseFactor, newInterval, newRepetitions };
}
The endpoint then does the bookkeeping:
const { newEaseFactor, newInterval, newRepetitions } =
calculateSM2(card.easeFactor ?? 2.5, card.interval ?? 1,
card.repetitions ?? 0, body.difficulty);
const nextReviewDate = new Date();
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval);
await docRef.update({
easeFactor: newEaseFactor,
interval: newInterval,
repetitions: newRepetitions,
nextReviewDate: Timestamp.fromDate(nextReviewDate),
lastReviewedAt: now,
reviewHistory: FieldValue.arrayUnion({ date: now, difficulty, timeTaken }),
});
A few notes on the algorithm's texture, because the numbers are more interesting than they look:
quality < 3 covers both Again (0) and Hard (2), so in my mapping Hard is a "soft fail" that resets the ladder. Anki treats Hard as a pass with a small multiplier; SM-2 purists treat 3 as the pass line. I've kept the strict version so far and it makes Hard a genuinely meaningful button — pressing it is admitting the card isn't stored yet.Is SM-2 the state of the art? No — FSRS and friends fit individualized forgetting curves and beat it in review efficiency. But SM-2 is thirty lines, needs no training data, has completely inspectable behavior, and gets you 90% of the benefit over "no system." For a personal tool, debuggability wins.
Writing good flashcards is a skill, and it's exactly the kind of tedious skilled labor that killed my Anki habit. So card authoring is delegated: I paste or link a learning entry, and a generation endpoint asks Claude for cards against a strict contract.
The system prompt encodes the flashcard-authoring rules I'd otherwise have to apply by hand:
You are an expert at creating effective flashcards for learning
technical concepts, following spaced repetition best practices:
PRINCIPLES:
1. Each card tests ONE atomic concept
2. Questions are specific and unambiguous
3. Answers are concise but complete
4. For code-related cards, use actual code examples
5. Vary the question types — and avoid yes/no questions entirely:
a card you can pass by coin flip teaches nothing
CARD TYPES:
- Definition: "What is X?" → definition
- Reverse: "What term describes …?" → the term
- Code: "What does this code do?" → explanation
- Comparison: "Difference between X and Y?" → key differences
- Application: "When would you use X?" → use cases
Respond with valid JSON only:
{ "flashcards": [ { "front": "...", "back": "...",
"frontType": "text|code", "backType": "text|code",
"codeLanguage": "...", "hint": "...", "explanation": "...",
"tags": ["..."] } ] }
The user prompt adds three parameters: the content, a difficulty level that maps to guidance text ("beginner: basic definitions and fundamentals" up through "expert: architectural decisions and nuanced trade-offs"), and — crucially — a list of existing terms to avoid duplicating:
const userPrompt = `Generate ${count} flashcards from the following content.
DIFFICULTY: ${difficulty}
${difficultyGuidance[difficulty]}
EXISTING TERMS (avoid duplicating these):
${existingTerms.join(', ')}
CONTENT:
${content}`;
That existingTerms line is my main defense against the biggest practical problem with LLM generation: it is not idempotent. Run generation twice on the same entry and you get two overlapping-but-differently-worded card sets; review both and you're wasting half your study time on near-duplicates. Feeding the terms already in my collection into the prompt turns "generate cards" into "generate cards I don't have." It's dedup at the semantic level, done by the only component in the system that understands semantics. (A stricter design would also hash sourceEntryId + content and refuse a second generation pass outright; I do the softer version and occasionally prune by hand.)
The response goes through the same defensive parsing as everything else LLM-shaped in my backend — strip accidental markdown fences, JSON.parse, verify the flashcards array exists, then normalize every card with fallback defaults (frontType: 'text', tags: []). Cards reach the client as proposals: I skim them, delete the duds, and only then are they saved with fresh SM-2 state (easeFactor: 2.5, interval: 0, nextReviewDate: now) and a sourceEntryId pointing home. Generation is cheap; polluting the review queue is expensive. The human belongs between those two steps.
Flashcards test atoms. But the failure mode of atomized knowledge is passing every card while being unable to combine them — knowing what an ease factor is and what an index is, yet freezing when asked to design the query the two imply. So the loop has a second assessment layer: quizzes generated from multiple learning entries at once.
The quiz endpoint takes a set of entry IDs, concatenates their titles, contents, and key takeaways into one context, and prompts for a mixed-format quiz:
Create questions that test understanding, not just memorization.
QUESTION TYPES: multiple_choice, true_false, short_answer, code_completion
GUIDELINES:
1. Test understanding, not just facts
2. Include scenario-based questions
3. Make incorrect options plausible but clearly wrong
4. Explain why each answer is correct/incorrect
Each question comes back with per-option explanations — the wrong answers teach too, which is the testing effect doing double duty. Two implementation notes that cost me an evening each:
lastIndexOf) rather than a lazy regex — a code_completion question can legitimately contain a ``` inside a field, and a non-greedy match will cut the document at it."Plausible but clearly wrong" turns out to be the highest-leverage line in the prompt. Without it you get distractors like "the database is deleted," which test nothing; with it, the model produces wrong answers adjacent to real misconceptions, and choosing among them is genuine retrieval work.
Worked: generation removed the habit-killing step. My Anki era died from card-authoring debt — unwritten cards piling up as guilt. Now the marginal cost of "this deserves cards" is one button. The collection grows in proportion to what I actually learn, not in proportion to my free evenings.
Worked: provenance links. Tapping through from a confusing card to the source entry resolves confusion in seconds, and quiz failures route me back to exactly the notes that need reinforcement. The graph structure (entry ↔ cards ↔ terms ↔ articles) is the quiet MVP of the schema.
Didn't: card quality variance. LLM cards cluster around "pretty good" with a stubborn tail of bad ones: fronts that leak the answer, backs that pack three facts onto one card (atomicity violation — the worst offense, because you can't grade a card you got two-thirds right), and trivia cards about incidental details no one needs to retain. The review-before-save step catches most of this, but it means generation is "drafting," not "authoring." My acceptance rate is maybe 80%, which is good enough to keep and not good enough to skip the skim.
Didn't: review debt pile-up. Spaced repetition has a brutal property — skipping days doesn't pause the system, it compounds it. After a two-week crunch at work I came back to a due-count in the hundreds, and a queue you dread is a queue you avoid, which is how every SRS habit dies. The limit(20) in the due query became my structural answer: a session is twenty items, full stop, and the backlog drains over days without any single crushing session. What I actually learned is that overdue-ness is not urgency — an overdue card is already forgotten or still remembered, and either way, reviewing twenty today beats reviewing zero because the number said 300.
Half-worked: four-button grading. Again/Hard/Good/Easy is the Anki-inherited standard, but under honest self-observation my Hard/Good boundary wobbles with mood, which injects noise into ease factors. If I rebuilt it, I'd consider collapsing to Again/Good (pass/fail), which is also what some modern schedulers prefer: fewer, cleaner signals.
reviewHistory has accumulated real data, I could fit per-card retention curves instead of SM-2's fixed formula — the data model already supports it, since history rides on every card.nextReviewDate by ±1 day to flatten the daily due histogram is a small change with big queue-morale returns.nextReviewDate field, active recall is front-before-back UI, the testing effect is a quiz generator. The theory was never the hard part; the logistics were, and logistics is what software is for.