Files
phaseflow/src/lib/garmin.ts
Petru Paler 59d70ee414
All checks were successful
Deploy / deploy (push) Successful in 1m38s
Use connectapi.garmin.com directly instead of web proxy
The connect.garmin.com/modern/proxy URL returns HTML (website) instead
of JSON API responses. Garth library uses connectapi.garmin.com subdomain
directly, which is the actual API endpoint.

- Change base URL from connect.garmin.com/modern/proxy to connectapi.garmin.com
- Update User-Agent to match garth library: GCM-iOS-5.19.1.2
- Factor out headers into getGarminHeaders() to avoid duplication
- Remove NK header (not needed when using connectapi subdomain)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 13:38:55 +00:00

219 lines
5.8 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 {
const response = await fetch(
`${GARMIN_API_URL}/usersummary-service/stats/bodyBattery/dates/${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);
const currentData = data?.bodyBatteryValuesArray?.[0];
const current = currentData?.charged ?? null;
const yesterdayStats = data?.bodyBatteryStatList?.[0];
const yesterdayLow = yesterdayStats?.min ?? null;
logger.info(
{
current,
yesterdayLow,
hasCurrentData: !!currentData,
hasYesterdayData: !!yesterdayStats,
},
"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(
oauth2Token: string,
): Promise<number> {
try {
const response = await fetch(
`${GARMIN_API_URL}/fitnessstats-service/activity`,
{
headers: getGarminHeaders(oauth2Token),
},
);
if (!response.ok) {
logger.warn(
{ status: response.status, endpoint: "fitnessstats" },
"Garmin intensity minutes API error",
);
return 0;
}
const text = await response.text();
if (!text.startsWith("{") && !text.startsWith("[")) {
logger.error(
{ endpoint: "fitnessstats", responseBody: text.slice(0, 1000) },
"Garmin returned non-JSON response",
);
return 0;
}
const data = JSON.parse(text);
const weeklyTotal = data?.weeklyTotal;
if (!weeklyTotal) {
logger.info(
{ hasWeeklyTotal: false },
"Garmin intensity minutes: no weekly data",
);
return 0;
}
const moderate = weeklyTotal.moderateIntensityMinutes ?? 0;
const vigorous = weeklyTotal.vigorousIntensityMinutes ?? 0;
const total = moderate + vigorous;
logger.info(
{ moderate, vigorous, total },
"Garmin intensity minutes data received",
);
return total;
} catch (error) {
logger.error(
{ err: error, endpoint: "fitnessstats" },
"Garmin intensity minutes fetch failed",
);
return 0;
}
}