aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 18:50:08 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 18:50:08 +0900
commitf443ac18ccb8ab34fb5bf69b0802eb69cf89cf06 (patch)
tree571fa2cdd1959598e623fdd839c07d63b03b1124 /src/client
parentb965d9432b4037dd2f65bb4c8690965e090228ca (diff)
downloadkioku-f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06.tar.gz
kioku-f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06.tar.zst
kioku-f443ac18ccb8ab34fb5bf69b0802eb69cf89cf06.zip
feat(client): add study session page with card flip and rating UI
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 <noreply@anthropic.com>
Diffstat (limited to 'src/client')
-rw-r--r--src/client/App.tsx13
-rw-r--r--src/client/pages/DeckDetailPage.tsx24
-rw-r--r--src/client/pages/StudyPage.test.tsx762
-rw-r--r--src/client/pages/StudyPage.tsx441
-rw-r--r--src/client/pages/index.ts1
5 files changed, 1240 insertions, 1 deletions
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() {
<DeckDetailPage />
</ProtectedRoute>
</Route>
+ <Route path="/decks/:deckId/study">
+ <ProtectedRoute>
+ <StudyPage />
+ </ProtectedRoute>
+ </Route>
<Route path="/login" component={LoginPage} />
<Route component={NotFoundPage} />
</Switch>
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" }}>
+ &larr; 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";