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

@@ -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.