From ad950be46447a74e523eeb2bd278641600dff2fb Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Wed, 31 Dec 2025 13:40:59 +0900
Subject: feat(client): group cards by note in DeckDetailPage
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Display note-based cards as grouped items showing all cards generated
from the same note, with note-level edit/delete actions. Legacy cards
without note association are shown separately with a "Legacy" badge.

- Add NoteGroupCard component for displaying note groups with card stats
- Add LegacyCardItem component for backward-compatible card display
- Add DeleteNoteModal for deleting notes and their cards
- Show Normal/Reversed badges for cards within note groups

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---
 src/client/pages/DeckDetailPage.test.tsx | 307 ++++++++++++++++++++++++++++++-
 1 file changed, 306 insertions(+), 1 deletion(-)

(limited to 'src/client/pages/DeckDetailPage.test.tsx')

diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx
index a6b8531..35303d9 100644
--- a/src/client/pages/DeckDetailPage.test.tsx
+++ b/src/client/pages/DeckDetailPage.test.tsx
@@ -51,10 +51,13 @@ const mockDeck = {
 	updatedAt: "2024-01-01T00:00:00Z",
 };
 
-const mockCards = [
+// Legacy cards (no noteId) for backward compatibility testing
+const mockLegacyCards = [
 	{
 		id: "card-1",
 		deckId: "deck-1",
+		noteId: null,
+		isReversed: null,
 		front: "Hello",
 		back: "こんにちは",
 		state: 0,
@@ -74,6 +77,8 @@ const mockCards = [
 	{
 		id: "card-2",
 		deckId: "deck-1",
+		noteId: null,
+		isReversed: null,
 		front: "Goodbye",
 		back: "さようなら",
 		state: 2,
@@ -92,6 +97,58 @@ const mockCards = [
 	},
 ];
 
+// 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,
+	},
+];
+
+// Mixed cards (both legacy and note-based)
+const mockMixedCards = [...mockLegacyCards, ...mockNoteBasedCards];
+
+// Alias for backward compatibility in existing tests
+const mockCards = mockLegacyCards;
+
 function renderWithProviders(path = "/decks/deck-1") {
 	const { hook } = memoryLocation({ path, static: true });
 	return render(
@@ -583,4 +640,252 @@ describe("DeckDetailPage", () => {
 			});
 		});
 	});
+
+	describe("Card Grouping by Note", () => {
+		it("groups cards by noteId and displays as note groups", async () => {
+			mockFetch
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ deck: mockDeck }),
+				})
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ cards: mockNoteBasedCards }),
+				});
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				// 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("displays legacy cards separately from note groups", async () => {
+			mockFetch
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ deck: mockDeck }),
+				})
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ cards: mockMixedCards }),
+				});
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				// Should show both note groups and legacy cards
+				expect(screen.getByTestId("note-group")).toBeDefined();
+			});
+
+			const legacyCards = screen.getAllByTestId("legacy-card");
+			expect(legacyCards.length).toBe(2); // 2 legacy cards
+
+			// Should show "Legacy" badge for legacy cards
+			const legacyBadges = screen.getAllByText("Legacy");
+			expect(legacyBadges.length).toBe(2);
+		});
+
+		it("shows Normal and Reversed badges for note-based cards", async () => {
+			mockFetch
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ deck: mockDeck }),
+				})
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ cards: mockNoteBasedCards }),
+				});
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				expect(screen.getByText("Normal")).toBeDefined();
+			});
+
+			expect(screen.getByText("Reversed")).toBeDefined();
+		});
+
+		it("shows note card count in note group header", async () => {
+			mockFetch
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ deck: mockDeck }),
+				})
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ cards: mockNoteBasedCards }),
+				});
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				// 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", async () => {
+			mockFetch
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ deck: mockDeck }),
+				})
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ cards: mockNoteBasedCards }),
+				});
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				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", async () => {
+			mockFetch
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ deck: mockDeck }),
+				})
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ cards: mockNoteBasedCards }),
+				});
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				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();
+
+			mockFetch
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ deck: mockDeck }),
+				})
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ cards: mockNoteBasedCards }),
+				});
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				expect(screen.getByTestId("note-group")).toBeDefined();
+			});
+
+			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();
+
+			mockFetch
+				// Initial load
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ deck: mockDeck }),
+				})
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ cards: mockNoteBasedCards }),
+				})
+				// Delete request
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ success: true }),
+				})
+				// Refresh cards after deletion
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ cards: [] }),
+				});
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				expect(screen.getByTestId("note-group")).toBeDefined();
+			});
+
+			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(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", {
+				method: "DELETE",
+				headers: { Authorization: "Bearer access-token" },
+			});
+
+			// Should show empty state after deletion
+			await waitFor(() => {
+				expect(screen.getByText("No cards yet")).toBeDefined();
+			});
+		});
+
+		it("displays note preview from normal card content", async () => {
+			mockFetch
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ deck: mockDeck }),
+				})
+				.mockResolvedValueOnce({
+					ok: true,
+					json: async () => ({ cards: mockNoteBasedCards }),
+				});
+
+			renderWithProviders();
+
+			await waitFor(() => {
+				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();
+		});
+	});
 });
-- 
cgit v1.2.3-70-g09d2

