Implement GET /api/today endpoint (P1.4)

Add the core daily snapshot API that powers the dashboard. Returns:
- Training decision (status, reason, icon) using decision engine
- Cycle data (cycleDay, phase, phaseConfig, daysUntilNextPhase)
- Biometrics (hrvStatus, bodyBattery, weekIntensity, phaseLimit)
- Nutrition guidance (seeds, carbRange, ketoGuidance)

When no DailyLog exists (Garmin not synced), returns sensible defaults:
hrvStatus="Unknown", bodyBattery=100, weekIntensity=0. This allows
the app to function without Garmin integration.

22 tests covering auth, validation, all decision paths, override
handling, phase-specific logic, and nutrition guidance.

🤖 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 19:03:13 +00:00
parent b6285e3c01
commit 949cb1671a
3 changed files with 634 additions and 9 deletions

View File

@@ -29,7 +29,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| PATCH /api/user | 501 | Returns Not Implemented |
| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog (8 tests) |
| GET /api/cycle/current | **COMPLETE** | Returns cycle day, phase, config, daysUntilNextPhase (10 tests) |
| GET /api/today | 501 | Returns Not Implemented |
| GET /api/today | **COMPLETE** | Returns decision, cycle, biometrics, nutrition (22 tests) |
| POST /api/overrides | 501 | Returns Not Implemented |
| DELETE /api/overrides | 501 | Returns Not Implemented |
| POST /api/garmin/tokens | 501 | Returns Not Implemented |
@@ -73,6 +73,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `src/app/api/user/route.test.ts` | **EXISTS** - 4 tests (GET profile, auth, sensitive field exclusion) |
| `src/app/api/cycle/period/route.test.ts` | **EXISTS** - 8 tests (POST period, auth, validation, date checks) |
| `src/app/api/cycle/current/route.test.ts` | **EXISTS** - 10 tests (GET current cycle, auth, all phases, rollover, custom lengths) |
| `src/app/api/today/route.test.ts` | **EXISTS** - 22 tests (daily snapshot, auth, decision, overrides, phases, nutrition, biometrics) |
| `src/lib/nutrition.test.ts` | **MISSING** |
| `src/lib/email.test.ts` | **MISSING** |
| `src/lib/ics.test.ts` | **MISSING** |
@@ -176,12 +177,17 @@ Minimum viable product - app can be used for daily decisions.
- **Why:** Dashboard needs this for display
- **Depends On:** P0.1, P0.2, P1.2
### P1.4: GET /api/today Implementation
- [ ] Return complete daily snapshot with decision, biometrics, nutrition
### P1.4: GET /api/today Implementation ✅ COMPLETE
- [x] Return complete daily snapshot with decision, biometrics, nutrition
- **Files:**
- `src/app/api/today/route.ts` - Implement GET aggregating all data sources
- `src/app/api/today/route.ts` - Implemented GET with `withAuth()` wrapper, aggregates cycle, biometrics, and nutrition
- **Tests:**
- `src/app/api/today/route.test.ts` - Test decision computation, data assembly
- `src/app/api/today/route.test.ts` - 22 tests covering auth, validation, decision calculation, overrides, phases, nutrition
- **Response Shape:**
- `decision` (status, reason, icon), `cycleDay`, `phase`, `phaseConfig`, `daysUntilNextPhase`, `cycleLength`
- `biometrics` (hrvStatus, bodyBatteryCurrent, bodyBatteryYesterdayLow, weekIntensityMinutes, phaseLimit)
- `nutrition` (seeds, carbRange, ketoGuidance)
- **Fallback Behavior:** When no DailyLog exists (Garmin not synced), returns defaults: hrvStatus="Unknown", BB=100, weekIntensity=0
- **Why:** This is THE core API for the dashboard
- **Depends On:** P0.1, P0.2, P0.3, P1.3
@@ -487,6 +493,7 @@ P2.14 Mini calendar
- [x] **GET /api/user** - Returns authenticated user profile, 4 tests (P0.4)
- [x] **POST /api/cycle/period** - Logs period start date, updates user, creates PeriodLog, 8 tests (P1.2)
- [x] **GET /api/cycle/current** - Returns cycle day, phase, phaseConfig, daysUntilNextPhase, cycleLength, 10 tests (P1.3)
- [x] **GET /api/today** - Returns complete daily snapshot with decision, biometrics, nutrition, 22 tests (P1.4)
---

