From f8e4be9b36a16969ac53bd9ce12ce8064be10196 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
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 <noreply@anthropic.com>
---
 src/client/pages/DeckDetailPage.tsx | 494 +++++++++++++++++-------------------
 1 file changed, 228 insertions(+), 266 deletions(-)

(limited to 'src/client/pages/DeckDetailPage.tsx')

diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx
index f9b50f2..1376fab 100644
--- a/src/client/pages/DeckDetailPage.tsx
+++ b/src/client/pages/DeckDetailPage.tsx
@@ -6,44 +6,25 @@ import {
 	faLayerGroup,
 	faPen,
 	faPlus,
-	faSpinner,
 	faTrash,
 } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useAtomValue, useSetAtom } from "jotai";
+import { Suspense, useMemo, useState, useTransition } from "react";
 import { Link, useParams } from "wouter";
-import { ApiClientError, apiClient } from "../api";
+import { type Card, cardsByDeckAtomFamily, deckByIdAtomFamily } from "../atoms";
 import { CreateNoteModal } from "../components/CreateNoteModal";
 import { DeleteCardModal } from "../components/DeleteCardModal";
 import { DeleteNoteModal } from "../components/DeleteNoteModal";
 import { EditCardModal } from "../components/EditCardModal";
 import { EditNoteModal } from "../components/EditNoteModal";
+import { ErrorBoundary } from "../components/ErrorBoundary";
 import { ImportNotesModal } from "../components/ImportNotesModal";
-
-interface Card {
-	id: string;
-	deckId: string;
-	noteId: string;
-	isReversed: boolean;
-	front: string;
-	back: string;
-	state: number;
-	due: string;
-	reps: number;
-	lapses: number;
-	createdAt: string;
-	updatedAt: string;
-}
+import { LoadingSpinner } from "../components/LoadingSpinner";
 
 /** Combined type for display: note group */
 type CardDisplayItem = { type: "note"; noteId: string; cards: Card[] };
 
