Skip to main content

Overview

Vocab Vault tracks user progress through two systems:
  1. Classic Mode: Simple three-state tracking (known/learning/unseen)
  2. SRS Mode: Spaced Repetition System using the SuperMemo 2 (SM-2) algorithm
Both systems persist data locally using Capacitor Preferences and work completely offline.

Classic Mode Progress

Term Status

Each term can be in one of three states:
type TermStatus = 'known' | 'learning' | 'unseen';
  • known: User has mastered this term
  • learning: User is currently studying this term
  • unseen: User has not encountered this term yet (default)
Location: /src/hooks/useProgress.ts:19

Progress Storage

Progress is stored as a simple object mapping term IDs to status:
interface Progress {
  [termId: number]: TermStatus;
}
Example:
{
  "1": "known",
  "2": "learning",
  "5": "known"
}
Unseen terms are not stored (absence = unseen), keeping storage minimal. Location: /src/hooks/useProgress.ts:21-23

Marking Terms

Terms are marked via the markTerm function:
const markTerm = useCallback(async (termId: number, status: TermStatus) => {
  const newProgress = { ...progress, [termId]: status };
  setProgress(newProgress);
  await Preferences.set({ key: STORAGE_KEY, value: JSON.stringify(newProgress) });
  await checkAchievements(newProgress, streak, totalCardsViewed, logoClicks, unlockedAchievements);
}, [progress, streak, totalCardsViewed, logoClicks, unlockedAchievements, checkAchievements]);
This function:
  1. Updates React state immediately
  2. Persists to local storage
  3. Checks for newly unlocked achievements
Location: /src/hooks/useProgress.ts:140-147

Category Progress

Category progress is calculated dynamically from term statuses:
const getCategoryProgress = useCallback((categoryId: string) => {
  const categoryTerms = getTermsByCategory(categoryId);
  const knownCount = categoryTerms.filter(term => progress[term.id] === 'known').length;
  const total = categoryTerms.length;
  const percentage = total > 0 ? Math.round((knownCount / total) * 100) : 0;
  return { knownCount, total, percentage };
}, [progress]);
Returns:
  • knownCount: Number of mastered terms
  • total: Total terms in category
  • percentage: Completion percentage
Location: /src/hooks/useProgress.ts:264-270

Spaced Repetition System (SRS)

SM-2 Algorithm

Vocab Vault implements the SuperMemo 2 algorithm for optimal review scheduling. The algorithm adjusts review intervals based on recall quality. Core Concepts:
  • Ease Factor: Difficulty multiplier (starts at 2.5)
  • Interval: Days until next review
  • Repetitions: Consecutive successful reviews
  • Quality: User’s recall rating (0-5)
Location: /src/lib/sm2.ts:1-231

SRS Card Structure

Each reviewed term becomes an SRS card:
export interface SRSCard {
  termId: number;
  easeFactor: number;      // How easy this card is (starts at 2.5)
  interval: number;        // Days until next review
  repetitions: number;     // Number of successful reviews in a row
  nextReviewDate: string;  // ISO date string
  lastReviewDate: string | null;
  quality: number;         // Last quality rating
}
Example Card:
{
  "termId": 1,
  "easeFactor": 2.5,
  "interval": 6,
  "repetitions": 2,
  "nextReviewDate": "2026-03-09",
  "lastReviewDate": "2026-03-03",
  "quality": 4
}
Location: /src/lib/sm2.ts:14-22

Initializing Cards

New cards are created when first reviewed:
export function initializeCard(termId: number): SRSCard {
  return {
    termId,
    easeFactor: 2.5,
    interval: 0,
    repetitions: 0,
    nextReviewDate: new Date().toISOString().split('T')[0],
    lastReviewDate: null,
    quality: 0,
  };
}
Location: /src/lib/sm2.ts:50-60

Quality Ratings

Users rate their recall on a 0-5 scale:
// 0 - Complete blackout, no recall
// 1 - Incorrect, but upon seeing the answer, remembered
// 2 - Incorrect, but the answer seemed easy to recall
// 3 - Correct with serious difficulty
// 4 - Correct with some hesitation
// 5 - Perfect response, instant recall
The UI simplifies this to 4 buttons:
[
  { value: 1, label: "Again", sm2Quality: 1 },    // Failed
  { value: 2, label: "Hard", sm2Quality: 3 },     // Correct but difficult
  { value: 3, label: "Good", sm2Quality: 4 },     // Correct with hesitation
  { value: 4, label: "Easy", sm2Quality: 5 },     // Perfect recall
]
Location: /src/lib/sm2.ts:40-45

Review Algorithm

