diff options
| author | Claude <noreply@anthropic.com> | 2026-01-20 01:16:15 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-01-20 01:16:15 +0000 |
| commit | 8b212f3030ec30ed68410e609ed55fd7f0b06ea0 (patch) | |
| tree | 487d93edd18f6544f576bff57f86ad30bf640080 /src | |
| parent | 188c49e6ae0dfa0af052a001bc40c26d448b1583 (diff) | |
| download | kioku-8b212f3030ec30ed68410e609ed55fd7f0b06ea0.tar.gz kioku-8b212f3030ec30ed68410e609ed55fd7f0b06ea0.tar.zst kioku-8b212f3030ec30ed68410e609ed55fd7f0b06ea0.zip | |
feat(deck): separate card list from deck detail page
Separate the card list view from the deck learning page to prevent users
from seeing cards they are about to study. The deck detail page now shows
only study statistics with a "Study Now" button and a "View Cards" link.
- Add new DeckCardsPage component at /decks/:deckId/cards for managing cards
- Simplify DeckDetailPage to show deck stats and navigation buttons
- Update routing in App.tsx with proper route ordering
- Add comprehensive tests for both pages
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/App.tsx | 10 | ||||
| -rw-r--r-- | src/client/pages/DeckCardsPage.test.tsx | 604 | ||||
| -rw-r--r-- | src/client/pages/DeckCardsPage.tsx | 457 | ||||
| -rw-r--r-- | src/client/pages/DeckDetailPage.test.tsx | 480 | ||||
| -rw-r--r-- | src/client/pages/DeckDetailPage.tsx | 416 | ||||
| -rw-r--r-- | src/client/pages/index.ts | 1 |
6 files changed, 1159 insertions, 809 deletions
diff --git a/src/client/App.tsx b/src/client/App.tsx index e1b794c..69af60f 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,6 +1,7 @@ import { Route, Switch } from "wouter"; import { OfflineBanner, ProtectedRoute } from "./components"; import { + DeckCardsPage, DeckDetailPage, HomePage, LoginPage, @@ -19,9 +20,9 @@ export function App() { <HomePage /> </ProtectedRoute> </Route> - <Route path="/decks/:deckId"> + <Route path="/decks/:deckId/cards"> <ProtectedRoute> - <DeckDetailPage /> + <DeckCardsPage /> </ProtectedRoute> </Route> <Route path="/decks/:deckId/study"> @@ -29,6 +30,11 @@ export function App() { <StudyPage /> </ProtectedRoute> </Route> + <Route path="/decks/:deckId"> + <ProtectedRoute> + <DeckDetailPage /> + </ProtectedRoute> + </Route> <Route path="/note-types"> <ProtectedRoute> <NoteTypesPage /> 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"; |
