Files
HealthLog/src/lib/garmin/sync-workouts.ts
T
Marc-André Bombeck e2ba86b12f feat(garmin): sync Garmin Connect through the Personal API
Garmin Connect now offers a Personal API: a person creates a key for
their own account and any client can buy short-lived tokens with it.
HealthLog takes that key in a new Garmin card, checks it with Garmin
before storing it sealed on the user row, and polls every hour.

- Transport: key exchange with a per-process token cache keyed on the
  key, one re-exchange on a refused token and then reauth_required, the
  429 Retry-After carried to the caller, the China host tried when the
  global one does not know the key, answers read with a byte cap, all
  egress through safeFetch to the two fixed hosts.
- Sync: daily summaries, sleep summaries, HRV, weigh-ins and the
  activity search, chunked to each resource's documented range, read one
  after another and stopped at the first rate limit; today's menstrual
  cycle summary adds logged period days the person has not logged.
- Backfill: a resumable walk through the shared admission lane that
  learns where the history starts from the daily summaries and the
  activities, waits out Garmin's rate limit and stamps its marker.
- Types: GARMIN source; BODY_BATTERY and STRESS_LEVEL for Garmin's own
  day scores, kept apart from the computed STRESS_SCORE; TRAINING_READINESS
  and ACUTE_TRAINING_LOAD for figures a bridge pushes through the ingest
  token. All four chart on the recovery page and sit on the ladders,
  units, ranges, labels and report groups like the device-native scores.
- Key expiry: learned from a token capped short of a day, else estimated
  from the validity picked at connect; the card warns a week ahead and
  asks for a new key once Garmin refuses the old one.
- Ledger, health, freshness, watchdog, backup classes, wipe plan,
  encrypted-column registry, rotation script, OpenAPI and all ten
  locales carry the new integration. The old "no Garmin connector" note
  is gone.
2026-11-04 10:14:10 +01:00

172 lines
5.8 KiB
TypeScript

