Fix Garmin intensity minutes and add user-configurable phase goals
All checks were successful
Deploy / deploy (push) Successful in 2m38s
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:
@@ -86,6 +86,11 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -48,6 +48,11 @@ describe("POST /api/calendar/regenerate-token", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -49,6 +49,12 @@ vi.mock("@/lib/pocketbase", () => ({
|
||||
notificationTime: record.notificationTime,
|
||||
timezone: record.timezone,
|
||||
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),
|
||||
updated: new Date(record.updated as string),
|
||||
})),
|
||||
@@ -136,6 +142,11 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
|
||||
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 { sendTokenExpirationWarning } from "@/lib/email";
|
||||
import { decrypt, encrypt } from "@/lib/encryption";
|
||||
@@ -190,7 +190,7 @@ export async function POST(request: Request) {
|
||||
new Date(),
|
||||
);
|
||||
const phase = getPhase(cycleDay, user.cycleLength);
|
||||
const phaseLimit = getPhaseLimit(phase);
|
||||
const phaseLimit = getUserPhaseLimit(phase, user);
|
||||
const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes);
|
||||
|
||||
// Calculate training decision
|
||||
|
||||
@@ -61,6 +61,11 @@ describe("POST /api/cron/notifications", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
|
||||
@@ -66,6 +66,11 @@ describe("GET /api/cycle/current", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
@@ -137,11 +142,11 @@ describe("GET /api/cycle/current", () => {
|
||||
|
||||
expect(body.phaseConfig).toBeDefined();
|
||||
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");
|
||||
// Phase configs days are for reference; actual boundaries are calculated dynamically
|
||||
expect(body.phaseConfig.days).toEqual([4, 15]);
|
||||
expect(body.phaseConfig.dailyAvg).toBe(17);
|
||||
expect(body.phaseConfig.dailyAvg).toBe(21);
|
||||
});
|
||||
|
||||
it("calculates daysUntilNextPhase correctly", async () => {
|
||||
@@ -174,7 +179,7 @@ describe("GET /api/cycle/current", () => {
|
||||
|
||||
expect(body.cycleDay).toBe(3);
|
||||
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
|
||||
});
|
||||
|
||||
@@ -194,7 +199,7 @@ describe("GET /api/cycle/current", () => {
|
||||
|
||||
expect(body.cycleDay).toBe(16);
|
||||
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
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ describe("POST /api/cycle/period", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -78,6 +78,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -108,6 +113,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -138,6 +148,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -166,6 +181,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -196,6 +216,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -228,6 +253,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -258,6 +288,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -288,6 +323,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -318,6 +358,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -345,6 +390,11 @@ describe("GET /api/garmin/status", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -60,6 +60,11 @@ describe("POST /api/garmin/tokens", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -276,6 +281,11 @@ describe("DELETE /api/garmin/tokens", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -48,6 +48,11 @@ describe("GET /api/history", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -62,6 +62,11 @@ describe("POST /api/overrides", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: overrides,
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
});
|
||||
@@ -195,6 +200,11 @@ describe("DELETE /api/overrides", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: overrides,
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
});
|
||||
|
||||
@@ -48,6 +48,11 @@ describe("GET /api/period-history", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -57,6 +57,11 @@ describe("PATCH /api/period-logs/[id]", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -284,6 +289,11 @@ describe("DELETE /api/period-logs/[id]", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -77,6 +77,11 @@ describe("GET /api/today", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
@@ -369,7 +374,7 @@ describe("GET /api/today", () => {
|
||||
const body = await response.json();
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
getCycleDay,
|
||||
getPhase,
|
||||
getPhaseConfig,
|
||||
getPhaseLimit,
|
||||
getUserPhaseLimit,
|
||||
} from "@/lib/cycle";
|
||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition";
|
||||
import { mapRecordToUser } from "@/lib/pocketbase";
|
||||
import type { DailyData, DailyLog, HrvStatus } from "@/types";
|
||||
|
||||
// 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
|
||||
// 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);
|
||||
const freshUserRecord = await pb.collection("users").getOne(user.id);
|
||||
const freshUser = mapRecordToUser(freshUserRecord);
|
||||
|
||||
// Validate required user data
|
||||
if (!freshUser.lastPeriodDate) {
|
||||
@@ -43,15 +45,15 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const lastPeriodDate = new Date(freshUser.lastPeriodDate as string);
|
||||
const cycleLength = freshUser.cycleLength as number;
|
||||
const activeOverrides = (freshUser.activeOverrides as string[]) || [];
|
||||
const lastPeriodDate = freshUser.lastPeriodDate;
|
||||
const cycleLength = freshUser.cycleLength;
|
||||
const activeOverrides = freshUser.activeOverrides || [];
|
||||
|
||||
// Calculate cycle information
|
||||
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
|
||||
const phase = getPhase(cycleDay, cycleLength);
|
||||
const phaseConfig = getPhaseConfig(phase);
|
||||
const phaseLimit = getPhaseLimit(phase);
|
||||
const phaseLimit = getUserPhaseLimit(phase, freshUser);
|
||||
|
||||
// Calculate days until next phase using dynamic boundaries
|
||||
// Phase boundaries: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14),
|
||||
|
||||
@@ -67,6 +67,11 @@ describe("GET /api/user", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: ["flare"],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -157,6 +162,11 @@ describe("PATCH /api/user", () => {
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: ["flare"],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -65,6 +65,11 @@ describe("withAuth", () => {
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date(),
|
||||
updated: new Date(),
|
||||
};
|
||||
|
||||
@@ -157,10 +157,11 @@ describe("getPhase", () => {
|
||||
|
||||
describe("getPhaseLimit", () => {
|
||||
it("returns correct weekly limits for each phase", () => {
|
||||
expect(getPhaseLimit("MENSTRUAL")).toBe(30);
|
||||
expect(getPhaseLimit("FOLLICULAR")).toBe(120);
|
||||
expect(getPhaseLimit("OVULATION")).toBe(80);
|
||||
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(100);
|
||||
// Default intensity goals (can be overridden per user)
|
||||
expect(getPhaseLimit("MENSTRUAL")).toBe(75);
|
||||
expect(getPhaseLimit("FOLLICULAR")).toBe(150);
|
||||
expect(getPhaseLimit("OVULATION")).toBe(100);
|
||||
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(120);
|
||||
expect(getPhaseLimit("LATE_LUTEAL")).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,33 +5,34 @@ import type { CyclePhase, PhaseConfig } from "@/types";
|
||||
// Base phase configurations with weekly limits and training guidance.
|
||||
// Note: The 'days' field is for the default 31-day cycle; actual boundaries
|
||||
// are calculated dynamically by getPhaseBoundaries() based on cycleLength.
|
||||
// Weekly limits are defaults that can be overridden per user.
|
||||
export const PHASE_CONFIGS: PhaseConfig[] = [
|
||||
{
|
||||
name: "MENSTRUAL",
|
||||
days: [1, 3],
|
||||
weeklyLimit: 30,
|
||||
dailyAvg: 10,
|
||||
weeklyLimit: 75,
|
||||
dailyAvg: 11,
|
||||
trainingType: "Gentle rebounding only",
|
||||
},
|
||||
{
|
||||
name: "FOLLICULAR",
|
||||
days: [4, 15],
|
||||
weeklyLimit: 120,
|
||||
dailyAvg: 17,
|
||||
weeklyLimit: 150,
|
||||
dailyAvg: 21,
|
||||
trainingType: "Strength + rebounding",
|
||||
},
|
||||
{
|
||||
name: "OVULATION",
|
||||
days: [16, 17],
|
||||
weeklyLimit: 80,
|
||||
dailyAvg: 40,
|
||||
weeklyLimit: 100,
|
||||
dailyAvg: 50,
|
||||
trainingType: "Peak performance",
|
||||
},
|
||||
{
|
||||
name: "EARLY_LUTEAL",
|
||||
days: [18, 24],
|
||||
weeklyLimit: 100,
|
||||
dailyAvg: 14,
|
||||
weeklyLimit: 120,
|
||||
dailyAvg: 17,
|
||||
trainingType: "Moderate training",
|
||||
},
|
||||
{
|
||||
@@ -96,3 +97,38 @@ export function getPhaseConfig(phase: CyclePhase): PhaseConfig {
|
||||
export function getPhaseLimit(phase: CyclePhase): number {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +319,55 @@ describe("fetchHrvStatus", () => {
|
||||
|
||||
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", () => {
|
||||
@@ -455,7 +504,9 @@ describe("fetchIntensityMinutes", () => {
|
||||
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(
|
||||
mockJsonResponse([
|
||||
{
|
||||
@@ -469,9 +520,54 @@ describe("fetchIntensityMinutes", () => {
|
||||
|
||||
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(
|
||||
"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({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-token",
|
||||
@@ -509,10 +605,11 @@ describe("fetchIntensityMinutes", () => {
|
||||
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
// 60 moderate + (0 × 2) = 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(
|
||||
mockJsonResponse([
|
||||
{
|
||||
@@ -525,7 +622,8 @@ describe("fetchIntensityMinutes", () => {
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -54,46 +54,77 @@ export function daysUntilExpiry(tokens: GarminTokens): number {
|
||||
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(
|
||||
date: string,
|
||||
oauth2Token: string,
|
||||
): Promise<HrvStatus> {
|
||||
try {
|
||||
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" },
|
||||
"Garmin HRV API error",
|
||||
);
|
||||
return "Unknown";
|
||||
// Try fetching today's HRV
|
||||
const todayResult = await fetchHrvForDate(date, oauth2Token);
|
||||
if (todayResult) {
|
||||
return todayResult;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.startsWith("{") && !text.startsWith("[")) {
|
||||
logger.error(
|
||||
{ endpoint: "hrv-service", responseBody: text.slice(0, 1000) },
|
||||
"Garmin returned non-JSON response",
|
||||
);
|
||||
return "Unknown";
|
||||
}
|
||||
const data = JSON.parse(text);
|
||||
const status = data?.hrvSummary?.status;
|
||||
// Fallback: try yesterday's HRV (common at 6 AM before sleep data processed)
|
||||
const dateObj = new Date(date);
|
||||
dateObj.setDate(dateObj.getDate() - 1);
|
||||
const yesterday = dateObj.toISOString().split("T")[0];
|
||||
|
||||
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(
|
||||
{ rawStatus: status, hasData: !!data?.hrvSummary },
|
||||
"Garmin HRV returned unknown status",
|
||||
{ today: date, yesterday },
|
||||
"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";
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -185,11 +216,15 @@ export async function fetchIntensityMinutes(
|
||||
oauth2Token: string,
|
||||
): Promise<number> {
|
||||
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 startDateObj = new Date(date);
|
||||
startDateObj.setDate(startDateObj.getDate() - 7);
|
||||
const startDate = startDateObj.toISOString().split("T")[0];
|
||||
const dateObj = new Date(date);
|
||||
const dayOfWeek = dateObj.getDay(); // 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
// 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(
|
||||
`${GARMIN_API_URL}/usersummary-service/stats/im/weekly/${startDate}/${endDate}`,
|
||||
@@ -231,10 +266,11 @@ export async function fetchIntensityMinutes(
|
||||
|
||||
const moderate = entry.moderateValue ?? 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(
|
||||
{ moderate, vigorous, total },
|
||||
{ moderate, vigorous, total, vigorousMultiplied: vigorous * 2 },
|
||||
"Garmin intensity minutes data received",
|
||||
);
|
||||
|
||||
|
||||
@@ -88,6 +88,15 @@ function parseDate(value: unknown): Date | null {
|
||||
/**
|
||||
* 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 {
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -100,6 +109,22 @@ export function mapRecordToUser(record: RecordModel): User {
|
||||
calendarToken: record.calendarToken as string,
|
||||
lastPeriodDate: parseDate(record.lastPeriodDate),
|
||||
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,
|
||||
timezone: record.timezone as string,
|
||||
activeOverrides: (record.activeOverrides as OverrideType[]) || [],
|
||||
|
||||
@@ -32,6 +32,13 @@ export interface User {
|
||||
lastPeriodDate: Date | null;
|
||||
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
|
||||
notificationTime: string; // "07:00"
|
||||
timezone: string;
|
||||
|
||||
Reference in New Issue
Block a user