-interface Deck {
-	id: string;
-	name: string;
-	description: string | null;
-}
-
 const CardStateLabels: Record<number, string> = {
 	0: "New",
 	1: "Learning",
@@ -178,18 +159,31 @@ function NoteGroupCard({
 	);
 }
 
-export function DeckDetailPage() {
-	const { deckId } = useParams<{ deckId: string }>();
-	const [deck, setDeck] = useState<Deck | null>(null);
-	const [cards, setCards] = useState<Card[]>([]);
-	const [isLoading, setIsLoading] = useState(true);
-	const [error, setError] = useState<string | null>(null);
-	const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
-	const [isImportModalOpen, setIsImportModalOpen] = useState(false);
-	const [editingCard, setEditingCard] = useState<Card | null>(null);
-	const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
-	const [deletingCard, setDeletingCard] = useState<Card | null>(null);
-	const [deletingNoteId, setDeletingNoteId] = useState<string | null>(null);
+function DeckHeader({ deckId }: { deckId: string }) {
+	const deck = useAtomValue(deckByIdAtomFamily(deckId));
+
+	return (
+		<div className="mb-8">
+			<h1 className="font-display text-3xl font-semibold text-ink mb-2">
+				{deck.name}
+			</h1>
+			{deck.description && <p className="text-muted">{deck.description}</p>}
+		</div>
+	);
+}
+
+function CardList({
+	deckId,
+	onEditNote,
+	onDeleteNote,
+	onCreateNote,
+}: {
+	deckId: string;
+	onEditNote: (noteId: string) => void;
+	onDeleteNote: (noteId: string) => void;
+	onCreateNote: () => void;
+}) {
+	const cards = useAtomValue(cardsByDeckAtomFamily(deckId));
 
 	// Group cards by note for display
 	const displayItems = useMemo((): CardDisplayItem[] => {
@@ -230,46 +224,153 @@ export function DeckDetailPage() {
 		return items;
 	}, [cards]);
 
-	const fetchDeck = useCallback(async () => {
-		if (!deckId) return;
+	if (cards.length === 0) {
+		return (
+			<div className="text-center py-12 bg-white rounded-xl border border-border/50">
+				<div className="w-14 h-14 mx-auto mb-4 bg-ivory rounded-xl flex items-center justify-center">
+					<FontAwesomeIcon
+						icon={faFile}
+						className="w-7 h-7 text-muted"
+						aria-hidden="true"
+					/>
+				</div>
+				<h3 className="font-display text-lg font-medium text-slate mb-2">
+					No cards yet
+				</h3>
+				<p className="text-muted text-sm mb-4">Add notes to start studying</p>
+				<button
+					type="button"
+					onClick={onCreateNote}
+					className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200"
+				>
+					<FontAwesomeIcon
+						icon={faPlus}
+						className="w-5 h-5"
+						aria-hidden="true"
+					/>
+					Add Your First Note
+				</button>
+			</div>
+		);
+	}
 
-		const res = await apiClient.rpc.api.decks[":id"].$get({
-			param: { id: deckId },
-		});
-		const data = await apiClient.handleResponse<{ deck: Deck }>(res);
-		setDeck(data.deck);
-	}, [deckId]);
+	return (
+		<div className="space-y-4">
+			{displayItems.map((item, index) => (
+				<NoteGroupCard
+					key={item.noteId}
+					noteId={item.noteId}
+					cards={item.cards}
+					index={index}
+					onEditNote={() => onEditNote(item.noteId)}
+					onDeleteNote={() => onDeleteNote(item.noteId)}
+				/>
+			))}
+		</div>
+	);
+}
 
-	const fetchCards = useCallback(async () => {
-		if (!deckId) return;
+function DeckContent({
+	deckId,
+	onCreateNote,
+	onImportNotes,
+	onEditNote,
+	onDeleteNote,
+}: {
+	deckId: string;
+	onCreateNote: () => void;
+	onImportNotes: () => void;
+	onEditNote: (noteId: string) => void;
+	onDeleteNote: (noteId: string) => void;
+}) {
+	const cards = useAtomValue(cardsByDeckAtomFamily(deckId));
 
-		const res = await apiClient.rpc.api.decks[":deckId"].cards.$get({
-			param: { deckId },
-		});
-		const data = await apiClient.handleResponse<{ cards: Card[] }>(res);
-		setCards(data.cards);
-	}, [deckId]);
-
-	const fetchData = useCallback(async () => {
-		setIsLoading(true);
-		setError(null);
-
-		try {
-			await Promise.all([fetchDeck(), fetchCards()]);
-		} catch (err) {
-			if (err instanceof ApiClientError) {
-				setError(err.message);
-			} else {
-				setError("Failed to load data. Please try again.");
-			}
-		} finally {
-			setIsLoading(false);
-		}
-	}, [fetchDeck, fetchCards]);
+	return (
+		<div className="animate-fade-in">
+			{/* Deck Header */}
+			<ErrorBoundary>
+				<Suspense fallback={<LoadingSpinner />}>
+					<DeckHeader deckId={deckId} />
+				</Suspense>
+			</ErrorBoundary>
+
+			{/* Study Button */}
+			<div className="mb-8">
+				<Link
+					href={`/decks/${deckId}/study`}
+					className="inline-flex items-center gap-2 bg-success hover:bg-success/90 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md"
+				>
+					<FontAwesomeIcon
+						icon={faCirclePlay}
+						className="w-5 h-5"
+						aria-hidden="true"
+					/>
+					Study Now
+				</Link>
+			</div>
 
-	useEffect(() => {
-		fetchData();
-	}, [fetchData]);
+			{/* Cards Section */}
+			<div className="flex items-center justify-between mb-6">
+				<h2 className="font-display text-xl font-medium text-slate">
+					Cards <span className="text-muted font-normal">({cards.length})</span>
+				</h2>
+				<div className="flex items-center gap-2">
+					<button
+						type="button"
+						onClick={onImportNotes}
+						className="inline-flex items-center gap-2 border border-border hover:bg-ivory text-slate font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
+					>
+						<FontAwesomeIcon
+							icon={faFileImport}
+							className="w-5 h-5"
+							aria-hidden="true"
+						/>
+						Import CSV
+					</button>
+					<button
+						type="button"
+						onClick={onCreateNote}
+						className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
+					>
+						<FontAwesomeIcon
+							icon={faPlus}
+							className="w-5 h-5"
+							aria-hidden="true"
+						/>
+						Add Note
+					</button>
+				</div>
+			</div>
+
+			{/* Card List */}
+			<CardList
+				deckId={deckId}
+				onEditNote={onEditNote}
+				onDeleteNote={onDeleteNote}
+				onCreateNote={onCreateNote}
+			/>
+		</div>
+	);
+}
+
+export function DeckDetailPage() {
+	const { deckId } = useParams<{ deckId: string }>();
+	const [, startTransition] = useTransition();
+
+	const reloadCards = useSetAtom(cardsByDeckAtomFamily(deckId || ""));
+
+	const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+	const [isImportModalOpen, setIsImportModalOpen] = useState(false);
+	const [editingCard, setEditingCard] = useState<Card | null>(null);
+	const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
+	const [deletingCard, setDeletingCard] = useState<Card | null>(null);
+	const [deletingNoteId, setDeletingNoteId] = useState<string | null>(null);
+
+	const handleCardMutation = () => {
+		startTransition(() => {
+			reloadCards();
+		});
+	};
 
 	if (!deckId) {
 		return (
@@ -308,204 +409,65 @@ export function DeckDetailPage() {
 
 			{/* Main Content */}
 			<main className="max-w-4xl mx-auto px-4 py-8">
-				{/* Loading State */}
-				{isLoading && (
-					<div className="flex items-center justify-center py-12">
-						<FontAwesomeIcon
-							icon={faSpinner}
-							className="h-8 w-8 text-primary animate-spin"
-							aria-hidden="true"
+				<ErrorBoundary>
+					<Suspense fallback={<LoadingSpinner />}>
+						<DeckContent
+							deckId={deckId}
+							onCreateNote={() => setIsCreateModalOpen(true)}
+							onImportNotes={() => setIsImportModalOpen(true)}
+							onEditNote={setEditingNoteId}
+							onDeleteNote={setDeletingNoteId}
 						/>
-					</div>
-				)}
-
-				{/* Error State */}
-				{error && (
-					<div
-						role="alert"
-						className="bg-error/5 border border-error/20 rounded-xl p-4 flex items-center justify-between"
-					>
-						<span className="text-error">{error}</span>
-						<button
-							type="button"
-							onClick={fetchData}
-							className="text-error hover:text-error/80 font-medium text-sm"
-						>
-							Retry
-						</button>
-					</div>
-				)}
-
-				{/* Deck Content */}
-				{!isLoading && !error && deck && (
-					<div className="animate-fade-in">
-						{/* Deck Header */}
-						<div className="mb-8">
-							<h1 className="font-display text-3xl font-semibold text-ink mb-2">
-								{deck.name}
-							</h1>
-							{deck.description && (
-								<p className="text-muted">{deck.description}</p>
-							)}
-						</div>
-
-						{/* Study Button */}
-						<div className="mb-8">
-							<Link
-								href={`/decks/${deckId}/study`}
-								className="inline-flex items-center gap-2 bg-success hover:bg-success/90 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md"
-							>
-								<FontAwesomeIcon
-									icon={faCirclePlay}
-									className="w-5 h-5"
-									aria-hidden="true"
-								/>
-								Study Now
-							</Link>
-						</div>
-
-						{/* Cards Section */}
-						<div className="flex items-center justify-between mb-6">
-							<h2 className="font-display text-xl font-medium text-slate">
-								Cards{" "}
-								<span className="text-muted font-normal">({cards.length})</span>
-							</h2>
-							<div className="flex items-center gap-2">
-								<button
-									type="button"
-									onClick={() => setIsImportModalOpen(true)}
-									className="inline-flex items-center gap-2 border border-border hover:bg-ivory text-slate font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
-								>
-									<FontAwesomeIcon
-										icon={faFileImport}
-										className="w-5 h-5"
-										aria-hidden="true"
-									/>
-									Import CSV
-								</button>
-								<button
-									type="button"
-									onClick={() => setIsCreateModalOpen(true)}
-									className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
-								>
-									<FontAwesomeIcon
-										icon={faPlus}
-										className="w-5 h-5"
-										aria-hidden="true"
-									/>
-									Add Note
-								</button>
-							</div>
-						</div>
-
-						{/* Empty State */}
-						{cards.length === 0 && (
-							<div className="text-center py-12 bg-white rounded-xl border border-border/50">
-								<div className="w-14 h-14 mx-auto mb-4 bg-ivory rounded-xl flex items-center justify-center">
-									<FontAwesomeIcon
-										icon={faFile}
-										className="w-7 h-7 text-muted"
-										aria-hidden="true"
-									/>
-								</div>
-								<h3 className="font-display text-lg font-medium text-slate mb-2">
-									No cards yet
-								</h3>
-								<p className="text-muted text-sm mb-4">
-									Add notes to start studying
-								</p>
-								<button
-									type="button"
-									onClick={() => setIsCreateModalOpen(true)}
-									className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200"
-								>
-									<FontAwesomeIcon
-										icon={faPlus}
-										className="w-5 h-5"
-										aria-hidden="true"
-									/>
-									Add Your First Note
-								</button>
-							</div>
-						)}
-
-						{/* Card List - Grouped by Note */}
-						{cards.length > 0 && (
-							<div className="space-y-4">
-								{displayItems.map((item, index) => (
-									<NoteGroupCard
-										key={item.noteId}
-										noteId={item.noteId}
-										cards={item.cards}
-										index={index}
-										onEditNote={() => setEditingNoteId(item.noteId)}
-										onDeleteNote={() => setDeletingNoteId(item.noteId)}
-									/>
-								))}
-							</div>
-						)}
-					</div>
-				)}
+					</Suspense>
+				</ErrorBoundary>
 			</main>
 
 			{/* Modals */}
-			{deckId && (
-				<CreateNoteModal
-					isOpen={isCreateModalOpen}
-					deckId={deckId}
-					onClose={() => setIsCreateModalOpen(false)}
-					onNoteCreated={fetchCards}
-				/>
-			)}
-
-			{deckId && (
-				<ImportNotesModal
-					isOpen={isImportModalOpen}
-					deckId={deckId}
-					onClose={() => setIsImportModalOpen(false)}
-					onImportComplete={fetchCards}
-				/>
-			)}
-
-			{deckId && (
-				<EditCardModal
-					isOpen={editingCard !== null}
-					deckId={deckId}
-					card={editingCard}
-					onClose={() => setEditingCard(null)}
-					onCardUpdated={fetchCards}
-				/>
-			)}
-
-			{deckId && (
-				<EditNoteModal
-					isOpen={editingNoteId !== null}
-					deckId={deckId}
-					noteId={editingNoteId}
-					onClose={() => setEditingNoteId(null)}
-					onNoteUpdated={fetchCards}
-				/>
-			)}
-
-			{deckId && (
-				<DeleteCardModal
-					isOpen={deletingCard !== null}
-					deckId={deckId}
-					card={deletingCard}
-					onClose={() => setDeletingCard(null)}
-					onCardDeleted={fetchCards}
-				/>
-			)}
-
-			{deckId && (
-				<DeleteNoteModal
-					isOpen={deletingNoteId !== null}
-					deckId={deckId}
-					noteId={deletingNoteId}
-					onClose={() => setDeletingNoteId(null)}
-					onNoteDeleted={fetchCards}
-				/>
-			)}
+			<CreateNoteModal
+				isOpen={isCreateModalOpen}
+				deckId={deckId}
+				onClose={() => setIsCreateModalOpen(false)}
+				onNoteCreated={handleCardMutation}
+			/>
+
+			<ImportNotesModal
+				isOpen={isImportModalOpen}
+				deckId={deckId}
+				onClose={() => setIsImportModalOpen(false)}
+				onImportComplete={handleCardMutation}
+			/>
+
+			<EditCardModal
+				isOpen={editingCard !== null}
+				deckId={deckId}
+				card={editingCard}
+				onClose={() => setEditingCard(null)}
+				onCardUpdated={handleCardMutation}
+			/>
+
+			<EditNoteModal
+				isOpen={editingNoteId !== null}
+				deckId={deckId}
+				noteId={editingNoteId}
+				onClose={() => setEditingNoteId(null)}
+				onNoteUpdated={handleCardMutation}
+			/>
+
+			<DeleteCardModal
+				isOpen={deletingCard !== null}
+				deckId={deckId}
+				card={deletingCard}
+				onClose={() => setDeletingCard(null)}
+				onCardDeleted={handleCardMutation}
+			/>
+
+			<DeleteNoteModal
+				isOpen={deletingNoteId !== null}
+				deckId={deckId}
+				noteId={deletingNoteId}
+				onClose={() => setDeletingNoteId(null)}
+				onNoteDeleted={handleCardMutation}
+			/>
 		</div>
 	);
 }
-- 
cgit v1.2.3-70-g09d2

