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/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
|
||||
|
||||
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: 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<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 (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">History</h1>
|
||||
<p className="text-gray-500">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">History</h1>
|
||||
{/* History table will be implemented here */}
|
||||
<p className="text-gray-500">History table placeholder</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user