aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/shared/fsrs.test.ts
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-02 10:41:12 +0900
committernsfisis <nsfisis@gmail.com>2026-05-02 10:41:12 +0900
commit8f1a08fefee3a8e928baec741c830a88a4cd7200 (patch)
tree19101c992c19e283e4fa30abafcd58cfeb401cc9 /src/shared/fsrs.test.ts
parent90b06b22e1e468cd19358536919a38ab6377fd23 (diff)
downloadkioku-8f1a08fefee3a8e928baec741c830a88a4cd7200.tar.gz
kioku-8f1a08fefee3a8e928baec741c830a88a4cd7200.tar.zst
kioku-8f1a08fefee3a8e928baec741c830a88a4cd7200.zip
feat(study): submit reviews offline via IndexedDB
Move FSRS scheduling to a shared module so the client can compute next card state without contacting the server. StudyPage now writes the updated card and review log straight to IndexedDB and lets the existing sync engine push them on reconnect, instead of POSTing to /api/decks/:deckId/study/:cardId. Online sessions still trigger a sync immediately so server-side aggregates stay fresh; offline sessions accumulate in pendingCountAtom until the next online tick. The legacy study POST route is preserved for backwards compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/shared/fsrs.test.ts')
-rw-r--r--src/shared/fsrs.test.ts90
1 files changed, 90 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);
+ });
+});