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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (`/settings`) | **COMPLETE** | Form with cycleLength, notificationTime, timezone |
|
||||||
| Settings/Garmin (`/settings/garmin`) | Placeholder | Needs token management UI |
|
| Settings/Garmin (`/settings/garmin`) | Placeholder | Needs token management UI |
|
||||||
| Calendar (`/calendar`) | Placeholder | Needs MonthView integration |
|
| 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 |
|
| Plan (`/plan`) | Placeholder | Needs phase details display |
|
||||||
|
|
||||||
### Components
|
### 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/[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/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/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** |
|
| E2E tests | **NONE** |
|
||||||
|
|
||||||
### Critical Business Rules (from Spec)
|
### Critical Business Rules (from Spec)
|
||||||
@@ -403,12 +404,12 @@ Full feature set for production use.
|
|||||||
- **Depends On:** P2.6
|
- **Depends On:** P2.6
|
||||||
- **Note:** DayCell is **COMPLETE**, MonthView needs grid implementation (~70% remaining)
|
- **Note:** DayCell is **COMPLETE**, MonthView needs grid implementation (~70% remaining)
|
||||||
|
|
||||||
### P2.12: History Page Implementation
|
### P2.12: History Page Implementation ✅ COMPLETE
|
||||||
- [ ] View past training decisions and data
|
- [x] View past training decisions and data
|
||||||
- **Files:**
|
- **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:**
|
- **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
|
- **Why:** Users want to review their training history
|
||||||
- **Depends On:** P2.8
|
- **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] **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] **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] **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
|
### Test Infrastructure
|
||||||
- [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup
|
- [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup
|
||||||
|
|||||||
623
src/app/history/page.test.tsx
Normal file
623
src/app/history/page.test.tsx
Normal file
@@ -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(<HistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: /history/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a back link to dashboard", async () => {
|
||||||
|
render(<HistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: /back to dashboard/i }),
|
||||||
|
).toHaveAttribute("href", "/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the history table with headers", async () => {
|
||||||
|
render(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const restCell = screen.getByText("REST");
|
||||||
|
expect(restCell).toHaveClass("text-red-600");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies appropriate styling for TRAIN decision", async () => {
|
||||||
|
render(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
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(<HistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/42 entries/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,325 @@
|
|||||||
// ABOUTME: Historical data view for past training decisions.
|
// ABOUTME: Historical data view for past training decisions.
|
||||||
// ABOUTME: Shows table of daily logs with biometrics and 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() {
|
export default function HistoryPage() {
|
||||||
|
const [data, setData] = useState<HistoryResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
return (
|
||||||
<div className="container mx-auto p-8">
|
<div className="container mx-auto p-8">
|
||||||
<h1 className="text-2xl font-bold mb-8">History</h1>
|
<h1 className="text-2xl font-bold mb-8">History</h1>
|
||||||
{/* History table will be implemented here */}
|
<p className="text-gray-500">Loading...</p>
|
||||||
<p className="text-gray-500">History table placeholder</p>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1 className="text-2xl font-bold">History</h1>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-blue-600 hover:text-blue-700 hover:underline"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date Filters */}
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="startDate"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="startDate"
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="endDate"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="endDate"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyFilters}
|
||||||
|
className="rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className="rounded-md bg-gray-200 px-4 py-2 text-gray-700 font-medium hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Entries */}
|
||||||
|
{data && (
|
||||||
|
<p className="text-sm text-gray-600 mb-4">{data.total} entries</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{data && data.items.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<p>No history found</p>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
Your training history will appear here once data is available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History Table */}
|
||||||
|
{data && data.items.length > 0 && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Day / Phase
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Decision
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Body Battery
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
HRV
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Intensity
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{data.items.map((log) => (
|
||||||
|
<tr key={log.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{formatDate(log.date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
||||||
|
<span className="text-gray-900">Day {log.cycleDay}</span>
|
||||||
|
<span className="text-gray-500 ml-2">
|
||||||
|
{formatPhase(log.phase)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
||||||
|
<span className={getDecisionClass(log.trainingDecision)}>
|
||||||
|
{log.trainingDecision}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{log.bodyBatteryCurrent ?? "-"}
|
||||||
|
<span className="text-gray-500 ml-1">
|
||||||
|
({log.bodyBatteryYesterdayLow ?? "-"} low)
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
||||||
|
<span className={getHrvClass(log.hrvStatus)}>
|
||||||
|
{log.hrvStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{log.weekIntensityMinutes} / {log.phaseLimit}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePreviousPage}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
Page {page} of {data.totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={!data.hasMore}
|
||||||
|
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user