Fix body battery and intensity minutes Garmin API endpoints
All checks were successful
Deploy / deploy (push) Successful in 2m27s

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 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 13:58:04 +00:00
parent 59d70ee414
commit cf89675b92
4 changed files with 117 additions and 66 deletions

View File

@@ -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()]; mockUsers = [createMockUser()];
await POST(createMockRequest(`Bearer ${validSecret}`)); 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",
);
}); });
}); });

View File

@@ -177,7 +177,7 @@ export async function POST(request: Request) {
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([ const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([
fetchHrvStatus(today, accessToken), fetchHrvStatus(today, accessToken),
fetchBodyBattery(today, accessToken), fetchBodyBattery(today, accessToken),
fetchIntensityMinutes(accessToken), fetchIntensityMinutes(today, accessToken),
]); ]);
// Calculate cycle info (lastPeriodDate guaranteed non-null by filter above) // Calculate cycle info (lastPeriodDate guaranteed non-null by filter above)

View File

@@ -336,12 +336,27 @@ describe("fetchBodyBattery", () => {
it("returns current and yesterday low values on success", async () => { it("returns current and yesterday low values on success", async () => {
global.fetch = vi.fn().mockResolvedValue( global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse({ mockJsonResponse([
bodyBatteryValuesArray: [ {
{ date: "2024-01-15", charged: 85, drained: 60 }, date: "2024-01-14",
], charged: 45,
bodyBatteryStatList: [{ date: "2024-01-14", min: 25, max: 95 }], 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"); const result = await fetchBodyBattery("2024-01-15", "test-token");
@@ -351,7 +366,7 @@ describe("fetchBodyBattery", () => {
yesterdayLow: 25, yesterdayLow: 25,
}); });
expect(global.fetch).toHaveBeenCalledWith( 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({ expect.objectContaining({
headers: expect.objectContaining({ headers: expect.objectContaining({
Authorization: "Bearer test-token", Authorization: "Bearer test-token",
@@ -362,10 +377,10 @@ describe("fetchBodyBattery", () => {
it("returns null values when data is missing", async () => { it("returns null values when data is missing", async () => {
global.fetch = vi.fn().mockResolvedValue( global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse({ mockJsonResponse([
bodyBatteryValuesArray: [], { date: "2024-01-14", bodyBatteryValuesArray: [] },
bodyBatteryStatList: [], { date: "2024-01-15", bodyBatteryValuesArray: [] },
}), ]),
); );
const result = await fetchBodyBattery("2024-01-15", "test-token"); 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 () => { it("returns null values when API returns empty array", async () => {
global.fetch = vi.fn().mockResolvedValue(mockJsonResponse({})); global.fetch = vi.fn().mockResolvedValue(mockJsonResponse([]));
const result = await fetchBodyBattery("2024-01-15", "test-token"); const result = await fetchBodyBattery("2024-01-15", "test-token");
@@ -414,10 +429,12 @@ describe("fetchBodyBattery", () => {
it("handles partial data - only current available", async () => { it("handles partial data - only current available", async () => {
global.fetch = vi.fn().mockResolvedValue( global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse({ mockJsonResponse([
bodyBatteryValuesArray: [{ date: "2024-01-15", charged: 70 }], {
bodyBatteryStatList: [], date: "2024-01-15",
}), bodyBatteryValuesArray: [[1705276800000, "charging", 70, 1.0]],
},
]),
); );
const result = await fetchBodyBattery("2024-01-15", "test-token"); 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 () => { it("returns 7-day intensity minutes total on success", async () => {
global.fetch = vi.fn().mockResolvedValue( global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse({ mockJsonResponse([
weeklyTotal: { {
moderateIntensityMinutes: 45, calendarDate: "2024-01-15",
vigorousIntensityMinutes: 30, 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(result).toBe(75);
expect(global.fetch).toHaveBeenCalledWith( 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({ expect.objectContaining({
headers: expect.objectContaining({ headers: expect.objectContaining({
Authorization: "Bearer test-token", Authorization: "Bearer test-token",
@@ -464,49 +483,49 @@ describe("fetchIntensityMinutes", () => {
}); });
it("returns 0 when no intensity data available", async () => { 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); expect(result).toBe(0);
}); });
it("returns 0 when weeklyTotal is null", async () => { it("returns 0 when response array is empty", async () => {
global.fetch = vi global.fetch = vi.fn().mockResolvedValue(mockJsonResponse([]));
.fn()
.mockResolvedValue(mockJsonResponse({ weeklyTotal: null }));
const result = await fetchIntensityMinutes("test-token"); const result = await fetchIntensityMinutes("2024-01-15", "test-token");
expect(result).toBe(0); expect(result).toBe(0);
}); });
it("handles only moderate intensity minutes", async () => { it("handles only moderate intensity minutes", async () => {
global.fetch = vi.fn().mockResolvedValue( global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse({ mockJsonResponse([
weeklyTotal: { {
moderateIntensityMinutes: 60, calendarDate: "2024-01-15",
vigorousIntensityMinutes: 0, moderateValue: 60,
vigorousValue: 0,
}, },
}), ]),
); );
const result = await fetchIntensityMinutes("test-token"); const result = await fetchIntensityMinutes("2024-01-15", "test-token");
expect(result).toBe(60); expect(result).toBe(60);
}); });
it("handles only vigorous intensity minutes", async () => { it("handles only vigorous intensity minutes", async () => {
global.fetch = vi.fn().mockResolvedValue( global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse({ mockJsonResponse([
weeklyTotal: { {
moderateIntensityMinutes: 0, calendarDate: "2024-01-15",
vigorousIntensityMinutes: 45, moderateValue: 0,
vigorousValue: 45,
}, },
}), ]),
); );
const result = await fetchIntensityMinutes("test-token"); const result = await fetchIntensityMinutes("2024-01-15", "test-token");
expect(result).toBe(45); expect(result).toBe(45);
}); });
@@ -517,7 +536,7 @@ describe("fetchIntensityMinutes", () => {
status: 401, status: 401,
}); });
const result = await fetchIntensityMinutes("test-token"); const result = await fetchIntensityMinutes("2024-01-15", "test-token");
expect(result).toBe(0); expect(result).toBe(0);
}); });
@@ -525,7 +544,7 @@ describe("fetchIntensityMinutes", () => {
it("returns 0 on network error", async () => { it("returns 0 on network error", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); 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); expect(result).toBe(0);
}); });

View File

@@ -109,8 +109,13 @@ export async function fetchBodyBattery(
oauth2Token: string, oauth2Token: string,
): Promise<BodyBatteryData> { ): Promise<BodyBatteryData> {
try { 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( 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), headers: getGarminHeaders(oauth2Token),
}, },
@@ -132,20 +137,33 @@ export async function fetchBodyBattery(
); );
return { current: null, yesterdayLow: null }; 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]; // Find today's and yesterday's data from the response array
const current = currentData?.charged ?? null; const todayData = data?.find((d) => d.date === date);
const yesterdayData = data?.find((d) => d.date === yesterday);
const yesterdayStats = data?.bodyBatteryStatList?.[0]; // Current = last value in today's bodyBatteryValuesArray (index 2 is the level)
const yesterdayLow = yesterdayStats?.min ?? null; 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( logger.info(
{ {
current, current,
yesterdayLow, yesterdayLow,
hasCurrentData: !!currentData, hasCurrentData: todayValues.length > 0,
hasYesterdayData: !!yesterdayStats, hasYesterdayData: yesterdayValues.length > 0,
}, },
"Garmin body battery data received", "Garmin body battery data received",
); );
@@ -161,11 +179,18 @@ export async function fetchBodyBattery(
} }
export async function fetchIntensityMinutes( export async function fetchIntensityMinutes(
date: string,
oauth2Token: string, oauth2Token: string,
): Promise<number> { ): Promise<number> {
try { 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( 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), headers: getGarminHeaders(oauth2Token),
}, },
@@ -173,7 +198,7 @@ export async function fetchIntensityMinutes(
if (!response.ok) { if (!response.ok) {
logger.warn( logger.warn(
{ status: response.status, endpoint: "fitnessstats" }, { status: response.status, endpoint: "intensityMinutes" },
"Garmin intensity minutes API error", "Garmin intensity minutes API error",
); );
return 0; return 0;
@@ -182,24 +207,28 @@ export async function fetchIntensityMinutes(
const text = await response.text(); const text = await response.text();
if (!text.startsWith("{") && !text.startsWith("[")) { if (!text.startsWith("{") && !text.startsWith("[")) {
logger.error( logger.error(
{ endpoint: "fitnessstats", responseBody: text.slice(0, 1000) }, { endpoint: "intensityMinutes", responseBody: text.slice(0, 1000) },
"Garmin returned non-JSON response", "Garmin returned non-JSON response",
); );
return 0; return 0;
} }
const data = JSON.parse(text); const data = JSON.parse(text) as Array<{
const weeklyTotal = data?.weeklyTotal; calendarDate: string;
moderateValue?: number;
vigorousValue?: number;
}>;
if (!weeklyTotal) { const entry = data?.[0];
if (!entry) {
logger.info( logger.info(
{ hasWeeklyTotal: false }, { hasData: false },
"Garmin intensity minutes: no weekly data", "Garmin intensity minutes: no weekly data",
); );
return 0; return 0;
} }
const moderate = weeklyTotal.moderateIntensityMinutes ?? 0; const moderate = entry.moderateValue ?? 0;
const vigorous = weeklyTotal.vigorousIntensityMinutes ?? 0; const vigorous = entry.vigorousValue ?? 0;
const total = moderate + vigorous; const total = moderate + vigorous;
logger.info( logger.info(
@@ -210,7 +239,7 @@ export async function fetchIntensityMinutes(
return total; return total;
} catch (error) { } catch (error) {
logger.error( logger.error(
{ err: error, endpoint: "fitnessstats" }, { err: error, endpoint: "intensityMinutes" },
"Garmin intensity minutes fetch failed", "Garmin intensity minutes fetch failed",
); );
return 0; return 0;