Fix body battery and intensity minutes Garmin API endpoints
All checks were successful
Deploy / deploy (push) Successful in 2m27s
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:
@@ -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",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user