managarten/manadeck/apps/mobile/utils/spacedRepetition.ts
Till-JS e7f5f942f3 chore: initial commit - consolidate 4 projects into monorepo
Projects included:
- maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing)
- manacore (Expo mobile + SvelteKit web + Astro landing)
- manadeck (NestJS backend + Expo mobile + SvelteKit web)
- memoro (Expo mobile + SvelteKit web + Astro landing)

This commit preserves the current state before monorepo restructuring.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:38:24 +01:00

216 lines
5.5 KiB
TypeScript

/**
* SM-2 Spaced Repetition Algorithm Implementation
* Based on the SuperMemo 2 algorithm
* https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
*/
export interface SM2Result {
interval: number; // Days until next review
repetitions: number; // Number of consecutive correct responses
easeFactor: number; // Difficulty factor (min 1.3)
nextReviewDate: Date;
}
export interface ReviewQuality {
quality: 0 | 1 | 2 | 3 | 4 | 5; // 0-2: incorrect, 3-5: correct
}
/**
* Calculate next review using SM-2 algorithm
*
* @param quality - Quality of recall (0-5)
* 5 - perfect response
* 4 - correct response after hesitation
* 3 - correct response recalled with serious difficulty
* 2 - incorrect response; where the correct one seemed easy to recall
* 1 - incorrect response; the correct one remembered
* 0 - complete blackout
* @param repetitions - Number of consecutive correct responses
* @param previousInterval - Previous interval in days
* @param previousEaseFactor - Previous ease factor (min 1.3)
*/
export function calculateSM2(
quality: number,
repetitions: number,
previousInterval: number,
previousEaseFactor: number
): SM2Result {
let interval = previousInterval;
let easeFactor = previousEaseFactor;
let reps = repetitions;
// Ensure quality is within bounds
quality = Math.max(0, Math.min(5, quality));
// Calculate new ease factor
// EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
easeFactor = easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
// Ease factor should not be less than 1.3
easeFactor = Math.max(1.3, easeFactor);
if (quality < 3) {
// Incorrect response - reset
reps = 0;
interval = 1; // Review again tomorrow
} else {
// Correct response
if (reps === 0) {
// First correct response
interval = 1;
} else if (reps === 1) {
// Second correct response
interval = 6;
} else {
// Subsequent correct responses
interval = Math.round(interval * easeFactor);
}
reps += 1;
}
// Calculate next review date
const nextReviewDate = new Date();
nextReviewDate.setDate(nextReviewDate.getDate() + interval);
nextReviewDate.setHours(4, 0, 0, 0); // Set to 4 AM for next day reviews
return {
interval,
repetitions: reps,
easeFactor,
nextReviewDate,
};
}
/**
* Convert difficulty rating to SM-2 quality score
*/
export function difficultyToQuality(difficulty: 'easy' | 'medium' | 'hard' | 'again'): number {
switch (difficulty) {
case 'easy':
return 5; // Perfect recall
case 'medium':
return 4; // Good recall with hesitation
case 'hard':
return 3; // Difficult but correct
case 'again':
return 1; // Incorrect, need to review again
default:
return 3;
}
}
/**
* Calculate review statistics
*/
export function calculateReviewStats(
totalReviews: number,
correctReviews: number,
easeFactor: number
): {
accuracy: number;
difficulty: 'easy' | 'medium' | 'hard';
masteryLevel: number;
} {
const accuracy = totalReviews > 0 ? (correctReviews / totalReviews) * 100 : 0;
// Determine difficulty based on ease factor
let difficulty: 'easy' | 'medium' | 'hard';
if (easeFactor >= 2.5) {
difficulty = 'easy';
} else if (easeFactor >= 2.0) {
difficulty = 'medium';
} else {
difficulty = 'hard';
}
// Calculate mastery level (0-100)
// Based on accuracy and ease factor
const masteryLevel = Math.min(
100,
Math.round(accuracy * 0.6 + ((easeFactor - 1.3) / (2.8 - 1.3)) * 40)
);
return {
accuracy,
difficulty,
masteryLevel,
};
}
/**
* Determine if a card is due for review
*/
export function isCardDue(nextReviewDate: Date | string): boolean {
const reviewDate = typeof nextReviewDate === 'string' ? new Date(nextReviewDate) : nextReviewDate;
const now = new Date();
now.setHours(23, 59, 59, 999); // End of today
return reviewDate <= now;
}
/**
* Get cards organized by review status
*/
export interface ReviewQueue {
new: string[]; // Card IDs that are new
learning: string[]; // Cards being learned (interval < 10 days)
review: string[]; // Cards due for review
total: number;
}
export function organizeReviewQueue(
cards: {
id: string;
status: 'new' | 'learning' | 'review' | 'relearning';
next_review_date: string;
interval: number;
}[]
): ReviewQueue {
const queue: ReviewQueue = {
new: [],
learning: [],
review: [],
total: 0,
};
const now = new Date();
cards.forEach((card) => {
if (card.status === 'new') {
queue.new.push(card.id);
} else if (card.interval < 10 || card.status === 'learning' || card.status === 'relearning') {
if (isCardDue(card.next_review_date)) {
queue.learning.push(card.id);
}
} else if (isCardDue(card.next_review_date)) {
queue.review.push(card.id);
}
});
queue.total = queue.new.length + queue.learning.length + queue.review.length;
return queue;
}
/**
* Format interval for display
*/
export function formatInterval(days: number): string {
if (days === 0) {
return 'Heute';
} else if (days === 1) {
return 'Morgen';
} else if (days < 7) {
return `In ${days} Tagen`;
} else if (days < 30) {
const weeks = Math.floor(days / 7);
return weeks === 1 ? 'In 1 Woche' : `In ${weeks} Wochen`;
} else if (days < 365) {
const months = Math.floor(days / 30);
return months === 1 ? 'In 1 Monat' : `In ${months} Monaten`;
} else {
const years = Math.floor(days / 365);
return years === 1 ? 'In 1 Jahr' : `In ${years} Jahren`;
}
}