// utils/spacedRepetition.ts // SM-2 inspired spaced repetition algorithm for Brain study mode const MIN_EASE_FACTOR = 1.3; const MAX_EASE_FACTOR = 3.0; const MAX_INTERVAL_DAYS = 365; const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; export interface SpacedRepetitionResult { nextDueAt: number; newInterval: number; newEaseFactor: number; } /** * Calculate the next review time based on whether the user answered correctly on first try * Uses a simplified SM-2 algorithm variant * * @param wasCorrectFirstTry - Whether the user got the answer right on their first attempt * @param currentInterval - Current interval in days (default 1) * @param easeFactor - Current ease factor (default 2.5) * @returns Object containing nextDueAt timestamp, new interval, and new ease factor */ export function calculateNextReview( wasCorrectFirstTry: boolean, currentInterval: number = 1, easeFactor: number = 2.5, ): SpacedRepetitionResult { const now = Date.now(); let newInterval: number; let newEaseFactor: number; if (wasCorrectFirstTry) { // Correct on first try: increase interval and slightly increase ease if (currentInterval === 1) { // First successful review: move to 1 day newInterval = 1; } else if (currentInterval < 6) { // Early reviews: move to 6 days newInterval = 6; } else { // Established cards: multiply by ease factor newInterval = Math.round(currentInterval * easeFactor); } // Increase ease factor slightly (capped at max) newEaseFactor = Math.min(easeFactor + 0.1, MAX_EASE_FACTOR); } else { // Incorrect or needed help: reset interval and decrease ease newInterval = 1; // Reset to 1 day // Decrease ease factor (minimum 1.3) newEaseFactor = Math.max(easeFactor - 0.2, MIN_EASE_FACTOR); } // Cap interval at maximum newInterval = Math.min(newInterval, MAX_INTERVAL_DAYS); // Calculate next due timestamp const nextDueAt = now + (newInterval * MILLISECONDS_PER_DAY); return { nextDueAt, newInterval, newEaseFactor, }; } /** * Check if a question is due for review * @param nextDueAt - The timestamp when the question is due * @returns true if the question is due now or overdue */ export function isDue(nextDueAt: number | null): boolean { if (nextDueAt === null) return true; // New cards are always due return Date.now() >= nextDueAt; } /** * Calculate priority score for sorting review queue * Overdue cards get higher priority, newer cards get medium priority * @param nextDueAt - When the card is due * @param isNew - Whether this is a new (never reviewed) card * @returns Priority score (higher = review sooner) */ export function calculatePriority( nextDueAt: number | null, isNew: boolean, ): number { const now = Date.now(); if (isNew) { // New cards have medium priority (score of 1000) return 1000; } if (nextDueAt === null) { // No due date set, treat as very high priority return 10000; } // Calculate how overdue the card is (in hours) const overdueHours = (now - nextDueAt) / (1000 * 60 * 60); if (overdueHours > 0) { // Overdue cards: higher score for more overdue // Cap at 9999 to keep below "no due date" priority return Math.min(2000 + overdueHours, 9999); } else { // Not yet due: score based on how soon it's due (lower = less urgent) // Cards due sooner have higher scores (closer to 1000) const hoursUntilDue = Math.abs(overdueHours); return Math.max(0, 999 - hoursUntilDue); } } /** * Sort questions by review priority (highest priority first) */ export function sortByPriority< T extends { nextDueAt: number | null; isNew: boolean }, >( items: T[], ): T[] { return [...items].sort((a, b) => { const priorityA = calculatePriority(a.nextDueAt, a.isNew); const priorityB = calculatePriority(b.nextDueAt, b.isNew); return priorityB - priorityA; // Higher priority first }); } /** * Get human-readable time until next review */ export function getTimeUntilReview(nextDueAt: number | null): string { if (nextDueAt === null) return "Now"; const now = Date.now(); const diff = nextDueAt - now; if (diff <= 0) return "Now"; const hours = Math.floor(diff / (1000 * 60 * 60)); const days = Math.floor(hours / 24); if (days > 0) { return `${days} day${days === 1 ? "" : "s"}`; } else if (hours > 0) { return `${hours} hour${hours === 1 ? "" : "s"}`; } else { const minutes = Math.floor(diff / (1000 * 60)); return `${minutes} minute${minutes === 1 ? "" : "s"}`; } }