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:
@@ -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 |
|
| PATCH /api/user | 501 | Returns Not Implemented |
|
||||||
| POST /api/cycle/period | **COMPLETE** | Logs period start, updates user, creates PeriodLog (8 tests) |
|
| 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/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 |
|
| POST /api/overrides | 501 | Returns Not Implemented |
|
||||||
| DELETE /api/overrides | 501 | Returns Not Implemented |
|
| DELETE /api/overrides | 501 | Returns Not Implemented |
|
||||||
| POST /api/garmin/tokens | 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/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/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/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/nutrition.test.ts` | **MISSING** |
|
||||||
| `src/lib/email.test.ts` | **MISSING** |
|
| `src/lib/email.test.ts` | **MISSING** |
|
||||||
| `src/lib/ics.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
|
- **Why:** Dashboard needs this for display
|
||||||
- **Depends On:** P0.1, P0.2, P1.2
|
- **Depends On:** P0.1, P0.2, P1.2
|
||||||
|
|
||||||
### P1.4: GET /api/today Implementation
|
### P1.4: GET /api/today Implementation ✅ COMPLETE
|
||||||
- [ ] Return complete daily snapshot with decision, biometrics, nutrition
|
- [x] Return complete daily snapshot with decision, biometrics, nutrition
|
||||||
- **Files:**
|
- **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:**
|
- **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
|
- **Why:** This is THE core API for the dashboard
|
||||||
- **Depends On:** P0.1, P0.2, P0.3, P1.3
|
- **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] **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] **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/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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
519
src/app/api/today/route.test.ts
Normal file
519
src/app/api/today/route.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,106 @@
|
|||||||
// ABOUTME: Returns complete daily snapshot with decision, biometrics, and nutrition.
|
// ABOUTME: Returns complete daily snapshot with decision, biometrics, and nutrition.
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
export async function GET() {
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
// TODO: Implement today's data retrieval
|
import {
|
||||||
return NextResponse.json({ message: "Not implemented" }, { status: 501 });
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user