// 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 { 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 { 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 { 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 { 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 { 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; } }