Files
phaseflow/src/lib/garmin.ts
Petru Paler cf89675b92
All checks were successful
Deploy / deploy (push) Successful in 2m27s
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 <noreply@anthropic.com>
2026-01-15 13:58:04 +00:00

248 lines
7.0 KiB
TypeScript

// ABOUTME: Garmin Connect API client using stored OAuth tokens.
// ABOUTME: Fetches body battery, HRV, and intensity minutes from Garmin.
import { logger } from "@/lib/logger";
import type { GarminTokens, HrvStatus } from "@/types";
// Use connectapi subdomain directly (same as garth library)
const GARMIN_API_URL = "https://connectapi.garmin.com";
// Headers matching garth library's http.py USER_AGENT
function getGarminHeaders(oauth2Token: string): Record<string, string> {
return {
Authorization: `Bearer ${oauth2Token}`,
"User-Agent": "GCM-iOS-5.19.1.2",
};
}
export interface BodyBatteryData {
current: number | null;
yesterdayLow: number | null;
}
export async function fetchGarminData(
endpoint: string,
oauth2Token: string,
): Promise<unknown> {
const response = await fetch(`${GARMIN_API_URL}${endpoint}`, {
headers: getGarminHeaders(oauth2Token),
});
if (!response.ok) {
throw new Error(`Garmin API error: ${response.status}`);
}
return response.json();
}
export function isTokenExpired(tokens: GarminTokens): boolean {
const expiresAt = new Date(tokens.expires_at);
return expiresAt <= new Date();
}
/**
* Calculate days until refresh token expiry.
* This is what users care about - when they need to re-authenticate.
* Falls back to access token expiry if refresh token expiry not available.
*/
export function daysUntilExpiry(tokens: GarminTokens): number {
const expiresAt = tokens.refresh_token_expires_at
? new Date(tokens.refresh_token_expires_at)
: new Date(tokens.expires_at);
const now = new Date();
const diffMs = expiresAt.getTime() - now.getTime();
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}
export async function fetchHrvStatus(
date: string,
oauth2Token: string,
): Promise<HrvStatus> {
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";
}
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;
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",
);
return "Unknown";
} catch (error) {
logger.error(
{ err: error, endpoint: "hrv-service" },
"Garmin HRV fetch failed",
);
return "Unknown";
}
}
export async function fetchBodyBattery(
date: string,
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}/wellness-service/wellness/bodyBattery/reports/daily?startDate=${yesterday}&endDate=${date}`,
{
headers: getGarminHeaders(oauth2Token),
},
);
if (!response.ok) {
logger.warn(
{ status: response.status, endpoint: "bodyBattery" },
"Garmin body battery API error",
);
return { current: null, yesterdayLow: null };
}
const text = await response.text();
if (!text.startsWith("{") && !text.startsWith("[")) {
logger.error(
{ endpoint: "bodyBattery", responseBody: text.slice(0, 1000) },
"Garmin returned non-JSON response",
);
return { current: null, yesterdayLow: null };
}
const data = JSON.parse(text) as Array<{
date: string;
bodyBatteryValuesArray?: Array<[number, string, number, number]>;
}>;
// 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);
// 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: todayValues.length > 0,
hasYesterdayData: yesterdayValues.length > 0,
},
"Garmin body battery data received",
);
return { current, yesterdayLow };
} catch (error) {
logger.error(
{ err: error, endpoint: "bodyBattery" },
"Garmin body battery fetch failed",
);
return { current: null, yesterdayLow: null };
}
}
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}/usersummary-service/stats/im/weekly?start=${startDate}&end=${endDate}`,
{
headers: getGarminHeaders(oauth2Token),
},
);
if (!response.ok) {
logger.warn(
{ status: response.status, endpoint: "intensityMinutes" },
"Garmin intensity minutes API error",
);
return 0;
}
const text = await response.text();
if (!text.startsWith("{") && !text.startsWith("[")) {
logger.error(
{ endpoint: "intensityMinutes", responseBody: text.slice(0, 1000) },
"Garmin returned non-JSON response",
);
return 0;
}
const data = JSON.parse(text) as Array<{
calendarDate: string;
moderateValue?: number;
vigorousValue?: number;
}>;
const entry = data?.[0];
if (!entry) {
logger.info(
{ hasData: false },
"Garmin intensity minutes: no weekly data",
);
return 0;
}
const moderate = entry.moderateValue ?? 0;
const vigorous = entry.vigorousValue ?? 0;
const total = moderate + vigorous;
logger.info(
{ moderate, vigorous, total },
"Garmin intensity minutes data received",
);
return total;
} catch (error) {
logger.error(
{ err: error, endpoint: "intensityMinutes" },
"Garmin intensity minutes fetch failed",
);
return 0;
}
}