diff --git a/backend/controllers/flashcardController.js b/backend/controllers/flashcardController.js index b346921b..e3ac1ced 100644 --- a/backend/controllers/flashcardController.js +++ b/backend/controllers/flashcardController.js @@ -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 @@ -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 @@ -190,7 +137,8 @@ const reviewFlashcard = async (req, res) => { repetition: flashcard.repetition, efactor: flashcard.efactor, }, - rating + rating, + timezone ); flashcard.interval = sm2Result.interval; @@ -308,4 +256,4 @@ module.exports = { deleteFlashcard, getFlashcardStats, calculateSM2, -}; \ No newline at end of file +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index e435f21c..6b07f232 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -2753,67 +2753,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/mongoose/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mongoose/node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mongoose/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/mongoose/node_modules/mongodb": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", diff --git a/backend/tests/srsAlgorithm.test.js b/backend/tests/srsAlgorithm.test.js new file mode 100644 index 00000000..eca3f5e7 --- /dev/null +++ b/backend/tests/srsAlgorithm.test.js @@ -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"); + }); + }); +}); diff --git a/backend/utils/srsAlgorithm.js b/backend/utils/srsAlgorithm.js new file mode 100644 index 00000000..15998e40 --- /dev/null +++ b/backend/utils/srsAlgorithm.js @@ -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 };