/**
* Garmin activities → `Workout` rows (`source = GARMIN`).
*
* Each activity becomes one row keyed on `(userId, source, externalId)` with
* Garmin's `activityId` as `externalId`, so a re-read (the hourly window, a
* backfill slice, an activity edited in Garmin Connect) overwrites in place.
* The write is the `createManyAndReturn` + `skipDuplicates`, update on
* conflict shape the Oura, Polar, WHOOP and Strava ingests use; the arrival
* event fires only for a fresh insert.
*
* Cross-source dedup is not done here: a Garmin run and the same run relayed
* through Apple Health stay two rows, and the read-time
* `pickCanonicalWorkoutRows` collapses the twin by the source ladder.
*
* The activity's name and description are the person's own free text and are
* not imported; neither are its start coordinates.
*/
import { prisma } from "@/lib/db";
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
import { annotate, getEvent } from "@/lib/logging/context";
import type { Prisma } from "@/generated/prisma/client";
import type { WorkoutSportType } from "@/lib/validations/workout";
import { parseGarminInstant } from "./map";
import { mapGarminSportType } from "./sport-map";
import type { GarminActivity } from "./wire-types";
export interface GarminWorkoutRow {
externalId: string;
sportType: WorkoutSportType;
startedAt: Date;
endedAt: Date;
durationSec: number;
totalEnergyKcal: number | null;
totalDistanceM: number | null;
avgHeartRate: number | null;
maxHeartRate: number | null;
stepCount: number | null;
elevationM: number | null;
metadata: Prisma.InputJsonValue;
}
function nonNegative(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
}
function wholeOrNull(v: unknown): number | null {
const n = nonNegative(v);
return n === null ? null : Math.round(n);
}
/**
* Map one activity, or null when it has no id, no start, or no positive
* duration.
*/
export function mapGarminActivity(a: GarminActivity): GarminWorkoutRow | null {
const id =
typeof a.activityId === "number" || typeof a.activityId === "string"
? String(a.activityId)
: "";
if (id === "") return null;
const startedAt =
parseGarminInstant(a.beginTimestamp) ?? parseGarminInstant(a.startTimeGMT);
if (!startedAt) return null;
// The timer duration is the workout; the elapsed one includes pauses.
const duration = nonNegative(a.duration) ?? nonNegative(a.elapsedDuration);
if (duration === null || duration <= 0) return null;
const elapsed = nonNegative(a.elapsedDuration);
const span = elapsed !== null && elapsed >= duration ? elapsed : duration;
const endedAt = new Date(startedAt.getTime() + Math.round(span * 1000));
const typeKey =
typeof a.activityType?.typeKey === "string" ? a.activityType.typeKey : null;
const metadata: Prisma.InputJsonValue = {
...(typeKey ? { garminActivity: typeKey } : {}),
...(nonNegative(a.aerobicTrainingEffect) !== null
? { garminAerobicEffect: a.aerobicTrainingEffect as number }
: {}),
...(nonNegative(a.anaerobicTrainingEffect) !== null
? { garminAnaerobicEffect: a.anaerobicTrainingEffect as number }
: {}),
...(nonNegative(a.activityTrainingLoad) !== null
? { garminTrainingLoad: a.activityTrainingLoad as number }
: {}),
};
const kcal = nonNegative(a.calories);
const avgHr = wholeOrNull(a.averageHR);
const maxHr = wholeOrNull(a.maxHR);
return {
externalId: id,
sportType: mapGarminSportType(typeKey),
startedAt,
endedAt,
durationSec: Math.round(duration),
totalEnergyKcal: kcal === null ? null : Math.round(kcal),
totalDistanceM: nonNegative(a.distance),
avgHeartRate: avgHr && avgHr > 0 ? avgHr : null,
maxHeartRate: maxHr && maxHr > 0 ? maxHr : null,
stepCount: wholeOrNull(a.steps),
elevationM: nonNegative(a.elevationGain),
metadata,
};
}
/**
* Upsert mapped Garmin workouts. Every row is attempted; the first failure is
* rethrown afterwards so a partly written batch is never reported clean.
* `emitArrivals: false` (the history backfill) writes without arrival events.
*/
export async function upsertGarminWorkouts(
userId: string,
rows: readonly GarminWorkoutRow[],
opts: { emitArrivals?: boolean } = {},
): Promise<number> {
if (rows.length === 0) return 0;
const emitArrivals = opts.emitArrivals ?? true;
let imported = 0;
let firstError: unknown;
let hadError = false;
for (const r of rows) {
const data = {
sportType: r.sportType,
startedAt: r.startedAt,
endedAt: r.endedAt,
durationSec: r.durationSec,
totalEnergyKcal: r.totalEnergyKcal,
totalDistanceM: r.totalDistanceM,
avgHeartRate: r.avgHeartRate,
maxHeartRate: r.maxHeartRate,
stepCount: r.stepCount,
elevationM: r.elevationM,
metadata: r.metadata,
};
try {
const [inserted] = await prisma.workout.createManyAndReturn({
data: { userId, source: "GARMIN", externalId: r.externalId, ...data },
skipDuplicates: true,
select: { id: true, startedAt: true },
});
if (inserted) {
if (emitArrivals) {
void emitInsertedWorkoutArrival(userId, inserted, "garmin").catch(
() => {},
);
}
} else {
await prisma.workout.update({
where: {
userId_source_externalId: {
userId,
source: "GARMIN",
externalId: r.externalId,
},
},
data,
});
}
imported += 1;
} catch (err) {
getEvent()?.addWarning(`garmin: failed to upsert workout: ${err}`);
if (!hadError) {
firstError = err;
hadError = true;
}
}
}
if (hadError) throw firstError;
annotate({
action: { name: "garmin.workout.ingest", details: { imported } },
});
return imported;
}