View File

@@ -0,0 +1,519 @@
// ABOUTME: Unit tests for today's daily snapshot API route.
// ABOUTME: Tests GET /api/today for decision, biometrics, and nutrition data.
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { afterEach, 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;
// Module-level variable to control mock daily log in tests
let currentMockDailyLog: DailyLog | null = null;
// Mock PocketBase client for database operations
vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({
collection: vi.fn(() => ({
getFirstListItem: vi.fn(async () => {
if (!currentMockDailyLog) {
const error = new Error("No DailyLog found");
(error as { status?: number }).status = 404;
throw error;
}
return currentMockDailyLog;
}),
})),
})),
loadAuthFromCookies: vi.fn(),
isAuthenticated: vi.fn(() => currentMockUser !== null),
getCurrentUser: vi.fn(() => currentMockUser),
}));
// 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/today", () => {
const createMockUser = (overrides: Partial<User> = {}): User => ({
id: "user123",
email: "test@example.com",
garminConnected: false,
garminOauth1Token: "",
garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-06-01"),
calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31,
notificationTime: "07:00",
timezone: "America/New_York",
activeOverrides: [],
created: new Date("2024-01-01"),
updated: new Date("2025-01-10"),
...overrides,
});
const createMockDailyLog = (overrides: Partial<DailyLog> = {}): DailyLog => ({
id: "log123",
user: "user123",
date: new Date("2025-01-10"),
cycleDay: 10,
phase: "FOLLICULAR",
bodyBatteryCurrent: 85,
bodyBatteryYesterdayLow: 40,
hrvStatus: "Balanced",
weekIntensityMinutes: 45,
phaseLimit: 120,
remainingMinutes: 75,
trainingDecision: "TRAIN",
decisionReason: "All systems go",
notificationSentAt: null,
created: new Date("2025-01-10"),
...overrides,
});
const mockRequest = {} as NextRequest;
beforeEach(() => {
vi.clearAllMocks();
currentMockUser = null;
currentMockDailyLog = null;
// Mock current date to 2025-01-10 for predictable testing
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-10T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
describe("authentication", () => {
it("returns 401 when not authenticated", async () => {
currentMockUser = null;
const response = await GET(mockRequest);
expect(response.status).toBe(401);
const body = await response.json();
expect(body.error).toBe("Unauthorized");
});
});
describe("validation", () => {
it("returns 400 when user has no lastPeriodDate", async () => {
currentMockUser = createMockUser({
lastPeriodDate: null as unknown as Date,
});
const response = await GET(mockRequest);
expect(response.status).toBe(400);
const body = await response.json();
expect(body.error).toContain("lastPeriodDate");
});
});
describe("response structure", () => {
it("returns complete daily snapshot with all required fields", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Decision fields
expect(body).toHaveProperty("decision");
expect(body.decision).toHaveProperty("status");
expect(body.decision).toHaveProperty("reason");
expect(body.decision).toHaveProperty("icon");
// Cycle fields
expect(body).toHaveProperty("cycleDay");
expect(body).toHaveProperty("phase");
expect(body).toHaveProperty("phaseConfig");
expect(body).toHaveProperty("daysUntilNextPhase");
expect(body).toHaveProperty("cycleLength");
// Biometric fields
expect(body).toHaveProperty("biometrics");
expect(body.biometrics).toHaveProperty("hrvStatus");
expect(body.biometrics).toHaveProperty("bodyBatteryCurrent");
expect(body.biometrics).toHaveProperty("bodyBatteryYesterdayLow");
expect(body.biometrics).toHaveProperty("weekIntensityMinutes");
expect(body.biometrics).toHaveProperty("phaseLimit");
// Nutrition fields
expect(body).toHaveProperty("nutrition");
expect(body.nutrition).toHaveProperty("seeds");
expect(body.nutrition).toHaveProperty("carbRange");
expect(body.nutrition).toHaveProperty("ketoGuidance");
});
});
describe("decision calculation", () => {
it("returns TRAIN when all biometrics are good", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog({
hrvStatus: "Balanced",
bodyBatteryCurrent: 90,
bodyBatteryYesterdayLow: 50,
weekIntensityMinutes: 30,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.decision.status).toBe("TRAIN");
expect(body.decision.icon).toBe("✅");
});
it("returns REST when HRV is unbalanced", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog({
hrvStatus: "Unbalanced",
bodyBatteryCurrent: 90,
bodyBatteryYesterdayLow: 50,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.decision.status).toBe("REST");
expect(body.decision.reason).toContain("HRV");
});
it("returns REST when body battery was depleted yesterday", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog({
hrvStatus: "Balanced",
bodyBatteryCurrent: 90,
bodyBatteryYesterdayLow: 25, // Below 30 threshold
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.decision.status).toBe("REST");
expect(body.decision.reason).toContain("depleted");
});
it("returns LIGHT when current body battery is low", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog({
hrvStatus: "Balanced",
bodyBatteryCurrent: 70, // Below 75 threshold
bodyBatteryYesterdayLow: 50,
weekIntensityMinutes: 30,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.decision.status).toBe("LIGHT");
});
it("returns REDUCED when current body battery is medium", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog({
hrvStatus: "Balanced",
bodyBatteryCurrent: 80, // Between 75 and 85
bodyBatteryYesterdayLow: 50,
weekIntensityMinutes: 30,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.decision.status).toBe("REDUCED");
});
it("returns REST when weekly limit is reached", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog({
hrvStatus: "Balanced",
bodyBatteryCurrent: 90,
bodyBatteryYesterdayLow: 50,
weekIntensityMinutes: 125, // Above 120 limit for FOLLICULAR
phaseLimit: 120,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.decision.status).toBe("REST");
expect(body.decision.reason).toContain("WEEKLY LIMIT");
});
});
describe("override handling", () => {
it("returns REST with flare override active", async () => {
currentMockUser = createMockUser({
activeOverrides: ["flare"],
});
currentMockDailyLog = createMockDailyLog({
hrvStatus: "Balanced",
bodyBatteryCurrent: 90,
bodyBatteryYesterdayLow: 50,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.decision.status).toBe("REST");
expect(body.decision.reason).toContain("flare");
});
it("returns REST with stress override active", async () => {
currentMockUser = createMockUser({
activeOverrides: ["stress"],
});
currentMockDailyLog = createMockDailyLog();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.decision.status).toBe("REST");
expect(body.decision.reason).toContain("stress");
});
it("prioritizes flare over stress when both active", async () => {
currentMockUser = createMockUser({
activeOverrides: ["stress", "flare"],
});
currentMockDailyLog = createMockDailyLog();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.decision.status).toBe("REST");
expect(body.decision.reason).toContain("flare");
});
});
describe("cycle data", () => {
it("returns correct cycle day and phase", async () => {
// lastPeriodDate: 2025-01-01, current: 2025-01-10 = cycle day 10
currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31,
});
currentMockDailyLog = createMockDailyLog();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.cycleDay).toBe(10);
expect(body.phase).toBe("FOLLICULAR");
expect(body.cycleLength).toBe(31);
});
it("returns phase configuration", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.phaseConfig.name).toBe("FOLLICULAR");
expect(body.phaseConfig.weeklyLimit).toBe(120);
});
it("returns days until next phase", async () => {
// Cycle day 10 in FOLLICULAR (days 4-14)
// Next phase (OVULATION) starts day 15, so 5 days away
currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-01"),
});
currentMockDailyLog = createMockDailyLog();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.daysUntilNextPhase).toBe(5);
});
});
describe("phase-specific decisions", () => {
it("returns GENTLE during MENSTRUAL phase", async () => {
// Set lastPeriodDate so cycle day = 2 (MENSTRUAL)
currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-09"), // 1 day ago = day 2
});
currentMockDailyLog = createMockDailyLog({
phase: "MENSTRUAL",
cycleDay: 2,
hrvStatus: "Balanced",
bodyBatteryCurrent: 90,
bodyBatteryYesterdayLow: 50,
weekIntensityMinutes: 0,
phaseLimit: 30,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.phase).toBe("MENSTRUAL");
expect(body.decision.status).toBe("GENTLE");
});
it("returns GENTLE during LATE_LUTEAL phase", async () => {
// Set lastPeriodDate so cycle day = 28 (LATE_LUTEAL)
currentMockUser = createMockUser({
lastPeriodDate: new Date("2024-12-14"), // 27 days ago = day 28
});
currentMockDailyLog = createMockDailyLog({
phase: "LATE_LUTEAL",
cycleDay: 28,
hrvStatus: "Balanced",
bodyBatteryCurrent: 90,
bodyBatteryYesterdayLow: 50,
weekIntensityMinutes: 0,
phaseLimit: 50,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.phase).toBe("LATE_LUTEAL");
expect(body.decision.status).toBe("GENTLE");
});
});
describe("nutrition guidance", () => {
it("returns correct nutrition for cycle day 10 (FOLLICULAR)", async () => {
currentMockUser = createMockUser({
lastPeriodDate: new Date("2025-01-01"), // cycle day 10
});
currentMockDailyLog = createMockDailyLog();
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Day 10 is in days 7-14 range per nutrition.ts
expect(body.nutrition.seeds).toBe("Flax (1-2 tbsp) + Pumpkin (1-2 tbsp)");
expect(body.nutrition.carbRange).toBe("20-100g");
expect(body.nutrition.ketoGuidance).toBe(
"OPTIONAL - optimal keto window",
);
});
it("returns correct nutrition during luteal phase", async () => {
// Set to cycle day 20 (EARLY_LUTEAL)
currentMockUser = createMockUser({
lastPeriodDate: new Date("2024-12-22"), // 19 days ago = day 20
});
currentMockDailyLog = createMockDailyLog({
cycleDay: 20,
phase: "EARLY_LUTEAL",
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Day 20 is in days 17-24 range (sesame+sunflower)
expect(body.nutrition.seeds).toBe(
"Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
);
expect(body.nutrition.carbRange).toBe("75-125g");
});
});
describe("biometrics data", () => {
it("returns biometrics from daily log when available", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = createMockDailyLog({
hrvStatus: "Balanced",
bodyBatteryCurrent: 85,
bodyBatteryYesterdayLow: 40,
weekIntensityMinutes: 45,
phaseLimit: 120,
});
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.biometrics.hrvStatus).toBe("Balanced");
expect(body.biometrics.bodyBatteryCurrent).toBe(85);
expect(body.biometrics.bodyBatteryYesterdayLow).toBe(40);
expect(body.biometrics.weekIntensityMinutes).toBe(45);
expect(body.biometrics.phaseLimit).toBe(120);
});
it("returns default biometrics when no daily log exists", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = null; // No daily log
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// Defaults when no Garmin data
expect(body.biometrics.hrvStatus).toBe("Unknown");
expect(body.biometrics.bodyBatteryCurrent).toBe(100);
expect(body.biometrics.bodyBatteryYesterdayLow).toBe(100);
expect(body.biometrics.weekIntensityMinutes).toBe(0);
});
it("uses TRAIN decision with default biometrics (no Garmin data)", async () => {
currentMockUser = createMockUser();
currentMockDailyLog = null;
const response = await GET(mockRequest);
expect(response.status).toBe(200);
const body = await response.json();
// With defaults (BB=100, HRV=Unknown), should allow training
// unless in restrictive phase
expect(body.decision.status).toBe("TRAIN");
});
});
});