The processReview function implements the SM-2 algorithm:
export function processReview(card: SRSCard, quality: number): SRSCard {
  const today = new Date().toISOString().split('T')[0];
  quality = Math.max(0, Math.min(5, quality)); // Clamp 0-5

  let newEaseFactor = card.easeFactor;
  let newInterval = card.interval;
  let newRepetitions = card.repetitions;

  if (quality >= 3) {
    // Correct response
    if (newRepetitions === 0) {
      newInterval = 1;
    } else if (newRepetitions === 1) {
      newInterval = 6;
    } else {
      newInterval = Math.round(card.interval * card.easeFactor);
    }
    newRepetitions += 1;
  } else {
    // Incorrect response - reset
    newRepetitions = 0;
    newInterval = 1;
  }

  // Update ease factor (minimum 1.3)
  newEaseFactor = card.easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
  newEaseFactor = Math.max(1.3, newEaseFactor);

  // Calculate next review date
  const nextDate = new Date();
  nextDate.setDate(nextDate.getDate() + newInterval);

  return {
    termId: card.termId,
    easeFactor: newEaseFactor,
    interval: newInterval,
    repetitions: newRepetitions,
    nextReviewDate: nextDate.toISOString().split('T')[0],
    lastReviewDate: today,
    quality,
  };
}
Interval Schedule:
  • First review (quality ≥ 3): 1 day
  • Second review (quality ≥ 3): 6 days
  • Subsequent reviews: previous interval × ease factor
  • Failed review (quality < 3): Reset to 1 day
Location: /src/lib/sm2.ts:66-109

Study Queue

The study queue combines due cards and new cards:
export function getStudyQueue(
  allTermIds: number[],
  cards: Record<number, SRSCard>,
  newCardsPerDay: number = 10
): number[] {
  const dueCards = getDueCards(cards).map(c => c.termId);
  const newCards = getNewCards(allTermIds, cards, newCardsPerDay);

  // Interleave: 3 due cards, then 1 new card
  const queue: number[] = [];
  let dueIdx = 0;
  let newIdx = 0;

  while (dueIdx < dueCards.length || newIdx < newCards.length) {
    // Add up to 3 due cards
    for (let i = 0; i < 3 && dueIdx < dueCards.length; i++) {
      queue.push(dueCards[dueIdx++]);
    }
    // Add 1 new card
    if (newIdx < newCards.length) {
      queue.push(newCards[newIdx++]);
    }
  }

  return queue;
}
Queue Strategy:
  1. Prioritize due cards (cards scheduled for today or earlier)
  2. Introduce new cards at a controlled rate (default 10/day)
  3. Interleave 3 due cards for every 1 new card
Location: /src/lib/sm2.ts:150-175

Due Cards

Cards are considered “due” when their nextReviewDate is today or earlier:
export function isDue(card: SRSCard): boolean {
  const today = new Date().toISOString().split('T')[0];
  return card.nextReviewDate <= today;
}

export function getDueCards(cards: Record<number, SRSCard>): SRSCard[] {
  return Object.values(cards)
    .filter(isDue)
    .sort((a, b) => {
      // Sort by: overdue first, then by ease factor (harder cards first)
      const aDate = new Date(a.nextReviewDate).getTime();
      const bDate = new Date(b.nextReviewDate).getTime();
      if (aDate !== bDate) return aDate - bDate;
      return a.easeFactor - b.easeFactor;
    });
}
Sorting Priority:
  1. Most overdue cards first
  2. Among cards due the same day, harder cards (lower ease factor) first
Location: /src/lib/sm2.ts:114-132

Mastery Levels

Cards progress through four mastery levels:
export function getMasteryLevel(card: SRSCard): 'new' | 'learning' | 'reviewing' | 'mastered' {
  if (card.repetitions === 0) return 'new';
  if (card.interval < 7) return 'learning';
  if (card.interval < 21) return 'reviewing';
  return 'mastered';
}
Levels:
  • New: Never reviewed successfully (0 repetitions)
  • Learning: Interval 1-6 days
  • Reviewing: Interval 7-20 days
  • Mastered: Interval 21+ days
Location: /src/lib/sm2.ts:213-218

Retention Statistics

The app calculates retention metrics across all cards:
export function getRetentionStats(cards: Record<number, SRSCard>) {
  const allCards = Object.values(cards);
  const total = allCards.length;

  if (total === 0) {
    return {
      learned: 0,
      learning: 0,
      mature: 0,
      total: 0,
      dueToday: 0,
      averageEase: 2.5
    };
  }

  const mature = allCards.filter(c => c.interval >= 21).length;
  const learning = allCards.filter(c => c.interval > 0 && c.interval < 21).length;
  const dueToday = getDueCards(cards).length;
  const averageEase = allCards.reduce((sum, c) => sum + c.easeFactor, 0) / total;

  return {
    learned: total,
    learning,
    mature,
    total,
    dueToday,
    averageEase: Math.round(averageEase * 100) / 100,
  };
}
Metrics:
  • learned: Total cards reviewed at least once
  • learning: Cards with interval < 21 days
  • mature: Cards with interval ≥ 21 days
  • dueToday: Cards scheduled for review today
  • averageEase: Mean ease factor across all cards
