From be80c60253cc38ea8c68dc179f25e8eadcf200f2 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sat, 10 Jan 2026 20:22:08 +0000 Subject: [PATCH] Implement History page with table view and pagination (P2.12) Add functional history page that displays DailyLog entries in a table with date, cycle day/phase, decision, body battery, HRV, and intensity columns. Features include: - Data fetching from /api/history endpoint - Pagination with previous/next navigation - Date filtering with start/end date inputs - Decision color coding (REST=red, TRAIN=green, GENTLE/LIGHT/REDUCED=yellow) - Loading and error states - Empty state when no history exists Co-Authored-By: Claude Opus 4.5 --- IMPLEMENTATION_PLAN.md | 12 +- src/app/history/page.test.tsx | 623 ++++++++++++++++++++++++++++++++++ src/app/history/page.tsx | 320 ++++++++++++++++- 3 files changed, 947 insertions(+), 8 deletions(-) create mode 100644 src/app/history/page.test.tsx diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index be3a287..15fcdd1 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -49,7 +49,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | Settings (`/settings`) | **COMPLETE** | Form with cycleLength, notificationTime, timezone | | Settings/Garmin (`/settings/garmin`) | Placeholder | Needs token management UI | | Calendar (`/calendar`) | Placeholder | Needs MonthView integration | -| History (`/history`) | Placeholder | Needs list/pagination implementation | +| History (`/history`) | **COMPLETE** | Table view with date filtering, pagination, decision styling, 26 tests | | Plan (`/plan`) | Placeholder | Needs phase details display | ### Components @@ -90,6 +90,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta | `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 10 tests (token validation, ICS generation, caching, error handling) | | `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) | | `src/app/api/history/route.test.ts` | **EXISTS** - 19 tests (pagination, date filtering, auth, validation) | +| `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) | | E2E tests | **NONE** | ### Critical Business Rules (from Spec) @@ -403,12 +404,12 @@ Full feature set for production use. - **Depends On:** P2.6 - **Note:** DayCell is **COMPLETE**, MonthView needs grid implementation (~70% remaining) -### P2.12: History Page Implementation -- [ ] View past training decisions and data +### P2.12: History Page Implementation ✅ COMPLETE +- [x] View past training decisions and data - **Files:** - - `src/app/history/page.tsx` - List view of DailyLogs with pagination + - `src/app/history/page.tsx` - Data fetching, table display, pagination, date filtering - **Tests:** - - E2E test: history loads, pagination works + - `src/app/history/page.test.tsx` - 26 tests covering rendering, data loading, pagination, filtering, error handling - **Why:** Users want to review their training history - **Depends On:** P2.8 @@ -615,6 +616,7 @@ P2.14 Mini calendar - [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6) - [x] **Dashboard Page** - Complete daily interface with /api/today integration, DecisionCard, DataPanel, NutritionPanel, OverrideToggles, 23 tests (P1.7) - [x] **Settings Page** - Form for cycleLength, notificationTime, timezone with validation, loading states, error handling, 24 tests (P2.9) +- [x] **History Page** - Table view of DailyLogs with date filtering, pagination, decision styling, 26 tests (P2.12) ### Test Infrastructure - [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup diff --git a/src/app/history/page.test.tsx b/src/app/history/page.test.tsx new file mode 100644 index 0000000..05f1922 --- /dev/null +++ b/src/app/history/page.test.tsx @@ -0,0 +1,623 @@ +// ABOUTME: Unit tests for the History page component. +// ABOUTME: Tests data loading, table rendering, pagination, and date filtering. +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock next/navigation +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: mockPush, + }), +})); + +// Mock fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +import HistoryPage from "./page"; + +describe("HistoryPage", () => { + const mockDailyLog = { + id: "log1", + user: "user123", + date: "2025-01-10", + cycleDay: 5, + phase: "FOLLICULAR", + bodyBatteryCurrent: 85, + bodyBatteryYesterdayLow: 45, + hrvStatus: "Balanced", + weekIntensityMinutes: 60, + phaseLimit: 120, + remainingMinutes: 60, + trainingDecision: "TRAIN", + decisionReason: "All metrics look good - full training today!", + notificationSentAt: "2025-01-10T07:30:00Z", + created: "2025-01-10T06:00:00Z", + }; + + const mockHistoryResponse = { + items: [mockDailyLog], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + hasMore: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockHistoryResponse), + }); + }); + + describe("rendering", () => { + it("renders the history heading", async () => { + render(); + + await waitFor(() => { + expect( + screen.getByRole("heading", { name: /history/i }), + ).toBeInTheDocument(); + }); + }); + + it("renders a back link to dashboard", async () => { + render(); + + await waitFor(() => { + expect( + screen.getByRole("link", { name: /back to dashboard/i }), + ).toHaveAttribute("href", "/"); + }); + }); + + it("renders the history table with headers", async () => { + render(); + + await waitFor(() => { + // Use getAllByRole to find table headers specifically + const columnHeaders = screen.getAllByRole("columnheader"); + expect(columnHeaders).toHaveLength(6); + expect(columnHeaders[0]).toHaveTextContent(/date/i); + expect(columnHeaders[1]).toHaveTextContent(/day.*phase/i); + expect(columnHeaders[2]).toHaveTextContent(/decision/i); + expect(columnHeaders[3]).toHaveTextContent(/body battery/i); + expect(columnHeaders[4]).toHaveTextContent(/hrv/i); + expect(columnHeaders[5]).toHaveTextContent(/intensity/i); + }); + }); + }); + + describe("data loading", () => { + it("fetches history data on mount", async () => { + render(); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/history"), + ); + }); + }); + + it("shows loading state while fetching", async () => { + let resolveHistory: (value: unknown) => void = () => {}; + const historyPromise = new Promise((resolve) => { + resolveHistory = resolve; + }); + mockFetch.mockReturnValue({ + ok: true, + json: () => historyPromise, + }); + + render(); + + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + + resolveHistory(mockHistoryResponse); + + await waitFor(() => { + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + }); + + it("displays daily log entries in the table", async () => { + render(); + + await waitFor(() => { + // Check that the log entry data is displayed + expect(screen.getByText(/jan 10, 2025/i)).toBeInTheDocument(); + expect(screen.getByText(/day 5/i)).toBeInTheDocument(); + expect(screen.getByText(/follicular/i)).toBeInTheDocument(); + expect(screen.getByText(/train/i)).toBeInTheDocument(); + expect(screen.getByText(/85/)).toBeInTheDocument(); // body battery current + expect(screen.getByText(/balanced/i)).toBeInTheDocument(); + expect(screen.getByText(/60.*\/.*120/)).toBeInTheDocument(); // intensity/limit + }); + }); + + it("displays multiple log entries", async () => { + const logs = [ + mockDailyLog, + { + ...mockDailyLog, + id: "log2", + date: "2025-01-09", + cycleDay: 4, + trainingDecision: "REST", + }, + { + ...mockDailyLog, + id: "log3", + date: "2025-01-08", + cycleDay: 3, + trainingDecision: "GENTLE", + }, + ]; + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + items: logs, + total: 3, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/jan 10, 2025/i)).toBeInTheDocument(); + expect(screen.getByText(/jan 9, 2025/i)).toBeInTheDocument(); + expect(screen.getByText(/jan 8, 2025/i)).toBeInTheDocument(); + }); + }); + }); + + describe("empty state", () => { + it("shows empty state message when no logs exist", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + items: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + hasMore: false, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/no history/i)).toBeInTheDocument(); + }); + }); + }); + + describe("error handling", () => { + it("shows error message on fetch failure", async () => { + mockFetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: "Failed to fetch history" }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect( + screen.getByText(/failed to fetch history/i), + ).toBeInTheDocument(); + }); + }); + + it("shows generic error for network failures", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); + + render(); + + await waitFor(() => { + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/network error/i)).toBeInTheDocument(); + }); + }); + }); + + describe("pagination", () => { + it("shows pagination info", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 50, + page: 1, + totalPages: 3, + hasMore: true, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument(); + }); + }); + + it("renders previous and next buttons", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 50, + page: 2, + totalPages: 3, + hasMore: true, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /previous/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /next/i }), + ).toBeInTheDocument(); + }); + }); + + it("disables previous button on first page", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 50, + page: 1, + totalPages: 3, + hasMore: true, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /previous/i }), + ).toBeDisabled(); + }); + }); + + it("disables next button on last page", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 50, + page: 3, + totalPages: 3, + hasMore: false, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /next/i })).toBeDisabled(); + }); + }); + + it("fetches next page when next button is clicked", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 50, + page: 1, + totalPages: 3, + hasMore: true, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /next/i }), + ).toBeInTheDocument(); + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 50, + page: 2, + totalPages: 3, + hasMore: true, + }), + }); + + fireEvent.click(screen.getByRole("button", { name: /next/i })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenLastCalledWith( + expect.stringContaining("page=2"), + ); + }); + }); + + it("fetches previous page when previous button is clicked", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 50, + page: 2, + totalPages: 3, + hasMore: true, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: /previous/i }), + ).toBeInTheDocument(); + }); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 50, + page: 1, + totalPages: 3, + hasMore: true, + }), + }); + + fireEvent.click(screen.getByRole("button", { name: /previous/i })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenLastCalledWith( + expect.stringContaining("page=1"), + ); + }); + }); + + it("hides pagination when there is only one page", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 5, + page: 1, + totalPages: 1, + hasMore: false, + }), + }); + + render(); + + await waitFor(() => { + expect( + screen.queryByRole("button", { name: /previous/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /next/i }), + ).not.toBeInTheDocument(); + }); + }); + }); + + describe("date filtering", () => { + it("renders date filter inputs", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/start date/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/end date/i)).toBeInTheDocument(); + }); + }); + + it("fetches with date filter when start date is set", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/start date/i)).toBeInTheDocument(); + }); + + const startDateInput = screen.getByLabelText(/start date/i); + fireEvent.change(startDateInput, { target: { value: "2025-01-01" } }); + + // Click a filter button to apply + const filterButton = screen.getByRole("button", { name: /apply/i }); + fireEvent.click(filterButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenLastCalledWith( + expect.stringContaining("startDate=2025-01-01"), + ); + }); + }); + + it("fetches with date filter when end date is set", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/end date/i)).toBeInTheDocument(); + }); + + const endDateInput = screen.getByLabelText(/end date/i); + fireEvent.change(endDateInput, { target: { value: "2025-01-15" } }); + + const filterButton = screen.getByRole("button", { name: /apply/i }); + fireEvent.click(filterButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenLastCalledWith( + expect.stringContaining("endDate=2025-01-15"), + ); + }); + }); + + it("clears filters and refetches when clear button is clicked", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/start date/i)).toBeInTheDocument(); + }); + + // Set a date filter first + const startDateInput = screen.getByLabelText(/start date/i); + fireEvent.change(startDateInput, { target: { value: "2025-01-01" } }); + + const filterButton = screen.getByRole("button", { name: /apply/i }); + fireEvent.click(filterButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenLastCalledWith( + expect.stringContaining("startDate"), + ); + }); + + // Clear filters + const clearButton = screen.getByRole("button", { name: /clear/i }); + fireEvent.click(clearButton); + + await waitFor(() => { + expect(startDateInput).toHaveValue(""); + // Should refetch without date params + expect(mockFetch).toHaveBeenLastCalledWith("/api/history?page=1"); + }); + }); + + it("resets to page 1 when filter is applied", async () => { + // Start on page 2 + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 50, + page: 2, + totalPages: 3, + hasMore: true, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/page 2 of 3/i)).toBeInTheDocument(); + }); + + // Apply filter + const startDateInput = screen.getByLabelText(/start date/i); + fireEvent.change(startDateInput, { target: { value: "2025-01-01" } }); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 10, + page: 1, + totalPages: 1, + hasMore: false, + }), + }); + + const filterButton = screen.getByRole("button", { name: /apply/i }); + fireEvent.click(filterButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenLastCalledWith( + expect.stringMatching(/page=1.*startDate|startDate.*page=1/), + ); + }); + }); + }); + + describe("decision styling", () => { + it("applies appropriate styling for REST decision", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + items: [{ ...mockDailyLog, trainingDecision: "REST" }], + }), + }); + + render(); + + await waitFor(() => { + const restCell = screen.getByText("REST"); + expect(restCell).toHaveClass("text-red-600"); + }); + }); + + it("applies appropriate styling for TRAIN decision", async () => { + render(); + + await waitFor(() => { + const trainCell = screen.getByText("TRAIN"); + expect(trainCell).toHaveClass("text-green-600"); + }); + }); + + it("applies appropriate styling for GENTLE decision", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + items: [{ ...mockDailyLog, trainingDecision: "GENTLE" }], + }), + }); + + render(); + + await waitFor(() => { + const gentleCell = screen.getByText("GENTLE"); + expect(gentleCell).toHaveClass("text-yellow-600"); + }); + }); + }); + + describe("total entries display", () => { + it("shows total entries count", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + ...mockHistoryResponse, + total: 42, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/42 entries/i)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/app/history/page.tsx b/src/app/history/page.tsx index 11c8df3..3ea7cf2 100644 --- a/src/app/history/page.tsx +++ b/src/app/history/page.tsx @@ -1,11 +1,325 @@ // ABOUTME: Historical data view for past training decisions. // ABOUTME: Shows table of daily logs with biometrics and decisions. +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +import type { CyclePhase, DailyLog, HrvStatus } from "@/types"; + +interface HistoryResponse { + items: DailyLog[]; + total: number; + page: number; + limit: number; + totalPages: number; + hasMore: boolean; +} + +/** + * Formats a date string for display. + */ +function formatDate(dateStr: string | Date): string { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +/** + * Formats phase name for display. + */ +function formatPhase(phase: CyclePhase): string { + return phase + .split("_") + .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) + .join(" "); +} + +/** + * Returns CSS classes for decision status. + */ +function getDecisionClass(decision: string): string { + switch (decision) { + case "REST": + return "text-red-600 font-semibold"; + case "TRAIN": + return "text-green-600 font-semibold"; + case "GENTLE": + case "LIGHT": + case "REDUCED": + return "text-yellow-600 font-semibold"; + default: + return "text-gray-600"; + } +} + +/** + * Returns CSS classes for HRV status. + */ +function getHrvClass(status: HrvStatus): string { + switch (status) { + case "Balanced": + return "text-green-600"; + case "Unbalanced": + return "text-red-600"; + default: + return "text-gray-500"; + } +} + export default function HistoryPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + + const fetchHistory = useCallback( + async (pageNum: number, start?: string, end?: string) => { + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + params.set("page", pageNum.toString()); + if (start) params.set("startDate", start); + if (end) params.set("endDate", end); + + const response = await fetch(`/api/history?${params.toString()}`); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || "Failed to fetch history"); + } + + setData(result); + setPage(result.page); + } catch (err) { + const message = + err instanceof Error ? err.message : "An error occurred"; + setError(message); + } finally { + setLoading(false); + } + }, + [], + ); + + useEffect(() => { + fetchHistory(1); + }, [fetchHistory]); + + const handleApplyFilters = () => { + fetchHistory(1, startDate || undefined, endDate || undefined); + }; + + const handleClearFilters = () => { + setStartDate(""); + setEndDate(""); + fetchHistory(1); + }; + + const handlePreviousPage = () => { + if (page > 1) { + fetchHistory(page - 1, startDate || undefined, endDate || undefined); + } + }; + + const handleNextPage = () => { + if (data?.hasMore) { + fetchHistory(page + 1, startDate || undefined, endDate || undefined); + } + }; + + if (loading && !data) { + return ( +
+

