diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 3ebcd3ebb..f0eac1028 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -4591,6 +4591,10 @@ paths:
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
- in: query
name: from
schema:
@@ -4671,6 +4675,7 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
- in: query
name: appleHealthMirror
schema:
@@ -5507,6 +5512,10 @@ paths:
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
description: The metric.
required: true
description: The metric.
@@ -5632,6 +5641,10 @@ paths:
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
description: The metric.
required: true
description: The metric.
@@ -5675,6 +5688,7 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
description: First source. Defaults to the source covering the most days.
- in: query
name: b
@@ -5698,6 +5712,7 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
description: Second source. Defaults to the next one.
responses:
"200":
@@ -8606,7 +8621,7 @@ paths:
type: object
properties:
steps:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8627,8 +8642,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
activeEnergy:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8649,8 +8665,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
walkingRunningDistance:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8671,8 +8688,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
flightsClimbed:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8693,8 +8711,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
sleep:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8715,8 +8734,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
weight:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8737,8 +8757,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bloodPressure:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8759,8 +8780,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
pulse:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8781,8 +8803,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bodyFat:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8803,8 +8826,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bodyTemperature:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8825,8 +8849,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
spo2:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8847,8 +8872,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
hrv:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8869,8 +8895,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
restingHeartRate:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8891,8 +8918,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
vo2Max:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8913,8 +8941,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
skinTemperature:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8935,8 +8964,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
respiratoryRate:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8957,8 +8987,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
recovery:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -8979,8 +9010,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
stress:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9001,8 +9033,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
sleepDebt:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9023,8 +9056,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
hrvRmssd:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9045,11 +9079,12 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
metricPriority:
type: object
properties:
steps:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9070,8 +9105,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
activeEnergy:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9092,8 +9128,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
walkingRunningDistance:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9114,8 +9151,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
flightsClimbed:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9136,8 +9174,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
sleep:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9158,8 +9197,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
weight:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9180,8 +9220,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bloodPressure:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9202,8 +9243,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
pulse:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9224,8 +9266,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bodyFat:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9246,8 +9289,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bodyTemperature:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9268,8 +9312,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
spo2:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9290,8 +9335,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
hrv:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9312,8 +9358,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
restingHeartRate:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9334,8 +9381,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
vo2Max:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9356,8 +9404,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
skinTemperature:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9378,8 +9427,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
respiratoryRate:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9400,8 +9450,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
recovery:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9422,8 +9473,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
stress:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9444,8 +9496,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
sleepDebt:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9466,8 +9519,9 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
hrvRmssd:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -9488,6 +9542,7 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
deviceTypePriority:
type: object
properties:
@@ -13562,6 +13617,10 @@ paths:
- ANS_CHARGE
- DAY_STRAIN
- WORKOUT_STRAIN
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
- CARDIO_LOAD
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
@@ -21465,6 +21524,167 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
+ /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:
+ $ref: "#/components/schemas/GarminConnectRequest"
+ responses:
+ "200":
+ description: Garmin accepted the key and it was stored.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GarminConnectEnvelope"
+ "400":
+ description: Body is not parseable JSON.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "401": *a1
+ "403": *a9
+ "413":
+ description: Body exceeds 16 KiB.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "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:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "429":
+ description: More than 10 connection attempts in 60 s from this user.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/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:
+ $ref: "#/components/schemas/GarminStatusEnvelope"
+ "401": *a1
+ "403": *a9
+ "422": *a4
+ "429": *a5
+ /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:
+ $ref: "#/components/schemas/GarminSyncEnvelope"
+ "401": *a1
+ "403": *a9
+ "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:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "429": *a5
+ "502":
+ description: The sync failed upstream. The classified failure is already recorded on the `garmin` ledger.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/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:
+ $ref: "#/components/schemas/GarminTestEnvelope"
+ "401": *a1
+ "403": *a9
+ "422":
+ description: No Garmin key is stored. `meta.errorCode` = `not_configured`.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "429":
+ description: More than 5 probes in 60 s from this user. `meta.errorCode` = `rate_limited_self`.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "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:
+ $ref: "#/components/schemas/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:
+ $ref: "#/components/schemas/GarminDisconnectEnvelope"
+ "401": *a1
+ "403": *a9
+ "404":
+ description: No Garmin key to clear.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a4
+ "429": *a5
/api/nightscout/disconnect:
post:
tags:
@@ -28003,7 +28223,7 @@ components:
type: number
const: 2
leaves:
- maxItems: 106
+ maxItems: 110
type: array
items:
type: string
@@ -28065,7 +28285,7 @@ components:
type: number
const: 2
leaves:
- maxItems: 106
+ maxItems: 110
type: array
items:
type: string
@@ -28940,6 +29160,10 @@ components:
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
value:
type: number
measuredAt:
@@ -30669,6 +30893,10 @@ components:
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
biomarkerId:
type: string
minLength: 1
@@ -31168,7 +31396,7 @@ components:
type: number
const: 2
leaves:
- maxItems: 106
+ maxItems: 110
type: array
items:
type: string
@@ -32366,6 +32594,10 @@ components:
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy,
flights, walking/running distance, VO2 max, body temperature).
InsightsSettingsPutRequest:
@@ -36629,6 +36861,31 @@ components:
button reaches the instance verbatim and answers as an opaque DNS failure. `token` may be omitted or empty for a
fully public instance (`AUTH_DEFAULT_ROLES=readable`). `allowPrivateHost` is accepted for older clients and
ignored: only the server-side `NIGHTSCOUT_PRIVATE_ORIGINS` allowlist can authorise a private origin."
+ GarminConnectRequest:
+ type: object
+ properties:
+ apiKey:
+ type: string
+ minLength: 16
+ maxLength: 1024
+ pattern: ^\S+$
+ validityDays:
+ anyOf:
+ - anyOf:
+ - type: number
+ const: 30
+ - type: number
+ const: 90
+ - type: number
+ const: 180
+ - type: number
+ const: 365
+ - type: "null"
+ required:
+ - apiKey
+ 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.
WhoopCredentialsRequest:
type: object
properties:
@@ -36827,6 +37084,10 @@ components:
- averageHeartRate
- maxHeartRate
- energyExpenditureKj
+ - bodyBattery
+ - stressLevel
+ - trainingReadiness
+ - acuteTrainingLoad
- gripStrength
- painNrs
- waistCircumference
@@ -37012,6 +37273,10 @@ components:
- averageHeartRate
- maxHeartRate
- energyExpenditureKj
+ - bodyBattery
+ - stressLevel
+ - trainingReadiness
+ - acuteTrainingLoad
- gripStrength
- painNrs
- waistCircumference
@@ -38858,88 +39123,88 @@ components:
environment.backfill_rate_limited, environment.invalid, environment.no_home,
environment.range_too_large, environment.travel.not_found, environment.travel_suggestion.not_found;
export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid;
- feedback — feedback.body.invalid; health_score_config — health_score_config.invalid,
- health_score_config.too_narrow; http — http.bad_gateway, http.bad_request, http.client_error,
- http.conflict, http.forbidden, http.gateway_timeout, http.gone, http.internal, http.invalid,
- http.method_not_allowed, http.not_found, http.payload_too_large, http.rate_limited, http.server_error,
- http.unauthenticated, http.unavailable, http.unsupported_media_type; illness — illness.day-log.invalid,
- illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid,
- illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import —
- import.apple_health.busy, import.csv.content_type, import.csv.empty, import.csv.too_large,
- import.rate_limited, import.write_failed; ingest — ingest.rate_limited; insights —
- insights.generate.budgetExceeded, insights.generate.outboundScreened,
- insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs —
- labs.biomarker.alias.taken, labs.biomarker.create.invalid, labs.biomarker.duplicate,
- labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch, labs.biomarker.notFound,
- labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid,
- labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge,
- labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.outputLimit, labs.ocr.pdfRendererUnavailable,
- labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.ocr.tooLarge, labs.restore.invalid,
- labs.result.notFound, labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable,
- labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid,
- labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found;
- managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee,
- managed_profile.guardian.required, managed_profile.handover.no_pending,
- managed_profile.handover.unknown_guardian, managed_profile.not_found; maintenance —
- maintenance.pulseDayMean.busy, maintenance.pulseDayMean.dryRunRequired; measurement —
- measurement.batch.invalid, measurement.batch.source_not_permitted, measurement.batch.too_large,
- measurement.bulk-delete.invalid, measurement.create.invalid, measurement.create.source_not_permitted,
- measurement.delete.too_large, measurement.duplicate_timestamp, measurement.restore.invalid,
- measurement.update.server_owned_source; medication — medication.intake.import.invalid_format,
- medication.intake.notTracked, medication.link.invalid; medications — medications.category.invalid,
- medications.category.limitReached, medications.category.unknown, medications.course.currentOpen,
- medications.course.invalid, medications.course.invalidRange, medications.course.notFound,
- medications.course.notRunning, medications.course.oneShot, medications.course.overlap,
- medications.course.windowRequired, medications.ingredients.extract.budgetExceeded,
- medications.ingredients.extract.failed, medications.ingredients.extract.fileTooLarge,
- medications.ingredients.extract.fileType, medications.ingredients.extract.rateLimited,
- medications.ingredients.invalid, medications.ingredients.unitMismatch,
- medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid,
- medications.intake.bulk.too_large, medications.intake.force_slot.invalid,
- medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed,
- medications.intake.taken_at.before_start, medications.mirror.limit_exceeded, medications.notFound;
- mentalHealth — mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json,
- modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large,
- mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp,
- mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range,
- mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json;
- notification — notification.action.already_used, notification.action.expired,
- notification.action.invalid, notification.action.module_disabled; nutrient — nutrient.batch.invalid,
- nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid,
- nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited,
- onboarding.complete.invalid, onboarding.complete.recordNotFound,
- onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid,
- onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired,
- passkey.response.invalid, passkey.unknown, passkey.verification.failed; personal_records —
- personal_records.invalid_query; practitioner — practitioner.invalid; profile —
- profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired,
- profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid,
- profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write —
- record_write.rate_limited; recovery — recovery.kit.stale; report-selection —
- report-selection.body.invalid_json, report-selection.body.invalid_shape,
- report-selection.leaves.unknown; request — request.cross_site; route — route.retired; search —
- search.invalid; security_activity — security_activity.invalid; share-link —
- share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
- share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
- share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
- sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
- sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
- sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused,
- sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
- sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
- sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
- source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
- symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
- timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached,
- tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token,
- tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid,
- vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found,
- vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found,
- vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
- vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
- workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted,
- workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict,
- ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
+ 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; http —
+ http.bad_gateway, http.bad_request, http.client_error, http.conflict, http.forbidden,
+ http.gateway_timeout, http.gone, http.internal, http.invalid, http.method_not_allowed, http.not_found,
+ http.payload_too_large, http.rate_limited, http.server_error, http.unauthenticated, http.unavailable,
+ http.unsupported_media_type; illness — illness.day-log.invalid, illness.disabled,
+ illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window,
+ illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy,
+ import.csv.content_type, import.csv.empty, import.csv.too_large, import.rate_limited,
+ import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded,
+ insights.generate.outboundScreened, insights.generate.profileScopeChanged,
+ insights.generate.scopeChanged; labs — labs.biomarker.alias.taken, labs.biomarker.create.invalid,
+ labs.biomarker.duplicate, labs.biomarker.merge.invalid, labs.biomarker.merge.unitMismatch,
+ labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid,
+ labs.create.invalid, labs.link.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed,
+ labs.ocr.fileTooLarge, labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.outputLimit,
+ labs.ocr.pdfRendererUnavailable, labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.ocr.tooLarge,
+ labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, labs.update.invalid,
+ labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected,
+ labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; life_event —
+ life_event.invalid, life_event.not_found; managed_profile — managed_profile.guardian.duplicate,
+ managed_profile.guardian.managed_invitee, managed_profile.guardian.required,
+ managed_profile.handover.no_pending, managed_profile.handover.unknown_guardian,
+ managed_profile.not_found; maintenance — maintenance.pulseDayMean.busy,
+ maintenance.pulseDayMean.dryRunRequired; measurement — measurement.batch.invalid,
+ measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid,
+ measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large,
+ measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source;
+ medication — medication.intake.import.invalid_format, medication.intake.notTracked,
+ medication.link.invalid; medications — medications.category.invalid, medications.category.limitReached,
+ medications.category.unknown, medications.course.currentOpen, medications.course.invalid,
+ medications.course.invalidRange, medications.course.notFound, medications.course.notRunning,
+ medications.course.oneShot, medications.course.overlap, medications.course.windowRequired,
+ medications.ingredients.extract.budgetExceeded, medications.ingredients.extract.failed,
+ medications.ingredients.extract.fileTooLarge, medications.ingredients.extract.fileType,
+ medications.ingredients.extract.rateLimited, medications.ingredients.invalid,
+ medications.ingredients.unitMismatch, medications.intake.bulk.apple_health_not_mirrored,
+ medications.intake.bulk.invalid, medications.intake.bulk.too_large,
+ medications.intake.force_slot.invalid, medications.intake.force_slot.occupied,
+ medications.intake.injection_site.disallowed, medications.intake.taken_at.before_start,
+ medications.mirror.limit_exceeded, medications.notFound; mentalHealth — mentalHealth.rateLimited; module
+ — module.disabled; modules — modules.body.invalid_json, modules.invalid; mood —
+ mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable,
+ mood.create.invalid, mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid,
+ mood.not_found, mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid;
+ notification-prefs — notification-prefs.body.invalid_json; notification —
+ notification.action.already_used, notification.action.expired, notification.action.invalid,
+ notification.action.module_disabled; nutrient — nutrient.batch.invalid, nutrient.batch.too_large,
+ nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day;
+ onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid,
+ onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch,
+ onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited,
+ onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown,
+ passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner —
+ practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited,
+ profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved;
+ profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported,
+ profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited; recovery —
+ recovery.kit.stale; report-selection — report-selection.body.invalid_json,
+ report-selection.body.invalid_shape, report-selection.leaves.unknown; request — request.cross_site;
+ route — route.retired; search — search.invalid; security_activity — security_activity.invalid;
+ share-link — share-link.document.decryptFailed, share-link.document.notFound,
+ share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound,
+ share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf;
+ sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending,
+ sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid,
+ sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account,
+ sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
+ sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
+ sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
+ source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
+ symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
+ symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
+ tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached,
+ tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination —
+ vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
+ vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
+ vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
+ vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
+ vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
+ workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
+ — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
@@ -41605,6 +41870,10 @@ components:
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
value:
type: number
valueMin:
@@ -41640,6 +41909,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
notes:
type:
- string
@@ -42367,6 +42637,10 @@ components:
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
- type: "null"
biomarkerName:
anyOf:
@@ -43152,6 +43426,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
externalId:
type:
- string
@@ -44119,6 +44394,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
externalId:
type:
- string
@@ -44958,6 +45234,10 @@ components:
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
dateKey:
type: string
pattern: ^\d{4}-\d{2}-\d{2}$
@@ -46153,6 +46433,10 @@ components:
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
- type: "null"
intervalDays:
anyOf:
@@ -46374,6 +46658,10 @@ components:
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
metricSlot:
type:
- string
@@ -46414,6 +46702,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
externalId:
type:
- string
@@ -46775,6 +47064,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
externalRecordingId:
type: string
minLength: 1
@@ -49348,6 +49638,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
description: Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. v1.38.x
added EXTERNAL for rows pushed in under a narrow `measurements:write` Bearer — a scale, a watch bridge, a
home-automation rule — and workouts pushed in under a narrow `workouts:write` Bearer carry it too. No client may
@@ -52678,6 +52969,10 @@ components:
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
date:
type: string
format: date
@@ -52714,6 +53009,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
picked:
anyOf:
- type: string
@@ -52734,6 +53030,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
- type: "null"
pickReason:
anyOf:
@@ -52771,6 +53068,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
rank:
type: integer
minimum: -9007199254740991
@@ -52950,6 +53248,10 @@ components:
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
+ - BODY_BATTERY
+ - STRESS_LEVEL
+ - TRAINING_READINESS
+ - ACUTE_TRAINING_LOAD
from:
type: string
format: date
@@ -52986,6 +53288,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
unit:
type:
- string
@@ -53014,6 +53317,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
days:
type: integer
minimum: -9007199254740991
@@ -53042,6 +53346,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
- type: "null"
b:
anyOf:
@@ -53063,6 +53368,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
- type: "null"
days:
type: array
@@ -53101,6 +53407,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
- type: "null"
required:
- date
@@ -58678,7 +58985,7 @@ components:
type: object
properties:
steps:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58699,8 +59006,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
activeEnergy:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58721,8 +59029,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
walkingRunningDistance:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58743,8 +59052,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
flightsClimbed:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58765,8 +59075,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
sleep:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58787,8 +59098,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
weight:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58809,8 +59121,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bloodPressure:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58831,8 +59144,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
pulse:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58853,8 +59167,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bodyFat:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58875,8 +59190,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bodyTemperature:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58897,8 +59213,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
spo2:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58919,8 +59236,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
hrv:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58941,8 +59259,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
restingHeartRate:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58963,8 +59282,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
vo2Max:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -58985,8 +59305,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
skinTemperature:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59007,8 +59328,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
respiratoryRate:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59029,8 +59351,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
recovery:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59051,8 +59374,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
stress:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59073,8 +59397,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
sleepDebt:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59095,8 +59420,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
hrvRmssd:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59117,11 +59443,12 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
metricPriority:
type: object
properties:
steps:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59142,8 +59469,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
activeEnergy:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59164,8 +59492,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
walkingRunningDistance:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59186,8 +59515,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
flightsClimbed:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59208,8 +59538,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
sleep:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59230,8 +59561,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
weight:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59252,8 +59584,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bloodPressure:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59274,8 +59607,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
pulse:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59296,8 +59630,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bodyFat:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59318,8 +59653,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
bodyTemperature:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59340,8 +59676,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
spo2:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59362,8 +59699,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
hrv:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59384,8 +59722,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
restingHeartRate:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59406,8 +59745,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
vo2Max:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59428,8 +59768,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
skinTemperature:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59450,8 +59791,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
respiratoryRate:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59472,8 +59814,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
recovery:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59494,8 +59837,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
stress:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59516,8 +59860,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
sleepDebt:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59538,8 +59883,9 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
hrvRmssd:
- maxItems: 16
+ maxItems: 17
type: array
items:
type: string
@@ -59560,6 +59906,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
additionalProperties: false
deviceTypePriority:
type: object
@@ -59611,6 +59958,7 @@ components:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - GARMIN
description: Ranked sources this account has connected or holds at least one measurement from, in ladder-catalogue
order. Read-only; an editor may list only these while the ladders keep every source.
required:
@@ -83445,6 +83793,240 @@ components:
additionalProperties: false
description: "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`."
+ GarminConnectEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/GarminConnectResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ GarminConnectResponse:
+ type: object
+ properties:
+ connected:
+ type: boolean
+ const: true
+ region:
+ type: string
+ enum:
+ - global
+ - china
+ keyExpiry:
+ $ref: "#/components/schemas/GarminKeyExpiry"
+ required:
+ - connected
+ - region
+ - keyExpiry
+ additionalProperties: false
+ GarminKeyExpiry:
+ type: object
+ properties:
+ state:
+ type: string
+ enum:
+ - ok
+ - expiring
+ - expired
+ - unknown
+ description: "`expiring` from seven days before the end, `expired` after it, `unknown` when neither Garmin nor the
+ person said when the key ends."
+ expiresAt:
+ anyOf:
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - type: "null"
+ description: "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:
+ anyOf:
+ - type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ - type: "null"
+ description: Whole days left, 0 on the last day. Null when unknown or expired.
+ required:
+ - state
+ - expiresAt
+ - daysLeft
+ additionalProperties: false
+ 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.
+ GarminStatusEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/GarminStatusResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ GarminStatusResponse:
+ type: object
+ properties:
+ connected:
+ type: boolean
+ description: A key is stored. Not a liveness claim; read `health` for that.
+ configured:
+ type: boolean
+ description: A key is stored. The connect marker.
+ region:
+ type: string
+ enum:
+ - global
+ - china
+ keyExpiry:
+ $ref: "#/components/schemas/GarminKeyExpiry"
+ backfill:
+ description: How far the history backfill has got.
+ type: object
+ properties:
+ complete:
+ type: boolean
+ share:
+ type: number
+ minimum: 0
+ maximum: 1
+ required:
+ - complete
+ - share
+ additionalProperties: false
+ state:
+ deprecated: true
+ description: "Deprecated, still served: read `health`."
+ type: string
+ enum:
+ - connected
+ - error_transient
+ - error_reauth
+ - disconnected
+ - parked
+ - unknown
+ lastSuccessAt:
+ anyOf:
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - type: "null"
+ lastAttemptAt:
+ anyOf:
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - type: "null"
+ lastError:
+ deprecated: true
+ description: "Deprecated, still served: read `health`."
+ type:
+ - string
+ - "null"
+ syncHealth:
+ deprecated: true
+ description: "Deprecated, still served: read `health`."
+ $ref: "#/components/schemas/SyncHealth"
+ health:
+ description: The unified connection state. Absent on the not-connected body.
+ anyOf:
+ - $ref: "#/components/schemas/ConnectionHealth"
+ - type: "null"
+ required:
+ - connected
+ - configured
+ additionalProperties: false
+ description: "Garmin connection status. When no key is stored the body is exactly `{ connected: false, configured: false
+ }`. The key is never returned."
+ GarminSyncEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/ProviderSyncOutcome"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ GarminTestEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/GarminTestResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ GarminTestResponse:
+ type: object
+ properties:
+ ok:
+ type: boolean
+ const: true
+ latencyMs:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: Round-trip time of the key exchange, in milliseconds.
+ required:
+ - ok
+ - latencyMs
+ additionalProperties: false
+ description: The stored key bought a token. Only ever returned on success; a failed probe is an error status.
+ GarminDisconnectEnvelope:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ disconnected:
+ type: boolean
+ const: true
+ required:
+ - disconnected
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
NightscoutDisconnectEnvelope:
type: object
properties:
@@ -84819,8 +85401,8 @@ components:
type: array
items:
$ref: "#/components/schemas/IntegrationStatusEntry"
- description: "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`
+ description: "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."
required:
@@ -84846,6 +85428,7 @@ components:
- strava
- nightscout
- dawarich
+ - garmin
description: Which provider this entry describes.
state:
type: string
@@ -85050,6 +85633,26 @@ components:
allowPrivateHost:
description: Nightscout only.
type: boolean
+ region:
+ description: Garmin only. Which Garmin service accepted the stored key; null when no key is stored.
+ anyOf:
+ - type: string
+ enum:
+ - global
+ - china
+ - type: "null"
+ keyExpiry:
+ description: Garmin only. Null when no key is stored.
+ anyOf:
+ - $ref: "#/components/schemas/GarminKeyExpiry"
+ - type: "null"
+ backfillShare:
+ description: Garmin only. Share of the history walk done, 0 to 1; 1 once `backfillCompleted`. Null when no key is stored.
+ anyOf:
+ - type: number
+ minimum: 0
+ maximum: 1
+ - type: "null"
retirement:
description: Fitbit only. The end of the legacy Fitbit Web API.
type: object
@@ -86175,6 +86778,10 @@ components:
- averageHeartRate
- maxHeartRate
- energyExpenditureKj
+ - bodyBattery
+ - stressLevel
+ - trainingReadiness
+ - acuteTrainingLoad
- gripStrength
- painNrs
- waistCircumference
@@ -86437,6 +87044,10 @@ components:
- averageHeartRate
- maxHeartRate
- energyExpenditureKj
+ - bodyBattery
+ - stressLevel
+ - trainingReadiness
+ - acuteTrainingLoad
- gripStrength
- painNrs
- waistCircumference
diff --git a/messages/de.json b/messages/de.json
index bbc6301ec..bd079b3f2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -969,7 +969,12 @@
"durationMinutesOnly": "{minutes} Min.",
"durationInvalid": "Gib eine Dauer ein, zum Beispiel 7,5, 7:30 oder 7h 30m.",
"durationTooLong": "Ein Eintrag kann nicht länger als 24 Stunden sein.",
- "sourceHealthConnect": "Health Connect"
+ "sourceHealthConnect": "Health Connect",
+ "typeBodyBattery": "Body Battery",
+ "typeStressLevel": "Stresslevel",
+ "typeTrainingReadiness": "Trainingsbereitschaft",
+ "typeAcuteTrainingLoad": "Akute Trainingsbelastung",
+ "sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Was HealthLog erfassen kann",
@@ -4759,7 +4764,8 @@
"unitScore": "Punkte",
"unitMinutes": "Min.",
"unitBpm": "bpm",
- "unitKj": "kJ"
+ "unitKj": "kJ",
+ "unitLoad": "Belastung"
},
"sleepQuality": {
"title": "Schlafqualität",
@@ -4781,7 +4787,11 @@
"cardioLoadExplainer": "Ein Trainingslast-Näherungswert, der die akut aufgebaute kardiovaskuläre Belastung abbildet. Vergleiche ihn mit deinen eigenen letzten Tagen.",
"averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
"maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
- "energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet."
+ "energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet.",
+ "bodyBatteryExplainer": "Deine Energiereserve auf Garmins Skala von 0 bis 100, der höchste Stand des Tages. Ruhe und Schlaf laden sie auf, Stress und Bewegung zehren davon.",
+ "trainingReadinessExplainer": "Wie bereit dein Körper für eine harte Einheit ist, auf Garmins Skala von 0 bis 100. Der Wert kommt über eine von dir eingerichtete Brücke, weil Garmin ihn nicht direkt teilt.",
+ "stressLevelExplainer": "Garmins durchschnittliches Stresslevel des Tages auf einer Skala von 0 bis 100, abgeleitet aus deiner Herzfrequenzvariabilität. Niedriger heißt ruhiger.",
+ "acuteTrainingLoadExplainer": "Garmins akute Trainingsbelastung: die gewichtete Belastung deiner Aktivitäten der letzten sieben Tage. Der Wert kommt über eine von dir eingerichtete Brücke. Vergleiche ihn mit deinen eigenen letzten Wochen."
},
"loadError": "Deine Erholungswerte konnten nicht geladen werden."
},
@@ -5706,7 +5716,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Externes Gerät",
- "HEALTH_CONNECT": "Health Connect"
+ "HEALTH_CONNECT": "Health Connect",
+ "GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Uhr",
@@ -7500,11 +7511,6 @@
"lastEnteredHere": "Zuletzt hier eingetragen {relative}"
}
},
- "garminInfo": {
- "title": "Garmin",
- "body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte (Body Battery, Training Load, HRV-Status, VO₂max) werden von Garmin an keine der beiden Plattformen weitergegeben.",
- "docsLink": "Zur Anleitung"
- },
"glucoseReference": {
"title": "Glukose-Referenzband",
"description": "Gegen welches Band deine Blutzuckerwerte bewertet werden.",
@@ -7704,7 +7710,36 @@
"deleteAccountConfirmDescriptionGrace": "Du wirst abgemeldet und dein Konto wird sofort gesperrt. Nach {days} Tagen wird es endgültig gelöscht, mit allen Gesundheitsdaten, Passkeys, Sessions und Audit-Einträgen. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
"deleteAccountScheduled": "Dein Konto ist gesperrt und wird am {date} endgültig gelöscht. Du wirst abgemeldet.",
"dangerZoneSuccessGrace": "Deine Gesundheitsakte wurde gelöscht. Ein Admin dieses Servers kann sie bis zum {date} wiederherstellen.",
- "dangerZoneRestoreLimits": "Auch Zugriffe enden: was du mit anderen teilst, was andere mit dir teilen, und jede Rolle als betreuende Person. Eine Wiederherstellung bringt die Akte so zurück, wie sie jetzt ist. Was nach dem Löschen hinzukommt, wird dabei ersetzt, und Integrationsverbindungen, Freigaben, Betreuungsrollen und Freigabe-Links kommen nicht zurück; sie müssen neu eingerichtet werden."
+ "dangerZoneRestoreLimits": "Auch Zugriffe enden: was du mit anderen teilst, was andere mit dir teilen, und jede Rolle als betreuende Person. Eine Wiederherstellung bringt die Akte so zurück, wie sie jetzt ist. Was nach dem Löschen hinzukommt, wird dabei ersetzt, und Integrationsverbindungen, Freigaben, Betreuungsrollen und Freigabe-Links kommen nicht zurück; sie müssen neu eingerichtet werden.",
+ "garmin": "Garmin",
+ "garminTag": "Personal API",
+ "garminDescription": "Füge einen Garmin-Personal-API-Schlüssel ein, um Schlaf, HRV, Body Battery, Stress, Schritte, Gewicht und Aktivitäten aus Garmin Connect zu synchronisieren.",
+ "garminSync": "Jetzt synchronisieren",
+ "garminSyncResult": "{count} Einträge synchronisiert",
+ "garminSyncFailed": "Synchronisierung fehlgeschlagen",
+ "garminViewData": "Erholung ansehen",
+ "garminReplaceKey": "Schlüssel ersetzen",
+ "garminDisconnect": "Trennen",
+ "garminDisconnectTitle": "Garmin trennen?",
+ "garminDisconnectDescription": "Künftige Synchronisierungen enden, und der Schlüssel wird von diesem Server entfernt. Bereits importierte Daten bleiben in deinem Verlauf. Bei Garmin bleibt der Schlüssel gültig, bis du ihn dort widerrufst.",
+ "garminKeyValidUntil": "Schlüssel gültig bis {date}",
+ "garminKeyExpired": "Dein Garmin-Schlüssel ist am {date} abgelaufen. Erstelle in Garmin Connect einen neuen Schlüssel und füge ihn hier ein, damit die Synchronisierung weiterläuft.",
+ "garminKeyExpiresToday": "Dein Garmin-Schlüssel läuft heute ab. Erstelle in Garmin Connect einen neuen Schlüssel und füge ihn hier ein, damit die Synchronisierung nicht stoppt.",
+ "garminKeyExpiring": "Dein Garmin-Schlüssel läuft am {date} ab. Erstelle vorher in Garmin Connect einen neuen Schlüssel und füge ihn hier ein.",
+ "garminKeyExpiryUnknown": "Garmin verrät nicht, wann dieser Schlüssel abläuft. Ersetze den Schlüssel und wähle die Gültigkeit, die du in Garmin Connect eingestellt hast, dann warnt dich diese Karte eine Woche vorher.",
+ "garminConnected": "Garmin verbunden. Die erste Synchronisierung läuft an.",
+ "garminConnectFailed": "Verbindung zu Garmin fehlgeschlagen.",
+ "garminKeyHowTo": "Öffne in Garmin Connect die Einstellungen, dann Personal API, und erstelle einen Schlüssel. Garmin zeigt ihn nur einmal an, kopiere ihn also sofort.",
+ "garminKeySettingsLink": "Garmin Connect öffnen",
+ "garminApiKey": "Personal-API-Schlüssel",
+ "garminApiKeyPlaceholder": "Schlüssel einfügen",
+ "garminApiKeySavedPlaceholder": "Gespeichert. Füge einen neuen Schlüssel ein, um ihn zu ersetzen",
+ "garminKeyValidity": "Gewählte Gültigkeit",
+ "garminKeyValidityUnknown": "Weiß ich nicht",
+ "garminKeyValidityDays": "{count} Tage",
+ "garminKeyValidityHelp": "Garmin meldet nicht, wann ein Schlüssel abläuft. Mit der Gültigkeit warnt dich diese Karte eine Woche, bevor der Schlüssel nicht mehr funktioniert.",
+ "garminConnect": "Verbinden",
+ "garminUpdate": "Änderungen speichern"
},
"admin": {
"title": "Administration",
diff --git a/messages/en.json b/messages/en.json
index 94db54003..1ea519f3c 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -969,7 +969,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Enter a duration like 7.5, 7:30 or 7h 30m.",
"durationTooLong": "One entry can't be longer than 24 hours.",
- "sourceHealthConnect": "Health Connect"
+ "sourceHealthConnect": "Health Connect",
+ "typeBodyBattery": "Body Battery",
+ "typeStressLevel": "Stress level",
+ "typeTrainingReadiness": "Training readiness",
+ "typeAcuteTrainingLoad": "Acute training load",
+ "sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "What HealthLog can track",
@@ -4759,7 +4764,8 @@
"unitScore": "score",
"unitMinutes": "min",
"unitBpm": "bpm",
- "unitKj": "kJ"
+ "unitKj": "kJ",
+ "unitLoad": "load"
},
"sleepQuality": {
"title": "Sleep quality",
@@ -4781,7 +4787,11 @@
"cardioLoadExplainer": "A training-load proxy that tracks the acute cardiovascular load you have built up. Compare it with your own recent days.",
"averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.",
"maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.",
- "energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively."
+ "energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively.",
+ "bodyBatteryExplainer": "Your energy reserve on Garmin's 0 to 100 scale, the day's highest charge. Rest and sleep fill it, stress and activity drain it.",
+ "trainingReadinessExplainer": "How ready your body is for a hard session, on Garmin's 0 to 100 scale. It arrives from a bridge you set up, since Garmin does not share it directly.",
+ "stressLevelExplainer": "Garmin's all-day average stress level on a 0 to 100 scale, read from your heart rate variability. Lower is calmer.",
+ "acuteTrainingLoadExplainer": "Garmin's acute training load: the weighted load of your last seven days of activity. It arrives from a bridge you set up. Compare it with your own recent weeks."
},
"loadError": "Your recovery readings could not be loaded."
},
@@ -5706,7 +5716,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "External device",
- "HEALTH_CONNECT": "Health Connect"
+ "HEALTH_CONNECT": "Health Connect",
+ "GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Watch",
@@ -7500,11 +7511,6 @@
"lastEnteredHere": "Last entered here {relative}"
}
},
- "garminInfo": {
- "title": "Garmin",
- "body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores (Body Battery, Training Load, HRV Status, VO₂ max) are not shared by Garmin to either platform.",
- "docsLink": "Read the guide"
- },
"glucoseReference": {
"title": "Glucose reference band",
"description": "Which band your blood-glucose readings are judged against.",
@@ -7704,7 +7710,36 @@
"deleteAccountConfirmDescriptionGrace": "You are signed out and your account is locked right away. After {days} days it is erased for good, with all health data, passkeys, sessions and audit entries. Until then, an admin of this server can undo the deletion. Copies in this server's backups age out after their retention period.",
"deleteAccountScheduled": "Your account is locked and will be erased on {date}. Signing you out.",
"dangerZoneSuccessGrace": "Your health record has been deleted. An admin of this server can restore it until {date}.",
- "dangerZoneRestoreLimits": "Access ends too: what you share with others, what they share with you, and any Guardian role. A restore puts the record back as it is now. Anything added after the deletion is replaced, and integration connections, sharing, Guardian roles and share links do not come back; they have to be set up again."
+ "dangerZoneRestoreLimits": "Access ends too: what you share with others, what they share with you, and any Guardian role. A restore puts the record back as it is now. Anything added after the deletion is replaced, and integration connections, sharing, Guardian roles and share links do not come back; they have to be set up again.",
+ "garmin": "Garmin",
+ "garminTag": "Personal API",
+ "garminDescription": "Paste a Garmin Personal API key to sync sleep, HRV, Body Battery, stress, steps, weight and activities from Garmin Connect.",
+ "garminSync": "Sync now",
+ "garminSyncResult": "{count} entries synchronized",
+ "garminSyncFailed": "Sync failed",
+ "garminViewData": "View your recovery",
+ "garminReplaceKey": "Replace key",
+ "garminDisconnect": "Disconnect",
+ "garminDisconnectTitle": "Disconnect Garmin?",
+ "garminDisconnectDescription": "Future syncs stop and the key is removed from this server. Data already imported stays in your history. The key keeps working at Garmin until you revoke it there.",
+ "garminKeyValidUntil": "Key valid until {date}",
+ "garminKeyExpired": "Your Garmin key expired on {date}. Create a new key in Garmin Connect and paste it here to resume syncing.",
+ "garminKeyExpiresToday": "Your Garmin key expires today. Create a new key in Garmin Connect and paste it here so syncing does not stop.",
+ "garminKeyExpiring": "Your Garmin key expires on {date}. Create a new key in Garmin Connect and paste it here before then.",
+ "garminKeyExpiryUnknown": "Garmin does not say when this key expires. Replace the key and choose the validity you picked in Garmin Connect, and this card will warn you a week ahead.",
+ "garminConnected": "Garmin connected. The first sync is on its way.",
+ "garminConnectFailed": "Could not connect to Garmin.",
+ "garminKeyHowTo": "In Garmin Connect, open Settings, then Personal API, and create a key. Garmin shows it only once, so copy it straight away.",
+ "garminKeySettingsLink": "Open Garmin Connect",
+ "garminApiKey": "Personal API key",
+ "garminApiKeyPlaceholder": "Paste the key",
+ "garminApiKeySavedPlaceholder": "Saved. Paste a new key to replace it",
+ "garminKeyValidity": "Validity you picked",
+ "garminKeyValidityUnknown": "I don't know",
+ "garminKeyValidityDays": "{count} days",
+ "garminKeyValidityHelp": "Garmin does not report when a key expires. With the validity, this card warns you a week before the key stops working.",
+ "garminConnect": "Connect",
+ "garminUpdate": "Save changes"
},
"admin": {
"title": "Administration",
diff --git a/messages/es.json b/messages/es.json
index 859f592ad..90c54cf8a 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -969,7 +969,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Escribe una duración como 7,5, 7:30 o 7h 30m.",
"durationTooLong": "Una entrada no puede durar más de 24 horas.",
- "sourceHealthConnect": "Health Connect"
+ "sourceHealthConnect": "Health Connect",
+ "typeBodyBattery": "Body Battery",
+ "typeStressLevel": "Nivel de estrés",
+ "typeTrainingReadiness": "Preparación para entrenar",
+ "typeAcuteTrainingLoad": "Carga de entrenamiento aguda",
+ "sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Lo que HealthLog puede registrar",
@@ -4759,7 +4764,8 @@
"unitScore": "puntos",
"unitMinutes": "min",
"unitBpm": "ppm",
- "unitKj": "kJ"
+ "unitKj": "kJ",
+ "unitLoad": "carga"
},
"sleepQuality": {
"title": "Calidad del sueño",
@@ -4781,7 +4787,11 @@
"cardioLoadExplainer": "Una aproximación de la carga de entrenamiento que sigue la carga cardiovascular acumulada. Compárala con tus propios días recientes.",
"averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.",
"maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.",
- "energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa."
+ "energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa.",
+ "bodyBatteryExplainer": "Tu reserva de energía en la escala de 0 a 100 de Garmin, la carga más alta del día. El descanso y el sueño la llenan, el estrés y la actividad la vacían.",
+ "trainingReadinessExplainer": "Lo preparado que está tu cuerpo para una sesión dura, en la escala de 0 a 100 de Garmin. Llega a través de un puente que configuraste, porque Garmin no la comparte directamente.",
+ "stressLevelExplainer": "El nivel de estrés medio del día según Garmin, en una escala de 0 a 100, a partir de la variabilidad de tu frecuencia cardíaca. Más bajo es más tranquilo.",
+ "acuteTrainingLoadExplainer": "La carga de entrenamiento aguda de Garmin: la carga ponderada de tu actividad de los últimos siete días. Llega a través de un puente que configuraste. Compárala con tus propias semanas recientes."
},
"loadError": "No se pudieron cargar tus valores de recuperación."
},
@@ -5706,7 +5716,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Dispositivo externo",
- "HEALTH_CONNECT": "Health Connect"
+ "HEALTH_CONNECT": "Health Connect",
+ "GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Reloj",
@@ -7500,11 +7511,6 @@
"lastEnteredHere": "Último registro aquí {relative}"
}
},
- "garminInfo": {
- "title": "Garmin",
- "body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin (Body Battery, Training Load, estado de HRV, VO₂ máx.) no las comparte Garmin con ninguna de las dos plataformas.",
- "docsLink": "Leer la guía"
- },
"glucoseReference": {
"title": "Banda de referencia de glucosa",
"description": "Con qué banda se evalúan tus valores de glucosa en sangre.",
@@ -7704,7 +7710,36 @@
"deleteAccountConfirmDescriptionGrace": "Se cierra tu sesión y tu cuenta se bloquea de inmediato. Al cabo de {days} días se borra para siempre, con todos tus datos de salud, llaves de acceso, sesiones y entradas de auditoría. Hasta entonces, un administrador de este servidor puede deshacer la eliminación. Las copias en las copias de seguridad de este servidor caducan al terminar su periodo de conservación.",
"deleteAccountScheduled": "Tu cuenta está bloqueada y se borrará el {date}. Cerrando tu sesión.",
"dangerZoneSuccessGrace": "Tu historial de salud se ha eliminado. Un administrador de este servidor puede restaurarlo hasta el {date}.",
- "dangerZoneRestoreLimits": "También termina el acceso: lo que compartes con otras personas, lo que ellas comparten contigo y cualquier papel de persona responsable. Una restauración devuelve el historial tal como está ahora. Lo que se añada después de la eliminación se sustituye, y las conexiones de integración, lo compartido, los papeles de persona responsable y los enlaces compartidos no vuelven; hay que configurarlos de nuevo."
+ "dangerZoneRestoreLimits": "También termina el acceso: lo que compartes con otras personas, lo que ellas comparten contigo y cualquier papel de persona responsable. Una restauración devuelve el historial tal como está ahora. Lo que se añada después de la eliminación se sustituye, y las conexiones de integración, lo compartido, los papeles de persona responsable y los enlaces compartidos no vuelven; hay que configurarlos de nuevo.",
+ "garmin": "Garmin",
+ "garminTag": "Personal API",
+ "garminDescription": "Pega una clave de la Garmin Personal API para sincronizar sueño, VFC, Body Battery, estrés, pasos, peso y actividades desde Garmin Connect.",
+ "garminSync": "Sincronizar ahora",
+ "garminSyncResult": "{count} entradas sincronizadas",
+ "garminSyncFailed": "Error de sincronización",
+ "garminViewData": "Ver tu recuperación",
+ "garminReplaceKey": "Reemplazar la clave",
+ "garminDisconnect": "Desconectar",
+ "garminDisconnectTitle": "¿Desconectar Garmin?",
+ "garminDisconnectDescription": "Las sincronizaciones futuras se detienen y la clave se elimina de este servidor. Los datos ya importados se quedan en tu historial. La clave sigue funcionando en Garmin hasta que la revoques allí.",
+ "garminKeyValidUntil": "Clave válida hasta el {date}",
+ "garminKeyExpired": "Tu clave de Garmin caducó el {date}. Crea una clave nueva en Garmin Connect y pégala aquí para reanudar la sincronización.",
+ "garminKeyExpiresToday": "Tu clave de Garmin caduca hoy. Crea una clave nueva en Garmin Connect y pégala aquí para que la sincronización no se detenga.",
+ "garminKeyExpiring": "Tu clave de Garmin caduca el {date}. Crea una clave nueva en Garmin Connect y pégala aquí antes de esa fecha.",
+ "garminKeyExpiryUnknown": "Garmin no indica cuándo caduca esta clave. Reemplaza la clave y elige la validez que seleccionaste en Garmin Connect, y esta tarjeta te avisará con una semana de antelación.",
+ "garminConnected": "Garmin conectado. La primera sincronización está en marcha.",
+ "garminConnectFailed": "No se pudo conectar con Garmin.",
+ "garminKeyHowTo": "En Garmin Connect, abre Configuración, luego Personal API, y crea una clave. Garmin solo la muestra una vez, así que cópiala enseguida.",
+ "garminKeySettingsLink": "Abrir Garmin Connect",
+ "garminApiKey": "Clave de la Personal API",
+ "garminApiKeyPlaceholder": "Pega la clave",
+ "garminApiKeySavedPlaceholder": "Guardada. Pega una clave nueva para reemplazarla",
+ "garminKeyValidity": "Validez elegida",
+ "garminKeyValidityUnknown": "No lo sé",
+ "garminKeyValidityDays": "{count} días",
+ "garminKeyValidityHelp": "Garmin no informa de cuándo caduca una clave. Con la validez, esta tarjeta te avisa una semana antes de que la clave deje de funcionar.",
+ "garminConnect": "Conectar",
+ "garminUpdate": "Guardar cambios"
},
"admin": {
"title": "Administration",
diff --git a/messages/fr.json b/messages/fr.json
index 87a920617..90ccb2bea 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -969,7 +969,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Saisissez une durée comme 7,5, 7:30 ou 7h 30m.",
"durationTooLong": "Une saisie ne peut pas dépasser 24 heures.",
- "sourceHealthConnect": "Health Connect"
+ "sourceHealthConnect": "Health Connect",
+ "typeBodyBattery": "Body Battery",
+ "typeStressLevel": "Niveau de stress",
+ "typeTrainingReadiness": "Disponibilité à l'entraînement",
+ "typeAcuteTrainingLoad": "Charge d'entraînement aiguë",
+ "sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Ce que HealthLog peut suivre",
@@ -4759,7 +4764,8 @@
"unitScore": "points",
"unitMinutes": "min",
"unitBpm": "bpm",
- "unitKj": "kJ"
+ "unitKj": "kJ",
+ "unitLoad": "charge"
},
"sleepQuality": {
"title": "Qualité du sommeil",
@@ -4781,7 +4787,11 @@
"cardioLoadExplainer": "Une estimation de la charge d'entraînement qui suit la charge cardiovasculaire accumulée. Comparez-la à vos propres journées récentes.",
"averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
"maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
- "energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement."
+ "energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement.",
+ "bodyBatteryExplainer": "Votre réserve d'énergie sur l'échelle de 0 à 100 de Garmin, le niveau le plus haut de la journée. Le repos et le sommeil la rechargent, le stress et l'activité la vident.",
+ "trainingReadinessExplainer": "À quel point votre corps est prêt pour une séance intense, sur l'échelle de 0 à 100 de Garmin. La valeur arrive par une passerelle que vous avez configurée, car Garmin ne la partage pas directement.",
+ "stressLevelExplainer": "Le niveau de stress moyen de la journée selon Garmin, sur une échelle de 0 à 100, tiré de la variabilité de votre fréquence cardiaque. Plus bas signifie plus calme.",
+ "acuteTrainingLoadExplainer": "La charge d'entraînement aiguë de Garmin : la charge pondérée de vos activités des sept derniers jours. Elle arrive par une passerelle que vous avez configurée. Comparez-la à vos propres semaines récentes."
},
"loadError": "Vos valeurs de récupération n'ont pas pu être chargées."
},
@@ -5706,7 +5716,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Appareil externe",
- "HEALTH_CONNECT": "Health Connect"
+ "HEALTH_CONNECT": "Health Connect",
+ "GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Montre",
@@ -7500,11 +7511,6 @@
"lastEnteredHere": "Dernière saisie ici {relative}"
}
},
- "garminInfo": {
- "title": "Garmin",
- "body": "Garmin n'a pas de connexion directe ici. Connectez-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin (Body Battery, Training Load, statut VFC, VO₂ max) ne sont partagés par Garmin avec aucune des deux plateformes.",
- "docsLink": "Lire le guide"
- },
"glucoseReference": {
"title": "Plage de référence du glucose",
"description": "La plage à laquelle vos glycémies sont comparées.",
@@ -7704,7 +7710,36 @@
"deleteAccountConfirmDescriptionGrace": "Vous êtes déconnecté et votre compte est verrouillé immédiatement. Au bout de {days} jours, il est effacé définitivement, avec toutes vos données de santé, vos clés d’accès, vos sessions et vos entrées d’audit. D’ici là, un administrateur de ce serveur peut annuler la suppression. Les copies dans les sauvegardes de ce serveur expirent à la fin de leur durée de conservation.",
"deleteAccountScheduled": "Votre compte est verrouillé et sera effacé le {date}. Déconnexion en cours.",
"dangerZoneSuccessGrace": "Votre dossier de santé a été supprimé. Un administrateur de ce serveur peut le restaurer jusqu’au {date}.",
- "dangerZoneRestoreLimits": "Les accès prennent fin aussi : ce que vous partagez avec d’autres, ce qu’ils partagent avec vous, et tout rôle de personne responsable. Une restauration remet le dossier tel qu’il est maintenant. Ce qui est ajouté après la suppression est remplacé, et les connexions d’intégration, les partages, les rôles de personne responsable et les liens de partage ne reviennent pas ; il faut les configurer à nouveau."
+ "dangerZoneRestoreLimits": "Les accès prennent fin aussi : ce que vous partagez avec d’autres, ce qu’ils partagent avec vous, et tout rôle de personne responsable. Une restauration remet le dossier tel qu’il est maintenant. Ce qui est ajouté après la suppression est remplacé, et les connexions d’intégration, les partages, les rôles de personne responsable et les liens de partage ne reviennent pas ; il faut les configurer à nouveau.",
+ "garmin": "Garmin",
+ "garminTag": "Personal API",
+ "garminDescription": "Collez une clé Garmin Personal API pour synchroniser le sommeil, la VFC, la Body Battery, le stress, les pas, le poids et les activités depuis Garmin Connect.",
+ "garminSync": "Synchroniser maintenant",
+ "garminSyncResult": "{count} entrées synchronisées",
+ "garminSyncFailed": "Échec de la synchronisation",
+ "garminViewData": "Voir votre récupération",
+ "garminReplaceKey": "Remplacer la clé",
+ "garminDisconnect": "Déconnecter",
+ "garminDisconnectTitle": "Déconnecter Garmin ?",
+ "garminDisconnectDescription": "Les synchronisations futures s'arrêtent et la clé est supprimée de ce serveur. Les données déjà importées restent dans votre historique. La clé reste valide chez Garmin jusqu'à ce que vous la révoquiez.",
+ "garminKeyValidUntil": "Clé valide jusqu'au {date}",
+ "garminKeyExpired": "Votre clé Garmin a expiré le {date}. Créez une nouvelle clé dans Garmin Connect et collez-la ici pour reprendre la synchronisation.",
+ "garminKeyExpiresToday": "Votre clé Garmin expire aujourd'hui. Créez une nouvelle clé dans Garmin Connect et collez-la ici pour que la synchronisation continue.",
+ "garminKeyExpiring": "Votre clé Garmin expire le {date}. Créez une nouvelle clé dans Garmin Connect et collez-la ici avant cette date.",
+ "garminKeyExpiryUnknown": "Garmin n'indique pas quand cette clé expire. Remplacez la clé et choisissez la validité sélectionnée dans Garmin Connect : cette carte vous préviendra une semaine avant.",
+ "garminConnected": "Garmin connecté. La première synchronisation est en route.",
+ "garminConnectFailed": "Impossible de se connecter à Garmin.",
+ "garminKeyHowTo": "Dans Garmin Connect, ouvrez Paramètres, puis Personal API, et créez une clé. Garmin ne l'affiche qu'une fois : copiez-la tout de suite.",
+ "garminKeySettingsLink": "Ouvrir Garmin Connect",
+ "garminApiKey": "Clé Personal API",
+ "garminApiKeyPlaceholder": "Collez la clé",
+ "garminApiKeySavedPlaceholder": "Enregistrée. Collez une nouvelle clé pour la remplacer",
+ "garminKeyValidity": "Validité choisie",
+ "garminKeyValidityUnknown": "Je ne sais pas",
+ "garminKeyValidityDays": "{count} jours",
+ "garminKeyValidityHelp": "Garmin n'indique pas quand une clé expire. Avec la validité, cette carte vous prévient une semaine avant que la clé cesse de fonctionner.",
+ "garminConnect": "Connecter",
+ "garminUpdate": "Enregistrer les modifications"
},
"admin": {
"title": "Administration",
diff --git a/messages/it.json b/messages/it.json
index 06623f5f4..2618d7811 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -969,7 +969,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Inserisci una durata come 7,5, 7:30 o 7h 30m.",
"durationTooLong": "Una voce non può superare le 24 ore.",
- "sourceHealthConnect": "Health Connect"
+ "sourceHealthConnect": "Health Connect",
+ "typeBodyBattery": "Body Battery",
+ "typeStressLevel": "Livello di stress",
+ "typeTrainingReadiness": "Prontezza all'allenamento",
+ "typeAcuteTrainingLoad": "Carico di allenamento acuto",
+ "sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Cosa può monitorare HealthLog",
@@ -4759,7 +4764,8 @@
"unitScore": "punti",
"unitMinutes": "min",
"unitBpm": "bpm",
- "unitKj": "kJ"
+ "unitKj": "kJ",
+ "unitLoad": "carico"
},
"sleepQuality": {
"title": "Qualità del sonno",
@@ -4781,7 +4787,11 @@
"cardioLoadExplainer": "Una stima del carico di allenamento che traccia il carico cardiovascolare accumulato. Confrontala con i tuoi giorni recenti.",
"averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
"maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
- "energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo."
+ "energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo.",
+ "bodyBatteryExplainer": "La tua riserva di energia sulla scala da 0 a 100 di Garmin, il livello più alto della giornata. Riposo e sonno la ricaricano, stress e attività la consumano.",
+ "trainingReadinessExplainer": "Quanto il tuo corpo è pronto per una sessione impegnativa, sulla scala da 0 a 100 di Garmin. Arriva tramite un ponte che hai configurato, perché Garmin non la condivide direttamente.",
+ "stressLevelExplainer": "Il livello di stress medio della giornata secondo Garmin, su una scala da 0 a 100, ricavato dalla variabilità della frequenza cardiaca. Più basso significa più calmo.",
+ "acuteTrainingLoadExplainer": "Il carico di allenamento acuto di Garmin: il carico ponderato delle tue attività degli ultimi sette giorni. Arriva tramite un ponte che hai configurato. Confrontalo con le tue settimane recenti."
},
"loadError": "Non è stato possibile caricare i tuoi valori di recupero."
},
@@ -5706,7 +5716,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Dispositivo esterno",
- "HEALTH_CONNECT": "Health Connect"
+ "HEALTH_CONNECT": "Health Connect",
+ "GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Orologio",
@@ -7500,11 +7511,6 @@
"lastEnteredHere": "Ultimo inserimento qui {relative}"
}
},
- "garminInfo": {
- "title": "Garmin",
- "body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin (Body Battery, Training Load, stato HRV, VO₂ max) non vengono condivisi da Garmin con nessuna delle due piattaforme.",
- "docsLink": "Leggi la guida"
- },
"glucoseReference": {
"title": "Banda di riferimento della glicemia",
"description": "La banda con cui vengono valutati i tuoi valori glicemici.",
@@ -7704,7 +7710,36 @@
"deleteAccountConfirmDescriptionGrace": "Vieni disconnesso e il tuo account viene bloccato subito. Dopo {days} giorni viene cancellato definitivamente, con tutti i dati sanitari, le passkey, le sessioni e le voci di audit. Fino ad allora un amministratore di questo server può annullare l’eliminazione. Le copie nei backup di questo server scadono al termine del loro periodo di conservazione.",
"deleteAccountScheduled": "Il tuo account è bloccato e verrà cancellato il {date}. Disconnessione in corso.",
"dangerZoneSuccessGrace": "La tua cartella sanitaria è stata eliminata. Un amministratore di questo server può ripristinarla fino al {date}.",
- "dangerZoneRestoreLimits": "Finisce anche l’accesso: ciò che condividi con altri, ciò che altri condividono con te e ogni ruolo di persona responsabile. Un ripristino riporta la cartella com’è adesso. Ciò che si aggiunge dopo l’eliminazione viene sostituito, e le connessioni di integrazione, le condivisioni, i ruoli di persona responsabile e i link di condivisione non tornano; vanno configurati di nuovo."
+ "dangerZoneRestoreLimits": "Finisce anche l’accesso: ciò che condividi con altri, ciò che altri condividono con te e ogni ruolo di persona responsabile. Un ripristino riporta la cartella com’è adesso. Ciò che si aggiunge dopo l’eliminazione viene sostituito, e le connessioni di integrazione, le condivisioni, i ruoli di persona responsabile e i link di condivisione non tornano; vanno configurati di nuovo.",
+ "garmin": "Garmin",
+ "garminTag": "Personal API",
+ "garminDescription": "Incolla una chiave Garmin Personal API per sincronizzare sonno, HRV, Body Battery, stress, passi, peso e attività da Garmin Connect.",
+ "garminSync": "Sincronizza ora",
+ "garminSyncResult": "{count} voci sincronizzate",
+ "garminSyncFailed": "Sincronizzazione non riuscita",
+ "garminViewData": "Vedi il tuo recupero",
+ "garminReplaceKey": "Sostituisci la chiave",
+ "garminDisconnect": "Disconnetti",
+ "garminDisconnectTitle": "Disconnettere Garmin?",
+ "garminDisconnectDescription": "Le sincronizzazioni future si fermano e la chiave viene rimossa da questo server. I dati già importati restano nella tua cronologia. La chiave resta valida su Garmin finché non la revochi lì.",
+ "garminKeyValidUntil": "Chiave valida fino al {date}",
+ "garminKeyExpired": "La tua chiave Garmin è scaduta il {date}. Crea una nuova chiave in Garmin Connect e incollala qui per riprendere la sincronizzazione.",
+ "garminKeyExpiresToday": "La tua chiave Garmin scade oggi. Crea una nuova chiave in Garmin Connect e incollala qui perché la sincronizzazione non si fermi.",
+ "garminKeyExpiring": "La tua chiave Garmin scade il {date}. Crea prima una nuova chiave in Garmin Connect e incollala qui.",
+ "garminKeyExpiryUnknown": "Garmin non dice quando scade questa chiave. Sostituisci la chiave e scegli la validità impostata in Garmin Connect: questa scheda ti avviserà una settimana prima.",
+ "garminConnected": "Garmin collegato. La prima sincronizzazione è in arrivo.",
+ "garminConnectFailed": "Impossibile collegarsi a Garmin.",
+ "garminKeyHowTo": "In Garmin Connect apri Impostazioni, poi Personal API, e crea una chiave. Garmin la mostra una sola volta, quindi copiala subito.",
+ "garminKeySettingsLink": "Apri Garmin Connect",
+ "garminApiKey": "Chiave Personal API",
+ "garminApiKeyPlaceholder": "Incolla la chiave",
+ "garminApiKeySavedPlaceholder": "Salvata. Incolla una nuova chiave per sostituirla",
+ "garminKeyValidity": "Validità scelta",
+ "garminKeyValidityUnknown": "Non lo so",
+ "garminKeyValidityDays": "{count} giorni",
+ "garminKeyValidityHelp": "Garmin non segnala quando una chiave scade. Con la validità, questa scheda ti avvisa una settimana prima che la chiave smetta di funzionare.",
+ "garminConnect": "Collega",
+ "garminUpdate": "Salva modifiche"
},
"admin": {
"title": "Administration",
diff --git a/messages/ko.json b/messages/ko.json
index cd07355df..223f65198 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -969,7 +969,12 @@
"durationMinutesOnly": "{minutes}분",
"durationInvalid": "7.5, 7:30, 7h 30m처럼 시간을 입력해 주세요.",
"durationTooLong": "한 번에 24시간보다 길게 입력할 수 없어요.",
- "sourceHealthConnect": "Health Connect"
+ "sourceHealthConnect": "Health Connect",
+ "typeBodyBattery": "바디 배터리",
+ "typeStressLevel": "스트레스 수준",
+ "typeTrainingReadiness": "트레이닝 준비도",
+ "typeAcuteTrainingLoad": "급성 훈련 부하",
+ "sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "HealthLog가 기록할 수 있는 것",
@@ -4759,7 +4764,8 @@
"unitScore": "점",
"unitMinutes": "min",
"unitBpm": "bpm",
- "unitKj": "kJ"
+ "unitKj": "kJ",
+ "unitLoad": "부하"
},
"sleepQuality": {
"title": "수면 품질",
@@ -4781,7 +4787,11 @@
"cardioLoadExplainer": "쌓인 급성 심혈관 부하를 따라가는 훈련 부하 지표예요. 내 최근 며칠과 비교해 보세요.",
"averageHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 평균 심박수예요.",
"maxHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 최고 심박수예요.",
- "energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄)."
+ "energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄).",
+ "bodyBatteryExplainer": "Garmin의 0~100 척도로 본 에너지 비축량으로, 하루 중 가장 높았던 값입니다. 휴식과 수면은 채우고, 스트레스와 활동은 소모합니다.",
+ "trainingReadinessExplainer": "Garmin의 0~100 척도로 본 고강도 운동에 대한 준비 정도입니다. Garmin이 직접 공유하지 않으므로 직접 설정한 브리지를 통해 들어옵니다.",
+ "stressLevelExplainer": "심박변이도에서 읽어낸 Garmin의 하루 평균 스트레스 수준(0~100)입니다. 낮을수록 차분한 상태입니다.",
+ "acuteTrainingLoadExplainer": "Garmin의 급성 훈련 부하로, 최근 7일 동안의 활동 부하를 가중 합산한 값입니다. 직접 설정한 브리지를 통해 들어옵니다. 최근 몇 주의 내 기록과 비교해 보세요."
},
"loadError": "회복 값을 불러오지 못했어요."
},
@@ -5706,7 +5716,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "외부 기기",
- "HEALTH_CONNECT": "Health Connect"
+ "HEALTH_CONNECT": "Health Connect",
+ "GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "워치",
@@ -7500,11 +7511,6 @@
"lastEnteredHere": "여기에서 마지막 입력 {relative}"
}
},
- "garminInfo": {
- "title": "Garmin",
- "body": "여기서는 Garmin에 직접 연결할 수 없어요. Apple Health(iOS)나 Google Health Connect(Android)를 거쳐 연결하세요. Body Battery, Training Load, HRV Status, VO₂ max 같은 Garmin 고유 점수는 Garmin이 두 플랫폼 어디에도 공유하지 않아요.",
- "docsLink": "가이드 보기"
- },
"glucoseReference": {
"title": "혈당 참고 구간",
"description": "혈당 측정값을 어떤 구간에 견줘 판단할지 정해요.",
@@ -7704,7 +7710,36 @@
"deleteAccountConfirmDescriptionGrace": "바로 로그아웃되고 계정이 잠겨요. {days}일 뒤에는 모든 건강 데이터, 패스키, 세션, 감사 기록과 함께 완전히 지워져요. 그때까지는 이 서버의 관리자가 삭제를 취소할 수 있어요. 이 서버 백업에 남은 사본은 보관 기간이 지나면 사라져요.",
"deleteAccountScheduled": "계정이 잠겼고 {date}에 완전히 지워져요. 로그아웃하는 중이에요.",
"dangerZoneSuccessGrace": "건강 기록이 삭제됐어요. {date}까지는 이 서버의 관리자가 복원할 수 있어요.",
- "dangerZoneRestoreLimits": "접근 권한도 끝나요: 다른 사람과 공유한 것, 다른 사람이 나와 공유한 것, 보호자 역할 모두요. 복원하면 기록이 지금 상태로 돌아와요. 삭제 후에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 역할, 공유 링크는 돌아오지 않아서 다시 설정해야 해요."
+ "dangerZoneRestoreLimits": "접근 권한도 끝나요: 다른 사람과 공유한 것, 다른 사람이 나와 공유한 것, 보호자 역할 모두요. 복원하면 기록이 지금 상태로 돌아와요. 삭제 후에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 역할, 공유 링크는 돌아오지 않아서 다시 설정해야 해요.",
+ "garmin": "Garmin",
+ "garminTag": "Personal API",
+ "garminDescription": "Garmin Personal API 키를 붙여 넣으면 Garmin Connect의 수면, HRV, 바디 배터리, 스트레스, 걸음 수, 체중, 활동이 동기화됩니다.",
+ "garminSync": "지금 동기화",
+ "garminSyncResult": "{count}개 항목 동기화됨",
+ "garminSyncFailed": "동기화 실패",
+ "garminViewData": "회복 보기",
+ "garminReplaceKey": "키 교체",
+ "garminDisconnect": "연결 해제",
+ "garminDisconnectTitle": "Garmin 연결을 해제할까요?",
+ "garminDisconnectDescription": "앞으로의 동기화가 중지되고 키가 이 서버에서 삭제됩니다. 이미 가져온 데이터는 기록에 남습니다. 키는 Garmin에서 취소할 때까지 계속 유효합니다.",
+ "garminKeyValidUntil": "키 유효 기간: {date}까지",
+ "garminKeyExpired": "Garmin 키가 {date}에 만료되었습니다. Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으면 동기화가 다시 시작됩니다.",
+ "garminKeyExpiresToday": "Garmin 키가 오늘 만료됩니다. 동기화가 멈추지 않도록 Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으세요.",
+ "garminKeyExpiring": "Garmin 키가 {date}에 만료됩니다. 그 전에 Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으세요.",
+ "garminKeyExpiryUnknown": "Garmin은 이 키의 만료일을 알려 주지 않습니다. 키를 교체하면서 Garmin Connect에서 고른 유효 기간을 선택하면 이 카드가 일주일 전에 알려 드립니다.",
+ "garminConnected": "Garmin이 연결되었습니다. 첫 동기화가 시작됩니다.",
+ "garminConnectFailed": "Garmin에 연결하지 못했습니다.",
+ "garminKeyHowTo": "Garmin Connect에서 설정, Personal API 순으로 열고 키를 만드세요. Garmin은 키를 한 번만 보여 주므로 바로 복사해 두세요.",
+ "garminKeySettingsLink": "Garmin Connect 열기",
+ "garminApiKey": "Personal API 키",
+ "garminApiKeyPlaceholder": "키 붙여 넣기",
+ "garminApiKeySavedPlaceholder": "저장됨. 교체하려면 새 키를 붙여 넣으세요",
+ "garminKeyValidity": "선택한 유효 기간",
+ "garminKeyValidityUnknown": "모르겠음",
+ "garminKeyValidityDays": "{count}일",
+ "garminKeyValidityHelp": "Garmin은 키 만료일을 알려 주지 않습니다. 유효 기간을 알려 주면 키가 작동을 멈추기 일주일 전에 이 카드가 알려 드립니다.",
+ "garminConnect": "연결",
+ "garminUpdate": "변경 사항 저장"
},
"admin": {
"title": "관리자",
diff --git a/messages/nl.json b/messages/nl.json
index 8e9596274..11ceeb582 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -969,7 +969,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Voer een duur in, bijvoorbeeld 7,5, 7:30 of 7h 30m.",
"durationTooLong": "Eén registratie kan niet langer zijn dan 24 uur.",
- "sourceHealthConnect": "Health Connect"
+ "sourceHealthConnect": "Health Connect",
+ "typeBodyBattery": "Body Battery",
+ "typeStressLevel": "Stressniveau",
+ "typeTrainingReadiness": "Trainingsgereedheid",
+ "typeAcuteTrainingLoad": "Acute trainingsbelasting",
+ "sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Wat HealthLog kan bijhouden",
@@ -4759,7 +4764,8 @@
"unitScore": "score",
"unitMinutes": "min",
"unitBpm": "bpm",
- "unitKj": "kJ"
+ "unitKj": "kJ",
+ "unitLoad": "belasting"
},
"sleepQuality": {
"title": "Slaapkwaliteit",
@@ -4778,7 +4784,11 @@
"cardioLoadExplainer": "Een indicatie van trainingsbelasting die de acute cardiovasculaire belasting volgt die je hebt opgebouwd. Vergelijk het met je eigen recente dagen.",
"averageHeartRateExplainer": "Je gemiddelde hartslag over de hele dag of cyclus, zoals gerapporteerd door je wearable.",
"maxHeartRateExplainer": "Je hoogste hartslag over de hele dag of cyclus, zoals gerapporteerd door je wearable.",
- "energyExpenditureKjExplainer": "De energie die je lichaam over de dag heeft verbruikt, in kilojoule, zoals je apparaat die zelf rapporteert."
+ "energyExpenditureKjExplainer": "De energie die je lichaam over de dag heeft verbruikt, in kilojoule, zoals je apparaat die zelf rapporteert.",
+ "bodyBatteryExplainer": "Je energiereserve op de schaal van 0 tot 100 van Garmin, het hoogste niveau van de dag. Rust en slaap vullen hem aan, stress en activiteit putten hem uit.",
+ "trainingReadinessExplainer": "Hoe klaar je lichaam is voor een zware training, op de schaal van 0 tot 100 van Garmin. De waarde komt via een brug die je zelf hebt ingesteld, omdat Garmin hem niet rechtstreeks deelt.",
+ "stressLevelExplainer": "Garmins gemiddelde stressniveau over de dag op een schaal van 0 tot 100, afgeleid uit je hartslagvariabiliteit. Lager is rustiger.",
+ "acuteTrainingLoadExplainer": "Garmins acute trainingsbelasting: de gewogen belasting van je activiteiten van de afgelopen zeven dagen. De waarde komt via een brug die je zelf hebt ingesteld. Vergelijk hem met je eigen recente weken."
},
"emptyAction": "Integraties openen",
"emptyOuraNoResilience": "Oura is verbonden, en het veerkrachtniveau verschijnt hier zodra Oura er een levert.",
@@ -5706,7 +5716,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Extern apparaat",
- "HEALTH_CONNECT": "Health Connect"
+ "HEALTH_CONNECT": "Health Connect",
+ "GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Horloge",
@@ -7500,11 +7511,6 @@
"lastEnteredHere": "Laatst hier ingevoerd {relative}"
}
},
- "garminInfo": {
- "title": "Garmin",
- "body": "Garmin heeft hier geen directe koppeling. Koppel via Apple Health (iOS) of Google Health Connect (Android). De eigen scores van Garmin (Body Battery, Training Load, HRV Status, VO₂ max) deelt Garmin met geen van beide platforms.",
- "docsLink": "Lees de handleiding"
- },
"glucoseReference": {
"title": "Referentiebereik voor glucose",
"description": "Aan welk bereik je bloedglucosemetingen worden getoetst.",
@@ -7704,7 +7710,36 @@
"deleteAccountConfirmDescriptionGrace": "Je wordt uitgelogd en je account wordt meteen vergrendeld. Na {days} dagen wordt het definitief gewist, met alle gezondheidsgegevens, passkeys, sessies en auditregels. Tot dan kan een beheerder van deze server de verwijdering ongedaan maken. Kopieën in de back-ups van deze server verlopen na hun bewaartermijn.",
"deleteAccountScheduled": "Je account is vergrendeld en wordt op {date} gewist. Je wordt uitgelogd.",
"dangerZoneSuccessGrace": "Je gezondheidsdossier is verwijderd. Een beheerder van deze server kan het tot {date} terugzetten.",
- "dangerZoneRestoreLimits": "Ook toegang eindigt: wat je met anderen deelt, wat anderen met jou delen en elke rol als verzorger. Terugzetten brengt het dossier terug zoals het nu is. Wat na het verwijderen is toegevoegd, wordt vervangen, en koppelingen, delen, verzorgersrollen en deellinks komen niet terug; die moeten opnieuw worden ingesteld."
+ "dangerZoneRestoreLimits": "Ook toegang eindigt: wat je met anderen deelt, wat anderen met jou delen en elke rol als verzorger. Terugzetten brengt het dossier terug zoals het nu is. Wat na het verwijderen is toegevoegd, wordt vervangen, en koppelingen, delen, verzorgersrollen en deellinks komen niet terug; die moeten opnieuw worden ingesteld.",
+ "garmin": "Garmin",
+ "garminTag": "Personal API",
+ "garminDescription": "Plak een Garmin Personal API-sleutel om slaap, HRV, Body Battery, stress, stappen, gewicht en activiteiten uit Garmin Connect te synchroniseren.",
+ "garminSync": "Nu synchroniseren",
+ "garminSyncResult": "{count} items gesynchroniseerd",
+ "garminSyncFailed": "Synchronisatie mislukt",
+ "garminViewData": "Je herstel bekijken",
+ "garminReplaceKey": "Sleutel vervangen",
+ "garminDisconnect": "Ontkoppelen",
+ "garminDisconnectTitle": "Garmin ontkoppelen?",
+ "garminDisconnectDescription": "Toekomstige synchronisaties stoppen en de sleutel wordt van deze server verwijderd. Al geïmporteerde gegevens blijven in je geschiedenis. Bij Garmin blijft de sleutel werken tot je hem daar intrekt.",
+ "garminKeyValidUntil": "Sleutel geldig tot {date}",
+ "garminKeyExpired": "Je Garmin-sleutel is op {date} verlopen. Maak in Garmin Connect een nieuwe sleutel en plak die hier om weer te synchroniseren.",
+ "garminKeyExpiresToday": "Je Garmin-sleutel verloopt vandaag. Maak in Garmin Connect een nieuwe sleutel en plak die hier zodat het synchroniseren niet stopt.",
+ "garminKeyExpiring": "Je Garmin-sleutel verloopt op {date}. Maak vóór die datum in Garmin Connect een nieuwe sleutel en plak die hier.",
+ "garminKeyExpiryUnknown": "Garmin vertelt niet wanneer deze sleutel verloopt. Vervang de sleutel en kies de geldigheid die je in Garmin Connect hebt gekozen, dan waarschuwt deze kaart je een week van tevoren.",
+ "garminConnected": "Garmin gekoppeld. De eerste synchronisatie is onderweg.",
+ "garminConnectFailed": "Kan geen verbinding maken met Garmin.",
+ "garminKeyHowTo": "Open in Garmin Connect de instellingen, dan Personal API, en maak een sleutel. Garmin toont hem maar één keer, dus kopieer hem meteen.",
+ "garminKeySettingsLink": "Garmin Connect openen",
+ "garminApiKey": "Personal API-sleutel",
+ "garminApiKeyPlaceholder": "Plak de sleutel",
+ "garminApiKeySavedPlaceholder": "Opgeslagen. Plak een nieuwe sleutel om hem te vervangen",
+ "garminKeyValidity": "Gekozen geldigheid",
+ "garminKeyValidityUnknown": "Weet ik niet",
+ "garminKeyValidityDays": "{count} dagen",
+ "garminKeyValidityHelp": "Garmin meldt niet wanneer een sleutel verloopt. Met de geldigheid waarschuwt deze kaart je een week voordat de sleutel stopt met werken.",
+ "garminConnect": "Koppelen",
+ "garminUpdate": "Wijzigingen opslaan"
},
"admin": {
"title": "Beheer",
diff --git a/messages/pl.json b/messages/pl.json
index cffa8cb9b..f81a96703 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -969,7 +969,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Wpisz czas trwania, np. 7,5, 7:30 lub 7h 30m.",
"durationTooLong": "Jeden wpis nie może przekraczać 24 godzin.",
- "sourceHealthConnect": "Health Connect"
+ "sourceHealthConnect": "Health Connect",
+ "typeBodyBattery": "Body Battery",
+ "typeStressLevel": "Poziom stresu",
+ "typeTrainingReadiness": "Gotowość do treningu",
+ "typeAcuteTrainingLoad": "Ostre obciążenie treningowe",
+ "sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Co HealthLog potrafi śledzić",
@@ -4759,7 +4764,8 @@
"unitScore": "pkt",
"unitMinutes": "min",
"unitBpm": "ud./min",
- "unitKj": "kJ"
+ "unitKj": "kJ",
+ "unitLoad": "obciążenie"
},
"sleepQuality": {
"title": "Jakość snu",
@@ -4781,7 +4787,11 @@
"cardioLoadExplainer": "Przybliżenie obciążenia treningowego, które śledzi nagromadzone obciążenie sercowo-naczyniowe. Porównuj je z własnymi ostatnimi dniami.",
"averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
"maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
- "energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia."
+ "energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia.",
+ "bodyBatteryExplainer": "Twoja rezerwa energii w skali Garmina od 0 do 100, najwyższy poziom dnia. Odpoczynek i sen ją uzupełniają, stres i aktywność ją zużywają.",
+ "trainingReadinessExplainer": "Jak bardzo Twoje ciało jest gotowe na ciężki trening, w skali Garmina od 0 do 100. Wartość przychodzi przez skonfigurowany przez Ciebie most, bo Garmin nie udostępnia jej bezpośrednio.",
+ "stressLevelExplainer": "Średni całodzienny poziom stresu według Garmina w skali od 0 do 100, wyliczany ze zmienności rytmu serca. Niżej znaczy spokojniej.",
+ "acuteTrainingLoadExplainer": "Ostre obciążenie treningowe Garmina: ważone obciążenie z Twoich aktywności z ostatnich siedmiu dni. Wartość przychodzi przez skonfigurowany przez Ciebie most. Porównuj je ze swoimi ostatnimi tygodniami."
},
"loadError": "Nie udało się załadować Twoich wartości regeneracji."
},
@@ -5706,7 +5716,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Urządzenie zewnętrzne",
- "HEALTH_CONNECT": "Health Connect"
+ "HEALTH_CONNECT": "Health Connect",
+ "GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Zegarek",
@@ -7500,11 +7511,6 @@
"lastEnteredHere": "Ostatni wpis tutaj {relative}"
}
},
- "garminInfo": {
- "title": "Garmin",
- "body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina (Body Battery, Training Load, status HRV, VO₂ max) nie są udostępniane przez Garmina żadnej z tych platform.",
- "docsLink": "Przeczytaj przewodnik"
- },
"glucoseReference": {
"title": "Zakres referencyjny glukozy",
"description": "Do jakiego zakresu porównywane są twoje wyniki glukozy we krwi.",
@@ -7704,7 +7710,36 @@
"deleteAccountConfirmDescriptionGrace": "Zostaniesz wylogowany, a twoje konto od razu zablokowane. Po {days} dniach zostanie trwale usunięte, razem ze wszystkimi danymi zdrowotnymi, kluczami dostępu, sesjami i wpisami audytu. Do tego czasu administrator tego serwera może cofnąć usunięcie. Kopie w kopiach zapasowych tego serwera wygasają po upływie okresu przechowywania.",
"deleteAccountScheduled": "Twoje konto jest zablokowane i zostanie usunięte {date}. Trwa wylogowywanie.",
"dangerZoneSuccessGrace": "Twoja dokumentacja zdrowotna została usunięta. Administrator tego serwera może ją przywrócić do {date}.",
- "dangerZoneRestoreLimits": "Kończy się też dostęp: to, co udostępniasz innym, to, co inni udostępniają tobie, i każda rola osoby opiekującej się. Przywrócenie odtwarza dokumentację taką, jaka jest teraz. To, co dojdzie po usunięciu, zostaje zastąpione, a połączenia integracji, udostępnienia, role opiekunów i linki udostępniania nie wracają; trzeba je skonfigurować od nowa."
+ "dangerZoneRestoreLimits": "Kończy się też dostęp: to, co udostępniasz innym, to, co inni udostępniają tobie, i każda rola osoby opiekującej się. Przywrócenie odtwarza dokumentację taką, jaka jest teraz. To, co dojdzie po usunięciu, zostaje zastąpione, a połączenia integracji, udostępnienia, role opiekunów i linki udostępniania nie wracają; trzeba je skonfigurować od nowa.",
+ "garmin": "Garmin",
+ "garminTag": "Personal API",
+ "garminDescription": "Wklej klucz Garmin Personal API, aby synchronizować sen, HRV, Body Battery, stres, kroki, wagę i aktywności z Garmin Connect.",
+ "garminSync": "Synchronizuj teraz",
+ "garminSyncResult": "Zsynchronizowano wpisy: {count}",
+ "garminSyncFailed": "Synchronizacja nie powiodła się",
+ "garminViewData": "Zobacz regenerację",
+ "garminReplaceKey": "Zastąp klucz",
+ "garminDisconnect": "Odłącz",
+ "garminDisconnectTitle": "Odłączyć Garmin?",
+ "garminDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane, a klucz usunięty z tego serwera. Zaimportowane dane zostają w Twojej historii. Klucz działa w Garminie, dopóki go tam nie unieważnisz.",
+ "garminKeyValidUntil": "Klucz ważny do {date}",
+ "garminKeyExpired": "Twój klucz Garmin wygasł {date}. Utwórz nowy klucz w Garmin Connect i wklej go tutaj, aby wznowić synchronizację.",
+ "garminKeyExpiresToday": "Twój klucz Garmin wygasa dzisiaj. Utwórz nowy klucz w Garmin Connect i wklej go tutaj, aby synchronizacja się nie zatrzymała.",
+ "garminKeyExpiring": "Twój klucz Garmin wygasa {date}. Przed tym dniem utwórz nowy klucz w Garmin Connect i wklej go tutaj.",
+ "garminKeyExpiryUnknown": "Garmin nie podaje, kiedy ten klucz wygaśnie. Zastąp klucz i wybierz ważność ustawioną w Garmin Connect, a ta karta ostrzeże Cię tydzień wcześniej.",
+ "garminConnected": "Połączono z Garmin. Pierwsza synchronizacja jest w drodze.",
+ "garminConnectFailed": "Nie udało się połączyć z Garmin.",
+ "garminKeyHowTo": "W Garmin Connect otwórz Ustawienia, następnie Personal API, i utwórz klucz. Garmin pokazuje go tylko raz, więc od razu go skopiuj.",
+ "garminKeySettingsLink": "Otwórz Garmin Connect",
+ "garminApiKey": "Klucz Personal API",
+ "garminApiKeyPlaceholder": "Wklej klucz",
+ "garminApiKeySavedPlaceholder": "Zapisano. Wklej nowy klucz, aby go zastąpić",
+ "garminKeyValidity": "Wybrana ważność",
+ "garminKeyValidityUnknown": "Nie wiem",
+ "garminKeyValidityDays": "{count} dni",
+ "garminKeyValidityHelp": "Garmin nie informuje, kiedy klucz wygasa. Dzięki ważności ta karta ostrzeże Cię tydzień przed tym, jak klucz przestanie działać.",
+ "garminConnect": "Połącz",
+ "garminUpdate": "Zapisz zmiany"
},
"admin": {
"title": "Administracja",
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index 4dee685c2..b7ea863fd 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -969,7 +969,12 @@
"durationMinutesOnly": "{minutes} min",
"durationInvalid": "Insira uma duração como 7,5, 7:30 ou 7h 30m.",
"durationTooLong": "Uma entrada não pode durar mais de 24 horas.",
- "sourceHealthConnect": "Health Connect"
+ "sourceHealthConnect": "Health Connect",
+ "typeBodyBattery": "Body Battery",
+ "typeStressLevel": "Nível de estresse",
+ "typeTrainingReadiness": "Prontidão para treinar",
+ "typeAcuteTrainingLoad": "Carga de treino aguda",
+ "sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "O que o HealthLog pode acompanhar",
@@ -4759,7 +4764,8 @@
"unitScore": "pontuação",
"unitMinutes": "min",
"unitBpm": "bpm",
- "unitKj": "kJ"
+ "unitKj": "kJ",
+ "unitLoad": "carga"
},
"sleepQuality": {
"title": "Qualidade do sono",
@@ -4781,7 +4787,11 @@
"cardioLoadExplainer": "Um proxy de carga de treino que acompanha a carga cardiovascular aguda que você acumulou. Compare com os seus próprios dias recentes.",
"averageHeartRateExplainer": "Sua frequência cardíaca média ao longo do dia ou ciclo inteiro, conforme relatado pelo seu wearable.",
"maxHeartRateExplainer": "Seu pico de frequência cardíaca ao longo do dia ou ciclo inteiro, conforme relatado pelo seu wearable.",
- "energyExpenditureKjExplainer": "A energia que seu corpo gastou ao longo do dia, em quilojoules, como seu dispositivo a reporta nativamente."
+ "energyExpenditureKjExplainer": "A energia que seu corpo gastou ao longo do dia, em quilojoules, como seu dispositivo a reporta nativamente.",
+ "bodyBatteryExplainer": "Sua reserva de energia na escala de 0 a 100 da Garmin, a carga mais alta do dia. Descanso e sono a recarregam, estresse e atividade a consomem.",
+ "trainingReadinessExplainer": "O quanto seu corpo está pronto para um treino pesado, na escala de 0 a 100 da Garmin. O valor chega por uma ponte que você configurou, pois a Garmin não o compartilha diretamente.",
+ "stressLevelExplainer": "O nível médio de estresse do dia segundo a Garmin, numa escala de 0 a 100, lido da variabilidade da sua frequência cardíaca. Mais baixo é mais calmo.",
+ "acuteTrainingLoadExplainer": "A carga de treino aguda da Garmin: a carga ponderada das suas atividades dos últimos sete dias. Chega por uma ponte que você configurou. Compare com as suas próprias semanas recentes."
},
"loadError": "Não foi possível carregar as suas leituras de recuperação."
},
@@ -5706,7 +5716,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Dispositivo externo",
- "HEALTH_CONNECT": "Health Connect"
+ "HEALTH_CONNECT": "Health Connect",
+ "GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Relógio",
@@ -7500,11 +7511,6 @@
"lastEnteredHere": "Último registro feito aqui {relative}"
}
},
- "garminInfo": {
- "title": "Garmin",
- "body": "O Garmin não tem conexão direta aqui. Conecte pelo Apple Health (iOS) ou pelo Google Health Connect (Android). As pontuações próprias da Garmin (Body Battery, Training Load, HRV Status, VO₂ máx.) não são compartilhadas pela Garmin com nenhuma das duas plataformas.",
- "docsLink": "Ler o guia"
- },
"glucoseReference": {
"title": "Faixa de referência da glicose",
"description": "Com qual faixa as suas leituras de glicose são comparadas.",
@@ -7704,7 +7710,36 @@
"deleteAccountConfirmDescriptionGrace": "Você sai da sessão e sua conta é bloqueada na hora. Depois de {days} dias, ela é apagada de vez, com todos os dados de saúde, chaves de acesso, sessões e registros de auditoria. Até lá, um administrador deste servidor pode desfazer a exclusão. As cópias nos backups deste servidor expiram depois do período de retenção.",
"deleteAccountScheduled": "Sua conta está bloqueada e será apagada em {date}. Saindo da sessão.",
"dangerZoneSuccessGrace": "Seu registro de saúde foi excluído. Um administrador deste servidor pode restaurá-lo até {date}.",
- "dangerZoneRestoreLimits": "O acesso também termina: o que você compartilha com outras pessoas, o que elas compartilham com você e qualquer papel de guardião. Uma restauração traz o registro de volta como está agora. O que for adicionado depois da exclusão é substituído, e conexões de integração, compartilhamentos, papéis de guardião e links de compartilhamento não voltam; precisam ser configurados de novo."
+ "dangerZoneRestoreLimits": "O acesso também termina: o que você compartilha com outras pessoas, o que elas compartilham com você e qualquer papel de guardião. Uma restauração traz o registro de volta como está agora. O que for adicionado depois da exclusão é substituído, e conexões de integração, compartilhamentos, papéis de guardião e links de compartilhamento não voltam; precisam ser configurados de novo.",
+ "garmin": "Garmin",
+ "garminTag": "Personal API",
+ "garminDescription": "Cole uma chave da Garmin Personal API para sincronizar sono, VFC, Body Battery, estresse, passos, peso e atividades do Garmin Connect.",
+ "garminSync": "Sincronizar agora",
+ "garminSyncResult": "{count} registros sincronizados",
+ "garminSyncFailed": "Falha na sincronização",
+ "garminViewData": "Ver sua recuperação",
+ "garminReplaceKey": "Substituir chave",
+ "garminDisconnect": "Desconectar",
+ "garminDisconnectTitle": "Desconectar a Garmin?",
+ "garminDisconnectDescription": "As próximas sincronizações param e a chave é removida deste servidor. Os dados já importados continuam no seu histórico. A chave continua válida na Garmin até você revogá-la lá.",
+ "garminKeyValidUntil": "Chave válida até {date}",
+ "garminKeyExpired": "Sua chave Garmin expirou em {date}. Crie uma nova chave no Garmin Connect e cole-a aqui para retomar a sincronização.",
+ "garminKeyExpiresToday": "Sua chave Garmin expira hoje. Crie uma nova chave no Garmin Connect e cole-a aqui para a sincronização não parar.",
+ "garminKeyExpiring": "Sua chave Garmin expira em {date}. Antes disso, crie uma nova chave no Garmin Connect e cole-a aqui.",
+ "garminKeyExpiryUnknown": "A Garmin não informa quando esta chave expira. Substitua a chave e escolha a validade definida no Garmin Connect, e este cartão avisará você com uma semana de antecedência.",
+ "garminConnected": "Garmin conectada. A primeira sincronização está a caminho.",
+ "garminConnectFailed": "Não foi possível conectar à Garmin.",
+ "garminKeyHowTo": "No Garmin Connect, abra Configurações, depois Personal API, e crie uma chave. A Garmin mostra a chave só uma vez, então copie-a na hora.",
+ "garminKeySettingsLink": "Abrir o Garmin Connect",
+ "garminApiKey": "Chave da Personal API",
+ "garminApiKeyPlaceholder": "Cole a chave",
+ "garminApiKeySavedPlaceholder": "Salva. Cole uma nova chave para substituí-la",
+ "garminKeyValidity": "Validade escolhida",
+ "garminKeyValidityUnknown": "Não sei",
+ "garminKeyValidityDays": "{count} dias",
+ "garminKeyValidityHelp": "A Garmin não informa quando uma chave expira. Com a validade, este cartão avisa você uma semana antes de a chave parar de funcionar.",
+ "garminConnect": "Conectar",
+ "garminUpdate": "Salvar alterações"
},
"admin": {
"title": "Administração",
diff --git a/messages/ru.json b/messages/ru.json
index 6e99a119e..479c7b73a 100644
--- a/messages/ru.json
+++ b/messages/ru.json
@@ -969,7 +969,12 @@
"durationMinutesOnly": "{minutes} мин",
"durationInvalid": "Введите длительность, например 7,5, 7:30 или 7ч 30м.",
"durationTooLong": "Одна запись не может быть длиннее 24 часов.",
- "sourceHealthConnect": "Health Connect"
+ "sourceHealthConnect": "Health Connect",
+ "typeBodyBattery": "Body Battery",
+ "typeStressLevel": "Уровень стресса",
+ "typeTrainingReadiness": "Готовность к тренировке",
+ "typeAcuteTrainingLoad": "Острая тренировочная нагрузка",
+ "sourceGarmin": "Garmin"
},
"metricCatalog": {
"title": "Что умеет отслеживать HealthLog",
@@ -4759,7 +4764,8 @@
"unitScore": "балл",
"unitMinutes": "мин",
"unitBpm": "уд/мин",
- "unitKj": "кДж"
+ "unitKj": "кДж",
+ "unitLoad": "нагрузка"
},
"sleepQuality": {
"title": "Качество сна",
@@ -4781,7 +4787,11 @@
"cardioLoadExplainer": "Показатель тренировочной нагрузки, отражающий накопленную острую сердечно-сосудистую нагрузку. Сравнивайте его со своими недавними днями.",
"averageHeartRateExplainer": "Ваша средняя ЧСС за весь день или цикл по данным носимого устройства.",
"maxHeartRateExplainer": "Ваша пиковая ЧСС за весь день или цикл по данным носимого устройства.",
- "energyExpenditureKjExplainer": "Энергия, которую ваш организм потратил за день, в килоджоулях, как её передаёт устройство."
+ "energyExpenditureKjExplainer": "Энергия, которую ваш организм потратил за день, в килоджоулях, как её передаёт устройство.",
+ "bodyBatteryExplainer": "Ваш запас энергии по шкале Garmin от 0 до 100, наивысший уровень за день. Отдых и сон его пополняют, стресс и активность расходуют.",
+ "trainingReadinessExplainer": "Насколько ваше тело готово к тяжёлой тренировке, по шкале Garmin от 0 до 100. Значение поступает через настроенный вами мост, потому что Garmin не передаёт его напрямую.",
+ "stressLevelExplainer": "Средний уровень стресса за день по шкале Garmin от 0 до 100, рассчитанный по вариабельности сердечного ритма. Чем ниже, тем спокойнее.",
+ "acuteTrainingLoadExplainer": "Острая тренировочная нагрузка Garmin: взвешенная нагрузка ваших тренировок за последние семь дней. Значение поступает через настроенный вами мост. Сравнивайте его со своими последними неделями."
},
"loadError": "Не удалось загрузить данные восстановления."
},
@@ -5706,7 +5716,8 @@
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava",
"EXTERNAL": "Внешнее устройство",
- "HEALTH_CONNECT": "Health Connect"
+ "HEALTH_CONNECT": "Health Connect",
+ "GARMIN": "Garmin"
},
"deviceLabels": {
"watch": "Часы",
@@ -7500,11 +7511,6 @@
"lastEnteredHere": "Последний ввод здесь {relative}"
}
},
- "garminInfo": {
- "title": "Garmin",
- "body": "У Garmin здесь нет прямого подключения. Подключайтесь через Apple Health (iOS) или Google Health Connect (Android). Собственные показатели Garmin (Body Battery, Training Load, HRV Status, VO₂ max) Garmin не передаёт ни на одну из платформ.",
- "docsLink": "Прочитать руководство"
- },
"glucoseReference": {
"title": "Референсный диапазон глюкозы",
"description": "По какому диапазону оцениваются ваши измерения глюкозы крови.",
@@ -7704,7 +7710,36 @@
"deleteAccountConfirmDescriptionGrace": "Вы выходите из аккаунта, и он сразу блокируется. Через {days} дней он стирается навсегда вместе со всеми данными о здоровье, ключами доступа, сеансами и записями аудита. До этого администратор этого сервера может отменить удаление. Копии в резервных копиях этого сервера истекают по истечении срока хранения.",
"deleteAccountScheduled": "Ваш аккаунт заблокирован и будет стёрт {date}. Выполняется выход.",
"dangerZoneSuccessGrace": "Ваша история здоровья удалена. Администратор этого сервера может восстановить её до {date}.",
- "dangerZoneRestoreLimits": "Доступ тоже завершается: то, чем вы делитесь с другими, то, чем они делятся с вами, и любая роль опекуна. Восстановление возвращает запись в том виде, в каком она сейчас. Всё, что добавлено после удаления, заменяется, а подключения интеграций, общий доступ, роли опекуна и ссылки общего доступа не возвращаются; их нужно настраивать заново."
+ "dangerZoneRestoreLimits": "Доступ тоже завершается: то, чем вы делитесь с другими, то, чем они делятся с вами, и любая роль опекуна. Восстановление возвращает запись в том виде, в каком она сейчас. Всё, что добавлено после удаления, заменяется, а подключения интеграций, общий доступ, роли опекуна и ссылки общего доступа не возвращаются; их нужно настраивать заново.",
+ "garmin": "Garmin",
+ "garminTag": "Personal API",
+ "garminDescription": "Вставьте ключ Garmin Personal API, чтобы синхронизировать сон, ВСР, Body Battery, стресс, шаги, вес и тренировки из Garmin Connect.",
+ "garminSync": "Синхронизировать сейчас",
+ "garminSyncResult": "Синхронизировано записей: {count}",
+ "garminSyncFailed": "Сбой синхронизации",
+ "garminViewData": "Посмотреть восстановление",
+ "garminReplaceKey": "Заменить ключ",
+ "garminDisconnect": "Отключить",
+ "garminDisconnectTitle": "Отключить Garmin?",
+ "garminDisconnectDescription": "Будущие синхронизации прекратятся, ключ будет удалён с этого сервера. Уже импортированные данные останутся в истории. В Garmin ключ действует, пока вы его там не отзовёте.",
+ "garminKeyValidUntil": "Ключ действует до {date}",
+ "garminKeyExpired": "Срок действия вашего ключа Garmin истёк {date}. Создайте новый ключ в Garmin Connect и вставьте его сюда, чтобы возобновить синхронизацию.",
+ "garminKeyExpiresToday": "Срок действия вашего ключа Garmin истекает сегодня. Создайте новый ключ в Garmin Connect и вставьте его сюда, чтобы синхронизация не остановилась.",
+ "garminKeyExpiring": "Срок действия вашего ключа Garmin истекает {date}. До этого создайте новый ключ в Garmin Connect и вставьте его сюда.",
+ "garminKeyExpiryUnknown": "Garmin не сообщает, когда истекает срок действия ключа. Замените ключ и выберите срок, указанный в Garmin Connect, и эта карточка предупредит вас за неделю.",
+ "garminConnected": "Garmin подключён. Первая синхронизация уже началась.",
+ "garminConnectFailed": "Не удалось подключиться к Garmin.",
+ "garminKeyHowTo": "В Garmin Connect откройте «Настройки», затем Personal API, и создайте ключ. Garmin показывает его только один раз, поэтому сразу скопируйте его.",
+ "garminKeySettingsLink": "Открыть Garmin Connect",
+ "garminApiKey": "Ключ Personal API",
+ "garminApiKeyPlaceholder": "Вставьте ключ",
+ "garminApiKeySavedPlaceholder": "Сохранён. Вставьте новый ключ, чтобы заменить его",
+ "garminKeyValidity": "Выбранный срок действия",
+ "garminKeyValidityUnknown": "Не знаю",
+ "garminKeyValidityDays": "{count} дней",
+ "garminKeyValidityHelp": "Garmin не сообщает, когда истекает срок действия ключа. Зная срок, эта карточка предупредит вас за неделю до того, как ключ перестанет работать.",
+ "garminConnect": "Подключить",
+ "garminUpdate": "Сохранить изменения"
},
"admin": {
"title": "Администрирование",
diff --git a/prisma/migrations/0492_garmin_personal_api/migration.sql b/prisma/migrations/0492_garmin_personal_api/migration.sql
new file mode 100644
index 000000000..7df840a44
--- /dev/null
+++ b/prisma/migrations/0492_garmin_personal_api/migration.sql
@@ -0,0 +1,34 @@
+-- Garmin Personal API (#1333): a per-user key pasted once, a new measurement
+-- source for the rows the sync writes, Garmin's own day scores (Body Battery,
+-- stress level), and two types a bridge pushes through the ingest token
+-- because the Personal API does not carry them (Training Readiness, acute
+-- training load).
+--
+-- One DO block, so the lock wait is bounded and the file is one statement
+-- that rolls back whole. Adding an enum value inside a transaction is fine
+-- since PostgreSQL 12 as long as the value is not used in it, and nothing
+-- here uses them.
+--
+-- Purely additive: nullable columns, no default, no backfill. IF NOT EXISTS
+-- keeps a replay harmless. Enum values cannot be dropped in PostgreSQL, so
+-- there is no down path for them; dropping the six columns undoes the rest.
+--
+-- @retry-on-lock-timeout
+
+DO $migration$
+BEGIN
+ PERFORM set_config('lock_timeout', '5s', true);
+ ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'GARMIN';
+ ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_BATTERY';
+ ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRESS_LEVEL';
+ ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TRAINING_READINESS';
+ ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ACUTE_TRAINING_LOAD';
+ ALTER TABLE "users"
+ ADD COLUMN IF NOT EXISTS "garmin_api_key_encrypted" TEXT,
+ ADD COLUMN IF NOT EXISTS "garmin_key_expires_at" TIMESTAMP(3),
+ ADD COLUMN IF NOT EXISTS "garmin_connected_at" TIMESTAMP(3),
+ ADD COLUMN IF NOT EXISTS "garmin_region" TEXT,
+ ADD COLUMN IF NOT EXISTS "garmin_backfill_progress" JSONB,
+ ADD COLUMN IF NOT EXISTS "garmin_backfill_completed_at" TIMESTAMP(3);
+END
+$migration$;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4454303e9..950f86b99 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -320,6 +320,23 @@ model User {
/// account another record already holds is refused
/// (`src/lib/integrations/oauth-kit/account-link.ts`). Cleared on disconnect.
ouraUserId String? @map("oura_user_id")
+
+ // v1.47 — Garmin Personal API (#1333, per-user, encrypted at app level). The
+ // person creates a key in Garmin Connect and pastes it once; the sync
+ // exchanges it for a short-lived token on every run, so the key is the only
+ // credential at rest. The token itself is never stored.
+ /// Sealed Garmin Personal API key.
+ garminApiKeyEncrypted String? @map("garmin_api_key_encrypted")
+ /// v1.47 — when the Garmin key stops working: learned from the token's own expiry once it is capped by the key's, else the validity the person picked at connect counted from then. Null when neither is known. Drives the card's expiry warning.
+ garminKeyExpiresAt DateTime? @map("garmin_key_expires_at")
+ /// v1.47 — when the current Garmin key was connected; the anchor the picked validity counts from.
+ garminConnectedAt DateTime? @map("garmin_connected_at")
+ /// v1.47 — which Garmin service accepted the key at connect: `global` (personal.connectapi.garmin.com) or `china` (…garmin.cn). Not a secret; the sync reads it to pick the host.
+ garminRegion String? @map("garmin_region")
+ /// v1.47 — how far the Garmin history backfill has got, per resource (see `src/lib/garmin/backfill-progress.ts`). Cleared on connect and disconnect.
+ garminBackfillProgress Json? @map("garmin_backfill_progress")
+ /// v1.47 — the Garmin history backfill finished (discovery predicate `garmin_backfill_completed_at IS NULL`). Cleared on connect.
+ garminBackfillCompletedAt DateTime? @map("garmin_backfill_completed_at")
/// v1.44 — which provider apps on this record were copied from a
/// guardian at connect, and from whom: `{ "": "" }`.
/// A managed profile has no settings page for its own app, so a guardian's is
@@ -1783,6 +1800,13 @@ enum MeasurementType {
INDOOR_TEMPERATURE // celsius — indoor air temperature. Distinct from every body temperature type.
INDOOR_HUMIDITY // percent (0–100) — indoor relative humidity.
AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis.
+ // v1.47 — Garmin Personal API (#1333). Garmin's own scores keep types of
+ // their own, so a device-native figure never shares a bucket with the
+ // COMPUTED proxies or another vendor's score of a different scale.
+ BODY_BATTERY // score (0–100) — Garmin Body Battery, the day's highest charge. Source GARMIN.
+ STRESS_LEVEL // score (0–100) — Garmin's all-day average stress level. Device-native and kept apart from the COMPUTED STRESS_SCORE, whose readers are single-source.
+ TRAINING_READINESS // score (0–100) — Garmin Training Readiness. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL).
+ ACUTE_TRAINING_LOAD // load (unit-less) — Garmin acute training load. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL).
@@map("measurement_type")
}
@@ -2018,6 +2042,11 @@ enum MeasurementSource {
// iOS DTO must mirror this spelling so the /api/sync/changes delta feed
// decodes the rows.
HEALTH_CONNECT
+ // v1.47 — Garmin Personal API (#1333). Server-owned: only the Garmin sync
+ // writes these rows, from the key the user pasted, so the batch +
+ // single-POST surfaces reject it like the other providers. iOS DTO must
+ // mirror this spelling so the /api/sync/changes delta feed decodes the rows.
+ GARMIN
@@map("measurement_source")
}
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index 780dee8dc..9a4754f7a 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -308,6 +308,7 @@ async function main() {
"nightscoutTokenEncrypted",
"dawarichUrlEncrypted",
"dawarichApiKeyEncrypted",
+ "garminApiKeyEncrypted",
"polarAccessTokenEncrypted",
"polarUserIdEncrypted",
"polarClientIdEncrypted",
diff --git a/src/__tests__/backfill-preserves-updated-at-guard.test.ts b/src/__tests__/backfill-preserves-updated-at-guard.test.ts
index 7531d6bd0..8c15f76ec 100644
--- a/src/__tests__/backfill-preserves-updated-at-guard.test.ts
+++ b/src/__tests__/backfill-preserves-updated-at-guard.test.ts
@@ -116,6 +116,8 @@ const BACKFILL_STAMPS: Record = {
"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":
diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json
index 7e93fa292..ff9370204 100644
--- a/src/__tests__/baselines/source-file-lines.json
+++ b/src/__tests__/baselines/source-file-lines.json
@@ -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,
diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json
index 537c6b9a6..e8b6bdae4 100644
--- a/src/__tests__/baselines/source-function-lines.json
+++ b/src/__tests__/baselines/source-function-lines.json
@@ -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,
diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json
index b95f224f2..f89a99ecd 100644
--- a/src/__tests__/baselines/version-stamp-comments.json
+++ b/src/__tests__/baselines/version-stamp-comments.json
@@ -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,
diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts
index 0102123dc..5e030daba 100644
--- a/src/__tests__/daily-reads-refetch-guard.test.ts
+++ b/src/__tests__/daily-reads-refetch-guard.test.ts
@@ -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",
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index 75f36fd43..720f1be30 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -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:
diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts
index 65a6ecb38..0845b7580 100644
--- a/src/__tests__/doctor-report-control-gating-guard.test.ts
+++ b/src/__tests__/doctor-report-control-gating-guard.test.ts
@@ -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);
});
diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts
index 4c1a46d9d..fb9801d4a 100644
--- a/src/__tests__/i18n-english-leak-guard.test.ts
+++ b/src/__tests__/i18n-english-leak-guard.test.ts
@@ -110,6 +110,10 @@ const LEGIT_IDENTICAL = new Set([
"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",
diff --git a/src/__tests__/record-settings-contract.test.ts b/src/__tests__/record-settings-contract.test.ts
index 8267b1ec1..666a3ece7 100644
--- a/src/__tests__/record-settings-contract.test.ts
+++ b/src/__tests__/record-settings-contract.test.ts
@@ -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(
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index 02e7a8a31..44e33938c 100644
--- a/src/__tests__/success-affordance-guard.test.ts
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -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,
diff --git a/src/__tests__/workout-sport-provenance-guard.test.ts b/src/__tests__/workout-sport-provenance-guard.test.ts
index addf8f02f..cfc3af61d 100644
--- a/src/__tests__/workout-sport-provenance-guard.test.ts
+++ b/src/__tests__/workout-sport-provenance-guard.test.ts
@@ -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",
diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts
index aa62a99d9..91075ebd2 100644
--- a/src/__tests__/written-outcome-response-consumer-guard.test.ts
+++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts
@@ -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"),
diff --git a/src/app/api/garmin/connect/route.ts b/src/app/api/garmin/connect/route.ts
new file mode 100644
index 000000000..2226b7959
--- /dev/null
+++ b/src/app/api/garmin/connect/route.ts
@@ -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),
+ });
+});
diff --git a/src/app/api/garmin/disconnect/route.ts b/src/app/api/garmin/disconnect/route.ts
new file mode 100644
index 000000000..d50de4684
--- /dev/null
+++ b/src/app/api/garmin/disconnect/route.ts
@@ -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 });
+});
diff --git a/src/app/api/garmin/status/route.ts b/src/app/api/garmin/status/route.ts
new file mode 100644
index 000000000..beafd8bb5
--- /dev/null
+++ b/src/app/api/garmin/status/route.ts
@@ -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"),
+ });
+});
diff --git a/src/app/api/garmin/sync/route.ts b/src/app/api/garmin/sync/route.ts
new file mode 100644
index 000000000..91d4f3ef6
--- /dev/null
+++ b/src/app/api/garmin/sync/route.ts
@@ -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);
+ }
+});
diff --git a/src/app/api/garmin/test/route.ts b/src/app/api/garmin/test/route.ts
new file mode 100644
index 000000000..9d71c4cb0
--- /dev/null
+++ b/src/app/api/garmin/test/route.ts
@@ -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" },
+ );
+ }
+});
diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts
index 5f490dac5..a18e9b290 100644
--- a/src/app/api/insights/metric-status/route.ts
+++ b/src/app/api/insights/metric-status/route.ts
@@ -62,6 +62,10 @@ const METRIC_MODULE: Partial> = {
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",
diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts
index 907f210cc..c25c41cf8 100644
--- a/src/app/api/integrations/status/__tests__/route.test.ts
+++ b/src/app/api/integrations/status/__tests__/route.test.ts
@@ -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" });
diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts
index 56539ee6e..af1b9da75 100644
--- a/src/app/api/integrations/status/route.ts
+++ b/src/app/api/integrations/status/route.ts
@@ -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 {
diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts
index e87ebba78..0d98d3313 100644
--- a/src/app/api/record-settings/integrations/route.ts
+++ b/src/app/api/record-settings/integrations/route.ts
@@ -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
diff --git a/src/components/insights/__tests__/coach-metric-scope-inventory.test.ts b/src/components/insights/__tests__/coach-metric-scope-inventory.test.ts
index 573d475c2..88a54c5b1 100644
--- a/src/components/insights/__tests__/coach-metric-scope-inventory.test.ts
+++ b/src/components/insights/__tests__/coach-metric-scope-inventory.test.ts
@@ -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,
diff --git a/src/components/insights/coach-metric-scope.ts b/src/components/insights/coach-metric-scope.ts
index 4655c2913..52faa7c36 100644
--- a/src/components/insights/coach-metric-scope.ts
+++ b/src/components/insights/coach-metric-scope.ts
@@ -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,
diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx
index f21fa05de..b88e544d7 100644
--- a/src/components/insights/recovery/recovery-section.tsx
+++ b/src/components/insights/recovery/recovery-section.tsx
@@ -58,46 +58,17 @@ const EMPTY_COPY = {
} as const satisfies Record;
/**
- * 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
- * `` chart with the identical 7 / 30 / 90 / All toggle,
- * and a per-chart AI assessment (``). 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["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
+ * `` chart with the identical 7 / 30 / 90 / All toggle,
+ * and a per-chart AI assessment (``). 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.
diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts
index e0d6a8125..c42d2733a 100644
--- a/src/components/measurements/measurement-list-meta.ts
+++ b/src/components/measurements/measurement-list-meta.ts
@@ -18,6 +18,7 @@ import {
Scale,
Heart,
Activity,
+ Battery,
Droplets,
Droplet,
Moon,
@@ -142,6 +143,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = {
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 = {
@@ -289,6 +294,11 @@ export const MEASUREMENT_TYPE_ICONS: Record = {
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
diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts
index 98409b66f..cf0bdd8a1 100644
--- a/src/components/measurements/measurement-type-colors.ts
+++ b/src/components/measurements/measurement-type-colors.ts
@@ -142,4 +142,10 @@ export const MEASUREMENT_TYPE_COLORS: Record = {
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",
};
diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx
index 40405165f..af3dc3164 100644
--- a/src/components/settings/__tests__/integrations-section.test.tsx
+++ b/src/components/settings/__tests__/integrations-section.test.tsx
@@ -102,6 +102,7 @@ const PROVIDERS = [
"oura",
"strava",
"nightscout",
+ "garmin",
] as const;
function health(overrides: Record = {}) {
@@ -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"',
- );
});
});
diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx
index ff99fb617..a91463cec 100644
--- a/src/components/settings/__tests__/report-selection-panel.test.tsx
+++ b/src/components/settings/__tests__/report-selection-panel.test.tsx
@@ -194,9 +194,9 @@ describe(" — 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");
});
diff --git a/src/components/settings/integrations/__tests__/cards-render-health.test.ts b/src/components/settings/integrations/__tests__/cards-render-health.test.ts
index 5ae749d7e..3e05a0eea 100644
--- a/src/components/settings/integrations/__tests__/cards-render-health.test.ts
+++ b/src/components/settings/integrations/__tests__/cards-render-health.test.ts
@@ -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 })
diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx
index 5e26efb55..51563cb68 100644
--- a/src/components/settings/integrations/connections-panel.tsx
+++ b/src/components/settings/integrations/connections-panel.tsx
@@ -2,7 +2,7 @@
/**
* `` — 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 = new Set([
"strava",
"nightscout",
"dawarich",
+ "garmin",
]);
export function ConnectionsPanel({
@@ -403,6 +404,9 @@ export function ConnectionsPanel({
callbackUrl={callbackUrls.strava}
/>
+
+
+
))}
- {/* 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). */}
-
-
-
);
}
diff --git a/src/components/settings/integrations/garmin-card.tsx b/src/components/settings/integrations/garmin-card.tsx
new file mode 100644
index 000000000..41c2f435c
--- /dev/null
+++ b/src/components/settings/integrations/garmin-card.tsx
@@ -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(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("#garmin-api-key")
+ ?.focus();
+ });
+ }
+
+ const form = ;
+
+ if (!connected) {
+ return (
+ {t("settings.garminTag")}}
+ description={
+
+ }
+ 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 (
+ {t("settings.garminTag")}}
+ 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 ? (
+
+ {t("settings.garminKeyValidUntil", {
+ date: fmt.date(keyExpiry.expiresAt),
+ })}
+
+ ) : null}
+ {status?.metricFreshness?.length ? (
+
+ ) : 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.
+
+ {expiryNote}
+
+ ) : null}
+
+ {editing ? form : null}
+
+ );
+}
+
+/**
+ * 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["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;
+ 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 (
+
+ );
+}
diff --git a/src/components/settings/integrations/garmin-info-note.tsx b/src/components/settings/integrations/garmin-info-note.tsx
deleted file mode 100644
index 10c3fd645..000000000
--- a/src/components/settings/integrations/garmin-info-note.tsx
+++ /dev/null
@@ -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 (
-
-
- {t("settings.garminInfo.body")}{" "}
-
- {t("settings.garminInfo.docsLink")}
-
-
-
- }
- />
-
- );
-}
diff --git a/src/components/settings/integrations/integration-fallback-row.tsx b/src/components/settings/integrations/integration-fallback-row.tsx
index 0597d07a0..7203530e1 100644
--- a/src/components/settings/integrations/integration-fallback-row.tsx
+++ b/src/components/settings/integrations/integration-fallback-row.tsx
@@ -40,6 +40,7 @@ export const INTEGRATION_DISPLAY_NAMES: Record = {
"google-health": "Google Health",
strava: "Strava",
dawarich: "Dawarich",
+ garmin: "Garmin",
};
/**
diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx
index 19ae6463e..def2a27fb 100644
--- a/src/components/settings/integrations/setup-guide-link.tsx
+++ b/src/components/settings/integrations/setup-guide-link.tsx
@@ -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}/`;
diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx
index ae125c1d1..59b1df24c 100644
--- a/src/components/settings/integrations/shared.tsx
+++ b/src/components/settings/integrations/shared.tsx
@@ -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. */
diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts
index 79c4093c6..e219696ce 100644
--- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts
+++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts
@@ -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", () => {
diff --git a/src/lib/command-palette/settings-cards.ts b/src/lib/command-palette/settings-cards.ts
index 3dd3a4191..cd8e71f23 100644
--- a/src/lib/command-palette/settings-cards.ts
+++ b/src/lib/command-palette/settings-cards.ts
@@ -149,7 +149,7 @@ export const SETTINGS_CARDS: ReadonlyArray = [
{
section: "integrations",
anchor: "garmin",
- titleKey: "settings.garminInfo.title",
+ titleKey: "settings.garmin",
},
{
section: "integrations",
diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts
index 0b1fa378c..8148f161d 100644
--- a/src/lib/crypto/encrypted-columns.ts
+++ b/src/lib/crypto/encrypted-columns.ts
@@ -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" },
diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts
index 62be34c74..fb9dac05e 100644
--- a/src/lib/dashboard-layout.ts
+++ b/src/lib/dashboard-layout.ts
@@ -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",
diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts
index 6900918ed..50088cced 100644
--- a/src/lib/data-wipe/wipe-plan.ts
+++ b/src/lib/data-wipe/wipe-plan.ts
@@ -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,
diff --git a/src/lib/export/account-settings-classes.ts b/src/lib/export/account-settings-classes.ts
index 436ed21b4..b582237a9 100644
--- a/src/lib/export/account-settings-classes.ts
+++ b/src/lib/export/account-settings-classes.ts
@@ -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",
diff --git a/src/lib/garmin/backfill-progress.ts b/src/lib/garmin/backfill-progress.ts
new file mode 100644
index 000000000..e0fa876a3
--- /dev/null
+++ b/src/lib/garmin/backfill-progress.ts
@@ -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>;
+}
+
+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;
+ const anchor = day.safeParse(raw.anchor);
+ if (!anchor.success) return null;
+ const rawSteps =
+ raw.steps && typeof raw.steps === "object"
+ ? (raw.steps as Record)
+ : {};
+ 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;
+}
diff --git a/src/lib/garmin/client.ts b/src/lib/garmin/client.ts
new file mode 100644
index 000000000..a5dfcbbf2
--- /dev/null
+++ b/src/lib/garmin/client.ts
@@ -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 `, 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;
+ 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 {
+ 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();
+
+/** 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 {
+ 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(
+ verb: string,
+ path: string,
+ query: GarminQuery = {},
+ ): Promise {
+ 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 {
+ 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;
+}
diff --git a/src/lib/garmin/credentials.ts b/src/lib/garmin/credentials.ts
new file mode 100644
index 000000000..c85379940
--- /dev/null
+++ b/src/lib/garmin/credentials.ts
@@ -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 {
+ 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 {
+ 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) },
+ });
+}
diff --git a/src/lib/garmin/cycle-sync.ts b/src/lib/garmin/cycle-sync.ts
new file mode 100644
index 000000000..b76c78f21
--- /dev/null
+++ b/src/lib/garmin/cycle-sync.ts
@@ -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 {
+ 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 {
+ return session.get(
+ "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 {
+ 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);
+}
diff --git a/src/lib/garmin/key-expiry.ts b/src/lib/garmin/key-expiry.ts
new file mode 100644
index 000000000..06f086a48
--- /dev/null
+++ b/src/lib/garmin/key-expiry.ts
@@ -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,
+ };
+}
diff --git a/src/lib/garmin/map.ts b/src/lib/garmin/map.ts
new file mode 100644
index 000000000..1e8c7ef5c
--- /dev/null
+++ b/src/lib/garmin/map.ts
@@ -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, 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();
+ 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;
+}
diff --git a/src/lib/garmin/sport-map.ts b/src/lib/garmin/sport-map.ts
new file mode 100644
index 000000000..b22f3f766
--- /dev/null
+++ b/src/lib/garmin/sport-map.ts
@@ -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 = [
+ // 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 = 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";
+}
diff --git a/src/lib/garmin/sync-workouts.ts b/src/lib/garmin/sync-workouts.ts
new file mode 100644
index 000000000..bf7eaa768
--- /dev/null
+++ b/src/lib/garmin/sync-workouts.ts
@@ -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 {
+ 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;
+}
diff --git a/src/lib/garmin/sync.ts b/src/lib/garmin/sync.ts
new file mode 100644
index 000000000..2817ac565
--- /dev/null
+++ b/src/lib/garmin/sync.ts
@@ -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 = {
+ 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 {
+ 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 {
+ // Offset paging: the cursor is the next `start`; a short page is the last.
+ const walk = await walkPages(
+ "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 {
+ 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(
+ "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(
+ "hrvDaily",
+ `${GARMIN_PATHS.hrvDaily}/${chunk.startDate}/${chunk.endDate}`,
+ );
+ out.measurements.push(...mapGarminHrv(env, tz));
+ break;
+ }
+ case "weight": {
+ const env = await session.get(
+ "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 {
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/src/lib/garmin/wire-types.ts b/src/lib/garmin/wire-types.ts
new file mode 100644
index 000000000..5d346732a
--- /dev/null
+++ b/src/lib/garmin/wire-types.ts
@@ -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;
+}
diff --git a/src/lib/i18n/source-labels.ts b/src/lib/i18n/source-labels.ts
index 57e3bc5f6..8fb84a6d5 100644
--- a/src/lib/i18n/source-labels.ts
+++ b/src/lib/i18n/source-labels.ts
@@ -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",
diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts
index 3b0fef932..5b28c6a11 100644
--- a/src/lib/insights/chart-tokens.ts
+++ b/src/lib/insights/chart-tokens.ts
@@ -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",
diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts
index 8f018dac7..8c81a7a08 100644
--- a/src/lib/insights/metric-status-registry.ts
+++ b/src/lib/insights/metric-status-registry.ts
@@ -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 = {
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: {
diff --git a/src/lib/insights/recovery-empty.ts b/src/lib/insights/recovery-empty.ts
index 40d48065d..c2c8dbf4b 100644
--- a/src/lib/insights/recovery-empty.ts
+++ b/src/lib/insights/recovery-empty.ts
@@ -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",
diff --git a/src/lib/integrations/connection-health-load.ts b/src/lib/integrations/connection-health-load.ts
index 6b32e9d9a..de199441e 100644
--- a/src/lib/integrations/connection-health-load.ts
+++ b/src/lib/integrations/connection-health-load.ts
@@ -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,
diff --git a/src/lib/integrations/connection-health.ts b/src/lib/integrations/connection-health.ts
index b69de63a3..e3d01ab02 100644
--- a/src/lib/integrations/connection-health.ts
+++ b/src/lib/integrations/connection-health.ts
@@ -44,6 +44,7 @@ export const CONNECTION_KEYS: readonly ConnectionKey[] = [
"strava",
"nightscout",
"dawarich",
+ "garmin",
"apple-health",
];
diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts
index 6cd5d56b1..13275efaa 100644
--- a/src/lib/integrations/metric-freshness.ts
+++ b/src/lib/integrations/metric-freshness.ts
@@ -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",
};
/**
diff --git a/src/lib/integrations/sources-in-use.ts b/src/lib/integrations/sources-in-use.ts
index 315065b37..7631416ed 100644
--- a/src/lib/integrations/sources-in-use.ts
+++ b/src/lib/integrations/sources-in-use.ts
@@ -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));
}
diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts
index 5e507d7e5..99d21b35f 100644
--- a/src/lib/integrations/status.ts
+++ b/src/lib/integrations/status.ts
@@ -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 = {
"google-health": "Google Health",
strava: "Strava",
dawarich: "Dawarich",
+ garmin: "Garmin",
};
const FAILURE_KIND_COPY: Record<
diff --git a/src/lib/integrations/sync-verdict.ts b/src/lib/integrations/sync-verdict.ts
index 8a0863c19..0f66eb9a6 100644
--- a/src/lib/integrations/sync-verdict.ts
+++ b/src/lib/integrations/sync-verdict.ts
@@ -126,6 +126,7 @@ export const INTEGRATION_CADENCE: Record = {
"google-health": POLLED_CADENCE,
strava: POLLED_CADENCE,
dawarich: NIGHTLY_CADENCE,
+ garmin: POLLED_CADENCE,
};
export interface SyncVerdictInput {
diff --git a/src/lib/integrations/watchdog-classify.ts b/src/lib/integrations/watchdog-classify.ts
index a28670ab4..44228adfd 100644
--- a/src/lib/integrations/watchdog-classify.ts
+++ b/src/lib/integrations/watchdog-classify.ts
@@ -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 = {
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 },
};
diff --git a/src/lib/integrations/watchdog.ts b/src/lib/integrations/watchdog.ts
index ffe4b5701..99216a73e 100644
--- a/src/lib/integrations/watchdog.ts
+++ b/src/lib/integrations/watchdog.ts
@@ -76,6 +76,7 @@ const LABELS: Record = {
strava: "Strava",
nightscout: "Nightscout",
dawarich: "Dawarich",
+ garmin: "Garmin",
"apple-health": "Apple Health",
};
@@ -145,6 +146,7 @@ async function listWatchedUserIds(): Promise {
{ stravaAccessTokenEncrypted: { not: null } },
{ nightscoutUrlEncrypted: { not: null } },
{ dawarichUrlEncrypted: { not: null } },
+ { garminApiKeyEncrypted: { not: null } },
{ healthKitLastSyncedAt: { not: null } },
],
},
diff --git a/src/lib/jobs/__tests__/integration-backfill-admission.test.ts b/src/lib/jobs/__tests__/integration-backfill-admission.test.ts
index 32126000b..1afac2367 100644
--- a/src/lib/jobs/__tests__/integration-backfill-admission.test.ts
+++ b/src/lib/jobs/__tests__/integration-backfill-admission.test.ts
@@ -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()),
+ 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();
diff --git a/src/lib/jobs/garmin-backfill-queue.ts b/src/lib/jobs/garmin-backfill-queue.ts
new file mode 100644
index 000000000..ea882f0ed
--- /dev/null
+++ b/src/lib/jobs/garmin-backfill-queue.ts
@@ -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;
diff --git a/src/lib/jobs/garmin-backfill.ts b/src/lib/jobs/garmin-backfill.ts
new file mode 100644
index 000000000..10390f6bf
--- /dev/null
+++ b/src/lib/jobs/garmin-backfill.ts
@@ -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,
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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),
+ };
+ }
+}
diff --git a/src/lib/jobs/integration-backfill-admission.ts b/src/lib/jobs/integration-backfill-admission.ts
index a2f1b05a6..daa5238ee 100644
--- a/src/lib/jobs/integration-backfill-admission.ts
+++ b/src/lib/jobs/integration-backfill-admission.ts
@@ -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 =
diff --git a/src/lib/jobs/integration-backfill-drain.ts b/src/lib/jobs/integration-backfill-drain.ts
index 033002cb6..ece706801 100644
--- a/src/lib/jobs/integration-backfill-drain.ts
+++ b/src/lib/jobs/integration-backfill-drain.ts
@@ -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;
+ }
}
}
diff --git a/src/lib/jobs/integration-poll-queues.ts b/src/lib/jobs/integration-poll-queues.ts
index 415921c0e..83dd4cd1d 100644
--- a/src/lib/jobs/integration-poll-queues.ts
+++ b/src/lib/jobs/integration-poll-queues.ts
@@ -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 * * * *";
diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts
index 297405af8..94b0107d0 100644
--- a/src/lib/jobs/queue-runtime.ts
+++ b/src/lib/jobs/queue-runtime.ts
@@ -166,6 +166,8 @@ export const QUEUE_RUNTIME: Readonly> = {
"strava-sync": cohortSync("Strava"),
"strava-backfill": forwarder,
"oura-backfill": forwarder,
+ "garmin-sync": cohortSync("Garmin"),
+ "garmin-backfill": forwarder,
// ── Status and insights ────────────────────────────────────────────
"insights-general-status": {
diff --git a/src/lib/jobs/registry/integration-sync.ts b/src/lib/jobs/registry/integration-sync.ts
index d5ec529a3..a7d63031f 100644
--- a/src/lib/jobs/registry/integration-sync.ts
+++ b/src/lib/jobs/registry/integration-sync.ts
@@ -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")
diff --git a/src/lib/jobs/reminder/garmin-sync.ts b/src/lib/jobs/reminder/garmin-sync.ts
new file mode 100644
index 000000000..e35e740e7
--- /dev/null
+++ b/src/lib/jobs/reminder/garmin-sync.ts
@@ -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,
+});
diff --git a/src/lib/jobs/reminder/registrar-shared.ts b/src/lib/jobs/reminder/registrar-shared.ts
index 1b4f7cfce..02a9e83d5 100644
--- a/src/lib/jobs/reminder/registrar-shared.ts
+++ b/src/lib/jobs/reminder/registrar-shared.ts
@@ -309,6 +309,7 @@ export const SCHEDULED_AND_SENT_QUEUES: ReadonlySet = new Set([
"environment-aq-history",
"environment-fetch",
"free-text-encryption-backfill",
+ "garmin-sync",
"geolite2-fetch",
"insight-pregenerate",
"intake-slot-dedup",
diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts
index 681cc2250..b94351e76 100644
--- a/src/lib/jobs/subsystem-surface.ts
+++ b/src/lib/jobs/subsystem-surface.ts
@@ -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
diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts
index 5912b81bf..d4f1fc11a 100644
--- a/src/lib/logging/__tests__/redact.test.ts
+++ b/src/lib/logging/__tests__/redact.test.ts
@@ -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]",
diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts
index ee83cd33d..4dcef81d2 100644
--- a/src/lib/logging/redact.ts
+++ b/src/lib/logging/redact.ts
@@ -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 `.
+ .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
diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts
index 10273c96e..861e4875f 100644
--- a/src/lib/mcp/__tests__/rich-reads.test.ts
+++ b/src/lib/mcp/__tests__/rich-reads.test.ts
@@ -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(
diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts
index 721798b34..a0c8b4b5c 100644
--- a/src/lib/mcp/rich-reads.ts
+++ b/src/lib/mcp/rich-reads.ts
@@ -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",
diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts
index b1ffdc978..4107d841b 100644
--- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts
+++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts
@@ -27,6 +27,12 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([
"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).
diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts
index 0620110b2..437442d26 100644
--- a/src/lib/measurements/categories.ts
+++ b/src/lib/measurements/categories.ts
@@ -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"],
]);
/**
diff --git a/src/lib/measurements/sources.ts b/src/lib/measurements/sources.ts
index 4df95cb74..c7f0b87bf 100644
--- a/src/lib/measurements/sources.ts
+++ b/src/lib/measurements/sources.ts
@@ -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;
diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts
index 12e3cf186..b18aa4084 100644
--- a/src/lib/measurements/type-label-keys.ts
+++ b/src/lib/measurements/type-label-keys.ts
@@ -113,6 +113,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = {
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",
diff --git a/src/lib/measurements/unit-label.ts b/src/lib/measurements/unit-label.ts
index f1f6fcfa2..1c256560e 100644
--- a/src/lib/measurements/unit-label.ts
+++ b/src/lib/measurements/unit-label.ts
@@ -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 = 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",
diff --git a/src/lib/measurements/unit-map.ts b/src/lib/measurements/unit-map.ts
index 943a0fade..f1ecfa3f1 100644
--- a/src/lib/measurements/unit-map.ts
+++ b/src/lib/measurements/unit-map.ts
@@ -170,6 +170,11 @@ const unitMap: Record = {
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 {
diff --git a/src/lib/measurements/value-ranges.ts b/src/lib/measurements/value-ranges.ts
index e37d1eba7..1bedd4b78 100644
--- a/src/lib/measurements/value-ranges.ts
+++ b/src/lib/measurements/value-ranges.ts
@@ -235,6 +235,13 @@ export const VALUE_RANGES: Record = {
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 },
diff --git a/src/lib/modules/measurement-scope.ts b/src/lib/modules/measurement-scope.ts
index 7975be709..eef50035c 100644
--- a/src/lib/modules/measurement-scope.ts
+++ b/src/lib/modules/measurement-scope.ts
@@ -99,7 +99,11 @@ const METRIC_STATUS_MODULE_OWNERS: Partial> =
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
diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts
index c0a7a2563..3b2ceb3f2 100644
--- a/src/lib/openapi/error-codes.ts
+++ b/src/lib/openapi/error-codes.ts
@@ -231,6 +231,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> =
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",
diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts
index a50b645d7..feee04143 100644
--- a/src/lib/openapi/routes/integrations.ts
+++ b/src/lib/openapi/routes/integrations.ts
@@ -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 = {
},
},
},
+ "/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"],
diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts
index f2ff28f9a..c8bce2edf 100644
--- a/src/lib/personal-records/pr-direction.ts
+++ b/src/lib/personal-records/pr-direction.ts
@@ -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
diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts
index 5355e3b79..b2e716604 100644
--- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts
+++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts
@@ -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", () => {
diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts
index dfd7b9bb7..c7bc9bca9 100644
--- a/src/lib/report-selection/catalogue.ts
+++ b/src/lib/report-selection/catalogue.ts
@@ -116,6 +116,7 @@ export const MEASUREMENT_LEAF_GROUP: Record = {
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 = {
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> = {
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",
diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts
index cd1eabcac..4725626a2 100644
--- a/src/lib/signals/registry.ts
+++ b/src/lib/signals/registry.ts
@@ -641,6 +641,67 @@ export const SIGNALS: Record = {
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",
diff --git a/src/lib/validations/__tests__/source-priority.test.ts b/src/lib/validations/__tests__/source-priority.test.ts
index aa3b205e4..f7b8f7c2b 100644
--- a/src/lib/validations/__tests__/source-priority.test.ts
+++ b/src/lib/validations/__tests__/source-priority.test.ts
@@ -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([...HAND_ENTERED, "HEALTH_CONNECT"]);
+ const added = new Set([
+ ...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([
diff --git a/src/lib/validations/garmin.ts b/src/lib/validations/garmin.ts
new file mode 100644
index 000000000..4d92af8b3
--- /dev/null
+++ b/src/lib/validations/garmin.ts
@@ -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;
diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts
index 6177c7e42..704fe1a7b 100644
--- a/src/lib/validations/measurement.ts
+++ b/src/lib/validations/measurement.ts
@@ -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",
]);
/**
diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts
index da0bb5a2f..06cd16a5d 100644
--- a/src/lib/validations/source-priority.ts
+++ b/src/lib/validations/source-priority.ts
@@ -261,12 +261,20 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
// 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 = {
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
+ "GARMIN",
"OURA",
"POLAR",
"MANUAL",
@@ -294,6 +303,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
+ "GARMIN",
"OURA",
"POLAR",
"MANUAL",
@@ -306,6 +316,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
+ "GARMIN",
"MANUAL",
"EXTERNAL",
"MCP",
@@ -323,6 +334,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
+ "GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -341,6 +353,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
+ "GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -352,6 +365,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
+ "GARMIN",
"MANUAL",
"EXTERNAL",
"MCP",
@@ -389,6 +403,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
"WITHINGS",
"APPLE_HEALTH",
"HEALTH_CONNECT",
+ "GARMIN",
"MANUAL",
"EXTERNAL",
"MCP",
@@ -415,6 +430,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
+ "GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -446,6 +462,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
+ "GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
@@ -475,6 +492,7 @@ export const DEFAULT_SOURCE_PRIORITY: Required = {
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
+ "GARMIN",
"OURA",
"POLAR",
"APPLE_HEALTH",
diff --git a/tests/integration/__golden__/backup-parity/refusals.json b/tests/integration/__golden__/backup-parity/refusals.json
index 48403a118..90dcf66df 100644
--- a/tests/integration/__golden__/backup-parity/refusals.json
+++ b/tests/integration/__golden__/backup-parity/refusals.json
@@ -68,7 +68,7 @@
"backupId": "",
"ownerId": "round-trip-owner",
"reason": "schema_invalid",
- "message": "measurements.0.type: Invalid option: expected one of \"WEIGHT\"|\"BLOOD_PRESSURE_SYS\"|\"BLOOD_PRESSURE_DIA\"|\"PULSE\"|\"BODY_FAT\"|\"SLEEP_DURATION\"|\"ACTIVITY_STEPS\"|\"BLOOD_GLUCOSE\"|\"TOTAL_BODY_WATER\"|\"BONE_MASS\"|\"OXYGEN_SATURATION\"|\"HEART_RATE_VARIABILITY\"|\"RESTING_HEART_RATE\"|\"ACTIVE_ENERGY_BURNED\"|\"FLIGHTS_CLIMBED\"|\"WALKING_RUNNING_DISTANCE\"|\"VO2_MAX\"|\"BODY_TEMPERATURE\"|\"FAT_FREE_MASS\"|\"FAT_MASS\"|\"MUSCLE_MASS\"|\"SKIN_TEMPERATURE\"|\"PULSE_WAVE_VELOCITY\"|\"VASCULAR_AGE\"|\"VISCERAL_FAT\"|\"AUDIO_EXPOSURE_ENV\"|\"AUDIO_EXPOSURE_HEADPHONE\"|\"TIME_IN_DAYLIGHT\"|\"WALKING_STEADINESS\"|\"AUDIO_EXPOSURE_EVENT\"|\"RESPIRATORY_RATE\"|\"BODY_MASS_INDEX\"|\"LEAN_BODY_MASS\"|\"WALKING_HEART_RATE_AVERAGE\"|\"WALKING_ASYMMETRY\"|\"WALKING_DOUBLE_SUPPORT\"|\"WALKING_STEP_LENGTH\"|\"WALKING_SPEED\"|\"CARDIO_RECOVERY\"|\"WRIST_TEMPERATURE\"|\"FALL_COUNT\"|\"SIX_MINUTE_WALK_DISTANCE\"|\"STAIR_ASCENT_SPEED\"|\"STAIR_DESCENT_SPEED\"|\"BREATHING_DISTURBANCES\"|\"IRREGULAR_RHYTHM_NOTIFICATION\"|\"HIGH_HEART_RATE_EVENT\"|\"LOW_HEART_RATE_EVENT\"|\"WALKING_STEADINESS_EVENT\"|\"BREATHING_DISTURBANCE_EVENT\"|\"RECOVERY_SCORE\"|\"STRESS_SCORE\"|\"STRAIN_SCORE\"|\"HRV_RMSSD\"|\"DAY_STRAIN\"|\"WORKOUT_STRAIN\"|\"SLEEP_PERFORMANCE\"|\"SLEEP_EFFICIENCY\"|\"SLEEP_CONSISTENCY\"|\"SLEEP_NEED\"|\"ENERGY_EXPENDITURE_KJ\"|\"AVERAGE_HEART_RATE\"|\"MAX_HEART_RATE\"|\"SLEEP_DISTURBANCE_COUNT\"|\"ANS_CHARGE\"|\"CARDIO_LOAD\"|\"SLEEP_SCORE\"|\"BODY_TEMPERATURE_DEVIATION\"|\"RESILIENCE\"|\"PHQ9_SCORE\"|\"GAD7_SCORE\"|\"GRIP_STRENGTH\"|\"PAIN_NRS\"|\"WAIST_CIRCUMFERENCE\"|\"WAIST_TO_HEIGHT\"|\"WHO5_SCORE\"|\"SCI_SCORE\"|\"EXERCISE_MINUTES\"|\"STAND_MINUTES\"|\"STAND_HOURS\"|\"MINDFUL_MINUTES\"|\"STATE_OF_MIND_VALENCE\"|\"INDOOR_CO2\"|\"INDOOR_TEMPERATURE\"|\"INDOOR_HUMIDITY\"|\"AFIB_BURDEN\""
+ "message": "measurements.0.type: Invalid option: expected one of \"WEIGHT\"|\"BLOOD_PRESSURE_SYS\"|\"BLOOD_PRESSURE_DIA\"|\"PULSE\"|\"BODY_FAT\"|\"SLEEP_DURATION\"|\"ACTIVITY_STEPS\"|\"BLOOD_GLUCOSE\"|\"TOTAL_BODY_WATER\"|\"BONE_MASS\"|\"OXYGEN_SATURATION\"|\"HEART_RATE_VARIABILITY\"|\"RESTING_HEART_RATE\"|\"ACTIVE_ENERGY_BURNED\"|\"FLIGHTS_CLIMBED\"|\"WALKING_RUNNING_DISTANCE\"|\"VO2_MAX\"|\"BODY_TEMPERATURE\"|\"FAT_FREE_MASS\"|\"FAT_MASS\"|\"MUSCLE_MASS\"|\"SKIN_TEMPERATURE\"|\"PULSE_WAVE_VELOCITY\"|\"VASCULAR_AGE\"|\"VISCERAL_FAT\"|\"AUDIO_EXPOSURE_ENV\"|\"AUDIO_EXPOSURE_HEADPHONE\"|\"TIME_IN_DAYLIGHT\"|\"WALKING_STEADINESS\"|\"AUDIO_EXPOSURE_EVENT\"|\"RESPIRATORY_RATE\"|\"BODY_MASS_INDEX\"|\"LEAN_BODY_MASS\"|\"WALKING_HEART_RATE_AVERAGE\"|\"WALKING_ASYMMETRY\"|\"WALKING_DOUBLE_SUPPORT\"|\"WALKING_STEP_LENGTH\"|\"WALKING_SPEED\"|\"CARDIO_RECOVERY\"|\"WRIST_TEMPERATURE\"|\"FALL_COUNT\"|\"SIX_MINUTE_WALK_DISTANCE\"|\"STAIR_ASCENT_SPEED\"|\"STAIR_DESCENT_SPEED\"|\"BREATHING_DISTURBANCES\"|\"IRREGULAR_RHYTHM_NOTIFICATION\"|\"HIGH_HEART_RATE_EVENT\"|\"LOW_HEART_RATE_EVENT\"|\"WALKING_STEADINESS_EVENT\"|\"BREATHING_DISTURBANCE_EVENT\"|\"RECOVERY_SCORE\"|\"STRESS_SCORE\"|\"STRAIN_SCORE\"|\"HRV_RMSSD\"|\"DAY_STRAIN\"|\"WORKOUT_STRAIN\"|\"SLEEP_PERFORMANCE\"|\"SLEEP_EFFICIENCY\"|\"SLEEP_CONSISTENCY\"|\"SLEEP_NEED\"|\"ENERGY_EXPENDITURE_KJ\"|\"AVERAGE_HEART_RATE\"|\"MAX_HEART_RATE\"|\"SLEEP_DISTURBANCE_COUNT\"|\"ANS_CHARGE\"|\"CARDIO_LOAD\"|\"SLEEP_SCORE\"|\"BODY_TEMPERATURE_DEVIATION\"|\"RESILIENCE\"|\"PHQ9_SCORE\"|\"GAD7_SCORE\"|\"GRIP_STRENGTH\"|\"PAIN_NRS\"|\"WAIST_CIRCUMFERENCE\"|\"WAIST_TO_HEIGHT\"|\"WHO5_SCORE\"|\"SCI_SCORE\"|\"EXERCISE_MINUTES\"|\"STAND_MINUTES\"|\"STAND_HOURS\"|\"MINDFUL_MINUTES\"|\"STATE_OF_MIND_VALENCE\"|\"INDOOR_CO2\"|\"INDOOR_TEMPERATURE\"|\"INDOOR_HUMIDITY\"|\"AFIB_BURDEN\"|\"BODY_BATTERY\"|\"STRESS_LEVEL\"|\"TRAINING_READINESS\"|\"ACUTE_TRAINING_LOAD\""
}
}
],
diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json
index 2f2a184eb..1c9a13ab5 100644
--- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json
+++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json
@@ -2742,6 +2742,12 @@
"ouraBackfillProgress": null,
"ouraBackfillCompletedAt": null,
"ouraUserId": null,
+ "garminApiKeyEncrypted": null,
+ "garminKeyExpiresAt": null,
+ "garminConnectedAt": null,
+ "garminRegion": null,
+ "garminBackfillProgress": null,
+ "garminBackfillCompletedAt": null,
"clientAppSharedBy": null,
"stravaClientIdEncrypted": null,
"stravaClientSecretEncrypted": null,
diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json
index 4521ac52d..fb90a5907 100644
--- a/tests/integration/__golden__/backup-parity/restore-portable.json
+++ b/tests/integration/__golden__/backup-parity/restore-portable.json
@@ -2236,6 +2236,12 @@
"ouraBackfillProgress": null,
"ouraBackfillCompletedAt": null,
"ouraUserId": null,
+ "garminApiKeyEncrypted": null,
+ "garminKeyExpiresAt": null,
+ "garminConnectedAt": null,
+ "garminRegion": null,
+ "garminBackfillProgress": null,
+ "garminBackfillCompletedAt": null,
"clientAppSharedBy": null,
"stravaClientIdEncrypted": null,
"stravaClientSecretEncrypted": null,
diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json
index a365d309e..e4a4164b6 100644
--- a/tests/integration/__golden__/backup-parity/restore-skips.json
+++ b/tests/integration/__golden__/backup-parity/restore-skips.json
@@ -879,6 +879,12 @@
"ouraBackfillProgress": null,
"ouraBackfillCompletedAt": null,
"ouraUserId": null,
+ "garminApiKeyEncrypted": null,
+ "garminKeyExpiresAt": null,
+ "garminConnectedAt": null,
+ "garminRegion": null,
+ "garminBackfillProgress": null,
+ "garminBackfillCompletedAt": null,
"clientAppSharedBy": null,
"stravaClientIdEncrypted": null,
"stravaClientSecretEncrypted": null,
diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json
index 0a98fd8c8..b9d555f23 100644
--- a/tests/integration/__golden__/backup-parity/restore-version-1.json
+++ b/tests/integration/__golden__/backup-parity/restore-version-1.json
@@ -1017,6 +1017,12 @@
"ouraBackfillProgress": null,
"ouraBackfillCompletedAt": null,
"ouraUserId": null,
+ "garminApiKeyEncrypted": null,
+ "garminKeyExpiresAt": null,
+ "garminConnectedAt": null,
+ "garminRegion": null,
+ "garminBackfillProgress": null,
+ "garminBackfillCompletedAt": null,
"clientAppSharedBy": null,
"stravaClientIdEncrypted": null,
"stravaClientSecretEncrypted": null,
diff --git a/tests/integration/__golden__/backup-parity/schema-issues.json b/tests/integration/__golden__/backup-parity/schema-issues.json
index d693f8905..3d4cc76b2 100644
--- a/tests/integration/__golden__/backup-parity/schema-issues.json
+++ b/tests/integration/__golden__/backup-parity/schema-issues.json
@@ -381,14 +381,15 @@
"GOOGLE_HEALTH",
"STRAVA",
"EXTERNAL",
- "HEALTH_CONNECT"
+ "HEALTH_CONNECT",
+ "GARMIN"
],
"path": [
"ecgRecordings",
0,
"source"
],
- "message": "Invalid option: expected one of \"MANUAL\"|\"WITHINGS\"|\"IMPORT\"|\"APPLE_HEALTH\"|\"COMPUTED\"|\"WHOOP\"|\"FITBIT\"|\"NIGHTSCOUT\"|\"POLAR\"|\"OURA\"|\"TELEGRAM\"|\"MCP\"|\"GOOGLE_HEALTH\"|\"STRAVA\"|\"EXTERNAL\"|\"HEALTH_CONNECT\""
+ "message": "Invalid option: expected one of \"MANUAL\"|\"WITHINGS\"|\"IMPORT\"|\"APPLE_HEALTH\"|\"COMPUTED\"|\"WHOOP\"|\"FITBIT\"|\"NIGHTSCOUT\"|\"POLAR\"|\"OURA\"|\"TELEGRAM\"|\"MCP\"|\"GOOGLE_HEALTH\"|\"STRAVA\"|\"EXTERNAL\"|\"HEALTH_CONNECT\"|\"GARMIN\""
},
{
"expected": "string",
diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json
index e02399f3a..f61b7b2c9 100644
--- a/tests/integration/__golden__/backup-parity/seeded-account.json
+++ b/tests/integration/__golden__/backup-parity/seeded-account.json
@@ -2353,6 +2353,14 @@
},
"ouraBackfillCompletedAt": "2026-06-30T12:34:56.789Z",
"ouraUserId": "rt-User-ouraUserId",
+ "garminApiKeyEncrypted": "",
+ "garminKeyExpiresAt": "2026-06-30T12:34:56.789Z",
+ "garminConnectedAt": "2026-06-30T12:34:56.789Z",
+ "garminRegion": "rt-User-garminRegion",
+ "garminBackfillProgress": {
+ "roundTrip": "rt-User-garminBackfillProgress"
+ },
+ "garminBackfillCompletedAt": "2026-06-30T12:34:56.789Z",
"clientAppSharedBy": {
"roundTrip": "rt-User-clientAppSharedBy"
},
diff --git a/tests/integration/__golden__/queue-registration.json b/tests/integration/__golden__/queue-registration.json
index 00d8de41c..0560c4642 100644
--- a/tests/integration/__golden__/queue-registration.json
+++ b/tests/integration/__golden__/queue-registration.json
@@ -1,5 +1,5 @@
{
- "registeredQueueCount": 127,
+ "registeredQueueCount": 129,
"registeredQueues": [
"account-deletion-purge",
"achievement-unlock-sweep",
@@ -48,6 +48,8 @@
"fitbit-sleep-repair",
"fitbit-sync",
"free-text-encryption-backfill",
+ "garmin-backfill",
+ "garmin-sync",
"geo-backfill",
"geolite2-fetch",
"google-health-backfill",
@@ -835,6 +837,36 @@
"retry_delay_max": null,
"retry_limit": 2
},
+ {
+ "dead_letter": null,
+ "deletion_seconds": 604800,
+ "expire_seconds": 900,
+ "heartbeat_seconds": null,
+ "name": "garmin-backfill",
+ "notify": false,
+ "partition": false,
+ "policy": "exclusive",
+ "retention_seconds": 1209600,
+ "retry_backoff": false,
+ "retry_delay": 0,
+ "retry_delay_max": null,
+ "retry_limit": 2
+ },
+ {
+ "dead_letter": null,
+ "deletion_seconds": 604800,
+ "expire_seconds": 900,
+ "heartbeat_seconds": null,
+ "name": "garmin-sync",
+ "notify": false,
+ "partition": false,
+ "policy": "standard",
+ "retention_seconds": 1209600,
+ "retry_backoff": false,
+ "retry_delay": 0,
+ "retry_delay_max": null,
+ "retry_limit": 2
+ },
{
"dead_letter": null,
"deletion_seconds": 604800,
@@ -2371,6 +2403,17 @@
},
"timezone": "Europe/Berlin"
},
+ {
+ "cron": "23 * * * *",
+ "data": {},
+ "key": "",
+ "kind": "cron",
+ "name": "garmin-sync",
+ "options": {
+ "tz": "Europe/Berlin"
+ },
+ "timezone": "Europe/Berlin"
+ },
{
"cron": "40 * * * *",
"data": {},
@@ -3311,6 +3354,18 @@
"localConcurrency": 1
}
},
+ {
+ "queue": "garmin-backfill",
+ "options": {
+ "localConcurrency": 1
+ }
+ },
+ {
+ "queue": "garmin-sync",
+ "options": {
+ "localConcurrency": 1
+ }
+ },
{
"queue": "geo-backfill",
"options": {
diff --git a/tests/integration/measurement-source-write-smoke.test.ts b/tests/integration/measurement-source-write-smoke.test.ts
index d2aa00ea3..6207089a0 100644
--- a/tests/integration/measurement-source-write-smoke.test.ts
+++ b/tests/integration/measurement-source-write-smoke.test.ts
@@ -19,6 +19,7 @@ import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import { upsertNightscoutEntries } from "@/lib/nightscout/sync";
import { upsertOuraMeasurements } from "@/lib/oura/sync";
+import { upsertGarminMeasurements } from "@/lib/garmin/sync";
import { upsertPolarMeasurements } from "@/lib/polar/sync";
import { syncUserActivity } from "@/lib/withings/sync-activity";
import { upsertWhoopMeasurements } from "@/lib/whoop/sync-core";
@@ -241,6 +242,21 @@ const SOURCE_CASES = {
]);
},
},
+ GARMIN: {
+ expectedType: "BODY_BATTERY",
+ expectedExternalId: "daily:2026-07-20:body_battery",
+ write: async () => {
+ await upsertGarminMeasurements(USER_ID, [
+ {
+ type: "BODY_BATTERY",
+ value: 81,
+ unit: "score",
+ measuredAt: new Date("2026-07-20T10:00:00.000Z"),
+ externalId: "daily:2026-07-20:body_battery",
+ },
+ ]);
+ },
+ },
TELEGRAM: {
expectedType: "WEIGHT",
expectedExternalId: "telegram:source-smoke",
@@ -423,9 +439,9 @@ describe("MeasurementSource write paths against real Postgres", () => {
});
}
- expect(sourceCases).toHaveLength(16);
+ expect(sourceCases).toHaveLength(17);
expect(await prisma.measurement.count({ where: { userId: USER_ID } })).toBe(
- 16,
+ 17,
);
});
});