From f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 7 Dec 2025 18:50:08 +0900 Subject: feat(client): add study session page with card flip and rating UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the complete study flow frontend: - Study session page with card display and flip interaction - Rating buttons (Again, Hard, Good, Easy) with keyboard shortcuts - Progress display showing remaining cards count - Session complete screen with review summary - Study Now button on deck detail page ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/dev/roadmap.md | 12 +- src/client/App.tsx | 13 +- src/client/pages/DeckDetailPage.tsx | 24 ++ src/client/pages/StudyPage.test.tsx | 762 ++++++++++++++++++++++++++++++++++++ src/client/pages/StudyPage.tsx | 441 +++++++++++++++++++++ src/client/pages/index.ts | 1 + 6 files changed, 1246 insertions(+), 7 deletions(-) create mode 100644 src/client/pages/StudyPage.test.tsx create mode 100644 src/client/pages/StudyPage.tsx diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 40ef7e1..b62fea5 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -124,12 +124,12 @@ Smaller features first to enable early MVP validation. - [x] Add tests ### Frontend -- [ ] Study session page -- [ ] Card flip interaction -- [ ] Rating buttons (Again, Hard, Good, Easy) -- [ ] Progress display (remaining cards) -- [ ] Session complete screen -- [ ] Add tests +- [x] Study session page +- [x] Card flip interaction +- [x] Rating buttons (Again, Hard, Good, Easy) +- [x] Progress display (remaining cards) +- [x] Session complete screen +- [x] Add tests **โœ… Milestone**: MVP complete - basic study flow works diff --git a/src/client/App.tsx b/src/client/App.tsx index 4c5b08a..f774003 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,6 +1,12 @@ import { Route, Switch } from "wouter"; import { ProtectedRoute } from "./components"; -import { DeckDetailPage, HomePage, LoginPage, NotFoundPage } from "./pages"; +import { + DeckDetailPage, + HomePage, + LoginPage, + NotFoundPage, + StudyPage, +} from "./pages"; export function App() { return ( @@ -15,6 +21,11 @@ export function App() { + + + + + diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index cdc216a..3d7ffb5 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -155,6 +155,30 @@ export function DeckDetailPage() { )} +
+ + + +
+
({ + apiClient: { + login: vi.fn(), + logout: vi.fn(), + isAuthenticated: vi.fn(), + getTokens: vi.fn(), + getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + $get: vi.fn(), + $post: vi.fn(), + }, + }, + }, + }, + ApiClientError: class ApiClientError extends Error { + constructor( + message: string, + public status: number, + public code?: string, + ) { + super(message); + this.name = "ApiClientError"; + } + }, +})); + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const mockDeck = { + id: "deck-1", + name: "Japanese Vocabulary", + description: "Common Japanese words", + newCardsPerDay: 20, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", +}; + +const mockDueCards = [ + { + id: "card-1", + deckId: "deck-1", + front: "Hello", + back: "ใ“ใ‚“ใซใกใฏ", + state: 0, + due: "2024-01-01T00:00:00Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + deletedAt: null, + syncVersion: 0, + }, + { + id: "card-2", + deckId: "deck-1", + front: "Goodbye", + back: "ใ•ใ‚ˆใ†ใชใ‚‰", + state: 0, + due: "2024-01-01T00:00:00Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + deletedAt: null, + syncVersion: 0, + }, +]; + +function renderWithProviders(path = "/decks/deck-1/study") { + const { hook } = memoryLocation({ path, static: true }); + return render( + + + + + + + , + ); +} + +describe("StudyPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiClient.getTokens).mockReturnValue({ + accessToken: "access-token", + refreshToken: "refresh-token", + }); + vi.mocked(apiClient.isAuthenticated).mockReturnValue(true); + vi.mocked(apiClient.getAuthHeader).mockReturnValue({ + Authorization: "Bearer access-token", + }); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + describe("Loading and Initial State", () => { + it("shows loading state while fetching data", async () => { + mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + + renderWithProviders(); + + expect(screen.getByText("Loading study session...")).toBeDefined(); + }); + + it("renders deck name and back link", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /Study: Japanese Vocabulary/ }), + ).toBeDefined(); + }); + + expect(screen.getByText(/Back to Deck/)).toBeDefined(); + }); + + it("passes auth header when fetching data", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: [] }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1", { + headers: { Authorization: "Bearer access-token" }, + }); + }); + expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/study", { + headers: { Authorization: "Bearer access-token" }, + }); + }); + }); + + describe("Error Handling", () => { + it("displays error on API failure", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ error: "Deck not found" }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "Deck not found", + ); + }); + }); + + it("allows retry after error", async () => { + const user = userEvent.setup(); + // First call fails + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: "Server error" }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: "Server error" }), + }) + // Retry succeeds + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeDefined(); + }); + + await user.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /Study: Japanese Vocabulary/ }), + ).toBeDefined(); + }); + }); + }); + + describe("No Cards State", () => { + it("shows no cards message when deck has no due cards", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: [] }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("no-cards")).toBeDefined(); + }); + expect(screen.getByText("No cards to study")).toBeDefined(); + expect( + screen.getByText("There are no due cards in this deck right now."), + ).toBeDefined(); + }); + }); + + describe("Card Display and Progress", () => { + it("shows remaining cards count", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("remaining-count").textContent).toBe( + "2 remaining", + ); + }); + }); + + it("displays the front of the first card", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front").textContent).toBe("Hello"); + }); + }); + + it("does not show rating buttons before card is flipped", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + expect(screen.queryByTestId("rating-buttons")).toBeNull(); + }); + }); + + describe("Card Flip Interaction", () => { + it("reveals answer when card is clicked", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.click(screen.getByTestId("card-container")); + + expect(screen.getByTestId("card-back").textContent).toBe("ใ“ใ‚“ใซใกใฏ"); + }); + + it("shows rating buttons after card is flipped", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.click(screen.getByTestId("card-container")); + + expect(screen.getByTestId("rating-buttons")).toBeDefined(); + expect(screen.getByTestId("rating-1")).toBeDefined(); + expect(screen.getByTestId("rating-2")).toBeDefined(); + expect(screen.getByTestId("rating-3")).toBeDefined(); + expect(screen.getByTestId("rating-4")).toBeDefined(); + }); + + it("displays rating labels on buttons", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.click(screen.getByTestId("card-container")); + + expect(screen.getByTestId("rating-1").textContent).toContain("Again"); + expect(screen.getByTestId("rating-2").textContent).toContain("Hard"); + expect(screen.getByTestId("rating-3").textContent).toContain("Good"); + expect(screen.getByTestId("rating-4").textContent).toContain("Easy"); + }); + }); + + describe("Rating Submission", () => { + it("submits review and moves to next card", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + // Submit review + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + // Flip card + await user.click(screen.getByTestId("card-container")); + + // Rate as Good + await user.click(screen.getByTestId("rating-3")); + + // Should move to next card + await waitFor(() => { + expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); + }); + + // Verify API was called + expect(mockFetch).toHaveBeenCalledWith( + "/api/decks/deck-1/study/card-1", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer access-token", + "Content-Type": "application/json", + }), + body: expect.stringContaining('"rating":3'), + }), + ); + }); + + it("updates remaining count after review", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("remaining-count").textContent).toBe( + "2 remaining", + ); + }); + + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + await waitFor(() => { + expect(screen.getByTestId("remaining-count").textContent).toBe( + "1 remaining", + ); + }); + }); + + it("shows error when rating submission fails", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: "Failed to submit review" }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + "Failed to submit review", + ); + }); + }); + }); + + describe("Session Complete", () => { + it("shows session complete screen after all cards reviewed", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: [mockDueCards[0]] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + // Review the only card + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + // Should show session complete + await waitFor(() => { + expect(screen.getByTestId("session-complete")).toBeDefined(); + }); + expect(screen.getByText("Session Complete!")).toBeDefined(); + expect(screen.getByTestId("completed-count").textContent).toBe("1"); + }); + + it("shows correct count for multiple cards reviewed", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + // First review + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }) + // Second review + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[1], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + // Review first card + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + // Review second card + await waitFor(() => { + expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); + }); + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-4")); + + // Should show session complete with 2 cards + await waitFor(() => { + expect(screen.getByTestId("session-complete")).toBeDefined(); + }); + expect(screen.getByTestId("completed-count").textContent).toBe("2"); + }); + + it("provides navigation links after session complete", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: [mockDueCards[0]] }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.click(screen.getByTestId("card-container")); + await user.click(screen.getByTestId("rating-3")); + + await waitFor(() => { + expect(screen.getByTestId("session-complete")).toBeDefined(); + }); + + expect(screen.getByText("Back to Deck")).toBeDefined(); + expect(screen.getByText("All Decks")).toBeDefined(); + }); + }); + + describe("Keyboard Shortcuts", () => { + it("flips card with Space key", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.keyboard(" "); + + expect(screen.getByTestId("card-back").textContent).toBe("ใ“ใ‚“ใซใกใฏ"); + }); + + it("flips card with Enter key", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.keyboard("{Enter}"); + + expect(screen.getByTestId("card-back").textContent).toBe("ใ“ใ‚“ใซใกใฏ"); + }); + + it("rates card with number keys", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.keyboard(" "); // Flip + await user.keyboard("3"); // Rate as Good + + await waitFor(() => { + expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); + }); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/decks/deck-1/study/card-1", + expect.objectContaining({ + body: expect.stringContaining('"rating":3'), + }), + ); + }); + + it("supports all rating keys (1, 2, 3, 4)", async () => { + const user = userEvent.setup(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ deck: mockDeck }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ cards: mockDueCards }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByTestId("card-front")).toBeDefined(); + }); + + await user.keyboard(" "); // Flip + await user.keyboard("1"); // Rate as Again + + expect(mockFetch).toHaveBeenCalledWith( + "/api/decks/deck-1/study/card-1", + expect.objectContaining({ + body: expect.stringContaining('"rating":1'), + }), + ); + }); + }); +}); diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx new file mode 100644 index 0000000..05e9943 --- /dev/null +++ b/src/client/pages/StudyPage.tsx @@ -0,0 +1,441 @@ +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) => ( + + ))} +
+ )} +
+ )} + + )} +
+ ); +} diff --git a/src/client/pages/index.ts b/src/client/pages/index.ts index c71e661..3fb507a 100644 --- a/src/client/pages/index.ts +++ b/src/client/pages/index.ts @@ -2,3 +2,4 @@ export { DeckDetailPage } from "./DeckDetailPage"; export { HomePage } from "./HomePage"; export { LoginPage } from "./LoginPage"; export { NotFoundPage } from "./NotFoundPage"; +export { StudyPage } from "./StudyPage"; -- cgit v1.2.3-70-g09d2