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"),
+161
View File
@@ -0,0 +1,161 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markReconnected } from "@/lib/integrations/status";
import { GARMIN_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
import { GARMIN_BACKFILL_QUEUE } from "@/lib/jobs/garmin-backfill-queue";
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready";
import {
clearGarminTokenCache,
exchangeGarminKey,
GARMIN_REGIONS,
GarminApiError,
type GarminRegion,
type GarminToken,
} from "@/lib/garmin/client";
import {
estimateGarminKeyExpiry,
resolveGarminKeyExpiry,
} from "@/lib/garmin/key-expiry";
import { garminConnectSchema } from "@/lib/validations/garmin";
/**
* Connect (or replace) the caller's Garmin Personal API key (#1333).
*
* The key is validated by buying a token with it before anything is stored:
* a mistyped, revoked or expired key answers here, not on the first cron
* tick. Garmin runs a separate service for accounts in China; a key the
* global service refuses is tried there once, and the region that accepted it
* is kept so the sync talks to the right host.
*
* On success the key is sealed at rest, the expiry is noted (Garmin's own
* when the token reveals it, else the validity the person picked), the
* history backfill is reopened, the `garmin` ledger is reset to connected,
* and a first sync plus the backfill are sent best-effort. The key is never
* logged, audited or echoed: the audit row carries only the region.
*
* Rate-limited 10/60 s per user: every attempt is an outbound exchange.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "garmin.connect" } });
const rl = await checkRateLimit(`garmin-connect:${user.id}`, 10, 60_000);
if (!rl.allowed) {
return apiError("Too many connection attempts", 429, {
headers: rateLimitHeaders(rl),
});
}
const parsed = await parseJsonBody(request, garminConnectSchema, {
maxBytes: 16 * 1024,
message: "A valid Garmin Personal API key is required",
});
if (!parsed.ok) return parsed.error;
const { apiKey, validityDays } = parsed.data;
let token: GarminToken | null = null;
let region: GarminRegion | null = null;
let lastError: unknown = null;
for (const candidate of GARMIN_REGIONS) {
try {
token = await exchangeGarminKey(apiKey, candidate);
region = candidate;
break;
} catch (err) {
lastError = err;
// Only a refused key is worth asking the other service about; an
// outage or a rate limit would answer the same there.
if (!(err instanceof GarminApiError && err.keyRefused)) break;
}
}
if (!token || !region) {
annotate({
meta: {
"garmin.connect.refused":
lastError instanceof GarminApiError ? lastError.reason : "network",
},
});
if (lastError instanceof GarminApiError && lastError.keyRefused) {
return apiError(
"Garmin did not accept this key. Check that it was copied whole and has not expired or been revoked.",
422,
{ errorCode: "garmin.key_refused" },
);
}
if (lastError instanceof GarminApiError && lastError.httpStatus === 429) {
return apiError(
"Garmin is limiting requests right now. Try again in a few minutes.",
422,
{ errorCode: "garmin.rate_limited" },
);
}
return apiError(
"Could not reach Garmin. Try again in a few minutes.",
422,
{ errorCode: "garmin.unreachable" },
);
}
const connectedAt = new Date();
const keyExpiresAt =
token.keyExpiresAt !== null
? new Date(token.keyExpiresAt)
: validityDays
? estimateGarminKeyExpiry(connectedAt, validityDays)
: null;
await prisma.user.update({
where: { id: user.id },
data: {
// Field by field; never the parsed body.
garminApiKeyEncrypted: encrypt(apiKey),
garminRegion: region,
garminConnectedAt: connectedAt,
garminKeyExpiresAt: keyExpiresAt,
// A new key reopens the history walk from today.
garminBackfillProgress: Prisma.DbNull,
garminBackfillCompletedAt: null,
},
});
clearGarminTokenCache(user.id);
await markReconnected(user.id, "garmin");
await auditLog("garmin.connect", { userId: user.id, details: { region } });
await enqueueWhenBossReady("garmin-sync", async (boss) => {
await boss
.send(GARMIN_SYNC_QUEUE, { userId: user.id })
.catch((err) =>
getEvent()?.addWarning(`garmin-sync connect enqueue failed: ${err}`),
);
await boss
.send(
GARMIN_BACKFILL_QUEUE,
{ userId: user.id, enqueuedAt: new Date().toISOString() },
integrationBackfillSourceOptions(`garmin-backfill|${user.id}`),
)
.catch((err) =>
getEvent()?.addWarning(
`garmin-backfill connect enqueue failed: ${err}`,
),
);
});
return apiSuccess({
connected: true,
region,
keyExpiry: resolveGarminKeyExpiry(keyExpiresAt),
});
});
+55
View File
@@ -0,0 +1,55 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markDisconnected } from "@/lib/integrations/status";
import { clearGarminTokenCache } from "@/lib/garmin/client";
/**
* Disconnect the caller's Garmin key.
*
* Clears the sealed key and everything stored about it (region, expiry,
* backfill progress) and parks the `garmin` ledger at `disconnected`. Rows
* already imported stay: disconnecting stops future syncs, it does not
* delete history. The key stays valid at Garmin until it is revoked there.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "garmin.disconnect" } });
const rl = await checkRateLimit(`garmin-disconnect:${user.id}`, 20, 60_000);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { garminApiKeyEncrypted: true },
});
if (!dbUser?.garminApiKeyEncrypted) {
return apiError("No Garmin connection", 404);
}
await prisma.user.update({
where: { id: user.id },
data: {
garminApiKeyEncrypted: null,
garminRegion: null,
garminConnectedAt: null,
garminKeyExpiresAt: null,
garminBackfillProgress: Prisma.DbNull,
garminBackfillCompletedAt: null,
},
});
clearGarminTokenCache(user.id);
await auditLog("garmin.disconnect", { userId: user.id });
await markDisconnected(user.id, "garmin");
return apiSuccess({ disconnected: true });
});
+76
View File
@@ -0,0 +1,76 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { getIntegrationStatus } from "@/lib/integrations/status";
import {
INTEGRATION_CADENCE,
resolveSyncVerdict,
} from "@/lib/integrations/sync-verdict";
import { resolveGarminKeyExpiry } from "@/lib/garmin/key-expiry";
import {
garminBackfillShare,
readGarminBackfillProgress,
} from "@/lib/garmin/backfill-progress";
import { isGarminRegion } from "@/lib/garmin/client";
/**
* Garmin connection status for the current user.
*
* `configured` is whether a key is stored. The ledger snapshot and the
* server-resolved health come off the shared `garmin` IntegrationKey, the
* same as every sibling. `keyExpiry` is the one Garmin-specific fact: when
* the key stops working, and whether that is close, resolved here so web and
* iOS warn alike. The key itself is never returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "garmin.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
garminApiKeyEncrypted: true,
garminRegion: true,
garminKeyExpiresAt: true,
garminBackfillProgress: true,
garminBackfillCompletedAt: true,
},
});
if (!dbUser?.garminApiKeyEncrypted) {
return apiSuccess({ connected: false, configured: false });
}
const status = await getIntegrationStatus(user.id, "garmin");
return apiSuccess({
connected: true,
configured: true,
region: isGarminRegion(dbUser.garminRegion)
? dbUser.garminRegion
: "global",
keyExpiry: resolveGarminKeyExpiry(dbUser.garminKeyExpiresAt),
backfill: {
complete: dbUser.garminBackfillCompletedAt !== null,
share: dbUser.garminBackfillCompletedAt
? 1
: garminBackfillShare(
readGarminBackfillProgress(dbUser.garminBackfillProgress),
),
},
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
syncHealth: resolveSyncVerdict({
connected: true,
configured: true,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
failingSinceAt: status.failingSince,
cadence: INTEGRATION_CADENCE["garmin"],
}),
health: await loadOneConnectionHealth(user.id, "garmin"),
});
});
+43
View File
@@ -0,0 +1,43 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserGarmin } from "@/lib/garmin/sync";
import { isGarminKeyRefusal } from "@/lib/garmin/client";
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
/**
* Manually trigger a Garmin sync for the current user.
*
* Reads the recent window of every resource; the history is the backfill's.
* `imported` counts rows written this run and `outcome` says how that count
* reads. No body. The error arm answers with a fixed message: the classified
* failure is already on the `garmin` ledger.
*
* Rate-limited 5/60 s per user, like the sibling sync routes.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "garmin.sync" } });
const rl = await checkRateLimit(`garmin-sync:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many sync requests", 429, {
errorCode: "rate_limited_self",
});
}
try {
const result = await syncUserGarmin(user.id);
return apiSuccess(resolveSyncOutcome(result));
} catch (error) {
if (isGarminKeyRefusal(error)) {
return apiError(
"Garmin no longer accepts the stored key. Create a new one and connect again.",
422,
{ errorCode: "garmin.key_refused" },
);
}
return apiError("Garmin sync failed", 502);
}
});
+75
View File
@@ -0,0 +1,75 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { getGarminCredentials } from "@/lib/garmin/credentials";
import { exchangeGarminKey, GarminApiError } from "@/lib/garmin/client";
import { SafeFetchError } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
/**
* Cheap live probe of the stored Garmin key: buy one token with it,
* through the same exchange the sync uses. Nothing is written; the token is
* dropped.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "garmin.test" } });
const rl = await checkRateLimit(`garmin-test:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many test requests", 429, {
errorCode: "rate_limited_self",
});
}
const creds = await getGarminCredentials(user.id);
if (!creds) {
return apiError("Garmin not connected", 422, {
errorCode: "not_configured",
});
}
const start = performance.now();
try {
await exchangeGarminKey(creds.apiKey, creds.region);
return apiSuccess({
ok: true,
latencyMs: Math.round(performance.now() - start),
});
} catch (e) {
if (e instanceof GarminApiError) {
const code = e.keyRefused
? "credentials_rejected"
: e.httpStatus === 429
? "rate_limited"
: e.httpStatus !== undefined && e.httpStatus >= 500
? "upstream_error"
: "connection_failed";
annotate({
meta: { garmin_test_status: e.httpStatus, garmin_test_code: code },
});
return apiError(
code === "credentials_rejected"
? "Garmin rejected the key"
: code === "rate_limited"
? "Garmin rate-limited the request"
: code === "upstream_error"
? "Garmin returned an error"
: "Garmin connection failed",
502,
{ errorCode: code },
);
}
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
annotate({
meta: { garmin_test_code: isTimeout ? "timeout" : "connection_failed" },
});
return apiError(
isTimeout ? "Garmin request timed out" : "Garmin connection failed",
502,
{ errorCode: isTimeout ? "timeout" : "connection_failed" },
);
}
});
@@ -62,6 +62,10 @@ const METRIC_MODULE: Partial<Record<MetricStatusMetricId, ModuleKey>> = {
DAY_STRAIN: "recovery",
WORKOUT_STRAIN: "recovery",
CARDIO_LOAD: "recovery",
BODY_BATTERY: "recovery",
STRESS_LEVEL: "recovery",
TRAINING_READINESS: "recovery",
ACUTE_TRAINING_LOAD: "recovery",
AVERAGE_HEART_RATE: "recovery",
MAX_HEART_RATE: "recovery",
ENERGY_EXPENDITURE_KJ: "recovery",
@@ -340,7 +340,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
expect(envelope.metricFreshnessDegraded).toBe(true);
// Still fail-soft: the rest of the envelope survives, all eight entries
// present, because a freshness hiccup is never worth the settings surface.
expect(envelope.integrations).toHaveLength(9);
expect(envelope.integrations).toHaveLength(10);
for (const entry of envelope.integrations) {
expect(entry.metricFreshness).toEqual([]);
}
@@ -472,6 +472,7 @@ describe("/api/integrations/status — the unified connection health", () => {
strava: null,
nightscout: null,
dawarich: null,
garmin: null,
"apple-health": null,
} as never);
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
+86
View File
@@ -15,6 +15,15 @@
* callers; the web cards no longer hit them.
*/
import { prisma } from "@/lib/db";
import { isGarminRegion, type GarminRegion } from "@/lib/garmin/client";
import {
resolveGarminKeyExpiry,
type GarminKeyExpiry,
} from "@/lib/garmin/key-expiry";
import {
garminBackfillShare,
readGarminBackfillProgress,
} from "@/lib/garmin/backfill-progress";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -83,6 +92,7 @@ export const GET = apiHandler(async () => {
stravaStatus,
nightscoutStatus,
dawarichStatus,
garminStatus,
dbUser,
withingsConn,
whoopConn,
@@ -108,6 +118,7 @@ export const GET = apiHandler(async () => {
getIntegrationStatus(user.id, "strava"),
getIntegrationStatus(user.id, "nightscout"),
getIntegrationStatus(user.id, "dawarich"),
getIntegrationStatus(user.id, "garmin"),
prisma.user.findUnique({
where: { id: user.id },
select: {
@@ -135,6 +146,12 @@ export const GET = apiHandler(async () => {
nightscoutTokenEncrypted: true,
nightscoutAllowPrivateHost: true,
dawarichUrlEncrypted: true,
// Garmin: the connect marker and the facts its card warns from.
garminApiKeyEncrypted: true,
garminRegion: true,
garminKeyExpiresAt: true,
garminBackfillProgress: true,
garminBackfillCompletedAt: true,
},
}),
prisma.withingsConnection.findUnique({
@@ -194,6 +211,7 @@ export const GET = apiHandler(async () => {
"strava",
"nightscout",
"dawarich",
"garmin",
]),
// See `metricFreshnessDegraded` below.
getSourceMetricFreshness(user.id).catch(() => {
@@ -319,6 +337,7 @@ export const GET = apiHandler(async () => {
const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted;
const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted;
const dawarichConfigured = !!dbUser?.dawarichUrlEncrypted;
const garmin = garminExtras(dbUser);
if (metricFreshnessDegraded) {
annotate({ meta: { integrations_metric_freshness_degraded: true } });
@@ -512,6 +531,20 @@ export const GET = apiHandler(async () => {
configured: dawarichConfigured,
}),
} satisfies IntegrationViewModel & DawarichExtras,
{
...publish(garminStatus),
connected: garmin.connected,
configured: garmin.configured,
region: garmin.region,
keyExpiry: garmin.keyExpiry,
backfillCompleted: garmin.backfillCompleted,
backfillShare: garmin.backfillShare,
health: health["garmin"],
...resolve("garmin", garminStatus, {
connected: garmin.connected,
configured: garmin.configured,
}),
} satisfies IntegrationViewModel & GarminExtras,
],
});
});
@@ -617,6 +650,59 @@ interface NightscoutExtras {
allowPrivateHost: boolean;
}
// Garmin: the connect marker plus the key's expiry, the region its
// key was accepted on, and how far the history backfill has got.
interface GarminExtras {
connected: boolean;
configured: boolean;
region: GarminRegion | null;
keyExpiry: GarminKeyExpiry | null;
backfillCompleted: boolean | null;
backfillShare: number | null;
}
/**
* The Garmin entry's own facts. A Personal API key the person pastes once is
* the connect marker; the key is never on the wire, only when it expires,
* resolved here so every client warns alike.
*/
function garminExtras(
dbUser: {
garminApiKeyEncrypted: string | null;
garminRegion: string | null;
garminKeyExpiresAt: Date | null;
garminBackfillProgress: unknown;
garminBackfillCompletedAt: Date | null;
} | null,
): GarminExtras {
const configured = !!dbUser?.garminApiKeyEncrypted;
if (!dbUser || !configured) {
return {
connected: false,
configured: false,
region: null,
keyExpiry: null,
backfillCompleted: null,
backfillShare: null,
};
}
const complete = dbUser.garminBackfillCompletedAt !== null;
return {
connected: true,
configured: true,
region: isGarminRegion(dbUser.garminRegion)
? dbUser.garminRegion
: "global",
keyExpiry: resolveGarminKeyExpiry(dbUser.garminKeyExpiresAt),
backfillCompleted: complete,
backfillShare: complete
? 1
: garminBackfillShare(
readGarminBackfillProgress(dbUser.garminBackfillProgress),
),
};
}
// v1.43 — Dawarich: the connect marker only; the card reads `health` and its
// own status route.
interface DawarichExtras {
@@ -87,6 +87,9 @@ export const GET = apiHandler(async () => {
// Not offered on a managed profile (no managed integrations before
// v1.44); listed so the map stays total.
dawarich: false,
// Garmin is a key the record's owner pastes on their own settings page;
// a managed profile has none to offer.
garmin: false,
};
// The one server-resolved state per connection, from the same loader the
// owner's own envelope uses, so a guardian reads the record's connections in
@@ -117,6 +117,10 @@ const GENERIC_METRIC_SCOPE_EXPECTATION: Readonly<
AVERAGE_HEART_RATE: null,
BREATHING_DISTURBANCES: null,
CARDIO_LOAD: null,
BODY_BATTERY: null,
STRESS_LEVEL: null,
TRAINING_READINESS: null,
ACUTE_TRAINING_LOAD: null,
CARDIO_RECOVERY: null,
DAY_STRAIN: null,
ENERGY_EXPENDITURE_KJ: null,
@@ -177,6 +177,10 @@ const STATUS_METRIC_SCOPE_SOURCE = {
DAY_STRAIN: null,
WORKOUT_STRAIN: null,
CARDIO_LOAD: null,
BODY_BATTERY: null,
STRESS_LEVEL: null,
TRAINING_READINESS: null,
ACUTE_TRAINING_LOAD: null,
AVERAGE_HEART_RATE: null,
MAX_HEART_RATE: null,
ENERGY_EXPENDITURE_KJ: null,
@@ -58,46 +58,17 @@ const EMPTY_COPY = {
} as const satisfies Record<RecoveryEmptyReason, string>;
/**
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
* sub-page.
*
* The page now reads exactly like every other metric page: each present
* device-native signal (WHOOP / Polar / Oura recharge, strain, load, and
* whole-day cardiac / energy figures) renders as a canonical block — a
* short explanatory line, the max / median / mean stat strip, the same
* `<HealthChartDynamic>` chart with the identical 7 / 30 / 90 / All toggle,
* and a per-chart AI assessment (`<MetricStatusCard>`). The composite
* RECOVERY_SCORE keeps its anatomy view at `/insights/scores/recovery`.
*
* B3 — the redundant "Recovery score" link block that used to lead the
* page is gone: this surface is reached from the overview already, so the
* cross-link was duplicate ("doppelt gemoppelt").
*
* Each block is data-gated (a signal with zero readings is dropped) and a
* group with no present block hides entirely, so a non-wearable account
* sees only the calm empty note. Reads the shared analytics summaries slice
* (no extra round-trip); server-authoritative throughout.
* The page's chart blocks, grouped: recharge, strain, cardio. A block only
* renders when its summary has data, so a provider-specific type costs
* nothing on an account without that provider.
*/
export function RecoverySection() {
const { t } = useTranslations();
const { data, isLoading, error, refetch } =
useInsightsAnalytics("SLEEP_DURATION");
// v1.18.1 — Rest Mode parity. The annotation rides the thick analytics
// payload's `healthScore` block (server-authoritative; iOS mirrors it). We
// read it off the same `["analytics"]` cache the insights mother page
// primes, so navigating in is a free hit and a direct URL load pays one
// thick fetch. Read-only — the score itself stays untouched.
const analyticsQuery = useAnalyticsQuery();
const restMode = ((
analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null
)?.restMode ?? null) as RestModeAnnotation | null;
const summaries = data?.summaries;
function recoveryBlockGroups(
t: ReturnType<typeof useTranslations>["t"],
): BlockGroup[] {
const unitScore = t("insights.deviceScore.unitScore");
const unitBpm = t("insights.deviceScore.unitBpm");
const unitKj = t("insights.deviceScore.unitKj");
const groups: BlockGroup[] = [
return [
{
key: "recharge",
blocks: [
@@ -112,6 +83,36 @@ export function RecoverySection() {
unit: unitScore,
fractionDigits: 1,
},
{
type: "BODY_BATTERY",
chartKey: "bodyBattery",
statusMetric: "BODY_BATTERY",
title: t("measurements.typeBodyBattery"),
explainer: t("insights.recovery.block.bodyBatteryExplainer"),
icon: Battery,
color: "var(--chart-2)",
unit: unitScore,
},
{
type: "TRAINING_READINESS",
chartKey: "trainingReadiness",
statusMetric: "TRAINING_READINESS",
title: t("measurements.typeTrainingReadiness"),
explainer: t("insights.recovery.block.trainingReadinessExplainer"),
icon: HeartPulse,
color: "var(--chart-4)",
unit: unitScore,
},
{
type: "STRESS_LEVEL",
chartKey: "stressLevel",
statusMetric: "STRESS_LEVEL",
title: t("measurements.typeStressLevel"),
explainer: t("insights.recovery.block.stressLevelExplainer"),
icon: Gauge,
color: "var(--chart-3)",
unit: unitScore,
},
],
},
{
@@ -149,6 +150,16 @@ export function RecoverySection() {
color: "var(--chart-1)",
unit: unitScore,
},
{
type: "ACUTE_TRAINING_LOAD",
chartKey: "acuteTrainingLoad",
statusMetric: "ACUTE_TRAINING_LOAD",
title: t("measurements.typeAcuteTrainingLoad"),
explainer: t("insights.recovery.block.acuteTrainingLoadExplainer"),
icon: TrendingUp,
color: "var(--chart-5)",
unit: t("insights.deviceScore.unitLoad"),
},
],
},
{
@@ -187,6 +198,45 @@ export function RecoverySection() {
],
},
];
}
/**
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
* sub-page.
*
* The page now reads exactly like every other metric page: each present
* device-native signal (WHOOP / Polar / Oura recharge, strain, load, and
* whole-day cardiac / energy figures) renders as a canonical block — a
* short explanatory line, the max / median / mean stat strip, the same
* `<HealthChartDynamic>` chart with the identical 7 / 30 / 90 / All toggle,
* and a per-chart AI assessment (`<MetricStatusCard>`). The composite
* RECOVERY_SCORE keeps its anatomy view at `/insights/scores/recovery`.
*
* B3 — the redundant "Recovery score" link block that used to lead the
* page is gone: this surface is reached from the overview already, so the
* cross-link was duplicate ("doppelt gemoppelt").
*
* Each block is data-gated (a signal with zero readings is dropped) and a
* group with no present block hides entirely, so a non-wearable account
* sees only the calm empty note. Reads the shared analytics summaries slice
* (no extra round-trip); server-authoritative throughout.
*/
export function RecoverySection() {
const { t } = useTranslations();
const { data, isLoading, error, refetch } =
useInsightsAnalytics("SLEEP_DURATION");
// v1.18.1 — Rest Mode parity. The annotation rides the thick analytics
// payload's `healthScore` block (server-authoritative; iOS mirrors it). We
// read it off the same `["analytics"]` cache the insights mother page
// primes, so navigating in is a free hit and a direct URL load pays one
// thick fetch. Read-only — the score itself stays untouched.
const analyticsQuery = useAnalyticsQuery();
const restMode = ((
analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null
)?.restMode ?? null) as RestModeAnnotation | null;
const summaries = data?.summaries;
const groups = recoveryBlockGroups(t);
// Whether the page has anything to show, and why not, is the server's
// answer (`recoveryEmpty`), so web and iOS read one decision.
@@ -18,6 +18,7 @@ import {
Scale,
Heart,
Activity,
Battery,
Droplets,
Droplet,
Moon,
@@ -142,6 +143,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
INDOOR_CO2: "measurements.typeIndoorCo2",
INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature",
INDOOR_HUMIDITY: "measurements.typeIndoorHumidity",
BODY_BATTERY: "measurements.typeBodyBattery",
STRESS_LEVEL: "measurements.typeStressLevel",
TRAINING_READINESS: "measurements.typeTrainingReadiness",
ACUTE_TRAINING_LOAD: "measurements.typeAcuteTrainingLoad",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -289,6 +294,11 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
INDOOR_CO2: Wind,
INDOOR_TEMPERATURE: Thermometer,
INDOOR_HUMIDITY: Droplets,
// Garmin scores, read beside the other device-native scores.
BODY_BATTERY: Battery,
STRESS_LEVEL: Gauge,
TRAINING_READINESS: HeartPulse,
ACUTE_TRAINING_LOAD: TrendingUp,
};
// The colour map lives in its own icon-free module so a surface that only
@@ -142,4 +142,10 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
INDOOR_CO2: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
INDOOR_TEMPERATURE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
INDOOR_HUMIDITY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
// Garmin scores take the device-native score family (chart-1),
// like ANS charge and cardio load.
BODY_BATTERY: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
STRESS_LEVEL: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
TRAINING_READINESS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
ACUTE_TRAINING_LOAD: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
};
@@ -102,6 +102,7 @@ const PROVIDERS = [
"oura",
"strava",
"nightscout",
"garmin",
] as const;
function health(overrides: Record<string, unknown> = {}) {
@@ -176,7 +177,7 @@ describe("IntegrationsSection — one status display per card", () => {
expect(html).not.toContain("Last attempt");
// The seven other providers and Apple Health are not connected and say so
// once each.
expect(count(html, ">Not connected.<")).toBe(8);
expect(count(html, ">Not connected.<")).toBe(9);
});
it("never shows the stored provider error or a failure counter", () => {
@@ -247,17 +248,12 @@ describe("IntegrationsSection — one status display per card", () => {
});
const html = render();
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8);
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(9);
for (const provider of PROVIDERS) {
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
expect(html).toContain(
`href="https://docs.healthlog.dev/guides/connect-${provider}/"`,
);
}
// Garmin has no connector; its note goes straight to the section of the
// Google Health guide that explains the route, not through a redirect.
expect(html).toContain(
'href="https://docs.healthlog.dev/guides/connect-google-health/#garmin-owners"',
);
});
});
@@ -194,9 +194,9 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "medications")).toBe("0/5");
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
expect(groupChip(html, "history")).toBe("0/5");
expect(groupChip(html, "cardio")).toBe("0/15");
expect(groupChip(html, "cardio")).toBe("0/16");
expect(groupChip(html, "activity")).toBe("0/12");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
expect(groupChip(html, "sleepRecovery")).toBe("0/19");
expect(groupChip(html, "mobility")).toBe("0/11");
expect(groupChip(html, "environment")).toBe("0/7");
});
@@ -87,10 +87,13 @@ describe("structural guard: cards read health, not the retired fields", () => {
/**
* `.state` is also the name of `health.state` and of a Google Health sync
* run's progress state. Those bases are the allowed readers; any other base
* reading `.state` is a ledger state finding its way back onto a card.
* run's progress state, and of the Garmin key's expiry state (a server-
* resolved fact of its own, not a ledger state). Those bases are the
* allowed readers; any other base reading `.state` is a ledger state
* finding its way back onto a card.
*/
const STATE_READERS = /(^|\.)(health|progress|syncProgress|record)$/;
const STATE_READERS =
/(^|\.)(health|progress|syncProgress|record|keyExpiry)$/;
const files = [
...walkSourceFiles(join(SRC, CARDS), { floor: 15 })
@@ -2,7 +2,7 @@
/**
* `<ConnectionsPanel>` — the connected-services half of Settings →
* Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Nightscout.
* Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Garmin / Nightscout.
*
* v1.18.0 — extracted out of `integrations-section.tsx` so the section
* file can host the Connections / Channels / Sources sub-tabs without growing
@@ -20,7 +20,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
import { DawarichCard } from "@/components/settings/integrations/dawarich-card";
import { FitbitRetirementNotice } from "@/components/settings/integrations/fitbit-retirement-notice";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GarminCard } from "@/components/settings/integrations/garmin-card";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
@@ -203,6 +203,7 @@ const CARD_BACKED_INTEGRATIONS: ReadonlySet<IntegrationKey> = new Set([
"strava",
"nightscout",
"dawarich",
"garmin",
]);
export function ConnectionsPanel({
@@ -403,6 +404,9 @@ export function ConnectionsPanel({
callbackUrl={callbackUrls.strava}
/>
</div>
<div id="garmin" className="scroll-mt-28">
<GarminCard viewModel={pickStatus(integrationStatus, "garmin")} />
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard
viewModel={pickStatus(integrationStatus, "nightscout")}
@@ -426,12 +430,6 @@ export function ConnectionsPanel({
<IntegrationFallbackRow status={entry} />
</div>
))}
{/* Garmin: no direct connector (business-partner-only). A quiet
non-connector note pointing to the Apple Health / Health Connect
path — not an OAuthProviderCard (nothing to connect). */}
<div id="garmin" className="scroll-mt-28">
<GarminInfoNote />
</div>
</div>
);
}
@@ -0,0 +1,412 @@
"use client";
// Garmin, through the Personal API (#1333). Like Nightscout this is a
// credential the person pastes once rather than an OAuth round-trip: they
// create a key in Garmin Connect and HealthLog buys short-lived tokens with
// it on every sync. The card reads the consolidated `/api/integrations/status`
// envelope like every sibling and renders the shared integration card. The
// one Garmin-specific fact it adds is the key's end: Garmin keys expire after
// the validity picked at creation, and the card says so ahead of time.
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
BookOpen,
ExternalLink,
KeyRound,
Link2,
Loader2,
Plug,
RefreshCw,
Save,
Unlink,
Watch,
} from "lucide-react";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { TagChip } from "@/components/ui/tag-chip";
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
import {
GARMIN_KEY_VALIDITY_DAYS,
type GarminKeyExpiry,
} from "@/lib/garmin/key-expiry";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
ActionResultLines,
useConnectionTest,
useStandardSync,
} from "./integration-actions";
import {
IntegrationCard,
type IntegrationMenuAction,
} from "./integration-card";
import { MetricFreshnessList } from "./metric-freshness-list";
import {
IntegrationCardDescription,
integrationDocsHref,
} from "./setup-guide-link";
import { failureStreakFor, type IntegrationStatusViewModel } from "./shared";
/** Where Garmin Connect creates and revokes Personal API keys. */
export const GARMIN_KEY_SETTINGS_URL =
"https://connect.garmin.com/app/settings/personalApiToken";
export function GarminCard({
viewModel,
}: {
viewModel?: IntegrationStatusViewModel;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const status = viewModel;
const connected = status?.connected ?? false;
const sync = useStandardSync({
provider: "garmin",
i18nPrefix: "settings.garmin",
acceptsFullSync: false,
});
const test = useConnectionTest("/api/garmin/test");
const [editing, setEditing] = useState(false);
const formRef = useRef<HTMLFormElement | null>(null);
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/garmin/disconnect");
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
// An expired or revoked key is resolved by pasting a new one: the button
// opens the form and puts the cursor in the key field.
function openForm() {
setEditing(true);
requestAnimationFrame(() => {
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
formRef.current
?.querySelector<HTMLInputElement>("#garmin-api-key")
?.focus();
});
}
const form = <GarminForm formRef={formRef} connected={connected} />;
if (!connected) {
return (
<IntegrationCard
provider="garmin"
providerName={t("settings.garmin")}
icon={Watch}
titleAccessory={<TagChip>{t("settings.garminTag")}</TagChip>}
description={
<IntegrationCardDescription
i18nPrefix="settings.garmin"
provider="garmin"
/>
}
loading={viewModel === undefined}
connected={false}
health={null}
setup={form}
/>
);
}
const keyExpiry = status?.keyExpiry ?? null;
const expiryNote = garminKeyExpiryNote(keyExpiry, t, fmt.date);
const menu: IntegrationMenuAction[] = [
{
id: "sync",
label: t("settings.garminSync"),
icon: RefreshCw,
disabled: sync.syncing,
onSelect: () => sync.run(false),
},
{
id: "test",
label: t("settings.testConnection.test"),
icon: Plug,
disabled: test.testing,
onSelect: test.run,
},
{
id: "view-data",
label: t("settings.garminViewData"),
icon: ArrowRight,
href: "/insights/recovery",
},
{
id: "credentials",
label: t("settings.garminReplaceKey"),
icon: KeyRound,
onSelect: () => setEditing((open) => !open),
},
{
id: "setup-guide",
label: t("settings.integrationSetupGuide"),
icon: BookOpen,
href: integrationDocsHref("garmin"),
external: true,
},
{
id: "disconnect",
label: t("settings.garminDisconnect"),
icon: Unlink,
destructive: true,
onSelect: () => disconnect.mutate(),
confirm: {
title: t("settings.garminDisconnectTitle"),
body: t("settings.garminDisconnectDescription"),
confirmLabel: t("settings.garminDisconnect"),
},
},
];
const streak = failureStreakFor(status);
return (
<IntegrationCard
provider="garmin"
providerName={t("settings.garmin")}
icon={Watch}
titleAccessory={<TagChip>{t("settings.garminTag")}</TagChip>}
connected
health={status?.health ?? null}
historyLoading={status?.backfillCompleted === false}
primary={{ onReconnect: openForm, onResume: openForm }}
menu={menu}
diagnosticsExtra={
streak
? [
t("settings.integrationCard.details.failureStreak", {
count: streak.count,
threshold: streak.threshold,
}),
]
: undefined
}
details={
<>
{keyExpiry?.state === "ok" && keyExpiry.expiresAt ? (
<p
className="text-muted-foreground text-xs"
data-testid="garmin-key-valid-until"
>
{t("settings.garminKeyValidUntil", {
date: fmt.date(keyExpiry.expiresAt),
})}
</p>
) : null}
{status?.metricFreshness?.length ? (
<MetricFreshnessList entries={status.metricFreshness} />
) : null}
</>
}
>
{expiryNote ? (
// The one fact the generic sentence cannot carry: the key has an end
// date the person chose, and only a new key moves it. Said in the
// body, not as a banner, like Nightscout's operator note.
<p
className="text-sm"
data-testid="garmin-key-expiry"
data-state={keyExpiry?.state}
>
{expiryNote}
</p>
) : null}
<ActionResultLines provider="garmin" sync={sync} test={test} />
{editing ? form : null}
</IntegrationCard>
);
}
/**
* The body sentence for a key that has expired, is about to, or whose end is
* unknown; null while it is comfortably valid.
*/
export function garminKeyExpiryNote(
keyExpiry: GarminKeyExpiry | null | undefined,
t: ReturnType<typeof useTranslations>["t"],
formatDate: (iso: string) => string,
): string | null {
if (!keyExpiry) return null;
const { expiresAt, daysLeft } = keyExpiry;
switch (keyExpiry.state) {
case "expired":
return t("settings.garminKeyExpired", {
date: expiresAt ? formatDate(expiresAt) : "",
});
case "expiring":
return daysLeft === 0
? t("settings.garminKeyExpiresToday")
: t("settings.garminKeyExpiring", {
date: expiresAt ? formatDate(expiresAt) : "",
});
case "unknown":
return t("settings.garminKeyExpiryUnknown");
default:
return null;
}
}
function GarminForm({
formRef,
connected,
}: {
formRef: React.RefObject<HTMLFormElement | null>;
connected: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [apiKey, setApiKey] = useState("");
const [validity, setValidity] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{
kind: "success" | "error";
text: string;
} | null>(null);
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
try {
const res = await apiFetchRaw("/api/garmin/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKey: apiKey.trim(),
validityDays: validity ? Number(validity) : null,
}),
});
if (res.ok) {
setMsg({ kind: "success", text: t("settings.garminConnected") });
setApiKey("");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
let text = t("settings.garminConnectFailed");
try {
const json = await res.json();
if (json.error) text = json.error;
} catch {
// keep the generic message
}
setMsg({ kind: "error", text });
}
} catch {
setMsg({ kind: "error", text: t("common.networkError") });
}
setSaving(false);
}
return (
<form
ref={formRef}
onSubmit={handleConnect}
className="space-y-3"
data-testid="garmin-form"
>
<p className="text-muted-foreground text-xs">
{t("settings.garminKeyHowTo")}{" "}
<a
href={GARMIN_KEY_SETTINGS_URL}
target="_blank"
rel="noopener noreferrer"
data-testid="garmin-key-settings-link"
className="hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
>
{t("settings.garminKeySettingsLink")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</p>
<div className="space-y-1.5">
<Label htmlFor="garmin-api-key">{t("settings.garminApiKey")}</Label>
<PasswordInput
id="garmin-api-key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
connected
? t("settings.garminApiKeySavedPlaceholder")
: t("settings.garminApiKeyPlaceholder")
}
maxLength={1024}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="garmin-key-validity">
{t("settings.garminKeyValidity")}
</Label>
<NativeSelect
id="garmin-key-validity"
value={validity}
onChange={(e) => setValidity(e.target.value)}
>
<option value="">{t("settings.garminKeyValidityUnknown")}</option>
{GARMIN_KEY_VALIDITY_DAYS.map((days) => (
<option key={days} value={String(days)}>
{t("settings.garminKeyValidityDays", { count: days })}
</option>
))}
</NativeSelect>
<p className="text-muted-foreground text-xs">
{t("settings.garminKeyValidityHelp")}
</p>
</div>
{msg && (
<p
role={msg.kind === "error" ? "alert" : "status"}
className={`text-sm ${msg.kind === "success" ? "text-success" : "text-destructive"}`}
>
{msg.text}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
variant={connected ? "outline" : "default"}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
disabled={saving || !apiKey.trim()}
data-slot={connected ? undefined : "integration-primary-action"}
data-action={connected ? undefined : "connect"}
data-testid="garmin-connect"
>
{saving ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
) : connected ? (
<Save className="size-3.5" aria-hidden="true" />
) : (
<Link2 className="size-3.5" aria-hidden="true" />
)}
{connected ? t("settings.garminUpdate") : t("settings.garminConnect")}
</Button>
</SettingsCardActions>
</form>
);
}
@@ -1,43 +0,0 @@
"use client";
// v1.28.x — Garmin is NOT a direct connector: Garmin's developer program is
// business-partner-only (approval-gated, needs public callbacks), so the
// self-hosted BYO model cannot register a paste-your-key Garmin app. The honest
// path is Apple Health (iOS) or Google Health Connect (Android), which already
// works with zero extra code. This quiet, non-connector info note pre-empts the
// recurring "where's Garmin?" question and points to the runbook.
import { ExternalLink, Watch } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { GARMIN_DOCS_HREF } from "@/components/settings/integrations/setup-guide-link";
import { useTranslations } from "@/lib/i18n/context";
export function GarminInfoNote() {
const { t } = useTranslations();
return (
<SettingsCard data-testid="garmin-info">
<SettingsCardHeader
icon={Watch}
title={t("settings.garminInfo.title")}
description={
<p className="text-muted-foreground">
{t("settings.garminInfo.body")}{" "}
<a
href={GARMIN_DOCS_HREF}
target="_blank"
rel="noopener noreferrer"
data-testid="garmin-docs-link"
data-slot="garmin-docs-link"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
>
{t("settings.garminInfo.docsLink")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</p>
}
/>
</SettingsCard>
);
}
@@ -40,6 +40,7 @@ export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
"google-health": "Google Health",
strava: "Strava",
dawarich: "Dawarich",
garmin: "Garmin",
};
/**
@@ -25,9 +25,6 @@ import { useTranslations } from "@/lib/i18n/context";
*/
export const DOCS_GUIDES_BASE = "https://docs.healthlog.dev/guides";
/** Garmin has no connector; the Google Health guide explains the route. */
export const GARMIN_DOCS_HREF = `${DOCS_GUIDES_BASE}/connect-google-health/#garmin-owners`;
export type IntegrationDocsProvider =
| "whoop"
| "withings"
@@ -37,7 +34,8 @@ export type IntegrationDocsProvider =
| "nightscout"
| "google-health"
| "strava"
| "dawarich";
| "dawarich"
| "garmin";
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
return `${DOCS_GUIDES_BASE}/connect-${provider}/`;
@@ -15,6 +15,7 @@ import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import type { PublishedFitbitRetirement } from "@/lib/fitbit/retirement";
import type { GarminKeyExpiry } from "@/lib/garmin/key-expiry";
export type IntegrationKey =
| "withings"
@@ -30,7 +31,9 @@ export type IntegrationKey =
// status and inherits the shared verdict.
| "nightscout"
// v1.43 — Dawarich movement context; publishes `health` only.
| "dawarich";
| "dawarich"
// Garmin Personal API; a pasted key, like Nightscout.
| "garmin";
/**
* One envelope entry as the cards read it. Since v1.42.4 the cards render
* `health` alone; the older health-shaped fields (ledger `state`, `lastError`,
@@ -54,7 +57,7 @@ export interface IntegrationStatusViewModel {
failureThreshold?: number;
configured?: boolean;
connected?: boolean;
/** WHOOP / Fitbit / Google Health: older history still loading. */
/** WHOOP / Fitbit / Google Health / Garmin: older history still loading. */
backfillCompleted?: boolean | null;
/**
* Google Health: data types whose history backfill ended before the start
@@ -70,6 +73,8 @@ export interface IntegrationStatusViewModel {
hasOwnCredentials?: boolean;
/** Nightscout: a stored token. */
hasToken?: boolean;
/** Garmin: when the stored key stops working, resolved on the server. */
keyExpiry?: GarminKeyExpiry | null;
/** Per-metric last-seen timestamps with the server-computed stale flag. */
metricFreshness?: MetricFreshnessEntry[];
/** Fitbit: the end of the legacy Web API, resolved on the server. */
@@ -124,6 +124,11 @@ const EXPECTED_TYPES = [
"INDOOR_CO2",
"INDOOR_TEMPERATURE",
"INDOOR_HUMIDITY",
// ── Garmin Personal API + bridged training figures ──
"BODY_BATTERY",
"STRESS_LEVEL",
"TRAINING_READINESS",
"ACUTE_TRAINING_LOAD",
] as const;
describe("measurementTypeEnum coverage", () => {
+1 -1
View File
@@ -149,7 +149,7 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
{
section: "integrations",
anchor: "garmin",
titleKey: "settings.garminInfo.title",
titleKey: "settings.garmin",
},
{
section: "integrations",
+2
View File
@@ -169,6 +169,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// v1.43 — Dawarich connection (URL and API key, like the Nightscout pair).
{ model: "User", field: "dawarichUrlEncrypted", kind: "string" },
{ model: "User", field: "dawarichApiKeyEncrypted", kind: "string" },
// Garmin Personal API key.
{ model: "User", field: "garminApiKeyEncrypted", kind: "string" },
{ model: "User", field: "polarAccessTokenEncrypted", kind: "string" },
{ model: "User", field: "polarUserIdEncrypted", kind: "string" },
{ model: "User", field: "polarClientIdEncrypted", kind: "string" },
+5
View File
@@ -322,6 +322,11 @@ export const CHART_OVERLAY_KEYS = [
"averageHeartRate",
"maxHeartRate",
"energyExpenditureKj",
// The Garmin scores and the bridged training figures.
"bodyBattery",
"stressLevel",
"trainingReadiness",
"acuteTrainingLoad",
// v1.25 — chart-overlay slots for the physical / clinical signal sub-pages.
// One per chart surface; camel-case keys mirror the existing convention.
"gripStrength",
+6
View File
@@ -465,6 +465,12 @@ export const USER_RESET = {
dawarichApiKeyEncrypted: null,
dawarichPrefsEncrypted: null,
dawarichAllowPrivateHost: false,
garminApiKeyEncrypted: null,
garminRegion: null,
garminConnectedAt: null,
garminKeyExpiresAt: null,
garminBackfillProgress: Prisma.DbNull,
garminBackfillCompletedAt: null,
polarAccessTokenEncrypted: null,
polarUserIdEncrypted: null,
polarClientIdEncrypted: null,
@@ -113,6 +113,12 @@ export const USER_COLUMNS_OUTSIDE_SETTINGS = {
dawarichApiKeyEncrypted: "CREDENTIAL",
dawarichPrefsEncrypted: "CREDENTIAL",
dawarichAllowPrivateHost: "CREDENTIAL",
// The Garmin key, and the facts that only mean something beside it: the
// service that accepted it, when it was connected and when it ends.
garminApiKeyEncrypted: "CREDENTIAL",
garminRegion: "CREDENTIAL",
garminConnectedAt: "CREDENTIAL",
garminKeyExpiresAt: "CREDENTIAL",
polarAccessTokenEncrypted: "CREDENTIAL",
polarUserIdEncrypted: "CREDENTIAL",
polarClientIdEncrypted: "CREDENTIAL",
@@ -150,6 +156,9 @@ export const USER_COLUMNS_OUTSIDE_SETTINGS = {
// provider; a carried note would claim history this host never imported.
ouraBackfillProgress: "OPERATIONAL",
ouraBackfillCompletedAt: "OPERATIONAL",
// Same for Garmin: a restored account reads its Garmin history again.
garminBackfillProgress: "OPERATIONAL",
garminBackfillCompletedAt: "OPERATIONAL",
healthKitLastSyncedAt: "OPERATIONAL",
healthKitLastSyncTrigger: "OPERATIONAL",
healthKitLastBackgroundSyncAt: "OPERATIONAL",
+127
View File
@@ -0,0 +1,127 @@
/**
* The `garminBackfillProgress` column: how far the Garmin history backfill
* has got, carried from one slice to the next.
*
* Each resource is walked from the day the backfill began (`anchor`) back
* toward the start of the account's history, one window at a time; after a
* window is written its start becomes the resource's `before`, so a slice
* that stops (its budget, a rate limit, a failed window) loses at most the
* window it was in. The menstrual cycle is walked cycle by cycle instead, its
* `before` being the day before the last cycle it read.
*
* Where history starts is learned: the daily summaries and the activities
* are walked first, back to `GARMIN_HISTORY_FLOOR`, and the oldest day either
* returned (`oldestSeen`) becomes `historyStart`. The other resources stop a
* week before it, so an account with three years of history costs three
* years of requests, not eighteen.
*
* Read leniently: a malformed entry is dropped and that resource starts over
* from the anchor, which is safe because every write is keyed.
*/
import { z } from "zod";
/** Garmin Connect went live in 2008; nothing it holds is older. */
export const GARMIN_HISTORY_FLOOR = "2008-01-01";
export const GARMIN_BACKFILL_STEPS = [
"daily",
"activities",
"sleep",
"hrv",
"weight",
"cycle",
] as const;
export type GarminBackfillStep = (typeof GARMIN_BACKFILL_STEPS)[number];
export interface GarminBackfillStepState {
/** Upper bound (YYYY-MM-DD) of the next window to read. */
before: string;
done?: boolean;
windows?: number;
}
export interface GarminBackfillProgress {
anchor: string;
oldestSeen?: string;
/** Learned once both probes are done; null when they found nothing. */
historyStart?: string | null;
steps: Partial<Record<GarminBackfillStep, GarminBackfillStepState>>;
}
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const stateSchema = z.object({
before: day,
done: z.boolean().optional().catch(undefined),
windows: z.number().int().min(0).optional().catch(undefined),
});
export function readGarminBackfillProgress(
value: unknown,
): GarminBackfillProgress | null {
if (value === null || typeof value !== "object") return null;
const raw = value as Record<string, unknown>;
const anchor = day.safeParse(raw.anchor);
if (!anchor.success) return null;
const rawSteps =
raw.steps && typeof raw.steps === "object"
? (raw.steps as Record<string, unknown>)
: {};
const steps: GarminBackfillProgress["steps"] = {};
for (const name of GARMIN_BACKFILL_STEPS) {
const parsed = stateSchema.safeParse(rawSteps[name]);
if (!parsed.success) continue;
steps[name] = {
before: parsed.data.before,
...(parsed.data.done ? { done: true } : {}),
...(parsed.data.windows !== undefined
? { windows: parsed.data.windows }
: {}),
};
}
const oldestSeen = day.safeParse(raw.oldestSeen);
const historyStart =
raw.historyStart === null ? null : day.safeParse(raw.historyStart);
return {
anchor: anchor.data,
...(oldestSeen.success ? { oldestSeen: oldestSeen.data } : {}),
...(historyStart === null
? { historyStart: null }
: historyStart.success
? { historyStart: historyStart.data }
: {}),
steps,
};
}
export function garminBackfillProgressJson(progress: GarminBackfillProgress) {
return {
anchor: progress.anchor,
...(progress.oldestSeen ? { oldestSeen: progress.oldestSeen } : {}),
...(progress.historyStart !== undefined
? { historyStart: progress.historyStart }
: {}),
steps: Object.fromEntries(
Object.entries(progress.steps).map(([name, s]) => [
name,
{
before: s!.before,
...(s!.done ? { done: true } : {}),
...(s!.windows !== undefined ? { windows: s!.windows } : {}),
},
]),
),
};
}
/** Share of the walk done, 0..1, for the card's progress line. */
export function garminBackfillShare(
progress: GarminBackfillProgress | null,
): number {
if (!progress) return 0;
const done = GARMIN_BACKFILL_STEPS.filter(
(s) => progress.steps[s]?.done,
).length;
return done / GARMIN_BACKFILL_STEPS.length;
}
+447
View File
@@ -0,0 +1,447 @@
/**
* Garmin Personal API transport: the key-for-token exchange, the bearer GET,
* and the date-range rules of each resource.
*
* The Personal API is pull-only and read-only. A person creates a key in
* Garmin Connect (Settings, Personal API) and pastes it here. Every data call
* needs a short-lived JWT the key buys at
* `POST /personalapitoken-service/personal-api/personal-api-key/token`
* (`Authorization: ApiKey <key>`, the body is the bare JWT). A token lives 24
* hours, or less once the key itself is about to expire, and there is no
* refresh token: a new one is simply bought again with the key.
*
* Tokens are cached in this process only, per account and per key (a changed
* key never reuses the old key's token). A data call refused with 401 buys one
* fresh token and retries once; a second refusal, or a refusal at the
* exchange itself, means the key was revoked or has expired, and surfaces as
* `reauth_required` so the card asks for a new one.
*
* Egress goes through `safeFetch` to two fixed hosts (global and China), so
* there is no user-supplied host to pin. Answers are read with a byte cap.
*/
import { createHash } from "node:crypto";
import { getEvent } from "@/lib/logging/context";
import { shiftDateKey } from "@/lib/tz/format";
import { readBodyCapped } from "@/lib/http/read-capped";
import { safeFetch } from "@/lib/safe-fetch";
import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after";
import {
classifyHttpStatus,
IntegrationApiError,
type IntegrationClassification,
} from "@/lib/integrations/http-status-classifier";
export const GARMIN_HOSTS = {
global: "https://personal.connectapi.garmin.com",
china: "https://personal.connectapi.garmin.cn",
} as const;
export type GarminRegion = keyof typeof GARMIN_HOSTS;
export const GARMIN_REGIONS = Object.keys(GARMIN_HOSTS) as GarminRegion[];
export function isGarminRegion(value: unknown): value is GarminRegion {
return value === "global" || value === "china";
}
export const GARMIN_TOKEN_PATH =
"/personalapitoken-service/personal-api/personal-api-key/token";
export const GARMIN_PATHS = {
dailySummaries: "/usersummary-service/v2/dailysummaries",
sleepSummaries: "/sleep-service/sleep/dailySleepSummariesByDate",
hrvDaily: "/hrv-service/hrv/daily",
weightRange: "/weight-service/weight/range",
activities: "/activitylist-service/activities/search/activities",
cycleSummary: "/periodichealth-service/menstrualcycle/summary",
} as const;
/**
* The widest request each resource accepts, in calendar days counting both
* ends. Daily summaries, sleep and the body-battery report carry documented
* maxima (365, 31, 31); HRV and weigh-ins document none, so they take the
* sleep figure rather than find the limit by being refused. Activities are
* paged and filtered by start date, so their window is only a slice size.
*/
export const GARMIN_RANGE_DAYS = {
dailySummaries: 365,
sleepSummaries: 31,
hrvDaily: 31,
weightRange: 31,
activities: 90,
} as const;
/** Page size of the activity search (Garmin's own default is 99). */
export const GARMIN_ACTIVITY_PAGE = 99;
/** Ceiling on one answer. A year of daily summaries is about a megabyte. */
export const GARMIN_MAX_BODY_BYTES = 8 * 1024 * 1024;
/** A regular token lives 24 h; anything markedly shorter is capped by the key. */
const KEY_CAPPED_BELOW_MS = 23 * 60 * 60 * 1000;
/** Replace a token an hour before it expires, never before half its life. */
const REFRESH_WINDOW_MS = 60 * 60 * 1000;
/** Used only when a token carries no readable `exp`. */
const FALLBACK_LIFETIME_MS = 23 * 60 * 60 * 1000;
const JWT_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
export class GarminApiError extends IntegrationApiError {
/** How long Garmin asked to be left alone (a 429's `Retry-After`). */
readonly retryAfterMs: number | undefined;
/** The key itself was refused: revoked, deleted or expired. */
readonly keyRefused: boolean;
constructor(opts: {
verb: string;
classification: IntegrationClassification;
httpStatus: number | undefined;
reason: string;
retryAfterMs?: number;
keyRefused?: boolean;
}) {
const { retryAfterMs, keyRefused, ...rest } = opts;
super({ vendor: "Garmin", ...rest });
this.name = "GarminApiError";
this.retryAfterMs = retryAfterMs;
this.keyRefused = keyRefused ?? false;
}
}
/** True for a refusal that a new key, not a retry, resolves. */
export function isGarminKeyRefusal(err: unknown): boolean {
return err instanceof GarminApiError && err.keyRefused;
}
/** True for Garmin's rate limit. */
export function isGarminRateLimit(err: unknown): err is GarminApiError {
return err instanceof GarminApiError && err.httpStatus === 429;
}
/** The token text: the bare JWT, or one wrapped in JSON or quotes. */
export function extractGarminToken(body: string): string {
const trimmed = body.trim();
if (!trimmed.startsWith("{")) return trimmed.replace(/^"|"$/g, "");
try {
const json = JSON.parse(trimmed) as Record<string, unknown>;
const candidate = json.access_token ?? json.accessToken ?? json.token;
return typeof candidate === "string" ? candidate.trim() : "";
} catch {
return "";
}
}
/** A JWT's `exp` claim in epoch ms, read without verifying the signature. */
export function garminTokenExpiry(token: string): number | undefined {
const payload = token.split(".")[1];
if (!payload) return undefined;
try {
const claims = JSON.parse(
Buffer.from(payload, "base64url").toString("utf8"),
) as { exp?: unknown };
return typeof claims.exp === "number" && Number.isFinite(claims.exp)
? claims.exp * 1000
: undefined;
} catch {
return undefined;
}
}
export interface GarminToken {
value: string;
/** Epoch ms. */
expiresAt: number;
/** Epoch ms after which a new token is bought. */
refreshAt: number;
/**
* The key's own expiry, when the token gave it away: a token Garmin caps
* at the key's end lives markedly less than the regular day.
*/
keyExpiresAt: number | null;
}
function refreshTime(now: number, expiresAt: number): number {
const lifetime = expiresAt - now;
if (lifetime <= 0) return now;
return now + Math.max(lifetime - REFRESH_WINDOW_MS, lifetime / 2);
}
export interface GarminTransport {
fetch?: typeof safeFetch;
now?: () => number;
}
/**
* Buy a token with the key. Throws a `GarminApiError` whose `keyRefused` is
* set when Garmin turned the key down (401 for a key it never issued, 404 for
* a deleted one, 403 for good measure).
*/
export async function exchangeGarminKey(
apiKey: string,
region: GarminRegion,
transport: GarminTransport = {},
): Promise<GarminToken> {
const doFetch = transport.fetch ?? safeFetch;
const now = transport.now ?? Date.now;
const start = performance.now();
const res = await doFetch(`${GARMIN_HOSTS[region]}${GARMIN_TOKEN_PATH}`, {
method: "POST",
headers: { Accept: "*/*", Authorization: `ApiKey ${apiKey}` },
});
const status = res.status;
getEvent()?.addExternalCall({
service: "garmin",
method: "token",
duration_ms: Math.round(performance.now() - start),
status,
error: status === 200 ? undefined : `http_${status}`,
});
if (status === 401 || status === 403 || status === 404) {
await res.body?.cancel().catch(() => {});
throw new GarminApiError({
verb: "token",
classification: "reauth_required",
httpStatus: status,
reason: "key_refused",
keyRefused: true,
});
}
if (status !== 200) {
await res.body?.cancel().catch(() => {});
const verdict = classifyHttpStatus(status);
throw new GarminApiError({
verb: "token",
classification: verdict.classification,
httpStatus: status,
reason: verdict.reason,
...(status === 429 || status === 503
? { retryAfterMs: retryAfterMsOf(res) }
: {}),
});
}
const read = await readBodyCapped(res, 64 * 1024);
const value = read.ok ? extractGarminToken(read.text) : "";
if (!JWT_SHAPE.test(value)) {
// A maintenance page served with 200, say. Never cached, never echoed.
throw new GarminApiError({
verb: "token",
classification: "transient",
httpStatus: status,
reason: "token_not_jwt",
});
}
const issuedAt = now();
const exp = garminTokenExpiry(value);
const expiresAt = exp ?? issuedAt + FALLBACK_LIFETIME_MS;
return {
value,
expiresAt,
refreshAt: refreshTime(issuedAt, expiresAt),
keyExpiresAt:
exp !== undefined && exp - issuedAt < KEY_CAPPED_BELOW_MS ? exp : null,
};
}
interface CachedToken extends GarminToken {
fingerprint: string;
}
/** Process-local token cache, per account. Never persisted. */
const tokenCache = new Map<string, CachedToken>();
/** Forget cached tokens (one account's, or all). Disconnect and tests call it. */
export function clearGarminTokenCache(userId?: string): void {
if (userId === undefined) tokenCache.clear();
else tokenCache.delete(userId);
}
function keyFingerprint(apiKey: string, region: GarminRegion): string {
return createHash("sha256").update(`${region}\n${apiKey}`).digest("hex");
}
export type GarminQuery = Record<
string,
string | number | boolean | undefined | null
>;
/**
* One account's session against the Personal API. Holds the key only for as
* long as the session object lives, buys tokens through the shared cache, and
* remembers what a token said about the key's expiry so the caller can
* persist it.
*/
export class GarminSession {
private readonly userId: string;
private readonly apiKey: string;
private readonly region: GarminRegion;
private readonly transport: GarminTransport;
private readonly fingerprint: string;
/** The key's expiry, once a token revealed it. */
learnedKeyExpiresAt: number | null = null;
/** Number of key exchanges this session made (for the record and tests). */
exchanges = 0;
constructor(opts: {
userId: string;
apiKey: string;
region: GarminRegion;
transport?: GarminTransport;
}) {
this.userId = opts.userId;
this.apiKey = opts.apiKey;
this.region = opts.region;
this.transport = opts.transport ?? {};
this.fingerprint = keyFingerprint(opts.apiKey, opts.region);
}
private now(): number {
return (this.transport.now ?? Date.now)();
}
/** A usable token: the cached one while fresh, else a new one. */
async token(force = false): Promise<string> {
const cached = tokenCache.get(this.userId);
if (
!force &&
cached &&
cached.fingerprint === this.fingerprint &&
this.now() < cached.refreshAt
) {
return cached.value;
}
const fresh = await exchangeGarminKey(
this.apiKey,
this.region,
this.transport,
);
this.exchanges += 1;
if (fresh.keyExpiresAt !== null) {
this.learnedKeyExpiresAt = fresh.keyExpiresAt;
}
tokenCache.set(this.userId, { ...fresh, fingerprint: this.fingerprint });
return fresh.value;
}
/**
* GET a resource. Returns the parsed body, or null for an empty answer
* (204, or no body). A 401 buys one new token and retries; a second 401
* is the key's end.
*/
async get<T>(
verb: string,
path: string,
query: GarminQuery = {},
): Promise<T | null> {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null) params.set(k, String(v));
}
const qs = params.toString();
const url = `${GARMIN_HOSTS[this.region]}${path}${qs ? `?${qs}` : ""}`;
let token = await this.token();
let res = await this.call(verb, url, token);
if (res.status === 401) {
await res.body?.cancel().catch(() => {});
token = await this.token(true);
res = await this.call(verb, url, token);
if (res.status === 401) {
await res.body?.cancel().catch(() => {});
tokenCache.delete(this.userId);
throw new GarminApiError({
verb,
classification: "reauth_required",
httpStatus: 401,
reason: "key_refused",
keyRefused: true,
});
}
}
if (res.status === 204) return null;
if (res.status < 200 || res.status >= 300) {
await res.body?.cancel().catch(() => {});
const verdict = classifyHttpStatus(res.status);
throw new GarminApiError({
verb,
// A 403 on a data call with a token the exchange just accepted is a
// resource the account cannot read, not a dead key.
classification:
res.status === 403 ? "persistent" : verdict.classification,
httpStatus: res.status,
reason: verdict.reason,
...(res.status === 429 || res.status === 503
? { retryAfterMs: retryAfterMsOf(res, this.now()) }
: {}),
});
}
const read = await readBodyCapped(res, GARMIN_MAX_BODY_BYTES);
if (!read.ok) {
throw new GarminApiError({
verb,
classification: "transient",
httpStatus: res.status,
reason: "body_too_large",
});
}
if (read.text.trim() === "") return null;
try {
return JSON.parse(read.text) as T;
} catch {
throw new GarminApiError({
verb,
classification: "transient",
httpStatus: res.status,
reason: "body_not_json",
});
}
}
private async call(
verb: string,
url: string,
token: string,
): Promise<Response> {
const doFetch = this.transport.fetch ?? safeFetch;
const start = performance.now();
const res = await doFetch(url, {
method: "GET",
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
});
getEvent()?.addExternalCall({
service: "garmin",
method: verb,
duration_ms: Math.round(performance.now() - start),
status: res.status,
error: res.ok ? undefined : `http_${res.status}`,
});
return res;
}
}
// ── date windows ────────────────────────────────────────────────────
/** `day` moved by `delta` calendar days (pure key arithmetic). */
export function shiftGarminDay(day: string, delta: number): string {
return shiftDateKey(day, delta);
}
/**
* Split `[startDate, endDate]` (both inclusive) into windows of at most
* `maxDays` calendar days, newest first. An inverted range yields nothing.
*/
export function chunkGarminRange(
startDate: string,
endDate: string,
maxDays: number,
): Array<{ startDate: string; endDate: string }> {
if (maxDays < 1) throw new Error("maxDays must be at least 1");
const out: Array<{ startDate: string; endDate: string }> = [];
let end = endDate;
while (end >= startDate) {
const candidate = shiftGarminDay(end, -(maxDays - 1));
const start = candidate < startDate ? startDate : candidate;
out.push({ startDate: start, endDate: end });
end = shiftGarminDay(start, -1);
}
return out;
}
+58
View File
@@ -0,0 +1,58 @@
/**
* The per-account Garmin connection: one Personal API key the person pasted,
* sealed at rest under the fail-closed `*Encrypted` convention, beside the
* host region it was accepted on and what is known about when it expires.
* This helper is the single read+decrypt path the sync, the backfill and the
* routes share; nothing else decrypts the key.
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { isGarminRegion, type GarminRegion } from "./client";
export interface GarminCredentials {
apiKey: string;
region: GarminRegion;
keyExpiresAt: Date | null;
connectedAt: Date | null;
}
/** Fetch and decrypt the account's Garmin key; null when none is stored. */
export async function getGarminCredentials(
userId: string,
): Promise<GarminCredentials | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
garminApiKeyEncrypted: true,
garminRegion: true,
garminKeyExpiresAt: true,
garminConnectedAt: true,
},
});
if (!user?.garminApiKeyEncrypted) return null;
return {
apiKey: decrypt(user.garminApiKeyEncrypted),
region: isGarminRegion(user.garminRegion) ? user.garminRegion : "global",
keyExpiresAt: user.garminKeyExpiresAt,
connectedAt: user.garminConnectedAt,
};
}
/**
* Persist the key's expiry a token revealed, when it differs from what is
* stored. A learned expiry is Garmin's own figure and replaces the estimate
* made from the validity picked at connect.
*/
export async function rememberGarminKeyExpiry(
userId: string,
learned: number | null,
stored: Date | null,
): Promise<void> {
if (learned === null) return;
if (stored && Math.abs(stored.getTime() - learned) < 60_000) return;
await prisma.user.updateMany({
where: { id: userId, garminApiKeyEncrypted: { not: null } },
data: { garminKeyExpiresAt: new Date(learned) },
});
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Garmin's menstrual-cycle summary → HealthLog's cycle tracker, as an
* ADDITIONAL, SUBORDINATE source, with the same write rule as Oura's cycle
* import (`src/lib/oura/cycle-sync.ts`): a day is only ever CREATED when no
* `CycleDayLog` row exists for it at all (any source), and never updated, so
* a person's own entry always wins and an hourly poll can never demote it.
* No `MenstrualCycle` span is created; cycle spans stay an explicit action.
*
* `GET /periodichealth-service/menstrualcycle/summary/{date}` describes the
* cycle a date falls in: its start and its period length. A cycle Garmin
* only predicts, and a pregnancy, carry nothing to import. The period days of
* a logged cycle are `start … start + periodLength − 1`, each written as the
* conservative `flow: LIGHT` marker (logged, intensity unknown), never in the
* future.
*
* Gated behind the cycle module (`isCycleAvailableForUser`), checked before
* Garmin is asked, so an account with cycle tracking off costs no request.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { GARMIN_PATHS, shiftGarminDay, type GarminSession } from "./client";
import type { GarminCycleDate, GarminCycleSummary } from "./wire-types";
const GARMIN_PERIOD_FLOW = "LIGHT" as const;
/** The longest period a single summary may claim before it is not believed. */
const MAX_PERIOD_DAYS = 14;
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
/** A cycle date as Garmin sends it: bare, or `{ localDate, date }`. */
export function garminCycleDay(value: GarminCycleDate): string | null {
if (typeof value === "string") {
const day = value.slice(0, 10);
return DAY_KEY.test(day) ? day : null;
}
if (value && typeof value === "object") {
if (typeof value.localDate === "string" && DAY_KEY.test(value.localDate)) {
return value.localDate;
}
if (typeof value.date === "string") {
const day = value.date.slice(0, 10);
return DAY_KEY.test(day) ? day : null;
}
}
return null;
}
export interface GarminCyclePeriod {
/** First day of the cycle. */
start: string;
/** The period's days, oldest first, none after `today`. */
days: string[];
}
/** The logged period a summary describes, or null when there is none. */
export function periodFromGarminCycleSummary(
summary: GarminCycleSummary | null,
today: string,
): GarminCyclePeriod | null {
if (!summary || summary.predictedCycle === true) return null;
if (summary.pregnancyCycle === true) return null;
const start = garminCycleDay(summary.startDate);
if (!start || start > today) return null;
const length = summary.periodLength;
if (typeof length !== "number" || !Number.isFinite(length)) return null;
const n = Math.round(length);
if (n < 1 || n > MAX_PERIOD_DAYS) return { start, days: [] };
const days: string[] = [];
for (let i = 0; i < n; i++) {
const day = shiftGarminDay(start, i);
if (day > today) break;
days.push(day);
}
return { start, days };
}
function isUniqueViolation(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code?: unknown }).code === "P2002"
);
}
/** Create the period days no row covers yet. Returns how many were created. */
export async function writeGarminPeriodDays(
userId: string,
days: readonly string[],
): Promise<number> {
if (days.length === 0) return 0;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
const tz = user?.timezone ?? null;
let created = 0;
for (const day of days) {
try {
await prisma.cycleDayLog.create({
data: {
userId,
date: day,
tz,
source: "GARMIN",
flow: GARMIN_PERIOD_FLOW,
},
});
created += 1;
} catch (err) {
// A row already exists: the person's own, an import's, or this
// module's earlier write. Garmin never overwrites.
if (isUniqueViolation(err)) continue;
getEvent()?.addWarning(
`garmin: cycle day-log create failed for ${userId} ${day}: ${err}`,
);
}
}
return created;
}
/** Read the cycle summary for one date (null when Garmin has none). */
export async function readGarminCycleSummary(
session: GarminSession,
day: string,
): Promise<GarminCycleSummary | null> {
return session.get<GarminCycleSummary>(
"cycleSummary",
`${GARMIN_PATHS.cycleSummary}/${day}`,
);
}
/**
* The hourly leg: the cycle `today` falls in. Returns days created; throws
* what the fetch throws, and the caller keeps it off the connection's ledger
* (a person who does not track a cycle in Garmin Connect is the common case).
*/
export async function syncGarminCycle(
userId: string,
session: GarminSession,
today: string,
): Promise<number> {
if (!(await isCycleAvailableForUser(userId))) return 0;
const summary = await readGarminCycleSummary(session, today);
const period = periodFromGarminCycleSummary(summary, today);
if (!period) return 0;
return writeGarminPeriodDays(userId, period.days);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* When a Garmin key stops working, and how close that is.
*
* Garmin does not publish a key's expiry anywhere the Personal API can read:
* the key is opaque and the token's `exp` is the token's own day, until the
* key's last day, when Garmin caps the token at the key's end. So the expiry
* is known from one of two places, in this order:
*
* 1. learned: a token lived markedly less than a day (`./client.ts`), and
* its `exp` is the key's end;
* 2. estimated: the validity the person picked when creating the key (30,
* 90, 180 or 365 days), counted from the day it was connected here.
*
* Neither known: the state is `unknown`, and the card asks for the validity.
* The warning starts a week ahead, which is when Garmin's own reminder mail
* goes out.
*/
/** The validities Garmin Connect offers when a key is created, in days. */
export const GARMIN_KEY_VALIDITY_DAYS = [30, 90, 180, 365] as const;
export type GarminKeyValidityDays = (typeof GARMIN_KEY_VALIDITY_DAYS)[number];
/** How long before the end the card starts warning. */
export const GARMIN_KEY_WARNING_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
export type GarminKeyExpiryState = "ok" | "expiring" | "expired" | "unknown";
export interface GarminKeyExpiry {
state: GarminKeyExpiryState;
/** ISO instant, or null when unknown. */
expiresAt: string | null;
/** Whole days left (0 on the last day), or null when unknown or past. */
daysLeft: number | null;
}
/** The expiry a picked validity gives, counted from `connectedAt`. */
export function estimateGarminKeyExpiry(
connectedAt: Date,
validityDays: GarminKeyValidityDays,
): Date {
return new Date(connectedAt.getTime() + validityDays * DAY_MS);
}
/** Resolve the expiry state at `now`. */
export function resolveGarminKeyExpiry(
expiresAt: Date | null,
now: Date = new Date(),
): GarminKeyExpiry {
if (!expiresAt) return { state: "unknown", expiresAt: null, daysLeft: null };
const left = expiresAt.getTime() - now.getTime();
if (left <= 0) {
return {
state: "expired",
expiresAt: expiresAt.toISOString(),
daysLeft: null,
};
}
const daysLeft = Math.floor(left / DAY_MS);
return {
state: daysLeft < GARMIN_KEY_WARNING_DAYS ? "expiring" : "ok",
expiresAt: expiresAt.toISOString(),
daysLeft,
};
}
+355
View File
@@ -0,0 +1,355 @@
/**
* Garmin records to measurement rows: pure functions over the wire shapes
* (`./wire-types.ts`), no I/O. `__tests__/map.test.ts` pins what each makes
* of made-up records shaped from Garmin's OpenAPI document.
*
* Every row carries its own `externalId`, keyed on the record's calendar day
* (or the weigh-in's sample id) and a field tag, so a re-read of the same day
* lands on the same row and Garmin's later re-scoring overwrites in place.
*
* What maps where:
*
* daily summary steps, step distance, floors, active kcal, resting HR,
* average stress level (STRESS_LEVEL), Body Battery high
* sleep summary per-stage minutes (light→CORE, deep, REM, awake) stamped
* on the wake instant, the overall sleep score, the night's
* average SpO2 and breathing rate
* HRV `lastNightAvg` (RMSSD) → HRV_RMSSD; Garmin reports no SDNN
* weigh-ins weight, BMI, body fat, body water, muscle, bone, visceral
*
* Masses are grams on the live service; body water is a percentage of the
* weigh-in's weight and is converted to the kilograms the type stores.
*/
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
import { importValue } from "@/lib/integrations/import-value";
import type {
GarminDailySummary,
GarminHrvEnvelope,
GarminInstant,
GarminSleepSummary,
GarminWeighIn,
GarminWeightRangeEnvelope,
} from "./wire-types";
export interface GarminMappedMeasurement {
type: string;
value: number;
unit: string;
measuredAt: Date;
externalId: string;
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE";
deviceType?: "watch" | "scale";
}
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
function isDay(value: unknown): value is string {
return typeof value === "string" && DAY_KEY.test(value);
}
function num(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
/**
* An instant as Garmin sends it. Epoch ms numbers, or a GMT wall-clock
* string without a zone (`2026-10-22T05:40:00.0`, `2026-10-22 05:15:02`),
* which is read as UTC because every field it is used for is a `GMT` one.
*/
export function parseGarminInstant(value: GarminInstant): Date | null {
if (typeof value === "number") {
if (!Number.isFinite(value) || value <= 0) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
if (typeof value !== "string" || value.trim() === "") return null;
let text = value.trim().replace(" ", "T");
if (!/(Z|[+-]\d{2}:?\d{2})$/.test(text)) text = `${text}Z`;
const d = new Date(text);
return Number.isNaN(d.getTime()) ? null : d;
}
/** A date-only record's instant: local noon of its date in the user's zone. */
function dayAnchor(day: string, tz: string | undefined): Date | null {
const d = canonicalDailyTimestamp(day, tz);
return Number.isNaN(d.getTime()) ? null : d;
}
function scoreValue(v: number | null): number | null {
// Garmin marks "not enough data" with negative sentinels (-1, -2).
if (v === null || v < 0 || v > 100) return null;
return Math.round(v);
}
/** One day of `v2/dailysummaries`. A day the watch recorded nothing maps to nothing. */
export function mapGarminDailySummary(
d: GarminDailySummary,
tz?: string,
): GarminMappedMeasurement[] {
if (!isDay(d.calendarDate)) return [];
// A summary exists for every calendar day, worn or not; only a day with
// wellness data carries readings. Otherwise a year before the first watch
// would import as a year of zero steps.
if (d.includesWellnessData !== true) return [];
const day = d.calendarDate;
const measuredAt = dayAnchor(day, tz);
if (!measuredAt) return [];
const out: GarminMappedMeasurement[] = [];
const push = (
type: string,
value: number | null,
unit: string,
tag: string,
) => {
if (value === null) return;
out.push({
type,
value,
unit,
measuredAt,
externalId: `daily:${day}:${tag}`,
deviceType: "watch",
});
};
const steps = num(d.movement?.steps?.value);
push(
"ACTIVITY_STEPS",
steps !== null && steps >= 0 ? Math.round(steps) : null,
"steps",
"steps",
);
const distance = num(d.movement?.steps?.distanceInMeters);
push(
"WALKING_RUNNING_DISTANCE",
distance !== null && distance >= 0 ? importValue(distance) : null,
"m",
"distance",
);
const floors = num(d.movement?.floorsAscended?.value);
push(
"FLIGHTS_CLIMBED",
floors !== null && floors >= 0 ? Math.round(floors) : null,
"flights",
"floors",
);
const active = num(d.calories?.burnedActive);
push(
"ACTIVE_ENERGY_BURNED",
active !== null && active >= 0 ? importValue(active) : null,
"kcal",
"active_energy",
);
const rhr = num(d.heartRate?.restingValue);
push(
"RESTING_HEART_RATE",
rhr !== null && rhr > 0 ? Math.round(rhr) : null,
"bpm",
"rhr",
);
push("STRESS_LEVEL", scoreValue(num(d.stress?.avgLevel)), "score", "stress");
push(
"BODY_BATTERY",
scoreValue(num(d.bodyBattery?.maxValue)),
"score",
"body_battery",
);
return out;
}
/**
* One night of `dailySleepSummariesByDate`. Days without sleep come back as
* an object whose `calendarDate` is null; those, and nights with no measured
* sleep, map to nothing.
*
* Garmin gives stage totals, not a timeline, so the four stage rows are
* stamped on the wake instant (the shape Oura's stage-total fallback writes),
* and the per-night scalars sit there too.
*/
export function mapGarminSleep(
s: GarminSleepSummary,
tz?: string,
): GarminMappedMeasurement[] {
if (!isDay(s.calendarDate)) return [];
const sleepSeconds = num(s.sleepTimeSeconds);
if (sleepSeconds === null || sleepSeconds <= 0) return [];
const day = s.calendarDate;
const measuredAt =
parseGarminInstant(s.sleepEndTimestampGMT) ?? dayAnchor(day, tz);
if (!measuredAt) return [];
const out: GarminMappedMeasurement[] = [];
const stages: Array<
[number | null, NonNullable<GarminMappedMeasurement["sleepStage"]>, string]
> = [
[num(s.lightSleepSeconds), "CORE", "sleep_core"],
[num(s.deepSleepSeconds), "DEEP", "sleep_deep"],
[num(s.remSleepSeconds), "REM", "sleep_rem"],
[num(s.awakeSleepSeconds), "AWAKE", "sleep_awake"],
];
for (const [sec, stage, tag] of stages) {
if (sec === null || sec < 0) continue;
out.push({
type: "SLEEP_DURATION",
value: importValue(sec / 60),
unit: "minutes",
measuredAt,
externalId: `sleep:${day}:${tag}`,
sleepStage: stage,
deviceType: "watch",
});
}
const score = scoreValue(num(s.sleepScores?.overall?.value ?? null));
if (score !== null) {
out.push({
type: "SLEEP_SCORE",
value: score,
unit: "score",
measuredAt,
externalId: `sleep:${day}:sleep_score`,
deviceType: "watch",
});
}
const spo2 = num(s.averageSpO2Value);
if (spo2 !== null && spo2 > 0) {
out.push({
type: "OXYGEN_SATURATION",
value: importValue(spo2),
unit: "%",
measuredAt,
externalId: `sleep:${day}:spo2`,
deviceType: "watch",
});
}
const breath = num(s.averageRespirationValue);
if (breath !== null && breath > 0) {
out.push({
type: "RESPIRATORY_RATE",
value: importValue(breath),
unit: "breaths/min",
measuredAt,
externalId: `sleep:${day}:resp_rate`,
deviceType: "watch",
});
}
return out;
}
/** The HRV daily envelope: each night's average (RMSSD) → `HRV_RMSSD`. */
export function mapGarminHrv(
env: GarminHrvEnvelope | null,
tz?: string,
): GarminMappedMeasurement[] {
const out: GarminMappedMeasurement[] = [];
for (const h of env?.hrvSummaries ?? []) {
if (!isDay(h?.calendarDate)) continue;
const v = num(h.lastNightAvg);
if (v === null || v <= 0) continue;
const measuredAt = dayAnchor(h.calendarDate, tz);
if (!measuredAt) continue;
out.push({
type: "HRV_RMSSD",
value: importValue(v),
unit: "ms",
measuredAt,
externalId: `hrv:${h.calendarDate}:hrv_rmssd`,
deviceType: "watch",
});
}
return out;
}
const GRAMS_PER_KG = 1000;
/** One weigh-in → its body-composition rows. */
export function mapGarminWeighIn(w: GarminWeighIn): GarminMappedMeasurement[] {
const id =
typeof w.samplePk === "number" || typeof w.samplePk === "string"
? String(w.samplePk)
: null;
if (!id) return [];
const measuredAt =
parseGarminInstant(w.timestampGMT) ?? parseGarminInstant(w.date);
if (!measuredAt) return [];
// An Index scale reports through Garmin Connect; a typed entry is MANUAL.
const deviceType =
typeof w.sourceType === "string" &&
w.sourceType.toUpperCase().includes("SCALE")
? ("scale" as const)
: undefined;
const out: GarminMappedMeasurement[] = [];
const push = (type: string, value: number | null, unit: string) => {
if (value === null) return;
out.push({
type,
value,
unit,
measuredAt,
externalId: `weight:${id}:${type.toLowerCase()}`,
...(deviceType ? { deviceType } : {}),
});
};
const grams = num(w.weight);
const kg = grams !== null && grams > 0 ? grams / GRAMS_PER_KG : null;
push("WEIGHT", kg === null ? null : importValue(kg), "kg");
const bmi = num(w.bmi);
push(
"BODY_MASS_INDEX",
bmi !== null && bmi > 0 ? importValue(bmi) : null,
"kg/m²",
);
const fat = num(w.bodyFat);
push("BODY_FAT", fat !== null && fat > 0 ? importValue(fat) : null, "%");
const water = num(w.bodyWater);
push(
"TOTAL_BODY_WATER",
water !== null && water > 0 && kg !== null
? importValue((kg * water) / 100)
: null,
"kg",
);
const muscle = num(w.muscleMass);
push(
"MUSCLE_MASS",
muscle !== null && muscle > 0 ? importValue(muscle / GRAMS_PER_KG) : null,
"kg",
);
const bone = num(w.boneMass);
push(
"BONE_MASS",
bone !== null && bone > 0 ? importValue(bone / GRAMS_PER_KG) : null,
"kg",
);
const visceral = num(w.visceralFat);
push(
"VISCERAL_FAT",
visceral !== null && visceral >= 0 ? importValue(visceral) : null,
"rating",
);
return out;
}
/** Every weigh-in of a weight-range envelope, each once. */
export function mapGarminWeightRange(
env: GarminWeightRangeEnvelope | null,
): GarminMappedMeasurement[] {
const seen = new Set<string>();
const out: GarminMappedMeasurement[] = [];
for (const day of env?.dailyWeightSummaries ?? []) {
const entries = day?.allWeightMetrics?.length
? day.allWeightMetrics
: day?.latestWeight
? [day.latestWeight]
: [];
for (const w of entries) {
for (const row of mapGarminWeighIn(w)) {
if (seen.has(row.externalId)) continue;
seen.add(row.externalId);
out.push(row);
}
}
}
return out;
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Garmin activity `activityType.typeKey` → HealthLog canonical
* `WorkoutSportType`.
*
* Garmin's keys are lowercase snake_case (`running`, `trail_running`,
* `road_biking`, `lap_swimming`, `strength_training`, ...), and some carry a
* version suffix (`kayaking_v2`). The bucket choices follow the Oura, Polar
* and Strava tables (paddle sports → rowing, racket sports → tennis,
* functional training → crossTraining, mobility and breath work →
* mindAndBody, snow sports → other), so one real session lands in one bucket
* whichever source reported it. An unknown key lands on `"other"`; the row
* still persists and the raw key travels in `Workout.metadata.garminActivity`.
*/
import type { WorkoutSportType } from "@/lib/validations/workout";
const GARMIN_SPORT_TABLE: ReadonlyArray<readonly [string, WorkoutSportType]> = [
// running
["running", "running"],
["trail_running", "running"],
["treadmill_running", "running"],
["track_running", "running"],
["indoor_running", "running"],
["street_running", "running"],
["virtual_run", "running"],
["ultra_run", "running"],
// cycling
["cycling", "cycling"],
["road_biking", "cycling"],
["mountain_biking", "cycling"],
["gravel_cycling", "cycling"],
["indoor_cycling", "cycling"],
["virtual_ride", "cycling"],
["cyclocross", "cycling"],
["e_bike_fitness", "cycling"],
["e_bike_mountain", "cycling"],
["bmx", "cycling"],
["track_cycling", "cycling"],
// walking / hiking
["walking", "walking"],
["casual_walking", "walking"],
["speed_walking", "walking"],
["indoor_walking", "walking"],
["hiking", "hiking"],
["mountaineering", "hiking"],
// water
["swimming", "swimming"],
["lap_swimming", "swimming"],
["open_water_swimming", "swimming"],
["rowing", "rowing"],
["indoor_rowing", "rowing"],
["kayaking", "rowing"],
["paddling", "rowing"],
["stand_up_paddleboarding", "rowing"],
// machines
["elliptical", "elliptical"],
["stair_climbing", "stairClimber"],
["floor_climbing", "stairClimber"],
// strength / intervals / functional
["strength_training", "strength"],
["hiit", "hiit"],
["indoor_cardio", "mixedCardio"],
["cardio", "mixedCardio"],
["fitness_equipment", "crossTraining"],
["bouldering", "crossTraining"],
["rock_climbing", "crossTraining"],
["indoor_climbing", "crossTraining"],
// mind and body
["yoga", "yoga"],
["pilates", "mindAndBody"],
["breathwork", "mindAndBody"],
["meditation", "mindAndBody"],
["mobility", "mindAndBody"],
// dance
["dance", "dance"],
// ball and racket sports
["golf", "golf"],
["tennis", "tennis"],
["table_tennis", "tennis"],
["squash", "tennis"],
["padel", "tennis"],
["pickleball", "tennis"],
["racquetball", "tennis"],
["badminton", "badminton"],
["basketball", "basketball"],
["soccer", "soccer"],
// persisted outside a bucket
["resort_skiing", "other"],
["backcountry_skiing", "other"],
["cross_country_classic_skiing", "other"],
["cross_country_skate_skiing", "other"],
["resort_snowboarding", "other"],
["inline_skating", "other"],
["surfing", "other"],
["boxing", "other"],
["multi_sport", "other"],
["triathlon", "other"],
["other", "other"],
];
/** Fold case, drop a `_v2`-style suffix and separators. */
function normaliseGarminTypeKey(raw: string): string {
return raw
.trim()
.toLowerCase()
.replace(/_v\d+$/, "")
.replace(/[^a-z0-9]+/g, "");
}
const GARMIN_SPORT_MAP: ReadonlyMap<string, WorkoutSportType> = new Map(
GARMIN_SPORT_TABLE.map(([key, canonical]) => [
normaliseGarminTypeKey(key),
canonical,
]),
);
/**
* Resolve a Garmin `typeKey` to a canonical `WorkoutSportType`; `"other"` for
* an absent, blank or unknown key. Never returns the raw key.
*/
export function mapGarminSportType(
typeKey: string | null | undefined,
): WorkoutSportType {
if (typeof typeKey !== "string" || typeKey.trim() === "") return "other";
return GARMIN_SPORT_MAP.get(normaliseGarminTypeKey(typeKey)) ?? "other";
}
+171
View File
@@ -0,0 +1,171 @@
/**
* 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;
}
+509
View File
@@ -0,0 +1,509 @@
/**
* Garmin Personal API sync: the hourly incremental read, and the resource
* readers the history backfill (`src/lib/jobs/garmin-backfill.ts`) shares.
*
* Resources, each read over a window of the user's calendar days and chunked
* to the resource's documented maximum (`GARMIN_RANGE_DAYS`):
*
* daily `v2/dailysummaries` steps, distance, floors, active kcal,
* resting HR, stress level, Body Battery
* sleep sleep summaries stage minutes, sleep score, SpO2,
* breathing rate
* hrv HRV daily summaries overnight RMSSD
* weight weight range (all) weight and body composition
* activities activity search (paged) workouts
*
* plus the menstrual-cycle summary of today, best-effort and off the ledger
* (`./cycle-sync.ts`). Rows are tagged `source = GARMIN`; the measurement
* write goes through the shared reconciler, so a re-read of the same day
* overwrites in place (Garmin finalises a night's figures after waking).
*
* The resources are read one after another, not in parallel: the Personal
* API is a per-person allowance, and a rate limit stops the run on the spot
* (the remaining resources wait for the next tick) rather than spending more
* requests into it. A refused key stops the run too and parks the connection
* at `reauth_required`; nothing else can fix it but a new key.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { annotate, getEvent } from "@/lib/logging/context";
import {
isReauthRequired,
markSyncFailureRecorded,
recordSyncFailure,
recordSyncSuccess,
toFailureKind,
type FailureKind,
} from "@/lib/integrations/status";
import { classifyIntegrationError } from "@/lib/integrations/http-status-classifier";
import type { InsertedMeasurementArrivalRow } from "@/lib/arrivals/measurement-emit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write";
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
import { walkPages } from "@/lib/integrations/page-walk";
import {
MeasurementReconciliationError,
reconcileExternalMeasurement,
} from "@/lib/measurements/reconcile-external-measurement";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
chunkGarminRange,
GARMIN_ACTIVITY_PAGE,
GARMIN_PATHS,
GARMIN_RANGE_DAYS,
GarminApiError,
GarminSession,
isGarminKeyRefusal,
isGarminRateLimit,
type GarminTransport,
} from "./client";
import { getGarminCredentials, rememberGarminKeyExpiry } from "./credentials";
import { syncGarminCycle } from "./cycle-sync";
import {
mapGarminDailySummary,
mapGarminHrv,
mapGarminSleep,
mapGarminWeightRange,
type GarminMappedMeasurement,
} from "./map";
import {
mapGarminActivity,
upsertGarminWorkouts,
type GarminWorkoutRow,
} from "./sync-workouts";
import type {
GarminActivity,
GarminDailySummaryEnvelope,
GarminHrvEnvelope,
GarminSleepSummary,
GarminWeightRangeEnvelope,
} from "./wire-types";
export const GARMIN_RESOURCES = [
"daily",
"sleep",
"hrv",
"weight",
"activities",
] as const;
export type GarminResource = (typeof GARMIN_RESOURCES)[number];
/** The widest single request per resource, in calendar days. */
export const GARMIN_RESOURCE_WINDOW_DAYS: Record<GarminResource, number> = {
daily: GARMIN_RANGE_DAYS.dailySummaries,
sleep: GARMIN_RANGE_DAYS.sleepSummaries,
hrv: GARMIN_RANGE_DAYS.hrvDaily,
weight: GARMIN_RANGE_DAYS.weightRange,
activities: GARMIN_RANGE_DAYS.activities,
};
/** Floor of the incremental window: covers Garmin's re-scoring of recent nights. */
export const GARMIN_SYNC_LOOKBACK_DAYS = 7;
/**
* Ceiling of the incremental window. A longer gap is history and belongs to
* the backfill, which a connect starts and boot discovery resumes.
*/
export const GARMIN_SYNC_MAX_LOOKBACK_DAYS = 30;
/** Upper bound on pages of one activity window (99 each). */
const MAX_ACTIVITY_PAGES = 50;
/** Readings per write transaction. */
const WRITE_CHUNK = 400;
const DAY_MS = 24 * 60 * 60 * 1000;
export function classifyGarminFailure(err: unknown): FailureKind {
return toFailureKind(classifyIntegrationError(err, "Garmin"));
}
/**
* Record a whole-connection Garmin failure on the `garmin` ledger with its
* classification and HTTP code. The message is Garmin's status and verb,
* never a body and never the key: `GarminApiError` is built from neither.
*/
export async function recordGarminSyncFailure(
userId: string,
err: unknown,
): Promise<void> {
await recordSyncFailure({
userId,
integration: "garmin",
kind: classifyGarminFailure(err),
message:
err instanceof GarminApiError
? err.message
: err instanceof Error
? err.name
: "unknown_error",
errorCode:
err instanceof GarminApiError && err.httpStatus != null
? String(err.httpStatus)
: undefined,
});
}
/** What one resource window yields. */
export interface GarminWindowRead {
measurements: GarminMappedMeasurement[];
workouts: GarminWorkoutRow[];
/** Oldest day the window returned real data for. */
oldestDay?: string;
}
function minDay(
a: string | undefined,
b: string | undefined,
): string | undefined {
if (b === undefined) return a;
return a === undefined || b < a ? b : a;
}
async function readActivities(
session: GarminSession,
window: { startDate: string; endDate: string },
): Promise<GarminActivity[]> {
// Offset paging: the cursor is the next `start`; a short page is the last.
const walk = await walkPages<number, GarminActivity>(
"garmin.activities",
MAX_ACTIVITY_PAGES,
async (cursor) => {
const start = cursor ?? 0;
const body = await session.get<
GarminActivity[] | { activityList?: GarminActivity[] }
>("activities", GARMIN_PATHS.activities, {
startDate: window.startDate,
endDate: window.endDate,
start,
limit: GARMIN_ACTIVITY_PAGE,
excludeChildren: true,
sortOrder: "desc",
});
const items = Array.isArray(body)
? body
: Array.isArray(body?.activityList)
? body.activityList
: [];
return {
items,
next:
items.length < GARMIN_ACTIVITY_PAGE
? null
: start + GARMIN_ACTIVITY_PAGE,
};
},
);
return walk.items;
}
/**
* Read and map one window of one resource. The window may be wider than the
* resource's maximum; it is chunked here.
*/
export async function readGarminWindow(
session: GarminSession,
resource: GarminResource,
window: { startDate: string; endDate: string },
tz: string,
): Promise<GarminWindowRead> {
const out: GarminWindowRead = { measurements: [], workouts: [] };
const chunks = chunkGarminRange(
window.startDate,
window.endDate,
GARMIN_RESOURCE_WINDOW_DAYS[resource],
);
for (const chunk of chunks) {
switch (resource) {
case "daily": {
const env = await session.get<GarminDailySummaryEnvelope>(
"dailySummaries",
GARMIN_PATHS.dailySummaries,
chunk,
);
for (const d of env?.data ?? []) {
const rows = mapGarminDailySummary(d, tz);
if (rows.length > 0) {
out.measurements.push(...rows);
out.oldestDay = minDay(out.oldestDay, d.calendarDate ?? undefined);
}
}
break;
}
case "sleep": {
const nights = await session.get<
GarminSleepSummary[] | GarminSleepSummary
>("sleepSummaries", GARMIN_PATHS.sleepSummaries, chunk);
const list = Array.isArray(nights) ? nights : nights ? [nights] : [];
for (const night of list) {
const rows = mapGarminSleep(night, tz);
if (rows.length > 0) {
out.measurements.push(...rows);
out.oldestDay = minDay(
out.oldestDay,
night.calendarDate ?? undefined,
);
}
}
break;
}
case "hrv": {
const env = await session.get<GarminHrvEnvelope>(
"hrvDaily",
`${GARMIN_PATHS.hrvDaily}/${chunk.startDate}/${chunk.endDate}`,
);
out.measurements.push(...mapGarminHrv(env, tz));
break;
}
case "weight": {
const env = await session.get<GarminWeightRangeEnvelope>(
"weightRange",
`${GARMIN_PATHS.weightRange}/${chunk.startDate}/${chunk.endDate}`,
{ includeAll: true },
);
out.measurements.push(...mapGarminWeightRange(env));
break;
}
case "activities": {
for (const a of await readActivities(session, chunk)) {
const row = mapGarminActivity(a);
if (!row) continue;
out.workouts.push(row);
out.oldestDay = minDay(out.oldestDay, userDayKey(row.startedAt, tz));
}
break;
}
}
}
return out;
}
/**
* Write mapped Garmin readings through the shared reconciler, in chunks so a
* year of daily summaries never rides one long transaction. Returns how many
* readings were written (the plausibility gate's refusals are not counted).
*/
export async function upsertGarminMeasurements(
userId: string,
readings: readonly GarminMappedMeasurement[],
opts: {
emitArrivals?: boolean;
onInserted?: (rows: InsertedMeasurementArrivalRow[]) => void;
} = {},
): Promise<number> {
if (readings.length === 0) return 0;
let imported = 0;
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
const insertedRows: InsertedMeasurementArrivalRow[] = [];
for (let i = 0; i < readings.length; i += WRITE_CHUNK) {
const chunk = readings.slice(i, i + WRITE_CHUNK);
const verdicts = await prisma.$transaction(
async (tx) => {
const outcomes = [];
for (const r of chunk) {
const verdict = await reconcileExternalMeasurement(
tx,
{
userId,
type: r.type as MeasurementType,
source: "GARMIN",
value: r.value,
unit: r.unit,
measuredAt: r.measuredAt,
externalId: r.externalId,
sleepStage: r.sleepStage ?? null,
deviceType: r.deviceType ?? null,
},
{ exactExternalMatch: "update" },
);
if (verdict.status === "failed") {
throw new MeasurementReconciliationError(verdict);
}
outcomes.push(verdict);
}
return outcomes;
},
{ timeout: 60_000 },
);
chunk.forEach((reading, index) => {
const verdict = verdicts[index]!;
if (verdict.status === "rejected_range") return;
for (const dirty of verdict.dirtyIdentities ?? []) touched.push(dirty);
imported += 1;
touched.push({
type: reading.type as MeasurementType,
measuredAt: reading.measuredAt,
});
if (verdict.status === "inserted") insertedRows.push(verdict.row);
});
}
opts.onInserted?.(insertedRows);
await afterProviderWrite({
userId,
provider: "garmin",
touched,
...(opts.emitArrivals !== false
? { arrivals: { rows: insertedRows, source: "garmin" } }
: {}),
});
return imported;
}
/**
* How far back this tick reaches: from the newest Garmin row held, plus a
* day of overlap, never under the re-score floor nor over the ceiling. No
* row at all (a fresh connection) takes the ceiling; the backfill takes the
* rest.
*/
export async function resolveGarminLookbackDays(
userId: string,
): Promise<number> {
const newest = await prisma.measurement.findFirst({
where: { userId, source: "GARMIN", deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { measuredAt: true },
});
if (!newest) return GARMIN_SYNC_MAX_LOOKBACK_DAYS;
const daysSince = Math.floor(
(Date.now() - newest.measuredAt.getTime()) / DAY_MS,
);
return Math.min(
GARMIN_SYNC_MAX_LOOKBACK_DAYS,
Math.max(GARMIN_SYNC_LOOKBACK_DAYS, daysSince + 1),
);
}
export interface GarminSyncOptions {
lookbackDays?: number;
/** Skip a connection parked on a refused key (the hourly cohort does). */
skipWhenParked?: boolean;
/** Test seam: the transport the session uses. */
transport?: GarminTransport;
}
/**
* Sync one account's recent Garmin data. `imported` counts rows written
* (measurements and workouts); `failed` is true when a resource did not
* settle. An account without a key is a clean no-op.
*/
export async function syncUserGarmin(
userId: string,
opts: GarminSyncOptions = {},
): Promise<SyncWriteResult> {
const creds = await getGarminCredentials(userId);
if (!creds) return { imported: 0, failed: false };
if (opts.skipWhenParked && (await isReauthRequired(userId, "garmin"))) {
annotate({ action: { name: "garmin.sync.skipped_reauth" } });
return { imported: 0, failed: false };
}
const tz = await resolveUserTimezone(userId);
const today = userDayKey(new Date(), tz);
const lookbackDays =
opts.lookbackDays ?? (await resolveGarminLookbackDays(userId));
const window = {
startDate: shiftDateKey(today, -lookbackDays),
endDate: today,
};
const session = new GarminSession({
userId,
apiKey: creds.apiKey,
region: creds.region,
transport: opts.transport,
});
let imported = 0;
const failures: Array<{ resource: GarminResource; err: unknown }> = [];
let insertedSleep: Date[] = [];
for (const resource of GARMIN_RESOURCES) {
try {
const read = await readGarminWindow(session, resource, window, tz);
imported += await upsertGarminMeasurements(userId, read.measurements, {
onInserted: (rows) => {
if (resource !== "sleep") return;
insertedSleep = rows
.filter((row) => row.type === "SLEEP_DURATION")
.map((row) => row.measuredAt);
},
});
imported += await upsertGarminWorkouts(userId, read.workouts);
} catch (err) {
if (isGarminKeyRefusal(err)) {
// The key is gone: nothing else in this run can succeed.
await recordGarminSyncFailure(userId, err);
throw markSyncFailureRecorded(err);
}
failures.push({ resource, err });
getEvent()?.addWarning(
`garmin: ${resource} failed for ${userId}: ${err instanceof Error ? err.message : String(err)}`,
);
// A rate limit is for the whole allowance: stop asking until next tick.
if (isGarminRateLimit(err)) break;
}
}
// Best-effort, off the ledger: most accounts track no cycle in Garmin.
if (!failures.some((f) => isGarminRateLimit(f.err))) {
try {
await syncGarminCycle(userId, session, today);
} catch (err) {
if (isGarminKeyRefusal(err)) {
await recordGarminSyncFailure(userId, err);
throw markSyncFailureRecorded(err);
}
getEvent()?.addWarning(
`garmin: cycle import skipped for ${userId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
try {
await rememberGarminKeyExpiry(
userId,
session.learnedKeyExpiresAt,
creds.keyExpiresAt,
);
} catch (err) {
getEvent()?.addWarning(`garmin: key expiry note failed: ${err}`);
}
void maybeEnqueueMorningRefresh(userId, insertedSleep).catch(() => {});
if (imported > 0) invalidateUserMeasurements(userId);
annotate({
meta: {
"garmin.sync": {
lookbackDays,
imported,
exchanges: session.exchanges,
failures: failures.map((f) => f.resource),
},
},
});
if (failures.length > 0) {
const first = failures[0]!.err;
await recordSyncFailure({
userId,
integration: "garmin",
kind: classifyGarminFailure(first),
message: `partial sync failure (${failures.map((f) => f.resource).join(", ")}): ${
first instanceof GarminApiError ? first.message : "error"
}`,
errorCode:
first instanceof GarminApiError && first.httpStatus != null
? String(first.httpStatus)
: undefined,
});
return { imported, failed: true };
}
await recordSyncSuccess(userId, "garmin");
return { imported, failed: false };
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Garmin Personal API wire shapes, the parts the sync reads.
*
* Taken from Garmin's published OpenAPI document (Personal API 1.0.0). Every
* field is optional and nullable on purpose: a day without data comes back as
* an object whose fields are all null, and the live service has been seen to
* send epoch milliseconds where the document says a date-time string (sleep
* window instants, weigh-in instants). The mappers in `./map.ts` accept both.
*/
/** An instant as Garmin sends it: epoch ms, or an ISO-like string without zone. */
export type GarminInstant = number | string | null | undefined;
/** `GET /usersummary-service/v2/dailysummaries` → `{ data: [...] }`. */
export interface GarminDailySummary {
calendarDate?: string | null;
includesWellnessData?: boolean | null;
movement?: {
steps?: { value?: number | null; distanceInMeters?: number | null } | null;
floorsAscended?: { value?: number | null } | null;
} | null;
calories?: { burnedActive?: number | null } | null;
heartRate?: { restingValue?: number | null } | null;
stress?: { avgLevel?: number | null } | null;
bodyBattery?: { maxValue?: number | null } | null;
}
export interface GarminDailySummaryEnvelope {
data?: GarminDailySummary[] | null;
}
/** One entry of `GET /sleep-service/sleep/dailySleepSummariesByDate`. */
export interface GarminSleepSummary {
id?: number | string | null;
calendarDate?: string | null;
sleepTimeSeconds?: number | null;
sleepEndTimestampGMT?: GarminInstant;
deepSleepSeconds?: number | null;
lightSleepSeconds?: number | null;
remSleepSeconds?: number | null;
awakeSleepSeconds?: number | null;
averageSpO2Value?: number | null;
averageRespirationValue?: number | null;
sleepScores?: Record<
string,
{ value?: number | null; qualifierKey?: string | null } | null
> | null;
}
/** `GET /hrv-service/hrv/daily/{fromDate}/{toDate}`. */
export interface GarminHrvEnvelope {
hrvSummaries?: Array<{
calendarDate?: string | null;
/** Average overnight HRV. Garmin measures RMSSD. */
lastNightAvg?: number | null;
}> | null;
}
/** One weigh-in. Masses are grams on the live service, whatever the document says. */
export interface GarminWeighIn {
samplePk?: number | string | null;
date?: GarminInstant;
timestampGMT?: GarminInstant;
calendarDate?: string | null;
weight?: number | null;
bmi?: number | null;
bodyFat?: number | null;
bodyWater?: number | null;
boneMass?: number | null;
muscleMass?: number | null;
visceralFat?: number | null;
sourceType?: string | null;
}
/** `GET /weight-service/weight/range/{start}/{end}?includeAll=true`. */
export interface GarminWeightRangeEnvelope {
dailyWeightSummaries?: Array<{
summaryDate?: string | null;
latestWeight?: GarminWeighIn | null;
allWeightMetrics?: GarminWeighIn[] | null;
}> | null;
}
/** One entry of `GET /activitylist-service/activities/search/activities`. */
export interface GarminActivity {
activityId?: number | string | null;
startTimeGMT?: string | null;
beginTimestamp?: number | null;
activityType?: { typeKey?: string | null } | null;
distance?: number | null;
duration?: number | null;
elapsedDuration?: number | null;
movingDuration?: number | null;
elevationGain?: number | null;
calories?: number | null;
averageHR?: number | null;
maxHR?: number | null;
steps?: number | null;
aerobicTrainingEffect?: number | null;
anaerobicTrainingEffect?: number | null;
activityTrainingLoad?: number | null;
}
/** A date the menstrual-cycle service sends either bare or wrapped. */
export type GarminCycleDate =
| string
| { localDate?: string | null; date?: string | null }
| null
| undefined;
/** `GET /periodichealth-service/menstrualcycle/summary/{calendarDate}`. */
export interface GarminCycleSummary {
startDate?: GarminCycleDate;
periodLength?: number | null;
predictedCycle?: boolean | null;
pregnancyCycle?: boolean | null;
}
+2
View File
@@ -36,6 +36,7 @@ export const MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS: Record<
NIGHTSCOUT: "settings.sections.sources.sourceLabels.NIGHTSCOUT",
POLAR: "settings.sections.sources.sourceLabels.POLAR",
OURA: "settings.sections.sources.sourceLabels.OURA",
GARMIN: "settings.sections.sources.sourceLabels.GARMIN",
TELEGRAM: "settings.sections.sources.sourceLabels.TELEGRAM",
MCP: "settings.sections.sources.sourceLabels.MCP",
GOOGLE_HEALTH: "settings.sections.sources.sourceLabels.GOOGLE_HEALTH",
@@ -65,6 +66,7 @@ export const MEASUREMENT_SOURCE_LIST_LABEL_KEYS: Record<
NIGHTSCOUT: "measurements.sourceNightscout",
POLAR: "measurements.sourcePolar",
OURA: "measurements.sourceOura",
GARMIN: "measurements.sourceGarmin",
TELEGRAM: "measurements.sourceTelegram",
MCP: "measurements.sourceMcp",
GOOGLE_HEALTH: "measurements.sourceGoogleHealth",
+5
View File
@@ -149,6 +149,11 @@ export const ALLOWED_CHART_TOKENS = [
"metric:INDOOR_CO2",
"metric:INDOOR_TEMPERATURE",
"metric:INDOOR_HUMIDITY",
// Garmin scores and the bridged training figures.
"metric:BODY_BATTERY",
"metric:STRESS_LEVEL",
"metric:TRAINING_READINESS",
"metric:ACUTE_TRAINING_LOAD",
"metric:GRIP_STRENGTH",
"metric:PAIN_NRS",
"metric:WAIST_CIRCUMFERENCE",
@@ -141,6 +141,11 @@ export type MetricStatusMetricId =
| "AVERAGE_HEART_RATE"
| "MAX_HEART_RATE"
| "ENERGY_EXPENDITURE_KJ"
// Garmin's day scores and the bridged training figures.
| "BODY_BATTERY"
| "STRESS_LEVEL"
| "TRAINING_READINESS"
| "ACUTE_TRAINING_LOAD"
// v1.25 — physical / clinical signals promoted to detail pages. Grip
// strength + waist carry a sex-aware band via the `norms.ts` tables the
// generic generator already threads through `lookupNormalRange`.
@@ -602,6 +607,44 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
direction: "target-band",
archetype: "activity-fitness",
},
// ── Garmin ──
// Body Battery — Garmin's 0–100 energy reserve; the day's highest charge.
BODY_BATTERY: {
id: "BODY_BATTERY",
measurementType: "BODY_BATTERY",
displayName: "Body Battery",
unit: "score",
direction: "higher-better",
archetype: "physiological-vital",
},
// Stress level — Garmin's 0–100 all-day average; lower is calmer.
STRESS_LEVEL: {
id: "STRESS_LEVEL",
measurementType: "STRESS_LEVEL",
displayName: "Stress level",
unit: "score",
direction: "lower-better",
archetype: "physiological-vital",
},
// Training readiness — Garmin's 0–100 morning readiness, bridged in.
TRAINING_READINESS: {
id: "TRAINING_READINESS",
measurementType: "TRAINING_READINESS",
displayName: "Training readiness",
unit: "score",
direction: "higher-better",
archetype: "physiological-vital",
},
// Acute training load — Garmin's seven-day load, bridged in. Descriptive
// against the personal baseline like cardio load.
ACUTE_TRAINING_LOAD: {
id: "ACUTE_TRAINING_LOAD",
measurementType: "ACUTE_TRAINING_LOAD",
displayName: "Acute training load",
unit: "load",
direction: "target-band",
archetype: "activity-fitness",
},
// Cardio load — training-load proxy (acute cardiovascular load).
// Descriptive against the personal baseline.
CARDIO_LOAD: {
+4
View File
@@ -26,6 +26,10 @@ export const RECOVERY_SUMMARY_TYPES = [
"DAY_STRAIN",
"WORKOUT_STRAIN",
"CARDIO_LOAD",
"BODY_BATTERY",
"STRESS_LEVEL",
"TRAINING_READINESS",
"ACUTE_TRAINING_LOAD",
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"ENERGY_EXPENDITURE_KJ",
@@ -184,6 +184,7 @@ export async function loadConnectionFacts(
stravaAccessTokenEncrypted: true,
nightscoutUrlEncrypted: true,
dawarichUrlEncrypted: true,
garminApiKeyEncrypted: true,
healthKitLastSyncedAt: true,
},
}),
@@ -245,6 +246,7 @@ export async function loadConnectionFacts(
strava: !!dbUser?.stravaAccessTokenEncrypted,
nightscout: !!dbUser?.nightscoutUrlEncrypted,
dawarich: !!dbUser?.dawarichUrlEncrypted,
garmin: !!dbUser?.garminApiKeyEncrypted,
// Apple Health has nothing to connect; it is "connected" once the phone
// has delivered anything (a live batch, or rows from the export import).
"apple-health": false,
@@ -44,6 +44,7 @@ export const CONNECTION_KEYS: readonly ConnectionKey[] = [
"strava",
"nightscout",
"dawarich",
"garmin",
"apple-health",
];
+2
View File
@@ -54,6 +54,7 @@ export const INTEGRATION_MEASUREMENT_SOURCE: Partial<
polar: "POLAR",
oura: "OURA",
"google-health": "GOOGLE_HEALTH",
garmin: "GARMIN",
};
/**
@@ -72,6 +73,7 @@ export const INTEGRATION_WORKOUT_SOURCE: Partial<
whoop: "WHOOP",
fitbit: "FITBIT",
"google-health": "GOOGLE_HEALTH",
garmin: "GARMIN",
};
/**
+2
View File
@@ -49,6 +49,7 @@ export async function getSourcesInUse(
polarAccessTokenEncrypted: true,
ouraAccessTokenEncrypted: true,
stravaAccessTokenEncrypted: true,
garminApiKeyEncrypted: true,
},
}),
prisma.withingsConnection.findUnique({
@@ -79,6 +80,7 @@ export async function getSourcesInUse(
if (user?.polarAccessTokenEncrypted) inUse.add("POLAR");
if (user?.ouraAccessTokenEncrypted) inUse.add("OURA");
if (user?.stravaAccessTokenEncrypted) inUse.add("STRAVA");
if (user?.garminApiKeyEncrypted) inUse.add("GARMIN");
return RANKABLE_SOURCES.filter((s) => inUse.has(s));
}
+4 -1
View File
@@ -63,7 +63,9 @@ export type IntegrationKey =
// v1.28.x — Strava OAuth workout source.
| "strava"
// v1.43 — Dawarich movement context (daily values, nightly).
| "dawarich";
| "dawarich"
// Garmin Personal API (a pasted key, hourly poll).
| "garmin";
/**
* Failure kinds carried into `recordSyncFailure`.
@@ -1281,6 +1283,7 @@ const INTEGRATION_LABELS: Record<IntegrationKey, string> = {
"google-health": "Google Health",
strava: "Strava",
dawarich: "Dawarich",
garmin: "Garmin",
};
const FAILURE_KIND_COPY: Record<
+1
View File
@@ -126,6 +126,7 @@ export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
"google-health": POLLED_CADENCE,
strava: POLLED_CADENCE,
dawarich: NIGHTLY_CADENCE,
garmin: POLLED_CADENCE,
};
export interface SyncVerdictInput {
+4 -1
View File
@@ -62,7 +62,7 @@ export const INCIDENT_AFTER_MS = DAY_MS;
*
* - 24 h for wearables and CGMs whose cloud receives data on its own
* (Fitbit, Google Health, Polar, Nightscout).
* - 48 h for Oura and WHOOP. Their rings and straps upload only when the
* - 48 h for Oura, WHOOP and Garmin. Their rings and straps upload only when the
* phone app is opened, so a day without opening it is ordinary and must
* not page anyone.
* - 48 h for Apple Health. iOS defers background delivery when the phone is
@@ -86,6 +86,9 @@ export const SOURCE_PROFILES: Record<ConnectionKey, SourceProfile> = {
polar: { kind: "continuous", silenceWindowMs: DAY_MS },
nightscout: { kind: "continuous", silenceWindowMs: DAY_MS },
dawarich: { kind: "on_demand", silenceWindowMs: DAY_MS },
// A Garmin watch uploads through the phone app like the rings and straps,
// so a day without opening it is ordinary.
garmin: { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
"apple-health": { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
};
+2
View File
@@ -76,6 +76,7 @@ const LABELS: Record<ConnectionKey, string> = {
strava: "Strava",
nightscout: "Nightscout",
dawarich: "Dawarich",
garmin: "Garmin",
"apple-health": "Apple Health",
};
@@ -145,6 +146,7 @@ async function listWatchedUserIds(): Promise<string[]> {
{ stravaAccessTokenEncrypted: { not: null } },
{ nightscoutUrlEncrypted: { not: null } },
{ dawarichUrlEncrypted: { not: null } },
{ garminApiKeyEncrypted: { not: null } },
{ healthKitLastSyncedAt: { not: null } },
],
},
@@ -68,6 +68,11 @@ vi.mock("@/lib/jobs/oura-backfill", async (importOriginal) => ({
enqueueBootTimeOuraBackfill: async (stagger: number) =>
recordStagger("oura-backfill", stagger),
}));
vi.mock("@/lib/jobs/garmin-backfill", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/jobs/garmin-backfill")>()),
enqueueBootTimeGarminBackfill: async (stagger: number) =>
recordStagger("garmin-backfill", stagger),
}));
import {
INTEGRATION_BACKFILL_ADMISSION_GROUP,
@@ -94,6 +99,7 @@ import {
} from "../integration-poll-queues";
import { LAB_BIOMARKER_BACKFILL_QUEUE } from "../lab-biomarker-backfill";
import { OURA_BACKFILL_QUEUE } from "../oura-backfill-queue";
import { GARMIN_BACKFILL_QUEUE } from "../garmin-backfill-queue";
import {
groupNamed,
jobOf,
@@ -113,6 +119,7 @@ const providerSources = [
"strava-backfill.ts",
"../fitbit/jobs/sleep-repair.ts",
"oura-backfill.ts",
"garmin-backfill.ts",
].map((file) => readFileSync(join(__dirname, "..", file), "utf8"));
describe("shared full-history backfill admission", () => {
@@ -127,9 +134,10 @@ describe("shared full-history backfill admission", () => {
"strava-backfill",
"fitbit-sleep-repair",
"oura-backfill",
"garmin-backfill",
]);
expect(INTEGRATION_BACKFILL_BOOT_ORDER.map(bootStaggerSecondsFor)).toEqual([
30, 60, 90, 120, 150, 180, 210, 240, 270,
30, 60, 90, 120, 150, 180, 210, 240, 270, 300,
]);
expect(INTEGRATION_BACKFILL_STAGGER_SECONDS).toBe(30);
@@ -232,6 +240,7 @@ describe("shared full-history backfill admission", () => {
[STRAVA_BACKFILL_QUEUE, "strava-backfill"],
[FITBIT_SLEEP_REPAIR_QUEUE, "fitbit-sleep-repair"],
[OURA_BACKFILL_QUEUE, "oura-backfill"],
[GARMIN_BACKFILL_QUEUE, "garmin-backfill"],
];
for (const [queue, kind] of sources) {
const { boss, sends } = recordingBoss();
+9
View File
@@ -0,0 +1,9 @@
/**
* The queue facts of `garmin-backfill.ts`: its name and concurrency, apart
* from the handler so the worker registry and every sender can read them
* without loading the job.
*/
export const GARMIN_BACKFILL_QUEUE = "garmin-backfill";
/** One account at a time; the shared lane bounds the instance. */
export const GARMIN_BACKFILL_CONCURRENCY = 1;
+506
View File
@@ -0,0 +1,506 @@
/**
* The Garmin history backfill: every resource's history, read once,
* in resumable slices through the shared integration backfill lane.
*
* The hourly sync reads a window of recent days; everything older is this
* job's. A connect clears `garminBackfillCompletedAt` and sends the first
* slice; boot discovery re-offers every connection whose stamp is unset. A
* slice walks the resources in `GARMIN_BACKFILL_STEPS` order, window by
* window, noting after every window where it got to
* (`../garmin/backfill-progress.ts`). It ends on its own budget, on the
* job's, when another heavy job wants the lane, or on Garmin's rate limit
* (waiting at least as long as `Retry-After` asks), and the next slice picks
* up at the window it stopped before.
*
* A refused key ends the chain: the connection is parked at
* `reauth_required` and a reconnect starts a new chain where this one
* stopped.
*/
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
enqueueIntegrationBackfillAdmission,
INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS,
integrationBackfillSourceOptions,
} from "@/lib/jobs/integration-backfill-admission";
import { LANE_YIELD_DELAY_SECONDS, laneWanted } from "@/lib/jobs/heavy-lane";
import { reportJobProgress } from "@/lib/jobs/job-observer";
import { isReauthRequired } from "@/lib/integrations/status";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
GarminSession,
isGarminKeyRefusal,
isGarminRateLimit,
type GarminTransport,
} from "@/lib/garmin/client";
import {
getGarminCredentials,
rememberGarminKeyExpiry,
} from "@/lib/garmin/credentials";
import {
periodFromGarminCycleSummary,
readGarminCycleSummary,
writeGarminPeriodDays,
} from "@/lib/garmin/cycle-sync";
import {
GARMIN_RESOURCE_WINDOW_DAYS,
readGarminWindow,
recordGarminSyncFailure,
upsertGarminMeasurements,
} from "@/lib/garmin/sync";
import { upsertGarminWorkouts } from "@/lib/garmin/sync-workouts";
import {
GARMIN_BACKFILL_STEPS,
GARMIN_HISTORY_FLOOR,
garminBackfillProgressJson,
readGarminBackfillProgress,
type GarminBackfillProgress,
type GarminBackfillStep,
type GarminBackfillStepState,
} from "@/lib/garmin/backfill-progress";
import { GARMIN_BACKFILL_QUEUE } from "./garmin-backfill-queue";
/** One slice: five minutes of walking, then the rest is the next slice's. */
export const GARMIN_BACKFILL_SLICE_MS = 5 * 60 * 1000;
/** The pause after a rate limit that named no wait. */
export const GARMIN_BACKFILL_THROTTLED_DELAY_SECONDS = 15 * 60;
/**
* The highest slice a chain may reach. Eighteen years of history is a few
* hundred windows in all; a slice writes dozens. A chain past this count
* counts progress it does not make.
*/
export const GARMIN_BACKFILL_MAX_SLICE = 500;
/** The resources that find where history starts; walked first. */
const HISTORY_PROBES: readonly GarminBackfillStep[] = ["daily", "activities"];
/** How far before the learned history start the other resources reach. */
const HISTORY_MARGIN_DAYS = 7;
/** Window size of the history walk per resource (the probes read wide). */
const BACKFILL_WINDOW_DAYS: Record<
Exclude<GarminBackfillStep, "cycle">,
number
> = {
daily: GARMIN_RESOURCE_WINDOW_DAYS.daily,
activities: 365,
sleep: 93,
hrv: 93,
weight: 186,
};
/** Cycles read per slice at most (one request each). */
const MAX_CYCLES_PER_SLICE = 60;
export interface GarminBackfillPayload {
userId: string;
enqueuedAt: string;
}
export interface GarminBackfillSliceOptions {
sliceMs?: number;
now?: () => number;
transport?: GarminTransport;
}
type StopReason = "budget" | "lane_wanted" | "rate_limited";
function minDay(
a: string | undefined,
b: string | undefined,
): string | undefined {
if (b === undefined) return a;
return a === undefined || b < a ? b : a;
}
function floorFor(
step: GarminBackfillStep,
progress: GarminBackfillProgress,
): string | undefined {
if (HISTORY_PROBES.includes(step)) return GARMIN_HISTORY_FLOOR;
if (progress.historyStart === undefined) return undefined;
if (progress.historyStart === null) return progress.anchor;
const margin = shiftDateKey(progress.historyStart, -HISTORY_MARGIN_DAYS);
return margin < GARMIN_HISTORY_FLOOR ? GARMIN_HISTORY_FLOOR : margin;
}
function settleHistoryStart(progress: GarminBackfillProgress): void {
if (progress.historyStart !== undefined) return;
if (HISTORY_PROBES.every((s) => progress.steps[s]?.done)) {
progress.historyStart = progress.oldestSeen ?? null;
}
}
async function saveProgress(
userId: string,
progress: GarminBackfillProgress,
): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { garminBackfillProgress: garminBackfillProgressJson(progress) },
});
}
interface SliceContext {
userId: string;
tz: string;
progress: GarminBackfillProgress;
session: GarminSession;
cycleAvailable: boolean;
shouldStop: () => boolean;
now: () => number;
deadline: number;
}
interface SliceWalk {
imported: number;
windowsWritten: number;
newlySettled: number;
stopReason: StopReason | null;
retryAfterMs: number | undefined;
failure: unknown;
failures: GarminBackfillStep[];
}
/**
* Read one window of a step and write it. Returns rows written, or null when
* the step reached its end (the cycle walk found no older cycle).
*/
async function readStepWindow(
ctx: SliceContext,
step: GarminBackfillStep,
state: GarminBackfillStepState,
floor: string,
): Promise<number | null> {
const { userId, progress, session } = ctx;
if (step === "cycle") {
const summary = await readGarminCycleSummary(session, state.before);
const period = periodFromGarminCycleSummary(summary, progress.anchor);
// No logged cycle at or before this day: the walk is over.
if (!period || period.start > state.before) return null;
await writeGarminPeriodDays(userId, period.days);
state.before = shiftDateKey(period.start, -1);
return 0;
}
const shifted = shiftDateKey(state.before, -(BACKFILL_WINDOW_DAYS[step] - 1));
const startDate = shifted < floor ? floor : shifted;
const read = await readGarminWindow(
session,
step,
{ startDate, endDate: state.before },
ctx.tz,
);
let written = await upsertGarminMeasurements(userId, read.measurements, {
emitArrivals: false,
});
written += await upsertGarminWorkouts(userId, read.workouts, {
emitArrivals: false,
});
if (HISTORY_PROBES.includes(step)) {
progress.oldestSeen = minDay(progress.oldestSeen, read.oldestDay);
}
state.before = shiftDateKey(startDate, -1);
return written;
}
/** Walk the steps until the slice's budget, the lane, a limit or the end. */
async function walkSlice(ctx: SliceContext): Promise<SliceWalk> {
const { userId, progress } = ctx;
const out: SliceWalk = {
imported: 0,
windowsWritten: 0,
newlySettled: 0,
stopReason: null,
retryAfterMs: undefined,
failure: undefined,
failures: [],
};
const settle = async (state: { done?: boolean }) => {
state.done = true;
out.newlySettled += 1;
settleHistoryStart(progress);
await saveProgress(userId, progress);
};
walk: for (const step of GARMIN_BACKFILL_STEPS) {
const state = progress.steps[step] ?? { before: progress.anchor };
progress.steps[step] = state;
if (state.done) continue;
const floor = floorFor(step, progress);
if (floor === undefined) break;
if (step === "cycle" && !ctx.cycleAvailable) {
await settle(state);
continue;
}
let windows = 0;
while (!state.done) {
if (state.before <= floor) {
await settle(state);
break;
}
if (
ctx.shouldStop() ||
ctx.now() >= ctx.deadline ||
(step === "cycle" && windows >= MAX_CYCLES_PER_SLICE)
) {
out.stopReason = "budget";
break walk;
}
if (laneWanted()) {
out.stopReason = "lane_wanted";
break walk;
}
try {
windows += 1;
const written = await readStepWindow(ctx, step, state, floor);
if (written === null) {
await settle(state);
break;
}
out.imported += written;
state.windows = (state.windows ?? 0) + 1;
out.windowsWritten += 1;
await saveProgress(userId, progress);
} catch (err) {
if (isGarminKeyRefusal(err)) {
await recordGarminSyncFailure(userId, err);
out.failure = err;
out.failures.push(step);
break walk;
}
if (isGarminRateLimit(err)) {
out.stopReason = "rate_limited";
out.retryAfterMs = err.retryAfterMs;
break walk;
}
// One resource's failure stays with it; the window is read again
// next slice.
out.failures.push(step);
out.failure ??= err;
getEvent()?.addWarning(
`garmin backfill: ${step} before ${state.before} failed: ${err instanceof Error ? err.message : String(err)}`,
);
continue walk;
}
}
}
return out;
}
/**
* Run one slice of the backfill for one account. Returns how many rows it
* wrote and whether it sent the next slice.
*/
export async function runGarminBackfillForUser(
userId: string,
shouldStop: () => boolean = () => false,
slice: number = 0,
options: GarminBackfillSliceOptions = {},
): Promise<{ imported: number; continued: boolean }> {
if (slice > GARMIN_BACKFILL_MAX_SLICE) {
throw new Error(
`garmin backfill for user ${userId} reached slice ${slice}, past the ceiling of ${GARMIN_BACKFILL_MAX_SLICE}`,
);
}
const now = options.now ?? Date.now;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { garminBackfillProgress: true, garminBackfillCompletedAt: true },
});
if (!user || user.garminBackfillCompletedAt) {
return { imported: 0, continued: false };
}
const creds = await getGarminCredentials(userId);
if (!creds) {
// Disconnected since the job was sent: nothing to read, and a reconnect
// reopens the backfill.
return { imported: 0, continued: false };
}
if (await isReauthRequired(userId, "garmin")) {
annotate({
action: { name: "garmin.backfill.skipped_reauth", details: { slice } },
});
return { imported: 0, continued: false };
}
const tz = await resolveUserTimezone(userId);
const progress: GarminBackfillProgress = readGarminBackfillProgress(
user.garminBackfillProgress,
) ?? { anchor: userDayKey(new Date(now()), tz), steps: {} };
const session = new GarminSession({
userId,
apiKey: creds.apiKey,
region: creds.region,
transport: options.transport,
});
const walked = await walkSlice({
userId,
tz,
progress,
session,
cycleAvailable: await isCycleAvailableForUser(userId),
shouldStop,
now,
deadline: now() + (options.sliceMs ?? GARMIN_BACKFILL_SLICE_MS),
});
const { imported, windowsWritten, newlySettled, stopReason, failures } =
walked;
const { retryAfterMs, failure } = walked;
try {
await rememberGarminKeyExpiry(
userId,
session.learnedKeyExpiresAt,
creds.keyExpiresAt,
);
} catch (err) {
// A convenience; the backfill does not depend on it.
getEvent()?.addWarning(`garmin backfill: key expiry note failed: ${err}`);
}
const settled = GARMIN_BACKFILL_STEPS.every((s) => progress.steps[s]?.done);
annotate({
meta: {
"garmin.backfill.slice": {
slice,
stopReason,
failures,
windowsWritten,
imported,
historyStart: progress.historyStart ?? null,
edges: Object.fromEntries(
Object.entries(progress.steps).map(([n, s]) => [
n,
s!.done ? "done" : s!.before,
]),
),
},
},
});
reportJobProgress({
garmin_backfill_slice: slice,
garmin_backfill_windows: windowsWritten,
});
if (settled && stopReason === null && failures.length === 0) {
await prisma.user.update({
where: { id: userId },
data: {
garminBackfillCompletedAt: new Date(now()),
garminBackfillProgress: garminBackfillProgressJson(progress),
},
});
annotate({
action: {
name: "garmin.backfill.complete",
details: { imported, slice },
},
});
return { imported, continued: false };
}
if (isGarminKeyRefusal(failure)) {
// Parked; a reconnect sends the next chain.
return { imported, continued: false };
}
const progressed = windowsWritten > 0 || newlySettled > 0;
if (progressed || stopReason === "rate_limited") {
const delaySeconds =
stopReason === "rate_limited"
? Math.max(
INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS,
retryAfterMs !== undefined
? Math.ceil(retryAfterMs / 1000)
: GARMIN_BACKFILL_THROTTLED_DELAY_SECONDS,
)
: stopReason === "lane_wanted"
? LANE_YIELD_DELAY_SECONDS
: INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS;
if (await sendNextSlice(userId, slice, delaySeconds)) {
annotate({
action: {
name: "garmin.backfill.continued",
details: { slice, stopReason, windowsWritten, delaySeconds },
},
});
return { imported, continued: true };
}
}
throw new Error(
`garmin backfill incomplete for user ${userId} (slice ${slice}${stopReason ? `, stopped on ${stopReason}` : ""}${failure instanceof Error ? `: ${failure.message}` : ""})`,
);
}
async function sendNextSlice(
userId: string,
slice: number,
delaySeconds: number,
): Promise<boolean> {
if (slice + 1 > GARMIN_BACKFILL_MAX_SLICE) return false;
const boss = getGlobalBoss();
if (!boss) return false;
const sent = await enqueueIntegrationBackfillAdmission(
boss,
{
kind: "garmin-backfill",
data: { userId, enqueuedAt: new Date().toISOString(), slice: slice + 1 },
},
delaySeconds,
);
return Boolean(sent);
}
/**
* Boot-time discovery: every live account with a Garmin key whose backfill is
* not stamped. Idempotent across reboots; errors come back in the result so
* the worker boot never fails on a miss.
*/
export async function enqueueBootTimeGarminBackfill(
startAfterSeconds: number = 0,
): Promise<{ enqueued: number; skipped: number; error: string | null }> {
const boss = getGlobalBoss();
if (!boss) return { enqueued: 0, skipped: 0, error: null };
try {
const users = await prisma.user.findMany({
where: {
garminBackfillCompletedAt: null,
garminApiKeyEncrypted: { not: null },
...ACTIVE_ACCOUNT,
},
select: { id: true },
});
let enqueued = 0;
let skipped = 0;
for (const { id: userId } of users) {
const payload: GarminBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(
GARMIN_BACKFILL_QUEUE,
payload,
integrationBackfillSourceOptions(
`garmin-backfill|${userId}`,
startAfterSeconds,
),
);
if (jobId) enqueued += 1;
else skipped += 1;
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
@@ -20,6 +20,7 @@ export const INTEGRATION_BACKFILL_BOOT_ORDER = [
// provider's boot stagger stays exactly where it was.
"fitbit-sleep-repair",
"oura-backfill",
"garmin-backfill",
] as const;
export type IntegrationBackfillKind =
@@ -23,6 +23,7 @@ import { runGoogleHealthSleepRepairForUser } from "@/lib/jobs/google-health-slee
import { runFitbitSleepRepairForUser } from "@/lib/fitbit/jobs/sleep-repair";
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
import { runOuraBackfillForUser } from "@/lib/jobs/oura-backfill";
import { runGarminBackfillForUser } from "@/lib/jobs/garmin-backfill";
import { runSleepTimelineBackfillForUser } from "@/lib/jobs/sleep-timeline-backfill";
import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill";
import {
@@ -159,6 +160,20 @@ async function runAdmitted(
);
return;
}
case "garmin-backfill": {
const { imported, continued } = await runGarminBackfillForUser(
userId,
shouldStop,
data.slice ?? 0,
);
totals.imported += imported;
if (continued) totals.continued += 1;
workerLog(
"info",
`[garmin-backfill] user=${userId} imported=${imported}${continued ? " continued" : ""}`,
);
return;
}
}
}
+5 -1
View File
@@ -7,7 +7,7 @@
* a queue without loading the sync it feeds.
*
* The hourly minutes are staggered so the polls do not land on one boss poll:
* used are :00 :05 :08 :11 :13 :15 :17 :18 :20 :35 :41 :47 :50.
* used are :00 :05 :08 :11 :13 :15 :17 :18 :20 :23 :35 :41 :47 :50.
*/
/** Withings measure catch-net (the webhook is primary). */
@@ -73,6 +73,10 @@ export const POLAR_SYNC_CRON = "13 * * * *";
export const OURA_SYNC_QUEUE = "oura-sync";
export const OURA_SYNC_CRON = "15 * * * *";
/** Garmin Personal API poll (no webhooks exist for the personal tier). */
export const GARMIN_SYNC_QUEUE = "garmin-sync";
export const GARMIN_SYNC_CRON = "23 * * * *";
/** Strava poll (the webhook is not wired). */
export const STRAVA_SYNC_QUEUE = "strava-sync";
export const STRAVA_SYNC_CRON = "17 * * * *";
+2
View File
@@ -166,6 +166,8 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
"strava-sync": cohortSync("Strava"),
"strava-backfill": forwarder,
"oura-backfill": forwarder,
"garmin-sync": cohortSync("Garmin"),
"garmin-backfill": forwarder,
// ── Status and insights ────────────────────────────────────────────
"insights-general-status": {
+24 -1
View File
@@ -1,6 +1,6 @@
/**
* The third-party provider sync queues: Withings, WHOOP, Fitbit, Google
* Health, Nightscout, Polar, Oura, Strava, the full-history backfills and
* Health, Nightscout, Polar, Oura, Strava, Garmin, the full-history backfills and
* repairs that run through the shared admission lane, the integration
* watchdog, and the OAuth-state and handoff prunes.
*
@@ -27,6 +27,8 @@ import {
type IntegrationBackfillKind,
} from "@/lib/jobs/integration-backfill-admission";
import {
GARMIN_SYNC_CRON,
GARMIN_SYNC_QUEUE,
GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON,
GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE,
GOOGLE_HEALTH_SYNC_CRON,
@@ -83,6 +85,10 @@ import {
OURA_BACKFILL_CONCURRENCY,
OURA_BACKFILL_QUEUE,
} from "@/lib/jobs/oura-backfill-queue";
import {
GARMIN_BACKFILL_CONCURRENCY,
GARMIN_BACKFILL_QUEUE,
} from "@/lib/jobs/garmin-backfill-queue";
import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared";
import {
SLEEP_TIMELINE_BACKFILL_CONCURRENCY,
@@ -295,6 +301,18 @@ const jobs: readonly JobDefinition[] = [
OURA_BACKFILL_CONCURRENCY,
"oura-backfill",
),
defineJob({
queue: GARMIN_SYNC_QUEUE,
cron: GARMIN_SYNC_CRON,
work: { localConcurrency: 1 },
load: async () =>
(await import("@/lib/jobs/reminder/garmin-sync")).handleGarminSync,
}),
backfillSource(
GARMIN_BACKFILL_QUEUE,
GARMIN_BACKFILL_CONCURRENCY,
"garmin-backfill",
),
defineJob({
queue: STRAVA_SYNC_QUEUE,
cron: STRAVA_SYNC_CRON,
@@ -376,6 +394,11 @@ const discovery: readonly BootDiscovery[] = [
stagger,
),
),
backfillDiscovery("garmin-backfill", async (stagger) =>
(await import("@/lib/jobs/garmin-backfill")).enqueueBootTimeGarminBackfill(
stagger,
),
),
backfillDiscovery("lab-biomarker-backfill", async (stagger) =>
(
await import("@/lib/jobs/lab-biomarker-backfill")
+28
View File
@@ -0,0 +1,28 @@
/**
* Garmin poll-cohort sync handler.
*
* Poll-only: the Personal API has no webhooks, so one hourly cron tick (no
* `userId`) walks every account holding a Garmin key and re-syncs each via
* `syncUserGarmin`. A connection parked on a refused key is skipped until
* the person pastes a new one. One account's failure is booked on its ledger
* and the pass goes on.
*/
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
import { recordGarminSyncFailure, syncUserGarmin } from "@/lib/garmin/sync";
import { makePollCohortHandler, type PollCohortPayload } from "./poll-cohort";
export type GarminSyncPayload = PollCohortPayload;
export const handleGarminSync = makePollCohortHandler({
taskName: "job.garmin_sync",
findCohort: async (prisma) => {
const users = await prisma.user.findMany({
where: { garminApiKeyEncrypted: { not: null }, ...ACTIVE_ACCOUNT },
select: { id: true },
});
return users.map((u) => u.id);
},
syncUser: async (userId) =>
(await syncUserGarmin(userId, { skipWhenParked: true })).imported,
recordFailure: recordGarminSyncFailure,
});
@@ -309,6 +309,7 @@ export const SCHEDULED_AND_SENT_QUEUES: ReadonlySet<string> = new Set([
"environment-aq-history",
"environment-fetch",
"free-text-encryption-backfill",
"garmin-sync",
"geolite2-fetch",
"insight-pregenerate",
"intake-slot-dedup",
+2
View File
@@ -47,6 +47,8 @@ const subsystemSurface = {
"integration-watchdog-daily": { audience: "system" },
"strava-backfill": { audience: "system" },
"oura-backfill": { audience: "system" },
"garmin-sync": { audience: "account" },
"garmin-backfill": { audience: "system" },
"oidc-native-handoff-cleanup": { audience: "system" },
// register-status.ts
+6
View File
@@ -3,6 +3,12 @@ import { describe, expect, it } from "vitest";
import { redactSecrets, redactOptional, PATH_SECRET_PATHS } from "../redact";
describe("redactSecrets", () => {
it("redacts a Garmin Personal API key header", () => {
expect(redactSecrets("Authorization: ApiKey made-up-key-0001")).toBe(
"Authorization: ApiKey [REDACTED]",
);
});
it("redacts Bearer tokens", () => {
expect(redactSecrets("Authorization: Bearer hlk_abc123def456")).toBe(
"Authorization: Bearer [REDACTED]",
+2
View File
@@ -125,6 +125,8 @@ export function redactSecrets(input: string): string {
return (
redactPathSegments(redactPrismaInvocation(input))
.replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
// A Garmin Personal API key rides as `Authorization: ApiKey <key>`.
.replace(/\bApiKey\s+\S+/g, "ApiKey [REDACTED]")
.replace(/bot\d+:[A-Za-z0-9_-]+/g, "bot[REDACTED]")
// `sk-` is a common English-prefix substring (task-force, risk-
// management, disk-io). Without a word-boundary, the original
+4
View File
@@ -896,6 +896,10 @@ describe("MCP_METRIC_STATUS_DISCOVERY", () => {
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
"ENERGY_EXPENDITURE_KJ",
"BODY_BATTERY",
"STRESS_LEVEL",
"TRAINING_READINESS",
"ACUTE_TRAINING_LOAD",
].sort(),
);
const wristTemp = MCP_METRIC_STATUS_DISCOVERY.find(
+4
View File
@@ -421,6 +421,10 @@ const METRIC_STATUS_DISCOVERY_IDS = [
"DAY_STRAIN",
"WORKOUT_STRAIN",
"CARDIO_LOAD",
"BODY_BATTERY",
"STRESS_LEVEL",
"TRAINING_READINESS",
"ACUTE_TRAINING_LOAD",
"FALL_COUNT",
"SIX_MINUTE_WALK_DISTANCE",
"STAIR_ASCENT_SPEED",
@@ -27,6 +27,12 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
"INDOOR_CO2",
"INDOOR_TEMPERATURE",
"INDOOR_HUMIDITY",
// Garmin's own scores (Personal API) and the training figures a bridge
// pushes through the ingest token; Garmin shares none of them with HealthKit.
"BODY_BATTERY",
"STRESS_LEVEL",
"TRAINING_READINESS",
"ACUTE_TRAINING_LOAD",
// Withings-only metrics (no HK identifier ships any of these as a
// first-class quantity — Apple delegates body-composition tail to
// third-party devices via the BodyMass/BodyFat pair).
+5
View File
@@ -234,6 +234,11 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["INDOOR_CO2", "environment"],
["INDOOR_TEMPERATURE", "environment"],
["INDOOR_HUMIDITY", "environment"],
// ── Garmin scores join the device-native scores ──
["BODY_BATTERY", "scores"],
["STRESS_LEVEL", "scores"],
["TRAINING_READINESS", "scores"],
["ACUTE_TRAINING_LOAD", "scores"],
]);
/**
+6
View File
@@ -80,4 +80,10 @@ export const MEASUREMENT_SOURCES = [
// batch allowlist, like IMPORT. Part of this enum so the read/response
// shapes (and the iOS decoder) can decode the rows it surfaces.
"HEALTH_CONNECT",
// Garmin Personal API (#1333). Server-owned: only the Garmin sync
// writes these rows, so the value is absent from
// `WRITABLE_MEASUREMENT_SOURCES` and the batch allowlist like every other
// provider. Part of this enum so the read/response shapes (and the iOS
// decoder) can decode the rows it surfaces.
"GARMIN",
] as const;
+4
View File
@@ -113,6 +113,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<MeasurementType, string> = {
INDOOR_CO2: "measurements.typeIndoorCo2",
INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature",
INDOOR_HUMIDITY: "measurements.typeIndoorHumidity",
BODY_BATTERY: "measurements.typeBodyBattery",
STRESS_LEVEL: "measurements.typeStressLevel",
TRAINING_READINESS: "measurements.typeTrainingReadiness",
ACUTE_TRAINING_LOAD: "measurements.typeAcuteTrainingLoad",
GRIP_STRENGTH: "measurements.typeGripStrength",
PAIN_NRS: "measurements.typePainNrs",
WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
+3 -1
View File
@@ -5,7 +5,7 @@
* are symbols every locale reads as they are (kg, mmHg, bpm, ms, %), but a
* handful are English words ("breaths/min", "steps", "flights", "years",
* "minutes") or bare kind markers ("score", "rating", "count", "event",
* "level", "ratio", "unknown"). Passed straight through, the words printed
* "level", "ratio", "load", "unknown"). Passed straight through, the words printed
* "15,4 breaths/min" on a German screen. Every display path resolves the
* token here, so a new word-shaped unit is a one-line addition in one place
* and `unit-label.test.ts` refuses a canonical unit that is neither a known
@@ -53,6 +53,8 @@ const UNITLESS_UNIT_TOKENS: ReadonlySet<string> = new Set([
"event",
"level",
"ratio",
// Garmin's acute training load: a bare load figure.
"load",
// State of Mind valence: a bare −1..1 number.
"valence",
"unknown",
+5
View File
@@ -170,6 +170,11 @@ const unitMap: Record<string, string> = {
INDOOR_CO2: "ppm",
INDOOR_TEMPERATURE: "celsius",
INDOOR_HUMIDITY: "%",
// ── Garmin scores (0–100) and the unit-less acute load ──
BODY_BATTERY: "score",
STRESS_LEVEL: "score",
TRAINING_READINESS: "score",
ACUTE_TRAINING_LOAD: "load",
};
export function getUnitForType(type: string): string {
+7
View File
@@ -235,6 +235,13 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
INDOOR_TEMPERATURE: { min: -20, max: 60 },
// Relative humidity, percent.
INDOOR_HUMIDITY: { min: 0, max: 100 },
// Garmin's 0–100 scores. Acute load is an EWMA of activity load;
// a heavy training block reaches the high hundreds, so 3000 leaves room
// without admitting a unit slip.
BODY_BATTERY: { min: 0, max: 100 },
STRESS_LEVEL: { min: 0, max: 100 },
TRAINING_READINESS: { min: 0, max: 100 },
ACUTE_TRAINING_LOAD: { min: 0, max: 3000 },
// Grip strength kg — a child can read in the single digits, an elite athlete
// tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise.
GRIP_STRENGTH: { min: 0, max: 120 },
+4
View File
@@ -99,7 +99,11 @@ const METRIC_STATUS_MODULE_OWNERS: Partial<Record<MeasurementType, ModuleKey>> =
DAY_STRAIN: "workouts",
WORKOUT_STRAIN: "workouts",
CARDIO_LOAD: "workouts",
ACUTE_TRAINING_LOAD: "workouts",
ANS_CHARGE: "recovery",
BODY_BATTERY: "recovery",
STRESS_LEVEL: "recovery",
TRAINING_READINESS: "recovery",
CARDIO_RECOVERY: "recovery",
// The HRV fallback type. `resolveRichMetricForUser` swaps HRV to its RMSSD
// fallback for an account that only has ring/strap nightly RMSSD rows, so
+1
View File
@@ -231,6 +231,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
export: ["export.selection.empty", "export.selection.unknown_leaf"],
familyHistory: ["familyHistory.invalid"],
feedback: ["feedback.body.invalid"],
garmin: ["garmin.key_refused", "garmin.rate_limited", "garmin.unreachable"],
health_score_config: [
"health_score_config.invalid",
"health_score_config.too_narrow",
+270 -1
View File
@@ -47,6 +47,7 @@ import { polarCredentialsSchema } from "@/lib/validations/polar";
import { stravaCredentialsSchema } from "@/lib/validations/strava";
import { whoopCredentialsSchema } from "@/lib/validations/whoop";
import { nightscoutConnectSchema } from "@/lib/validations/nightscout";
import { garminConnectSchema } from "@/lib/validations/garmin";
import { withingsCredentialsSchema } from "@/lib/validations/withings";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
import { isoDateTime } from "@/lib/validations/datetime";
@@ -596,6 +597,98 @@ const nightscoutTestResponse = z
"A live probe that reached the configured instance and read one SGV entry. Only ever returned on success — a failed probe is an error status, never `ok: false`.",
});
// ── Garmin Personal API ──────────────────────────────────────
const garminKeyExpiry = z
.object({
state: z
.enum(["ok", "expiring", "expired", "unknown"])
.describe(
"`expiring` from seven days before the end, `expired` after it, `unknown` when neither Garmin nor the person said when the key ends.",
),
expiresAt: isoDateTime()
.nullable()
.describe(
"When the key stops working: Garmin's own figure once a token revealed it (a token is capped at the key's end in its last day), else the validity picked at connect counted from the connect. Null when unknown.",
),
daysLeft: z
.number()
.int()
.min(0)
.nullable()
.describe(
"Whole days left, 0 on the last day. Null when unknown or expired.",
),
})
.meta({
id: "GarminKeyExpiry",
description:
"When the stored Garmin Personal API key expires, resolved on the server so every client warns alike. Garmin keys are valid for 30, 90, 180 or 365 days from creation and cannot be extended; a new key replaces the old.",
});
const garminConnectRequest = garminConnectSchema.meta({
id: "GarminConnectRequest",
description:
"The Personal API key from Garmin Connect (Settings, Personal API), trimmed before use, and optionally the validity picked when it was created. Garmin does not report a key's expiry, so the validity is what lets the card warn a week ahead until the key's last day reveals the real end.",
});
const garminStatusResponse = z
.object({
connected: z
.boolean()
.describe(
"A key is stored. Not a liveness claim; read `health` for that.",
),
configured: z.boolean().describe("A key is stored. The connect marker."),
region: z.enum(["global", "china"]).optional(),
keyExpiry: garminKeyExpiry.optional(),
backfill: z
.object({
complete: z.boolean(),
share: z.number().min(0).max(1),
})
.optional()
.describe("How far the history backfill has got."),
state: integrationLedgerState.optional().meta(legacy()),
lastSuccessAt: isoDateTime().nullable().optional(),
lastAttemptAt: isoDateTime().nullable().optional(),
lastError: z.string().nullable().optional().meta(legacy()),
syncHealth: syncHealth.optional().meta(legacy()),
health: connectionHealth
.nullable()
.optional()
.describe(
"The unified connection state. Absent on the not-connected body.",
),
})
.meta({
id: "GarminStatusResponse",
description:
"Garmin connection status. When no key is stored the body is exactly `{ connected: false, configured: false }`. The key is never returned.",
});
const garminConnectResponse = z
.object({
connected: z.literal(true),
region: z.enum(["global", "china"]),
keyExpiry: garminKeyExpiry,
})
.meta({ id: "GarminConnectResponse" });
const garminTestResponse = z
.object({
ok: z.literal(true),
latencyMs: z
.number()
.int()
.describe("Round-trip time of the key exchange, in milliseconds."),
})
.meta({
id: "GarminTestResponse",
description:
"The stored key bought a token. Only ever returned on success; a failed probe is an error status.",
});
const whoopCredentialsStatusResponse = z
.object({
hasCredentials: z
@@ -1099,6 +1192,7 @@ const integrationEnvelopeEntry = z
"strava",
"nightscout",
"dawarich",
"garmin",
])
.describe("Which provider this entry describes."),
state: integrationLedgerState.meta(legacy()),
@@ -1159,6 +1253,26 @@ const integrationEnvelopeEntry = z
),
hasToken: z.boolean().optional().describe("Nightscout only."),
allowPrivateHost: z.boolean().optional().describe("Nightscout only."),
region: z
.enum(["global", "china"])
.nullable()
.optional()
.describe(
"Garmin only. Which Garmin service accepted the stored key; null when no key is stored.",
),
keyExpiry: garminKeyExpiry
.nullable()
.optional()
.describe("Garmin only. Null when no key is stored."),
backfillShare: z
.number()
.min(0)
.max(1)
.nullable()
.optional()
.describe(
"Garmin only. Share of the history walk done, 0 to 1; 1 once `backfillCompleted`. Null when no key is stored.",
),
retirement: z
.object({
endsOn: z
@@ -1211,7 +1325,7 @@ const integrationStatusEnvelope = z
integrations: z
.array(integrationEnvelopeEntry)
.describe(
"All nine providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, strava, nightscout, dawarich. A provider the user has never touched still appears, with `connected: false` and the ledger state `unknown` — absence from this list never means anything, because nothing is ever absent from it.",
"All ten providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, strava, nightscout, dawarich, garmin. A provider the user has never touched still appears, with `connected: false` and the ledger state `unknown` — absence from this list never means anything, because nothing is ever absent from it.",
),
})
.meta({
@@ -1501,6 +1615,161 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/garmin/connect": {
post: {
tags: ["Integrations"],
summary: "Connect (or replace) the caller's Garmin Personal API key",
description:
"Validates the key by buying a token with it BEFORE storing anything, first at Garmin's global service and, when that refuses the key, at the China service. On success the key is encrypted at rest, the region that accepted it and the key's expiry are noted, the history backfill is reopened, the `garmin` ledger is reset to connected, and a first sync plus the backfill are enqueued best-effort. Re-sending replaces the key. The key is never logged or echoed. Body capped at 16 KiB, rate-limited 10 requests / 60 s per user. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: { "application/json": { schema: garminConnectRequest } },
},
responses: {
"200": {
description: "Garmin accepted the key and it was stored.",
content: {
"application/json": {
schema: dataEnvelope(
garminConnectResponse,
"GarminConnectEnvelope",
),
},
},
},
"400": {
description: "Body is not parseable JSON.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Body exceeds 16 KiB.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"The body failed validation, or Garmin did not accept the key (`meta.errorCode` = `garmin.key_refused`), is limiting requests (`garmin.rate_limited`), or could not be reached (`garmin.unreachable`). Nothing was written.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description:
"More than 10 connection attempts in 60 s from this user.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/garmin/status": {
get: {
tags: ["Integrations"],
summary: "Read the caller's Garmin connection status",
description:
"Returns the connect marker, the key's expiry, the backfill progress, the shared `garmin` ledger snapshot and the unified connection state. The key is never returned. Auth via cookie or Bearer.",
responses: {
"200": {
description: "Connection status.",
content: {
"application/json": {
schema: dataEnvelope(
garminStatusResponse,
"GarminStatusEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/garmin/sync": {
post: {
tags: ["Integrations"],
summary: "Trigger an on-demand Garmin sync",
description:
"Reads the recent window of daily summaries, sleep, HRV, weigh-ins and activities (and today's menstrual-cycle summary) with the stored key. History beyond the window is the backfill's. No body. Rate-limited 5 requests / 60 s per user.",
responses: {
"200": {
description: "Sync run complete.",
content: {
"application/json": {
schema: dataEnvelope(providerSyncOutcome, "GarminSyncEnvelope"),
},
},
},
"502": {
description:
"The sync failed upstream. The classified failure is already recorded on the `garmin` ledger.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"Garmin no longer accepts the stored key (`meta.errorCode` = `garmin.key_refused`): it expired or was revoked. A new key is the only fix.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/garmin/test": {
post: {
tags: ["Integrations"],
summary: "Probe the stored Garmin key",
description:
"Buys one token with the stored key through the same exchange the sync uses. Nothing is written. Rate-limited 5 requests / 60 s per user. Auth via cookie or Bearer.",
responses: {
"200": {
description: "The key bought a token.",
content: {
"application/json": {
schema: dataEnvelope(garminTestResponse, "GarminTestEnvelope"),
},
},
},
"502": {
description:
"The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the key was refused), `rate_limited` (Garmin's 429), `upstream_error` (5xx), `timeout`, `connection_failed`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"No Garmin key is stored. `meta.errorCode` = `not_configured`.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description:
"More than 5 probes in 60 s from this user. `meta.errorCode` = `rate_limited_self`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/garmin/disconnect": {
post: {
tags: ["Integrations"],
summary: "Disconnect the caller's Garmin key",
description:
"Clears the stored key, its region, expiry and backfill progress, and parks the `garmin` ledger at `disconnected`. Rows already imported stay. The key keeps working at Garmin until revoked there. No body. Rate-limited 20 requests / 60 s per user. Auth via cookie or Bearer.",
responses: {
"200": {
description: "The connection was cleared.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ disconnected: z.literal(true) }),
"GarminDisconnectEnvelope",
),
},
},
},
"404": {
description: "No Garmin key to clear.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/nightscout/disconnect": {
post: {
tags: ["Integrations"],
+6
View File
@@ -260,6 +260,12 @@ export function getPRDirection(
// other device-derived scores (HRV_RMSSD / DAY_STRAIN).
case "ANS_CHARGE":
case "CARDIO_LOAD":
// Garmin's day scores and the bridged training figures are
// device composites without a goal axis, like the ones above.
case "BODY_BATTERY":
case "STRESS_LEVEL":
case "TRAINING_READINESS":
case "ACUTE_TRAINING_LOAD":
// v1.17.1 — Oura coverage. The Sleep Score is a nightly derived composite
// (like the other sleep-quality indices), and the body-temperature
// deviation is a homeostatic signed offset centred on 0 — neither has a
@@ -63,9 +63,9 @@ function resolves(key: string): boolean {
describe("report selection catalogue", () => {
it("carries every measurement type and every structured leaf", () => {
expect(MEASUREMENT_LEAF_IDS).toHaveLength(86);
expect(MEASUREMENT_LEAF_IDS).toHaveLength(90);
expect(STRUCTURED_LEAF_IDS).toHaveLength(20);
expect(ALL_LEAF_IDS).toHaveLength(106);
expect(ALL_LEAF_IDS).toHaveLength(110);
});
it("resolves a label for every leaf", () => {
+8
View File
@@ -116,6 +116,7 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
MAX_HEART_RATE: "cardio",
CARDIO_RECOVERY: "cardio",
CARDIO_LOAD: "cardio",
ACUTE_TRAINING_LOAD: "cardio",
PULSE_WAVE_VELOCITY: "cardio",
VASCULAR_AGE: "cardio",
IRREGULAR_RHYTHM_NOTIFICATION: "cardio",
@@ -151,6 +152,9 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
STRESS_SCORE: "sleepRecovery",
RESILIENCE: "sleepRecovery",
ANS_CHARGE: "sleepRecovery",
BODY_BATTERY: "sleepRecovery",
STRESS_LEVEL: "sleepRecovery",
TRAINING_READINESS: "sleepRecovery",
SKIN_TEMPERATURE: "sleepRecovery",
WRIST_TEMPERATURE: "sleepRecovery",
BODY_TEMPERATURE_DEVIATION: "sleepRecovery",
@@ -307,6 +311,10 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
STRESS_SCORE: "recovery",
RESILIENCE: "recovery",
ANS_CHARGE: "recovery",
BODY_BATTERY: "recovery",
STRESS_LEVEL: "recovery",
TRAINING_READINESS: "recovery",
ACUTE_TRAINING_LOAD: "workouts",
// Sleep.
SLEEP_DURATION: "sleep",
SLEEP_SCORE: "sleep",
+61
View File
@@ -641,6 +641,67 @@ export const SIGNALS: Record<string, SignalDefinition> = {
mcp: false,
},
},
// ── Garmin day scores and the bridged training figures ──
BODY_BATTERY: {
key: "BODY_BATTERY",
kind: "measurement",
source: { measurementType: "BODY_BATTERY" },
displayName: "Body Battery",
unit: "score",
direction: "higher-better",
archetype: "physiological-vital",
surfaces: {
detailPage: true,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
STRESS_LEVEL: {
key: "STRESS_LEVEL",
kind: "measurement",
source: { measurementType: "STRESS_LEVEL" },
displayName: "Stress level",
unit: "score",
direction: "lower-better",
archetype: "physiological-vital",
surfaces: {
detailPage: true,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
TRAINING_READINESS: {
key: "TRAINING_READINESS",
kind: "measurement",
source: { measurementType: "TRAINING_READINESS" },
displayName: "Training readiness",
unit: "score",
direction: "higher-better",
archetype: "physiological-vital",
surfaces: {
detailPage: true,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
ACUTE_TRAINING_LOAD: {
key: "ACUTE_TRAINING_LOAD",
kind: "measurement",
source: { measurementType: "ACUTE_TRAINING_LOAD" },
displayName: "Acute training load",
unit: "load",
direction: "target-band",
archetype: "activity-fitness",
surfaces: {
detailPage: true,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
CARDIO_LOAD: {
key: "CARDIO_LOAD",
kind: "measurement",
@@ -103,7 +103,7 @@ describe("sourcePrioritySchema", () => {
it("rejects an unknown source string", () => {
const result = sourcePrioritySchema.safeParse({
weight: ["GARMIN", "WITHINGS"],
weight: ["SAMSUNG_HEALTH", "WITHINGS"],
});
expect(result.success).toBe(false);
});
@@ -578,7 +578,12 @@ describe("DEFAULT_SOURCE_PRIORITY — the sources that had no place (v1.43.11)",
});
it("leaves the order of the sources the ladders already named alone", () => {
const added = new Set<string>([...HAND_ENTERED, "HEALTH_CONNECT"]);
const added = new Set<string>([
...HAND_ENTERED,
"HEALTH_CONNECT",
// Garmin joined the ladders later; without it the rest kept its order.
"GARMIN",
]);
const without = (key: keyof typeof DEFAULT_SOURCE_PRIORITY) =>
DEFAULT_SOURCE_PRIORITY[key].filter((s) => !added.has(s));
expect(without("weight")).toEqual([
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod/v4";
import { GARMIN_KEY_VALIDITY_DAYS } from "@/lib/garmin/key-expiry";
/**
* `POST /api/garmin/connect`. The key is what Garmin Connect shows once when
* it is created (Settings, Personal API). It is trimmed before use: a copy
* button's trailing newline would otherwise reach Garmin and answer as a
* refused key. `validityDays` is the validity picked at creation; Garmin
* does not report it, so it is the only way to warn ahead of the end until
* the key's last day gives it away.
*/
export const garminConnectSchema = z.object({
apiKey: z
.string()
.trim()
.min(16, "The Garmin key is too short")
.max(1024, "The Garmin key is too long")
.regex(/^\S+$/, "The Garmin key cannot contain spaces"),
validityDays: z
.union(GARMIN_KEY_VALIDITY_DAYS.map((d) => z.literal(d)))
.nullable()
.optional(),
});
export type GarminConnectInput = z.infer<typeof garminConnectSchema>;
+12
View File
@@ -157,6 +157,18 @@ export const measurementTypeEnum = z.enum([
"INDOOR_CO2",
"INDOOR_TEMPERATURE",
"INDOOR_HUMIDITY",
// ── Garmin Personal API (#1333) ──
// BODY_BATTERY and STRESS_LEVEL ingest server-side as `source = GARMIN`.
// STRESS_LEVEL is Garmin's own all-day stress average and is kept apart
// from the COMPUTED STRESS_SCORE proxy: the score readers collapse only the
// recovery set across sources, so a second STRESS_SCORE producer would be
// averaged with the proxy instead of ranked above it.
"BODY_BATTERY",
"STRESS_LEVEL",
// The Personal API does not carry these two; a home-automation bridge pushes
// them through the ingest token (`measurements:write`, source EXTERNAL).
"TRAINING_READINESS",
"ACUTE_TRAINING_LOAD",
]);
/**
+18
View File
@@ -261,12 +261,20 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// device-native wearables and above MANUAL so an Apple-Watch + Strava twin of
// the same run collapses onto the richer device row, while a Strava-only run
// still survives.
// Garmin (Personal API) is a worn watch in the same class as the
// Fitbit / Google Health wearables and ranks right after them on every
// ladder it writes to, above the rings and straps that follow. On weight
// and body fat it sits with the scale-fed sources, since a Garmin Index
// scale reports there; below WITHINGS and the HealthKit relay. It writes
// no SDNN, so the `hrv` ladder does not name it; its overnight HRV is
// RMSSD and ranks on `hrvRmssd`.
steps: [
"APPLE_HEALTH",
"HEALTH_CONNECT",
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"STRAVA",
@@ -281,6 +289,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"MANUAL",
@@ -294,6 +303,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"MANUAL",
@@ -306,6 +316,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"MANUAL",
"EXTERNAL",
"MCP",
@@ -323,6 +334,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -341,6 +353,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -352,6 +365,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"GARMIN",
"MANUAL",
"EXTERNAL",
"MCP",
@@ -389,6 +403,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
"GARMIN",
"MANUAL",
"EXTERNAL",
"MCP",
@@ -415,6 +430,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -446,6 +462,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -475,6 +492,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",