History

+

Loading...

+
+ ); + } + return (
-

History

- {/* History table will be implemented here */} -

History table placeholder

+
+

History

+ + Back to Dashboard + +
+ + {error && ( +
+ {error} +
+ )} + + {/* Date Filters */} +
+
+
+ + setStartDate(e.target.value)} + className="rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+
+ + setEndDate(e.target.value)} + className="rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+
+ + +
+
+
+ + {/* Total Entries */} + {data && ( +

{data.total} entries

+ )} + + {/* Empty State */} + {data && data.items.length === 0 && ( +
+

No history found

+

+ Your training history will appear here once data is available. +

+
+ )} + + {/* History Table */} + {data && data.items.length > 0 && ( +
+ + + + + + + + + + + + + {data.items.map((log) => ( + + + + + + + + + ))} + +
+ Date + + Day / Phase + + Decision + + Body Battery + + HRV + + Intensity +
+ {formatDate(log.date)} + + Day {log.cycleDay} + + {formatPhase(log.phase)} + + + + {log.trainingDecision} + + + {log.bodyBatteryCurrent ?? "-"} + + ({log.bodyBatteryYesterdayLow ?? "-"} low) + + + + {log.hrvStatus} + + + {log.weekIntensityMinutes} / {log.phaseLimit} +
+
+ )} + + {/* Pagination */} + {data && data.totalPages > 1 && ( +
+ + + Page {page} of {data.totalPages} + + +
+ )}
); }