From 8f1a08fefee3a8e928baec741c830a88a4cd7200 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 2 May 2026 10:41:12 +0900 Subject: 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) --- src/shared/fsrs.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/shared/fsrs.ts (limited to 'src/shared/fsrs.ts') 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, + }; +} -- cgit v1.3.1