Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 5 additions & 57 deletions backend/controllers/flashcardController.js
Original file line number Diff line number Diff line change
@@ -1,60 +1,7 @@
const mongoose = require("mongoose");
const Flashcard = require("../models/Flashcard");

/**
* SuperMemo SM-2 Algorithm helper
* Returns updated { interval, repetition, efactor, dueDate }
*/
const calculateSM2 = ({ interval = 0, repetition = 0, efactor = 2.5 }, rating) => {
let score = 3;
if (rating === "again" || rating === "1") score = 1;
else if (rating === "hard" || rating === "2") score = 2;
else if (rating === "medium" || rating === "good" || rating === "3") score = 4;
else if (rating === "easy" || rating === "4") score = 5;

let newRepetition = repetition;
let newInterval = interval;
let newEFactor = efactor;

if (score < 3) {
// Failed recall (Again / Hard)
if (score === 1) {
newRepetition = 0;
newInterval = 1;
} else {
// Hard: keep or slight progression
newRepetition = repetition > 0 ? repetition : 1;
newInterval = repetition <= 1 ? 1 : Math.max(1, Math.round(interval * 1.2));
}
} else {
// Successful recall (Medium / Easy)
if (repetition === 0) {
newInterval = score === 5 ? 2 : 1;
} else if (repetition === 1) {
newInterval = score === 5 ? 7 : 6;
} else {
const multiplier = score === 5 ? newEFactor * 1.3 : newEFactor;
newInterval = Math.max(1, Math.round(interval * multiplier));
}
newRepetition += 1;
}

// Update Ease Factor (EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)))
const q = score;
newEFactor = newEFactor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02));
if (newEFactor < 1.3) newEFactor = 1.3;
newEFactor = Math.round(newEFactor * 100 + Number.EPSILON) / 100;

const now = new Date();
const nextDueDate = new Date(now.getTime() + newInterval * 24 * 60 * 60 * 1000);

return {
interval: newInterval,
repetition: newRepetition,
efactor: newEFactor,
dueDate: nextDueDate,
};
};
const { calculateSM2 } = require("../utils/srsAlgorithm");

/**
* @desc Create a new flashcard or bookmark a question for SRS
Expand Down Expand Up @@ -158,7 +105,7 @@ const getUserFlashcards = async (req, res) => {
const reviewFlashcard = async (req, res) => {
try {
const { id } = req.params;
const { rating } = req.body;
const { rating, timezone } = req.body;
const userId = req.user._id;

// Validate ObjectId format before querying database
Expand Down Expand Up @@ -190,7 +137,8 @@ const reviewFlashcard = async (req, res) => {
repetition: flashcard.repetition,
efactor: flashcard.efactor,
},
rating
rating,
timezone
);

flashcard.interval = sm2Result.interval;
Expand Down Expand Up @@ -308,4 +256,4 @@ module.exports = {
deleteFlashcard,
getFlashcardStats,
calculateSM2,
};
};
61 changes: 0 additions & 61 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions backend/tests/srsAlgorithm.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { calculateSM2, getMidnightInTimezone } from "../utils/srsAlgorithm";

describe("SRS Algorithm (SM-2)", () => {
beforeEach(() => {
// Mock system time to a fixed date for reliable testing
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-07T12:00:00Z"));
});

afterEach(() => {
vi.useRealTimers();
});

describe("getMidnightInTimezone", () => {
it("should correctly calculate midnight UTC for a future date", () => {
// 1 day from 2026-08-07 12:00 UTC is 2026-08-08 00:00 UTC
const result = getMidnightInTimezone(new Date(), 1, "UTC");
expect(result.toISOString()).toBe("2026-08-08T00:00:00.000Z");
});

it("should correctly calculate midnight in America/New_York (UTC-4 summer)", () => {
// Current: 2026-08-07 12:00 UTC = 08:00 EDT
// Add 1 day -> target local is 2026-08-08 00:00 EDT
// 00:00 EDT is 04:00 UTC
const result = getMidnightInTimezone(new Date(), 1, "America/New_York");
expect(result.toISOString()).toBe("2026-08-08T04:00:00.000Z");
});

it("should correctly calculate midnight in Australia/Sydney (UTC+10 winter)", () => {
// Current: 2026-08-07 12:00 UTC = 22:00 AEST
// Add 1 day -> target local is 2026-08-08 00:00 AEST
// 00:00 AEST is 14:00 UTC on 2026-08-07
const result = getMidnightInTimezone(new Date(), 1, "Australia/Sydney");
expect(result.toISOString()).toBe("2026-08-07T14:00:00.000Z");
});
});

describe("calculateSM2 Ratings", () => {
it('should handle "again" rating (score 1)', () => {
const result = calculateSM2({ interval: 5, repetition: 3, efactor: 2.5 }, "again");
expect(result.repetition).toBe(0);
expect(result.interval).toBe(1);
expect(result.efactor).toBe(1.96);
});

it('should handle "hard" rating (score 2)', () => {
const result = calculateSM2({ interval: 5, repetition: 3, efactor: 2.5 }, "hard");
// Repetition remains if > 0
expect(result.repetition).toBe(3);
// Interval = Math.max(1, Math.round(5 * 1.2)) = 6
expect(result.interval).toBe(6);
expect(result.efactor).toBe(2.18);
});

it('should handle "good" / "medium" rating (score 4) for first time', () => {
const result = calculateSM2({ interval: 0, repetition: 0, efactor: 2.5 }, "good");
expect(result.repetition).toBe(1);
expect(result.interval).toBe(1);
expect(result.efactor).toBe(2.5); // EF unchanged for score 4
});

it('should handle "easy" rating (score 5) for first time', () => {
const result = calculateSM2({ interval: 0, repetition: 0, efactor: 2.5 }, "easy");
expect(result.repetition).toBe(1);
expect(result.interval).toBe(2);
expect(result.efactor).toBe(2.6); // EF increases
});

it("should multiply interval by EFactor for successive good ratings", () => {
const result = calculateSM2({ interval: 6, repetition: 2, efactor: 2.5 }, "good");
expect(result.repetition).toBe(3);
expect(result.interval).toBe(15); // Math.round(6 * 2.5)
});
});

describe("calculateSM2 Bounds and Fallbacks", () => {
it("should not let EFactor drop below 1.3", () => {
const result = calculateSM2({ interval: 1, repetition: 1, efactor: 1.3 }, "again");
expect(result.efactor).toBe(1.3);
});

it("should safely fallback when given invalid timezone", () => {
// With invalid timezone, it catches the error and just adds 24 hours
// newInterval for "good" first time is 1. 24 hours later.
const result = calculateSM2({ interval: 1, repetition: 0, efactor: 2.5 }, "good", "Invalid/Timezone");
expect(result.dueDate.toISOString()).toBe("2026-08-08T12:00:00.000Z");
});
});
});
104 changes: 104 additions & 0 deletions backend/utils/srsAlgorithm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Helper to get UTC time for midnight in a specific timezone
*/
function getMidnightInTimezone(date, addDays, timeZone) {
const tz = timeZone || "UTC";

const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hour12: false,
});

