diff --git a/scripts/setup-db.ts b/scripts/setup-db.ts index c933579..cc14dd6 100644 --- a/scripts/setup-db.ts +++ b/scripts/setup-db.ts @@ -156,6 +156,12 @@ export const USER_CUSTOM_FIELDS: CollectionField[] = [ { name: "notificationTime", type: "text" }, { name: "timezone", type: "text" }, { 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" }, ]; /** diff --git a/src/app/api/calendar/[userId]/[token].ics/route.test.ts b/src/app/api/calendar/[userId]/[token].ics/route.test.ts index c79e3ea..e2020e6 100644 --- a/src/app/api/calendar/[userId]/[token].ics/route.test.ts +++ b/src/app/api/calendar/[userId]/[token].ics/route.test.ts @@ -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"), }; diff --git a/src/app/api/calendar/regenerate-token/route.test.ts b/src/app/api/calendar/regenerate-token/route.test.ts index 4fc682c..90afbec 100644 --- a/src/app/api/calendar/regenerate-token/route.test.ts +++ b/src/app/api/calendar/regenerate-token/route.test.ts @@ -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"), }; diff --git a/src/app/api/cron/garmin-sync/route.test.ts b/src/app/api/cron/garmin-sync/route.test.ts index 39b73dd..80d4823 100644 --- a/src/app/api/cron/garmin-sync/route.test.ts +++ b/src/app/api/cron/garmin-sync/route.test.ts @@ -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, diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts index e2e7933..bdadb7e 100644 --- a/src/app/api/cron/garmin-sync/route.ts +++ b/src/app/api/cron/garmin-sync/route.ts @@ -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 diff --git a/src/app/api/cron/notifications/route.test.ts b/src/app/api/cron/notifications/route.test.ts index c73c89f..3e7c7a3 100644 --- a/src/app/api/cron/notifications/route.test.ts +++ b/src/app/api/cron/notifications/route.test.ts @@ -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, diff --git a/src/app/api/cycle/current/route.test.ts b/src/app/api/cycle/current/route.test.ts index 12acbfc..34f2519 100644 --- a/src/app/api/cycle/current/route.test.ts +++ b/src/app/api/cycle/current/route.test.ts @@ -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 }); diff --git a/src/app/api/cycle/period/route.test.ts b/src/app/api/cycle/period/route.test.ts index f46cc80..7aa51c4 100644 --- a/src/app/api/cycle/period/route.test.ts +++ b/src/app/api/cycle/period/route.test.ts @@ -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"), }; diff --git a/src/app/api/garmin/status/route.test.ts b/src/app/api/garmin/status/route.test.ts index 06936c5..8f33334 100644 --- a/src/app/api/garmin/status/route.test.ts +++ b/src/app/api/garmin/status/route.test.ts @@ -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"), }; diff --git a/src/app/api/garmin/tokens/route.test.ts b/src/app/api/garmin/tokens/route.test.ts index 326ea77..5dd3b16 100644 --- a/src/app/api/garmin/tokens/route.test.ts +++ b/src/app/api/garmin/tokens/route.test.ts @@ -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"), }; diff --git a/src/app/api/history/route.test.ts b/src/app/api/history/route.test.ts index 7779ead..0f411ab 100644 --- a/src/app/api/history/route.test.ts +++ b/src/app/api/history/route.test.ts @@ -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"), }; diff --git a/src/app/api/overrides/route.test.ts b/src/app/api/overrides/route.test.ts index a62982c..115cd76 100644 --- a/src/app/api/overrides/route.test.ts +++ b/src/app/api/overrides/route.test.ts @@ -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"), }); diff --git a/src/app/api/period-history/route.test.ts b/src/app/api/period-history/route.test.ts index e83574d..deb17b8 100644 --- a/src/app/api/period-history/route.test.ts +++ b/src/app/api/period-history/route.test.ts @@ -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"), }; diff --git a/src/app/api/period-logs/[id]/route.test.ts b/src/app/api/period-logs/[id]/route.test.ts index 0020f28..3e1c5fb 100644 --- a/src/app/api/period-logs/[id]/route.test.ts +++ b/src/app/api/period-logs/[id]/route.test.ts @@ -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"), }; diff --git a/src/app/api/today/route.test.ts b/src/app/api/today/route.test.ts index 4d72250..a4e5e42 100644 --- a/src/app/api/today/route.test.ts +++ b/src/app/api/today/route.test.ts @@ -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 () => { diff --git a/src/app/api/today/route.ts b/src/app/api/today/route.ts index 8d8630d..454c496 100644 --- a/src/app/api/today/route.ts +++ b/src/app/api/today/route.ts @@ -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), diff --git a/src/app/api/user/route.test.ts b/src/app/api/user/route.test.ts index c33daaf..7163846 100644 --- a/src/app/api/user/route.test.ts +++ b/src/app/api/user/route.test.ts @@ -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"), }; diff --git a/src/lib/auth-middleware.test.ts b/src/lib/auth-middleware.test.ts index 1c3ef80..d2f0925 100644 --- a/src/lib/auth-middleware.test.ts +++ b/src/lib/auth-middleware.test.ts @@ -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(), }; diff --git a/src/lib/cycle.test.ts b/src/lib/cycle.test.ts index 2aaa542..25b1925 100644 --- a/src/lib/cycle.test.ts +++ b/src/lib/cycle.test.ts @@ -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); }); }); diff --git a/src/lib/cycle.ts b/src/lib/cycle.ts index 71bdc8d..4f04749 100644 --- a/src/lib/cycle.ts +++ b/src/lib/cycle.ts @@ -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); + } +} diff --git a/src/lib/garmin.test.ts b/src/lib/garmin.test.ts index 88485fd..a2df0e6 100644 --- a/src/lib/garmin.test.ts +++ b/src/lib/garmin.test.ts @@ -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 () => { diff --git a/src/lib/garmin.ts b/src/lib/garmin.ts index a7b0a1c..676959e 100644 --- a/src/lib/garmin.ts +++ b/src/lib/garmin.ts @@ -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 { + 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 { 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 { 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", ); diff --git a/src/lib/pocketbase.ts b/src/lib/pocketbase.ts index 056ee93..5ffa717 100644 --- a/src/lib/pocketbase.ts +++ b/src/lib/pocketbase.ts @@ -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[]) || [], diff --git a/src/types/index.ts b/src/types/index.ts index ada6690..fe9140f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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;