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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 10:14:10 +01:00
1 parent 67909f2d9d
commit e2ba86b12f
119 files changed
+5798 -400

No files matched your search

@@ -116,6 +116,8 @@ const BACKFILL_STAMPS: Record<string, string> = {
"stamps the account's backfill-completed marker",
"src/lib/jobs/oura-backfill.ts > user":
"writes the account's backfill progress note and completed marker",
"src/lib/jobs/garmin-backfill.ts > user":
"writes the account's backfill progress note and completed marker",
"src/lib/jobs/document-content-index-backfill.ts > documentContentIndex":
"the index row is rebuilt content; its updatedAt is the rebuild time",
"src/lib/jobs/lab-biomarker-backfill.ts > biomarker":
@@ -39,7 +39,7 @@
"lib/daily/digest.ts": 1317,
"lib/daily/load-digest.ts": 1000,
"lib/daily/today-overview.ts": 838,
"lib/dashboard-layout.ts": 971,
"lib/dashboard-layout.ts": 976,
"lib/dashboard/snapshot.ts": 1550,
"lib/day/records.ts": 1174,
"lib/export/backup-plan.ts": 853,
@@ -62,7 +62,7 @@
"lib/insights/correlations.ts": 1026,
"lib/insights/features.ts": 1875,
"lib/insights/mood-aggregates.ts": 1053,
"lib/integrations/status.ts": 1445,
"lib/integrations/status.ts": 1448,
"lib/jobs/coach-nudge.ts": 1084,
"lib/jobs/insight-pregenerate.ts": 1189,
"lib/jobs/medication-intake-import.ts": 819,
@@ -70,7 +70,7 @@
"lib/jobs/reaction-line.ts": 803,
"lib/managed-profiles/handover.ts": 986,
"lib/mcp/record-search.ts": 1397,
"lib/mcp/rich-reads.ts": 1467,
"lib/mcp/rich-reads.ts": 1471,
"lib/measurements/dense-intraday-retention.ts": 1016,
"lib/measurements/import-apple-health-export.ts": 1722,
"lib/medications/scheduling/cadence.ts": 976,
@@ -21,7 +21,7 @@
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 259,
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
"app/api/integrations/status/route.ts :: apiHandler(arg)": 451,
"app/api/integrations/status/route.ts :: apiHandler(arg)": 475,
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239,
"app/api/labs/ocr/commit/route.ts :: commitOcrRows": 280,
"app/api/labs/route.ts :: postLabResult": 242,
@@ -293,7 +293,7 @@
"lib/insights/features.ts :: extractFeatures": 796,
"lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246,
"lib/insights/status-provider.ts :: runStatusCompletion": 215,
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227,
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 229,
"lib/integrations/status.ts :: recordSyncFailure": 292,
"lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300,
"lib/jobs/coach-nudge.ts :: findTriggerForUser": 279,
@@ -335,7 +335,7 @@
"lib/notifications/senders/apns.ts :: sendViaApns": 299,
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
"lib/personal-records/pr-direction.ts :: getPRDirection": 202,
"lib/personal-records/pr-direction.ts :: getPRDirection": 208,
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272,
"lib/strava/sync.ts :: runStravaSync": 219,
"lib/support/report.ts :: collectSupportReportFacts": 332,
@@ -1378,7 +1378,6 @@
"components/settings/integrations/__tests__/apple-health-card.test.tsx": 2,
"components/settings/integrations/apple-health-card.tsx": 1,
"components/settings/integrations/connections-panel.tsx": 5,
"components/settings/integrations/garmin-info-note.tsx": 1,
"components/settings/integrations/notification-channels-panel.tsx": 1,
"components/settings/integrations/setup-guide-link.tsx": 2,
"components/settings/integrations/shared.tsx": 3,
@@ -125,6 +125,7 @@ const NO_REFETCH_ALLOWLIST = [
// Background integration-sync completion on the Settings surface — the user
// is configuring an integration, not acting on the hero; convergence via the
// daily reads' own poll/focus refetch is the intended behaviour.
"components/settings/integrations/garmin-card.tsx",
"components/settings/integrations/google-health-card.tsx",
"components/settings/integrations/integration-actions.tsx",
"components/settings/integrations/nightscout-card.tsx",
@@ -719,6 +719,14 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/garmin-card.tsx",
destroys:
"the stored Garmin Personal API key — reconnecting means pasting a key again, and a key Garmin showed only once may mean creating a new one",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/dawarich-card.tsx",
destroys:
@@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(106);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86);
expect(ALL_LEAF_IDS.length).toBe(110);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(90);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
});
@@ -110,6 +110,10 @@ const LEGIT_IDENTICAL = new Set<string>([
"settings.integrations.oura.scope.items.vo2max",
// Brand / product / proper nouns.
"settings.googleHealth",
// Garmin's own names: the Body Battery score and the Personal API product,
// which Garmin Connect shows under the same English name in every locale.
"measurements.typeBodyBattery",
"settings.garminTag",
"admin.glitchtipDsn",
"admin.integrationWebPush",
"admin.offlineGeoEnabled",
@@ -158,6 +158,7 @@ describe("record settings contract", () => {
"google-health": false,
strava: false,
dawarich: false,
garmin: false,
} as const;
expect(
@@ -189,6 +190,7 @@ describe("record settings contract", () => {
"google-health": false,
strava: false,
dawarich: false,
garmin: false,
} as const;
expect(
@@ -417,6 +417,10 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/integrations/nightscout-card.tsx": {
"text-success": 1,
},
// The connect form's "connected" line, the Nightscout form's twin.
"src/components/settings/integrations/garmin-card.tsx": {
"text-success": 1,
},
// A paused connection resumed.
"src/components/settings/integrations/oauth-integration-card.tsx": {
"text-success": 1,
@@ -115,6 +115,15 @@ const PROVIDER_INGESTS: ProviderIngest[] = [
writeFile: "lib/import/health-connect/import.ts",
metadataKey: "healthConnectExerciseType",
},
{
provider: "Garmin",
mapper: "mapGarminSportType",
mapperFile: "lib/garmin/sport-map.ts",
captureFile: "lib/garmin/sync-workouts.ts",
captureField: "a.activityType?.typeKey",
writeFile: "lib/garmin/sync-workouts.ts",
metadataKey: "garminActivity",
},
{
provider: "Oura",
mapper: "mapOuraSportType",
@@ -663,6 +663,13 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
state: GOOGLE_HEALTH_STATE_EXCEPTION,
},
},
{
id: "garmin",
path: join(ROOT, "src/app/api/garmin/sync/route.ts"),
responseClass: "resolved",
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
exceptions: {},
},
{
id: "nightscout",
path: join(ROOT, "src/app/api/nightscout/sync/route.ts"),