Files
phaseflow/src/app/api/today/route.ts
Petru Paler a184909957
All checks were successful
Deploy / deploy (push) Successful in 2m28s
Fix PocketBase query error by sorting by date instead of created
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>
2026-01-15 21:55:41 +00:00

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