155 lines
4.5 KiB
TypeScript
155 lines
4.5 KiB
TypeScript
// 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"}`;
|
|
}
|
|
}
|