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/client/pages/StudyPage.tsx | 129 +++++++++++++++++------------------------ 1 file changed, 52 insertions(+), 77 deletions(-) (limited to 'src/client/pages/StudyPage.tsx') diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index 584f543..fed8b36 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -6,7 +6,7 @@ import { faRotateLeft, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { Suspense, useCallback, @@ -16,16 +16,16 @@ import { useState, } from "react"; import { Link, useLocation, useParams } from "wouter"; -import { ApiClientError, apiClient } from "../api"; -import { studyDataAtomFamily } from "../atoms"; +import { isOnlineAtom, studyDataAtomFamily, syncActionAtom } from "../atoms"; import { EditNoteModal } from "../components/EditNoteModal"; import { ErrorBoundary } from "../components/ErrorBoundary"; -import type { CardStateType } from "../db"; +import type { CardStateType, LocalCard, RatingType } from "../db"; import { queryClient } from "../queryClient"; +import { submitReviewLocal, undoReviewLocal } from "../sync"; import { renderCard } from "../utils/templateRenderer"; -type Rating = 1 | 2 | 3 | 4; -type PendingReview = { cardId: string; rating: Rating; durationMs: number }; +type Rating = RatingType; +type LastReview = { prevCard: LocalCard; reviewLogId: string }; const RatingLabels: Record = { 1: "Again", @@ -61,6 +61,8 @@ function StudySession({ const { data: { deck, cards }, } = useAtomValue(studyDataAtomFamily(deckId)); + const isOnline = useAtomValue(isOnlineAtom); + const triggerSync = useSetAtom(syncActionAtom); // Session state (kept as useState - transient UI state) const [currentIndex, setCurrentIndex] = useState(0); @@ -69,17 +71,9 @@ function StudySession({ const [submitError, setSubmitError] = useState(null); const [completedCount, setCompletedCount] = useState(0); const cardStartTimeRef = useRef(Date.now()); - const [pendingReview, setPendingReview] = useState( - null, - ); - const pendingReviewRef = useRef(null); + const [lastReview, setLastReview] = useState(null); const [editingNoteId, setEditingNoteId] = useState(null); - // Keep ref in sync with state for cleanup effect - useEffect(() => { - pendingReviewRef.current = pendingReview; - }, [pendingReview]); - // biome-ignore lint/correctness/useExhaustiveDependencies: Reset timer when card changes useEffect(() => { cardStartTimeRef.current = Date.now(); @@ -89,19 +83,6 @@ function StudySession({ setIsFlipped(true); }, []); - const flushPendingReview = useCallback( - async (review: PendingReview) => { - const res = await apiClient.rpc.api.decks[":deckId"].study[ - ":cardId" - ].$post({ - param: { deckId, cardId: review.cardId }, - json: { rating: review.rating, durationMs: review.durationMs }, - }); - await apiClient.handleResponse(res); - }, - [deckId], - ); - const handleRating = useCallback( async (rating: Rating) => { if (isSubmitting) return; @@ -114,36 +95,50 @@ function StudySession({ const durationMs = Date.now() - cardStartTimeRef.current; - // Flush previous pending review first - if (pendingReview) { - try { - await flushPendingReview(pendingReview); - } catch (err) { - if (err instanceof ApiClientError) { - setSubmitError(err.message); - } else { - setSubmitError("Failed to submit review. Please try again."); - } + try { + const result = await submitReviewLocal({ + cardId: currentCard.id, + rating, + durationMs, + }); + setLastReview({ + prevCard: result.prevCard, + reviewLogId: result.reviewLogId, + }); + setCompletedCount((prev) => prev + 1); + setIsFlipped(false); + setCurrentIndex((prev) => prev + 1); + + if (isOnline) { + // Fire-and-forget: sync runs in background; failures are + // recoverable on the next online tick. + triggerSync().catch(() => {}); } + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Failed to submit review. Please try again."; + setSubmitError(message); + } finally { + setIsSubmitting(false); } - - // Save current review as pending (don't send yet) - setPendingReview({ cardId: currentCard.id, rating, durationMs }); - setCompletedCount((prev) => prev + 1); - setIsFlipped(false); - setCurrentIndex((prev) => prev + 1); - setIsSubmitting(false); }, - [isSubmitting, cards, currentIndex, pendingReview, flushPendingReview], + [isSubmitting, cards, currentIndex, isOnline, triggerSync], ); - const handleUndo = useCallback(() => { - if (!pendingReview) return; - setPendingReview(null); + const handleUndo = useCallback(async () => { + if (!lastReview) return; + try { + await undoReviewLocal(lastReview); + } catch { + // Best-effort undo: swallow errors so the user can keep navigating. + } + setLastReview(null); setCurrentIndex((prev) => prev - 1); setCompletedCount((prev) => prev - 1); setIsFlipped(false); - }, [pendingReview]); + }, [lastReview]); const [isNavigating, setIsNavigating] = useState(false); @@ -151,39 +146,19 @@ function StudySession({ async (href: string) => { if (isNavigating) return; setIsNavigating(true); - const review = pendingReviewRef.current; - if (review) { - try { - await flushPendingReview(review); - setPendingReview(null); - } catch { - // Continue navigation even on error - } - } await queryClient.invalidateQueries({ queryKey: ["decks"] }); onNavigate(href); }, - [isNavigating, flushPendingReview, onNavigate], + [isNavigating, onNavigate], ); - // Flush pending review on unmount (fire-and-forget) + // Refresh deck queries on unmount so cached due-counts pick up the + // just-submitted reviews once they sync. useEffect(() => { return () => { - const review = pendingReviewRef.current; - if (review) { - apiClient.rpc.api.decks[":deckId"].study[":cardId"] - .$post({ - param: { deckId, cardId: review.cardId }, - json: { rating: review.rating, durationMs: review.durationMs }, - }) - .then((res) => apiClient.handleResponse(res)) - .then(() => queryClient.invalidateQueries({ queryKey: ["decks"] })) - .catch(() => {}); - } else { - queryClient.invalidateQueries({ queryKey: ["decks"] }); - } + queryClient.invalidateQueries({ queryKey: ["decks"] }); }; - }, [deckId]); + }, []); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -350,7 +325,7 @@ function StudySession({ card{completedCount !== 1 ? "s" : ""}

- {pendingReview && ( + {lastReview && (