Fix Garmin intensity minutes and add user-configurable phase goals
All checks were successful
Deploy / deploy (push) Successful in 2m38s

- Apply 2x multiplier for vigorous intensity minutes (matches Garmin)
- Use calendar week (Mon-Sun) instead of trailing 7 days for intensity
- Add HRV yesterday fallback when today's data returns empty
- Add user-configurable phase intensity goals with new defaults:
  - Menstrual: 75, Follicular: 150, Ovulation: 100
  - Early Luteal: 120, Late Luteal: 50
- Update garmin-sync and today routes to use user-specific phase limits

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 20:18:20 +00:00
parent a1495ff23f
commit 6cd0c06396
24 changed files with 423 additions and 66 deletions

View File

@@ -156,6 +156,12 @@ export const USER_CUSTOM_FIELDS: CollectionField[] = [
{ name: "notificationTime", type: "text" }, { name: "notificationTime", type: "text" },
{ name: "timezone", type: "text" }, { name: "timezone", type: "text" },
{ name: "activeOverrides", type: "json" }, { name: "activeOverrides", type: "json" },
// Phase-specific intensity goals (weekly minutes)
{ name: "intensityGoalMenstrual", type: "number" },
{ name: "intensityGoalFollicular", type: "number" },
{ name: "intensityGoalOvulation", type: "number" },
{ name: "intensityGoalEarlyLuteal", type: "number" },
{ name: "intensityGoalLateLuteal", type: "number" },
]; ];
/** /**

View File

@@ -86,6 +86,11 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };

View File

@@ -48,6 +48,11 @@ describe("POST /api/calendar/regenerate-token", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };

View File

@@ -49,6 +49,12 @@ vi.mock("@/lib/pocketbase", () => ({
notificationTime: record.notificationTime, notificationTime: record.notificationTime,
timezone: record.timezone, timezone: record.timezone,
activeOverrides: record.activeOverrides || [], activeOverrides: record.activeOverrides || [],
intensityGoalMenstrual: (record.intensityGoalMenstrual as number) ?? 75,
intensityGoalFollicular: (record.intensityGoalFollicular as number) ?? 150,
intensityGoalOvulation: (record.intensityGoalOvulation as number) ?? 100,
intensityGoalEarlyLuteal:
(record.intensityGoalEarlyLuteal as number) ?? 120,
intensityGoalLateLuteal: (record.intensityGoalLateLuteal as number) ?? 50,
created: new Date(record.created as string), created: new Date(record.created as string),
updated: new Date(record.updated as string), updated: new Date(record.updated as string),
})), })),
@@ -136,6 +142,11 @@ describe("POST /api/cron/garmin-sync", () => {
notificationTime: "07:00", notificationTime: "07:00",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
...overrides, ...overrides,

View File

@@ -2,7 +2,7 @@
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users. // ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle"; import { getCycleDay, getPhase, getUserPhaseLimit } from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine"; import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { sendTokenExpirationWarning } from "@/lib/email"; import { sendTokenExpirationWarning } from "@/lib/email";
import { decrypt, encrypt } from "@/lib/encryption"; import { decrypt, encrypt } from "@/lib/encryption";
@@ -190,7 +190,7 @@ export async function POST(request: Request) {
new Date(), new Date(),
); );
const phase = getPhase(cycleDay, user.cycleLength); const phase = getPhase(cycleDay, user.cycleLength);
const phaseLimit = getPhaseLimit(phase); const phaseLimit = getUserPhaseLimit(phase, user);
const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes); const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes);
// Calculate training decision // Calculate training decision

View File

@@ -61,6 +61,11 @@ describe("POST /api/cron/notifications", () => {
notificationTime: "07:00", notificationTime: "07:00",
timezone: "UTC", timezone: "UTC",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
...overrides, ...overrides,

View File

@@ -66,6 +66,11 @@ describe("GET /api/cycle/current", () => {
notificationTime: "07:00", notificationTime: "07:00",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
...overrides, ...overrides,
@@ -137,11 +142,11 @@ describe("GET /api/cycle/current", () => {
expect(body.phaseConfig).toBeDefined(); expect(body.phaseConfig).toBeDefined();
expect(body.phaseConfig.name).toBe("FOLLICULAR"); expect(body.phaseConfig.name).toBe("FOLLICULAR");
expect(body.phaseConfig.weeklyLimit).toBe(120); expect(body.phaseConfig.weeklyLimit).toBe(150);
expect(body.phaseConfig.trainingType).toBe("Strength + rebounding"); expect(body.phaseConfig.trainingType).toBe("Strength + rebounding");
// Phase configs days are for reference; actual boundaries are calculated dynamically // Phase configs days are for reference; actual boundaries are calculated dynamically
expect(body.phaseConfig.days).toEqual([4, 15]); expect(body.phaseConfig.days).toEqual([4, 15]);
expect(body.phaseConfig.dailyAvg).toBe(17); expect(body.phaseConfig.dailyAvg).toBe(21);
}); });
it("calculates daysUntilNextPhase correctly", async () => { it("calculates daysUntilNextPhase correctly", async () => {
@@ -174,7 +179,7 @@ describe("GET /api/cycle/current", () => {
expect(body.cycleDay).toBe(3); expect(body.cycleDay).toBe(3);
expect(body.phase).toBe("MENSTRUAL"); expect(body.phase).toBe("MENSTRUAL");
expect(body.phaseConfig.weeklyLimit).toBe(30); expect(body.phaseConfig.weeklyLimit).toBe(75);
expect(body.daysUntilNextPhase).toBe(1); // Day 4 is FOLLICULAR expect(body.daysUntilNextPhase).toBe(1); // Day 4 is FOLLICULAR
}); });
@@ -194,7 +199,7 @@ describe("GET /api/cycle/current", () => {
expect(body.cycleDay).toBe(16); expect(body.cycleDay).toBe(16);
expect(body.phase).toBe("OVULATION"); expect(body.phase).toBe("OVULATION");
expect(body.phaseConfig.weeklyLimit).toBe(80); expect(body.phaseConfig.weeklyLimit).toBe(100);
expect(body.daysUntilNextPhase).toBe(2); // Day 18 is EARLY_LUTEAL expect(body.daysUntilNextPhase).toBe(2); // Day 18 is EARLY_LUTEAL
}); });

View File

@@ -50,6 +50,11 @@ describe("POST /api/cycle/period", () => {
notificationTime: "07:00", notificationTime: "07:00",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };

View File

@@ -78,6 +78,11 @@ describe("GET /api/garmin/status", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -108,6 +113,11 @@ describe("GET /api/garmin/status", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -138,6 +148,11 @@ describe("GET /api/garmin/status", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -166,6 +181,11 @@ describe("GET /api/garmin/status", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -196,6 +216,11 @@ describe("GET /api/garmin/status", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -228,6 +253,11 @@ describe("GET /api/garmin/status", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -258,6 +288,11 @@ describe("GET /api/garmin/status", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -288,6 +323,11 @@ describe("GET /api/garmin/status", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -318,6 +358,11 @@ describe("GET /api/garmin/status", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -345,6 +390,11 @@ describe("GET /api/garmin/status", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };

View File

@@ -60,6 +60,11 @@ describe("POST /api/garmin/tokens", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -276,6 +281,11 @@ describe("DELETE /api/garmin/tokens", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };

View File

@@ -48,6 +48,11 @@ describe("GET /api/history", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };

View File

@@ -62,6 +62,11 @@ describe("POST /api/overrides", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: overrides, activeOverrides: overrides,
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}); });
@@ -195,6 +200,11 @@ describe("DELETE /api/overrides", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: overrides, activeOverrides: overrides,
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}); });

View File

@@ -48,6 +48,11 @@ describe("GET /api/period-history", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };

View File

@@ -57,6 +57,11 @@ describe("PATCH /api/period-logs/[id]", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -284,6 +289,11 @@ describe("DELETE /api/period-logs/[id]", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };

View File

@@ -77,6 +77,11 @@ describe("GET /api/today", () => {
notificationTime: "07:00", notificationTime: "07:00",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
...overrides, ...overrides,
@@ -369,7 +374,7 @@ describe("GET /api/today", () => {
const body = await response.json(); const body = await response.json();
expect(body.phaseConfig.name).toBe("FOLLICULAR"); expect(body.phaseConfig.name).toBe("FOLLICULAR");
expect(body.phaseConfig.weeklyLimit).toBe(120); expect(body.phaseConfig.weeklyLimit).toBe(150);
}); });
it("returns days until next phase", async () => { it("returns days until next phase", async () => {

View File

@@ -7,11 +7,12 @@ import {
getCycleDay, getCycleDay,
getPhase, getPhase,
getPhaseConfig, getPhaseConfig,
getPhaseLimit, getUserPhaseLimit,
} from "@/lib/cycle"; } from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine"; import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition"; import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition";
import { mapRecordToUser } from "@/lib/pocketbase";
import type { DailyData, DailyLog, HrvStatus } from "@/types"; import type { DailyData, DailyLog, HrvStatus } from "@/types";
// Default biometrics when no Garmin data is available // Default biometrics when no Garmin data is available
@@ -31,7 +32,8 @@ export const GET = withAuth(async (_request, user, pb) => {
// Fetch fresh user data from database to get latest values // Fetch fresh user data from database to get latest values
// The user param from withAuth is from auth store cache which may be stale // 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) // (e.g., after logging a period, the cookie still has old data)
const freshUser = await pb.collection("users").getOne(user.id); const freshUserRecord = await pb.collection("users").getOne(user.id);
const freshUser = mapRecordToUser(freshUserRecord);
// Validate required user data // Validate required user data
if (!freshUser.lastPeriodDate) { if (!freshUser.lastPeriodDate) {
@@ -43,15 +45,15 @@ export const GET = withAuth(async (_request, user, pb) => {
{ status: 400 }, { status: 400 },
); );
} }
const lastPeriodDate = new Date(freshUser.lastPeriodDate as string); const lastPeriodDate = freshUser.lastPeriodDate;
const cycleLength = freshUser.cycleLength as number; const cycleLength = freshUser.cycleLength;
const activeOverrides = (freshUser.activeOverrides as string[]) || []; const activeOverrides = freshUser.activeOverrides || [];
// Calculate cycle information // Calculate cycle information
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date()); const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
const phase = getPhase(cycleDay, cycleLength); const phase = getPhase(cycleDay, cycleLength);
const phaseConfig = getPhaseConfig(phase); const phaseConfig = getPhaseConfig(phase);
const phaseLimit = getPhaseLimit(phase); const phaseLimit = getUserPhaseLimit(phase, freshUser);
// Calculate days until next phase using dynamic boundaries // Calculate days until next phase using dynamic boundaries
// Phase boundaries: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14), // Phase boundaries: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14),

View File

@@ -67,6 +67,11 @@ describe("GET /api/user", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: ["flare"], activeOverrides: ["flare"],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };
@@ -157,6 +162,11 @@ describe("PATCH /api/user", () => {
notificationTime: "07:30", notificationTime: "07:30",
timezone: "America/New_York", timezone: "America/New_York",
activeOverrides: ["flare"], activeOverrides: ["flare"],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date("2024-01-01"), created: new Date("2024-01-01"),
updated: new Date("2025-01-10"), updated: new Date("2025-01-10"),
}; };

View File

@@ -65,6 +65,11 @@ describe("withAuth", () => {
notificationTime: "07:00", notificationTime: "07:00",
timezone: "UTC", timezone: "UTC",
activeOverrides: [], activeOverrides: [],
intensityGoalMenstrual: 75,
intensityGoalFollicular: 150,
intensityGoalOvulation: 100,
intensityGoalEarlyLuteal: 120,
intensityGoalLateLuteal: 50,
created: new Date(), created: new Date(),
updated: new Date(), updated: new Date(),
}; };

View File

@@ -157,10 +157,11 @@ describe("getPhase", () => {
describe("getPhaseLimit", () => { describe("getPhaseLimit", () => {
it("returns correct weekly limits for each phase", () => { it("returns correct weekly limits for each phase", () => {
expect(getPhaseLimit("MENSTRUAL")).toBe(30); // Default intensity goals (can be overridden per user)
expect(getPhaseLimit("FOLLICULAR")).toBe(120); expect(getPhaseLimit("MENSTRUAL")).toBe(75);
expect(getPhaseLimit("OVULATION")).toBe(80); expect(getPhaseLimit("FOLLICULAR")).toBe(150);
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(100); expect(getPhaseLimit("OVULATION")).toBe(100);
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(120);
expect(getPhaseLimit("LATE_LUTEAL")).toBe(50); expect(getPhaseLimit("LATE_LUTEAL")).toBe(50);
}); });
}); });

View File

@@ -5,33 +5,34 @@ import type { CyclePhase, PhaseConfig } from "@/types";
// Base phase configurations with weekly limits and training guidance. // Base phase configurations with weekly limits and training guidance.
// Note: The 'days' field is for the default 31-day cycle; actual boundaries // Note: The 'days' field is for the default 31-day cycle; actual boundaries
// are calculated dynamically by getPhaseBoundaries() based on cycleLength. // are calculated dynamically by getPhaseBoundaries() based on cycleLength.
// Weekly limits are defaults that can be overridden per user.
export const PHASE_CONFIGS: PhaseConfig[] = [ export const PHASE_CONFIGS: PhaseConfig[] = [
{ {
name: "MENSTRUAL", name: "MENSTRUAL",
days: [1, 3], days: [1, 3],
weeklyLimit: 30, weeklyLimit: 75,
dailyAvg: 10, dailyAvg: 11,
trainingType: "Gentle rebounding only", trainingType: "Gentle rebounding only",
}, },
{ {
name: "FOLLICULAR", name: "FOLLICULAR",
days: [4, 15], days: [4, 15],
weeklyLimit: 120, weeklyLimit: 150,
dailyAvg: 17, dailyAvg: 21,
trainingType: "Strength + rebounding", trainingType: "Strength + rebounding",
}, },
{ {
name: "OVULATION", name: "OVULATION",
days: [16, 17], days: [16, 17],
weeklyLimit: 80, weeklyLimit: 100,
dailyAvg: 40, dailyAvg: 50,
trainingType: "Peak performance", trainingType: "Peak performance",
}, },
{ {
name: "EARLY_LUTEAL", name: "EARLY_LUTEAL",
days: [18, 24], days: [18, 24],
weeklyLimit: 100, weeklyLimit: 120,
dailyAvg: 14, dailyAvg: 17,
trainingType: "Moderate training", trainingType: "Moderate training",
}, },
{ {
@@ -96,3 +97,38 @@ export function getPhaseConfig(phase: CyclePhase): PhaseConfig {
export function getPhaseLimit(phase: CyclePhase): number { export function getPhaseLimit(phase: CyclePhase): number {
return getPhaseConfig(phase).weeklyLimit; return getPhaseConfig(phase).weeklyLimit;
} }
/**
* User-specific intensity goals for phase limits.
*/
export interface UserIntensityGoals {
intensityGoalMenstrual: number;
intensityGoalFollicular: number;
intensityGoalOvulation: number;
intensityGoalEarlyLuteal: number;
intensityGoalLateLuteal: number;
}
/**
* Gets the phase limit using user-specific goals if available.
* Falls back to default phase limits if user goals are not set.
*/
export function getUserPhaseLimit(
phase: CyclePhase,
userGoals: UserIntensityGoals,
): number {
switch (phase) {
case "MENSTRUAL":
return userGoals.intensityGoalMenstrual;
case "FOLLICULAR":
return userGoals.intensityGoalFollicular;
case "OVULATION":
return userGoals.intensityGoalOvulation;
case "EARLY_LUTEAL":
return userGoals.intensityGoalEarlyLuteal;
case "LATE_LUTEAL":
return userGoals.intensityGoalLateLuteal;
default:
return getPhaseLimit(phase);
}
}

