2026-06-16 09:43:48 +02:00
|
|
|
import { GarminConnect } from "garmin-connect";
|
|
|
|
|
import type { IActivity } from "garmin-connect/dist/garmin/types/activity";
|
|
|
|
|
import type { IOauth1Token } from "garmin-connect/dist/garmin/types";
|
2026-06-18 09:43:25 +02:00
|
|
|
import type { RoutePoint, RunMetrics, RunningActivityInput } from "@/lib/models/running";
|
2026-06-16 09:43:48 +02:00
|
|
|
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;
|
2026-06-18 09:43:25 +02:00
|
|
|
const MAX_ELEVATION_POINTS = 300;
|
2026-06-16 09:43:48 +02:00
|
|
|
|
2026-06-18 09:43:25 +02:00
|
|
|
type GarminPolylinePoint = { lat: number; lon: number };
|
2026-06-16 09:43:48 +02:00
|
|
|
type GarminActivityDetailsResponse = {
|
|
|
|
|
geoPolylineDTO?: { polyline?: GarminPolylinePoint[] };
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-18 09:43:25 +02:00
|
|
|
async function fetchPolyline(
|
2026-06-16 09:43:48 +02:00
|
|
|
client: GarminConnect,
|
|
|
|
|
garminActivityId: number
|
|
|
|
|
): Promise<RoutePoint[] | null> {
|
|
|
|
|
const url = `${GC_API}/activity-service/activity/${garminActivityId}/details?maxPolylineSize=${MAX_POLYLINE_POINTS}`;
|
|
|
|
|
const data = await client.get<GarminActivityDetailsResponse>(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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 09:43:25 +02:00
|
|
|
type GpxPoint = { lat: number; lon: number; ele: number };
|
|
|
|
|
|
|
|
|
|
async function fetchGpxPoints(
|
|
|
|
|
client: GarminConnect,
|
|
|
|
|
garminActivityId: number
|
|
|
|
|
): Promise<GpxPoint[] | null> {
|
|
|
|
|
const gpxUrl = `${GC_API}/download-service/export/gpx/activity/${garminActivityId}`;
|
|
|
|
|
const gpxText = await client.get<string>(gpxUrl);
|
|
|
|
|
if (!gpxText || typeof gpxText !== "string") return null;
|
|
|
|
|
|
|
|
|
|
const trkptRe = /<trkpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>[\s\S]*?<ele>([^<]+)<\/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<T>(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<RunMetrics | null> {
|
|
|
|
|
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<Response>(url);
|
|
|
|
|
const descriptors = data?.metricDescriptors ?? [];
|
|
|
|
|
const rows = data?.activityDetailMetrics ?? [];
|
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
const idx: Record<string, number> = {};
|
|
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 09:43:48 +02:00
|
|
|
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<void> {
|
|
|
|
|
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<GarminConnect> {
|
|
|
|
|
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<RunningActivityInput[]> {
|
|
|
|
|
const activities = await client.getActivities(0, FETCH_LIMIT);
|
|
|
|
|
|
|
|
|
|
return activities.filter(isRunningActivity).map(mapActivity);
|
|
|
|
|
}
|