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:
2026-01-10 20:22:08 +00:00
parent 75f0e8ec80
commit be80c60253
3 changed files with 947 additions and 8 deletions

View File

@@ -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

View 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();
});
});
});
});

View File

@@ -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>
{/* History table will be implemented here */}
<p className="text-gray-500">History table placeholder</p>
<p className="text-gray-500">Loading...</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>
);
}