aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/shared
diff options
context:
space:
mode:
Diffstat (limited to 'src/shared')
-rw-r--r--src/shared/fsrs.test.ts90
-rw-r--r--src/shared/fsrs.ts74
2 files changed, 164 insertions, 0 deletions
diff --git a/src/shared/fsrs.test.ts b/src/shared/fsrs.test.ts
new file mode 100644
index 0000000..83f2309
--- /dev/null
+++ b/src/shared/fsrs.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from "vitest";
+import { computeNextSchedule, type ScheduleInput } from "./fsrs";
+
+const baseCard: ScheduleInput = {
+ state: 0,
+ due: new Date("2026-05-01T00:00:00Z"),
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+};
+
+describe("computeNextSchedule", () => {
+ it("schedules a new card forward when rated Good", () => {
+ const now = new Date("2026-05-02T10:00:00Z");
+ const result = computeNextSchedule(baseCard, 3, now);
+
+ expect(result.reps).toBe(1);
+ expect(result.lastReview.getTime()).toBe(now.getTime());
+ expect(result.due.getTime()).toBeGreaterThan(now.getTime());
+ expect(result.stability).toBeGreaterThan(0);
+ expect(result.difficulty).toBeGreaterThan(0);
+ });
+
+ it("counts a lapse when a Review-state card is rated Again", () => {
+ const card: ScheduleInput = {
+ state: 2,
+ due: new Date("2026-05-01T00:00:00Z"),
+ stability: 10,
+ difficulty: 5,
+ elapsedDays: 5,
+ scheduledDays: 5,
+ reps: 3,
+ lapses: 0,
+ lastReview: new Date("2026-04-26T00:00:00Z"),
+ };
+ const now = new Date("2026-05-02T10:00:00Z");
+ const result = computeNextSchedule(card, 1, now);
+
+ expect(result.lapses).toBe(1);
+ });
+
+ it("computes reviewElapsedDays from previous lastReview", () => {
+ const lastReview = new Date("2026-04-29T00:00:00Z");
+ const card: ScheduleInput = {
+ ...baseCard,
+ state: 2,
+ stability: 5,
+ difficulty: 5,
+ lastReview,
+ reps: 1,
+ };
+ const now = new Date("2026-05-02T00:00:00Z");
+ const result = computeNextSchedule(card, 3, now);
+
+ expect(result.reviewElapsedDays).toBe(3);
+ });
+
+ it("uses 0 reviewElapsedDays for a card without lastReview", () => {
+ const now = new Date("2026-05-02T00:00:00Z");
+ const result = computeNextSchedule(baseCard, 3, now);
+
+ expect(result.reviewElapsedDays).toBe(0);
+ });
+
+ it("higher ratings yield longer scheduled intervals than lower ratings", () => {
+ const card: ScheduleInput = {
+ state: 2,
+ due: new Date("2026-05-01T00:00:00Z"),
+ stability: 10,
+ difficulty: 5,
+ elapsedDays: 10,
+ scheduledDays: 10,
+ reps: 5,
+ lapses: 0,
+ lastReview: new Date("2026-04-21T00:00:00Z"),
+ };
+ const now = new Date("2026-05-02T00:00:00Z");
+
+ const hard = computeNextSchedule(card, 2, now);
+ const good = computeNextSchedule(card, 3, now);
+ const easy = computeNextSchedule(card, 4, now);
+
+ expect(easy.scheduledDays).toBeGreaterThanOrEqual(good.scheduledDays);
+ expect(good.scheduledDays).toBeGreaterThanOrEqual(hard.scheduledDays);
+ });
+});
diff --git a/src/shared/fsrs.ts b/src/shared/fsrs.ts
new file mode 100644
index 0000000..1cf60ba
--- /dev/null
+++ b/src/shared/fsrs.ts
@@ -0,0 +1,74 @@
+import {
+ type Card as FSRSCard,
+ type State as FSRSState,
+ fsrs,
+ type Grade,
+} from "ts-fsrs";
+
+const f = fsrs({ enable_fuzz: true });
+
+export interface ScheduleInput {
+ state: number;
+ due: Date;
+ stability: number;
+ difficulty: number;
+ elapsedDays: number;
+ scheduledDays: number;
+ reps: number;
+ lapses: number;
+ lastReview: Date | null;
+}
+
+export interface ScheduleResult {
+ state: number;
+ due: Date;
+ stability: number;
+ difficulty: number;
+ elapsedDays: number;
+ scheduledDays: number;
+ reps: number;
+ lapses: number;
+ lastReview: Date;
+ /** Days elapsed since the previous review (for ReviewLog). */
+ reviewElapsedDays: number;
+}
+
+export function computeNextSchedule(
+ card: ScheduleInput,
+ rating: 1 | 2 | 3 | 4,
+ now: Date,
+): ScheduleResult {
+ const fsrsCard: FSRSCard = {
+ due: card.due,
+ stability: card.stability,
+ difficulty: card.difficulty,
+ elapsed_days: card.elapsedDays,
+ scheduled_days: card.scheduledDays,
+ reps: card.reps,
+ lapses: card.lapses,
+ state: card.state as FSRSState,
+ last_review: card.lastReview ?? undefined,
+ learning_steps: 0,
+ };
+
+ const result = f.next(fsrsCard, now, rating as Grade);
+
+ const reviewElapsedDays = card.lastReview
+ ? Math.round(
+ (now.getTime() - card.lastReview.getTime()) / (1000 * 60 * 60 * 24),
+ )
+ : 0;
+
+ return {
+ state: result.card.state,
+ due: result.card.due,
+ stability: result.card.stability,
+ difficulty: result.card.difficulty,
+ elapsedDays: result.card.elapsed_days,
+ scheduledDays: result.card.scheduled_days,
+ reps: result.card.reps,
+ lapses: result.card.lapses,
+ lastReview: now,
+ reviewElapsedDays,
+ };
+}