mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 04:19:39 +02:00
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>
216 lines
5.5 KiB
TypeScript
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`;
|
|
}
|
|
}
|