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:
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchHrvStatus(
|
// Helper to fetch HRV for a specific date
|
||||||
|
async function fetchHrvForDate(
|
||||||
date: string,
|
date: string,
|
||||||
oauth2Token: string,
|
oauth2Token: string,
|
||||||
): Promise<HrvStatus> {
|
): Promise<HrvStatus | null> {
|
||||||
try {
|
|
||||||
const response = await fetch(`${GARMIN_API_URL}/hrv-service/hrv/${date}`, {
|
const response = await fetch(`${GARMIN_API_URL}/hrv-service/hrv/${date}`, {
|
||||||
headers: getGarminHeaders(oauth2Token),
|
headers: getGarminHeaders(oauth2Token),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ status: response.status, endpoint: "hrv-service" },
|
{ status: response.status, endpoint: "hrv-service", date },
|
||||||
"Garmin HRV API error",
|
"Garmin HRV API error",
|
||||||
);
|
);
|
||||||
return "Unknown";
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!text.startsWith("{") && !text.startsWith("[")) {
|
if (!text.startsWith("{") && !text.startsWith("[")) {
|
||||||
logger.error(
|
logger.warn(
|
||||||
{ endpoint: "hrv-service", responseBody: text.slice(0, 1000) },
|
{ endpoint: "hrv-service", date, isEmpty: text === "" },
|
||||||
"Garmin returned non-JSON response",
|
"Garmin HRV returned non-JSON response",
|
||||||
);
|
);
|
||||||
return "Unknown";
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
const status = data?.hrvSummary?.status;
|
const status = data?.hrvSummary?.status;
|
||||||
|
|
||||||
if (status === "BALANCED") {
|
if (status === "BALANCED") {
|
||||||
logger.info({ status: "BALANCED" }, "Garmin HRV data received");
|
logger.info({ status: "BALANCED", date }, "Garmin HRV data received");
|
||||||
return "Balanced";
|
return "Balanced";
|
||||||
}
|
}
|
||||||
if (status === "UNBALANCED") {
|
if (status === "UNBALANCED") {
|
||||||
logger.info({ status: "UNBALANCED" }, "Garmin HRV data received");
|
logger.info({ status: "UNBALANCED", date }, "Garmin HRV data received");
|
||||||
return "Unbalanced";
|
return "Unbalanced";
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ rawStatus: status, hasData: !!data?.hrvSummary },
|
{ rawStatus: status, hasData: !!data?.hrvSummary, date },
|
||||||
"Garmin HRV returned unknown status",
|
"Garmin HRV returned unknown status",
|
||||||
);
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHrvStatus(
|
||||||
|
date: string,
|
||||||
|
oauth2Token: string,
|
||||||
|
): Promise<HrvStatus> {
|
||||||
|
try {
|
||||||
|
// Try fetching today's HRV
|
||||||
|
const todayResult = await fetchHrvForDate(date, oauth2Token);
|
||||||
|
if (todayResult) {
|
||||||
|
return todayResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ 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";
|
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",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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[]) || [],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user