Implement history API endpoint (P2.8)

Add GET /api/history for paginated historical daily logs with:
- Pagination support (page/limit query params, default 20 per page)
- Date filtering (startDate/endDate in YYYY-MM-DD format)
- Validation for all parameters with descriptive error messages
- Sort by date descending (most recent first)
- Response includes items, total, page, limit, totalPages, hasMore

Includes 19 tests covering pagination, date filtering, auth, and validation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-10 20:06:50 +00:00
parent 532d49f570
commit e73d131450
3 changed files with 545 additions and 8 deletions

View File

@@ -22,7 +22,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
- ~~`src/lib/auth-middleware.ts`~~ - **CREATED** in P0.2
- ~~`src/middleware.ts`~~ - **CREATED** in P0.2
### API Routes (12 total)
### API Routes (15 total)
| Route | Status | Notes |
|-------|--------|-------|
| GET /api/user | **COMPLETE** | Returns user profile with `withAuth()` |
@@ -39,6 +39,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| POST /api/calendar/regenerate-token | **COMPLETE** | Generates 32-char token, returns URL (9 tests) |
| POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs (22 tests) |
| POST /api/cron/notifications | **COMPLETE** | Sends daily emails with timezone matching, DailyLog handling (20 tests) |
| GET /api/history | **COMPLETE** | Paginated historical daily logs with date filtering (19 tests) |
### Pages (7 total)
| Page | Status | Notes |
@@ -88,6 +89,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `src/app/api/cron/notifications/route.test.ts` | **EXISTS** - 20 tests (timezone matching, DailyLog handling, email sending) |
| `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) |
| E2E tests | **NONE** |
### Critical Business Rules (from Spec)
@@ -357,12 +359,18 @@ Full feature set for production use.
- **Why:** Security feature for calendar URLs
- **Depends On:** P0.1, P0.2
### P2.8: GET /api/history Implementation
- [ ] Return paginated historical daily logs
### P2.8: GET /api/history Implementation ✅ COMPLETE
- [x] Return paginated historical daily logs
- **Files:**
- `src/app/api/history/route.ts` - Query DailyLog with pagination
- `src/app/api/history/route.ts` - Query DailyLog with pagination, date filtering, validation
- **Tests:**
- `src/app/api/history/route.test.ts` - Test pagination, date filtering
- `src/app/api/history/route.test.ts` - 19 tests covering pagination, date filtering, auth, validation
- **Features Implemented:**
- Pagination with page/limit parameters (default: page=1, limit=20)
- Date filtering with startDate/endDate query params (YYYY-MM-DD format)
- Validation for all parameters with descriptive error messages
- Sort by date descending (most recent first)
- Returns items, total, page, limit, totalPages, hasMore
- **Why:** Users want to see their training history
- **Depends On:** P0.1, P0.2
@@ -601,6 +609,7 @@ P2.14 Mini calendar
- [x] **POST /api/cron/notifications** - Sends daily email notifications with timezone matching, DailyLog handling, nutrition guidance, 20 tests (P2.5)
- [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events, token validation, caching headers, 10 tests (P2.6)
- [x] **POST /api/calendar/regenerate-token** - Generates new 32-char calendar token, returns URL, 9 tests (P2.7)
- [x] **GET /api/history** - Paginated historical daily logs with date filtering, validation, 19 tests (P2.8)
### Pages
- [x] **Login Page** - Email/password form with PocketBase auth, error handling, loading states, redirect, 14 tests (P1.6)

View File

@@ -0,0 +1,440 @@
// ABOUTME: Unit tests for historical daily logs API route.
// ABOUTME: Tests GET /api/history for pagination, date filtering, and auth.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DailyLog, User } from "@/types";
// Module-level variable to control mock user in tests
let currentMockUser: User | null = null;
// Track PocketBase collection calls
const mockGetList = vi.fn();
// Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn(() => ({
getList: mockGetList,
})),
})),
}));
// Mock the auth-middleware module
vi.mock("@/lib/auth-middleware", () => ({
withAuth: vi.fn((handler) => {
return async (request: NextRequest) => {
if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return handler(request, currentMockUser);
};
}),
}));
import { GET } from "./route";
describe("GET /api/history", () => {
const mockUser: User = {
id: "user123",
email: "test@example.com",
garminConnected: true,
garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"),
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28,
notificationTime: "07:30",
timezone: "America/New_York",
activeOverrides: [],
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
};
const mockDailyLog: DailyLog = {
id: "log1",
user: "user123",
date: new 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: new Date("2025-01-10T07:30:00Z"),
created: new Date("2025-01-10T06:00:00Z"),
};
// Helper to create mock request with query parameters
function createMockRequest(params: Record<string, string> = {}): NextRequest {
const url = new URL("http://localhost:3000/api/history");
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return {
url: url.toString(),
nextUrl: url,
} as unknown as NextRequest;
}
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
mockGetList.mockReset();
});
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
it("returns paginated daily logs for authenticated user", async () => {
currentMockUser = mockUser;
const logs = [
mockDailyLog,
{
...mockDailyLog,
id: "log2",
date: new Date("2025-01-09"),
cycleDay: 4,
},
];
mockGetList.mockResolvedValue({
items: logs,
totalItems: 2,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.items).toHaveLength(2);
expect(body.total).toBe(2);
expect(body.page).toBe(1);
expect(body.limit).toBe(20);
expect(body.hasMore).toBe(false);
});
it("uses default pagination values (page=1, limit=20)", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-date",
}),
);
});
it("respects page parameter", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 50,
totalPages: 3,
page: 2,
});
const mockRequest = createMockRequest({ page: "2" });
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
2,
20,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-date",
}),
);
});
it("respects limit parameter", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 50,
totalPages: 5,
page: 1,
});
const mockRequest = createMockRequest({ limit: "10" });
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
10,
expect.objectContaining({
filter: expect.stringContaining('user="user123"'),
sort: "-date",
}),
);
});
it("returns empty array when no logs exist", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.items).toHaveLength(0);
expect(body.total).toBe(0);
expect(body.hasMore).toBe(false);
});
it("filters by startDate", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [mockDailyLog],
totalItems: 1,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest({ startDate: "2025-01-05" });
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringMatching(/user="user123".*date>="2025-01-05"/),
sort: "-date",
}),
);
});
it("filters by endDate", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [mockDailyLog],
totalItems: 1,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest({ endDate: "2025-01-15" });
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringMatching(/user="user123".*date<="2025-01-15"/),
sort: "-date",
}),
);
});
it("filters by both startDate and endDate", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [mockDailyLog],
totalItems: 1,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest({
startDate: "2025-01-01",
endDate: "2025-01-15",
});
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringMatching(
/user="user123".*date>="2025-01-01".*date<="2025-01-15"/,
),
sort: "-date",
}),
);
});
it("returns 400 for invalid page value", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ page: "0" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("page");
});
it("returns 400 for non-numeric page value", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ page: "abc" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("page");
});
it("returns 400 for invalid limit value (too low)", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ limit: "0" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("limit");
});
it("returns 400 for invalid limit value (too high)", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ limit: "101" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("limit");
});
it("returns hasMore=true when more pages exist", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: Array(20).fill(mockDailyLog),
totalItems: 50,
totalPages: 3,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.hasMore).toBe(true);
});
it("returns hasMore=false on last page", async () => {
currentMockUser = mockUser;
mockGetList.mockResolvedValue({
items: [mockDailyLog],
totalItems: 41,
totalPages: 3,
page: 3,
});
const mockRequest = createMockRequest({ page: "3" });
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.hasMore).toBe(false);
});
it("returns 400 for invalid startDate format", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ startDate: "not-a-date" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("startDate");
});
it("returns 400 for invalid endDate format", async () => {
currentMockUser = mockUser;
const mockRequest = createMockRequest({ endDate: "not-a-date" });
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("endDate");
});
it("sorts by date descending (most recent first)", async () => {
currentMockUser = mockUser;
const logs = [
{ ...mockDailyLog, id: "log1", date: new Date("2025-01-10") },
{ ...mockDailyLog, id: "log2", date: new Date("2025-01-09") },
{ ...mockDailyLog, id: "log3", date: new Date("2025-01-08") },
];
mockGetList.mockResolvedValue({
items: logs,
totalItems: 3,
totalPages: 1,
page: 1,
});
const mockRequest = createMockRequest();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
sort: "-date",
}),
);
});
it("only returns logs for the authenticated user", async () => {
currentMockUser = { ...mockUser, id: "different-user" };
mockGetList.mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
});
const mockRequest = createMockRequest();
await GET(mockRequest);
expect(mockGetList).toHaveBeenCalledWith(
1,
20,
expect.objectContaining({
filter: expect.stringContaining('user="different-user"'),
}),
);
});
});

View File

@@ -2,7 +2,95 @@
// ABOUTME: Returns paginated list of past training decisions and data.
import { NextResponse } from "next/server";
export async function GET() {
// TODO: Implement history retrieval
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
import { withAuth } from "@/lib/auth-middleware";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { DailyLog } from "@/types";
// Validation constants
const MIN_PAGE = 1;
const MIN_LIMIT = 1;
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 20;
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
/**
* Validates that a date string is in YYYY-MM-DD format and represents a valid date.
*/
function isValidDateFormat(dateStr: string): boolean {
if (!DATE_REGEX.test(dateStr)) {
return false;
}
const date = new Date(dateStr);
return !Number.isNaN(date.getTime());
}
export const GET = withAuth(async (request, user) => {
const { searchParams } = request.nextUrl;
// Parse and validate page parameter
const pageParam = searchParams.get("page");
const page = pageParam ? Number.parseInt(pageParam, 10) : 1;
if (Number.isNaN(page) || page < MIN_PAGE) {
return NextResponse.json(
{
error: `Invalid page: must be a positive integer (minimum ${MIN_PAGE})`,
},
{ status: 400 },
);
}
// Parse and validate limit parameter
const limitParam = searchParams.get("limit");
const limit = limitParam ? Number.parseInt(limitParam, 10) : DEFAULT_LIMIT;
if (Number.isNaN(limit) || limit < MIN_LIMIT || limit > MAX_LIMIT) {
return NextResponse.json(
{ error: `Invalid limit: must be between ${MIN_LIMIT} and ${MAX_LIMIT}` },
{ status: 400 },
);
}
// Parse and validate date filters
const startDate = searchParams.get("startDate");
if (startDate && !isValidDateFormat(startDate)) {
return NextResponse.json(
{ error: "Invalid startDate: must be in YYYY-MM-DD format" },
{ status: 400 },
);
}
const endDate = searchParams.get("endDate");
if (endDate && !isValidDateFormat(endDate)) {
return NextResponse.json(
{ error: "Invalid endDate: must be in YYYY-MM-DD format" },
{ status: 400 },
);
}
// Build filter expression
const filters: string[] = [`user="${user.id}"`];
if (startDate) {
filters.push(`date>="${startDate}"`);
}
if (endDate) {
filters.push(`date<="${endDate}"`);
}
const filter = filters.join(" && ");
// Query PocketBase
const pb = createPocketBaseClient();
const result = await pb
.collection("dailyLogs")
.getList<DailyLog>(page, limit, {
filter,
sort: "-date",
});
return NextResponse.json({
items: result.items,
total: result.totalItems,
page: result.page,
limit,
totalPages: result.totalPages,
hasMore: result.page < result.totalPages,
});
});