diff options
| author | nsfisis <54318333+nsfisis@users.noreply.github.com> | 2026-01-20 19:55:24 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-20 19:55:24 +0900 |
| commit | 86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6 (patch) | |
| tree | 487d93edd18f6544f576bff57f86ad30bf640080 /src | |
| parent | 188c49e6ae0dfa0af052a001bc40c26d448b1583 (diff) | |
| parent | 8b212f3030ec30ed68410e609ed55fd7f0b06ea0 (diff) | |
| download | kioku-86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6.tar.gz kioku-86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6.tar.zst kioku-86a18da3ff9c5d9edcc7fc17c19e5e36eb16aef6.zip | |
Merge pull request #10 from nsfisis/claude/separate-deck-learning-view-jb2rV
Separate deck detail and card learning 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"; |
