diff options
Diffstat (limited to 'src/client/pages')
| -rw-r--r-- | src/client/pages/DeckDetailPage.tsx | 24 | ||||
| -rw-r--r-- | src/client/pages/StudyPage.test.tsx | 762 | ||||
| -rw-r--r-- | src/client/pages/StudyPage.tsx | 441 | ||||
| -rw-r--r-- | src/client/pages/index.ts | 1 |
4 files changed, 1228 insertions, 0 deletions
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 @@ -158,6 +158,30 @@ export function DeckDetailPage() { <div style={{ display: "flex", + gap: "0.5rem", + marginBottom: "1rem", + }} + > + <Link href={`/decks/${deckId}/study`}> + <button + type="button" + style={{ + backgroundColor: "#28a745", + color: "white", + border: "none", + padding: "0.5rem 1rem", + borderRadius: "4px", + cursor: "pointer", + }} + > + Study Now + </button> + </Link> + </div> + + <div + style={{ + display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem", diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx new file mode 100644 index 0000000..bab9193 --- /dev/null +++ b/src/client/pages/StudyPage.test.tsx @@ -0,0 +1,762 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Route, Router } from "wouter"; +import { memoryLocation } from "wouter/memory-location"; +import { apiClient } from "../api/client"; +import { AuthProvider } from "../stores"; +import { StudyPage } from "./StudyPage"; + +vi.mock("../api/client", () => ({ + 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( + <Router hook={hook}> + <AuthProvider> + <Route path="/decks/:deckId/study"> + <StudyPage /> + </Route> + </AuthProvider> + </Router>, + ); +} + +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<Rating, string> = { + 1: "Again", + 2: "Hard", + 3: "Good", + 4: "Easy", +}; + +const RatingColors: Record<Rating, string> = { + 1: "#dc3545", + 2: "#fd7e14", + 3: "#28a745", + 4: "#007bff", +}; + +export function StudyPage() { + const { deckId } = useParams<{ deckId: string }>(); + const [deck, setDeck] = useState<Deck | null>(null); + const [cards, setCards] = useState<Card[]>([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [isFlipped, setIsFlipped] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState<string | null>(null); + const [completedCount, setCompletedCount] = useState(0); + const cardStartTimeRef = useRef<number>(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<string, Rating> = { + "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 ( + <div> + <p>Invalid deck ID</p> + <Link href="/">Back to decks</Link> + </div> + ); + } + + const currentCard = cards[currentIndex]; + const isSessionComplete = currentIndex >= cards.length && cards.length > 0; + const hasNoCards = !isLoading && cards.length === 0; + const remainingCards = cards.length - currentIndex; + + return ( + <div style={{ maxWidth: "600px", margin: "0 auto", padding: "1rem" }}> + <header style={{ marginBottom: "1rem" }}> + <Link href={`/decks/${deckId}`} style={{ textDecoration: "none" }}> + ← Back to Deck + </Link> + </header> + + {isLoading && <p>Loading study session...</p>} + + {error && ( + <div role="alert" style={{ color: "red", marginBottom: "1rem" }}> + {error} + <button + type="button" + onClick={fetchData} + style={{ marginLeft: "0.5rem" }} + > + Retry + </button> + </div> + )} + + {!isLoading && !error && deck && ( + <> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "1rem", + }} + > + <h1 style={{ margin: 0 }}>Study: {deck.name}</h1> + {!isSessionComplete && !hasNoCards && ( + <span + data-testid="remaining-count" + style={{ + backgroundColor: "#f0f0f0", + padding: "0.25rem 0.75rem", + borderRadius: "12px", + fontSize: "0.875rem", + }} + > + {remainingCards} remaining + </span> + )} + </div> + + {hasNoCards && ( + <div + data-testid="no-cards" + style={{ + textAlign: "center", + padding: "3rem 1rem", + backgroundColor: "#f8f9fa", + borderRadius: "8px", + }} + > + <h2 style={{ marginTop: 0 }}>No cards to study</h2> + <p style={{ color: "#666" }}> + There are no due cards in this deck right now. + </p> + <Link href={`/decks/${deckId}`}> + <button type="button">Back to Deck</button> + </Link> + </div> + )} + + {isSessionComplete && ( + <div + data-testid="session-complete" + style={{ + textAlign: "center", + padding: "3rem 1rem", + backgroundColor: "#d4edda", + borderRadius: "8px", + }} + > + <h2 style={{ marginTop: 0, color: "#155724" }}> + Session Complete! + </h2> + <p style={{ fontSize: "1.25rem", marginBottom: "1.5rem" }}> + You reviewed{" "} + <strong data-testid="completed-count">{completedCount}</strong>{" "} + card{completedCount !== 1 ? "s" : ""}. + </p> + <div style={{ display: "flex", gap: "1rem", justifyContent: "center" }}> + <Link href={`/decks/${deckId}`}> + <button type="button">Back to Deck</button> + </Link> + <Link href="/"> + <button type="button">All Decks</button> + </Link> + </div> + </div> + )} + + {currentCard && !isSessionComplete && ( + <div data-testid="study-card"> + <div + data-testid="card-container" + onClick={!isFlipped ? handleFlip : undefined} + onKeyDown={(e) => { + 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 ? ( + <> + <p + data-testid="card-front" + style={{ + fontSize: "1.25rem", + textAlign: "center", + margin: 0, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {currentCard.front} + </p> + <p + style={{ + marginTop: "1.5rem", + color: "#666", + fontSize: "0.875rem", + }} + > + Click or press Space to reveal + </p> + </> + ) : ( + <> + <p + data-testid="card-back" + style={{ + fontSize: "1.25rem", + textAlign: "center", + margin: 0, + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {currentCard.back} + </p> + </> + )} + </div> + + {isFlipped && ( + <div + data-testid="rating-buttons" + style={{ + display: "flex", + gap: "0.5rem", + justifyContent: "center", + marginTop: "1rem", + }} + > + {([1, 2, 3, 4] as Rating[]).map((rating) => ( + <button + key={rating} + type="button" + data-testid={`rating-${rating}`} + onClick={() => handleRating(rating)} + disabled={isSubmitting} + style={{ + flex: 1, + padding: "0.75rem 1rem", + backgroundColor: RatingColors[rating], + color: "white", + border: "none", + borderRadius: "4px", + cursor: isSubmitting ? "not-allowed" : "pointer", + opacity: isSubmitting ? 0.6 : 1, + fontSize: "0.875rem", + }} + > + <span style={{ display: "block", fontWeight: "bold" }}> + {RatingLabels[rating]} + </span> + <span style={{ display: "block", fontSize: "0.75rem" }}> + {rating} + </span> + </button> + ))} + </div> + )} + </div> + )} + </> + )} + </div> + ); +} 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"; |
