import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useParams } from "wouter"; import { ApiClientError, apiClient } from "../api"; interface Card { id: string; deckId: string; front: string; back: string; state: number; due: string; stability: number; difficulty: number; reps: number; lapses: number; } interface Deck { id: string; name: string; } type Rating = 1 | 2 | 3 | 4; 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", }; export function StudyPage() { const { deckId } = useParams<{ deckId: string }>(); const [deck, setDeck] = useState(null); const [cards, setCards] = useState([]); 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 [completedCount, setCompletedCount] = useState(0); const cardStartTimeRef = useRef(Date.now()); const fetchDeck = useCallback(async () => { if (!deckId) return; const authHeader = apiClient.getAuthHeader(); if (!authHeader) { throw new ApiClientError("Not authenticated", 401); } const res = await fetch(`/api/decks/${deckId}`, { headers: authHeader, }); if (!res.ok) { const errorBody = await res.json().catch(() => ({})); throw new ApiClientError( (errorBody as { error?: string }).error || `Request failed with status ${res.status}`, res.status, ); } const data = await res.json(); setDeck(data.deck); }, [deckId]); const fetchDueCards = useCallback(async () => { if (!deckId) return; const authHeader = apiClient.getAuthHeader(); if (!authHeader) { throw new ApiClientError("Not authenticated", 401); } const res = await fetch(`/api/decks/${deckId}/study`, { headers: authHeader, }); if (!res.ok) { const errorBody = await res.json().catch(() => ({})); throw new ApiClientError( (errorBody as { error?: string }).error || `Request failed with status ${res.status}`, res.status, ); } const data = await res.json(); setCards(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(); }, [currentIndex]); const handleFlip = useCallback(() => { setIsFlipped(true); }, []); const handleRating = useCallback( async (rating: Rating) => { if (!deckId || isSubmitting) return; const currentCard = cards[currentIndex]; if (!currentCard) return; setIsSubmitting(true); setError(null); const durationMs = Date.now() - cardStartTimeRef.current; try { const authHeader = apiClient.getAuthHeader(); if (!authHeader) { throw new ApiClientError("Not authenticated", 401); } const res = await fetch( `/api/decks/${deckId}/study/${currentCard.id}`, { method: "POST", headers: { ...authHeader, "Content-Type": "application/json", }, body: JSON.stringify({ rating, durationMs }), }, ); if (!res.ok) { const errorBody = await res.json().catch(() => ({})); throw new ApiClientError( (errorBody as { error?: string }).error || `Request failed with status ${res.status}`, res.status, ); } setCompletedCount((prev) => prev + 1); setIsFlipped(false); setCurrentIndex((prev) => prev + 1); } catch (err) { if (err instanceof ApiClientError) { setError(err.message); } else { setError("Failed to submit review. Please try again."); } } finally { setIsSubmitting(false); } }, [deckId, isSubmitting, cards, currentIndex], ); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (isSubmitting) return; if (!isFlipped) { if (e.key === " " || e.key === "Enter") { e.preventDefault(); handleFlip(); } } else { const keyRatingMap: Record = { "1": 1, "2": 2, "3": 3, "4": 4, }; const rating = keyRatingMap[e.key]; if (rating) { e.preventDefault(); handleRating(rating); } } }, [isFlipped, isSubmitting, handleFlip, handleRating], ); useEffect(() => { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); if (!deckId) { return (

Invalid deck ID

Back to decks
); } const currentCard = cards[currentIndex]; const isSessionComplete = currentIndex >= cards.length && cards.length > 0; const hasNoCards = !isLoading && cards.length === 0; const remainingCards = cards.length - currentIndex; return (
{/* Header */}
Back to Deck
{/* 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 && !isSessionComplete && (
{/* Card */} {/* Rating Buttons */} {isFlipped && (
{([1, 2, 3, 4] as Rating[]).map((rating) => ( ))}
)}
)}
)}
); }