import { faCheck, faChevronLeft, faCircleCheck, faPen, faRotateLeft, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useAtomValue } from "jotai"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Link, useLocation, useParams } from "wouter"; import { ApiClientError, apiClient } from "../api"; import { studyDataAtomFamily } from "../atoms"; import { EditNoteModal } from "../components/EditNoteModal"; import { ErrorBoundary } from "../components/ErrorBoundary"; import type { CardStateType } from "../db"; import { queryClient } from "../queryClient"; import { renderCard } from "../utils/templateRenderer"; type Rating = 1 | 2 | 3 | 4; type PendingReview = { cardId: string; rating: Rating; durationMs: number }; const RatingLabels: Record = { 1: "Again", 2: "Hard", 3: "Good", 4: "Easy", }; const RatingStyles: Record = { 1: "bg-again hover:bg-again/90 focus:ring-again/30", 2: "bg-hard hover:bg-hard/90 focus:ring-hard/30", 3: "bg-good hover:bg-good/90 focus:ring-good/30", 4: "bg-easy hover:bg-easy/90 focus:ring-easy/30", }; const CardStateBadge: Record< CardStateType, { label: string; className: string } > = { 0: { label: "New", className: "bg-info/10 text-info" }, 1: { label: "Learning", className: "bg-warning/10 text-warning" }, 2: { label: "Review", className: "bg-success/10 text-success" }, 3: { label: "Relearning", className: "bg-error/10 text-error" }, }; function StudySession({ deckId, onNavigate, }: { deckId: string; onNavigate: (href: string) => void; }) { const { data: { deck, cards }, } = useAtomValue(studyDataAtomFamily(deckId)); // Session state (kept as useState - transient UI state) const [currentIndex, setCurrentIndex] = useState(0); const [isFlipped, setIsFlipped] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); 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 [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(); }, [currentIndex]); const handleFlip = useCallback(() => { 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; const currentCard = cards[currentIndex]; if (!currentCard) return; setIsSubmitting(true); setSubmitError(null); 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."); } } } // 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], ); const handleUndo = useCallback(() => { if (!pendingReview) return; setPendingReview(null); setCurrentIndex((prev) => prev - 1); setCompletedCount((prev) => prev - 1); setIsFlipped(false); }, [pendingReview]); const [isNavigating, setIsNavigating] = useState(false); const handleNavigateAway = useCallback( 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], ); // Flush pending review on unmount (fire-and-forget) 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"] }); } }; }, [deckId]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (isSubmitting) return; if (editingNoteId) return; // Edit: E key to open edit modal if (e.key === "e" && cards[currentIndex]) { e.preventDefault(); setEditingNoteId(cards[currentIndex].noteId); return; } // Undo: Ctrl+Z / Cmd+Z anytime, or z when card is not flipped if ( (e.key === "z" && (e.ctrlKey || e.metaKey)) || (e.key === "z" && !e.ctrlKey && !e.metaKey && !isFlipped) ) { e.preventDefault(); handleUndo(); return; } if (!isFlipped) { if (e.key === " " || e.key === "Enter") { e.preventDefault(); handleFlip(); } } else { const keyRatingMap: Record = { "1": 1, "2": 2, "3": 3, "4": 4, " ": 3, }; const rating = keyRatingMap[e.key]; if (rating) { e.preventDefault(); handleRating(rating); } } }, [ isFlipped, isSubmitting, editingNoteId, cards, currentIndex, handleFlip, handleRating, handleUndo, ], ); useEffect(() => { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); const currentCard = cards[currentIndex]; const isSessionComplete = currentIndex >= cards.length && cards.length > 0; const hasNoCards = cards.length === 0; const remainingCards = cards.length - currentIndex; const cardContent = useMemo(() => { if (!currentCard) return null; return renderCard({ frontTemplate: currentCard.noteType.frontTemplate, backTemplate: currentCard.noteType.backTemplate, fieldValues: currentCard.fieldValuesMap, isReversed: currentCard.isReversed ?? false, }); }, [currentCard]); return (
{/* Submit Error */} {submitError && (
{submitError}
)} {/* Study Header */}

{deck.name}

{!isSessionComplete && !hasNoCards && ( {remainingCards} remaining )}
{/* Undo Button */} {pendingReview && !isFlipped && !isSessionComplete && (
)} {/* 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" : ""}

{pendingReview && ( )}
)} {/* Active Study Card */} {currentCard && cardContent && !isSessionComplete && (
{/* Card */} {/* Rating Buttons */} {isFlipped && (
{([1, 2, 3, 4] as Rating[]).map((rating) => ( ))}
)}
)} {/* Edit Note Modal */} setEditingNoteId(null)} onNoteUpdated={() => setEditingNoteId(null)} />
); } export function StudyPage() { const { deckId } = useParams<{ deckId: string }>(); const [, navigate] = useLocation(); if (!deckId) { return (

Invalid deck ID

Back to decks
); } return (
{/* Header */}
{/* Main Content */}
} >
); }