All checks were successful
Deploy / deploy (push) Successful in 2m28s
The sort=-created parameter was causing PocketBase to return a 400 error when querying dailyLogs. This is likely a compatibility issue with how PocketBase handles the auto-generated 'created' field in certain query combinations. Changing to sort by -date resolves the issue and makes more semantic sense for dailyLogs which have one record per day. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
156 lines
5.0 KiB
TypeScript
156 lines
5.0 KiB
TypeScript
// ABOUTME: API route for today's training decision and data.
|
|
// ABOUTME: Returns complete daily snapshot with decision, biometrics, and nutrition.
|
|
import { NextResponse } from "next/server";
|
|
|
|
import { withAuth } from "@/lib/auth-middleware";
|
|
import {
|
|
getCycleDay,
|
|
getPhase,
|
|
getPhaseConfig,
|
|
getPhaseLimit,
|
|
} from "@/lib/cycle";
|
|
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
|
import { logger } from "@/lib/logger";
|
|
import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition";
|
|
import type { DailyData, DailyLog, HrvStatus } from "@/types";
|
|
|
|
// Default biometrics when no Garmin data is available
|
|
const DEFAULT_BIOMETRICS: {
|
|
hrvStatus: HrvStatus;
|
|
bodyBatteryCurrent: number;
|
|
bodyBatteryYesterdayLow: number;
|
|
weekIntensityMinutes: number;
|
|
} = {
|
|
hrvStatus: "Unknown",
|
|
bodyBatteryCurrent: 100,
|
|
bodyBatteryYesterdayLow: 100,
|
|
weekIntensityMinutes: 0,
|
|
};
|
|
|
|
export const GET = withAuth(async (_request, user, pb) => {
|
|
// Fetch fresh user data from database to get latest values
|
|
// The user param from withAuth is from auth store cache which may be stale
|
|
// (e.g., after logging a period, the cookie still has old data)
|
|
const freshUser = await pb.collection("users").getOne(user.id);
|
|
|
|
// Validate required user data
|
|
if (!freshUser.lastPeriodDate) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
"User has no lastPeriodDate set. Please log your period start date first.",
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
const lastPeriodDate = new Date(freshUser.lastPeriodDate as string);
|
|
const cycleLength = freshUser.cycleLength as number;
|
|
const activeOverrides = (freshUser.activeOverrides as string[]) || [];
|
|
|
|
// Calculate cycle information
|
|
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
|
|
const phase = getPhase(cycleDay, cycleLength);
|
|
const phaseConfig = getPhaseConfig(phase);
|
|
const phaseLimit = getPhaseLimit(phase);
|
|
|
|
// Calculate days until next phase using dynamic boundaries
|
|
// Phase boundaries: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14),
|
|
// EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl
|
|
let daysUntilNextPhase: number;
|
|
if (phase === "LATE_LUTEAL") {
|
|
daysUntilNextPhase = cycleLength - cycleDay + 1;
|
|
} else if (phase === "MENSTRUAL") {
|
|
daysUntilNextPhase = 4 - cycleDay;
|
|
} else if (phase === "FOLLICULAR") {
|
|
daysUntilNextPhase = cycleLength - 15 - cycleDay;
|
|
} else if (phase === "OVULATION") {
|
|
daysUntilNextPhase = cycleLength - 13 - cycleDay;
|
|
} else {
|
|
// EARLY_LUTEAL
|
|
daysUntilNextPhase = cycleLength - 6 - cycleDay;
|
|
}
|
|
|
|
// Try to fetch today's DailyLog for biometrics
|
|
// Sort by date DESC to get the most recent record if multiple exist
|
|
let biometrics = { ...DEFAULT_BIOMETRICS, phaseLimit };
|
|
try {
|
|
// Use YYYY-MM-DD format with >= and < operators for PocketBase date field
|
|
// PocketBase accepts simple date strings in comparison operators
|
|
const today = new Date().toISOString().split("T")[0];
|
|
const tomorrow = new Date(Date.now() + 86400000)
|
|
.toISOString()
|
|
.split("T")[0];
|
|
|
|
logger.info({ userId: user.id, today, tomorrow }, "Fetching dailyLog");
|
|
const dailyLog = await pb
|
|
.collection("dailyLogs")
|
|
.getFirstListItem<DailyLog>(
|
|
`user="${user.id}" && date>="${today}" && date<"${tomorrow}"`,
|
|
{ sort: "-date" },
|
|
);
|
|
|
|
logger.info(
|
|
{
|
|
userId: user.id,
|
|
dailyLogId: dailyLog.id,
|
|
hrvStatus: dailyLog.hrvStatus,
|
|
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
|
|
},
|
|
"Found dailyLog",
|
|
);
|
|
|
|
biometrics = {
|
|
hrvStatus: dailyLog.hrvStatus,
|
|
bodyBatteryCurrent:
|
|
dailyLog.bodyBatteryCurrent ?? DEFAULT_BIOMETRICS.bodyBatteryCurrent,
|
|
bodyBatteryYesterdayLow:
|
|
dailyLog.bodyBatteryYesterdayLow ??
|
|
DEFAULT_BIOMETRICS.bodyBatteryYesterdayLow,
|
|
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
|
|
phaseLimit: dailyLog.phaseLimit,
|
|
};
|
|
} catch (err) {
|
|
logger.warn({ userId: user.id, err }, "No dailyLog found, using 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,
|
|
activeOverrides as import("@/types").OverrideType[],
|
|
);
|
|
|
|
// Log decision calculation per observability spec
|
|
logger.info(
|
|
{ userId: user.id, decision: decision.status, reason: decision.reason },
|
|
"Decision calculated",
|
|
);
|
|
|
|
// Get nutrition guidance with seed switch alert
|
|
const baseNutrition = getNutritionGuidance(cycleDay);
|
|
const nutrition = {
|
|
...baseNutrition,
|
|
seedSwitchAlert: getSeedSwitchAlert(cycleDay),
|
|
};
|
|
|
|
return NextResponse.json({
|
|
decision,
|
|
cycleDay,
|
|
phase,
|
|
phaseConfig,
|
|
daysUntilNextPhase,
|
|
cycleLength,
|
|
biometrics,
|
|
nutrition,
|
|
});
|
|
});
|