This document describes the translation-based spaced repetition system, its data model, and study flow.
This document describes the translation-based spaced repetition system, its data model, and study flow.
translation row (a phrase
pair) produces two cards:
primary_to_secondarysecondary_to_primaryphrase rows.deck
deck_translation
deck and translation.srs_card
srs_review_log
srs_card
deck_id, translation_id, directionstate (new, learning, review, relearning)due_at, interval_days, ease, reps, lapses, step_indexlast_reviewed_at (timestamp of last review)suspended (boolean; suspended cards are excluded from review queues)stability, difficulty (nullable; reserved for future FSRS)srs_review_log
srs_card_id, deck_id, translation_id, directionreviewed_at, rating (failed, hard, good, easy)state_*, interval_*, ease_*, due_*dayStartHour), not at a specific time. This ensures cards are available throughout the entire day.maxNewPerDay - Maximum number of cards in 'new' state that can be seen for
the first time each daymaxReviewsPerDay - Maximum number of review sessions for cards in
'learning', 'review', or 'relearning' states each daydayStartHour (default: 4am).srs_review_log using the day start boundary:
state_before = 'new' in the review logstate_before != 'new' in the review loggetDailyLimitsRemaining() returns { newRemaining, reviewsRemaining, newDone, reviewsDone }suspended = true) are excluded from all review queues.The queue is built per deck following these rules:
New cards: Only shown if under the daily limit (maxNewPerDay)
due_at <= now)suspended = false)maxNewPerDay - newCardsReviewedTodaycreated_at ascending (oldest first)Review cards: Only shows cards that are actually due
due_at <= nowsuspended = false)maxReviewsPerDay - reviewsCompletedTodaydue_at ascending (most overdue first)Queue behavior:
due_at and then adjusted to maintain
minimum spacing between cards with the same translation_id (see
Translation Pair Separation section)Assume a deck with:
maxNewPerDay = 20, maxReviewsPerDay = 200Day 1 (First Session):
Day 1 (10+ minutes later):
Day 2:
Day 3:
This section reflects the current implementation in ReviewSessionScreen.tsx
and lib/srs/queue.ts.
The review session follows a specific lifecycle:
When the screen loses focus (user navigates away), all session state is reset
via useFocusEffect. This ensures a fresh session starts when the user returns,
incorporating any new due cards.
On mount (or re-focus), initializeSession() runs once:
initializeSession()
├── Compute tomorrowStartMs via getNextStudyDayStart(now, dayStartHour)
├── getDailyLimitsRemaining(db, { deckId, now, dayStartHour, maxNewPerDay, maxReviewsPerDay })
│ ├── Query srs_review_log for reviews since day start
│ ├── Count new cards reviewed (state_before = 'new')
│ ├── Count reviews completed (state_before != 'new')
│ └── Return { newRemaining, reviewsRemaining, newDone, reviewsDone }
├── getReviewQueue(db, { deckId, nowMs, reviewsRemaining, newRemaining })
│ ├── Fetch review cards (learning/review/relearning) where due_at <= now
│ ├── Fetch new cards where due_at <= now (up to newRemaining)
│ ├── Combine and pass through sortCardsMaintainingSeparation()
│ └── Return sorted queue
├── Store sessionCards and compute initial stats
├── countCardsStillDueToday(db, { cardIds, tomorrowStartMs })
└── Hydrate and display first card
Known limitation:
tomorrowStartMsis calculated once at session start and does not update during the session. If a user reviews across thedayStartHourboundary (e.g., starts at 3:50 AM and continues past 4:00 AM with default settings), the "due today" threshold becomes stale. This is acceptable because users rarely review across day boundaries, and the session resets when the screen loses focus.
loadNextCard(queue, startIndex) finds and displays the next due card:
loadNextCard(queue, startIndex)
├── If queue is empty → setCurrentItem(null), end session
├── getNextDueCardIndex({ queue, nowMs, startIndex })
│ └── Linear scan from startIndex, return first card where dueAt <= nowMs
├── If null and startIndex > 0 → Wrap around: getNextDueCardIndex({ queue, nowMs, startIndex: 0 })
│ └── Cards earlier in queue may have become due after being rescheduled
├── If still null → No due cards, setCurrentItem(null), end session
├── Re-fetch card from database (ensures fresh scheduling data)
├── hydrateCard(freshCard)
│ ├── Fetch translation, primary phrase, secondary phrase
│ ├── Determine front/back based on card.direction
│ └── Return { card, translation, front, back } or null on failure
├── If hydration fails → Remove card from queue, recursively try next
└── Set currentItem, showBack=false, update currentIndex
Key behaviors:
startIndex forward,
searches from index 0 to catch cards that became due earlier in the queueWhen the user rates a card, handleRate(rating) executes:
handleRate(rating)
├── scheduleSm2Review(cardState, rating, nowMs, dayStartHour)
│ ├── Compute new { state, dueAt, intervalDays, ease, reps, lapses, stepIndex }
│ └── For future days, use getFutureDayStartMs() to schedule at day start
├── db.write()
│ ├── Update srs_card with new scheduling fields
│ └── Create srs_review_log entry with before/after snapshots
├── Decide reinsertion:
│ ├── If update.dueAt < tomorrowStartMs → Re-fetch card from DB
│ └── Otherwise → Card exits queue (scheduled for tomorrow+)
├── applyCardRescheduleToQueue({ queue, currentCardId, refreshedCard, tomorrowStartMs, currentIndex })
│ ├── Remove current card from queue
│ ├── If still due today → insertCardMaintainingSeparation(queue, refreshedCard)
│ └── Compute nextStartIndex (accounting for queue shifts)
├── Update sessionCards with new queue
├── countCardsStillDueToday(db, { cardIds, tomorrowStartMs })
├── Update sessionStats.remainingToday
├── Track completed cards:
│ └── If update.dueAt >= tomorrowStartMs → Add card.id to completedCardIds Set
├── If remainingToday === 0 → Session complete, setCurrentItem(null)
└── Otherwise → loadNextCard(newQueue, nextStartIndex)
applyCardRescheduleToQueue() handles the complexity of maintaining correct
indices after queue mutations:
applyCardRescheduleToQueue({ queue, currentCardId, refreshedCard, tomorrowStartMs, currentIndex })
├── Remove current card: newQueue = queue.filter(c => c.id !== currentCardId)
├── If card still due today:
│ ├── insertCardMaintainingSeparation(newQueue, refreshedCard)
│ ├── Find reinsertedIndex
│ └── If reinsertedIndex <= currentIndex → nextStartIndex = currentIndex + 1
│ (Card inserted at/before our position, need to skip over it)
├── If nextStartIndex >= newQueue.length → Clamp (handled by wrap-around in loadNextCard)
└── Return { queue: newQueue, nextStartIndex }
To avoid showing both directions of a translation pair too close together (e.g., "dog → perro" then "perro → dog"), two algorithms maintain minimum spacing:
translation_id must be at least
MIN_CARD_SPACING (4) positions apart in the queue.sortCardsMaintainingSeparation(cards) - Initial queue sorting:
1. Sort cards by due_at ascending
2. Iterate looking for spacing violations (cards with same translation_id within MIN_CARD_SPACING positions)
3. For each violation found:
a. Look ahead up to searchRange (max(10, MIN_CARD_SPACING * 2)) positions for a swap candidate
b. Swap only if:
- Candidate has different translation_id
- Swap won't create new violations
- Urgency difference < 1 hour (maintains reasonable order)
c. If no forward candidate, look backward up to searchRange positions
d. Fallback: Swap forward even if it creates new violations (to make progress)
4. Repeat until no violations found or max iterations reached (queue.length * 3)
insertCardMaintainingSeparation(queue, card) - Reinsertion during session:
1. Find ideal position based on card.dueAt (maintain urgency order)
2. Check if insertion would violate spacing (same translation_id within MIN_CARD_SPACING positions)
3. If violation would occur:
a. Search forward for next valid position that maintains spacing
b. If no forward position found, search backward
c. Insert at first valid position found
4. If no valid position found (edge case: all cards have same translation_id) → Append to end
Note: The spacing algorithm prioritizes maintaining urgency order (by due_at)
while ensuring translation pairs are well-separated. If pairs are naturally far
apart temporally, they may appear closer in the queue, but this is acceptable
since they're not due at similar times anyway.
The session tracks:
totalInSession: Number of cards in the initial queueremainingToday: Cards still due before tomorrowStartMs (recalculated after
each rating)completedCardIds: Set of unique card IDs scheduled for tomorrow or latercompletedCardIds.size represents unique cards completed, not total reviews. A
card reviewed multiple times during learning steps only counts once when it
finally graduates to tomorrow.
database/schema.ts, database/migrations.tsdatabase/models/Deck.ts, database/models/DeckTranslation.ts,
database/models/SrsCard.ts, database/models/SrsReviewLog.tslib/srs/sm2.ts, lib/srs/queue.ts, lib/srs/time.tsfeatures/review/screens/ReviewHomeScreen.tsx,
features/review/screens/ReviewSessionScreen.tsx,
features/review/screens/DecksScreen.tsxfeatures/phrase/screens/PhraseDetailScreen.tsxsrs_review_log plus stability/difficulty fields to
migrate.