Skip to content

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

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.

The in-repo reference docs/reference/spaced-repetition.md matches the code on intervals, caps, and session sizes.

packages/shared/src/learning/spaced-repetition-core.ts
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.

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.

apps/web/src/lib/learn/spaced-repetition.ts
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:

  1. removes unseen codes from the due list (so a flag is never counted twice),
  2. interleaves each pool by country.region || country.continent with interleaveLearnQueue, so neighbours with similar flags are spread out,
  3. slices each pool to its cap, and
  4. 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.

apps/web/src/lib/learn/LearnPageClient.svelte
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.

apps/web/src/convex/learn.ts
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:

apps/web/src/lib/learn/learn-cloud-sync.ts
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.

Let IkI_k be the interval in days after the kk-th successful grade on a card that started unseen. For Good,

I1=0 (due in 10 min),I2=1,Ik+1=min⁡ ⁣(180, max⁡(1, round⁡(2.5 Ik)))(k≥2)I_1 = 0 \ (\text{due in 10 min}), \qquad I_2 = 1, \qquad I_{k+1} = \min\!\big(180,\ \max(1,\ \operatorname{round}(2.5\, I_k))\big) \quad (k \ge 2)

and for Easy from an unseen card,

I1=4,Ik+1=min⁡ ⁣(180, max⁡(1, round⁡(4.0 Ik)))(k≥1).I_1 = 4, \qquad I_{k+1} = \min\!\big(180,\ \max(1,\ \operatorname{round}(4.0\, I_k))\big) \quad (k \ge 1).

Math.round rounds halves up, so 2.5×3=7.52.5 \times 3 = 7.5 becomes 8 and 2.5×125=312.52.5 \times 125 = 312.5 is capped to 180. Without the cap and rounding, Good grows as Ik≈2.5 k−2I_k \approx 2.5^{\,k-2}; the cap is hit when 2.5 k−2>1802.5^{\,k-2} > 180, i.e. k−2>log⁡2.5180≈5.67k - 2 > \log_{2.5} 180 \approx 5.67.

Produced by running applyLearnReview from createDefaultLearnCard and grading each time the card came due. “Due at” is days since the first grade.

Grade #Good: repetitionsGood: intervalGood: due atEasy: repetitionsEasy: intervalEasy: due at
1110 min0.01244
2211.0131620
3334.0146484
44812.015180264
552032.016180444
665082.017180624
77125207.018180804
88180387.019180984
99180567.01101801164

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.

weaveLearnSessionParts keeps a running credit. It starts at the due weight ww and adds ww 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 w=0.3w = 0.3, the share of due cards while both pools last is ww, and the first due card appears third. Running buildLearnSessionDeck("learn", …) with 4 due and 20 new codes:

TargetDeck
10n0 n1 d1 n2 n3 d2 n4 n5 n6 d3
5 (first session)n0 n1 d1 n2 n3

The function’s default is w=0.7w = 0.7 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 ⌈5×0.7⌉=4\lceil 5 \times 0.7 \rceil = 4.

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. flushLearnReviewEvents slices the queue to MAX_LEARN_REVIEW_EVENTS_PER_BATCH (50). After a successful call, submitLearnReviewBatch re-reads storage and writes pendingEventsAfterSyncBatch(queue, batch, consumed), which drops batch.slice(0, consumed) by eventId and leaves everything else. The loop then reads the queue again and sends the next slice. learn-cloud-sync.test.ts covers 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 === 0 is isLearnReviewSyncThrottled and 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 same lastReviewDate as the local card. On the next pull, the tie goes to the cloud, and the local card is replaced by the fresh one (for example repetitions 6, interval 50 becomes repetitions 1, interval 0). This follows from the >= in mergeCloudCardsIntoState; 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. eventId is a crypto.randomUUID() and the by_user_event index 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. readPendingEvents parses each entry and drops anything malformed. Cloud cards pass through sanitizeLearnCardProgress, which rounds and clamps, so a bad interval cannot 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.
ChoiceAlternativeWhy this one
Fixed multipliers (2.5 Good, 4.0 Easy), no ease factorSM-2 per-card easedocs/reference/spaced-repetition.md gives the reason as a simpler, more predictable model. It also means one less field to sync and merge.
Three gradesSM-2’s four (with Hard)Same doc: fewer decisions per card in short daily sessions.
Hard cap at 180 daysUnbounded growthMatches the documented mastered review horizon. A mastered flag still comes back about twice a year.
Local-first schedule, cloud as a replay of eventsServer-authoritative scheduleGrading works offline and has no network latency. Events are idempotent and replay through the same shared function.
Event batches with a rolling-window throttleOne mutation per grade, unthrottledBounds writes per user. The cost is queued events when a player grades quickly.
Last-review-wins merge per cardMerging event historiesSimple and deterministic. It loses information when two devices graded the same card independently, and ties favour the cloud.
No XP for LearnXP per gradeGrades 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.