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 RatingColors: Record = { 1: "#dc3545", 2: "#fd7e14", 3: "#28a745", 4: "#007bff", }; 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]); useEffect(() => { cardStartTimeRef.current = Date.now(); }, [currentIndex]); const handleFlip = () => { setIsFlipped(true); }; const handleRating = 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); } }; 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], ); 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 (
← Back to Deck
{isLoading &&

Loading study session...

} {error && (
{error}
)} {!isLoading && !error && deck && ( <>

Study: {deck.name}

{!isSessionComplete && !hasNoCards && ( {remainingCards} remaining )}
{hasNoCards && (

No cards to study

There are no due cards in this deck right now.

)} {isSessionComplete && (

Session Complete!

You reviewed{" "} {completedCount}{" "} card{completedCount !== 1 ? "s" : ""}.

)} {currentCard && !isSessionComplete && (
{ if (!isFlipped && (e.key === " " || e.key === "Enter")) { e.preventDefault(); handleFlip(); } }} role="button" tabIndex={0} aria-label={isFlipped ? "Card showing answer" : "Click to reveal answer"} style={{ border: "1px solid #ccc", borderRadius: "8px", padding: "2rem", minHeight: "200px", display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", cursor: isFlipped ? "default" : "pointer", backgroundColor: isFlipped ? "#f8f9fa" : "white", transition: "background-color 0.2s", }} > {!isFlipped ? ( <>

{currentCard.front}

Click or press Space to reveal

) : ( <>

{currentCard.back}

)}
{isFlipped && (
{([1, 2, 3, 4] as Rating[]).map((rating) => ( ))}
)}
)} )}
); }