This commit is contained in:
Dominik Klarkowski
2026-06-18 09:43:25 +02:00
parent ee178feff0
commit d00a5a42ac
9 changed files with 621 additions and 21 deletions

View File

@@ -1,7 +1,7 @@
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, RunningActivityInput } from "@/lib/models/running";
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";
@@ -61,13 +61,14 @@ function mapActivity(activity: IActivity): RunningActivityInput {
const GC_API = "https://connectapi.garmin.com";
const MAX_POLYLINE_POINTS = 500;
const MAX_ELEVATION_POINTS = 300;
type GarminPolylinePoint = { lat: number; lon: number; altitude?: number };
type GarminPolylinePoint = { lat: number; lon: number };
type GarminActivityDetailsResponse = {
geoPolylineDTO?: { polyline?: GarminPolylinePoint[] };
};
export async function fetchActivityRoutePoints(
async function fetchPolyline(
client: GarminConnect,
garminActivityId: number
): Promise<RoutePoint[] | null> {
@@ -78,6 +79,108 @@ export async function fetchActivityRoutePoints(
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<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),
};
}
function getCredentials(): { username: string; password: string } {
const username = process.env.GARMIN_EMAIL;
const password = process.env.GARMIN_PASSWORD;