/** * SM-2 Algorithm Implementation * * Implements the SuperMemo SM-2 spaced repetition algorithm * as specified in the MemoHanzi specification. * * Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2 */ /** * Progress data for a single card */ export interface CardProgress { easeFactor: number interval: number // in days consecutiveCorrect: number incorrectCount: number lastReviewDate: Date | null nextReviewDate: Date | null } /** * Initial values for a new card */ export const INITIAL_PROGRESS: CardProgress = { easeFactor: 2.5, interval: 1, consecutiveCorrect: 0, incorrectCount: 0, lastReviewDate: null, nextReviewDate: null, } /** * Result of calculating the next review */ export interface ReviewResult { easeFactor: number interval: number consecutiveCorrect: number incorrectCount: number nextReviewDate: Date } /** * Calculate the next review for a correct answer * * @param progress Current card progress * @param reviewDate Date of the review (defaults to now) * @returns Updated progress values */ export function calculateCorrectAnswer( progress: CardProgress, reviewDate: Date = new Date() ): ReviewResult { let newInterval: number let newEaseFactor: number let newConsecutiveCorrect: number // Calculate new interval based on consecutive correct count if (progress.consecutiveCorrect === 0) { newInterval = 1 } else if (progress.consecutiveCorrect === 1) { newInterval = 6 } else { newInterval = Math.round(progress.interval * progress.easeFactor) } // Increase ease factor (making future intervals longer) newEaseFactor = progress.easeFactor + 0.1 // Increment consecutive correct count newConsecutiveCorrect = progress.consecutiveCorrect + 1 // Calculate next review date const nextReviewDate = new Date(reviewDate) nextReviewDate.setDate(nextReviewDate.getDate() + newInterval) return { easeFactor: newEaseFactor, interval: newInterval, consecutiveCorrect: newConsecutiveCorrect, incorrectCount: progress.incorrectCount, nextReviewDate, } } /** * Calculate the next review for an incorrect answer * * @param progress Current card progress * @param reviewDate Date of the review (defaults to now) * @returns Updated progress values */ export function calculateIncorrectAnswer( progress: CardProgress, reviewDate: Date = new Date() ): ReviewResult { // Reset interval to 1 day const newInterval = 1 // Reset consecutive correct count const newConsecutiveCorrect = 0 // Decrease ease factor (but not below 1.3) const newEaseFactor = Math.max(1.3, progress.easeFactor - 0.2) // Increment incorrect count const newIncorrectCount = progress.incorrectCount + 1 // Calculate next review date (1 day from now) const nextReviewDate = new Date(reviewDate) nextReviewDate.setDate(nextReviewDate.getDate() + newInterval) return { easeFactor: newEaseFactor, interval: newInterval, consecutiveCorrect: newConsecutiveCorrect, incorrectCount: newIncorrectCount, nextReviewDate, } } /** * Difficulty enum matching the Prisma schema */ export enum Difficulty { EASY = "EASY", NORMAL = "NORMAL", HARD = "HARD", SUSPENDED = "SUSPENDED", } /** * Card for selection with progress and metadata */ export interface SelectableCard { id: string nextReviewDate: Date | null incorrectCount: number consecutiveCorrect: number manualDifficulty: Difficulty } /** * Select cards for a learning session * * Algorithm: * 1. Filter out SUSPENDED cards * 2. Filter cards that are due (nextReviewDate <= now) * 3. Apply priority: HARD cards first, NORMAL, then EASY * 4. Sort by: nextReviewDate ASC, incorrectCount DESC, consecutiveCorrect ASC * 5. Limit to cardsPerSession * * @param cards Available cards * @param cardsPerSession Maximum number of cards to select * @param now Current date (defaults to now) * @returns Selected cards for the session */ export function selectCardsForSession( cards: SelectableCard[], cardsPerSession: number, now: Date = new Date() ): SelectableCard[] { // Filter out suspended cards const activeCards = cards.filter( (card) => card.manualDifficulty !== Difficulty.SUSPENDED ) // Filter cards that are due (nextReviewDate <= now or null for new cards) const dueCards = activeCards.filter( (card) => card.nextReviewDate === null || card.nextReviewDate <= now ) // Apply difficulty priority and sort const sortedCards = dueCards.sort((a, b) => { // Priority by difficulty: HARD > NORMAL > EASY const difficultyPriority = { [Difficulty.HARD]: 0, [Difficulty.NORMAL]: 1, [Difficulty.EASY]: 2, [Difficulty.SUSPENDED]: 3, // Should not appear due to filter } const aPriority = difficultyPriority[a.manualDifficulty] const bPriority = difficultyPriority[b.manualDifficulty] if (aPriority !== bPriority) { return aPriority - bPriority } // Sort by nextReviewDate (null = new cards, should come first) if (a.nextReviewDate === null && b.nextReviewDate !== null) return -1 if (a.nextReviewDate !== null && b.nextReviewDate === null) return 1 if (a.nextReviewDate !== null && b.nextReviewDate !== null) { const dateCompare = a.nextReviewDate.getTime() - b.nextReviewDate.getTime() if (dateCompare !== 0) return dateCompare } // Sort by incorrectCount DESC (more incorrect = higher priority) if (a.incorrectCount !== b.incorrectCount) { return b.incorrectCount - a.incorrectCount } // Sort by consecutiveCorrect ASC (fewer correct = higher priority) return a.consecutiveCorrect - b.consecutiveCorrect }) // Limit to cardsPerSession return sortedCards.slice(0, cardsPerSession) } /** * Hanzi option for wrong answer generation */ export interface HanziOption { id: string simplified: string pinyin: string hskLevel: string } /** * Generate wrong answers for a multiple choice question * * Selects 3 random incorrect pinyin from the same HSK level, * ensuring no duplicates. * * @param correctAnswer The correct hanzi * @param sameHskOptions Available hanzi from the same HSK level * @returns Array of 3 wrong pinyin options */ export function generateWrongAnswers( correctAnswer: HanziOption, sameHskOptions: HanziOption[] ): string[] { // Filter out the correct answer and any with duplicate pinyin const candidates = sameHskOptions.filter( (option) => option.id !== correctAnswer.id && option.pinyin !== correctAnswer.pinyin ) // If not enough candidates, throw error if (candidates.length < 3) { throw new Error( `Not enough wrong answers available. Need 3, found ${candidates.length}` ) } // Fisher-Yates shuffle const shuffled = [...candidates] for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] } // Take first 3 return shuffled.slice(0, 3).map((option) => option.pinyin) } /** * Shuffle an array of options (for randomizing answer positions) * Uses Fisher-Yates shuffle algorithm * * @param options Array to shuffle * @returns Shuffled array */ export function shuffleOptions(options: T[]): T[] { const shuffled = [...options] for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] } return shuffled }