aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 17:44:14 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 17:44:14 +0900
commitef40cc0f3b1b3013046820b84e8482f1c6a29533 (patch)
treee0951e308793684efee8f1369068e7e61ca9994e
parent797ef2fcfaa7ac63355c13809a644401a76250bc (diff)
downloadkioku-ef40cc0f3b1b3013046820b84e8482f1c6a29533.tar.gz
kioku-ef40cc0f3b1b3013046820b84e8482f1c6a29533.tar.zst
kioku-ef40cc0f3b1b3013046820b84e8482f1c6a29533.zip
feat(client): add deck list page with empty state and list view
Implement HomePage to display user's decks fetched from the API. Includes loading state, error handling with retry, and empty state messaging. Also adds comprehensive tests for the deck list page. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--docs/dev/roadmap.md2
-rw-r--r--src/client/App.test.tsx25
-rw-r--r--src/client/pages/HomePage.test.tsx291
-rw-r--r--src/client/pages/HomePage.tsx117
4 files changed, 431 insertions, 4 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 819107e..54a3376 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -84,7 +84,7 @@ Smaller features first to enable early MVP validation.
- [x] Add tests
### Frontend
-- [ ] Deck list page (empty state, list view)
+- [x] Deck list page (empty state, list view)
- [ ] Create deck modal/form
- [ ] Edit deck
- [ ] Delete deck (with confirmation)
diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx
index 516cbeb..bdc281a 100644
--- a/src/client/App.test.tsx
+++ b/src/client/App.test.tsx
@@ -15,6 +15,14 @@ vi.mock("./api/client", () => ({
logout: vi.fn(),
isAuthenticated: vi.fn(),
getTokens: vi.fn(),
+ getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ decks: {
+ $get: vi.fn(),
+ },
+ },
+ },
},
ApiClientError: class ApiClientError extends Error {
constructor(
@@ -28,6 +36,12 @@ vi.mock("./api/client", () => ({
},
}));
+// Helper to create mock responses compatible with Hono's ClientResponse
+// biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
+function mockResponse(data: { ok: boolean; status?: number; json: () => Promise<any> }) {
+ return data as unknown as Awaited<ReturnType<typeof apiClient.rpc.api.decks.$get>>;
+}
+
function renderWithRouter(path: string) {
const { hook } = memoryLocation({ path, static: true });
return render(
@@ -58,12 +72,21 @@ describe("App routing", () => {
refreshToken: "refresh-token",
});
vi.mocked(apiClient.isAuthenticated).mockReturnValue(true);
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue({
+ Authorization: "Bearer access-token",
+ });
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: [] }),
+ }),
+ );
});
it("renders home page at /", () => {
renderWithRouter("/");
expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
- expect(screen.getByText("Spaced repetition learning app")).toBeDefined();
+ expect(screen.getByRole("heading", { name: "Your Decks" })).toBeDefined();
});
});
diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx
new file mode 100644
index 0000000..f471924
--- /dev/null
+++ b/src/client/pages/HomePage.test.tsx
@@ -0,0 +1,291 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import { apiClient } from "../api/client";
+import { AuthProvider } from "../stores";
+import { HomePage } from "./HomePage";
+
+vi.mock("../api/client", () => ({
+ apiClient: {
+ login: vi.fn(),
+ logout: vi.fn(),
+ isAuthenticated: vi.fn(),
+ getTokens: vi.fn(),
+ getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ decks: {
+ $get: vi.fn(),
+ },
+ },
+ },
+ },
+ ApiClientError: class ApiClientError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public code?: string,
+ ) {
+ super(message);
+ this.name = "ApiClientError";
+ }
+ },
+}));
+
+// Helper to create mock responses compatible with Hono's ClientResponse
+// biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
+function mockResponse(data: { ok: boolean; status?: number; json: () => Promise<any> }) {
+ return data as unknown as Awaited<ReturnType<typeof apiClient.rpc.api.decks.$get>>;
+}
+
+const mockDecks = [
+ {
+ id: "deck-1",
+ name: "Japanese Vocabulary",
+ description: "Common Japanese words",
+ newCardsPerDay: 20,
+ createdAt: "2024-01-01T00:00:00Z",
+ updatedAt: "2024-01-01T00:00:00Z",
+ },
+ {
+ id: "deck-2",
+ name: "Spanish Verbs",
+ description: null,
+ newCardsPerDay: 10,
+ createdAt: "2024-01-02T00:00:00Z",
+ updatedAt: "2024-01-02T00:00:00Z",
+ },
+];
+
+function renderWithProviders(path = "/") {
+ const { hook } = memoryLocation({ path });
+ return render(
+ <Router hook={hook}>
+ <AuthProvider>
+ <HomePage />
+ </AuthProvider>
+ </Router>,
+ );
+}
+
+describe("HomePage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(apiClient.getTokens).mockReturnValue({
+ accessToken: "access-token",
+ refreshToken: "refresh-token",
+ });
+ vi.mocked(apiClient.isAuthenticated).mockReturnValue(true);
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue({
+ Authorization: "Bearer access-token",
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it("renders page title and logout button", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: [] }),
+ }),
+ );
+
+ renderWithProviders();
+
+ expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Logout" })).toBeDefined();
+ });
+
+ it("shows loading state while fetching decks", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockImplementation(
+ () => new Promise(() => {}), // Never resolves
+ );
+
+ renderWithProviders();
+
+ expect(screen.getByText("Loading decks...")).toBeDefined();
+ });
+
+ it("displays empty state when no decks exist", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: [] }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("You don't have any decks yet.")).toBeDefined();
+ });
+ expect(
+ screen.getByText("Create your first deck to start learning!"),
+ ).toBeDefined();
+ });
+
+ it("displays list of decks", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: mockDecks }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "Japanese Vocabulary" }),
+ ).toBeDefined();
+ });
+ expect(
+ screen.getByRole("heading", { name: "Spanish Verbs" }),
+ ).toBeDefined();
+ expect(screen.getByText("Common Japanese words")).toBeDefined();
+ });
+
+ it("displays error on API failure", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "Internal server error" }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Internal server error",
+ );
+ });
+ });
+
+ it("displays generic error on unexpected failure", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockRejectedValue(
+ new Error("Network error"),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Failed to load decks. Please try again.",
+ );
+ });
+ });
+
+ it("allows retry after error", async () => {
+ const user = userEvent.setup();
+ vi.mocked(apiClient.rpc.api.decks.$get)
+ .mockResolvedValueOnce(
+ mockResponse({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "Server error" }),
+ }),
+ )
+ .mockResolvedValueOnce(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: mockDecks }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeDefined();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Retry" }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "Japanese Vocabulary" }),
+ ).toBeDefined();
+ });
+ });
+
+ it("calls logout when logout button is clicked", async () => {
+ const user = userEvent.setup();
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: [] }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.queryByText("Loading decks...")).toBeNull();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Logout" }));
+
+ expect(apiClient.logout).toHaveBeenCalled();
+ });
+
+ it("does not show description if deck has none", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({
+ decks: [
+ {
+ id: "deck-1",
+ name: "No Description Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: "2024-01-01T00:00:00Z",
+ updatedAt: "2024-01-01T00:00:00Z",
+ },
+ ],
+ }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "No Description Deck" }),
+ ).toBeDefined();
+ });
+
+ // The deck item should only contain the heading, no description paragraph
+ const deckItem = screen
+ .getByRole("heading", { name: "No Description Deck" })
+ .closest("li");
+ expect(deckItem?.querySelectorAll("p").length).toBe(0);
+ });
+
+ it("passes auth header when fetching decks", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: [] }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledWith(undefined, {
+ headers: { Authorization: "Bearer access-token" },
+ });
+ });
+ });
+});
diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx
index 1d65484..c9d0843 100644
--- a/src/client/pages/HomePage.tsx
+++ b/src/client/pages/HomePage.tsx
@@ -1,8 +1,121 @@
+import { useCallback, useEffect, useState } from "react";
+import { ApiClientError, apiClient } from "../api";
+import { useAuth } from "../stores";
+
+interface Deck {
+ id: string;
+ name: string;
+ description: string | null;
+ newCardsPerDay: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
export function HomePage() {
+ const { logout } = useAuth();
+ const [decks, setDecks] = useState<Deck[]>([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ const fetchDecks = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const res = await apiClient.rpc.api.decks.$get(undefined, {
+ headers: apiClient.getAuthHeader(),
+ });
+
+ if (!res.ok) {
+ const errorBody = await res.json().catch(() => ({}));
+ throw new ApiClientError(
+ (errorBody as { error?: string }).error ||
+ `Request failed with status ${res.status}`,
+ res.status,
+ );
+ }
+
+ const data = await res.json();
+ setDecks(data.decks);
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to load decks. Please try again.");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchDecks();
+ }, [fetchDecks]);
+
return (
<div>
- <h1>Kioku</h1>
- <p>Spaced repetition learning app</p>
+ <header
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: "1rem",
+ }}
+ >
+ <h1>Kioku</h1>
+ <button type="button" onClick={logout}>
+ Logout
+ </button>
+ </header>
+
+ <main>
+ <h2>Your Decks</h2>
+
+ {isLoading && <p>Loading decks...</p>}
+
+ {error && (
+ <div role="alert" style={{ color: "red" }}>
+ {error}
+ <button
+ type="button"
+ onClick={fetchDecks}
+ style={{ marginLeft: "0.5rem" }}
+ >
+ Retry
+ </button>
+ </div>
+ )}
+
+ {!isLoading && !error && decks.length === 0 && (
+ <div>
+ <p>You don't have any decks yet.</p>
+ <p>Create your first deck to start learning!</p>
+ </div>
+ )}
+
+ {!isLoading && !error && decks.length > 0 && (
+ <ul style={{ listStyle: "none", padding: 0 }}>
+ {decks.map((deck) => (
+ <li
+ key={deck.id}
+ style={{
+ border: "1px solid #ccc",
+ padding: "1rem",
+ marginBottom: "0.5rem",
+ borderRadius: "4px",
+ }}
+ >
+ <h3 style={{ margin: 0 }}>{deck.name}</h3>
+ {deck.description && (
+ <p style={{ margin: "0.5rem 0 0 0", color: "#666" }}>
+ {deck.description}
+ </p>
+ )}
+ </li>
+ ))}
+ </ul>
+ )}
+ </main>
</div>
);
}