aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages/StudyPage.tsx
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/client/pages/StudyPage.tsx
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/client/pages/StudyPage.tsx')
-rw-r--r--src/client/pages/StudyPage.tsx129
1 files changed, 52 insertions, 77 deletions
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<Rating, string> = {
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<string | null>(null);
const [completedCount, setCompletedCount] = useState(0);
const cardStartTimeRef = useRef<number>(Date.now());
- const [pendingReview, setPendingReview] = useState<PendingReview | null>(
- null,
- );
- const pendingReviewRef = useRef<PendingReview | null>(null);
+ const [lastReview, setLastReview] = useState<LastReview | null>(null);
const [editingNoteId, setEditingNoteId] = useState<string | null>(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" : ""}
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
- {pendingReview && (
+ {lastReview && (
<button
type="button"
data-testid="undo-button"
@@ -406,7 +381,7 @@ function StudySession({
{/* Top-right action buttons */}
<div className="absolute top-3 right-3 flex items-center gap-1">
{/* Undo button */}
- {pendingReview && !isFlipped && (
+ {lastReview && !isFlipped && (
/* biome-ignore lint/a11y/useSemanticElements: Cannot nest <button> inside parent <button>, using span with role="button" instead */
<span
role="button"