From cf89675b92c63211bf16c13718bd9da7228638f1 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 15 Jan 2026 13:58:04 +0000 Subject: [PATCH] Fix body battery and intensity minutes Garmin API endpoints Body Battery: - Change endpoint from /usersummary-service/stats/bodyBattery/dates/ to /wellness-service/wellness/bodyBattery/reports/daily - Parse new response format: array with bodyBatteryValuesArray time series - Current value = last entry's level (index 2) - YesterdayLow = min level from yesterday's data Intensity Minutes: - Change endpoint from /fitnessstats-service/activity to /usersummary-service/stats/im/weekly - Add date parameter to function signature - Parse new response format: array with moderateValue/vigorousValue Endpoints verified against python-garminconnect source code. Co-Authored-By: Claude Opus 4.5 --- src/app/api/cron/garmin-sync/route.test.ts | 7 +- src/app/api/cron/garmin-sync/route.ts | 2 +- src/lib/garmin.test.ts | 109 ++++++++++++--------- src/lib/garmin.ts | 65 ++++++++---- 4 files changed, 117 insertions(+), 66 deletions(-) diff --git a/src/app/api/cron/garmin-sync/route.test.ts b/src/app/api/cron/garmin-sync/route.test.ts index c6838dc..27db71a 100644 --- a/src/app/api/cron/garmin-sync/route.test.ts +++ b/src/app/api/cron/garmin-sync/route.test.ts @@ -336,12 +336,15 @@ describe("POST /api/cron/garmin-sync", () => { ); }); - it("fetches intensity minutes", async () => { + it("fetches intensity minutes with today's date", async () => { mockUsers = [createMockUser()]; await POST(createMockRequest(`Bearer ${validSecret}`)); - expect(mockFetchIntensityMinutes).toHaveBeenCalledWith("mock-token-123"); + expect(mockFetchIntensityMinutes).toHaveBeenCalledWith( + expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + "mock-token-123", + ); }); }); diff --git a/src/app/api/cron/garmin-sync/route.ts b/src/app/api/cron/garmin-sync/route.ts index ac842f8..6bed67c 100644 --- a/src/app/api/cron/garmin-sync/route.ts +++ b/src/app/api/cron/garmin-sync/route.ts @@ -177,7 +177,7 @@ export async function POST(request: Request) { const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([ fetchHrvStatus(today, accessToken), fetchBodyBattery(today, accessToken), - fetchIntensityMinutes(accessToken), + fetchIntensityMinutes(today, accessToken), ]); // Calculate cycle info (lastPeriodDate guaranteed non-null by filter above) diff --git a/src/lib/garmin.test.ts b/src/lib/garmin.test.ts index 943edae..faa5ff4 100644 --- a/src/lib/garmin.test.ts +++ b/src/lib/garmin.test.ts @@ -336,12 +336,27 @@ describe("fetchBodyBattery", () => { it("returns current and yesterday low values on success", async () => { global.fetch = vi.fn().mockResolvedValue( - mockJsonResponse({ - bodyBatteryValuesArray: [ - { date: "2024-01-15", charged: 85, drained: 60 }, - ], - bodyBatteryStatList: [{ date: "2024-01-14", min: 25, max: 95 }], - }), + mockJsonResponse([ + { + date: "2024-01-14", + charged: 45, + drained: 30, + bodyBatteryValuesArray: [ + [1705190400000, "charging", 25, 1.0], + [1705194000000, "draining", 40, 1.0], + [1705197600000, "charging", 35, 1.0], + ], + }, + { + date: "2024-01-15", + charged: 85, + drained: 60, + bodyBatteryValuesArray: [ + [1705276800000, "charging", 65, 1.0], + [1705280400000, "draining", 85, 1.0], + ], + }, + ]), ); const result = await fetchBodyBattery("2024-01-15", "test-token"); @@ -351,7 +366,7 @@ describe("fetchBodyBattery", () => { yesterdayLow: 25, }); expect(global.fetch).toHaveBeenCalledWith( - "https://connectapi.garmin.com/usersummary-service/stats/bodyBattery/dates/2024-01-15", + "https://connectapi.garmin.com/wellness-service/wellness/bodyBattery/reports/daily?startDate=2024-01-14&endDate=2024-01-15", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token", @@ -362,10 +377,10 @@ describe("fetchBodyBattery", () => { it("returns null values when data is missing", async () => { global.fetch = vi.fn().mockResolvedValue( - mockJsonResponse({ - bodyBatteryValuesArray: [], - bodyBatteryStatList: [], - }), + mockJsonResponse([ + { date: "2024-01-14", bodyBatteryValuesArray: [] }, + { date: "2024-01-15", bodyBatteryValuesArray: [] }, + ]), ); const result = await fetchBodyBattery("2024-01-15", "test-token"); @@ -376,8 +391,8 @@ describe("fetchBodyBattery", () => { }); }); - it("returns null values when API returns empty object", async () => { - global.fetch = vi.fn().mockResolvedValue(mockJsonResponse({})); + it("returns null values when API returns empty array", async () => { + global.fetch = vi.fn().mockResolvedValue(mockJsonResponse([])); const result = await fetchBodyBattery("2024-01-15", "test-token"); @@ -414,10 +429,12 @@ describe("fetchBodyBattery", () => { it("handles partial data - only current available", async () => { global.fetch = vi.fn().mockResolvedValue( - mockJsonResponse({ - bodyBatteryValuesArray: [{ date: "2024-01-15", charged: 70 }], - bodyBatteryStatList: [], - }), + mockJsonResponse([ + { + date: "2024-01-15", + bodyBatteryValuesArray: [[1705276800000, "charging", 70, 1.0]], + }, + ]), ); const result = await fetchBodyBattery("2024-01-15", "test-token"); @@ -442,19 +459,21 @@ describe("fetchIntensityMinutes", () => { it("returns 7-day intensity minutes total on success", async () => { global.fetch = vi.fn().mockResolvedValue( - mockJsonResponse({ - weeklyTotal: { - moderateIntensityMinutes: 45, - vigorousIntensityMinutes: 30, + mockJsonResponse([ + { + calendarDate: "2024-01-15", + weeklyGoal: 150, + moderateValue: 45, + vigorousValue: 30, }, - }), + ]), ); - const result = await fetchIntensityMinutes("test-token"); + const result = await fetchIntensityMinutes("2024-01-15", "test-token"); expect(result).toBe(75); expect(global.fetch).toHaveBeenCalledWith( - "https://connectapi.garmin.com/fitnessstats-service/activity", + "https://connectapi.garmin.com/usersummary-service/stats/im/weekly?start=2024-01-08&end=2024-01-15", expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer test-token", @@ -464,49 +483,49 @@ describe("fetchIntensityMinutes", () => { }); it("returns 0 when no intensity data available", async () => { - global.fetch = vi.fn().mockResolvedValue(mockJsonResponse({})); + global.fetch = vi.fn().mockResolvedValue(mockJsonResponse([])); - const result = await fetchIntensityMinutes("test-token"); + const result = await fetchIntensityMinutes("2024-01-15", "test-token"); expect(result).toBe(0); }); - it("returns 0 when weeklyTotal is null", async () => { - global.fetch = vi - .fn() - .mockResolvedValue(mockJsonResponse({ weeklyTotal: null })); + it("returns 0 when response array is empty", async () => { + global.fetch = vi.fn().mockResolvedValue(mockJsonResponse([])); - const result = await fetchIntensityMinutes("test-token"); + const result = await fetchIntensityMinutes("2024-01-15", "test-token"); expect(result).toBe(0); }); it("handles only moderate intensity minutes", async () => { global.fetch = vi.fn().mockResolvedValue( - mockJsonResponse({ - weeklyTotal: { - moderateIntensityMinutes: 60, - vigorousIntensityMinutes: 0, + mockJsonResponse([ + { + calendarDate: "2024-01-15", + moderateValue: 60, + vigorousValue: 0, }, - }), + ]), ); - const result = await fetchIntensityMinutes("test-token"); + const result = await fetchIntensityMinutes("2024-01-15", "test-token"); expect(result).toBe(60); }); it("handles only vigorous intensity minutes", async () => { global.fetch = vi.fn().mockResolvedValue( - mockJsonResponse({ - weeklyTotal: { - moderateIntensityMinutes: 0, - vigorousIntensityMinutes: 45, + mockJsonResponse([ + { + calendarDate: "2024-01-15", + moderateValue: 0, + vigorousValue: 45, }, - }), + ]), ); - const result = await fetchIntensityMinutes("test-token"); + const result = await fetchIntensityMinutes("2024-01-15", "test-token"); expect(result).toBe(45); }); @@ -517,7 +536,7 @@ describe("fetchIntensityMinutes", () => { status: 401, }); - const result = await fetchIntensityMinutes("test-token"); + const result = await fetchIntensityMinutes("2024-01-15", "test-token"); expect(result).toBe(0); }); @@ -525,7 +544,7 @@ describe("fetchIntensityMinutes", () => { it("returns 0 on network error", async () => { global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); - const result = await fetchIntensityMinutes("test-token"); + const result = await fetchIntensityMinutes("2024-01-15", "test-token"); expect(result).toBe(0); }); diff --git a/src/lib/garmin.ts b/src/lib/garmin.ts index 497268a..7b5ba1a 100644 --- a/src/lib/garmin.ts +++ b/src/lib/garmin.ts @@ -109,8 +109,13 @@ export async function fetchBodyBattery( oauth2Token: string, ): Promise { try { + // Calculate yesterday's date for the API request + const dateObj = new Date(date); + dateObj.setDate(dateObj.getDate() - 1); + const yesterday = dateObj.toISOString().split("T")[0]; + const response = await fetch( - `${GARMIN_API_URL}/usersummary-service/stats/bodyBattery/dates/${date}`, + `${GARMIN_API_URL}/wellness-service/wellness/bodyBattery/reports/daily?startDate=${yesterday}&endDate=${date}`, { headers: getGarminHeaders(oauth2Token), }, @@ -132,20 +137,33 @@ export async function fetchBodyBattery( ); return { current: null, yesterdayLow: null }; } - const data = JSON.parse(text); + const data = JSON.parse(text) as Array<{ + date: string; + bodyBatteryValuesArray?: Array<[number, string, number, number]>; + }>; - const currentData = data?.bodyBatteryValuesArray?.[0]; - const current = currentData?.charged ?? null; + // Find today's and yesterday's data from the response array + const todayData = data?.find((d) => d.date === date); + const yesterdayData = data?.find((d) => d.date === yesterday); - const yesterdayStats = data?.bodyBatteryStatList?.[0]; - const yesterdayLow = yesterdayStats?.min ?? null; + // Current = last value in today's bodyBatteryValuesArray (index 2 is the level) + const todayValues = todayData?.bodyBatteryValuesArray ?? []; + const current = + todayValues.length > 0 ? todayValues[todayValues.length - 1][2] : null; + + // Yesterday low = minimum level in yesterday's bodyBatteryValuesArray + const yesterdayValues = yesterdayData?.bodyBatteryValuesArray ?? []; + const yesterdayLow = + yesterdayValues.length > 0 + ? Math.min(...yesterdayValues.map((v) => v[2])) + : null; logger.info( { current, yesterdayLow, - hasCurrentData: !!currentData, - hasYesterdayData: !!yesterdayStats, + hasCurrentData: todayValues.length > 0, + hasYesterdayData: yesterdayValues.length > 0, }, "Garmin body battery data received", ); @@ -161,11 +179,18 @@ export async function fetchBodyBattery( } export async function fetchIntensityMinutes( + date: string, oauth2Token: string, ): Promise { try { + // Calculate 7 days before the date for weekly range + const endDate = date; + const startDateObj = new Date(date); + startDateObj.setDate(startDateObj.getDate() - 7); + const startDate = startDateObj.toISOString().split("T")[0]; + const response = await fetch( - `${GARMIN_API_URL}/fitnessstats-service/activity`, + `${GARMIN_API_URL}/usersummary-service/stats/im/weekly?start=${startDate}&end=${endDate}`, { headers: getGarminHeaders(oauth2Token), }, @@ -173,7 +198,7 @@ export async function fetchIntensityMinutes( if (!response.ok) { logger.warn( - { status: response.status, endpoint: "fitnessstats" }, + { status: response.status, endpoint: "intensityMinutes" }, "Garmin intensity minutes API error", ); return 0; @@ -182,24 +207,28 @@ export async function fetchIntensityMinutes( const text = await response.text(); if (!text.startsWith("{") && !text.startsWith("[")) { logger.error( - { endpoint: "fitnessstats", responseBody: text.slice(0, 1000) }, + { endpoint: "intensityMinutes", responseBody: text.slice(0, 1000) }, "Garmin returned non-JSON response", ); return 0; } - const data = JSON.parse(text); - const weeklyTotal = data?.weeklyTotal; + const data = JSON.parse(text) as Array<{ + calendarDate: string; + moderateValue?: number; + vigorousValue?: number; + }>; - if (!weeklyTotal) { + const entry = data?.[0]; + if (!entry) { logger.info( - { hasWeeklyTotal: false }, + { hasData: false }, "Garmin intensity minutes: no weekly data", ); return 0; } - const moderate = weeklyTotal.moderateIntensityMinutes ?? 0; - const vigorous = weeklyTotal.vigorousIntensityMinutes ?? 0; + const moderate = entry.moderateValue ?? 0; + const vigorous = entry.vigorousValue ?? 0; const total = moderate + vigorous; logger.info( @@ -210,7 +239,7 @@ export async function fetchIntensityMinutes( return total; } catch (error) { logger.error( - { err: error, endpoint: "fitnessstats" }, + { err: error, endpoint: "intensityMinutes" }, "Garmin intensity minutes fetch failed", ); return 0;