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

@@ -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,
});
});