const parts = formatter.formatToParts(date);
const getPart = (type) => parseInt(parts.find((p) => p.type === type).value, 10);

const year = getPart("year");
const month = getPart("month") - 1; // 0-indexed
const day = getPart("day");

// Local target date (midnight of target day)
const targetDate = new Date(year, month, day + addDays, 0, 0, 0);

// Find the exact UTC time that matches this local date in the target timezone
let guess = new Date(Date.UTC(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate(), 0, 0, 0));

for (let i = 0; i < 3; i++) {
const guessStr = guess.toLocaleString("en-US", { timeZone: tz, hour12: false });
const guessLocal = new Date(guessStr);
const diff = targetDate.getTime() - guessLocal.getTime();
if (diff === 0) break;
guess = new Date(guess.getTime() + diff);
}

return guess;
}

/**
* SuperMemo SM-2 Algorithm helper
* Returns updated { interval, repetition, efactor, dueDate }
*/
const calculateSM2 = ({ interval = 0, repetition = 0, efactor = 2.5 }, rating, timezone = "UTC") => {
let score = 3;
if (rating === "again" || rating === "1") score = 1;
else if (rating === "hard" || rating === "2") score = 2;
else if (rating === "medium" || rating === "good" || rating === "3") score = 4;
else if (rating === "easy" || rating === "4") score = 5;

let newRepetition = repetition;
let newInterval = interval;
let newEFactor = efactor;

if (score < 3) {
// Failed recall (Again / Hard)
if (score === 1) {
newRepetition = 0;
newInterval = 1;
} else {
// Hard: keep or slight progression
newRepetition = repetition > 0 ? repetition : 1;
newInterval = repetition <= 1 ? 1 : Math.max(1, Math.round(interval * 1.2));
}
} else {
// Successful recall (Medium / Easy)
if (repetition === 0) {
newInterval = score === 5 ? 2 : 1;
} else if (repetition === 1) {
newInterval = score === 5 ? 7 : 6;
} else {
const multiplier = score === 5 ? newEFactor * 1.3 : newEFactor;
newInterval = Math.max(1, Math.round(interval * multiplier));
}
newRepetition += 1;
}

// Update Ease Factor (EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)))
const q = score;
newEFactor = newEFactor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02));
if (newEFactor < 1.3) newEFactor = 1.3;
newEFactor = Math.round(newEFactor * 100 + Number.EPSILON) / 100;

const now = new Date();

let nextDueDate;
try {
nextDueDate = getMidnightInTimezone(now, newInterval, timezone);
} catch (error) {
// Fallback if timezone is invalid
nextDueDate = new Date(now.getTime() + newInterval * 24 * 60 * 60 * 1000);
}

return {
interval: newInterval,
repetition: newRepetition,
efactor: newEFactor,
dueDate: nextDueDate,
};
};

module.exports = { calculateSM2, getMidnightInTimezone };