View File

@@ -2,7 +2,106 @@
// ABOUTME: Returns complete daily snapshot with decision, biometrics, and nutrition.
import { NextResponse } from "next/server";
export async function GET() {
// TODO: Implement today's data retrieval
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
}
import { withAuth } from "@/lib/auth-middleware";
import {
getCycleDay,
getPhase,
getPhaseConfig,
getPhaseLimit,
PHASE_CONFIGS,
} from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { getNutritionGuidance } from "@/lib/nutrition";
import { createPocketBaseClient } from "@/lib/pocketbase";
import type { DailyData, DailyLog } from "@/types";
// Default biometrics when no Garmin data is available
const DEFAULT_BIOMETRICS = {
hrvStatus: "Unknown" as const,
bodyBatteryCurrent: 100,
bodyBatteryYesterdayLow: 100,
weekIntensityMinutes: 0,
};
export const GET = withAuth(async (_request, user) => {
// Validate required user data
if (!user.lastPeriodDate) {
return NextResponse.json(
{
error:
"User has no lastPeriodDate set. Please log your period start date first.",
},
{ status: 400 },
);
}
// Calculate cycle information
const cycleDay = getCycleDay(
new Date(user.lastPeriodDate),
user.cycleLength,
new Date(),
);
const phase = getPhase(cycleDay);
const phaseConfig = getPhaseConfig(phase);
const phaseLimit = getPhaseLimit(phase);
// Calculate days until next phase
const currentPhaseIndex = PHASE_CONFIGS.findIndex((c) => c.name === phase);
const nextPhaseIndex = (currentPhaseIndex + 1) % PHASE_CONFIGS.length;
const nextPhaseStartDay = PHASE_CONFIGS[nextPhaseIndex].days[0];
let daysUntilNextPhase: number;
if (nextPhaseIndex === 0) {
// Currently in LATE_LUTEAL, next phase is MENSTRUAL (start of new cycle)
daysUntilNextPhase = user.cycleLength - cycleDay + 1;
} else {
daysUntilNextPhase = nextPhaseStartDay - cycleDay;
}
// Try to fetch today's DailyLog for biometrics
let biometrics = { ...DEFAULT_BIOMETRICS, phaseLimit };
try {
const pb = createPocketBaseClient();
const today = new Date().toISOString().split("T")[0];
const dailyLog = await pb
.collection("dailyLogs")
.getFirstListItem<DailyLog>(`user="${user.id}" && date~"${today}"`);
biometrics = {
hrvStatus: dailyLog.hrvStatus,
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
phaseLimit: dailyLog.phaseLimit,
};
} catch {
// No daily log found - use defaults
}
// Build DailyData for decision engine
const dailyData: DailyData = {
hrvStatus: biometrics.hrvStatus,
bbYesterdayLow: biometrics.bodyBatteryYesterdayLow,
phase,
weekIntensity: biometrics.weekIntensityMinutes,
phaseLimit: biometrics.phaseLimit,
bbCurrent: biometrics.bodyBatteryCurrent,
};
// Get training decision with override handling
const decision = getDecisionWithOverrides(dailyData, user.activeOverrides);
// Get nutrition guidance
const nutrition = getNutritionGuidance(cycleDay);
return NextResponse.json({
decision,
cycleDay,
phase,
phaseConfig,
daysUntilNextPhase,
cycleLength: user.cycleLength,
biometrics,
nutrition,
});
});