Spaced repetition (Learn and Review)
1. Foundational mental model
Section titled “1. Foundational mental model”Each country in a Learn pack is a card. The default countries pack is gameCountries (205 flags); learn-packs.ts also defines sovereign, constituent, territory, and partially recognized packs. A card has two numbers that matter: repetitions (how many successful steps in a row) and interval (days until the next review). The player sees a flag, recalls the name, reveals it, and grades the recall as Again, Good, or Easy. One pure function, calculateNextReview, turns the grade into the next (repetitions, interval, nextReviewDate).
There is no per-card ease factor. The growth rate is a constant per grade: Good multiplies the interval by 2.5, Easy by 4.0, and Again resets the card. Every interval is rounded to whole days and capped at 180.
On the Good path, the multi-day stage starts at repetitions = 3. Easy skips ahead: it sets repetitions = 2 with a 4- or 7-day interval, so the next Good or Easy already multiplies. The dashboard calls repetitions ≥ 3 mastered and 1–2 learning. A card after Again has the same numbers as a fresh card (repetitions = 0, interval = 0) but is due in 10 minutes instead of now.
Interactive
Learn grade sandbox
Runs applyLearnReview() from @flags/shared. Each grade is applied at the card's due time, so the interval you see is the one the next session would use.
Japan
New
- Repetitions
- 0
- Interval
- now
- Grades
- 0
Good on a new card stays at 10 minutes. The second Good is 1 day, then the interval grows by 2.5. Easy jumps to 4 days, then multiplies by 4, capped at 180 days. Again always returns here.
Learn and Review are two modes of the same /learn page. There is no separate /review route. Learn mixes unseen flags with due ones. Review draws only due cards the player has already seen.
The browser holds the working copy in localStorage under spaced-repetition-v2. Signed-in players also send each grade to Convex as an event. Convex replays the event through the same shared function and keeps its own copy of every card, which other devices pull down and merge.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”The in-repo reference docs/reference/spaced-repetition.md matches the code on intervals, caps, and session sizes.
3. Concrete implementation
Section titled “3. Concrete implementation”The scheduler
Section titled “The scheduler” if (rating === "good") { if (repetitions === 0) { repetitions = 1; interval = 0; return { ...card, repetitions, interval, nextReviewDate: now + 10 * 60 * 1000, lastReviewDate: now, }; } if (repetitions === 1) { repetitions = 2; interval = 1; } else { repetitions += 1; interval = Math.max(1, Math.round(interval * 2.5)); } } else if (rating === "easy") { if (repetitions === 0) { repetitions = 2; interval = 4; } else if (repetitions === 1) { repetitions = 2; interval = 7; } else { repetitions += 1; interval = Math.max(1, Math.round(interval * 4.0)); } }Again short-circuits before this block: repetitions: 0, interval: 0, nextReviewDate: now + LEARN_AGAIN_DELAY_MS. applyLearnReview wraps the scheduler and also sets firstSeenDate on the first grade and increments againCount or successCount.
calculateNextReview never looks at whether the card was due. A card graded early (for example from a focus list) grows from its stored interval exactly as if it had waited.
Building a session
Section titled “Building a session”LearnPageClient.svelte picks the target size from whether the player has any history (hasAnyActivity is false when there are no cards and no dailyLog entries): LEARN_CARDS_PER_FIRST_SESSION = 5 for the first session, LEARN_CARDS_PER_SESSION = 10 after that. getNewCards returns pool codes with no card at all. getDueCards returns codes with no card or with nextReviewDate <= now, soonest first.
export const getLearnSessionReviewCap = ( sessionTarget: number, reviewDueCount: number, availableNewCount: number): number => { if (reviewDueCount === 0) { return 0; } if (availableNewCount === 0) { return Math.min(reviewDueCount, sessionTarget); }
const preferredNew = Math.min( availableNewCount, Math.max(1, Math.ceil(sessionTarget * LEARN_NEW_SHARE)) ); const reviewCap = sessionTarget - preferredNew; return Math.min(reviewDueCount, Math.max(0, reviewCap));};With LEARN_NEW_SHARE = 0.7, a 10-card Learn session holds up to 3 due reviews and fills the rest with new flags. When fewer than 3 reviews are due, new flags take the spare slots. buildLearnSessionDeck then:
- removes unseen codes from the due list (so a flag is never counted twice),
- interleaves each pool by
country.region || country.continentwithinterleaveLearnQueue, so neighbours with similar flags are spread out, - slices each pool to its cap, and
- merges the two with
weaveLearnSessionParts(duePicks, newPicks, sessionTarget, 0.3).
Review mode skips steps 3 and 4: it returns interleaved due, already-seen cards, sliced to the target, or an empty deck.
The page has two more session kinds in learn-session-engine.ts. buildFocusSessionDeck takes the first 10 codes of a dashboard list (struggling or near-mastery). buildMistakeRoundDeck collects the unique codes graded Again in the session that just ended; the page offers it after the summary. Again does not put the card back into the running session.
Sync to Convex
Section titled “Sync to Convex” if (account.isAuthenticated) { learnSync.refreshPendingCount(); void syncLearnAfterReview(account, { eventId: crypto.randomUUID(), countryCode: currentCode, rating, reviewedAt, }); }syncLearnAfterReview appends the event to flags-learn-review-pending-v1 (skipping a duplicate eventId) and calls flushLearnReviewEvents, which sends the queue in slices of 50 until it is empty or a call fails. LearnReviewSync.svelte also runs syncLearnCloudState once per signed-in session and on every window focus: flush first, then pull learn.getLearnCardsSnapshot and merge.
const now = Date.now(); if (!(await tryConsumeLearnReviewBatchSlot(context, userId, now))) { return { applied: 0, consumed: 0 }; }
const events = args.events.slice(0, MAX_LEARN_REVIEW_EVENTS_PER_BATCH); await ensureRetentionSummary(context, userId);
let applied = 0; let consumed = 0; for (const event of events) { consumed += 1; const [existingEvent] = await context.db .query("learnReviewEvents") .withIndex("by_user_event", (query) => query.eq("userId", userId).eq("eventId", event.eventId) ) .take(1); if (existingEvent) { continue; }For each new event the mutation loads the learnCards row by by_user_country (or starts from createDefaultLearnCard(countryCode, reviewedAt)), applies applyLearnReview(base, rating, reviewedAt), patches or inserts the row, and records the event in learnReviewEvents. It returns { applied, consumed }. The client drops consumed events from the head of the batch; consumed === 0 with a non-empty batch means the throttle said no, and the flush stops with a failure status.
The merge rule on pull is last-review-wins per card, with ties going to the cloud:
for (const card of cloudCards) { const existing = cards[card.code]; if (!existing || (card.lastReviewDate ?? 0) >= (existing.lastReviewDate ?? 0)) { cards[card.code] = mapCloudCardToLocal(card); } }hydrateLearnStateFromCloud skips the pull when this user has already hydrated in this tab and nothing is pending. A forced pull (window focus) bypasses that, except within 5 seconds of the previous pull.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Interval recurrence
Section titled “Interval recurrence”Let be the interval in days after the -th successful grade on a card that started unseen. For Good,
and for Easy from an unseen card,
Math.round rounds halves up, so becomes 8 and is capped to 180. Without the cap and rounding, Good grows as ; the cap is hit when , i.e. .
Interval growth for repeated grades
Section titled “Interval growth for repeated grades”Produced by running applyLearnReview from createDefaultLearnCard and grading each time the card came due. “Due at” is days since the first grade.
| Grade # | Good: repetitions | Good: interval | Good: due at | Easy: repetitions | Easy: interval | Easy: due at |
|---|---|---|---|---|---|---|
| 1 | 1 | 10 min | 0.01 | 2 | 4 | 4 |
| 2 | 2 | 1 | 1.01 | 3 | 16 | 20 |
| 3 | 3 | 3 | 4.01 | 4 | 64 | 84 |
| 4 | 4 | 8 | 12.01 | 5 | 180 | 264 |
| 5 | 5 | 20 | 32.01 | 6 | 180 | 444 |
| 6 | 6 | 50 | 82.01 | 7 | 180 | 624 |
| 7 | 7 | 125 | 207.01 | 8 | 180 | 804 |
| 8 | 8 | 180 | 387.01 | 9 | 180 | 984 |
| 9 | 9 | 180 | 567.01 | 10 | 180 | 1164 |
Two consequences follow from the table. A card counts as mastered (repetitions ≥ 3) after three Good grades spread over about one day, long before it reaches the 180-day horizon. And an Again at any point returns the card to row 1: the next Good gives 10 minutes, then 1 day.
Session weave
Section titled “Session weave”weaveLearnSessionParts keeps a running credit. It starts at the due weight and adds before each pick when both pools still have cards; a due card is taken when the credit reaches 1 (and 1 is subtracted), otherwise a new card. With , the share of due cards while both pools last is , and the first due card appears third. Running buildLearnSessionDeck("learn", …) with 4 due and 20 new codes:
| Target | Deck |
|---|---|
| 10 | n0 n1 d1 n2 n3 d2 n4 n5 n6 d3 |
| 5 (first session) | n0 n1 d1 n2 n3 |
The function’s default is with a comment saying the first card should favour reviews; the only caller passes 0.3, which starts with new cards. The 5-card first session gets one due slot because .
Throttle arithmetic
Section titled “Throttle arithmetic”Every grade triggers one mutation, so the throttle is a per-user rate on grades as long as the queue stays short. 40 calls per 5 minutes is one call per 7.5 seconds on average. estimateSessionMinutes assumes 6 seconds per card, so a player moving faster than the estimate for several sessions in a row can hit the limit; the events then wait in the queue and go out in batches of up to 50 once the window slides.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Batches over 50, and grades during a flush.
flushLearnReviewEventsslices the queue toMAX_LEARN_REVIEW_EVENTS_PER_BATCH(50). After a successful call,submitLearnReviewBatchre-reads storage and writespendingEventsAfterSyncBatch(queue, batch, consumed), which dropsbatch.slice(0, consumed)byeventIdand leaves everything else. The loop then reads the queue again and sends the next slice.learn-cloud-sync.test.tscovers a trailing event past the submitted batch and an event enqueued while that batch was in flight. Two flushes in flight each remove only the ids their own response consumed. - Failed flush. A network error returns before the write. A throttle response with
consumed === 0isisLearnReviewSyncThrottledand also returns before the write, so the queue stays as it was. - Guest progress and the tie rule. A guest who reviewed a card locally and then signs in has no Convex row for it. The next signed-in grade builds the Convex card from
createDefaultLearnCard, with the samelastReviewDateas the local card. On the next pull, the tie goes to the cloud, and the local card is replaced by the fresh one (for examplerepetitions 6, interval 50becomesrepetitions 1, interval 0). This follows from the>=inmergeCloudCardsIntoState; no test covers it. - Client-supplied
reviewedAt. The server schedules from the timestamp the client sends. A modified client can move its own review dates. The only effect is on that player’s Learn schedule, since Learn awards no XP and has no leaderboard. - Replayed events.
eventIdis acrypto.randomUUID()and theby_user_eventindex makes a second submission a no-op that still counts as consumed. - Oversized batches. The mutation slices to 50 and reports only what it looked at in
consumed, so the client keeps the rest. - Corrupt local data.
readPendingEventsparses each entry and drops anything malformed. Cloud cards pass throughsanitizeLearnCardProgress, which rounds and clamps, so a badintervalcannot schedule a card 10 years out. - Clock skew. Due checks use
Date.now()on the device. A device clock set ahead makes cards due early; one set behind hides them. The server has no view of when a card is due.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why this one |
|---|---|---|
| Fixed multipliers (2.5 Good, 4.0 Easy), no ease factor | SM-2 per-card ease | docs/reference/spaced-repetition.md gives the reason as a simpler, more predictable model. It also means one less field to sync and merge. |
| Three grades | SM-2’s four (with Hard) | Same doc: fewer decisions per card in short daily sessions. |
| Hard cap at 180 days | Unbounded growth | Matches the documented mastered review horizon. A mastered flag still comes back about twice a year. |
| Local-first schedule, cloud as a replay of events | Server-authoritative schedule | Grading works offline and has no network latency. Events are idempotent and replay through the same shared function. |
| Event batches with a rolling-window throttle | One mutation per grade, unthrottled | Bounds writes per user. The cost is queued events when a player grades quickly. |
| Last-review-wins merge per card | Merging event histories | Simple and deterministic. It loses information when two devices graded the same card independently, and ties favour the cloud. |
| No XP for Learn | XP per grade | Grades are self-reported; tying XP to them would reward pressing Easy. |
Non-goals: a separate Review route, syncing streaks or the activity chart, typed-answer grading, per-card difficulty modelling, and importing guest progress into an account.