From f8e4be9b36a16969ac53bd9ce12ce8064be10196 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 4 Jan 2026 17:43:59 +0900 Subject: refactor(client): migrate state management from React Context to Jotai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace AuthProvider and SyncProvider with Jotai atoms for more granular state management and better performance. This migration: - Creates atoms for auth, sync, decks, cards, noteTypes, and study state - Uses atomFamily for parameterized state (e.g., cards by deckId) - Introduces StoreInitializer component for subscription initialization - Updates all components and pages to use useAtomValue/useSetAtom - Updates all tests to use Jotai Provider with createStore pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/pages/StudyPage.tsx | 482 ++++++++++++++++++----------------------- 1 file changed, 210 insertions(+), 272 deletions(-) (limited to 'src/client/pages/StudyPage.tsx') diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index b6c9a3b..cec11d3 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -2,42 +2,24 @@ import { faCheck, faChevronLeft, faCircleCheck, - faSpinner, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAtomValue } from "jotai"; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Link, useParams } from "wouter"; import { ApiClientError, apiClient } from "../api"; -import { shuffle } from "../utils/shuffle"; +import { studyDataAtomFamily } from "../atoms"; +import { ErrorBoundary } from "../components/ErrorBoundary"; +import { LoadingSpinner } from "../components/LoadingSpinner"; import { renderCard } from "../utils/templateRenderer"; -interface Card { - id: string; - deckId: string; - noteId: string; - isReversed: boolean; - front: string; - back: string; - state: number; - due: string; - stability: number; - difficulty: number; - reps: number; - lapses: number; - /** Note type templates for rendering */ - noteType: { - frontTemplate: string; - backTemplate: string; - }; - /** Field values as a name-value map for template rendering */ - fieldValuesMap: Record; -} - -interface Deck { - id: string; - name: string; -} - type Rating = 1 | 2 | 3 | 4; const RatingLabels: Record = { @@ -54,59 +36,17 @@ const RatingStyles: Record = { 4: "bg-easy hover:bg-easy/90 focus:ring-easy/30", }; -export function StudyPage() { - const { deckId } = useParams<{ deckId: string }>(); - const [deck, setDeck] = useState(null); - const [cards, setCards] = useState([]); +function StudySession({ deckId }: { deckId: string }) { + const { deck, cards } = useAtomValue(studyDataAtomFamily(deckId)); + + // Session state (kept as useState - transient UI state) const [currentIndex, setCurrentIndex] = useState(0); const [isFlipped, setIsFlipped] = useState(false); - const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); + const [submitError, setSubmitError] = useState(null); const [completedCount, setCompletedCount] = useState(0); const cardStartTimeRef = useRef(Date.now()); - const fetchDeck = useCallback(async () => { - if (!deckId) return; - - const res = await apiClient.rpc.api.decks[":id"].$get({ - param: { id: deckId }, - }); - const data = await apiClient.handleResponse<{ deck: Deck }>(res); - setDeck(data.deck); - }, [deckId]); - - const fetchDueCards = useCallback(async () => { - if (!deckId) return; - - const res = await apiClient.rpc.api.decks[":deckId"].study.$get({ - param: { deckId }, - }); - const data = await apiClient.handleResponse<{ cards: Card[] }>(res); - setCards(shuffle(data.cards)); - }, [deckId]); - - const fetchData = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - await Promise.all([fetchDeck(), fetchDueCards()]); - } catch (err) { - if (err instanceof ApiClientError) { - setError(err.message); - } else { - setError("Failed to load study session. Please try again."); - } - } finally { - setIsLoading(false); - } - }, [fetchDeck, fetchDueCards]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - // biome-ignore lint/correctness/useExhaustiveDependencies: Reset timer when card changes useEffect(() => { cardStartTimeRef.current = Date.now(); @@ -118,13 +58,13 @@ export function StudyPage() { const handleRating = useCallback( async (rating: Rating) => { - if (!deckId || isSubmitting) return; + if (isSubmitting) return; const currentCard = cards[currentIndex]; if (!currentCard) return; setIsSubmitting(true); - setError(null); + setSubmitError(null); const durationMs = Date.now() - cardStartTimeRef.current; @@ -142,9 +82,9 @@ export function StudyPage() { setCurrentIndex((prev) => prev + 1); } catch (err) { if (err instanceof ApiClientError) { - setError(err.message); + setSubmitError(err.message); } else { - setError("Failed to submit review. Please try again."); + setSubmitError("Failed to submit review. Please try again."); } } finally { setIsSubmitting(false); @@ -187,7 +127,7 @@ export function StudyPage() { const currentCard = cards[currentIndex]; const isSessionComplete = currentIndex >= cards.length && cards.length > 0; - const hasNoCards = !isLoading && cards.length === 0; + const hasNoCards = cards.length === 0; const remainingCards = cards.length - currentIndex; // Compute rendered card content for both legacy and note-based cards @@ -209,6 +149,189 @@ export function StudyPage() { return { front: currentCard.front, back: currentCard.back }; }, [currentCard]); + return ( +
+ {/* Submit Error */} + {submitError && ( +
+ {submitError} + +
+ )} + + {/* Study Header */} +
+

+ {deck.name} +

+ {!isSessionComplete && !hasNoCards && ( + + {remainingCards} remaining + + )} +
+ + {/* No Cards State */} + {hasNoCards && ( +
+
+
+
+

+ All caught up! +

+

+ No cards due for review right now +

+ + Back to Deck + +
+
+ )} + + {/* Session Complete State */} + {isSessionComplete && ( +
+
+
+
+

+ Session Complete! +

+

You reviewed

+

+ {completedCount} +

+

+ card{completedCount !== 1 ? "s" : ""} +

+
+ + Back to Deck + + + All Decks + +
+
+
+ )} + + {/* Active Study Card */} + {currentCard && cardContent && !isSessionComplete && ( +
+ {/* Card */} + + + {/* Rating Buttons */} + {isFlipped && ( +
+ {([1, 2, 3, 4] as Rating[]).map((rating) => ( + + ))} +
+ )} +
+ )} +
+ ); +} + +export function StudyPage() { + const { deckId } = useParams<{ deckId: string }>(); + if (!deckId) { return (
@@ -246,196 +369,11 @@ export function StudyPage() { {/* Main Content */}
- {/* Loading State */} - {isLoading && ( -
-
- )} - - {/* Error State */} - {error && ( -
- {error} - -
- )} - - {/* Study Content */} - {!isLoading && !error && deck && ( -
- {/* Study Header */} -
-

- {deck.name} -

- {!isSessionComplete && !hasNoCards && ( - - {remainingCards} remaining - - )} -
- - {/* No Cards State */} - {hasNoCards && ( -
-
-
-
-

- All caught up! -

-

- No cards due for review right now -

- - Back to Deck - -
-
- )} - - {/* Session Complete State */} - {isSessionComplete && ( -
-
-
-
-

- Session Complete! -

-

You reviewed

-

- {completedCount} -

-

- card{completedCount !== 1 ? "s" : ""} -

-
- - Back to Deck - - - All Decks - -
-
-
- )} - - {/* Active Study Card */} - {currentCard && cardContent && !isSessionComplete && ( -
- {/* Card */} - - - {/* Rating Buttons */} - {isFlipped && ( -
- {([1, 2, 3, 4] as Rating[]).map((rating) => ( - - ))} -
- )} -
- )} -
- )} + + }> + + +
); -- cgit v1.2.3-70-g09d2