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()];
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([
fetchHrvStatus(today, accessToken),
fetchBodyBattery(today, accessToken),
fetchIntensityMinutes(accessToken),
fetchIntensityMinutes(today, accessToken),
]);
// 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 () => {
global.fetch = vi.fn().mockResolvedValue(
mockJsonResponse({
mockJsonResponse([
{
date: "2024-01-14",
charged: 45,
drained: 30,
bodyBatteryValuesArray: [
{ date: "2024-01-15", charged: 85, drained: 60 },
[1705190400000, "charging", 25, 1.0],
[1705194000000, "draining", 40, 1.0],
[1705197600000, "charging", 35, 1.0],
],
bodyBatteryStatList: [{ date: "2024-01-14", min: 25, max: 95 }],
}),
},
{
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);
});

View File

@@ -109,8 +109,13 @@ export async function fetchBodyBattery(
oauth2Token: string,
): Promise<BodyBatteryData> {
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<number> {
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;