aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/pages')
-rw-r--r--src/client/pages/DeckCardsPage.test.tsx604
-rw-r--r--src/client/pages/DeckCardsPage.tsx457
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx480
-rw-r--r--src/client/pages/DeckDetailPage.tsx416
-rw-r--r--src/client/pages/index.ts1
5 files changed, 1151 insertions, 807 deletions
diff --git a/src/client/pages/DeckCardsPage.test.tsx b/src/client/pages/DeckCardsPage.test.tsx
new file mode 100644
index 0000000..d70da83
--- /dev/null
+++ b/src/client/pages/DeckCardsPage.test.tsx
@@ -0,0 +1,604 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createStore, Provider } from "jotai";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Route, Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import {
+ authLoadingAtom,
+ type Card,
+ cardsByDeckAtomFamily,
+ type Deck,
+ deckByIdAtomFamily,
+} from "../atoms";
+import { clearAtomFamilyCaches } from "../atoms/utils";
+import { DeckCardsPage } from "./DeckCardsPage";
+
+const mockDeckGet = vi.fn();
+const mockCardsGet = vi.fn();
+const mockNoteDelete = vi.fn();
+const mockHandleResponse = vi.fn();
+
+vi.mock("../api/client", () => ({
+ apiClient: {
+ login: vi.fn(),
+ logout: vi.fn(),
+ isAuthenticated: vi.fn(),
+ getTokens: vi.fn(),
+ getAuthHeader: vi.fn(),
+ onSessionExpired: vi.fn(() => vi.fn()),
+ rpc: {
+ api: {
+ decks: {
+ ":id": {
+ $get: (args: unknown) => mockDeckGet(args),
+ },
+ ":deckId": {
+ cards: {
+ $get: (args: unknown) => mockCardsGet(args),
+ },
+ notes: {
+ ":noteId": {
+ $delete: (args: unknown) => mockNoteDelete(args),
+ },
+ },
+ },
+ },
+ },
+ },
+ handleResponse: (res: unknown) => mockHandleResponse(res),
+ },
+ ApiClientError: class ApiClientError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public code?: string,
+ ) {
+ super(message);
+ this.name = "ApiClientError";
+ }
+ },
+}));
+
+import { ApiClientError, apiClient } from "../api/client";
+
+const mockDeck = {
+ id: "deck-1",
+ name: "Japanese Vocabulary",
+ description: "Common Japanese words",
+ newCardsPerDay: 20,
+ dueCardCount: 0,
+ createdAt: "2024-01-01T00:00:00Z",
+ updatedAt: "2024-01-01T00:00:00Z",
+};
+
+// Basic note-based cards (each with its own note)
+const mockBasicCards = [
+ {
+ id: "card-1",
+ deckId: "deck-1",
+ noteId: "note-1",
+ isReversed: false,
+ 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",
+ noteId: "note-2",
+ isReversed: false,
+ front: "Goodbye",
+ back: "さようなら",
+ state: 2,
+ due: "2024-01-02T00:00:00Z",
+ stability: 5.5,
+ difficulty: 5.0,
+ elapsedDays: 1,
+ scheduledDays: 7,
+ reps: 5,
+ lapses: 1,
+ lastReview: "2024-01-01T00:00:00Z",
+ createdAt: "2024-01-01T00:00:00Z",
+ updatedAt: "2024-01-01T00:00:00Z",
+ deletedAt: null,
+ syncVersion: 0,
+ },
+];
+
+// Note-based cards (with noteId)
+const mockNoteBasedCards = [
+ {
+ id: "card-3",
+ deckId: "deck-1",
+ noteId: "note-1",
+ isReversed: false,
+ front: "Apple",
+ 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-02T00:00:00Z",
+ updatedAt: "2024-01-02T00:00:00Z",
+ deletedAt: null,
+ syncVersion: 0,
+ },
+ {
+ id: "card-4",
+ deckId: "deck-1",
+ noteId: "note-1",
+ isReversed: true,
+ front: "りんご",
+ back: "Apple",
+ state: 0,
+ due: "2024-01-01T00:00:00Z",
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 2,
+ lapses: 0,
+ lastReview: null,
+ createdAt: "2024-01-02T00:00:00Z",
+ updatedAt: "2024-01-02T00:00:00Z",
+ deletedAt: null,
+ syncVersion: 0,
+ },
+];
+
+// Alias for existing tests
+const mockCards = mockBasicCards;
+
+interface RenderOptions {
+ path?: string;
+ initialDeck?: Deck;
+ initialCards?: Card[];
+}
+
+function renderWithProviders({
+ path = "/decks/deck-1/cards",
+ initialDeck,
+ initialCards,
+}: RenderOptions = {}) {
+ const { hook } = memoryLocation({ path, static: true });
+ const store = createStore();
+ store.set(authLoadingAtom, false);
+
+ // Extract deckId from path
+ const deckIdMatch = path.match(/\/decks\/([^/]+)/);
+ const deckId = deckIdMatch?.[1] ?? "deck-1";
+
+ // Hydrate atoms if initial data provided
+ if (initialDeck !== undefined) {
+ store.set(deckByIdAtomFamily(deckId), initialDeck);
+ }
+ if (initialCards !== undefined) {
+ store.set(cardsByDeckAtomFamily(deckId), initialCards);
+ }
+
+ return render(
+ <Provider store={store}>
+ <Router hook={hook}>
+ <Route path="/decks/:deckId/cards">
+ <DeckCardsPage />
+ </Route>
+ </Router>
+ </Provider>,
+ );
+}
+
+describe("DeckCardsPage", () => {
+ 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",
+ });
+
+ // handleResponse simulates actual behavior
+ mockHandleResponse.mockImplementation(async (res) => {
+ if (res.ok === undefined && res.status === undefined) {
+ return res;
+ }
+ if (!res.ok) {
+ const body = await res.json?.().catch(() => ({}));
+ throw new Error(
+ body?.error || `Request failed with status ${res.status}`,
+ );
+ }
+ return typeof res.json === "function" ? res.json() : res;
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ clearAtomFamilyCaches();
+ });
+
+ it("renders back link and deck name", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockCards,
+ });
+
+ expect(
+ screen.getByRole("heading", { name: "Japanese Vocabulary" }),
+ ).toBeDefined();
+ expect(screen.getByText(/Back to Deck/)).toBeDefined();
+ expect(screen.getByText("Common Japanese words")).toBeDefined();
+ });
+
+ it("shows loading state while fetching data", async () => {
+ mockDeckGet.mockImplementation(() => new Promise(() => {}));
+ mockCardsGet.mockImplementation(() => new Promise(() => {}));
+
+ renderWithProviders();
+
+ expect(document.querySelector(".animate-spin")).toBeDefined();
+ });
+
+ it("displays empty state when no cards exist", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: [],
+ });
+
+ expect(screen.getByText("No cards yet")).toBeDefined();
+ expect(screen.getByText("Add notes to start studying")).toBeDefined();
+ });
+
+ it("displays list of cards", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockCards,
+ });
+
+ expect(screen.getByText("Hello")).toBeDefined();
+ expect(screen.getByText("こんにちは")).toBeDefined();
+ expect(screen.getByText("Goodbye")).toBeDefined();
+ expect(screen.getByText("さようなら")).toBeDefined();
+ });
+
+ it("displays card count", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockCards,
+ });
+
+ expect(screen.getByText("(2)")).toBeDefined();
+ });
+
+ it("displays card state labels", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockCards,
+ });
+
+ expect(screen.getByText("New")).toBeDefined();
+ expect(screen.getByText("Review")).toBeDefined();
+ });
+
+ it("displays card stats (reps and lapses)", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockCards,
+ });
+
+ expect(screen.getByText("0 reviews")).toBeDefined();
+ expect(screen.getByText("5 reviews")).toBeDefined();
+ expect(screen.getByText("1 lapses")).toBeDefined();
+ });
+
+ it("does not show description if deck has none", () => {
+ const deckWithoutDescription = { ...mockDeck, description: null };
+ renderWithProviders({
+ initialDeck: deckWithoutDescription,
+ initialCards: [],
+ });
+
+ expect(
+ screen.getByRole("heading", { name: "Japanese Vocabulary" }),
+ ).toBeDefined();
+ expect(screen.queryByText("Common Japanese words")).toBeNull();
+ });
+
+ describe("Delete Note", () => {
+ it("shows Delete button for each note", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockCards,
+ });
+
+ expect(screen.getByText("Hello")).toBeDefined();
+
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete note",
+ });
+ expect(deleteButtons.length).toBe(2);
+ });
+
+ it("opens delete confirmation modal when Delete button is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockCards,
+ });
+
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete note",
+ });
+ const firstDeleteButton = deleteButtons[0];
+ if (firstDeleteButton) {
+ await user.click(firstDeleteButton);
+ }
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Delete Note" }),
+ ).toBeDefined();
+ });
+
+ it("closes delete modal when Cancel is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockCards,
+ });
+
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete note",
+ });
+ const firstDeleteButton = deleteButtons[0];
+ if (firstDeleteButton) {
+ await user.click(firstDeleteButton);
+ }
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("deletes note and refreshes list on confirmation", async () => {
+ const user = userEvent.setup();
+
+ mockCardsGet.mockResolvedValue({
+ cards: [mockCards[1]],
+ });
+ mockNoteDelete.mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockCards,
+ });
+
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete note",
+ });
+ const firstDeleteButton = deleteButtons[0];
+ if (firstDeleteButton) {
+ await user.click(firstDeleteButton);
+ }
+
+ const dialog = screen.getByRole("dialog");
+ const modalButtons = dialog.querySelectorAll("button");
+ const confirmDeleteButton = Array.from(modalButtons).find((btn) =>
+ btn.textContent?.includes("Delete"),
+ );
+ if (confirmDeleteButton) {
+ await user.click(confirmDeleteButton);
+ }
+
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ expect(mockNoteDelete).toHaveBeenCalledWith({
+ param: { deckId: "deck-1", noteId: "note-1" },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("(1)")).toBeDefined();
+ });
+ });
+
+ it("displays error when delete fails", async () => {
+ const user = userEvent.setup();
+
+ mockNoteDelete.mockRejectedValue(
+ new ApiClientError("Failed to delete note", 500),
+ );
+
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockCards,
+ });
+
+ const deleteButtons = screen.getAllByRole("button", {
+ name: "Delete note",
+ });
+ const firstDeleteButton = deleteButtons[0];
+ if (firstDeleteButton) {
+ await user.click(firstDeleteButton);
+ }
+
+ const dialog = screen.getByRole("dialog");
+ const modalButtons = dialog.querySelectorAll("button");
+ const confirmDeleteButton = Array.from(modalButtons).find((btn) =>
+ btn.textContent?.includes("Delete"),
+ );
+ if (confirmDeleteButton) {
+ await user.click(confirmDeleteButton);
+ }
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Failed to delete note",
+ );
+ });
+ });
+ });
+
+ describe("Card Grouping by Note", () => {
+ it("groups cards by noteId and displays as note groups", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockNoteBasedCards,
+ });
+
+ expect(screen.getByTestId("note-group")).toBeDefined();
+
+ const noteCards = screen.getAllByTestId("note-card");
+ expect(noteCards.length).toBe(2);
+ });
+
+ it("shows Normal and Reversed badges for note-based cards", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockNoteBasedCards,
+ });
+
+ expect(screen.getByText("Normal")).toBeDefined();
+ expect(screen.getByText("Reversed")).toBeDefined();
+ });
+
+ it("shows note card count in note group header", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockNoteBasedCards,
+ });
+
+ expect(screen.getByText("Note (2 cards)")).toBeDefined();
+ });
+
+ it("shows edit note button for note groups", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockNoteBasedCards,
+ });
+
+ expect(screen.getByTestId("note-group")).toBeDefined();
+
+ const editNoteButton = screen.getByRole("button", { name: "Edit note" });
+ expect(editNoteButton).toBeDefined();
+ });
+
+ it("shows delete note button for note groups", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockNoteBasedCards,
+ });
+
+ expect(screen.getByTestId("note-group")).toBeDefined();
+
+ const deleteNoteButton = screen.getByRole("button", {
+ name: "Delete note",
+ });
+ expect(deleteNoteButton).toBeDefined();
+ });
+
+ it("opens delete note modal when delete button is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockNoteBasedCards,
+ });
+
+ const deleteNoteButton = screen.getByRole("button", {
+ name: "Delete note",
+ });
+ await user.click(deleteNoteButton);
+
+ expect(screen.getByRole("dialog")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Delete Note" }),
+ ).toBeDefined();
+ });
+
+ it("deletes note and refreshes list when confirmed", async () => {
+ const user = userEvent.setup();
+
+ mockCardsGet.mockResolvedValue({ cards: [] });
+ mockNoteDelete.mockResolvedValue({
+ ok: true,
+ json: async () => ({ success: true }),
+ });
+
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockNoteBasedCards,
+ });
+
+ const deleteNoteButton = screen.getByRole("button", {
+ name: "Delete note",
+ });
+ await user.click(deleteNoteButton);
+
+ const dialog = screen.getByRole("dialog");
+ const modalButtons = dialog.querySelectorAll("button");
+ const confirmDeleteButton = Array.from(modalButtons).find((btn) =>
+ btn.textContent?.includes("Delete"),
+ );
+ if (confirmDeleteButton) {
+ await user.click(confirmDeleteButton);
+ }
+
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ expect(mockNoteDelete).toHaveBeenCalledWith({
+ param: { deckId: "deck-1", noteId: "note-1" },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("No cards yet")).toBeDefined();
+ });
+ });
+
+ it("displays note preview from normal card content", () => {
+ renderWithProviders({
+ initialDeck: mockDeck,
+ initialCards: mockNoteBasedCards,
+ });
+
+ expect(screen.getByTestId("note-group")).toBeDefined();
+
+ expect(screen.getByText("Apple")).toBeDefined();
+ expect(screen.getByText("りんご")).toBeDefined();
+ });
+ });
+});
diff --git a/src/client/pages/DeckCardsPage.tsx b/src/client/pages/DeckCardsPage.tsx
new file mode 100644
index 0000000..416760a
--- /dev/null
+++ b/src/client/pages/DeckCardsPage.tsx
@@ -0,0 +1,457 @@
+import {
+ faChevronLeft,
+ faFile,
+ faFileImport,
+ faLayerGroup,
+ faPen,
+ faPlus,
+ faTrash,
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useAtomValue, useSetAtom } from "jotai";
+import { Suspense, useMemo, useState, useTransition } from "react";
+import { Link, useParams } from "wouter";
+import { type Card, cardsByDeckAtomFamily, deckByIdAtomFamily } from "../atoms";
+import { CreateNoteModal } from "../components/CreateNoteModal";
+import { DeleteCardModal } from "../components/DeleteCardModal";
+import { DeleteNoteModal } from "../components/DeleteNoteModal";
+import { EditCardModal } from "../components/EditCardModal";
+import { EditNoteModal } from "../components/EditNoteModal";
+import { ErrorBoundary } from "../components/ErrorBoundary";
+import { ImportNotesModal } from "../components/ImportNotesModal";
+import { LoadingSpinner } from "../components/LoadingSpinner";
+
+/** Combined type for display: note group */
+type CardDisplayItem = { type: "note"; noteId: string; cards: Card[] };
+
+const CardStateLabels: Record<number, string> = {
+ 0: "New",
+ 1: "Learning",
+ 2: "Review",
+ 3: "Relearning",
+};
+
+const CardStateColors: Record<number, string> = {
+ 0: "bg-info/10 text-info",
+ 1: "bg-warning/10 text-warning",
+ 2: "bg-success/10 text-success",
+ 3: "bg-error/10 text-error",
+};
+
+/** Component for displaying a group of cards from the same note */
+function NoteGroupCard({
+ noteId,
+ cards,
+ index,
+ onEditNote,
+ onDeleteNote,
+}: {
+ noteId: string;
+ cards: Card[];
+ index: number;
+ onEditNote: () => void;
+ onDeleteNote: () => void;
+}) {
+ // Use the first card's front/back as preview (normal card takes precedence)
+ const previewCard = cards.find((c) => !c.isReversed) ?? cards[0];
+ if (!previewCard) return null;
+
+ return (
+ <div
+ data-testid="note-group"
+ data-note-id={noteId}
+ className="bg-white rounded-xl border border-border/50 shadow-card hover:shadow-md transition-all duration-200 overflow-hidden"
+ style={{ animationDelay: `${index * 30}ms` }}
+ >
+ {/* Note Header */}
+ <div className="flex items-center justify-between px-5 py-3 border-b border-border/30 bg-ivory/30">
+ <div className="flex items-center gap-2">
+ <FontAwesomeIcon
+ icon={faLayerGroup}
+ className="w-4 h-4 text-muted"
+ aria-hidden="true"
+ />
+ <span className="text-sm font-medium text-slate">
+ Note ({cards.length} card{cards.length !== 1 ? "s" : ""})
+ </span>
+ </div>
+ <div className="flex items-center gap-1">
+ <button
+ type="button"
+ onClick={onEditNote}
+ className="p-2 text-muted hover:text-slate hover:bg-white rounded-lg transition-colors"
+ title="Edit note"
+ >
+ <FontAwesomeIcon
+ icon={faPen}
+ className="w-4 h-4"
+ aria-hidden="true"
+ />
+ </button>
+ <button
+ type="button"
+ onClick={onDeleteNote}
+ className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors"
+ title="Delete note"
+ >
+ <FontAwesomeIcon
+ icon={faTrash}
+ className="w-4 h-4"
+ aria-hidden="true"
+ />
+ </button>
+ </div>
+ </div>
+
+ {/* Note Content Preview */}
+ <div className="p-5">
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <span className="text-xs font-medium text-muted uppercase tracking-wide">
+ Front
+ </span>
+ <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words">
+ {previewCard.front}
+ </p>
+ </div>
+ <div>
+ <span className="text-xs font-medium text-muted uppercase tracking-wide">
+ Back
+ </span>
+ <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words">
+ {previewCard.back}
+ </p>
+ </div>
+ </div>
+
+ {/* Cards within this note */}
+ <div className="space-y-2">
+ {cards.map((card) => (
+ <div
+ key={card.id}
+ data-testid="note-card"
+ className="flex items-center gap-3 text-xs p-2 bg-ivory/50 rounded-lg"
+ >
+ <span
+ className={`px-2 py-0.5 rounded-full font-medium ${CardStateColors[card.state] || "bg-muted/10 text-muted"}`}
+ >
+ {CardStateLabels[card.state] || "Unknown"}
+ </span>
+ {card.isReversed ? (
+ <span className="px-2 py-0.5 rounded-full font-medium bg-purple-100 text-purple-700">
+ Reversed
+ </span>
+ ) : (
+ <span className="px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700">
+ Normal
+ </span>
+ )}
+ <span className="text-muted">{card.reps} reviews</span>
+ {card.lapses > 0 && (
+ <span className="text-muted">{card.lapses} lapses</span>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function DeckHeader({ deckId }: { deckId: string }) {
+ const deck = useAtomValue(deckByIdAtomFamily(deckId));
+
+ return (
+ <div className="mb-8">
+ <h1 className="font-display text-3xl font-semibold text-ink mb-2">
+ {deck.name}
+ </h1>
+ {deck.description && <p className="text-muted">{deck.description}</p>}
+ </div>
+ );
+}
+
+function CardList({
+ deckId,
+ onEditNote,
+ onDeleteNote,
+ onCreateNote,
+}: {
+ deckId: string;
+ onEditNote: (noteId: string) => void;
+ onDeleteNote: (noteId: string) => void;
+ onCreateNote: () => void;
+}) {
+ const cards = useAtomValue(cardsByDeckAtomFamily(deckId));
+
+ // Group cards by note for display
+ const displayItems = useMemo((): CardDisplayItem[] => {
+ const noteGroups = new Map<string, Card[]>();
+
+ for (const card of cards) {
+ const existing = noteGroups.get(card.noteId);
+ if (existing) {
+ existing.push(card);
+ } else {
+ noteGroups.set(card.noteId, [card]);
+ }
+ }
+
+ // Sort note groups by earliest card creation (newest first)
+ const sortedNoteGroups = Array.from(noteGroups.entries()).sort(
+ ([, cardsA], [, cardsB]) => {
+ const minA = Math.min(
+ ...cardsA.map((c) => new Date(c.createdAt).getTime()),
+ );
+ const minB = Math.min(
+ ...cardsB.map((c) => new Date(c.createdAt).getTime()),
+ );
+ return minB - minA; // Newest first
+ },
+ );
+
+ const items: CardDisplayItem[] = [];
+ for (const [noteId, noteCards] of sortedNoteGroups) {
+ // Sort cards within group: normal first, then reversed
+ noteCards.sort((a, b) => {
+ if (a.isReversed === b.isReversed) return 0;
+ return a.isReversed ? 1 : -1;
+ });
+ items.push({ type: "note", noteId, cards: noteCards });
+ }
+
+ return items;
+ }, [cards]);
+
+ if (cards.length === 0) {
+ return (
+ <div className="text-center py-12 bg-white rounded-xl border border-border/50">
+ <div className="w-14 h-14 mx-auto mb-4 bg-ivory rounded-xl flex items-center justify-center">
+ <FontAwesomeIcon
+ icon={faFile}
+ className="w-7 h-7 text-muted"
+ aria-hidden="true"
+ />
+ </div>
+ <h3 className="font-display text-lg font-medium text-slate mb-2">
+ No cards yet
+ </h3>
+ <p className="text-muted text-sm mb-4">Add notes to start studying</p>
+ <button
+ type="button"
+ onClick={onCreateNote}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200"
+ >
+ <FontAwesomeIcon
+ icon={faPlus}
+ className="w-5 h-5"
+ aria-hidden="true"
+ />
+ Add Your First Note
+ </button>
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-4">
+ {displayItems.map((item, index) => (
+ <NoteGroupCard
+ key={item.noteId}
+ noteId={item.noteId}
+ cards={item.cards}
+ index={index}
+ onEditNote={() => onEditNote(item.noteId)}
+ onDeleteNote={() => onDeleteNote(item.noteId)}
+ />
+ ))}
+ </div>
+ );
+}
+
+function CardsContent({
+ deckId,
+ onCreateNote,
+ onImportNotes,
+ onEditNote,
+ onDeleteNote,
+}: {
+ deckId: string;
+ onCreateNote: () => void;
+ onImportNotes: () => void;
+ onEditNote: (noteId: string) => void;
+ onDeleteNote: (noteId: string) => void;
+}) {
+ const cards = useAtomValue(cardsByDeckAtomFamily(deckId));
+
+ return (
+ <div className="animate-fade-in">
+ {/* Deck Header */}
+ <ErrorBoundary>
+ <Suspense fallback={<LoadingSpinner />}>
+ <DeckHeader deckId={deckId} />
+ </Suspense>
+ </ErrorBoundary>
+
+ {/* Cards Section */}
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="font-display text-xl font-medium text-slate">
+ Cards <span className="text-muted font-normal">({cards.length})</span>
+ </h2>
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={onImportNotes}
+ className="inline-flex items-center gap-2 border border-border hover:bg-ivory text-slate font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
+ >
+ <FontAwesomeIcon
+ icon={faFileImport}
+ className="w-5 h-5"
+ aria-hidden="true"
+ />
+ Import CSV
+ </button>
+ <button
+ type="button"
+ onClick={onCreateNote}
+ className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
+ >
+ <FontAwesomeIcon
+ icon={faPlus}
+ className="w-5 h-5"
+ aria-hidden="true"
+ />
+ Add Note
+ </button>
+ </div>
+ </div>
+
+ {/* Card List */}
+ <CardList
+ deckId={deckId}
+ onEditNote={onEditNote}
+ onDeleteNote={onDeleteNote}
+ onCreateNote={onCreateNote}
+ />
+ </div>
+ );
+}
+
+export function DeckCardsPage() {
+ const { deckId } = useParams<{ deckId: string }>();
+ const [, startTransition] = useTransition();
+
+ const reloadCards = useSetAtom(cardsByDeckAtomFamily(deckId || ""));
+
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [isImportModalOpen, setIsImportModalOpen] = useState(false);
+ const [editingCard, setEditingCard] = useState<Card | null>(null);
+ const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
+ const [deletingCard, setDeletingCard] = useState<Card | null>(null);
+ const [deletingNoteId, setDeletingNoteId] = useState<string | null>(null);
+
+ const handleCardMutation = () => {
+ startTransition(() => {
+ reloadCards();
+ });
+ };
+
+ if (!deckId) {
+ return (
+ <div className="min-h-screen bg-cream flex items-center justify-center">
+ <div className="text-center">
+ <p className="text-muted mb-4">Invalid deck ID</p>
+ <Link
+ href="/"
+ className="text-primary hover:text-primary-dark font-medium"
+ >
+ Back to decks
+ </Link>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="min-h-screen bg-cream">
+ {/* Header */}
+ <header className="bg-white border-b border-border/50">
+ <div className="max-w-4xl mx-auto px-4 py-4">
+ <Link
+ href={`/decks/${deckId}`}
+ className="inline-flex items-center gap-2 text-muted hover:text-slate transition-colors text-sm"
+ >
+ <FontAwesomeIcon
+ icon={faChevronLeft}
+ className="w-4 h-4"
+ aria-hidden="true"
+ />
+ Back to Deck
+ </Link>
+ </div>
+ </header>
+
+ {/* Main Content */}
+ <main className="max-w-4xl mx-auto px-4 py-8">
+ <ErrorBoundary>
+ <Suspense fallback={<LoadingSpinner />}>
+ <CardsContent
+ deckId={deckId}
+ onCreateNote={() => setIsCreateModalOpen(true)}
+ onImportNotes={() => setIsImportModalOpen(true)}
+ onEditNote={setEditingNoteId}
+ onDeleteNote={setDeletingNoteId}
+ />
+ </Suspense>
+ </ErrorBoundary>
+ </main>
+
+ {/* Modals */}
+ <CreateNoteModal
+ isOpen={isCreateModalOpen}
+ deckId={deckId}
+ onClose={() => setIsCreateModalOpen(false)}
+ onNoteCreated={handleCardMutation}
+ />
+
+ <ImportNotesModal
+ isOpen={isImportModalOpen}
+ deckId={deckId}
+ onClose={() => setIsImportModalOpen(false)}
+ onImportComplete={handleCardMutation}
+ />
+
+ <EditCardModal
+ isOpen={editingCard !== null}
+ deckId={deckId}
+ card={editingCard}
+ onClose={() => setEditingCard(null)}
+ onCardUpdated={handleCardMutation}
+ />
+
+ <EditNoteModal
+ isOpen={editingNoteId !== null}
+ deckId={deckId}
+ noteId={editingNoteId}
+ onClose={() => setEditingNoteId(null)}
+ onNoteUpdated={handleCardMutation}
+ />
+
+ <DeleteCardModal
+ isOpen={deletingCard !== null}
+ deckId={deckId}
+ card={deletingCard}
+ onClose={() => setDeletingCard(null)}
+ onCardDeleted={handleCardMutation}
+ />
+
+ <DeleteNoteModal
+ isOpen={deletingNoteId !== null}
+ deckId={deckId}
+ noteId={deletingNoteId}
+ onClose={() => setDeletingNoteId(null)}
+ onNoteDeleted={handleCardMutation}
+ />
+ </div>
+ );
+}
diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx
index b138a0b..903edb7 100644
--- a/src/client/pages/DeckDetailPage.test.tsx
+++ b/src/client/pages/DeckDetailPage.test.tsx
@@ -1,8 +1,7 @@
/**
* @vitest-environment jsdom
*/
-import { cleanup, render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
+import { cleanup, render, screen } from "@testing-library/react";
import { createStore, Provider } from "jotai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Route, Router } from "wouter";
@@ -19,7 +18,6 @@ import { DeckDetailPage } from "./DeckDetailPage";
const mockDeckGet = vi.fn();
const mockCardsGet = vi.fn();
-const mockNoteDelete = vi.fn();
const mockHandleResponse = vi.fn();
vi.mock("../api/client", () => ({
@@ -40,11 +38,6 @@ vi.mock("../api/client", () => ({
cards: {
$get: (args: unknown) => mockCardsGet(args),
},
- notes: {
- ":noteId": {
- $delete: (args: unknown) => mockNoteDelete(args),
- },
- },
},
},
},
@@ -63,7 +56,7 @@ vi.mock("../api/client", () => ({
},
}));
-import { ApiClientError, apiClient } from "../api/client";
+import { apiClient } from "../api/client";
const mockDeck = {
id: "deck-1",
@@ -75,8 +68,7 @@ const mockDeck = {
updatedAt: "2024-01-01T00:00:00Z",
};
-// Basic note-based cards (each with its own note)
-const mockBasicCards = [
+const mockCards = [
{
id: "card-1",
deckId: "deck-1",
@@ -85,7 +77,7 @@ const mockBasicCards = [
front: "Hello",
back: "こんにちは",
state: 0,
- due: "2024-01-01T00:00:00Z",
+ due: "2099-01-01T00:00:00Z", // Not due yet (future date)
stability: 0,
difficulty: 0,
elapsedDays: 0,
@@ -106,7 +98,7 @@ const mockBasicCards = [
front: "Goodbye",
back: "さようなら",
state: 2,
- due: "2024-01-02T00:00:00Z",
+ due: new Date().toISOString(), // Due now
stability: 5.5,
difficulty: 5.0,
elapsedDays: 1,
@@ -121,55 +113,6 @@ const mockBasicCards = [
},
];
-// Note-based cards (with noteId)
-const mockNoteBasedCards = [
- {
- id: "card-3",
- deckId: "deck-1",
- noteId: "note-1",
- isReversed: false,
- front: "Apple",
- 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-02T00:00:00Z",
- updatedAt: "2024-01-02T00:00:00Z",
- deletedAt: null,
- syncVersion: 0,
- },
- {
- id: "card-4",
- deckId: "deck-1",
- noteId: "note-1",
- isReversed: true,
- front: "りんご",
- back: "Apple",
- state: 0,
- due: "2024-01-01T00:00:00Z",
- stability: 0,
- difficulty: 0,
- elapsedDays: 0,
- scheduledDays: 0,
- reps: 2,
- lapses: 0,
- lastReview: null,
- createdAt: "2024-01-02T00:00:00Z",
- updatedAt: "2024-01-02T00:00:00Z",
- deletedAt: null,
- syncVersion: 0,
- },
-];
-
-// Alias for existing tests
-const mockCards = mockBasicCards;
-
interface RenderOptions {
path?: string;
initialDeck?: Deck;
@@ -220,15 +163,10 @@ describe("DeckDetailPage", () => {
Authorization: "Bearer access-token",
});
- // handleResponse simulates actual behavior
- // - If response is a plain object (from mocked RPC), pass through
- // - If response is Response-like with ok/status, handle properly
mockHandleResponse.mockImplementation(async (res) => {
- // Plain object (already the data) - pass through
if (res.ok === undefined && res.status === undefined) {
return res;
}
- // Response-like object
if (!res.ok) {
const body = await res.json?.().catch(() => ({}));
throw new Error(
@@ -259,418 +197,86 @@ describe("DeckDetailPage", () => {
});
it("shows loading state while fetching data", async () => {
- mockDeckGet.mockImplementation(() => new Promise(() => {})); // Never resolves
- mockCardsGet.mockImplementation(() => new Promise(() => {})); // Never resolves
+ mockDeckGet.mockImplementation(() => new Promise(() => {}));
+ mockCardsGet.mockImplementation(() => new Promise(() => {}));
renderWithProviders();
- // Loading state shows spinner (svg with animate-spin class)
expect(document.querySelector(".animate-spin")).toBeDefined();
});
- it("displays empty state when no cards exist", () => {
+ it("does not show description if deck has none", () => {
+ const deckWithoutDescription = { ...mockDeck, description: null };
renderWithProviders({
- initialDeck: mockDeck,
+ initialDeck: deckWithoutDescription,
initialCards: [],
});
- expect(screen.getByText("No cards yet")).toBeDefined();
- expect(screen.getByText("Add notes to start studying")).toBeDefined();
+ expect(
+ screen.getByRole("heading", { name: "Japanese Vocabulary" }),
+ ).toBeDefined();
+ expect(screen.queryByText("Common Japanese words")).toBeNull();
});
- it("displays list of cards", () => {
+ it("displays Study Now button", () => {
renderWithProviders({
initialDeck: mockDeck,
initialCards: mockCards,
});
- expect(screen.getByText("Hello")).toBeDefined();
- expect(screen.getByText("こんにちは")).toBeDefined();
- expect(screen.getByText("Goodbye")).toBeDefined();
- expect(screen.getByText("さようなら")).toBeDefined();
+ const studyButton = screen.getByRole("link", { name: /Study Now/ });
+ expect(studyButton).toBeDefined();
+ expect(studyButton.getAttribute("href")).toBe("/decks/deck-1/study");
});
- it("displays card count", () => {
+ it("displays View Cards link", () => {
renderWithProviders({
initialDeck: mockDeck,
initialCards: mockCards,
});
- expect(screen.getByText("(2)")).toBeDefined();
+ const viewCardsLink = screen.getByRole("link", { name: /View Cards/ });
+ expect(viewCardsLink).toBeDefined();
+ expect(viewCardsLink.getAttribute("href")).toBe("/decks/deck-1/cards");
});
- it("displays card state labels", () => {
+ it("displays total card count", () => {
renderWithProviders({
initialDeck: mockDeck,
initialCards: mockCards,
});
- expect(screen.getByText("New")).toBeDefined();
- expect(screen.getByText("Review")).toBeDefined();
+ const totalCardsLabel = screen.getByText("Total Cards");
+ expect(totalCardsLabel).toBeDefined();
+ // Find the count within the same container
+ const totalCardsContainer = totalCardsLabel.parentElement;
+ expect(totalCardsContainer?.querySelector(".text-ink")?.textContent).toBe(
+ "2",
+ );
});
- it("displays card stats (reps and lapses)", () => {
+ it("displays due card count", () => {
renderWithProviders({
initialDeck: mockDeck,
initialCards: mockCards,
});
- expect(screen.getByText("0 reviews")).toBeDefined();
- expect(screen.getByText("5 reviews")).toBeDefined();
- expect(screen.getByText("1 lapses")).toBeDefined();
- });
-
- // Note: Error display tests are skipped - see HomePage.test.tsx for details
- it.skip("displays error on API failure for deck", async () => {
- mockDeckGet.mockRejectedValue(new ApiClientError("Deck not found", 404));
- mockCardsGet.mockResolvedValue({ cards: [] });
-
- renderWithProviders();
-
- await waitFor(() => {
- expect(screen.getByRole("alert").textContent).toContain("Deck not found");
- });
- });
-
- it.skip("displays error on API failure for cards", async () => {
- mockDeckGet.mockResolvedValue({ deck: mockDeck });
- mockCardsGet.mockRejectedValue(
- new ApiClientError("Failed to load cards", 500),
- );
-
- renderWithProviders();
-
- await waitFor(() => {
- expect(screen.getByRole("alert").textContent).toContain(
- "Failed to load cards",
- );
- });
- });
-
- // Skip: Testing RPC endpoint calls is difficult with Suspense in test environment.
- // The async atoms don't complete their fetch cycle reliably in vitest.
- // The actual API integration is tested via hydration-based UI tests.
- it.skip("calls correct RPC endpoints when fetching data", async () => {
- mockDeckGet.mockResolvedValue({ deck: mockDeck });
- mockCardsGet.mockResolvedValue({ cards: [] });
-
- renderWithProviders();
-
- await waitFor(
- () => {
- expect(mockDeckGet).toHaveBeenCalledWith({
- param: { id: "deck-1" },
- });
- },
- { timeout: 3000 },
- );
- expect(mockCardsGet).toHaveBeenCalledWith({
- param: { deckId: "deck-1" },
- });
+ const dueLabel = screen.getByText("Due Today");
+ expect(dueLabel).toBeDefined();
+ // Find the count within the same container (one card is due)
+ const dueContainer = dueLabel.parentElement;
+ expect(dueContainer?.querySelector(".text-primary")?.textContent).toBe("1");
});
- it("does not show description if deck has none", () => {
- const deckWithoutDescription = { ...mockDeck, description: null };
+ it("does not display card list (cards are hidden)", () => {
renderWithProviders({
- initialDeck: deckWithoutDescription,
- initialCards: [],
- });
-
- expect(
- screen.getByRole("heading", { name: "Japanese Vocabulary" }),
- ).toBeDefined();
-
- // No description should be shown
- expect(screen.queryByText("Common Japanese words")).toBeNull();
- });
-
- describe("Delete Note", () => {
- it("shows Delete button for each note", () => {
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockCards,
- });
-
- expect(screen.getByText("Hello")).toBeDefined();
-
- const deleteButtons = screen.getAllByRole("button", {
- name: "Delete note",
- });
- expect(deleteButtons.length).toBe(2);
- });
-
- it("opens delete confirmation modal when Delete button is clicked", async () => {
- const user = userEvent.setup();
-
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockCards,
- });
-
- const deleteButtons = screen.getAllByRole("button", {
- name: "Delete note",
- });
- const firstDeleteButton = deleteButtons[0];
- if (firstDeleteButton) {
- await user.click(firstDeleteButton);
- }
-
- expect(screen.getByRole("dialog")).toBeDefined();
- expect(
- screen.getByRole("heading", { name: "Delete Note" }),
- ).toBeDefined();
- });
-
- it("closes delete modal when Cancel is clicked", async () => {
- const user = userEvent.setup();
-
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockCards,
- });
-
- const deleteButtons = screen.getAllByRole("button", {
- name: "Delete note",
- });
- const firstDeleteButton = deleteButtons[0];
- if (firstDeleteButton) {
- await user.click(firstDeleteButton);
- }
-
- expect(screen.getByRole("dialog")).toBeDefined();
-
- await user.click(screen.getByRole("button", { name: "Cancel" }));
-
- expect(screen.queryByRole("dialog")).toBeNull();
- });
-
- it("deletes note and refreshes list on confirmation", async () => {
- const user = userEvent.setup();
-
- // After mutation, the list will refetch
- mockCardsGet.mockResolvedValue({
- cards: [mockCards[1]],
- });
- mockNoteDelete.mockResolvedValue({
- ok: true,
- json: async () => ({ success: true }),
- });
-
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockCards,
- });
-
- const deleteButtons = screen.getAllByRole("button", {
- name: "Delete note",
- });
- const firstDeleteButton = deleteButtons[0];
- if (firstDeleteButton) {
- await user.click(firstDeleteButton);
- }
-
- // Find the Delete button in the modal
- const dialog = screen.getByRole("dialog");
- const modalButtons = dialog.querySelectorAll("button");
- const confirmDeleteButton = Array.from(modalButtons).find((btn) =>
- btn.textContent?.includes("Delete"),
- );
- if (confirmDeleteButton) {
- await user.click(confirmDeleteButton);
- }
-
- // Wait for modal to close and list to refresh
- await waitFor(() => {
- expect(screen.queryByRole("dialog")).toBeNull();
- });
-
- // Verify DELETE request was made to notes endpoint
- expect(mockNoteDelete).toHaveBeenCalledWith({
- param: { deckId: "deck-1", noteId: "note-1" },
- });
-
- // Verify card count updated
- await waitFor(() => {
- expect(screen.getByText("(1)")).toBeDefined();
- });
- });
-
- it("displays error when delete fails", async () => {
- const user = userEvent.setup();
-
- mockNoteDelete.mockRejectedValue(
- new ApiClientError("Failed to delete note", 500),
- );
-
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockCards,
- });
-
- const deleteButtons = screen.getAllByRole("button", {
- name: "Delete note",
- });
- const firstDeleteButton = deleteButtons[0];
- if (firstDeleteButton) {
- await user.click(firstDeleteButton);
- }
-
- // Find the Delete button in the modal
- const dialog = screen.getByRole("dialog");
- const modalButtons = dialog.querySelectorAll("button");
- const confirmDeleteButton = Array.from(modalButtons).find((btn) =>
- btn.textContent?.includes("Delete"),
- );
- if (confirmDeleteButton) {
- await user.click(confirmDeleteButton);
- }
-
- // Error should be displayed in the modal
- await waitFor(() => {
- expect(screen.getByRole("alert").textContent).toContain(
- "Failed to delete note",
- );
- });
- });
- });
-
- describe("Card Grouping by Note", () => {
- it("groups cards by noteId and displays as note groups", () => {
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockNoteBasedCards,
- });
-
- // Should show note group container
- expect(screen.getByTestId("note-group")).toBeDefined();
-
- // Should display both cards within the note group
- const noteCards = screen.getAllByTestId("note-card");
- expect(noteCards.length).toBe(2);
- });
-
- it("shows Normal and Reversed badges for note-based cards", () => {
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockNoteBasedCards,
- });
-
- expect(screen.getByText("Normal")).toBeDefined();
- expect(screen.getByText("Reversed")).toBeDefined();
- });
-
- it("shows note card count in note group header", () => {
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockNoteBasedCards,
- });
-
- // Should show "Note (2 cards)" since there are 2 cards from the same note
- expect(screen.getByText("Note (2 cards)")).toBeDefined();
- });
-
- it("shows edit note button for note groups", () => {
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockNoteBasedCards,
- });
-
- expect(screen.getByTestId("note-group")).toBeDefined();
-
- const editNoteButton = screen.getByRole("button", { name: "Edit note" });
- expect(editNoteButton).toBeDefined();
- });
-
- it("shows delete note button for note groups", () => {
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockNoteBasedCards,
- });
-
- expect(screen.getByTestId("note-group")).toBeDefined();
-
- const deleteNoteButton = screen.getByRole("button", {
- name: "Delete note",
- });
- expect(deleteNoteButton).toBeDefined();
- });
-
- it("opens delete note modal when delete button is clicked", async () => {
- const user = userEvent.setup();
-
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockNoteBasedCards,
- });
-
- const deleteNoteButton = screen.getByRole("button", {
- name: "Delete note",
- });
- await user.click(deleteNoteButton);
-
- expect(screen.getByRole("dialog")).toBeDefined();
- expect(
- screen.getByRole("heading", { name: "Delete Note" }),
- ).toBeDefined();
- });
-
- it("deletes note and refreshes list when confirmed", async () => {
- const user = userEvent.setup();
-
- // After mutation, the list will refetch
- mockCardsGet.mockResolvedValue({ cards: [] });
- mockNoteDelete.mockResolvedValue({
- ok: true,
- json: async () => ({ success: true }),
- });
-
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockNoteBasedCards,
- });
-
- const deleteNoteButton = screen.getByRole("button", {
- name: "Delete note",
- });
- await user.click(deleteNoteButton);
-
- // Confirm deletion in modal
- const dialog = screen.getByRole("dialog");
- const modalButtons = dialog.querySelectorAll("button");
- const confirmDeleteButton = Array.from(modalButtons).find((btn) =>
- btn.textContent?.includes("Delete"),
- );
- if (confirmDeleteButton) {
- await user.click(confirmDeleteButton);
- }
-
- // Wait for modal to close
- await waitFor(() => {
- expect(screen.queryByRole("dialog")).toBeNull();
- });
-
- // Verify DELETE request was made to notes endpoint
- expect(mockNoteDelete).toHaveBeenCalledWith({
- param: { deckId: "deck-1", noteId: "note-1" },
- });
-
- // Should show empty state after deletion
- await waitFor(() => {
- expect(screen.getByText("No cards yet")).toBeDefined();
- });
+ initialDeck: mockDeck,
+ initialCards: mockCards,
});
- it("displays note preview from normal card content", () => {
- renderWithProviders({
- initialDeck: mockDeck,
- initialCards: mockNoteBasedCards,
- });
-
- expect(screen.getByTestId("note-group")).toBeDefined();
-
- // The normal card's front/back should be displayed as preview
- expect(screen.getByText("Apple")).toBeDefined();
- expect(screen.getByText("りんご")).toBeDefined();
- });
+ // Card content should NOT be visible on deck detail page
+ expect(screen.queryByText("Hello")).toBeNull();
+ expect(screen.queryByText("こんにちは")).toBeNull();
+ expect(screen.queryByText("Goodbye")).toBeNull();
});
});
diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx
index 1376fab..d39f063 100644
--- a/src/client/pages/DeckDetailPage.tsx
+++ b/src/client/pages/DeckDetailPage.tsx
@@ -1,164 +1,16 @@
import {
faChevronLeft,
faCirclePlay,
- faFile,
- faFileImport,
faLayerGroup,
- faPen,
- faPlus,
- faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { useAtomValue, useSetAtom } from "jotai";
-import { Suspense, useMemo, useState, useTransition } from "react";
+import { useAtomValue } from "jotai";
+import { Suspense } from "react";
import { Link, useParams } from "wouter";
-import { type Card, cardsByDeckAtomFamily, deckByIdAtomFamily } from "../atoms";
-import { CreateNoteModal } from "../components/CreateNoteModal";
-import { DeleteCardModal } from "../components/DeleteCardModal";
-import { DeleteNoteModal } from "../components/DeleteNoteModal";
-import { EditCardModal } from "../components/EditCardModal";
-import { EditNoteModal } from "../components/EditNoteModal";
+import { cardsByDeckAtomFamily, deckByIdAtomFamily } from "../atoms";
import { ErrorBoundary } from "../components/ErrorBoundary";
-import { ImportNotesModal } from "../components/ImportNotesModal";
import { LoadingSpinner } from "../components/LoadingSpinner";
-/** Combined type for display: note group */
-type CardDisplayItem = { type: "note"; noteId: string; cards: Card[] };
-
-const CardStateLabels: Record<number, string> = {
- 0: "New",
- 1: "Learning",
- 2: "Review",
- 3: "Relearning",
-};
-
-const CardStateColors: Record<number, string> = {
- 0: "bg-info/10 text-info",
- 1: "bg-warning/10 text-warning",
- 2: "bg-success/10 text-success",
- 3: "bg-error/10 text-error",
-};
-
-/** Component for displaying a group of cards from the same note */
-function NoteGroupCard({
- noteId,
- cards,
- index,
- onEditNote,
- onDeleteNote,
-}: {
- noteId: string;
- cards: Card[];
- index: number;
- onEditNote: () => void;
- onDeleteNote: () => void;
-}) {
- // Use the first card's front/back as preview (normal card takes precedence)
- const previewCard = cards.find((c) => !c.isReversed) ?? cards[0];
- if (!previewCard) return null;
-
- return (
- <div
- data-testid="note-group"
- data-note-id={noteId}
- className="bg-white rounded-xl border border-border/50 shadow-card hover:shadow-md transition-all duration-200 overflow-hidden"
- style={{ animationDelay: `${index * 30}ms` }}
- >
- {/* Note Header */}
- <div className="flex items-center justify-between px-5 py-3 border-b border-border/30 bg-ivory/30">
- <div className="flex items-center gap-2">
- <FontAwesomeIcon
- icon={faLayerGroup}
- className="w-4 h-4 text-muted"
- aria-hidden="true"
- />
- <span className="text-sm font-medium text-slate">
- Note ({cards.length} card{cards.length !== 1 ? "s" : ""})
- </span>
- </div>
- <div className="flex items-center gap-1">
- <button
- type="button"
- onClick={onEditNote}
- className="p-2 text-muted hover:text-slate hover:bg-white rounded-lg transition-colors"
- title="Edit note"
- >
- <FontAwesomeIcon
- icon={faPen}
- className="w-4 h-4"
- aria-hidden="true"
- />
- </button>
- <button
- type="button"
- onClick={onDeleteNote}
- className="p-2 text-muted hover:text-error hover:bg-error/5 rounded-lg transition-colors"
- title="Delete note"
- >
- <FontAwesomeIcon
- icon={faTrash}
- className="w-4 h-4"
- aria-hidden="true"
- />
- </button>
- </div>
- </div>
-
- {/* Note Content Preview */}
- <div className="p-5">
- <div className="grid grid-cols-2 gap-4 mb-4">
- <div>
- <span className="text-xs font-medium text-muted uppercase tracking-wide">
- Front
- </span>
- <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words">
- {previewCard.front}
- </p>
- </div>
- <div>
- <span className="text-xs font-medium text-muted uppercase tracking-wide">
- Back
- </span>
- <p className="mt-1 text-slate text-sm line-clamp-2 whitespace-pre-wrap break-words">
- {previewCard.back}
- </p>
- </div>
- </div>
-
- {/* Cards within this note */}
- <div className="space-y-2">
- {cards.map((card) => (
- <div
- key={card.id}
- data-testid="note-card"
- className="flex items-center gap-3 text-xs p-2 bg-ivory/50 rounded-lg"
- >
- <span
- className={`px-2 py-0.5 rounded-full font-medium ${CardStateColors[card.state] || "bg-muted/10 text-muted"}`}
- >
- {CardStateLabels[card.state] || "Unknown"}
- </span>
- {card.isReversed ? (
- <span className="px-2 py-0.5 rounded-full font-medium bg-purple-100 text-purple-700">
- Reversed
- </span>
- ) : (
- <span className="px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700">
- Normal
- </span>
- )}
- <span className="text-muted">{card.reps} reviews</span>
- {card.lapses > 0 && (
- <span className="text-muted">{card.lapses} lapses</span>
- )}
- </div>
- ))}
- </div>
- </div>
- </div>
- );
-}
-
function DeckHeader({ deckId }: { deckId: string }) {
const deck = useAtomValue(deckByIdAtomFamily(deckId));
@@ -172,119 +24,32 @@ function DeckHeader({ deckId }: { deckId: string }) {
);
}
-function CardList({
- deckId,
- onEditNote,
- onDeleteNote,
- onCreateNote,
-}: {
- deckId: string;
- onEditNote: (noteId: string) => void;
- onDeleteNote: (noteId: string) => void;
- onCreateNote: () => void;
-}) {
+function DeckStats({ deckId }: { deckId: string }) {
const cards = useAtomValue(cardsByDeckAtomFamily(deckId));
- // Group cards by note for display
- const displayItems = useMemo((): CardDisplayItem[] => {
- const noteGroups = new Map<string, Card[]>();
-
- for (const card of cards) {
- const existing = noteGroups.get(card.noteId);
- if (existing) {
- existing.push(card);
- } else {
- noteGroups.set(card.noteId, [card]);
- }
- }
-
- // Sort note groups by earliest card creation (newest first)
- const sortedNoteGroups = Array.from(noteGroups.entries()).sort(
- ([, cardsA], [, cardsB]) => {
- const minA = Math.min(
- ...cardsA.map((c) => new Date(c.createdAt).getTime()),
- );
- const minB = Math.min(
- ...cardsB.map((c) => new Date(c.createdAt).getTime()),
- );
- return minB - minA; // Newest first
- },
- );
-
- const items: CardDisplayItem[] = [];
- for (const [noteId, noteCards] of sortedNoteGroups) {
- // Sort cards within group: normal first, then reversed
- noteCards.sort((a, b) => {
- if (a.isReversed === b.isReversed) return 0;
- return a.isReversed ? 1 : -1;
- });
- items.push({ type: "note", noteId, cards: noteCards });
- }
-
- return items;
- }, [cards]);
+ // Count cards due today
+ const now = new Date();
+ const dueCards = cards.filter((card) => new Date(card.due) <= now);
- if (cards.length === 0) {
- return (
- <div className="text-center py-12 bg-white rounded-xl border border-border/50">
- <div className="w-14 h-14 mx-auto mb-4 bg-ivory rounded-xl flex items-center justify-center">
- <FontAwesomeIcon
- icon={faFile}
- className="w-7 h-7 text-muted"
- aria-hidden="true"
- />
+ return (
+ <div className="bg-white rounded-xl border border-border/50 p-6 mb-6">
+ <div className="grid grid-cols-2 gap-6">
+ <div>
+ <p className="text-sm text-muted mb-1">Total Cards</p>
+ <p className="text-2xl font-semibold text-ink">{cards.length}</p>
+ </div>
+ <div>
+ <p className="text-sm text-muted mb-1">Due Today</p>
+ <p className="text-2xl font-semibold text-primary">
+ {dueCards.length}
+ </p>
</div>
- <h3 className="font-display text-lg font-medium text-slate mb-2">
- No cards yet
- </h3>
- <p className="text-muted text-sm mb-4">Add notes to start studying</p>
- <button
- type="button"
- onClick={onCreateNote}
- className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200"
- >
- <FontAwesomeIcon
- icon={faPlus}
- className="w-5 h-5"
- aria-hidden="true"
- />
- Add Your First Note
- </button>
</div>
- );
- }
-
- return (
- <div className="space-y-4">
- {displayItems.map((item, index) => (
- <NoteGroupCard
- key={item.noteId}
- noteId={item.noteId}
- cards={item.cards}
- index={index}
- onEditNote={() => onEditNote(item.noteId)}
- onDeleteNote={() => onDeleteNote(item.noteId)}
- />
- ))}
</div>
);
}
-function DeckContent({
- deckId,
- onCreateNote,
- onImportNotes,
- onEditNote,
- onDeleteNote,
-}: {
- deckId: string;
- onCreateNote: () => void;
- onImportNotes: () => void;
- onEditNote: (noteId: string) => void;
- onDeleteNote: (noteId: string) => void;
-}) {
- const cards = useAtomValue(cardsByDeckAtomFamily(deckId));
-
+function DeckContent({ deckId }: { deckId: string }) {
return (
<div className="animate-fade-in">
{/* Deck Header */}
@@ -294,83 +59,47 @@ function DeckContent({
</Suspense>
</ErrorBoundary>
- {/* Study Button */}
- <div className="mb-8">
+ {/* Deck Stats */}
+ <ErrorBoundary>
+ <Suspense fallback={<LoadingSpinner />}>
+ <DeckStats deckId={deckId} />
+ </Suspense>
+ </ErrorBoundary>
+
+ {/* Action Buttons */}
+ <div className="space-y-4">
+ {/* Study Button */}
<Link
href={`/decks/${deckId}/study`}
- className="inline-flex items-center gap-2 bg-success hover:bg-success/90 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md"
+ className="flex items-center justify-center gap-3 w-full bg-success hover:bg-success/90 text-white font-medium py-4 px-6 rounded-xl transition-all duration-200 active:scale-[0.98] shadow-sm hover:shadow-md"
>
<FontAwesomeIcon
icon={faCirclePlay}
- className="w-5 h-5"
+ className="w-6 h-6"
aria-hidden="true"
/>
- Study Now
+ <span className="text-lg">Study Now</span>
</Link>
- </div>
- {/* Cards Section */}
- <div className="flex items-center justify-between mb-6">
- <h2 className="font-display text-xl font-medium text-slate">
- Cards <span className="text-muted font-normal">({cards.length})</span>
- </h2>
- <div className="flex items-center gap-2">
- <button
- type="button"
- onClick={onImportNotes}
- className="inline-flex items-center gap-2 border border-border hover:bg-ivory text-slate font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
- >
- <FontAwesomeIcon
- icon={faFileImport}
- className="w-5 h-5"
- aria-hidden="true"
- />
- Import CSV
- </button>
- <button
- type="button"
- onClick={onCreateNote}
- className="inline-flex items-center gap-2 bg-primary hover:bg-primary-dark text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 active:scale-[0.98]"
- >
- <FontAwesomeIcon
- icon={faPlus}
- className="w-5 h-5"
- aria-hidden="true"
- />
- Add Note
- </button>
- </div>
+ {/* View Cards Link */}
+ <Link
+ href={`/decks/${deckId}/cards`}
+ className="flex items-center justify-center gap-3 w-full border border-border hover:bg-ivory text-slate font-medium py-4 px-6 rounded-xl transition-all duration-200 active:scale-[0.98]"
+ >
+ <FontAwesomeIcon
+ icon={faLayerGroup}
+ className="w-5 h-5"
+ aria-hidden="true"
+ />
+ <span className="text-lg">View Cards</span>
+ </Link>
</div>
-
- {/* Card List */}
- <CardList
- deckId={deckId}
- onEditNote={onEditNote}
- onDeleteNote={onDeleteNote}
- onCreateNote={onCreateNote}
- />
</div>
);
}
export function DeckDetailPage() {
const { deckId } = useParams<{ deckId: string }>();
- const [, startTransition] = useTransition();
-
- const reloadCards = useSetAtom(cardsByDeckAtomFamily(deckId || ""));
-
- const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
- const [isImportModalOpen, setIsImportModalOpen] = useState(false);
- const [editingCard, setEditingCard] = useState<Card | null>(null);
- const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
- const [deletingCard, setDeletingCard] = useState<Card | null>(null);
- const [deletingNoteId, setDeletingNoteId] = useState<string | null>(null);
-
- const handleCardMutation = () => {
- startTransition(() => {
- reloadCards();
- });
- };
if (!deckId) {
return (
@@ -411,63 +140,10 @@ export function DeckDetailPage() {
<main className="max-w-4xl mx-auto px-4 py-8">
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner />}>
- <DeckContent
- deckId={deckId}
- onCreateNote={() => setIsCreateModalOpen(true)}
- onImportNotes={() => setIsImportModalOpen(true)}
- onEditNote={setEditingNoteId}
- onDeleteNote={setDeletingNoteId}
- />
+ <DeckContent deckId={deckId} />
</Suspense>
</ErrorBoundary>
</main>
-
- {/* Modals */}
- <CreateNoteModal
- isOpen={isCreateModalOpen}
- deckId={deckId}
- onClose={() => setIsCreateModalOpen(false)}
- onNoteCreated={handleCardMutation}
- />
-
- <ImportNotesModal
- isOpen={isImportModalOpen}
- deckId={deckId}
- onClose={() => setIsImportModalOpen(false)}
- onImportComplete={handleCardMutation}
- />
-
- <EditCardModal
- isOpen={editingCard !== null}
- deckId={deckId}
- card={editingCard}
- onClose={() => setEditingCard(null)}
- onCardUpdated={handleCardMutation}
- />
-
- <EditNoteModal
- isOpen={editingNoteId !== null}
- deckId={deckId}
- noteId={editingNoteId}
- onClose={() => setEditingNoteId(null)}
- onNoteUpdated={handleCardMutation}
- />
-
- <DeleteCardModal
- isOpen={deletingCard !== null}
- deckId={deckId}
- card={deletingCard}
- onClose={() => setDeletingCard(null)}
- onCardDeleted={handleCardMutation}
- />
-
- <DeleteNoteModal
- isOpen={deletingNoteId !== null}
- deckId={deckId}
- noteId={deletingNoteId}
- onClose={() => setDeletingNoteId(null)}
- onNoteDeleted={handleCardMutation}
- />
</div>
);
}
diff --git a/src/client/pages/index.ts b/src/client/pages/index.ts
index 597ea39..e071884 100644
--- a/src/client/pages/index.ts
+++ b/src/client/pages/index.ts
@@ -1,3 +1,4 @@
+export { DeckCardsPage } from "./DeckCardsPage";
export { DeckDetailPage } from "./DeckDetailPage";
export { HomePage } from "./HomePage";
export { LoginPage } from "./LoginPage";