diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c915d4aa..b5e7c15e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,24 @@ Each injected dose now carries a chip with the site next to its status, in the table and on the mobile cards, so the rotation can be read back from the history. Doses without a site look as before. +- **Garmin, directly.** Garmin Connect now offers a Personal API, and + HealthLog reads it: create a key in Garmin Connect (Settings, Personal + API), paste it into the new Garmin card, and steps, distance, floors, + active calories, resting heart rate, sleep stages and sleep score, + overnight SpO₂, breathing rate and HRV, weigh-ins with body composition, + activities and logged period days sync every hour, with the whole + history filled in behind. Body Battery and Garmin's all-day stress level + arrive as types of their own (`BODY_BATTERY`, `STRESS_LEVEL`), never mixed + into the computed scores. Garmin keys expire after 30 to 365 days; the + card warns a week ahead and asks for a new key when Garmin stops + accepting the old one. Accounts on Garmin's China service work too; the + key goes only to the service you pick. The key is stored encrypted and + only ever sent to Garmin. (#1333) +- **Training Readiness and acute training load.** The Personal API does + not carry them, so they are new types (`TRAINING_READINESS`, + `ACUTE_TRAINING_LOAD`) a Home Assistant bridge can push through an ingest + token, and they chart on the recovery page next to Body Battery. The Home + Assistant guide has the automation. (#1333) ### Changed @@ -96,6 +114,8 @@ - @Oguz3003 for staying with the Raspberry Pi 4 until it reads PDFs (#1311). - @mills1975 for asking how blood pressure differs over the day (#1331). +- @kruemelro for asking for a direct Garmin connection on Garmin's new + Personal API (#1333). ## [1.46.6] — 2026-11-02 diff --git a/docs/README.md b/docs/README.md index 225e5a1f8..b500f6784 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,7 +50,7 @@ notes, and machine-readable specs that ship alongside the source: . - [`integrations/`](./integrations/) — per-integration reference: Withings, WHOOP, Fitbit, Google Health, Strava, Nightscout, Dawarich and - the Garmin route; the Apple Health, Health Connect, document and data + Garmin; the Apple Health, Health Connect, document and data imports; AI providers; and Home Assistant with its blueprint. Oura and Polar are covered on the docs site only. - [`adr/`](./adr/) — architecture decision records. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3ebcd3ebb..5d784eddf 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,104 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN + bodyBattery: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + stressLevel: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + trainingReadiness: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + acuteTrainingLoad: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN metricPriority: type: object properties: steps: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9070,8 +9197,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN activeEnergy: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9092,8 +9220,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN walkingRunningDistance: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9114,8 +9243,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN flightsClimbed: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9136,8 +9266,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN sleep: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9158,8 +9289,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN weight: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9180,8 +9312,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN bloodPressure: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9202,8 +9335,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN pulse: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9224,8 +9358,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN bodyFat: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9246,8 +9381,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN bodyTemperature: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9268,8 +9404,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN spo2: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9290,8 +9427,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN hrv: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9312,8 +9450,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN restingHeartRate: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9334,8 +9473,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN vo2Max: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9356,8 +9496,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN skinTemperature: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9378,8 +9519,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN respiratoryRate: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9400,8 +9542,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN recovery: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9422,8 +9565,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN stress: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9444,8 +9588,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN sleepDebt: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9466,8 +9611,9 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN hrvRmssd: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -9488,6 +9634,99 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN + bodyBattery: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + stressLevel: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + trainingReadiness: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + acuteTrainingLoad: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN deviceTypePriority: type: object properties: @@ -13562,6 +13801,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 +21708,197 @@ 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, at the one Garmin service `region` + names; the key is never sent to the other. 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/key-expiry: + patch: + tags: + - Integrations + summary: Correct when the stored Garmin key expires + description: Sets the stored key's expiry from the validity picked when it was created, counted from `createdOn` or else + from the day it was connected, without pasting the key again. Nothing is sent to Garmin. Rate-limited 20 + requests / 60 s per user. Auth via cookie or Bearer. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GarminKeyExpiryRequest" + responses: + "200": + description: The expiry was stored. + content: + application/json: + schema: + $ref: "#/components/schemas/GarminKeyExpiryEnvelope" + "401": *a1 + "403": *a9 + "404": + description: No Garmin key is stored. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /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 +28437,7 @@ components: type: number const: 2 leaves: - maxItems: 106 + maxItems: 110 type: array items: type: string @@ -28065,7 +28499,7 @@ components: type: number const: 2 leaves: - maxItems: 106 + maxItems: 110 type: array items: type: string @@ -28940,6 +29374,10 @@ components: - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY + - BODY_BATTERY + - STRESS_LEVEL + - TRAINING_READINESS + - ACUTE_TRAINING_LOAD value: type: number measuredAt: @@ -30669,6 +31107,10 @@ components: - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY + - BODY_BATTERY + - STRESS_LEVEL + - TRAINING_READINESS + - ACUTE_TRAINING_LOAD biomarkerId: type: string minLength: 1 @@ -31168,7 +31610,7 @@ components: type: number const: 2 leaves: - maxItems: 106 + maxItems: 110 type: array items: type: string @@ -32366,6 +32808,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 +37075,58 @@ 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+$ + region: + default: global + type: string + enum: + - global + - china + 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; `region`, the one + Garmin service the key is sent to (`global` by default, `china` for Garmin's service in China); 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. + GarminKeyExpiryRequest: + type: object + properties: + validityDays: + anyOf: + - anyOf: + - type: number + const: 30 + - type: number + const: 90 + - type: number + const: 180 + - type: number + const: 365 + - type: "null" + createdOn: + type: string + description: The validity picked when the key was created (30, 90, 180 or 365 days; null forgets the estimate) and, + optionally, the day it was created in Garmin Connect. Without `createdOn` the validity counts from the day the + key was connected. WhoopCredentialsRequest: type: object properties: @@ -36827,6 +37325,10 @@ components: - averageHeartRate - maxHeartRate - energyExpenditureKj + - bodyBattery + - stressLevel + - trainingReadiness + - acuteTrainingLoad - gripStrength - painNrs - waistCircumference @@ -37012,6 +37514,10 @@ components: - averageHeartRate - maxHeartRate - energyExpenditureKj + - bodyBattery + - stressLevel + - trainingReadiness + - acuteTrainingLoad - gripStrength - painNrs - waistCircumference @@ -38858,88 +39364,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 +42111,10 @@ components: - INDOOR_TEMPERATURE - INDOOR_HUMIDITY - AFIB_BURDEN + - BODY_BATTERY + - STRESS_LEVEL + - TRAINING_READINESS + - ACUTE_TRAINING_LOAD value: type: number valueMin: @@ -41640,6 +42150,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN notes: type: - string @@ -42367,6 +42878,10 @@ components: - INDOOR_TEMPERATURE - INDOOR_HUMIDITY - AFIB_BURDEN + - BODY_BATTERY + - STRESS_LEVEL + - TRAINING_READINESS + - ACUTE_TRAINING_LOAD - type: "null" biomarkerName: anyOf: @@ -43152,6 +43667,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN externalId: type: - string @@ -44119,6 +44635,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN externalId: type: - string @@ -44958,6 +45475,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 +46674,10 @@ components: - INDOOR_TEMPERATURE - INDOOR_HUMIDITY - AFIB_BURDEN + - BODY_BATTERY + - STRESS_LEVEL + - TRAINING_READINESS + - ACUTE_TRAINING_LOAD - type: "null" intervalDays: anyOf: @@ -46374,6 +46899,10 @@ components: - INDOOR_TEMPERATURE - INDOOR_HUMIDITY - AFIB_BURDEN + - BODY_BATTERY + - STRESS_LEVEL + - TRAINING_READINESS + - ACUTE_TRAINING_LOAD metricSlot: type: - string @@ -46414,6 +46943,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN externalId: type: - string @@ -46775,6 +47305,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN externalRecordingId: type: string minLength: 1 @@ -49348,6 +49879,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 +53210,10 @@ components: - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY + - BODY_BATTERY + - STRESS_LEVEL + - TRAINING_READINESS + - ACUTE_TRAINING_LOAD date: type: string format: date @@ -52714,6 +53250,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN picked: anyOf: - type: string @@ -52734,6 +53271,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN - type: "null" pickReason: anyOf: @@ -52771,6 +53309,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN rank: type: integer minimum: -9007199254740991 @@ -52950,6 +53489,10 @@ components: - INDOOR_CO2 - INDOOR_TEMPERATURE - INDOOR_HUMIDITY + - BODY_BATTERY + - STRESS_LEVEL + - TRAINING_READINESS + - ACUTE_TRAINING_LOAD from: type: string format: date @@ -52986,6 +53529,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN unit: type: - string @@ -53014,6 +53558,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN days: type: integer minimum: -9007199254740991 @@ -53042,6 +53587,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN - type: "null" b: anyOf: @@ -53063,6 +53609,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN - type: "null" days: type: array @@ -53101,6 +53648,7 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN - type: "null" required: - date @@ -58678,7 +59226,7 @@ components: type: object properties: steps: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58699,8 +59247,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN activeEnergy: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58721,8 +59270,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN walkingRunningDistance: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58743,8 +59293,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN flightsClimbed: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58765,8 +59316,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN sleep: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58787,8 +59339,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN weight: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58809,8 +59362,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN bloodPressure: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58831,8 +59385,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN pulse: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58853,8 +59408,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN bodyFat: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58875,8 +59431,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN bodyTemperature: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58897,8 +59454,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN spo2: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58919,8 +59477,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN hrv: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58941,8 +59500,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN restingHeartRate: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58963,8 +59523,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN vo2Max: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -58985,8 +59546,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN skinTemperature: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59007,8 +59569,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN respiratoryRate: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59029,8 +59592,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN recovery: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59051,8 +59615,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN stress: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59073,8 +59638,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN sleepDebt: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59095,8 +59661,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN hrvRmssd: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59117,11 +59684,104 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN + bodyBattery: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + stressLevel: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + trainingReadiness: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + acuteTrainingLoad: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN metricPriority: type: object properties: steps: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59142,8 +59802,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN activeEnergy: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59164,8 +59825,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN walkingRunningDistance: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59186,8 +59848,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN flightsClimbed: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59208,8 +59871,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN sleep: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59230,8 +59894,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN weight: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59252,8 +59917,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN bloodPressure: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59274,8 +59940,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN pulse: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59296,8 +59963,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN bodyFat: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59318,8 +59986,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN bodyTemperature: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59340,8 +60009,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN spo2: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59362,8 +60032,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN hrv: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59384,8 +60055,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN restingHeartRate: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59406,8 +60078,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN vo2Max: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59428,8 +60101,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN skinTemperature: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59450,8 +60124,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN respiratoryRate: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59472,8 +60147,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN recovery: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59494,8 +60170,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN stress: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59516,8 +60193,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN sleepDebt: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59538,8 +60216,9 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN hrvRmssd: - maxItems: 16 + maxItems: 17 type: array items: type: string @@ -59560,6 +60239,99 @@ components: - STRAVA - EXTERNAL - HEALTH_CONNECT + - GARMIN + bodyBattery: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + stressLevel: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + trainingReadiness: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN + acuteTrainingLoad: + maxItems: 17 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + - GARMIN additionalProperties: false deviceTypePriority: type: object @@ -59611,6 +60383,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: @@ -68324,11 +69097,11 @@ components: - oura_no_resilience - type: "null" description: "Why the recovery page has nothing to chart, resolved on the server; null when any recovery type - (ANS_CHARGE, DAY_STRAIN, WORKOUT_STRAIN, CARDIO_LOAD, AVERAGE_HEART_RATE, MAX_HEART_RATE, - ENERGY_EXPENDITURE_KJ, RESILIENCE) has a reading. `oura_permission`: Oura is connected and its grant leaves - out the scope the resilience level needs, one reconnect fixes it. `oura_no_resilience`: Oura is connected - and no level has arrived yet. `general`: anyone else. Clients render the empty note from this value and - never work it out from the connection status." + (ANS_CHARGE, DAY_STRAIN, WORKOUT_STRAIN, CARDIO_LOAD, BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS, + ACUTE_TRAINING_LOAD, AVERAGE_HEART_RATE, MAX_HEART_RATE, ENERGY_EXPENDITURE_KJ, RESILIENCE) has a reading. + `oura_permission`: Oura is connected and its grant leaves out the scope the resilience level needs, one + reconnect fixes it. `oura_no_resilience`: Oura is connected and no level has arrived yet. `general`: anyone + else. Clients render the empty note from this value and never work it out from the connection status." required: - summaries - bmi @@ -83445,6 +84218,277 @@ 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 + unavailableResources: + type: array + items: + type: string + enum: + - daily + - activities + - sleep + - hrv + - weight + - cycle + description: Resources Garmin refuses for this account with a lasting 4xx (a menstrual-cycle summary for someone who + tracks no cycle, say). Each is skipped by the sync and counts as done for the backfill; none turns the + connection red. A new key clears the list. + 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. + GarminKeyExpiryEnvelope: + type: object + properties: + data: + type: object + properties: + keyExpiry: + $ref: "#/components/schemas/GarminKeyExpiry" + required: + - keyExpiry + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + 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 +85863,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 +85890,7 @@ components: - strava - nightscout - dawarich + - garmin description: Which provider this entry describes. state: type: string @@ -85050,6 +86095,43 @@ 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" + unavailableResources: + description: Garmin only. Null when no key is stored. + anyOf: + - type: array + items: + type: string + enum: + - daily + - activities + - sleep + - hrv + - weight + - cycle + description: Resources Garmin refuses for this account with a lasting 4xx (a menstrual-cycle summary for someone who + tracks no cycle, say). Each is skipped by the sync and counts as done for the backfill; none turns the + connection red. A new key clears the list. + - type: "null" retirement: description: Fitbit only. The end of the legacy Fitbit Web API. type: object @@ -86175,6 +87257,10 @@ components: - averageHeartRate - maxHeartRate - energyExpenditureKj + - bodyBattery + - stressLevel + - trainingReadiness + - acuteTrainingLoad - gripStrength - painNrs - waistCircumference @@ -86437,6 +87523,10 @@ components: - averageHeartRate - maxHeartRate - energyExpenditureKj + - bodyBattery + - stressLevel + - trainingReadiness + - acuteTrainingLoad - gripStrength - painNrs - waistCircumference diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md index 33f72b545..7a74f821e 100644 --- a/docs/integrations/apple-health.md +++ b/docs/integrations/apple-health.md @@ -314,7 +314,7 @@ id for debugging. ## Garmin owners -Garmin has no direct connector for a self-hosted instance. Garmin Connect writes -to Apple Health automatically, so the import path on this page is how Garmin data -reaches HealthLog on iOS. See [garmin.md](./garmin.md) for what comes through and -what Garmin withholds. +Garmin Connect writes to Apple Health automatically, so the import path on this +page brings Garmin data in on iOS. The direct [Garmin](./garmin.md) integration +reads more (Body Battery, stress, Garmin's sleep score) with a key from your +own Garmin account. diff --git a/docs/integrations/garmin.md b/docs/integrations/garmin.md index b1aa7117e..00def5e9a 100644 --- a/docs/integrations/garmin.md +++ b/docs/integrations/garmin.md @@ -1,52 +1,114 @@ -# Garmin (via Apple Health or Google Health Connect) +# Garmin -**There is no direct Garmin connector for a self-hosted HealthLog instance, and -there cannot be one.** Garmin's data-sharing program is business-partner-only: -it is approval-gated, requires a public callback endpoint, and is not the -"register your own app, paste a key" model the wearable integrations here rely -on. So instead of a broken half-integration, HealthLog reads Garmin data the -honest way — through the phone health platform your Garmin already feeds. +HealthLog reads Garmin Connect directly through Garmin's **Personal API**: you +create a key for your own account in Garmin Connect, paste it into HealthLog, +and the server pulls your data every hour. Nothing is pushed by Garmin, and +nothing is written back. -## The path that works +The Personal API is for your own data only. Each person on an instance +creates and pastes their own key; there is no shared app and no operator +setup. -``` -Garmin device → Garmin Connect → Apple Health (iOS) ─┐ - → Google Health Connect ├─→ HealthLog - (Android) ─┘ -``` +## Create a key -- **iOS:** Garmin Connect writes to Apple Health automatically. Import it with - the existing [Apple Health](./apple-health.md) path (the `export.zip` upload or - the native client's background sync). -- **Android:** since Garmin Connect 5.14.1 (July 2025) the app writes to **Google - Health Connect**. Connect HealthLog through the existing - [Google Health](./google-health.md) integration. +1. Open [Garmin Connect → Settings → Personal API](https://connect.garmin.com/app/settings/personalApiToken). +2. Create a key and choose how long it stays valid: 30, 90, 180 days or one + year. +3. Copy it right away. Garmin shows the key once. -No extra code, no Garmin developer account, no approval queue — if your Garmin -already syncs to your phone's health platform, HealthLog already reads it. +## Connect + +Open **Settings → Integrations → Garmin**, paste the key, and pick the same +validity you chose in Garmin Connect. HealthLog checks the key with Garmin +before storing anything, so a mistyped or revoked key is refused on the spot. +The key is stored encrypted at rest and is never shown again, logged or sent +anywhere but Garmin. + +Accounts registered with Garmin's China service work too: pick **Garmin +Connect China** as the service. The key is only ever sent to the service you +pick. + +After connecting, the last 30 days arrive within a minute and the rest of your +history follows in the background. The card says "Older history is still +loading" until it is done. + +## Key expiry + +Garmin keys expire and cannot be extended. Garmin does not report a key's end +date through the API, so HealthLog works it out from the validity you picked +at connect, and replaces that estimate with Garmin's own date once the key is +in its last day (the short-lived tokens it buys are cut off at the key's end). + +From seven days before the end the card says when the key expires. When it +has expired, or you revoked it in Garmin Connect, syncing stops and the card +asks for a new key: create one, open **Replace key** in the card's menu and +paste it. Nothing imported is lost. If you did not pick a validity, the card +says that it cannot warn ahead; **Key validity** in the card's menu sets it +afterwards, together with the day you created the key, without pasting the +key again. A key Garmin turned down is asked about again once a day, so a +refusal that passes on its own clears without you doing anything. ## What comes through -Everything Garmin shares with Apple Health / Health Connect flows in: steps, -heart rate (including beat-to-beat variability), sleep stages (REM / deep / -light), calories (active and total), distance, weight and body composition, -cadence, and — partially — blood-oxygen saturation. +| Garmin data | HealthLog type | Unit | +| --------------------------------------------------------------------- | -------------------------- | ------------- | +| Steps | `ACTIVITY_STEPS` | steps | +| Step distance | `WALKING_RUNNING_DISTANCE` | m | +| Floors climbed | `FLIGHTS_CLIMBED` | flights | +| Active calories | `ACTIVE_ENERGY_BURNED` | kcal | +| Resting heart rate | `RESTING_HEART_RATE` | bpm | +| All-day average stress level | `STRESS_LEVEL` | 0–100 | +| Body Battery (the day's high) | `BODY_BATTERY` | 0–100 | +| Sleep stages (light, deep, REM, awake) | `SLEEP_DURATION` per stage | minutes | +| Sleep score | `SLEEP_SCORE` | 0–100 | +| Average overnight SpO₂ | `OXYGEN_SATURATION` | % | +| Average overnight breathing rate | `RESPIRATORY_RATE` | breaths/min | +| Overnight HRV (RMSSD) | `HRV_RMSSD` | ms | +| Weight, BMI, body fat, body water, muscle and bone mass, visceral fat | the body-composition types | kg, %, rating | +| Activities | workouts | | +| Logged period days | the cycle tracker | | -## What Garmin withholds +Notes: -These are Garmin's **proprietary** scores. Garmin does **not** share them with -Apple Health or Health Connect, so no self-hosted integration — and no amount of -configuration — can obtain them. They are only available inside Garmin Connect -itself: +- Garmin's HRV is RMSSD and lands on `HRV_RMSSD`. Garmin reports no SDNN, so + nothing is written to `HEART_RATE_VARIABILITY`. +- Stress and Body Battery are Garmin's own scores and keep types of their own. + They are not mixed into HealthLog's computed stress score. +- Days you did not wear the watch import nothing, not zeros. +- Period days are only added on days you have not logged yourself; your own + entries are never changed. Cycles Garmin only predicts are not imported. +- Weigh-ins are read back to the start of Garmin Connect, also from before + your first watch, so a scale-only account gets its whole history. +- A night you delete in Garmin Connect, or that Garmin turns into a nap, is + removed here on the next sync. +- Data Garmin does not provide for your account (a menstrual-cycle summary + when you track none, for example) is skipped, does not count as an error, + and is listed under **Details for a bug report** on the card. +- When the same data also arrives through Apple Health or Health Connect, the + source order in **Settings → Sources** decides which one a chart shows. + Garmin ranks with the other watches. -- Body Battery -- HRV Status -- VO₂ max -- Training Load and Training Effect -- Recovery Time -- Intensity Minutes -- Stress score -- Running dynamics +## What the Personal API does not carry -If you rely on those specific numbers, keep the Garmin Connect app for them; -everything else lands in HealthLog through the platform path above. +Training Readiness, Training Status, acute training load and recovery time +are not part of the Personal API. If you read them elsewhere, for example +through a Home Assistant integration, push them through an ingest token: see +[Home Assistant](./home-assistant.md#garmin-training-figures). They land as +`TRAINING_READINESS` and `ACUTE_TRAINING_LOAD` and show on the recovery page +beside Body Battery. + +## Limits + +- Garmin sets how many requests a key may make. HealthLog reads one resource + at a time and stops the hour's run at Garmin's first rate-limit answer; the + history backfill waits as long as Garmin asks before it goes on. +- Disconnecting removes the key from HealthLog. It stays valid at Garmin until + you revoke it there. + +## Without a key + +Garmin Connect also writes to Apple Health on iOS and to Google Health Connect +on Android, so the [Apple Health](./apple-health.md) and +[Google Health](./google-health.md) paths keep working for anyone who prefers +not to create a key. Body Battery, stress and Garmin's sleep score only come +through the Personal API. diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md index 3176a827d..226a973cf 100644 --- a/docs/integrations/google-health.md +++ b/docs/integrations/google-health.md @@ -315,7 +315,7 @@ Previously synced measurements stay in the database. ## Garmin owners -Garmin has no direct connector for a self-hosted instance. Since Garmin Connect -5.14.1 (July 2025) the app writes to Google Health Connect, so this integration -is how Garmin data reaches HealthLog on Android. See [garmin.md](./garmin.md) for -what comes through and what Garmin withholds. +Since Garmin Connect 5.14.1 (July 2025) the app writes to Google Health Connect, +so this path brings Garmin data in on Android. The direct [Garmin](./garmin.md) +integration reads more (Body Battery, stress, Garmin's sleep score) with a key +from your own Garmin account. diff --git a/docs/integrations/home-assistant.md b/docs/integrations/home-assistant.md index d858e7e80..7af9fb050 100644 --- a/docs/integrations/home-assistant.md +++ b/docs/integrations/home-assistant.md @@ -149,6 +149,38 @@ Units are derived server-side from the type, so send the number in the unit above and do not send a `unit` field. The full enum is in `docs/api/openapi.yaml` under `MeasurementType`. +## Garmin training figures + +The Garmin Personal API does not carry Training Readiness or the acute +training load (see [Garmin](./garmin.md)). If Home Assistant reads them, for +example through a Garmin Connect integration, send them through the same +ingest token. They have types of their own and appear on the recovery page: + +| What the sensor reports | `type` | Unit expected | +| -------------------------- | --------------------- | ------------- | +| Garmin Training Readiness | `TRAINING_READINESS` | score 0–100 | +| Garmin acute training load | `ACUTE_TRAINING_LOAD` | load, no unit | + +Both change about once a day. This automation pushes Training Readiness +whenever the sensor changes, through the `healthlog_measurement` command +above; the acute load is the same with its own sensor and type: + +```yaml +automation: + - alias: "HealthLog: Garmin training readiness" + triggers: + - trigger: state + entity_id: sensor.garmin_training_readiness + conditions: + - condition: template + value_template: "{{ trigger.to_state.state | is_number }}" + actions: + - action: rest_command.healthlog_measurement + data: + type: TRAINING_READINESS + value: "{{ trigger.to_state.state | float }}" +``` + ## Room climate Indoor CO₂, temperature and humidity are their own types, kept apart from the diff --git a/messages/de.json b/messages/de.json index bbc6301ec..5abe7c515 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." }, @@ -5688,7 +5698,11 @@ "recovery": "Erholung", "stress": "Stress", "sleepDebt": "Schlafdefizit", - "hrvRmssd": "HRV (RMSSD)" + "hrvRmssd": "HRV (RMSSD)", + "bodyBattery": "Body Battery", + "stressLevel": "Stresslevel", + "trainingReadiness": "Trainingsbereitschaft", + "acuteTrainingLoad": "Akute Trainingsbelastung" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5706,7 +5720,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 +7515,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 +7714,58 @@ "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.", + "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", + "garminRegion": "Garmin-Dienst", + "garminRegionGlobal": "Garmin Connect (global)", + "garminRegionChina": "Garmin Connect China", + "garminErrors": { + "keyRefused": "Garmin hat diesen Schlüssel nicht angenommen. Prüfe, ob er vollständig kopiert ist, zum gewählten Dienst gehört und weder abgelaufen noch widerrufen ist.", + "rateLimited": "Garmin begrenzt gerade die Anfragen. Versuche es in ein paar Minuten noch einmal.", + "unreachable": "Garmin ist nicht erreichbar. Versuche es in ein paar Minuten noch einmal.", + "invalid": "Dieser Schlüssel sieht nicht richtig aus. Füge ihn noch einmal ohne Leerzeichen ein.", + "generic": "Das hat nicht geklappt. Versuche es noch einmal." + }, + "garminKeyValidityMenu": "Gültigkeit des Schlüssels", + "garminValiditySaved": "Gültigkeit gespeichert.", + "garminKeyCreatedOn": "Erstellt am", + "garminKeyCreatedOnHelp": "Der Tag, an dem du den Schlüssel in Garmin Connect erstellt hast. Leer gelassen zählt die Gültigkeit ab dem Tag, an dem du ihn hier verbunden hast.", + "garminUnavailable": "Garmin stellt für dein Konto diese Daten nicht bereit: {list}.", + "garminResource": { + "daily": "Tagesübersichten", + "activities": "Aktivitäten", + "sleep": "Schlaf", + "hrv": "HRV", + "weight": "Wiegungen", + "cycle": "Menstruationszyklus" + } }, "admin": { "title": "Administration", diff --git a/messages/en.json b/messages/en.json index 94db54003..4160fe8fd 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." }, @@ -5688,7 +5698,11 @@ "recovery": "Recovery", "stress": "Stress", "sleepDebt": "Sleep debt", - "hrvRmssd": "HRV (RMSSD)" + "hrvRmssd": "HRV (RMSSD)", + "bodyBattery": "Body Battery", + "stressLevel": "Stress level", + "trainingReadiness": "Training readiness", + "acuteTrainingLoad": "Acute training load" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5706,7 +5720,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 +7515,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 +7714,58 @@ "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.", + "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", + "garminRegion": "Garmin service", + "garminRegionGlobal": "Garmin Connect (global)", + "garminRegionChina": "Garmin Connect China", + "garminErrors": { + "keyRefused": "Garmin did not accept this key. Check that it was copied whole, belongs to the chosen service and has not expired or been revoked.", + "rateLimited": "Garmin is limiting requests right now. Try again in a few minutes.", + "unreachable": "Garmin cannot be reached. Try again in a few minutes.", + "invalid": "That key does not look right. Paste it again, without spaces.", + "generic": "That did not work. Try again." + }, + "garminKeyValidityMenu": "Key validity", + "garminValiditySaved": "Validity saved.", + "garminKeyCreatedOn": "Created on", + "garminKeyCreatedOnHelp": "The day you created the key in Garmin Connect. Leave it empty to count from the day you connected it here.", + "garminUnavailable": "Garmin does not provide this data for your account: {list}.", + "garminResource": { + "daily": "daily summaries", + "activities": "activities", + "sleep": "sleep", + "hrv": "HRV", + "weight": "weigh-ins", + "cycle": "menstrual cycle" + } }, "admin": { "title": "Administration", diff --git a/messages/es.json b/messages/es.json index 859f592ad..aff7c3bca 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." }, @@ -5688,7 +5698,11 @@ "recovery": "Recuperación", "stress": "Estrés", "sleepDebt": "Deuda de sueño", - "hrvRmssd": "VFC (RMSSD)" + "hrvRmssd": "VFC (RMSSD)", + "bodyBattery": "Body Battery", + "stressLevel": "Nivel de estrés", + "trainingReadiness": "Preparación para entrenar", + "acuteTrainingLoad": "Carga de entrenamiento aguda" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5706,7 +5720,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 +7515,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 +7714,58 @@ "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.", + "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", + "garminRegion": "Servicio de Garmin", + "garminRegionGlobal": "Garmin Connect (global)", + "garminRegionChina": "Garmin Connect China", + "garminErrors": { + "keyRefused": "Garmin no aceptó esta clave. Comprueba que se copió entera, que pertenece al servicio elegido y que no ha caducado ni se ha revocado.", + "rateLimited": "Garmin está limitando las solicitudes. Inténtalo de nuevo en unos minutos.", + "unreachable": "No se puede contactar con Garmin. Inténtalo de nuevo en unos minutos.", + "invalid": "Esa clave no parece correcta. Pégala de nuevo, sin espacios.", + "generic": "No ha funcionado. Inténtalo de nuevo." + }, + "garminKeyValidityMenu": "Validez de la clave", + "garminValiditySaved": "Validez guardada.", + "garminKeyCreatedOn": "Creada el", + "garminKeyCreatedOnHelp": "El día en que creaste la clave en Garmin Connect. Si lo dejas vacío, se cuenta desde el día en que la conectaste aquí.", + "garminUnavailable": "Garmin no ofrece estos datos para tu cuenta: {list}.", + "garminResource": { + "daily": "resúmenes diarios", + "activities": "actividades", + "sleep": "sueño", + "hrv": "VFC", + "weight": "pesajes", + "cycle": "ciclo menstrual" + } }, "admin": { "title": "Administration", diff --git a/messages/fr.json b/messages/fr.json index 87a920617..348f3d5a4 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." }, @@ -5688,7 +5698,11 @@ "recovery": "Récupération", "stress": "Stress", "sleepDebt": "Dette de sommeil", - "hrvRmssd": "VFC (RMSSD)" + "hrvRmssd": "VFC (RMSSD)", + "bodyBattery": "Body Battery", + "stressLevel": "Niveau de stress", + "trainingReadiness": "Disponibilité à l'entraînement", + "acuteTrainingLoad": "Charge d'entraînement aiguë" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5706,7 +5720,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 +7515,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 +7714,58 @@ "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.", + "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", + "garminRegion": "Service Garmin", + "garminRegionGlobal": "Garmin Connect (international)", + "garminRegionChina": "Garmin Connect Chine", + "garminErrors": { + "keyRefused": "Garmin n'a pas accepté cette clé. Vérifiez qu'elle a été copiée en entier, qu'elle appartient au service choisi et qu'elle n'a ni expiré ni été révoquée.", + "rateLimited": "Garmin limite les requêtes en ce moment. Réessayez dans quelques minutes.", + "unreachable": "Impossible de joindre Garmin. Réessayez dans quelques minutes.", + "invalid": "Cette clé ne semble pas correcte. Collez-la à nouveau, sans espaces.", + "generic": "Cela n'a pas fonctionné. Réessayez." + }, + "garminKeyValidityMenu": "Validité de la clé", + "garminValiditySaved": "Validité enregistrée.", + "garminKeyCreatedOn": "Créée le", + "garminKeyCreatedOnHelp": "Le jour où vous avez créé la clé dans Garmin Connect. Laissé vide, le décompte part du jour où vous l'avez connectée ici.", + "garminUnavailable": "Garmin ne fournit pas ces données pour votre compte : {list}.", + "garminResource": { + "daily": "résumés quotidiens", + "activities": "activités", + "sleep": "sommeil", + "hrv": "VFC", + "weight": "pesées", + "cycle": "cycle menstruel" + } }, "admin": { "title": "Administration", diff --git a/messages/it.json b/messages/it.json index 06623f5f4..ad13867cc 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." }, @@ -5688,7 +5698,11 @@ "recovery": "Recupero", "stress": "Stress", "sleepDebt": "Debito di sonno", - "hrvRmssd": "HRV (RMSSD)" + "hrvRmssd": "HRV (RMSSD)", + "bodyBattery": "Body Battery", + "stressLevel": "Livello di stress", + "trainingReadiness": "Prontezza all'allenamento", + "acuteTrainingLoad": "Carico di allenamento acuto" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5706,7 +5720,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 +7515,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 +7714,58 @@ "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.", + "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", + "garminRegion": "Servizio Garmin", + "garminRegionGlobal": "Garmin Connect (globale)", + "garminRegionChina": "Garmin Connect Cina", + "garminErrors": { + "keyRefused": "Garmin non ha accettato questa chiave. Controlla che sia stata copiata per intero, che appartenga al servizio scelto e che non sia scaduta o revocata.", + "rateLimited": "Garmin sta limitando le richieste. Riprova tra qualche minuto.", + "unreachable": "Garmin non è raggiungibile. Riprova tra qualche minuto.", + "invalid": "Questa chiave non sembra corretta. Incollala di nuovo, senza spazi.", + "generic": "Non ha funzionato. Riprova." + }, + "garminKeyValidityMenu": "Validità della chiave", + "garminValiditySaved": "Validità salvata.", + "garminKeyCreatedOn": "Creata il", + "garminKeyCreatedOnHelp": "Il giorno in cui hai creato la chiave in Garmin Connect. Se lo lasci vuoto, si conta dal giorno in cui l'hai collegata qui.", + "garminUnavailable": "Garmin non fornisce questi dati per il tuo account: {list}.", + "garminResource": { + "daily": "riepiloghi giornalieri", + "activities": "attività", + "sleep": "sonno", + "hrv": "HRV", + "weight": "pesate", + "cycle": "ciclo mestruale" + } }, "admin": { "title": "Administration", diff --git a/messages/ko.json b/messages/ko.json index cd07355df..98499067d 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": "회복 값을 불러오지 못했어요." }, @@ -5688,7 +5698,11 @@ "recovery": "회복", "stress": "스트레스", "sleepDebt": "수면 부채", - "hrvRmssd": "심박변이도 (RMSSD)" + "hrvRmssd": "심박변이도 (RMSSD)", + "bodyBattery": "바디 배터리", + "stressLevel": "스트레스 수준", + "trainingReadiness": "트레이닝 준비도", + "acuteTrainingLoad": "급성 훈련 부하" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5706,7 +5720,8 @@ "GOOGLE_HEALTH": "Google Health", "STRAVA": "Strava", "EXTERNAL": "외부 기기", - "HEALTH_CONNECT": "Health Connect" + "HEALTH_CONNECT": "Health Connect", + "GARMIN": "Garmin" }, "deviceLabels": { "watch": "워치", @@ -7500,11 +7515,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 +7714,58 @@ "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이 연결되었습니다. 첫 동기화가 시작됩니다.", + "garminKeyHowTo": "Garmin Connect에서 설정, Personal API 순으로 열고 키를 만드세요. Garmin은 키를 한 번만 보여 주므로 바로 복사해 두세요.", + "garminKeySettingsLink": "Garmin Connect 열기", + "garminApiKey": "Personal API 키", + "garminApiKeyPlaceholder": "키 붙여 넣기", + "garminApiKeySavedPlaceholder": "저장됨. 교체하려면 새 키를 붙여 넣으세요", + "garminKeyValidity": "선택한 유효 기간", + "garminKeyValidityUnknown": "모르겠음", + "garminKeyValidityDays": "{count}일", + "garminKeyValidityHelp": "Garmin은 키 만료일을 알려 주지 않습니다. 유효 기간을 알려 주면 키가 작동을 멈추기 일주일 전에 이 카드가 알려 드립니다.", + "garminConnect": "연결", + "garminUpdate": "변경 사항 저장", + "garminRegion": "Garmin 서비스", + "garminRegionGlobal": "Garmin Connect (글로벌)", + "garminRegionChina": "Garmin Connect 중국", + "garminErrors": { + "keyRefused": "Garmin이 이 키를 받아들이지 않았습니다. 키를 빠짐없이 복사했는지, 선택한 서비스의 키인지, 만료되거나 취소되지 않았는지 확인하세요.", + "rateLimited": "Garmin이 지금 요청을 제한하고 있습니다. 몇 분 후에 다시 시도하세요.", + "unreachable": "Garmin에 연결할 수 없습니다. 몇 분 후에 다시 시도하세요.", + "invalid": "키 형식이 올바르지 않습니다. 공백 없이 다시 붙여 넣으세요.", + "generic": "실패했습니다. 다시 시도하세요." + }, + "garminKeyValidityMenu": "키 유효 기간", + "garminValiditySaved": "유효 기간을 저장했습니다.", + "garminKeyCreatedOn": "생성일", + "garminKeyCreatedOnHelp": "Garmin Connect에서 키를 만든 날입니다. 비워 두면 여기에 연결한 날부터 계산합니다.", + "garminUnavailable": "Garmin이 내 계정에 이 데이터를 제공하지 않습니다: {list}.", + "garminResource": { + "daily": "일일 요약", + "activities": "활동", + "sleep": "수면", + "hrv": "HRV", + "weight": "체중 측정", + "cycle": "생리 주기" + } }, "admin": { "title": "관리자", diff --git a/messages/nl.json b/messages/nl.json index 8e9596274..96d427f61 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.", @@ -5688,7 +5698,11 @@ "recovery": "Herstel", "stress": "Stress", "sleepDebt": "Slaaptekort", - "hrvRmssd": "HRV (RMSSD)" + "hrvRmssd": "HRV (RMSSD)", + "bodyBattery": "Body Battery", + "stressLevel": "Stressniveau", + "trainingReadiness": "Trainingsgereedheid", + "acuteTrainingLoad": "Acute trainingsbelasting" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5706,7 +5720,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 +7515,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 +7714,58 @@ "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.", + "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", + "garminRegion": "Garmin-dienst", + "garminRegionGlobal": "Garmin Connect (wereldwijd)", + "garminRegionChina": "Garmin Connect China", + "garminErrors": { + "keyRefused": "Garmin heeft deze sleutel niet geaccepteerd. Controleer of hij volledig is gekopieerd, bij de gekozen dienst hoort en niet verlopen of ingetrokken is.", + "rateLimited": "Garmin beperkt op dit moment het aantal verzoeken. Probeer het over een paar minuten opnieuw.", + "unreachable": "Garmin is niet bereikbaar. Probeer het over een paar minuten opnieuw.", + "invalid": "Die sleutel ziet er niet goed uit. Plak hem opnieuw, zonder spaties.", + "generic": "Dat is niet gelukt. Probeer het opnieuw." + }, + "garminKeyValidityMenu": "Geldigheid van de sleutel", + "garminValiditySaved": "Geldigheid opgeslagen.", + "garminKeyCreatedOn": "Aangemaakt op", + "garminKeyCreatedOnHelp": "De dag waarop je de sleutel in Garmin Connect hebt gemaakt. Leeg gelaten telt de geldigheid vanaf de dag dat je hem hier hebt gekoppeld.", + "garminUnavailable": "Garmin levert deze gegevens niet voor je account: {list}.", + "garminResource": { + "daily": "dagoverzichten", + "activities": "activiteiten", + "sleep": "slaap", + "hrv": "HRV", + "weight": "wegingen", + "cycle": "menstruatiecyclus" + } }, "admin": { "title": "Beheer", diff --git a/messages/pl.json b/messages/pl.json index cffa8cb9b..d5fb8cd97 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." }, @@ -5688,7 +5698,11 @@ "recovery": "Regeneracja", "stress": "Stres", "sleepDebt": "Dług snu", - "hrvRmssd": "HRV (RMSSD)" + "hrvRmssd": "HRV (RMSSD)", + "bodyBattery": "Body Battery", + "stressLevel": "Poziom stresu", + "trainingReadiness": "Gotowość do treningu", + "acuteTrainingLoad": "Ostre obciążenie treningowe" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5706,7 +5720,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 +7515,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 +7714,58 @@ "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.", + "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", + "garminRegion": "Usługa Garmin", + "garminRegionGlobal": "Garmin Connect (globalny)", + "garminRegionChina": "Garmin Connect Chiny", + "garminErrors": { + "keyRefused": "Garmin nie przyjął tego klucza. Sprawdź, czy został skopiowany w całości, należy do wybranej usługi i nie wygasł ani nie został unieważniony.", + "rateLimited": "Garmin ogranicza teraz liczbę zapytań. Spróbuj ponownie za kilka minut.", + "unreachable": "Nie można połączyć się z Garmin. Spróbuj ponownie za kilka minut.", + "invalid": "Ten klucz wygląda nieprawidłowo. Wklej go ponownie, bez spacji.", + "generic": "Nie udało się. Spróbuj ponownie." + }, + "garminKeyValidityMenu": "Ważność klucza", + "garminValiditySaved": "Zapisano ważność.", + "garminKeyCreatedOn": "Utworzono", + "garminKeyCreatedOnHelp": "Dzień, w którym utworzono klucz w Garmin Connect. Jeśli zostawisz puste, ważność liczy się od dnia połączenia go tutaj.", + "garminUnavailable": "Garmin nie udostępnia tych danych dla Twojego konta: {list}.", + "garminResource": { + "daily": "podsumowania dzienne", + "activities": "aktywności", + "sleep": "sen", + "hrv": "HRV", + "weight": "pomiary wagi", + "cycle": "cykl menstruacyjny" + } }, "admin": { "title": "Administracja", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 4dee685c2..7436334e1 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." }, @@ -5688,7 +5698,11 @@ "recovery": "Recuperação", "stress": "Estresse", "sleepDebt": "Déficit de sono", - "hrvRmssd": "VFC (RMSSD)" + "hrvRmssd": "VFC (RMSSD)", + "bodyBattery": "Body Battery", + "stressLevel": "Nível de estresse", + "trainingReadiness": "Prontidão para treinar", + "acuteTrainingLoad": "Carga de treino aguda" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5706,7 +5720,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 +7515,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 +7714,58 @@ "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.", + "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", + "garminRegion": "Serviço Garmin", + "garminRegionGlobal": "Garmin Connect (global)", + "garminRegionChina": "Garmin Connect China", + "garminErrors": { + "keyRefused": "A Garmin não aceitou esta chave. Verifique se ela foi copiada inteira, pertence ao serviço escolhido e não expirou nem foi revogada.", + "rateLimited": "A Garmin está limitando as solicitações agora. Tente de novo em alguns minutos.", + "unreachable": "Não foi possível alcançar a Garmin. Tente de novo em alguns minutos.", + "invalid": "Essa chave não parece correta. Cole-a de novo, sem espaços.", + "generic": "Não deu certo. Tente de novo." + }, + "garminKeyValidityMenu": "Validade da chave", + "garminValiditySaved": "Validade salva.", + "garminKeyCreatedOn": "Criada em", + "garminKeyCreatedOnHelp": "O dia em que você criou a chave no Garmin Connect. Deixe vazio para contar a partir do dia em que a conectou aqui.", + "garminUnavailable": "A Garmin não fornece estes dados para sua conta: {list}.", + "garminResource": { + "daily": "resumos diários", + "activities": "atividades", + "sleep": "sono", + "hrv": "VFC", + "weight": "pesagens", + "cycle": "ciclo menstrual" + } }, "admin": { "title": "Administração", diff --git a/messages/ru.json b/messages/ru.json index 6e99a119e..7ef35a608 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": "Не удалось загрузить данные восстановления." }, @@ -5688,7 +5698,11 @@ "recovery": "Восстановление", "stress": "Стресс", "sleepDebt": "Дефицит сна", - "hrvRmssd": "HRV (RMSSD)" + "hrvRmssd": "HRV (RMSSD)", + "bodyBattery": "Body Battery", + "stressLevel": "Уровень стресса", + "trainingReadiness": "Готовность к тренировке", + "acuteTrainingLoad": "Острая тренировочная нагрузка" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5706,7 +5720,8 @@ "GOOGLE_HEALTH": "Google Health", "STRAVA": "Strava", "EXTERNAL": "Внешнее устройство", - "HEALTH_CONNECT": "Health Connect" + "HEALTH_CONNECT": "Health Connect", + "GARMIN": "Garmin" }, "deviceLabels": { "watch": "Часы", @@ -7500,11 +7515,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 +7714,58 @@ "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 подключён. Первая синхронизация уже началась.", + "garminKeyHowTo": "В Garmin Connect откройте «Настройки», затем Personal API, и создайте ключ. Garmin показывает его только один раз, поэтому сразу скопируйте его.", + "garminKeySettingsLink": "Открыть Garmin Connect", + "garminApiKey": "Ключ Personal API", + "garminApiKeyPlaceholder": "Вставьте ключ", + "garminApiKeySavedPlaceholder": "Сохранён. Вставьте новый ключ, чтобы заменить его", + "garminKeyValidity": "Выбранный срок действия", + "garminKeyValidityUnknown": "Не знаю", + "garminKeyValidityDays": "{count} дней", + "garminKeyValidityHelp": "Garmin не сообщает, когда истекает срок действия ключа. Зная срок, эта карточка предупредит вас за неделю до того, как ключ перестанет работать.", + "garminConnect": "Подключить", + "garminUpdate": "Сохранить изменения", + "garminRegion": "Сервис Garmin", + "garminRegionGlobal": "Garmin Connect (глобальный)", + "garminRegionChina": "Garmin Connect Китай", + "garminErrors": { + "keyRefused": "Garmin не принял этот ключ. Проверьте, что он скопирован целиком, относится к выбранному сервису и не истёк и не отозван.", + "rateLimited": "Garmin сейчас ограничивает запросы. Попробуйте снова через несколько минут.", + "unreachable": "Garmin недоступен. Попробуйте снова через несколько минут.", + "invalid": "Ключ выглядит неправильно. Вставьте его ещё раз без пробелов.", + "generic": "Не получилось. Попробуйте ещё раз." + }, + "garminKeyValidityMenu": "Срок действия ключа", + "garminValiditySaved": "Срок действия сохранён.", + "garminKeyCreatedOn": "Дата создания", + "garminKeyCreatedOnHelp": "День, когда вы создали ключ в Garmin Connect. Если оставить пустым, срок считается со дня подключения здесь.", + "garminUnavailable": "Garmin не предоставляет эти данные для вашего аккаунта: {list}.", + "garminResource": { + "daily": "дневные сводки", + "activities": "тренировки", + "sleep": "сон", + "hrv": "ВСР", + "weight": "взвешивания", + "cycle": "менструальный цикл" + } }, "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..f96e8db4a 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)": 476, "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..9ed7c49f2 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -110,6 +110,14 @@ 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", + "settings.sections.sources.metrics.bodyBattery", + // Garmin's own service names. + "settings.garminRegionGlobal", + "settings.garminRegionChina", "admin.glitchtipDsn", "admin.integrationWebPush", "admin.offlineGeoEnabled", diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index 3d3d11f24..7278619b8 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -70,6 +70,8 @@ const WRITE_SITES: Record = { "lib/whoop/webhook-handler.ts": TAIL, // Retiring the rows of Oura sleep periods the person rejected or deleted. "lib/oura/sync.ts": TAIL, + // Retiring the rows of nights Garmin no longer reports. + "lib/garmin/sync.ts": TAIL, // Retiring the equivalent-distance rows earlier Oura syncs wrote. "lib/oura/retire-distance.ts": TAIL, // Integration syncs: each collects the (type, day) pairs it touched and 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..c4e57bd0a --- /dev/null +++ b/src/app/api/garmin/connect/route.ts @@ -0,0 +1,153 @@ +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, + GarminApiError, + 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; the person + * picks one, only that host is contacted, and the choice is kept so the + * sync talks to the same 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, region, validityDays } = parsed.data; + + // Only the service the person chose is asked: the key is theirs to send, + // and a key for one service means nothing to the other. + let token: GarminToken | null = null; + let lastError: unknown = null; + try { + token = await exchangeGarminKey(apiKey, region); + } catch (err) { + lastError = err; + } + + if (!token) { + 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/key-expiry/route.ts b/src/app/api/garmin/key-expiry/route.ts new file mode 100644 index 000000000..9c257bb46 --- /dev/null +++ b/src/app/api/garmin/key-expiry/route.ts @@ -0,0 +1,64 @@ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + estimateGarminKeyExpiry, + resolveGarminKeyExpiry, +} from "@/lib/garmin/key-expiry"; +import { garminKeyExpirySchema } from "@/lib/validations/garmin"; +import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only"; + +/** + * Correct when the stored Garmin key expires, without pasting it again. + * + * Garmin does not report a key's end, so the card counts it from the + * validity picked when the key was created. A wrong pick, or a key created + * some days before it was connected here, is set right through this route: + * the validity counts from `createdOn` when given, else from the day the key + * was connected. `validityDays: null` forgets the estimate. Nothing is sent + * to Garmin. Rate-limited 20/60 s per user. + */ +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "garmin.key_expiry.update" } }); + + const rl = await checkRateLimit(`garmin-key-expiry:${user.id}`, 20, 60_000); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); + } + + const parsed = await parseJsonBody(request, garminKeyExpirySchema, { + maxBytes: 4 * 1024, + }); + if (!parsed.ok) return parsed.error; + const { validityDays, createdOn } = parsed.data; + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { garminApiKeyEncrypted: true, garminConnectedAt: true }, + }); + if (!dbUser?.garminApiKeyEncrypted) { + return apiError("No Garmin connection", 404); + } + + const from = createdOn + ? dateOnlyAtNoonUtc(createdOn) + : (dbUser.garminConnectedAt ?? new Date()); + const keyExpiresAt = validityDays + ? estimateGarminKeyExpiry(from, validityDays) + : null; + + await prisma.user.update({ + where: { id: user.id }, + data: { garminKeyExpiresAt: keyExpiresAt }, + }); + + return apiSuccess({ keyExpiry: resolveGarminKeyExpiry(keyExpiresAt) }); +}); diff --git a/src/app/api/garmin/status/route.ts b/src/app/api/garmin/status/route.ts new file mode 100644 index 000000000..a46befc98 --- /dev/null +++ b/src/app/api/garmin/status/route.ts @@ -0,0 +1,77 @@ +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, + garminUnavailableResources, + 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"); + const progress = readGarminBackfillProgress(dbUser.garminBackfillProgress); + 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(progress), + }, + unavailableResources: garminUnavailableResources(progress), + 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..e297b2e2e 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -15,6 +15,17 @@ * 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, + garminUnavailableResources, + readGarminBackfillProgress, + type GarminBackfillStep, +} 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 +94,7 @@ export const GET = apiHandler(async () => { stravaStatus, nightscoutStatus, dawarichStatus, + garminStatus, dbUser, withingsConn, whoopConn, @@ -108,6 +120,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 +148,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 +213,7 @@ export const GET = apiHandler(async () => { "strava", "nightscout", "dawarich", + "garmin", ]), // See `metricFreshnessDegraded` below. getSourceMetricFreshness(user.id).catch(() => { @@ -319,6 +339,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 +533,21 @@ 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, + unavailableResources: garmin.unavailableResources, + health: health["garmin"], + ...resolve("garmin", garminStatus, { + connected: garmin.connected, + configured: garmin.configured, + }), + } satisfies IntegrationViewModel & GarminExtras, ], }); }); @@ -617,6 +653,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; + unavailableResources: GarminBackfillStep[] | 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, + unavailableResources: null, + }; + } + const complete = dbUser.garminBackfillCompletedAt !== null; + const progress = readGarminBackfillProgress(dbUser.garminBackfillProgress); + return { + connected: true, + configured: true, + region: isGarminRegion(dbUser.garminRegion) + ? dbUser.garminRegion + : "global", + keyExpiry: resolveGarminKeyExpiry(dbUser.garminKeyExpiresAt), + backfillCompleted: complete, + backfillShare: complete ? 1 : garminBackfillShare(progress), + unavailableResources: garminUnavailableResources(progress), + }; +} + // 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/__tests__/garmin-card.test.tsx b/src/components/settings/integrations/__tests__/garmin-card.test.tsx new file mode 100644 index 000000000..b089ffe26 --- /dev/null +++ b/src/components/settings/integrations/__tests__/garmin-card.test.tsx @@ -0,0 +1,196 @@ +/** + * Garmin on the shared integration card. + * + * 1. Not connected: the key form, the link to Garmin Connect's key page, + * the validity choice and Connect, inside the shared card. + * 2. Connected: the same menu as Nightscout, a refused key opens the form, + * and disconnect asks first and invalidates the envelope. + * 3. The key's end: a sentence in the body while it is close, past or + * unknown, and the date in the details while it is comfortably valid. + */ +import { describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { queryKeys } from "@/lib/query-keys"; + +const invalidateSpy = vi.fn(); +const capturedDisconnectOnSuccess: { fn: (() => void) | null } = { fn: null }; + +vi.mock("@tanstack/react-query", () => ({ + useMutation: ({ onSuccess }: { onSuccess?: () => void }) => { + capturedDisconnectOnSuccess.fn = onSuccess ?? null; + return { mutate: vi.fn(), isPending: false }; + }, + useQueryClient: () => ({ invalidateQueries: invalidateSpy }), +})); + +let lastCardProps: IntegrationCardProps | null = null; +vi.mock("../integration-card", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IntegrationCard: (props: IntegrationCardProps) => { + lastCardProps = props; + return createElement(actual.IntegrationCard, props); + }, + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import type { GarminKeyExpiry } from "@/lib/garmin/key-expiry"; +import type { IntegrationCardProps } from "../integration-card"; +import { GarminCard, GARMIN_KEY_SETTINGS_URL } from "../garmin-card"; +import type { IntegrationStatusViewModel } from "../shared"; +import { healthFixture } from "./health-fixture"; + +function render(viewModel?: Partial) { + return renderToStaticMarkup( + + + , + ); +} + +function connected(keyExpiry: GarminKeyExpiry | null) { + return render({ + connected: true, + configured: true, + health: healthFixture(), + keyExpiry, + metricFreshness: [], + }); +} + +describe("GarminCard on the shared card", () => { + it("shows the key form inside the shared card while not connected", () => { + const html = render({ connected: false, configured: false, health: null }); + expect(html).toContain('data-slot="integration-card"'); + expect(html).toContain('data-state="not_connected"'); + expect(html).toContain('id="garmin-api-key"'); + expect(html).toContain('id="garmin-key-validity"'); + expect(html).toContain(`href="${GARMIN_KEY_SETTINGS_URL}"`); + expect(html).toContain('data-testid="garmin-connect"'); + expect(html).toContain( + 'href="https://docs.healthlog.dev/guides/connect-garmin/"', + ); + // The service: Garmin Connect (global) first, China second. + expect(html).toContain('id="garmin-region"'); + expect(html).toMatch( + /