diff options
Diffstat (limited to 'src/shared')
| -rw-r--r-- | src/shared/fsrs.test.ts | 90 | ||||
| -rw-r--r-- | src/shared/fsrs.ts | 74 |
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, + }; +} |
