From 83c06051fe26e680585bd3a38572ad7b768c5975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 4 Nov 2026 10:56:52 +0100 Subject: [PATCH] fix(garmin): read weigh-ins in both shapes, keep refused resources out of the way Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live service sends a samplePk and grams. A weigh-in now takes its id from samplePk or else its own GMT instant, reads each mass by magnitude (above 1000 is grams), and is written whole or not at all: a missing weight, a missing instant or one implausible value drops every row of it, counted on the wide event by reason. Refused resources: a lasting 4xx on one resource (a cycle summary for an account that tracks no cycle) is noted once on the progress column, counts as done for the backfill, is skipped by the hourly sync and is listed in the card's details. It no longer holds the backfill open or turns the connection red. The cycle leg never parks the connection, whatever it is refused with. Backfill: weigh-ins walk back to the start of Garmin Connect on their own, so history before the first watch day and a scale-only account arrive. Progress and the completion stamp are written only while the connection the slice started on is still the stored one. Key: connect sends the key to the one service the person picks (Garmin Connect or Garmin Connect China), never to both. A parked key is asked about again once a day with a single token exchange and reconnected when Garmin accepts it. The key's end is read from the token's own iat and exp, an estimate the key outlived is cleared after an accepted exchange, and the validity can be corrected from the card's menu (PATCH /api/garmin/key-expiry, with the day the key was created) without pasting it again. Sync: an incremental run reaches back to the last successful sync, up to a year, so an outage longer than the 30-day window leaves no gap; a night Garmin no longer reports (deleted, or turned into a nap) is retired. Card: connect errors are translated from meta.errorCode in every locale. Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and ACUTE_TRAINING_LOAD get source ladders of their own (the device or the bridge first, then the hand-entered sources in their usual order), join the ranked types and the provenance picker, and the recovery-empty description names them. --- docs/api/openapi.yaml | 505 +++++++++++++++++- messages/de.json | 32 +- messages/en.json | 32 +- messages/es.json | 32 +- messages/fr.json | 32 +- messages/it.json | 32 +- messages/ko.json | 32 +- messages/nl.json | 32 +- messages/pl.json | 32 +- messages/pt-BR.json | 32 +- messages/ru.json | 32 +- .../baselines/source-function-lines.json | 2 +- src/__tests__/i18n-english-leak-guard.test.ts | 4 + .../measurement-write-tail-guard.test.ts | 2 + src/app/api/garmin/connect/route.ts | 30 +- src/app/api/garmin/key-expiry/route.ts | 64 +++ src/app/api/garmin/status/route.ts | 7 +- src/app/api/integrations/status/route.ts | 13 +- .../__tests__/garmin-card.test.tsx | 35 +- .../settings/integrations/garmin-card.tsx | 370 ++++++++++--- .../settings/integrations/shared.tsx | 3 + src/components/settings/sources-section.tsx | 4 + src/lib/analytics/source-rank-sql.ts | 5 + src/lib/garmin/__tests__/client.test.ts | 34 +- src/lib/garmin/__tests__/map.test.ts | 105 +++- src/lib/garmin/__tests__/unavailable.test.ts | 44 ++ src/lib/garmin/backfill-progress.ts | 27 + src/lib/garmin/client.ts | 39 +- src/lib/garmin/credentials.ts | 27 +- src/lib/garmin/map.ts | 109 ++-- src/lib/garmin/sync.ts | 206 ++++++- src/lib/garmin/unavailable.ts | 90 ++++ src/lib/jobs/garmin-backfill.ts | 101 +++- src/lib/measurements/cumulative-day-sum.ts | 8 + src/lib/openapi/routes/integrations.ts | 56 +- src/lib/openapi/routes/recovery-empty.ts | 2 +- src/lib/provenance/ranked-types.ts | 4 + src/lib/validations/garmin.ts | 35 +- src/lib/validations/source-priority.ts | 17 + tests/integration/garmin-sync.test.ts | 393 +++++++++++++- 40 files changed, 2355 insertions(+), 306 deletions(-) create mode 100644 src/app/api/garmin/key-expiry/route.ts create mode 100644 src/lib/garmin/__tests__/unavailable.test.ts create mode 100644 src/lib/garmin/unavailable.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f0eac1028..5d784eddf 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9080,6 +9080,98 @@ paths: - 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: @@ -9543,6 +9635,98 @@ paths: - 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: @@ -21529,12 +21713,11 @@ paths: tags: - Integrations summary: Connect (or replace) the caller's Garmin Personal API key - description: Validates the key by buying a token with it BEFORE storing anything, first at Garmin's global service and, - when that refuses the key, at the China service. On success the key is encrypted at rest, the region that - accepted it and the key's expiry are noted, the history backfill is reopened, the `garmin` ledger is reset to - connected, and a first sync plus the backfill are enqueued best-effort. Re-sending replaces the key. The key is - never logged or echoed. Body capped at 16 KiB, rate-limited 10 requests / 60 s per user. Auth via cookie or - Bearer. + 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: @@ -21660,6 +21843,37 @@ paths: 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: @@ -36869,6 +37083,12 @@ components: minLength: 16 maxLength: 1024 pattern: ^\S+$ + region: + default: global + type: string + enum: + - global + - china validityDays: anyOf: - anyOf: @@ -36883,9 +37103,30 @@ components: - type: "null" required: - apiKey - description: The Personal API key from Garmin Connect (Settings, Personal API), trimmed before use, and optionally the - validity picked when it was created. Garmin does not report a key's expiry, so the validity is what lets the + 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: @@ -59444,6 +59685,98 @@ components: - 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: @@ -59907,6 +60240,98 @@ components: - 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 @@ -68672,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 @@ -83909,6 +84334,20 @@ components: - 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`." @@ -84003,6 +84442,29 @@ components: - 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: @@ -85653,6 +86115,23 @@ components: 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 diff --git a/messages/de.json b/messages/de.json index bd079b3f2..5abe7c515 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5698,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", @@ -7728,7 +7732,6 @@ "garminKeyExpiring": "Dein Garmin-Schlüssel läuft am {date} ab. Erstelle vorher in Garmin Connect einen neuen Schlüssel und füge ihn hier ein.", "garminKeyExpiryUnknown": "Garmin verrät nicht, wann dieser Schlüssel abläuft. Ersetze den Schlüssel und wähle die Gültigkeit, die du in Garmin Connect eingestellt hast, dann warnt dich diese Karte eine Woche vorher.", "garminConnected": "Garmin verbunden. Die erste Synchronisierung läuft an.", - "garminConnectFailed": "Verbindung zu Garmin fehlgeschlagen.", "garminKeyHowTo": "Öffne in Garmin Connect die Einstellungen, dann Personal API, und erstelle einen Schlüssel. Garmin zeigt ihn nur einmal an, kopiere ihn also sofort.", "garminKeySettingsLink": "Garmin Connect öffnen", "garminApiKey": "Personal-API-Schlüssel", @@ -7739,7 +7742,30 @@ "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" + "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 1ea519f3c..4160fe8fd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5698,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", @@ -7728,7 +7732,6 @@ "garminKeyExpiring": "Your Garmin key expires on {date}. Create a new key in Garmin Connect and paste it here before then.", "garminKeyExpiryUnknown": "Garmin does not say when this key expires. Replace the key and choose the validity you picked in Garmin Connect, and this card will warn you a week ahead.", "garminConnected": "Garmin connected. The first sync is on its way.", - "garminConnectFailed": "Could not connect to Garmin.", "garminKeyHowTo": "In Garmin Connect, open Settings, then Personal API, and create a key. Garmin shows it only once, so copy it straight away.", "garminKeySettingsLink": "Open Garmin Connect", "garminApiKey": "Personal API key", @@ -7739,7 +7742,30 @@ "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" + "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 90c54cf8a..aff7c3bca 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5698,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", @@ -7728,7 +7732,6 @@ "garminKeyExpiring": "Tu clave de Garmin caduca el {date}. Crea una clave nueva en Garmin Connect y pégala aquí antes de esa fecha.", "garminKeyExpiryUnknown": "Garmin no indica cuándo caduca esta clave. Reemplaza la clave y elige la validez que seleccionaste en Garmin Connect, y esta tarjeta te avisará con una semana de antelación.", "garminConnected": "Garmin conectado. La primera sincronización está en marcha.", - "garminConnectFailed": "No se pudo conectar con Garmin.", "garminKeyHowTo": "En Garmin Connect, abre Configuración, luego Personal API, y crea una clave. Garmin solo la muestra una vez, así que cópiala enseguida.", "garminKeySettingsLink": "Abrir Garmin Connect", "garminApiKey": "Clave de la Personal API", @@ -7739,7 +7742,30 @@ "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" + "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 90ccb2bea..348f3d5a4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5698,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", @@ -7728,7 +7732,6 @@ "garminKeyExpiring": "Votre clé Garmin expire le {date}. Créez une nouvelle clé dans Garmin Connect et collez-la ici avant cette date.", "garminKeyExpiryUnknown": "Garmin n'indique pas quand cette clé expire. Remplacez la clé et choisissez la validité sélectionnée dans Garmin Connect : cette carte vous préviendra une semaine avant.", "garminConnected": "Garmin connecté. La première synchronisation est en route.", - "garminConnectFailed": "Impossible de se connecter à Garmin.", "garminKeyHowTo": "Dans Garmin Connect, ouvrez Paramètres, puis Personal API, et créez une clé. Garmin ne l'affiche qu'une fois : copiez-la tout de suite.", "garminKeySettingsLink": "Ouvrir Garmin Connect", "garminApiKey": "Clé Personal API", @@ -7739,7 +7742,30 @@ "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" + "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 2618d7811..ad13867cc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5698,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", @@ -7728,7 +7732,6 @@ "garminKeyExpiring": "La tua chiave Garmin scade il {date}. Crea prima una nuova chiave in Garmin Connect e incollala qui.", "garminKeyExpiryUnknown": "Garmin non dice quando scade questa chiave. Sostituisci la chiave e scegli la validità impostata in Garmin Connect: questa scheda ti avviserà una settimana prima.", "garminConnected": "Garmin collegato. La prima sincronizzazione è in arrivo.", - "garminConnectFailed": "Impossibile collegarsi a Garmin.", "garminKeyHowTo": "In Garmin Connect apri Impostazioni, poi Personal API, e crea una chiave. Garmin la mostra una sola volta, quindi copiala subito.", "garminKeySettingsLink": "Apri Garmin Connect", "garminApiKey": "Chiave Personal API", @@ -7739,7 +7742,30 @@ "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" + "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 223f65198..98499067d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5698,7 +5698,11 @@ "recovery": "회복", "stress": "스트레스", "sleepDebt": "수면 부채", - "hrvRmssd": "심박변이도 (RMSSD)" + "hrvRmssd": "심박변이도 (RMSSD)", + "bodyBattery": "바디 배터리", + "stressLevel": "스트레스 수준", + "trainingReadiness": "트레이닝 준비도", + "acuteTrainingLoad": "급성 훈련 부하" }, "sourceLabels": { "WITHINGS": "Withings", @@ -7728,7 +7732,6 @@ "garminKeyExpiring": "Garmin 키가 {date}에 만료됩니다. 그 전에 Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으세요.", "garminKeyExpiryUnknown": "Garmin은 이 키의 만료일을 알려 주지 않습니다. 키를 교체하면서 Garmin Connect에서 고른 유효 기간을 선택하면 이 카드가 일주일 전에 알려 드립니다.", "garminConnected": "Garmin이 연결되었습니다. 첫 동기화가 시작됩니다.", - "garminConnectFailed": "Garmin에 연결하지 못했습니다.", "garminKeyHowTo": "Garmin Connect에서 설정, Personal API 순으로 열고 키를 만드세요. Garmin은 키를 한 번만 보여 주므로 바로 복사해 두세요.", "garminKeySettingsLink": "Garmin Connect 열기", "garminApiKey": "Personal API 키", @@ -7739,7 +7742,30 @@ "garminKeyValidityDays": "{count}일", "garminKeyValidityHelp": "Garmin은 키 만료일을 알려 주지 않습니다. 유효 기간을 알려 주면 키가 작동을 멈추기 일주일 전에 이 카드가 알려 드립니다.", "garminConnect": "연결", - "garminUpdate": "변경 사항 저장" + "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 11ceeb582..96d427f61 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -5698,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", @@ -7728,7 +7732,6 @@ "garminKeyExpiring": "Je Garmin-sleutel verloopt op {date}. Maak vóór die datum in Garmin Connect een nieuwe sleutel en plak die hier.", "garminKeyExpiryUnknown": "Garmin vertelt niet wanneer deze sleutel verloopt. Vervang de sleutel en kies de geldigheid die je in Garmin Connect hebt gekozen, dan waarschuwt deze kaart je een week van tevoren.", "garminConnected": "Garmin gekoppeld. De eerste synchronisatie is onderweg.", - "garminConnectFailed": "Kan geen verbinding maken met Garmin.", "garminKeyHowTo": "Open in Garmin Connect de instellingen, dan Personal API, en maak een sleutel. Garmin toont hem maar één keer, dus kopieer hem meteen.", "garminKeySettingsLink": "Garmin Connect openen", "garminApiKey": "Personal API-sleutel", @@ -7739,7 +7742,30 @@ "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" + "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 f81a96703..d5fb8cd97 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5698,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", @@ -7728,7 +7732,6 @@ "garminKeyExpiring": "Twój klucz Garmin wygasa {date}. Przed tym dniem utwórz nowy klucz w Garmin Connect i wklej go tutaj.", "garminKeyExpiryUnknown": "Garmin nie podaje, kiedy ten klucz wygaśnie. Zastąp klucz i wybierz ważność ustawioną w Garmin Connect, a ta karta ostrzeże Cię tydzień wcześniej.", "garminConnected": "Połączono z Garmin. Pierwsza synchronizacja jest w drodze.", - "garminConnectFailed": "Nie udało się połączyć z Garmin.", "garminKeyHowTo": "W Garmin Connect otwórz Ustawienia, następnie Personal API, i utwórz klucz. Garmin pokazuje go tylko raz, więc od razu go skopiuj.", "garminKeySettingsLink": "Otwórz Garmin Connect", "garminApiKey": "Klucz Personal API", @@ -7739,7 +7742,30 @@ "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" + "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 b7ea863fd..7436334e1 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -5698,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", @@ -7728,7 +7732,6 @@ "garminKeyExpiring": "Sua chave Garmin expira em {date}. Antes disso, crie uma nova chave no Garmin Connect e cole-a aqui.", "garminKeyExpiryUnknown": "A Garmin não informa quando esta chave expira. Substitua a chave e escolha a validade definida no Garmin Connect, e este cartão avisará você com uma semana de antecedência.", "garminConnected": "Garmin conectada. A primeira sincronização está a caminho.", - "garminConnectFailed": "Não foi possível conectar à Garmin.", "garminKeyHowTo": "No Garmin Connect, abra Configurações, depois Personal API, e crie uma chave. A Garmin mostra a chave só uma vez, então copie-a na hora.", "garminKeySettingsLink": "Abrir o Garmin Connect", "garminApiKey": "Chave da Personal API", @@ -7739,7 +7742,30 @@ "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" + "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 479c7b73a..7ef35a608 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -5698,7 +5698,11 @@ "recovery": "Восстановление", "stress": "Стресс", "sleepDebt": "Дефицит сна", - "hrvRmssd": "HRV (RMSSD)" + "hrvRmssd": "HRV (RMSSD)", + "bodyBattery": "Body Battery", + "stressLevel": "Уровень стресса", + "trainingReadiness": "Готовность к тренировке", + "acuteTrainingLoad": "Острая тренировочная нагрузка" }, "sourceLabels": { "WITHINGS": "Withings", @@ -7728,7 +7732,6 @@ "garminKeyExpiring": "Срок действия вашего ключа Garmin истекает {date}. До этого создайте новый ключ в Garmin Connect и вставьте его сюда.", "garminKeyExpiryUnknown": "Garmin не сообщает, когда истекает срок действия ключа. Замените ключ и выберите срок, указанный в Garmin Connect, и эта карточка предупредит вас за неделю.", "garminConnected": "Garmin подключён. Первая синхронизация уже началась.", - "garminConnectFailed": "Не удалось подключиться к Garmin.", "garminKeyHowTo": "В Garmin Connect откройте «Настройки», затем Personal API, и создайте ключ. Garmin показывает его только один раз, поэтому сразу скопируйте его.", "garminKeySettingsLink": "Открыть Garmin Connect", "garminApiKey": "Ключ Personal API", @@ -7739,7 +7742,30 @@ "garminKeyValidityDays": "{count} дней", "garminKeyValidityHelp": "Garmin не сообщает, когда истекает срок действия ключа. Зная срок, эта карточка предупредит вас за неделю до того, как ключ перестанет работать.", "garminConnect": "Подключить", - "garminUpdate": "Сохранить изменения" + "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/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index e8b6bdae4..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)": 475, + "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, diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index fb9801d4a..9ed7c49f2 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -114,6 +114,10 @@ const LEGIT_IDENTICAL = new Set([ // 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/app/api/garmin/connect/route.ts b/src/app/api/garmin/connect/route.ts index 2226b7959..c4e57bd0a 100644 --- a/src/app/api/garmin/connect/route.ts +++ b/src/app/api/garmin/connect/route.ts @@ -17,9 +17,7 @@ import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready"; import { clearGarminTokenCache, exchangeGarminKey, - GARMIN_REGIONS, GarminApiError, - type GarminRegion, type GarminToken, } from "@/lib/garmin/client"; import { @@ -33,9 +31,9 @@ import { garminConnectSchema } from "@/lib/validations/garmin"; * * The key is validated by buying a token with it before anything is stored: * a mistyped, revoked or expired key answers here, not on the first cron - * tick. Garmin runs a separate service for accounts in China; a key the - * global service refuses is tried there once, and the region that accepted it - * is kept so the sync talks to the right host. + * 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 @@ -62,25 +60,19 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (!parsed.ok) return parsed.error; - const { apiKey, validityDays } = parsed.data; + 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 region: GarminRegion | null = null; let lastError: unknown = null; - for (const candidate of GARMIN_REGIONS) { - try { - token = await exchangeGarminKey(apiKey, candidate); - region = candidate; - break; - } catch (err) { - lastError = err; - // Only a refused key is worth asking the other service about; an - // outage or a rate limit would answer the same there. - if (!(err instanceof GarminApiError && err.keyRefused)) break; - } + try { + token = await exchangeGarminKey(apiKey, region); + } catch (err) { + lastError = err; } - if (!token || !region) { + if (!token) { annotate({ meta: { "garmin.connect.refused": 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 index beafd8bb5..a46befc98 100644 --- a/src/app/api/garmin/status/route.ts +++ b/src/app/api/garmin/status/route.ts @@ -11,6 +11,7 @@ import { import { resolveGarminKeyExpiry } from "@/lib/garmin/key-expiry"; import { garminBackfillShare, + garminUnavailableResources, readGarminBackfillProgress, } from "@/lib/garmin/backfill-progress"; import { isGarminRegion } from "@/lib/garmin/client"; @@ -43,6 +44,7 @@ export const GET = apiHandler(async () => { } const status = await getIntegrationStatus(user.id, "garmin"); + const progress = readGarminBackfillProgress(dbUser.garminBackfillProgress); return apiSuccess({ connected: true, configured: true, @@ -54,10 +56,9 @@ export const GET = apiHandler(async () => { complete: dbUser.garminBackfillCompletedAt !== null, share: dbUser.garminBackfillCompletedAt ? 1 - : garminBackfillShare( - readGarminBackfillProgress(dbUser.garminBackfillProgress), - ), + : garminBackfillShare(progress), }, + unavailableResources: garminUnavailableResources(progress), state: status.state, lastSuccessAt: status.lastSuccessAt, lastAttemptAt: status.lastAttemptAt, diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index af1b9da75..e297b2e2e 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -22,7 +22,9 @@ import { } 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"; @@ -539,6 +541,7 @@ export const GET = apiHandler(async () => { keyExpiry: garmin.keyExpiry, backfillCompleted: garmin.backfillCompleted, backfillShare: garmin.backfillShare, + unavailableResources: garmin.unavailableResources, health: health["garmin"], ...resolve("garmin", garminStatus, { connected: garmin.connected, @@ -659,6 +662,7 @@ interface GarminExtras { keyExpiry: GarminKeyExpiry | null; backfillCompleted: boolean | null; backfillShare: number | null; + unavailableResources: GarminBackfillStep[] | null; } /** @@ -684,9 +688,11 @@ function garminExtras( keyExpiry: null, backfillCompleted: null, backfillShare: null, + unavailableResources: null, }; } const complete = dbUser.garminBackfillCompletedAt !== null; + const progress = readGarminBackfillProgress(dbUser.garminBackfillProgress); return { connected: true, configured: true, @@ -695,11 +701,8 @@ function garminExtras( : "global", keyExpiry: resolveGarminKeyExpiry(dbUser.garminKeyExpiresAt), backfillCompleted: complete, - backfillShare: complete - ? 1 - : garminBackfillShare( - readGarminBackfillProgress(dbUser.garminBackfillProgress), - ), + backfillShare: complete ? 1 : garminBackfillShare(progress), + unavailableResources: garminUnavailableResources(progress), }; } diff --git a/src/components/settings/integrations/__tests__/garmin-card.test.tsx b/src/components/settings/integrations/__tests__/garmin-card.test.tsx index c9beb65c5..b089ffe26 100644 --- a/src/components/settings/integrations/__tests__/garmin-card.test.tsx +++ b/src/components/settings/integrations/__tests__/garmin-card.test.tsx @@ -83,8 +83,13 @@ describe("GarminCard on the shared card", () => { expect(html).toContain( 'href="https://docs.healthlog.dev/guides/connect-garmin/"', ); - // 30, 90, 180 and 365 days, plus "I don't know". - expect(html.match(/