import { GarminConnect } from "garmin-connect"; import type { IActivity } from "garmin-connect/dist/garmin/types/activity"; import type { IOauth1Token } from "garmin-connect/dist/garmin/types"; import type { RoutePoint, RunMetrics, RunningActivityInput } from "@/lib/models/running"; import { getSavedOauth1Token } from "@/lib/models/garmin-auth"; import { completeMfaAndGetTicket, loginAndGetTicket, type GarminPendingMfa } from "@/lib/garmin/sso"; const FETCH_LIMIT = 50; export class GarminLoginRequiredError extends Error { constructor() { super("Wymagane logowanie do Garmin Connect."); } } function parseGarminDate(value: string): Date { return new Date(`${value.replace(" ", "T")}Z`); } function isRunningActivity(activity: IActivity): boolean { return activity.activityType?.typeKey?.toLowerCase().includes("running") ?? false; } function toNumber(value: unknown): number | undefined { return typeof value === "number" && value > 0 ? value : undefined; } function toText(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function mapActivity(activity: IActivity): RunningActivityInput { return { garminActivityId: activity.activityId, name: activity.activityName, startTime: parseGarminDate(activity.startTimeGMT), durationSec: activity.duration, distanceM: activity.distance, avgPaceSecPerKm: activity.averageSpeed > 0 ? 1000 / activity.averageSpeed : 0, avgHr: activity.averageHR || undefined, maxHr: activity.maxHR || undefined, calories: activity.calories || undefined, elevationGainM: activity.elevationGain || undefined, avgCadence: activity.averageRunningCadenceInStepsPerMinute || undefined, avgVerticalOscillationCm: toNumber(activity.avgVerticalOscillation), avgGroundContactTimeMs: toNumber(activity.avgGroundContactTime), avgStrideLengthCm: activity.avgStrideLength || undefined, avgGroundContactBalanceLeftPct: toNumber(activity.avgGroundContactBalance), avgVerticalRatioPct: toNumber(activity.avgVerticalRatio), vo2Max: activity.vO2MaxValue || undefined, aerobicTrainingEffect: toNumber(activity.aerobicTrainingEffect), anaerobicTrainingEffect: toNumber(activity.anaerobicTrainingEffect), trainingEffectLabel: toText(activity.trainingEffectLabel), avgPowerW: toNumber(activity.avgPower), maxPowerW: toNumber(activity.maxPower), normPowerW: toNumber(activity.normPower), avgRespirationRate: toNumber(activity.avgRespirationRate), hasRoute: activity.hasPolyline || undefined, }; } const GC_API = "https://connectapi.garmin.com"; const MAX_POLYLINE_POINTS = 500; const MAX_ELEVATION_POINTS = 300; type GarminPolylinePoint = { lat: number; lon: number }; type GarminActivityDetailsResponse = { geoPolylineDTO?: { polyline?: GarminPolylinePoint[] }; }; async function fetchPolyline( client: GarminConnect, garminActivityId: number ): Promise { const url = `${GC_API}/activity-service/activity/${garminActivityId}/details?maxPolylineSize=${MAX_POLYLINE_POINTS}`; const data = await client.get(url); const polyline = data?.geoPolylineDTO?.polyline; if (!Array.isArray(polyline) || polyline.length === 0) return null; return polyline.map((p) => [p.lat, p.lon] as RoutePoint); } type GpxPoint = { lat: number; lon: number; ele: number }; async function fetchGpxPoints( client: GarminConnect, garminActivityId: number ): Promise { const gpxUrl = `${GC_API}/download-service/export/gpx/activity/${garminActivityId}`; const gpxText = await client.get(gpxUrl); if (!gpxText || typeof gpxText !== "string") return null; const trkptRe = /]*>[\s\S]*?([^<]+)<\/ele>/g; const points: GpxPoint[] = []; let m: RegExpExecArray | null; while ((m = trkptRe.exec(gpxText)) !== null) { points.push({ lat: parseFloat(m[1]), lon: parseFloat(m[2]), ele: parseFloat(m[3]) }); } return points.length > 0 ? points : null; } function downsample(arr: T[], maxLen: number): T[] { if (arr.length <= maxLen) return arr; const step = arr.length / maxLen; return Array.from({ length: maxLen }, (_, i) => arr[Math.round(i * step)]); } export async function fetchActivityRoutePoints( client: GarminConnect, garminActivityId: number ): Promise<{ points: RoutePoint[]; elevationProfile: number[] } | null> { const [polylinePoints, gpxPoints] = await Promise.allSettled([ fetchPolyline(client, garminActivityId), fetchGpxPoints(client, garminActivityId), ]); const routePoints = polylinePoints.status === "fulfilled" ? polylinePoints.value : null; const gpx = gpxPoints.status === "fulfilled" ? gpxPoints.value : null; if (!routePoints && !gpx) return null; const points = routePoints ?? (gpx ? downsample(gpx.map((p) => [p.lat, p.lon] as RoutePoint), MAX_POLYLINE_POINTS) : []); const elevationProfile = gpx ? downsample(gpx, MAX_ELEVATION_POINTS).map((p) => p.ele) : (routePoints ? routePoints.map(() => 0) : []); return { points, elevationProfile }; } export async function fetchActivityRunMetrics( client: GarminConnect, garminActivityId: number ): Promise { const url = `${GC_API}/activity-service/activity/${garminActivityId}/details`; type Descriptor = { key: string; metricsIndex: number }; type Row = { metrics: (number | null)[] }; type Response = { metricDescriptors?: Descriptor[]; activityDetailMetrics?: Row[] }; const data = await client.get(url); const descriptors = data?.metricDescriptors ?? []; const rows = data?.activityDetailMetrics ?? []; if (rows.length === 0) return null; const idx: Record = {}; for (const d of descriptors) idx[d.key] = d.metricsIndex; const get = (row: Row, key: string): number | null => { const i = idx[key]; return i !== undefined ? (row.metrics[i] ?? null) : null; }; const MAX = 300; const step = Math.max(1, Math.floor(rows.length / MAX)); const sampled = rows.filter((_, i) => i % step === 0 || i === rows.length - 1); // Try several known Garmin distance metric keys const distKey = ["directDistance", "sumDistance", "directCumulativeDistance"].find( (k) => sampled.some((row) => get(row, k) !== null && get(row, k)! > 0) ); const distanceKm = sampled.map((row) => { const d = distKey ? get(row, distKey) : null; return d !== null ? Math.round(d / 10) / 100 : 0; }); const series = (key: string, decimals = 0): number[] | undefined => { const values = sampled.map((row) => { const v = get(row, key); if (v === null) return 0; return decimals > 0 ? Math.round(v * 10 ** decimals) / 10 ** decimals : Math.round(v); }); return values.some((v) => v > 0) ? values : undefined; }; return { distanceKm, hrBpm: series("directHeartRate"), cadenceSpm: series("directDoubleCadence"), gctMs: series("directGroundContactTime"), gcbLeftPct: series("directGroundContactBalanceLeft", 1), }; } function getCredentials(): { username: string; password: string } { const username = process.env.GARMIN_EMAIL; const password = process.env.GARMIN_PASSWORD; if (!username || !password) { throw new Error("Brak danych logowania do Garmin Connect (GARMIN_EMAIL / GARMIN_PASSWORD)."); } return { username, password }; } async function exchangeOauth1Token(client: GarminConnect, oauth1Token: IOauth1Token): Promise { const http = client.client; if (!http.OAUTH_CONSUMER) { await http.fetchOauthConsumer(); } const consumer = http.OAUTH_CONSUMER; if (!consumer) { throw new Error("Nie udało się pobrać konfiguracji OAuth Garmin."); } const oauth = http.getOauthClient(consumer); http.oauth1Token = oauth1Token; await http.exchange({ oauth, token: oauth1Token }); } /** * Returns a client authenticated using a previously saved OAuth1 token * (long-lived, survives across syncs) - no MFA needed if it's still valid. */ export async function getAuthorizedClient(): Promise { const saved = await getSavedOauth1Token(); if (!saved) { throw new GarminLoginRequiredError(); } const client = new GarminConnect({ username: "", password: "" }); try { await exchangeOauth1Token(client, saved); } catch { throw new GarminLoginRequiredError(); } return client; } async function establishClientFromTicket(ticket: string): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> { const client = new GarminConnect({ username: "", password: "" }); await client.client.fetchOauthConsumer(); const oauth1 = await client.client.getOauth1Token(ticket); await client.client.exchange(oauth1); return { client, oauth1Token: oauth1.token }; } /** * Starts a fresh SSO login using env credentials. If the account requires * MFA, returns the pending state needed to complete it via * `completeGarminMfaLogin` once the user supplies the emailed code. */ export async function beginGarminLogin(): Promise< { client: GarminConnect; oauth1Token: IOauth1Token } | { mfaRequired: true; pendingState: GarminPendingMfa } > { const { username, password } = getCredentials(); const result = await loginAndGetTicket(username, password); if ("mfaRequired" in result) return result; return establishClientFromTicket(result.ticket); } export async function completeGarminMfaLogin( pendingState: GarminPendingMfa, code: string ): Promise<{ client: GarminConnect; oauth1Token: IOauth1Token }> { const ticket = await completeMfaAndGetTicket(pendingState, code); return establishClientFromTicket(ticket); } /** * Returns all recent running activities (mapped), regardless of `since` - * callers should upsert all of them so previously-synced activities get * backfilled with newly added metric fields, but can use `since` to decide * which ones are "new" for reporting purposes. */ export async function fetchRunningActivities(client: GarminConnect): Promise { const activities = await client.getActivities(0, FETCH_LIMIT); return activities.filter(isRunningActivity).map(mapActivity); }