spaced-repetition
npx skills add https://github.com/jchaselubitz/drill-app --skill spaced-repetition
Agent 安装分布
Skill 文档
Spaced Repetition Architecture
This document describes the translation-based spaced repetition system, its data model, and study flow.
Overview
- The SRS system is translation-pair based. Each
translationrow (a phrase pair) produces two cards:primary_to_secondarysecondary_to_primary
- Cards store only scheduling state and foreign keys. The displayed text is
pulled live from
phraserows. - A deck contains translation pairs. Cards are scoped to a single deck.
Data Model (WatermelonDB)
Tables
deck- Stores user-created decks and a default deck.
deck_translation- Join table linking
deckandtranslation.
- Join table linking
srs_card- Scheduling state for each direction of a translation pair.
srs_review_log- Append-only review history for analytics and future FSRS upgrades.
Key Fields
srs_carddeck_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_logsrs_card_id,deck_id,translation_id,directionreviewed_at,rating(failed,hard,good,easy)- before/after snapshots:
state_*,interval_*,ease_*,due_*
Scheduling
- Scheduling is SM-2 inspired (Anki-aligned) with learning steps.
- Learning phase (new cards):
- Failed: 1 minute (resets to step 0)
- Hard: 5 minutes (stays at current step, doesn’t advance)
- Good: Single step of 10 minutes, then graduates to 1 day (after completing the step)
- Easy: 4 days (immediately graduates to review state)
- Relearning phase (after lapsing a review card):
- Failed: Reset to step 0 (10 minutes)
- Hard: Stay at current step (10 minutes)
- Good: Advance through steps, then return to review state with interval reset to max(1 day, previous interval)
- Easy: Immediately return to review state with interval reset to max(1 day, previous interval)
- Review phase:
- Failed: -0.20 ease penalty, interval resets to 1 day (via multiplier of 0), enters relearning
- Hard: -0.15 ease, interval à 1.2 (minimum 1 day)
- Good: interval à ease (no ease change, minimum 1 day)
- Easy: +0.15 ease, interval à ease à 1.3 (minimum 1 day)
- Ease factor defaults to 2.5 and is clamped to minimum 1.3 (matching Anki).
- Future day scheduling: Cards scheduled for future days (after graduating from learning or in review phase) are scheduled at the start of that day (
dayStartHour), not at a specific time. This ensures cards are available throughout the entire day. - Learning/relearning steps use precise timestamps to support intra-day intervals (minutes).
- Review history is logged for each rating event with before/after snapshots.
Daily Limits and Rollover
- Daily limits are per-deck:
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 day
- Day rollover is computed with a configurable
dayStartHour(default: 4am). - Counts are derived from
srs_review_logusing the day start boundary:- New cards are counted by
state_before = 'new'in the review log - Reviews are counted by
state_before != 'new'in the review log
- New cards are counted by
getDailyLimitsRemaining()returns{ newRemaining, reviewsRemaining, newDone, reviewsDone }- Important: Daily limits are applied when the session queue is initially built. Cards reinserted later in the same session (because they are still due today) are not re-checked against daily limits.
- Suspended cards (
suspended = true) are excluded from all review queues.
Study Queue
The queue is built per deck following these rules:
-
New cards: Only shown if under the daily limit (
maxNewPerDay)- Fetches cards in ‘new’ state that are due (
due_at <= now) - Excludes suspended cards (
suspended = false) - Limited to
maxNewPerDay - newCardsReviewedToday - Sorted by
created_atascending (oldest first)
- Fetches cards in ‘new’ state that are due (
-
Review cards: Only shows cards that are actually due
- Fetches cards in ‘learning’, ‘review’, or ‘relearning’ states where
due_at <= now - Excludes suspended cards (
suspended = false) - Limited to
maxReviewsPerDay - reviewsCompletedToday - Sorted by
due_atascending (most overdue first)
- Fetches cards in ‘learning’, ‘review’, or ‘relearning’ states where
-
Queue behavior:
- New and review cards are combined
- The combined list is sorted by
due_atand then adjusted to maintain minimum spacing between cards with the sametranslation_id(see Translation Pair Separation section) - During a session, the in-memory queue is updated in place (no automatic reload when exhausted)
Example Scenario
Assume a deck with:
- 44 cards in ‘new’ state
- Settings:
maxNewPerDay = 20,maxReviewsPerDay = 200
Day 1 (First Session):
- User sees 20 new cards (maxNewPerDay limit)
- Each card reviewed with ‘good’ transitions to ‘learning’ state with a single step (10 minutes)
- Remaining 24 new cards are NOT shown – they wait for subsequent days
- Session ends once no cards remain due today in the in-memory queue
Day 1 (10+ minutes later):
- The 20 ‘learning’ cards are now due for their next review
- User starts a new session and sees those 20 cards again (as reviews, not new cards)
- After completing the learning step (rating ‘good’ again), cards graduate to ‘review’ state and are scheduled for the start of tomorrow
Day 2:
- User starts a new session and sees 20 more new cards (next batch from the 24 remaining)
- Plus any cards from Day 1 that are due for review
- Pattern continues until all new cards have been introduced
Day 3:
- User starts a new session and sees final 4 new cards
- Plus any reviews that are scheduled for today
- No new cards remain after this
UI Flow
- Review tab:
- Deck picker, daily counts, and Start Review.
- Review session:
- Prompt (front), reveal (back), rate with 4 buttons.
- Deck management:
- Create/select decks, set active deck.
- Phrase detail:
- Each translation pair can be assigned to a deck, which creates/updates its two cards.
Review Session Flow (Queue Creation and Updates)
This section reflects the current implementation in ReviewSessionScreen.tsx
and lib/srs/queue.ts.
Session Lifecycle
The review session follows a specific lifecycle:
- Screen Focus â Session initializes (if not already initialized)
- Review Loop â User rates cards, queue updates, next card loads
- Session End â All cards scheduled for tomorrow or later
- Screen Blur â Session state resets completely
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.
Session Initialization
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.
Loading the Next Card
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:
- Wrap-around search: If no due card is found from
startIndexforward, searches from index 0 to catch cards that became due earlier in the queue - Fresh data: Cards are re-fetched from the database before display to avoid stale scheduling data
- Graceful hydration failure: If a card can’t be hydrated (missing translation/phrases), it’s removed from the queue and the next card is tried
Per-Rating Update Flow
When 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)
Queue Index Management
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 }
Translation Pair Separation
To avoid showing both directions of a translation pair too close together (e.g., “dog â perro” then “perro â dog”), two algorithms maintain minimum spacing:
- Minimum spacing: Cards with the same
translation_idmust be at leastMIN_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.
Session Statistics
The session tracks:
totalInSession: Number of cards in the initial queueremainingToday: Cards still due beforetomorrowStartMs(recalculated after each rating)completedCardIds: Set of unique card IDs scheduled for tomorrow or later
completedCardIds.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.
Key Files
- Schema/migrations:
database/schema.ts,database/migrations.ts - Models:
database/models/Deck.ts,database/models/DeckTranslation.ts,database/models/SrsCard.ts,database/models/SrsReviewLog.ts - Scheduler/queue:
lib/srs/sm2.ts,lib/srs/queue.ts,lib/srs/time.ts - Screens:
features/review/screens/ReviewHomeScreen.tsx,features/review/screens/ReviewSessionScreen.tsx,features/review/screens/DecksScreen.tsx - Phrase integration:
features/phrase/screens/PhraseDetailScreen.tsx
Future Extensions
- FSRS scheduling: use
srs_review_logplusstability/difficultyfields to migrate. - Multi-deck assignment per translation (optional).
- Additional card templates (cloze, hints, or extra metadata on cards).