Location: /src/lib/sm2.ts:180-208

Streak Tracking

Streaks track consecutive days of studying.

Streak Data Structure

interface StreakData {
  streak: number;
  lastStudyDate: string | null;
}
Example:
{
  "streak": 7,
  "lastStudyDate": "2026-03-03"
}
Location: /src/hooks/useProgress.ts:25-28

Streak Logic

Streaks are updated when the user studies:
const updateStreak = useCallback(async () => {
  const today = getLocalDateKey(); // YYYY-MM-DD in local timezone
  const { value: streakStored } = await Preferences.get({ key: STREAK_KEY });
  const streakData = safeJsonParse<StreakData>(streakStored, { streak: 0, lastStudyDate: null });

  if (streakData.lastStudyDate === today) return; // Already updated today

  let newStreak = 1;
  if (streakData.lastStudyDate) {
    const diffDays = diffDateKeys(today, streakData.lastStudyDate);
    if (diffDays === 1) {
      newStreak = streakData.streak + 1; // Consecutive day
    }
    // diffDays > 1: Streak broken, reset to 1
  }

  const newStreakData = { streak: newStreak, lastStudyDate: today };
  await Preferences.set({ key: STREAK_KEY, value: JSON.stringify(newStreakData) });
  setStreak(newStreak);
}, []);
Rules:
  • Studying today when last study was yesterday: Increment streak
  • Studying today when last study was 2+ days ago: Reset streak to 1
  • Studying multiple times in one day: No change
Location: /src/hooks/useProgress.ts:301-332

Date Handling

Streaks use local date keys to avoid timezone issues:
import { getLocalDateKey, diffDateKeys } from '@/lib/date';

const today = getLocalDateKey(); // "2026-03-03" in device timezone
const diff = diffDateKeys("2026-03-03", "2026-03-02"); // 1 day
This ensures streaks work correctly across time zones and daylight saving changes.

Achievement System

Achievements are unlocked when user statistics meet specific conditions.

UserStats Calculation

Current stats are calculated from progress data:
const getCurrentStats = useCallback((
  currentProgress: Progress,
  currentStreak: number,
  currentViews: number,
  currentClicks: number,
  currentUnlocked: string[]
): UserStats => {
  const catProgress: Record<string, number> = {};
  categories.forEach(cat => {
    const terms = getTermsByCategory(cat.id);
    const known = terms.filter(t => currentProgress[t.id] === 'known').length;
    catProgress[cat.id] = known;
  });

  return {
    totalCardsViewed: currentViews,
    unlockedAchievements: currentUnlocked,
    categoryProgress: catProgress,
    streak: currentStreak,
    logoClicks: currentClicks,
    lastStudyTime: Date.now()
  };
}, []);
Location: /src/hooks/useProgress.ts:86-108

Achievement Checking

Achievements are checked after any progress change:
const checkAchievements = useCallback(async (
  currentProgress: Progress,
  currentStreak: number,
  currentViews: number,
  currentClicks: number,
  currentUnlocked: string[]
) => {
  const stats = getCurrentStats(currentProgress, currentStreak, currentViews, currentClicks, currentUnlocked);
  const newUnlocked: string[] = [];
  let latestAchievement: Achievement | null = null;

  ACHIEVEMENTS.forEach(achievement => {
    if (!currentUnlocked.includes(achievement.id)) {
      if (achievement.condition(stats)) {
        newUnlocked.push(achievement.id);
        latestAchievement = achievement;
      }
    }
  });

  if (newUnlocked.length > 0) {
    const updatedUnlocked = [...currentUnlocked, ...newUnlocked];
    setUnlockedAchievements(updatedUnlocked);
    await Preferences.set({ key: ACHIEVEMENTS_KEY, value: JSON.stringify(updatedUnlocked) });
    setNewlyUnlocked(latestAchievement); // Triggers notification
  }
}, [getCurrentStats]);
Flow:
  1. Calculate current stats from progress data
  2. Loop through all achievements
  3. Check each achievement’s condition function
  4. Track newly unlocked achievements
  5. Update storage and show notification
Location: /src/hooks/useProgress.ts:111-137

Data Flow Summary

Classic Mode

User marks term → markTerm() → Update state → Persist to storage → Check achievements

SRS Mode

User rates card → reviewCard() → Process SM-2 → Update SRS data → Persist → Update classic progress → Check achievements

Streak

User studies → updateStreak() → Compare dates → Calculate new streak → Persist → Check achievements
All operations are asynchronous but provide immediate UI feedback through optimistic state updates.

Build docs developers (and LLMs) love