mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
172 lines
5.8 KiB
TypeScript
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;
|
|
}
|