View File

@@ -319,6 +319,55 @@ describe("fetchHrvStatus", () => {
expect(result).toBe("Unknown"); expect(result).toBe("Unknown");
}); });
it("falls back to yesterday's HRV when today returns empty response", async () => {
// First call (today) returns empty, second call (yesterday) returns BALANCED
global.fetch = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(""),
json: () => Promise.resolve({}),
})
.mockResolvedValueOnce(
mockJsonResponse({
hrvSummary: { lastNightAvg: 45, weeklyAvg: 42, status: "BALANCED" },
}),
);
const result = await fetchHrvStatus("2024-01-15", "test-token");
expect(result).toBe("Balanced");
// Verify both today and yesterday were called
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch).toHaveBeenNthCalledWith(
1,
"https://connectapi.garmin.com/hrv-service/hrv/2024-01-15",
expect.anything(),
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"https://connectapi.garmin.com/hrv-service/hrv/2024-01-14",
expect.anything(),
);
});
it("returns Unknown when both today and yesterday HRV are unavailable", async () => {
// Both calls return empty responses
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: () => Promise.resolve(""),
json: () => Promise.resolve({}),
});
const result = await fetchHrvStatus("2024-01-15", "test-token");
expect(result).toBe("Unknown");
// Verify both today and yesterday were tried
expect(global.fetch).toHaveBeenCalledTimes(2);
});
}); });
describe("fetchBodyBattery", () => { describe("fetchBodyBattery", () => {
@@ -455,7 +504,9 @@ describe("fetchIntensityMinutes", () => {
global.fetch = originalFetch; global.fetch = originalFetch;
}); });
it("returns 7-day intensity minutes total on success", async () => { it("counts vigorous minutes as 2x (Garmin algorithm)", async () => {
// Garmin counts vigorous minutes at 2x multiplier for weekly goals
// 45 moderate + (30 vigorous × 2) = 45 + 60 = 105
global.fetch = vi.fn().mockResolvedValue( global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse([ mockJsonResponse([
{ {
@@ -469,9 +520,54 @@ describe("fetchIntensityMinutes", () => {
const result = await fetchIntensityMinutes("2024-01-15", "test-token"); const result = await fetchIntensityMinutes("2024-01-15", "test-token");
expect(result).toBe(75); expect(result).toBe(105); // 45 + (30 × 2) = 105
});
it("uses calendar week starting from Monday", async () => {
// 2024-01-17 is a Wednesday, so calendar week starts Monday 2024-01-15
global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse([
{
calendarDate: "2024-01-17",
weeklyGoal: 150,
moderateValue: 60,
vigorousValue: 20,
},
]),
);
await fetchIntensityMinutes("2024-01-17", "test-token");
// Should call with Monday of the current week as start date
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
"https://connectapi.garmin.com/usersummary-service/stats/im/weekly/2024-01-08/2024-01-15", "https://connectapi.garmin.com/usersummary-service/stats/im/weekly/2024-01-15/2024-01-17",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer test-token",
}),
}),
);
});
it("returns intensity minutes total on success", async () => {
global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse([
{
calendarDate: "2024-01-15",
weeklyGoal: 150,
moderateValue: 45,
vigorousValue: 30,
},
]),
);
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
// 45 moderate + (30 vigorous × 2) = 105
expect(result).toBe(105);
// 2024-01-15 is Monday, so start date is same day (Monday of that week)
expect(global.fetch).toHaveBeenCalledWith(
"https://connectapi.garmin.com/usersummary-service/stats/im/weekly/2024-01-15/2024-01-15",
expect.objectContaining({ expect.objectContaining({
headers: expect.objectContaining({ headers: expect.objectContaining({
Authorization: "Bearer test-token", Authorization: "Bearer test-token",
@@ -509,10 +605,11 @@ describe("fetchIntensityMinutes", () => {
const result = await fetchIntensityMinutes("2024-01-15", "test-token"); const result = await fetchIntensityMinutes("2024-01-15", "test-token");
// 60 moderate + (0 × 2) = 60
expect(result).toBe(60); expect(result).toBe(60);
}); });
it("handles only vigorous intensity minutes", async () => { it("handles only vigorous intensity minutes with 2x multiplier", async () => {
global.fetch = vi.fn().mockResolvedValue( global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse([ mockJsonResponse([
{ {
@@ -525,7 +622,8 @@ describe("fetchIntensityMinutes", () => {
const result = await fetchIntensityMinutes("2024-01-15", "test-token"); const result = await fetchIntensityMinutes("2024-01-15", "test-token");
expect(result).toBe(45); // 0 moderate + (45 × 2) = 90
expect(result).toBe(90);
}); });
it("returns 0 when API request fails", async () => { it("returns 0 when API request fails", async () => {

View File

@@ -54,46 +54,77 @@ export function daysUntilExpiry(tokens: GarminTokens): number {
return Math.floor(diffMs / (1000 * 60 * 60 * 24)); return Math.floor(diffMs / (1000 * 60 * 60 * 24));
} }
// Helper to fetch HRV for a specific date
async function fetchHrvForDate(
date: string,
oauth2Token: string,
): Promise<HrvStatus | null> {
const response = await fetch(`${GARMIN_API_URL}/hrv-service/hrv/${date}`, {
headers: getGarminHeaders(oauth2Token),
});
if (!response.ok) {
logger.warn(
{ status: response.status, endpoint: "hrv-service", date },
"Garmin HRV API error",
);
return null;
}
const text = await response.text();
if (!text.startsWith("{") && !text.startsWith("[")) {
logger.warn(
{ endpoint: "hrv-service", date, isEmpty: text === "" },
"Garmin HRV returned non-JSON response",
);
return null;
}
const data = JSON.parse(text);
const status = data?.hrvSummary?.status;
if (status === "BALANCED") {
logger.info({ status: "BALANCED", date }, "Garmin HRV data received");
return "Balanced";
}
if (status === "UNBALANCED") {
logger.info({ status: "UNBALANCED", date }, "Garmin HRV data received");
return "Unbalanced";
}
logger.info(
{ rawStatus: status, hasData: !!data?.hrvSummary, date },
"Garmin HRV returned unknown status",
);
return null;
}
export async function fetchHrvStatus( export async function fetchHrvStatus(
date: string, date: string,
oauth2Token: string, oauth2Token: string,
): Promise<HrvStatus> { ): Promise<HrvStatus> {
try { try {
const response = await fetch(`${GARMIN_API_URL}/hrv-service/hrv/${date}`, { // Try fetching today's HRV
headers: getGarminHeaders(oauth2Token), const todayResult = await fetchHrvForDate(date, oauth2Token);
}); if (todayResult) {
return todayResult;
if (!response.ok) {
logger.warn(
{ status: response.status, endpoint: "hrv-service" },
"Garmin HRV API error",
);
return "Unknown";
} }
const text = await response.text(); // Fallback: try yesterday's HRV (common at 6 AM before sleep data processed)
if (!text.startsWith("{") && !text.startsWith("[")) { const dateObj = new Date(date);
logger.error( dateObj.setDate(dateObj.getDate() - 1);
{ endpoint: "hrv-service", responseBody: text.slice(0, 1000) }, const yesterday = dateObj.toISOString().split("T")[0];
"Garmin returned non-JSON response",
);
return "Unknown";
}
const data = JSON.parse(text);
const status = data?.hrvSummary?.status;
if (status === "BALANCED") {
logger.info({ status: "BALANCED" }, "Garmin HRV data received");
return "Balanced";
}
if (status === "UNBALANCED") {
logger.info({ status: "UNBALANCED" }, "Garmin HRV data received");
return "Unbalanced";
}
logger.info( logger.info(
{ rawStatus: status, hasData: !!data?.hrvSummary }, { today: date, yesterday },
"Garmin HRV returned unknown status", "HRV unavailable today, trying yesterday",
); );
const yesterdayResult = await fetchHrvForDate(yesterday, oauth2Token);
if (yesterdayResult) {
logger.info({ date: yesterday }, "Using yesterday's HRV data");
return yesterdayResult;
}
return "Unknown"; return "Unknown";
} catch (error) { } catch (error) {
logger.error( logger.error(
@@ -185,11 +216,15 @@ export async function fetchIntensityMinutes(
oauth2Token: string, oauth2Token: string,
): Promise<number> { ): Promise<number> {
try { try {
// Calculate 7 days before the date for weekly range // Calculate Monday of the current calendar week for Garmin's weekly tracking
const endDate = date; const endDate = date;
const startDateObj = new Date(date); const dateObj = new Date(date);
startDateObj.setDate(startDateObj.getDate() - 7); const dayOfWeek = dateObj.getDay(); // 0=Sunday, 1=Monday, ..., 6=Saturday
const startDate = startDateObj.toISOString().split("T")[0]; // Calculate days to subtract to get to Monday (if Sunday, go back 6 days)
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const mondayObj = new Date(dateObj);
mondayObj.setDate(dateObj.getDate() - daysToMonday);
const startDate = mondayObj.toISOString().split("T")[0];
const response = await fetch( const response = await fetch(
`${GARMIN_API_URL}/usersummary-service/stats/im/weekly/${startDate}/${endDate}`, `${GARMIN_API_URL}/usersummary-service/stats/im/weekly/${startDate}/${endDate}`,
@@ -231,10 +266,11 @@ export async function fetchIntensityMinutes(
const moderate = entry.moderateValue ?? 0; const moderate = entry.moderateValue ?? 0;
const vigorous = entry.vigorousValue ?? 0; const vigorous = entry.vigorousValue ?? 0;
const total = moderate + vigorous; // Garmin counts vigorous minutes at 2x multiplier for weekly intensity goal
const total = moderate + vigorous * 2;
logger.info( logger.info(
{ moderate, vigorous, total }, { moderate, vigorous, total, vigorousMultiplied: vigorous * 2 },
"Garmin intensity minutes data received", "Garmin intensity minutes data received",
); );

View File

@@ -88,6 +88,15 @@ function parseDate(value: unknown): Date | null {
/** /**
* Maps a PocketBase record to our typed User interface. * Maps a PocketBase record to our typed User interface.
*/ */
// Default intensity goals for each phase (weekly minutes)
export const DEFAULT_INTENSITY_GOALS = {
menstrual: 75,
follicular: 150,
ovulation: 100,
earlyLuteal: 120,
lateLuteal: 50,
};
export function mapRecordToUser(record: RecordModel): User { export function mapRecordToUser(record: RecordModel): User {
return { return {
id: record.id, id: record.id,
@@ -100,6 +109,22 @@ export function mapRecordToUser(record: RecordModel): User {
calendarToken: record.calendarToken as string, calendarToken: record.calendarToken as string,
lastPeriodDate: parseDate(record.lastPeriodDate), lastPeriodDate: parseDate(record.lastPeriodDate),
cycleLength: record.cycleLength as number, cycleLength: record.cycleLength as number,
// Intensity goals with defaults for existing users
intensityGoalMenstrual:
(record.intensityGoalMenstrual as number) ??
DEFAULT_INTENSITY_GOALS.menstrual,
intensityGoalFollicular:
(record.intensityGoalFollicular as number) ??
DEFAULT_INTENSITY_GOALS.follicular,
intensityGoalOvulation:
(record.intensityGoalOvulation as number) ??
DEFAULT_INTENSITY_GOALS.ovulation,
intensityGoalEarlyLuteal:
(record.intensityGoalEarlyLuteal as number) ??
DEFAULT_INTENSITY_GOALS.earlyLuteal,
intensityGoalLateLuteal:
(record.intensityGoalLateLuteal as number) ??
DEFAULT_INTENSITY_GOALS.lateLuteal,
notificationTime: record.notificationTime as string, notificationTime: record.notificationTime as string,
timezone: record.timezone as string, timezone: record.timezone as string,
activeOverrides: (record.activeOverrides as OverrideType[]) || [], activeOverrides: (record.activeOverrides as OverrideType[]) || [],

View File

@@ -32,6 +32,13 @@ export interface User {
lastPeriodDate: Date | null; lastPeriodDate: Date | null;
cycleLength: number; // default: 31 cycleLength: number; // default: 31
// Phase-specific intensity goals (weekly minutes)
intensityGoalMenstrual: number; // default: 75
intensityGoalFollicular: number; // default: 150
intensityGoalOvulation: number; // default: 100
intensityGoalEarlyLuteal: number; // default: 120
intensityGoalLateLuteal: number; // default: 50
// Preferences // Preferences
notificationTime: string; // "07:00" notificationTime: string; // "07:00"
timezone: string; timezone: string;