integratedhub/utils/spacedRepetition.ts
2026-01-20 09:02:26 -06:00

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"}`;
}
}