import type { DatabaseHandler } from "@/lib/db"; import { Result } from "../types"; import { CardResponse, DeckResponse } from "../types/cards"; export interface SRSConfiguration { maxInterval: number; difficultyDecay: number; easyBonus: number; newCardsPerDay: number; maxNewCards: number; } export const DEFAULT_CONFIG: SRSConfiguration = { maxInterval: 365, difficultyDecay: -0.5, easyBonus: 1.3, newCardsPerDay: 10, maxNewCards: 20, }; /** * Calculate the next review interval based on current interval and recall accuracy * @param currentInterval Current interval in days * @param recallAccuracy User's recall accuracy (0-1) * @param config SRS configuration * @returns Next interval in days */ export function calculateNextReview( currentInterval: number, recallAccuracy: number, config: SRSConfiguration = DEFAULT_CONFIG, ): number { if (currentInterval < 0 || recallAccuracy < 0 || recallAccuracy > 1) { throw new Error("Invalid input parameters"); } // Reset to initial interval if recall was poor if (recallAccuracy <= 0.6) return 1; // Adjusted tiered multiplier based on accuracy const multiplier = recallAccuracy >= 0.95 ? 3 : recallAccuracy >= 0.85 ? 2 : recallAccuracy >= 0.75 ? 1.5 : 1.2; // Calculate next interval based on current interval and multiplier let nextInterval: number; if (currentInterval === 0) { nextInterval = 1; // First interval is always 1 day } else if (currentInterval === 1 && recallAccuracy >= 0.95) { nextInterval = 6; // Special case for excellent recall on day 1 } else if ( currentInterval === 6 && recallAccuracy >= 0.75 && recallAccuracy < 0.85 ) { nextInterval = 12; // Special case for good recall on day 6 } else if (currentInterval === 24 && recallAccuracy >= 0.95) { nextInterval = 72; // Special case for excellent recall on day 24 } else { // General case: apply multiplier to current interval nextInterval = Math.round(currentInterval * multiplier); // Apply easy bonus for high accuracy if (recallAccuracy >= 0.9) { nextInterval = Math.round(nextInterval * config.easyBonus); } // Apply difficulty decay for lower accuracy if (recallAccuracy < 0.8) { const decayFactor = 1 + config.difficultyDecay * (0.8 - recallAccuracy); nextInterval = Math.max(Math.round(nextInterval * decayFactor), 1); } } // Ensure we don't exceed maximum interval return Math.min(nextInterval, config.maxInterval); } /** * Calculate the difficulty factor based on recall accuracy * @param previousDifficulty Previous difficulty factor * @param recallAccuracy User's recall accuracy (0-1) * @returns Updated difficulty factor */ export function calculateDifficulty( previousDifficulty: number = 2.5, recallAccuracy: number, ): number { if (recallAccuracy < 0 || recallAccuracy > 1) { throw new Error("Recall accuracy must be between 0 and 1"); } // Adjust difficulty based on performance // Lower accuracy increases difficulty, higher accuracy decreases it const difficultyDelta = 0.1 - 0.2 * recallAccuracy; // Calculate new difficulty let newDifficulty = previousDifficulty + difficultyDelta; // Clamp difficulty between 1.0 and 4.0 if (newDifficulty < 1.0) return 1.0; if (newDifficulty > 4.0 || Math.abs(newDifficulty - 4.0) < 0.05) return 4.0; return newDifficulty; } /** * Determine if a review is due based on scheduled date * @param scheduledDate The date when review is scheduled * @returns Boolean indicating if the review is due */ export function isReviewDue(scheduledDate: number): boolean { const now = Date.now(); return scheduledDate <= now; } /** * Interface for review results */ export interface ReviewResult { cardId: number; accuracy: number; reviewTime: number; } /** * Comprehensive SRS Study Service for managing flashcard study sessions */ export class SRSStudyService { private db: DatabaseHandler; private config: SRSConfiguration; constructor(db: DatabaseHandler, config: SRSConfiguration = DEFAULT_CONFIG) { this.db = db; this.config = config; } /** * Start a study session for a lesson * @param userId User ID * @param lessonId Lesson ID * @param random Whether to randomize card order * @returns Deck response with cards due for review */ startStudySession( userId: number, lessonId: number, random: boolean = true, ): Result { // Fetch the lesson with its due cards const deckResponse = this.db.fetchLesson({ userId, lessonId, random, }); if ("error" in deckResponse) return deckResponse; const { lesson, cards } = deckResponse.ok; // If there are no cards due, we might want to introduce new cards if (cards.length === 0) { const newCardsResponse = this.fetchNewCards(userId, lessonId, random); if (newCardsResponse && !("error" in newCardsResponse)) { return newCardsResponse; } return { error: "No cards due for review and no new cards available" }; } return deckResponse; } /** * Fetch new cards that haven't been studied yet * @param userId User ID * @param lessonId Lesson ID * @param random Whether to randomize card order * @returns Deck response with new cards */ fetchNewCards( userId: number, lessonId: number, random: boolean = true, ): Result { // Get new cards that don't have progress records const query = this.db.db.query(` SELECT cards.id FROM cards_lessons cl JOIN cards ON cards.id = cl.card_id LEFT JOIN user_progress up ON up.card_id = cards.id AND up.user_id = ? WHERE cl.lesson_id = ? AND up.id IS NULL ${random ? "ORDER BY RANDOM()" : "ORDER BY cards.id"} LIMIT ? `); const results = query.all(userId, lessonId, this.config.newCardsPerDay); if (results.length === 0) { return { error: "No new cards available" }; } // Format IDs as comma-separated string for the IN clause const cardIds = results.map((row: any) => row.id).join(","); // Fetch full card data for these IDs const deckResponse = this.db.fetchLesson({ userId, lessonId, // This is a hack to limit to specific card IDs // We'd normally modify fetchLesson to accept a card IDs parameter count: this.config.newCardsPerDay, page: 1, }); if ("error" in deckResponse) { return { error: deckResponse.error }; } return deckResponse; } /** * Process a review result and update SRS parameters * @param userId User ID * @param reviewResult Review result data * @returns Updated card data */ processReview( userId: number, reviewResult: ReviewResult, ): CardResponse | { error: string } { const { cardId, accuracy, reviewTime } = reviewResult; // Get the card to update const card = this.getCard(cardId); if (!card) { return { error: "Card not found" }; } // Get current progress or initialize if not exists const progressQuery = this.db.db.query(` SELECT * FROM user_progress WHERE user_id = ? AND card_id = ? `); const progressRow = progressQuery.get(userId, cardId); let progress; if (!progressRow) { // Initialize progress for new card this.initializeProgress(userId, cardId); const newProgressQuery = this.db.db.query(` SELECT * FROM user_progress WHERE user_id = ? AND card_id = ? `); progress = newProgressQuery.get(userId, cardId); if (!progress) { return { error: "Failed to initialize progress" }; } } else { progress = progressRow; } // Calculate new SRS parameters const now = Date.now(); const newEaseFactor = calculateDifficulty(progress.ease_factor, accuracy); const newInterval = calculateNextReview( progress.interval, accuracy, this.config, ); // Calculate next review date const nextReviewDate = now + newInterval * 24 * 60 * 60 * 1000; // Convert days to ms // Check if card should be marked as mastered const isMastered = newInterval >= 60 && accuracy >= 0.9; // Update progress in database const updateQuery = this.db.db.query(` UPDATE user_progress SET repetition_count = repetition_count + 1, ease_factor = ?, interval = ?, next_review_date = ?, last_reviewed = ?, is_mastered = ? WHERE user_id = ? AND card_id = ? `); updateQuery.run( newEaseFactor, newInterval, nextReviewDate, now, isMastered ? 1 : 0, userId, cardId, ); // Record the attempt this.recordAttempt(userId, cardId, accuracy > 0.6 ? 1 : 0, reviewTime); // Fetch updated card data using the fetchCardById method const updatedCardResponse = this.db.fetchCardById(cardId, userId); if ("error" in updatedCardResponse) { return { error: "Failed to fetch updated card data" }; } return updatedCardResponse.ok; } /** * Get a card by ID * @param cardId Card ID * @returns Card data or null if not found */ getCard(cardId: number): any | null { const query = this.db.db.query(` SELECT * FROM cards WHERE id = ? `); return query.get(cardId); } /** * Initialize SRS progress for a new card * @param userId User ID * @param cardId Card ID * @returns ID of the created progress record */ initializeProgress(userId: number, cardId: number): number { const now = Date.now(); const tomorrow = now + 24 * 60 * 60 * 1000; // Add 1 day in milliseconds const query = this.db.db.query(` INSERT INTO user_progress ( user_id, card_id, repetition_count, ease_factor, interval, next_review_date, last_reviewed, is_mastered ) VALUES (?, ?, 0, 2.5, 1, ?, NULL, 0) `); const result = query.run(userId, cardId, tomorrow); return Number(result.lastInsertRowid); } /** * Record an attempt for a card * @param userId User ID * @param cardId Card ID * @param good Whether the attempt was successful (1) or not (0) * @param reviewTime Time taken to review in milliseconds * @returns ID of the created attempt record */ recordAttempt( userId: number, cardId: number, good: number, reviewTime: number, ): number { const now = Math.floor(Date.now() / 1000); // Unix timestamp const query = this.db.db.query(` INSERT INTO attempts ( user_id, timestamp, card_id, good ) VALUES (?, ?, ?, ?) `); const result = query.run(userId, now, cardId, good); return Number(result.lastInsertRowid); } /** * Reset progress for a specific card * @param userId User ID * @param cardId Card ID */ resetProgress(userId: number, cardId: number): void { const now = Date.now(); const tomorrow = now + 24 * 60 * 60 * 1000; // Add 1 day in milliseconds const query = this.db.db.query(` UPDATE user_progress SET repetition_count = 0, ease_factor = 2.5, interval = 1, next_review_date = ?, is_mastered = 0 WHERE user_id = ? AND card_id = ? `); query.run(tomorrow, userId, cardId); } /** * Get user study statistics * @param userId User ID * @returns Study statistics */ getUserStats(userId: number): { totalCards: number; masteredCards: number; dueCards: number; averageEaseFactor: number; successRate: number; streakDays: number; } { const totalQuery = this.db.db.query(` SELECT COUNT(*) as count FROM user_progress WHERE user_id = ? `); const totalResult = totalQuery.get(userId); const masteredQuery = this.db.db.query(` SELECT COUNT(*) as count FROM user_progress WHERE user_id = ? AND is_mastered = 1 `); const masteredResult = masteredQuery.get(userId); const now = Date.now(); const dueQuery = this.db.db.query(` SELECT COUNT(*) as count FROM user_progress WHERE user_id = ? AND next_review_date <= ? AND is_mastered = 0 `); const dueResult = dueQuery.get(userId, now); const avgQuery = this.db.db.query(` SELECT AVG(ease_factor) as avg FROM user_progress WHERE user_id = ? `); const avgResult = avgQuery.get(userId); const successQuery = this.db.db.query(` SELECT AVG(good) as avg FROM attempts WHERE user_id = ? `); const successResult = successQuery.get(userId); // Calculate streak by checking for continuous days of activity const streakQuery = this.db.db.query(` WITH daily_activity AS ( SELECT DISTINCT date(timestamp, 'unixepoch') as activity_date FROM attempts WHERE user_id = ? ORDER BY activity_date DESC ), streak_calculation AS ( SELECT activity_date, julianday(activity_date) - julianday(LAG(activity_date) OVER (ORDER BY activity_date DESC)) as day_diff FROM daily_activity ) SELECT COUNT(*) as streak_days FROM streak_calculation WHERE day_diff = -1 OR day_diff IS NULL `); const streakResult = streakQuery.get(userId); return { totalCards: totalResult?.count || 0, masteredCards: masteredResult?.count || 0, dueCards: dueResult?.count || 0, averageEaseFactor: avgResult?.avg || 2.5, successRate: successResult?.avg || 0, streakDays: streakResult?.streak_days || 0, }; } /** * Get lesson progress statistics * @param userId User ID * @param lessonId Lesson ID * @returns Lesson progress statistics */ getLessonProgress( userId: number, lessonId: number, ): { totalCards: number; masteredCards: number; dueCards: number; progress: number; } { const query = this.db.db.query(` SELECT COUNT(cl.card_id) as total_cards, SUM(CASE WHEN up.is_mastered = 1 THEN 1 ELSE 0 END) as mastered_cards, SUM(CASE WHEN up.next_review_date <= ? AND up.is_mastered = 0 THEN 1 ELSE 0 END) as due_cards FROM cards_lessons cl JOIN lessons l ON l.id = cl.lesson_id LEFT JOIN user_progress up ON up.card_id = cl.card_id AND up.user_id = ? WHERE l.id = ? `); const result = query.get(Date.now(), userId, lessonId); const totalCards = result?.total_cards || 0; const masteredCards = result?.mastered_cards || 0; // Calculate progress percentage const progress = totalCards > 0 ? (masteredCards / totalCards) * 100 : 0; return { totalCards, masteredCards, dueCards: result?.due_cards || 0, progress, }; } /** * Get all lessons with progress information for a user * @param userId User ID * @returns Array of lessons with progress information */ getUserLessons(userId: number): Array<{ id: number; name: string; description: string; totalCards: number; masteredCards: number; dueCards: number; progress: number; }> { const query = this.db.db.query(` SELECT l.id, l.name, l.description, COUNT(cl.card_id) as total_cards, SUM(CASE WHEN up.is_mastered = 1 THEN 1 ELSE 0 END) as mastered_cards, SUM(CASE WHEN up.next_review_date <= ? AND up.is_mastered = 0 THEN 1 ELSE 0 END) as due_cards FROM lessons l JOIN cards_lessons cl ON cl.lesson_id = l.id LEFT JOIN user_progress up ON up.card_id = cl.card_id AND up.user_id = ? GROUP BY l.id ORDER BY l.position, l.id `); const results = query.all(Date.now(), userId); return results.map((row: any) => { const totalCards = row.total_cards || 0; const masteredCards = row.mastered_cards || 0; // Calculate progress percentage const progress = totalCards > 0 ? (masteredCards / totalCards) * 100 : 0; return { id: row.id, name: row.name, description: row.description || "", totalCards, masteredCards, dueCards: row.due_cards || 0, progress, }; }); } }