mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
This commit is contained in:
1 parent
73f16e5389
commit
83c06051fe
40 files changed
+2355
-306
No files matched your search
+492
-13
@@ -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
|
||||
|
||||
+29
-3
@@ -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",
|
||||
|
||||
+29
-3
@@ -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",
|
||||
|
||||
+29
-3
@@ -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",
|
||||
|
||||
+29
-3
@@ -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",
|
||||
|
||||
+29
-3
@@ -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",
|
||||
|
||||
+29
-3
@@ -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": "관리자",
|
||||
|
||||
+29
-3
@@ -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",
|
||||
|
||||
+29
-3
@@ -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",
|
||||
|
||||
+29
-3
@@ -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",
|
||||
|
||||
+29
-3
@@ -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": "Администрирование",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -114,6 +114,10 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
// 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",
|
||||
|
||||
@@ -70,6 +70,8 @@ const WRITE_SITES: Record<string, string> = {
|
||||
"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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/<option /g)).toHaveLength(5);
|
||||
// The service: Garmin Connect (global) first, China second.
|
||||
expect(html).toContain('id="garmin-region"');
|
||||
expect(html).toMatch(
|
||||
/<option value="global"[^>]*>Garmin Connect \(global\)<\/option><option value="china"/,
|
||||
);
|
||||
// Two services, then 30, 90, 180 and 365 days plus "I don't know".
|
||||
expect(html.match(/<option /g)).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("keeps the sibling menu and opens the form for a new key", () => {
|
||||
@@ -102,6 +107,7 @@ describe("GarminCard on the shared card", () => {
|
||||
"test",
|
||||
"view-data",
|
||||
"credentials",
|
||||
"validity",
|
||||
"setup-guide",
|
||||
"disconnect",
|
||||
]);
|
||||
@@ -163,3 +169,28 @@ describe("GarminCard on the shared card", () => {
|
||||
expect(details).toContain('data-testid="garmin-key-valid-until"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("GarminCard — resources Garmin does not provide", () => {
|
||||
it("lists them in the details and the copied diagnostics, never the body", () => {
|
||||
const html = render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
health: healthFixture(),
|
||||
keyExpiry: {
|
||||
state: "ok",
|
||||
expiresAt: "2027-01-30T00:00:00.000Z",
|
||||
daysLeft: 87,
|
||||
},
|
||||
unavailableResources: ["cycle"],
|
||||
});
|
||||
const details = html.slice(html.indexOf('data-slot="integration-details"'));
|
||||
expect(details).toContain('data-testid="garmin-unavailable"');
|
||||
expect(details).toContain(
|
||||
"Garmin does not provide this data for your account: menstrual cycle.",
|
||||
);
|
||||
expect(lastCardProps?.diagnosticsExtra).toContain(
|
||||
"Garmin does not provide this data for your account: menstrual cycle.",
|
||||
);
|
||||
expect(html).toMatch(/data-state="ok"/);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
CalendarClock,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Link2,
|
||||
@@ -26,11 +27,13 @@ import {
|
||||
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import type { GarminBackfillStep } from "@/lib/garmin/backfill-progress";
|
||||
import {
|
||||
GARMIN_KEY_VALIDITY_DAYS,
|
||||
type GarminKeyExpiry,
|
||||
@@ -58,10 +61,88 @@ import {
|
||||
} from "./setup-guide-link";
|
||||
import { failureStreakFor, type IntegrationStatusViewModel } from "./shared";
|
||||
|
||||
/** The name of each Garmin resource, for the unavailable note. */
|
||||
const GARMIN_RESOURCE_KEYS: Record<GarminBackfillStep, string> = {
|
||||
daily: "settings.garminResource.daily",
|
||||
activities: "settings.garminResource.activities",
|
||||
sleep: "settings.garminResource.sleep",
|
||||
hrv: "settings.garminResource.hrv",
|
||||
weight: "settings.garminResource.weight",
|
||||
cycle: "settings.garminResource.cycle",
|
||||
};
|
||||
|
||||
/** Where Garmin Connect creates and revokes Personal API keys. */
|
||||
export const GARMIN_KEY_SETTINGS_URL =
|
||||
"https://connect.garmin.com/app/settings/personalApiToken";
|
||||
|
||||
/** The connected card's menu, in the order every sibling uses. */
|
||||
function garminMenu(
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
h: {
|
||||
syncing: boolean;
|
||||
testing: boolean;
|
||||
onSync: () => void;
|
||||
onTest: () => void;
|
||||
onToggleKey: () => void;
|
||||
onToggleValidity: () => void;
|
||||
onDisconnect: () => void;
|
||||
},
|
||||
): IntegrationMenuAction[] {
|
||||
return [
|
||||
{
|
||||
id: "sync",
|
||||
label: t("settings.garminSync"),
|
||||
icon: RefreshCw,
|
||||
disabled: h.syncing,
|
||||
onSelect: h.onSync,
|
||||
},
|
||||
{
|
||||
id: "test",
|
||||
label: t("settings.testConnection.test"),
|
||||
icon: Plug,
|
||||
disabled: h.testing,
|
||||
onSelect: h.onTest,
|
||||
},
|
||||
{
|
||||
id: "view-data",
|
||||
label: t("settings.garminViewData"),
|
||||
icon: ArrowRight,
|
||||
href: "/insights/recovery",
|
||||
},
|
||||
{
|
||||
id: "credentials",
|
||||
label: t("settings.garminReplaceKey"),
|
||||
icon: KeyRound,
|
||||
onSelect: h.onToggleKey,
|
||||
},
|
||||
{
|
||||
id: "validity",
|
||||
label: t("settings.garminKeyValidityMenu"),
|
||||
icon: CalendarClock,
|
||||
onSelect: h.onToggleValidity,
|
||||
},
|
||||
{
|
||||
id: "setup-guide",
|
||||
label: t("settings.integrationSetupGuide"),
|
||||
icon: BookOpen,
|
||||
href: integrationDocsHref("garmin"),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "disconnect",
|
||||
label: t("settings.garminDisconnect"),
|
||||
icon: Unlink,
|
||||
destructive: true,
|
||||
onSelect: h.onDisconnect,
|
||||
confirm: {
|
||||
title: t("settings.garminDisconnectTitle"),
|
||||
body: t("settings.garminDisconnectDescription"),
|
||||
confirmLabel: t("settings.garminDisconnect"),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function GarminCard({
|
||||
viewModel,
|
||||
}: {
|
||||
@@ -80,6 +161,7 @@ export function GarminCard({
|
||||
});
|
||||
const test = useConnectionTest("/api/garmin/test");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [validityEditing, setValidityEditing] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
const disconnect = useMutation({
|
||||
@@ -131,55 +213,26 @@ export function GarminCard({
|
||||
const keyExpiry = status?.keyExpiry ?? null;
|
||||
const expiryNote = garminKeyExpiryNote(keyExpiry, t, fmt.date);
|
||||
|
||||
const menu: IntegrationMenuAction[] = [
|
||||
{
|
||||
id: "sync",
|
||||
label: t("settings.garminSync"),
|
||||
icon: RefreshCw,
|
||||
disabled: sync.syncing,
|
||||
onSelect: () => sync.run(false),
|
||||
},
|
||||
{
|
||||
id: "test",
|
||||
label: t("settings.testConnection.test"),
|
||||
icon: Plug,
|
||||
disabled: test.testing,
|
||||
onSelect: test.run,
|
||||
},
|
||||
{
|
||||
id: "view-data",
|
||||
label: t("settings.garminViewData"),
|
||||
icon: ArrowRight,
|
||||
href: "/insights/recovery",
|
||||
},
|
||||
{
|
||||
id: "credentials",
|
||||
label: t("settings.garminReplaceKey"),
|
||||
icon: KeyRound,
|
||||
onSelect: () => setEditing((open) => !open),
|
||||
},
|
||||
{
|
||||
id: "setup-guide",
|
||||
label: t("settings.integrationSetupGuide"),
|
||||
icon: BookOpen,
|
||||
href: integrationDocsHref("garmin"),
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
id: "disconnect",
|
||||
label: t("settings.garminDisconnect"),
|
||||
icon: Unlink,
|
||||
destructive: true,
|
||||
onSelect: () => disconnect.mutate(),
|
||||
confirm: {
|
||||
title: t("settings.garminDisconnectTitle"),
|
||||
body: t("settings.garminDisconnectDescription"),
|
||||
confirmLabel: t("settings.garminDisconnect"),
|
||||
},
|
||||
},
|
||||
];
|
||||
const menu = garminMenu(t, {
|
||||
syncing: sync.syncing,
|
||||
testing: test.testing,
|
||||
onSync: () => sync.run(false),
|
||||
onTest: test.run,
|
||||
onToggleKey: () => setEditing((open) => !open),
|
||||
onToggleValidity: () => setValidityEditing((open) => !open),
|
||||
onDisconnect: () => disconnect.mutate(),
|
||||
});
|
||||
|
||||
const streak = failureStreakFor(status);
|
||||
// Resources Garmin refuses for this account (a cycle summary for someone
|
||||
// who tracks none): listed in the details, never a failure of the card.
|
||||
const unavailable = status?.unavailableResources ?? [];
|
||||
const unavailableNote =
|
||||
unavailable.length > 0
|
||||
? t("settings.garminUnavailable", {
|
||||
list: unavailable.map((r) => t(GARMIN_RESOURCE_KEYS[r])).join(", "),
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<IntegrationCard
|
||||
@@ -192,16 +245,17 @@ export function GarminCard({
|
||||
historyLoading={status?.backfillCompleted === false}
|
||||
primary={{ onReconnect: openForm, onResume: openForm }}
|
||||
menu={menu}
|
||||
diagnosticsExtra={
|
||||
streak
|
||||
diagnosticsExtra={[
|
||||
...(streak
|
||||
? [
|
||||
t("settings.integrationCard.details.failureStreak", {
|
||||
count: streak.count,
|
||||
threshold: streak.threshold,
|
||||
}),
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
: []),
|
||||
...(unavailableNote ? [unavailableNote] : []),
|
||||
]}
|
||||
details={
|
||||
<>
|
||||
{keyExpiry?.state === "ok" && keyExpiry.expiresAt ? (
|
||||
@@ -214,6 +268,14 @@ export function GarminCard({
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{unavailableNote ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="garmin-unavailable"
|
||||
>
|
||||
{unavailableNote}
|
||||
</p>
|
||||
) : null}
|
||||
{status?.metricFreshness?.length ? (
|
||||
<MetricFreshnessList entries={status.metricFreshness} />
|
||||
) : null}
|
||||
@@ -234,6 +296,7 @@ export function GarminCard({
|
||||
) : null}
|
||||
<ActionResultLines provider="garmin" sync={sync} test={test} />
|
||||
{editing ? form : null}
|
||||
{validityEditing ? <GarminValidityForm /> : null}
|
||||
</IntegrationCard>
|
||||
);
|
||||
}
|
||||
@@ -267,6 +330,71 @@ export function garminKeyExpiryNote(
|
||||
}
|
||||
}
|
||||
|
||||
/** Error codes the Garmin routes answer, with the sentence each one gets. */
|
||||
const GARMIN_ERROR_KEYS: Record<string, string> = {
|
||||
"garmin.key_refused": "settings.garminErrors.keyRefused",
|
||||
"garmin.rate_limited": "settings.garminErrors.rateLimited",
|
||||
"garmin.unreachable": "settings.garminErrors.unreachable",
|
||||
};
|
||||
|
||||
/** The sentence for a refused request, from its `meta.errorCode`. */
|
||||
async function garminErrorKey(res: Response): Promise<string> {
|
||||
try {
|
||||
const json = (await res.json()) as { meta?: { errorCode?: unknown } };
|
||||
const code = json?.meta?.errorCode;
|
||||
if (typeof code === "string" && GARMIN_ERROR_KEYS[code]) {
|
||||
return GARMIN_ERROR_KEYS[code];
|
||||
}
|
||||
} catch {
|
||||
// keep the generic sentence
|
||||
}
|
||||
return res.status === 422
|
||||
? "settings.garminErrors.invalid"
|
||||
: "settings.garminErrors.generic";
|
||||
}
|
||||
|
||||
function ValiditySelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">{t("settings.garminKeyValidityUnknown")}</option>
|
||||
{GARMIN_KEY_VALIDITY_DAYS.map((days) => (
|
||||
<option key={days} value={String(days)}>
|
||||
{t("settings.garminKeyValidityDays", { count: days })}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({
|
||||
msg,
|
||||
}: {
|
||||
msg: { kind: "success" | "error"; text: string } | null;
|
||||
}) {
|
||||
if (!msg) return null;
|
||||
return (
|
||||
<p
|
||||
role={msg.kind === "error" ? "alert" : "status"}
|
||||
className={`text-sm ${msg.kind === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{msg.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function GarminForm({
|
||||
formRef,
|
||||
connected,
|
||||
@@ -277,6 +405,7 @@ function GarminForm({
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [region, setRegion] = useState<"global" | "china">("global");
|
||||
const [validity, setValidity] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{
|
||||
@@ -294,6 +423,7 @@ function GarminForm({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
apiKey: apiKey.trim(),
|
||||
region,
|
||||
validityDays: validity ? Number(validity) : null,
|
||||
}),
|
||||
});
|
||||
@@ -305,14 +435,7 @@ function GarminForm({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
let text = t("settings.garminConnectFailed");
|
||||
try {
|
||||
const json = await res.json();
|
||||
if (json.error) text = json.error;
|
||||
} catch {
|
||||
// keep the generic message
|
||||
}
|
||||
setMsg({ kind: "error", text });
|
||||
setMsg({ kind: "error", text: t(await garminErrorKey(res)) });
|
||||
}
|
||||
} catch {
|
||||
setMsg({ kind: "error", text: t("common.networkError") });
|
||||
@@ -358,34 +481,33 @@ function GarminForm({
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="garmin-region">{t("settings.garminRegion")}</Label>
|
||||
<NativeSelect
|
||||
id="garmin-region"
|
||||
value={region}
|
||||
onChange={(e) =>
|
||||
setRegion(e.target.value === "china" ? "china" : "global")
|
||||
}
|
||||
>
|
||||
<option value="global">{t("settings.garminRegionGlobal")}</option>
|
||||
<option value="china">{t("settings.garminRegionChina")}</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="garmin-key-validity">
|
||||
{t("settings.garminKeyValidity")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
<ValiditySelect
|
||||
id="garmin-key-validity"
|
||||
value={validity}
|
||||
onChange={(e) => setValidity(e.target.value)}
|
||||
>
|
||||
<option value="">{t("settings.garminKeyValidityUnknown")}</option>
|
||||
{GARMIN_KEY_VALIDITY_DAYS.map((days) => (
|
||||
<option key={days} value={String(days)}>
|
||||
{t("settings.garminKeyValidityDays", { count: days })}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
onChange={setValidity}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.garminKeyValidityHelp")}
|
||||
</p>
|
||||
</div>
|
||||
{msg && (
|
||||
<p
|
||||
role={msg.kind === "error" ? "alert" : "status"}
|
||||
className={`text-sm ${msg.kind === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
<FormMessage msg={msg} />
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -410,3 +532,97 @@ function GarminForm({
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the stored key's validity without pasting the key again: the
|
||||
* validity picked in Garmin Connect and the day the key was created there.
|
||||
*/
|
||||
function GarminValidityForm() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [validity, setValidity] = useState("");
|
||||
const [createdOn, setCreatedOn] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{
|
||||
kind: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/garmin/key-expiry", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
validityDays: validity ? Number(validity) : null,
|
||||
...(createdOn ? { createdOn } : {}),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMsg({ kind: "success", text: t("settings.garminValiditySaved") });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
setMsg({ kind: "error", text: t(await garminErrorKey(res)) });
|
||||
}
|
||||
} catch {
|
||||
setMsg({ kind: "error", text: t("common.networkError") });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSave}
|
||||
className="space-y-3"
|
||||
data-testid="garmin-validity-form"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="garmin-validity-days">
|
||||
{t("settings.garminKeyValidity")}
|
||||
</Label>
|
||||
<ValiditySelect
|
||||
id="garmin-validity-days"
|
||||
value={validity}
|
||||
onChange={setValidity}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="garmin-key-created-on">
|
||||
{t("settings.garminKeyCreatedOn")}
|
||||
</Label>
|
||||
<Input
|
||||
id="garmin-key-created-on"
|
||||
type="date"
|
||||
value={createdOn}
|
||||
onChange={(e) => setCreatedOn(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.garminKeyCreatedOnHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<FormMessage msg={msg} />
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
disabled={saving}
|
||||
data-testid="garmin-validity-save"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t("settings.garminUpdate")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { PublishedFitbitRetirement } from "@/lib/fitbit/retirement";
|
||||
import type { GarminKeyExpiry } from "@/lib/garmin/key-expiry";
|
||||
import type { GarminBackfillStep } from "@/lib/garmin/backfill-progress";
|
||||
|
||||
export type IntegrationKey =
|
||||
| "withings"
|
||||
@@ -75,6 +76,8 @@ export interface IntegrationStatusViewModel {
|
||||
hasToken?: boolean;
|
||||
/** Garmin: when the stored key stops working, resolved on the server. */
|
||||
keyExpiry?: GarminKeyExpiry | null;
|
||||
/** Garmin: resources Garmin refuses for this account, for the details. */
|
||||
unavailableResources?: GarminBackfillStep[] | null;
|
||||
/** Per-metric last-seen timestamps with the server-computed stale flag. */
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
/** Fitbit: the end of the legacy Web API, resolved on the server. */
|
||||
|
||||
@@ -129,6 +129,10 @@ const METRIC_LABEL_KEYS: Record<SourcePriorityMetricKey, string> = {
|
||||
stress: "settings.sections.sources.metrics.stress",
|
||||
// v1.25.0 — native-vs-computed sleep-debt ladder.
|
||||
sleepDebt: "settings.sections.sources.metrics.sleepDebt",
|
||||
bodyBattery: "settings.sections.sources.metrics.bodyBattery",
|
||||
stressLevel: "settings.sections.sources.metrics.stressLevel",
|
||||
trainingReadiness: "settings.sections.sources.metrics.trainingReadiness",
|
||||
acuteTrainingLoad: "settings.sections.sources.metrics.acuteTrainingLoad",
|
||||
};
|
||||
|
||||
const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
|
||||
|
||||
@@ -52,6 +52,11 @@ export const RANKED_TYPES: readonly MeasurementType[] = [
|
||||
// so the SQL collapse ranks a future device-native producer above the
|
||||
// COMPUTED proxy deterministically. No-op until a second producer lands.
|
||||
"STRESS_SCORE",
|
||||
// Garmin's day scores and the bridged training figures.
|
||||
"BODY_BATTERY",
|
||||
"STRESS_LEVEL",
|
||||
"TRAINING_READINESS",
|
||||
"ACUTE_TRAINING_LOAD",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
|
||||
@@ -16,11 +16,15 @@ import {
|
||||
const NOW = Date.parse("2026-11-04T08:00:00Z");
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
/** A made-up unsigned JWT with the given `exp` (epoch seconds). */
|
||||
function jwt(expSeconds: number, nonce = "a"): string {
|
||||
/** A made-up unsigned JWT with the given `exp` (and `iat`), epoch seconds. */
|
||||
function jwt(expSeconds: number, nonce = "a", iatSeconds?: number): string {
|
||||
const enc = (o: object) =>
|
||||
Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||
return `${enc({ alg: "none" })}.${enc({ exp: expSeconds, n: nonce })}.sig`;
|
||||
const claims =
|
||||
iatSeconds === undefined
|
||||
? { exp: expSeconds, n: nonce }
|
||||
: { exp: expSeconds, iat: iatSeconds, n: nonce };
|
||||
return `${enc({ alg: "none" })}.${enc(claims)}.sig`;
|
||||
}
|
||||
|
||||
interface Call {
|
||||
@@ -106,6 +110,29 @@ describe("exchangeGarminKey", () => {
|
||||
expect(token.keyExpiresAt).toBe(exp * 1000);
|
||||
});
|
||||
|
||||
it("reads the token's lifetime from its own claims, not the local clock", async () => {
|
||||
// A regular day-long token, read by a clock 20 hours ahead of Garmin's:
|
||||
// exp minus now says four hours, exp minus iat says a full day.
|
||||
const iat = Math.floor((NOW - 20 * HOUR) / 1000);
|
||||
const exp = iat + 24 * 3600;
|
||||
const g = fakeGarmin({ token: [tokenResponse(jwt(exp, "a", iat))] });
|
||||
const token = await exchangeGarminKey("k".repeat(20), "global", {
|
||||
fetch: g.fetch,
|
||||
now: () => NOW,
|
||||
});
|
||||
expect(token.keyExpiresAt).toBeNull();
|
||||
// A token Garmin cut short at the key's end, with its own iat.
|
||||
const iat2 = Math.floor(NOW / 1000);
|
||||
const g2 = fakeGarmin({
|
||||
token: [tokenResponse(jwt(iat2 + 3 * 3600, "b", iat2))],
|
||||
});
|
||||
const capped = await exchangeGarminKey("k".repeat(20), "global", {
|
||||
fetch: g2.fetch,
|
||||
now: () => NOW,
|
||||
});
|
||||
expect(capped.keyExpiresAt).toBe((iat2 + 3 * 3600) * 1000);
|
||||
});
|
||||
|
||||
it.each([401, 403, 404])(
|
||||
"treats %s at the exchange as a refused key",
|
||||
async (status) => {
|
||||
@@ -215,6 +242,7 @@ describe("GarminSession", () => {
|
||||
const s = session(g.fetch);
|
||||
expect(await s.get("x", "/p")).toEqual({ ok: true });
|
||||
expect(s.exchanges).toBe(2);
|
||||
expect(s.proven).toBe(true);
|
||||
expect(g.calls.at(-1)!.auth).toBe(`Bearer ${jwt(exp, "b")}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -175,7 +175,43 @@ describe("mapGarminHrv", () => {
|
||||
});
|
||||
|
||||
describe("mapGarminWeightRange", () => {
|
||||
it("converts grams to kilograms and body water from percent", () => {
|
||||
it("reads the documented shape: kilograms, no samplePk, a string instant", () => {
|
||||
const rows = mapGarminWeightRange({
|
||||
dailyWeightSummaries: [
|
||||
{
|
||||
summaryDate: "2026-10-20",
|
||||
allWeightMetrics: [
|
||||
{
|
||||
calendarDate: "2026-10-20",
|
||||
timestampGMT: "2026-10-20T06:10:00.000Z",
|
||||
weight: 80,
|
||||
bmi: 24.7,
|
||||
bodyFat: 18.5,
|
||||
bodyWater: 55,
|
||||
muscleMass: 36,
|
||||
boneMass: 3,
|
||||
visceralFat: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(byType(rows)).toEqual({
|
||||
WEIGHT: 80,
|
||||
BODY_MASS_INDEX: 24.7,
|
||||
BODY_FAT: 18.5,
|
||||
TOTAL_BODY_WATER: 44,
|
||||
MUSCLE_MASS: 36,
|
||||
BONE_MASS: 3,
|
||||
VISCERAL_FAT: 7,
|
||||
});
|
||||
const at = Date.parse("2026-10-20T06:10:00.000Z");
|
||||
expect(rows.every((r) => r.externalId.startsWith(`weight:t${at}:`))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads the live shape: grams and a samplePk", () => {
|
||||
const rows = mapGarminWeightRange({
|
||||
dailyWeightSummaries: [
|
||||
{
|
||||
@@ -185,48 +221,61 @@ describe("mapGarminWeightRange", () => {
|
||||
samplePk: 11,
|
||||
timestampGMT: 1_792_474_200_000,
|
||||
weight: 80_000,
|
||||
bmi: 24.7,
|
||||
bodyFat: 18.5,
|
||||
bodyWater: 55,
|
||||
muscleMass: 36_400,
|
||||
boneMass: 3_200,
|
||||
visceralFat: 7,
|
||||
sourceType: "INDEX_SCALE",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
summaryDate: "2026-10-21",
|
||||
latestWeight: {
|
||||
samplePk: 12,
|
||||
date: 1_792_560_000_000,
|
||||
weight: 79_600,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const first = rows.filter((r) => r.externalId.startsWith("weight:11:"));
|
||||
expect(byType(first)).toEqual({
|
||||
expect(byType(rows)).toEqual({
|
||||
WEIGHT: 80,
|
||||
BODY_MASS_INDEX: 24.7,
|
||||
BODY_FAT: 18.5,
|
||||
TOTAL_BODY_WATER: 44,
|
||||
MUSCLE_MASS: 36.4,
|
||||
BONE_MASS: 3.2,
|
||||
VISCERAL_FAT: 7,
|
||||
});
|
||||
expect(first.every((r) => r.deviceType === "scale")).toBe(true);
|
||||
const second = rows.filter((r) => r.externalId.startsWith("weight:12:"));
|
||||
expect(byType(second)).toEqual({ WEIGHT: 79.6 });
|
||||
expect(second[0]!.deviceType).toBeUndefined();
|
||||
expect(rows.every((r) => r.externalId.startsWith("weight:11:"))).toBe(true);
|
||||
expect(rows.every((r) => r.deviceType === "scale")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not write a weigh-in twice when it is both latest and listed", () => {
|
||||
it("writes a weigh-in whole or not at all", () => {
|
||||
const base = { timestampGMT: "2026-10-20T06:10:00.000Z" };
|
||||
// No weight: nothing, not a BMI on its own.
|
||||
expect(
|
||||
mapGarminWeightRange({
|
||||
dailyWeightSummaries: [{ allWeightMetrics: [{ ...base, bmi: 24 }] }],
|
||||
}),
|
||||
).toEqual([]);
|
||||
// One implausible value takes the whole weigh-in with it.
|
||||
expect(
|
||||
mapGarminWeightRange({
|
||||
dailyWeightSummaries: [
|
||||
{ allWeightMetrics: [{ ...base, weight: 80, bodyFat: 95 }] },
|
||||
],
|
||||
}),
|
||||
).toEqual([]);
|
||||
// No instant: nothing.
|
||||
expect(
|
||||
mapGarminWeightRange({
|
||||
dailyWeightSummaries: [{ allWeightMetrics: [{ weight: 80 }] }],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to the day's latest weigh-in and writes it once", () => {
|
||||
const w = { samplePk: 5, date: 1_792_560_000_000, weight: 70_000 };
|
||||
const rows = mapGarminWeightRange({
|
||||
dailyWeightSummaries: [{ latestWeight: w, allWeightMetrics: [w] }],
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(
|
||||
mapGarminWeightRange({
|
||||
dailyWeightSummaries: [{ latestWeight: w, allWeightMetrics: [w] }],
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
byType(
|
||||
mapGarminWeightRange({
|
||||
dailyWeightSummaries: [{ latestWeight: { ...w, samplePk: 6 } }],
|
||||
}),
|
||||
),
|
||||
).toEqual({ WEIGHT: 70 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { GarminApiError } from "../client";
|
||||
import { garminUnavailableCode } from "../unavailable";
|
||||
|
||||
function err(
|
||||
httpStatus: number | undefined,
|
||||
opts: { keyRefused?: boolean } = {},
|
||||
) {
|
||||
return new GarminApiError({
|
||||
verb: "x",
|
||||
classification: httpStatus === 401 ? "reauth_required" : "persistent",
|
||||
httpStatus,
|
||||
reason: httpStatus ? `http_${httpStatus}` : "network",
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
describe("garminUnavailableCode", () => {
|
||||
it("takes a lasting 4xx on a resource as unavailable for the account", () => {
|
||||
expect(garminUnavailableCode("sleep", err(404))).toBe("http_404");
|
||||
expect(garminUnavailableCode("weight", err(403))).toBe("http_403");
|
||||
expect(garminUnavailableCode("hrv", err(400))).toBe("http_400");
|
||||
});
|
||||
|
||||
it("leaves outages, the allowance and the key to the ordinary paths", () => {
|
||||
expect(garminUnavailableCode("sleep", err(500))).toBeNull();
|
||||
expect(garminUnavailableCode("sleep", err(429))).toBeNull();
|
||||
expect(
|
||||
garminUnavailableCode("sleep", err(401, { keyRefused: true })),
|
||||
).toBeNull();
|
||||
expect(garminUnavailableCode("sleep", new Error("socket"))).toBeNull();
|
||||
});
|
||||
|
||||
it("never lets the cycle leg park the connection", () => {
|
||||
expect(garminUnavailableCode("cycle", err(401, { keyRefused: true }))).toBe(
|
||||
"key_refused",
|
||||
);
|
||||
expect(garminUnavailableCode("cycle", err(500))).toBe("http_500");
|
||||
expect(garminUnavailableCode("cycle", new Error("socket"))).toBe("error");
|
||||
// The allowance is still the allowance.
|
||||
expect(garminUnavailableCode("cycle", err(429))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,13 @@ export interface GarminBackfillProgress {
|
||||
oldestSeen?: string;
|
||||
/** Learned once both probes are done; null when they found nothing. */
|
||||
historyStart?: string | null;
|
||||
/**
|
||||
* Resources Garmin refuses for this account with a lasting 4xx (a cycle
|
||||
* summary for someone who tracks no cycle, say), by HTTP code. A refused
|
||||
* resource is done for the backfill and skipped by the hourly sync; it
|
||||
* does not turn the connection red. A new key clears the note.
|
||||
*/
|
||||
unavailable?: Partial<Record<GarminBackfillStep, string>>;
|
||||
steps: Partial<Record<GarminBackfillStep, GarminBackfillStepState>>;
|
||||
}
|
||||
|
||||
@@ -80,6 +87,15 @@ export function readGarminBackfillProgress(
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const unavailable: Partial<Record<GarminBackfillStep, string>> = {};
|
||||
if (raw.unavailable && typeof raw.unavailable === "object") {
|
||||
for (const name of GARMIN_BACKFILL_STEPS) {
|
||||
const code = (raw.unavailable as Record<string, unknown>)[name];
|
||||
if (typeof code === "string" && /^[a-z0-9_]{1,32}$/.test(code)) {
|
||||
unavailable[name] = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
const oldestSeen = day.safeParse(raw.oldestSeen);
|
||||
const historyStart =
|
||||
raw.historyStart === null ? null : day.safeParse(raw.historyStart);
|
||||
@@ -91,6 +107,7 @@ export function readGarminBackfillProgress(
|
||||
: historyStart.success
|
||||
? { historyStart: historyStart.data }
|
||||
: {}),
|
||||
...(Object.keys(unavailable).length > 0 ? { unavailable } : {}),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
@@ -102,6 +119,9 @@ export function garminBackfillProgressJson(progress: GarminBackfillProgress) {
|
||||
...(progress.historyStart !== undefined
|
||||
? { historyStart: progress.historyStart }
|
||||
: {}),
|
||||
...(progress.unavailable && Object.keys(progress.unavailable).length > 0
|
||||
? { unavailable: { ...progress.unavailable } }
|
||||
: {}),
|
||||
steps: Object.fromEntries(
|
||||
Object.entries(progress.steps).map(([name, s]) => [
|
||||
name,
|
||||
@@ -115,6 +135,13 @@ export function garminBackfillProgressJson(progress: GarminBackfillProgress) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The resources Garmin refuses for this account, sorted, for the card. */
|
||||
export function garminUnavailableResources(
|
||||
progress: GarminBackfillProgress | null,
|
||||
): GarminBackfillStep[] {
|
||||
return GARMIN_BACKFILL_STEPS.filter((s) => progress?.unavailable?.[s]);
|
||||
}
|
||||
|
||||
/** Share of the walk done, 0..1, for the card's progress line. */
|
||||
export function garminBackfillShare(
|
||||
progress: GarminBackfillProgress | null,
|
||||
|
||||
+29
-10
@@ -131,22 +131,30 @@ export function extractGarminToken(body: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** A JWT's `exp` claim in epoch ms, read without verifying the signature. */
|
||||
export function garminTokenExpiry(token: string): number | undefined {
|
||||
/** A JWT's time claims (`exp`, `iat`) in epoch ms, read without verifying. */
|
||||
export function garminTokenClaims(token: string): {
|
||||
exp: number | undefined;
|
||||
iat: number | undefined;
|
||||
} {
|
||||
const payload = token.split(".")[1];
|
||||
if (!payload) return undefined;
|
||||
if (!payload) return { exp: undefined, iat: undefined };
|
||||
try {
|
||||
const claims = JSON.parse(
|
||||
Buffer.from(payload, "base64url").toString("utf8"),
|
||||
) as { exp?: unknown };
|
||||
return typeof claims.exp === "number" && Number.isFinite(claims.exp)
|
||||
? claims.exp * 1000
|
||||
: undefined;
|
||||
) as { exp?: unknown; iat?: unknown };
|
||||
const ms = (v: unknown) =>
|
||||
typeof v === "number" && Number.isFinite(v) ? v * 1000 : undefined;
|
||||
return { exp: ms(claims.exp), iat: ms(claims.iat) };
|
||||
} catch {
|
||||
return undefined;
|
||||
return { exp: undefined, iat: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
/** A JWT's `exp` claim in epoch ms, read without verifying the signature. */
|
||||
export function garminTokenExpiry(token: string): number | undefined {
|
||||
return garminTokenClaims(token).exp;
|
||||
}
|
||||
|
||||
export interface GarminToken {
|
||||
value: string;
|
||||
/** Epoch ms. */
|
||||
@@ -231,14 +239,21 @@ export async function exchangeGarminKey(
|
||||
});
|
||||
}
|
||||
const issuedAt = now();
|
||||
const exp = garminTokenExpiry(value);
|
||||
const { exp, iat } = garminTokenClaims(value);
|
||||
// The token's own lifetime, from its issue claim when it carries one: the
|
||||
// local clock may be off by minutes, the token's two claims are not.
|
||||
const lifetime = exp === undefined ? undefined : exp - (iat ?? issuedAt);
|
||||
const expiresAt = exp ?? issuedAt + FALLBACK_LIFETIME_MS;
|
||||
return {
|
||||
value,
|
||||
expiresAt,
|
||||
refreshAt: refreshTime(issuedAt, expiresAt),
|
||||
keyExpiresAt:
|
||||
exp !== undefined && exp - issuedAt < KEY_CAPPED_BELOW_MS ? exp : null,
|
||||
exp !== undefined &&
|
||||
lifetime !== undefined &&
|
||||
lifetime < KEY_CAPPED_BELOW_MS
|
||||
? exp
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,6 +295,8 @@ export class GarminSession {
|
||||
learnedKeyExpiresAt: number | null = null;
|
||||
/** Number of key exchanges this session made (for the record and tests). */
|
||||
exchanges = 0;
|
||||
/** Garmin accepted the key in this session: a token bought or a read answered. */
|
||||
proven = false;
|
||||
|
||||
constructor(opts: {
|
||||
userId: string;
|
||||
@@ -315,6 +332,7 @@ export class GarminSession {
|
||||
this.transport,
|
||||
);
|
||||
this.exchanges += 1;
|
||||
this.proven = true;
|
||||
if (fresh.keyExpiresAt !== null) {
|
||||
this.learnedKeyExpiresAt = fresh.keyExpiresAt;
|
||||
}
|
||||
@@ -357,6 +375,7 @@ export class GarminSession {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (res.status >= 200 && res.status < 300) this.proven = true;
|
||||
if (res.status === 204) return null;
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
|
||||
@@ -40,19 +40,34 @@ export async function getGarminCredentials(
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the key's expiry a token revealed, when it differs from what is
|
||||
* stored. A learned expiry is Garmin's own figure and replaces the estimate
|
||||
* made from the validity picked at connect.
|
||||
* Keep the stored expiry honest after a run.
|
||||
*
|
||||
* - A token that revealed the key's end (`learned`) is Garmin's own figure
|
||||
* and replaces whatever is stored, the estimate from the picked validity
|
||||
* included.
|
||||
* - A key that just bought a token has not expired, whatever the estimate
|
||||
* says: an estimate already in the past is wrong (the key was created
|
||||
* later than assumed, or with a longer validity) and is cleared, so the
|
||||
* card stops calling a working key expired.
|
||||
*/
|
||||
export async function rememberGarminKeyExpiry(
|
||||
userId: string,
|
||||
learned: number | null,
|
||||
stored: Date | null,
|
||||
opts: { exchanged?: boolean; now?: Date } = {},
|
||||
): Promise<void> {
|
||||
if (learned === null) return;
|
||||
if (stored && Math.abs(stored.getTime() - learned) < 60_000) return;
|
||||
const now = opts.now ?? new Date();
|
||||
let next: Date | null | undefined;
|
||||
if (learned !== null) {
|
||||
if (!stored || Math.abs(stored.getTime() - learned) >= 60_000) {
|
||||
next = new Date(learned);
|
||||
}
|
||||
} else if (opts.exchanged && stored && stored.getTime() <= now.getTime()) {
|
||||
next = null;
|
||||
}
|
||||
if (next === undefined) return;
|
||||
await prisma.user.updateMany({
|
||||
where: { id: userId, garminApiKeyEncrypted: { not: null } },
|
||||
data: { garminKeyExpiresAt: new Date(learned) },
|
||||
data: { garminKeyExpiresAt: next },
|
||||
});
|
||||
}
|
||||
+75
-34
@@ -17,11 +17,14 @@
|
||||
* HRV `lastNightAvg` (RMSSD) → HRV_RMSSD; Garmin reports no SDNN
|
||||
* weigh-ins weight, BMI, body fat, body water, muscle, bone, visceral
|
||||
*
|
||||
* Masses are grams on the live service; body water is a percentage of the
|
||||
* weigh-in's weight and is converted to the kilograms the type stores.
|
||||
* Masses are kilograms in Garmin's document and grams on the live service,
|
||||
* so each is read by magnitude; body water is a percentage of the weigh-in's
|
||||
* weight and is converted to the kilograms the type stores.
|
||||
*/
|
||||
import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
|
||||
import { importValue } from "@/lib/integrations/import-value";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { validateMeasurementRange } from "@/lib/measurements/value-ranges";
|
||||
|
||||
import type {
|
||||
GarminDailySummary,
|
||||
@@ -261,28 +264,63 @@ export function mapGarminHrv(
|
||||
return out;
|
||||
}
|
||||
|
||||
const GRAMS_PER_KG = 1000;
|
||||
/**
|
||||
* Above this a mass is grams, at or below it kilograms. Garmin's document
|
||||
* says kilograms; the live service has been seen sending grams. No human
|
||||
* weighs a tonne, and no muscle or bone mass reaches 1000 kg, so one bound
|
||||
* decides each field on its own.
|
||||
*/
|
||||
const GRAMS_ABOVE = 1000;
|
||||
|
||||
/** One weigh-in → its body-composition rows. */
|
||||
export function mapGarminWeighIn(w: GarminWeighIn): GarminMappedMeasurement[] {
|
||||
const id =
|
||||
typeof w.samplePk === "number" || typeof w.samplePk === "string"
|
||||
? String(w.samplePk)
|
||||
: null;
|
||||
if (!id) return [];
|
||||
function kilograms(value: number | null): number | null {
|
||||
if (value === null || value <= 0) return null;
|
||||
return value > GRAMS_ABOVE ? value / 1000 : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The weigh-in's stable id: Garmin's `samplePk` when it sends one (the live
|
||||
* service does, the document does not), else the reading's own GMT instant,
|
||||
* which a re-read reports unchanged.
|
||||
*/
|
||||
function weighInId(w: GarminWeighIn, measuredAt: Date): string {
|
||||
if (typeof w.samplePk === "number" || typeof w.samplePk === "string") {
|
||||
const id = String(w.samplePk).trim();
|
||||
if (id !== "") return id;
|
||||
}
|
||||
return `t${measuredAt.getTime()}`;
|
||||
}
|
||||
|
||||
/** Why a weigh-in mapped to nothing. */
|
||||
export type GarminWeighInDrop = "no_instant" | "no_weight" | "implausible";
|
||||
|
||||
export interface GarminWeighInMapping {
|
||||
rows: GarminMappedMeasurement[];
|
||||
dropped: GarminWeighInDrop | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One weigh-in → its body-composition rows, all or nothing. A weigh-in whose
|
||||
* weight is missing, or any of whose values falls outside its type's band,
|
||||
* maps to no row at all: half a record (a BMI without the weight it was
|
||||
* computed from) reads as a complete one.
|
||||
*/
|
||||
export function mapGarminWeighIn(w: GarminWeighIn): GarminWeighInMapping {
|
||||
const measuredAt =
|
||||
parseGarminInstant(w.timestampGMT) ?? parseGarminInstant(w.date);
|
||||
if (!measuredAt) return [];
|
||||
if (!measuredAt) return { rows: [], dropped: "no_instant" };
|
||||
const kg = kilograms(num(w.weight));
|
||||
if (kg === null) return { rows: [], dropped: "no_weight" };
|
||||
const id = weighInId(w, measuredAt);
|
||||
// An Index scale reports through Garmin Connect; a typed entry is MANUAL.
|
||||
const deviceType =
|
||||
typeof w.sourceType === "string" &&
|
||||
w.sourceType.toUpperCase().includes("SCALE")
|
||||
? ("scale" as const)
|
||||
: undefined;
|
||||
const out: GarminMappedMeasurement[] = [];
|
||||
const rows: GarminMappedMeasurement[] = [];
|
||||
const push = (type: string, value: number | null, unit: string) => {
|
||||
if (value === null) return;
|
||||
out.push({
|
||||
rows.push({
|
||||
type,
|
||||
value,
|
||||
unit,
|
||||
@@ -291,9 +329,7 @@ export function mapGarminWeighIn(w: GarminWeighIn): GarminMappedMeasurement[] {
|
||||
...(deviceType ? { deviceType } : {}),
|
||||
});
|
||||
};
|
||||
const grams = num(w.weight);
|
||||
const kg = grams !== null && grams > 0 ? grams / GRAMS_PER_KG : null;
|
||||
push("WEIGHT", kg === null ? null : importValue(kg), "kg");
|
||||
push("WEIGHT", importValue(kg), "kg");
|
||||
const bmi = num(w.bmi);
|
||||
push(
|
||||
"BODY_MASS_INDEX",
|
||||
@@ -305,38 +341,35 @@ export function mapGarminWeighIn(w: GarminWeighIn): GarminMappedMeasurement[] {
|
||||
const water = num(w.bodyWater);
|
||||
push(
|
||||
"TOTAL_BODY_WATER",
|
||||
water !== null && water > 0 && kg !== null
|
||||
? importValue((kg * water) / 100)
|
||||
: null,
|
||||
"kg",
|
||||
);
|
||||
const muscle = num(w.muscleMass);
|
||||
push(
|
||||
"MUSCLE_MASS",
|
||||
muscle !== null && muscle > 0 ? importValue(muscle / GRAMS_PER_KG) : null,
|
||||
"kg",
|
||||
);
|
||||
const bone = num(w.boneMass);
|
||||
push(
|
||||
"BONE_MASS",
|
||||
bone !== null && bone > 0 ? importValue(bone / GRAMS_PER_KG) : null,
|
||||
water !== null && water > 0 ? importValue((kg * water) / 100) : null,
|
||||
"kg",
|
||||
);
|
||||
const muscle = kilograms(num(w.muscleMass));
|
||||
push("MUSCLE_MASS", muscle === null ? null : importValue(muscle), "kg");
|
||||
const bone = kilograms(num(w.boneMass));
|
||||
push("BONE_MASS", bone === null ? null : importValue(bone), "kg");
|
||||
const visceral = num(w.visceralFat);
|
||||
push(
|
||||
"VISCERAL_FAT",
|
||||
visceral !== null && visceral >= 0 ? importValue(visceral) : null,
|
||||
"rating",
|
||||
);
|
||||
return out;
|
||||
if (rows.some((r) => validateMeasurementRange(r.type, r.value) !== null)) {
|
||||
return { rows: [], dropped: "implausible" };
|
||||
}
|
||||
return { rows, dropped: null };
|
||||
}
|
||||
|
||||
/** Every weigh-in of a weight-range envelope, each once. */
|
||||
/**
|
||||
* Every weigh-in of a weight-range envelope, each once. A weigh-in that maps
|
||||
* to nothing is counted on the wide event by reason, never silently.
|
||||
*/
|
||||
export function mapGarminWeightRange(
|
||||
env: GarminWeightRangeEnvelope | null,
|
||||
): GarminMappedMeasurement[] {
|
||||
const seen = new Set<string>();
|
||||
const out: GarminMappedMeasurement[] = [];
|
||||
const dropped: Partial<Record<GarminWeighInDrop, number>> = {};
|
||||
for (const day of env?.dailyWeightSummaries ?? []) {
|
||||
const entries = day?.allWeightMetrics?.length
|
||||
? day.allWeightMetrics
|
||||
@@ -344,12 +377,20 @@ export function mapGarminWeightRange(
|
||||
? [day.latestWeight]
|
||||
: [];
|
||||
for (const w of entries) {
|
||||
for (const row of mapGarminWeighIn(w)) {
|
||||
const mapped = mapGarminWeighIn(w);
|
||||
if (mapped.dropped) {
|
||||
dropped[mapped.dropped] = (dropped[mapped.dropped] ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
for (const row of mapped.rows) {
|
||||
if (seen.has(row.externalId)) continue;
|
||||
seen.add(row.externalId);
|
||||
out.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(dropped).length > 0) {
|
||||
annotate({ meta: { "garmin.weight.dropped": dropped } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+181
-25
@@ -28,7 +28,9 @@ import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
getIntegrationStatus,
|
||||
isReauthRequired,
|
||||
markReconnected,
|
||||
markSyncFailureRecorded,
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
@@ -39,6 +41,7 @@ import { classifyIntegrationError } from "@/lib/integrations/http-status-classif
|
||||
import type { InsertedMeasurementArrivalRow } from "@/lib/arrivals/measurement-emit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write";
|
||||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||||
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import { walkPages } from "@/lib/integrations/page-walk";
|
||||
@@ -51,6 +54,8 @@ import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
import {
|
||||
chunkGarminRange,
|
||||
exchangeGarminKey,
|
||||
shiftGarminDay,
|
||||
GARMIN_ACTIVITY_PAGE,
|
||||
GARMIN_PATHS,
|
||||
GARMIN_RANGE_DAYS,
|
||||
@@ -58,10 +63,16 @@ import {
|
||||
GarminSession,
|
||||
isGarminKeyRefusal,
|
||||
isGarminRateLimit,
|
||||
type GarminRegion,
|
||||
type GarminTransport,
|
||||
} from "./client";
|
||||
import { getGarminCredentials, rememberGarminKeyExpiry } from "./credentials";
|
||||
import { syncGarminCycle } from "./cycle-sync";
|
||||
import {
|
||||
garminUnavailableCode,
|
||||
markGarminResourceUnavailable,
|
||||
readGarminUnavailable,
|
||||
} from "./unavailable";
|
||||
import {
|
||||
mapGarminDailySummary,
|
||||
mapGarminHrv,
|
||||
@@ -154,6 +165,11 @@ export interface GarminWindowRead {
|
||||
workouts: GarminWorkoutRow[];
|
||||
/** Oldest day the window returned real data for. */
|
||||
oldestDay?: string;
|
||||
/**
|
||||
* Sleep only: days of the window Garmin answered for without a night (the
|
||||
* night was deleted, or reduced to a nap). Their earlier rows are retired.
|
||||
*/
|
||||
sleepEmptyDays: string[];
|
||||
}
|
||||
|
||||
function minDay(
|
||||
@@ -164,6 +180,59 @@ function minDay(
|
||||
return a === undefined || b < a ? b : a;
|
||||
}
|
||||
|
||||
/** Every calendar day from `start` to `end`, both included. */
|
||||
export function garminDaysBetween(start: string, end: string): string[] {
|
||||
const days: string[] = [];
|
||||
for (let d = start; d <= end; d = shiftGarminDay(d, 1)) days.push(d);
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire what earlier syncs imported for nights Garmin no longer reports (a
|
||||
* night deleted in Garmin Connect, or reduced to a nap): every live GARMIN
|
||||
* row keyed under `sleep:<day>:`, whatever its type, because the stages and
|
||||
* the night's scalars all came from that night. Soft delete, then the shared
|
||||
* tail refolds the touched days. Best-effort: the day stays inside the
|
||||
* window, so the next run retries it.
|
||||
*/
|
||||
export async function retireGarminSleepDays(
|
||||
userId: string,
|
||||
days: readonly string[],
|
||||
): Promise<number> {
|
||||
const prefixes = days
|
||||
.filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d))
|
||||
.map((d) => `sleep:${d}:`);
|
||||
if (prefixes.length === 0) return 0;
|
||||
try {
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "GARMIN",
|
||||
deletedAt: null,
|
||||
OR: prefixes.map((prefix) => ({ externalId: { startsWith: prefix } })),
|
||||
},
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
if (rows.length === 0) return 0;
|
||||
const res = await prisma.measurement.updateMany({
|
||||
where: { userId, id: { in: rows.map((r) => r.id) }, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
await afterMeasurementMutation(userId, rows, {
|
||||
source: "sync",
|
||||
label: "garmin",
|
||||
});
|
||||
invalidateUserMeasurements(userId);
|
||||
annotate({ meta: { garmin_sleep_retired_rows: res.count } });
|
||||
return res.count;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`garmin: retiring removed nights failed for ${userId}: ${err}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function readActivities(
|
||||
session: GarminSession,
|
||||
window: { startDate: string; endDate: string },
|
||||
@@ -211,7 +280,11 @@ export async function readGarminWindow(
|
||||
window: { startDate: string; endDate: string },
|
||||
tz: string,
|
||||
): Promise<GarminWindowRead> {
|
||||
const out: GarminWindowRead = { measurements: [], workouts: [] };
|
||||
const out: GarminWindowRead = {
|
||||
measurements: [],
|
||||
workouts: [],
|
||||
sleepEmptyDays: [],
|
||||
};
|
||||
const chunks = chunkGarminRange(
|
||||
window.startDate,
|
||||
window.endDate,
|
||||
@@ -239,16 +312,25 @@ export async function readGarminWindow(
|
||||
GarminSleepSummary[] | GarminSleepSummary
|
||||
>("sleepSummaries", GARMIN_PATHS.sleepSummaries, chunk);
|
||||
const list = Array.isArray(nights) ? nights : nights ? [nights] : [];
|
||||
const nightDays = new Set<string>();
|
||||
for (const night of list) {
|
||||
const rows = mapGarminSleep(night, tz);
|
||||
if (rows.length > 0) {
|
||||
out.measurements.push(...rows);
|
||||
nightDays.add(night.calendarDate!);
|
||||
out.oldestDay = minDay(
|
||||
out.oldestDay,
|
||||
night.calendarDate ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Garmin answers one entry per day, a night or an empty placeholder.
|
||||
// An answer with no entries at all is not trusted to say anything.
|
||||
if (list.length > 0) {
|
||||
for (const day of garminDaysBetween(chunk.startDate, chunk.endDate)) {
|
||||
if (!nightDays.has(day)) out.sleepEmptyDays.push(day);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "hrv": {
|
||||
@@ -355,28 +437,49 @@ export async function upsertGarminMeasurements(
|
||||
return imported;
|
||||
}
|
||||
|
||||
/** Ceiling of a catch-up after an outage, from the last successful sync. */
|
||||
export const GARMIN_SYNC_CATCHUP_MAX_DAYS = 365;
|
||||
|
||||
/**
|
||||
* How far back this tick reaches: from the newest Garmin row held, plus a
|
||||
* day of overlap, never under the re-score floor nor over the ceiling. No
|
||||
* row at all (a fresh connection) takes the ceiling; the backfill takes the
|
||||
* rest.
|
||||
* How far back this tick reaches. Three bounds, the widest wins:
|
||||
*
|
||||
* - the re-score floor (`GARMIN_SYNC_LOOKBACK_DAYS`);
|
||||
* - the newest Garmin row held, plus a day of overlap, up to the 30-day
|
||||
* ceiling, for data a watch uploads days late while syncs still succeed;
|
||||
* - the last successful sync, plus a day, up to a year, so an outage of
|
||||
* any length short of that closes without a gap once the key works
|
||||
* again.
|
||||
*
|
||||
* A connection that never succeeded takes the 30-day ceiling; the backfill
|
||||
* reads the rest.
|
||||
*/
|
||||
export async function resolveGarminLookbackDays(
|
||||
userId: string,
|
||||
now: number = Date.now(),
|
||||
): Promise<number> {
|
||||
const newest = await prisma.measurement.findFirst({
|
||||
where: { userId, source: "GARMIN", deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
if (!newest) return GARMIN_SYNC_MAX_LOOKBACK_DAYS;
|
||||
const daysSince = Math.floor(
|
||||
(Date.now() - newest.measuredAt.getTime()) / DAY_MS,
|
||||
);
|
||||
return Math.min(
|
||||
GARMIN_SYNC_MAX_LOOKBACK_DAYS,
|
||||
Math.max(GARMIN_SYNC_LOOKBACK_DAYS, daysSince + 1),
|
||||
);
|
||||
const [newest, status] = await Promise.all([
|
||||
prisma.measurement.findFirst({
|
||||
where: { userId, source: "GARMIN", deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { measuredAt: true },
|
||||
}),
|
||||
getIntegrationStatus(userId, "garmin"),
|
||||
]);
|
||||
const since = (at: number) => Math.floor((now - at) / DAY_MS) + 1;
|
||||
if (!newest && !status.lastSuccessAt) return GARMIN_SYNC_MAX_LOOKBACK_DAYS;
|
||||
const fromData = newest
|
||||
? Math.min(
|
||||
GARMIN_SYNC_MAX_LOOKBACK_DAYS,
|
||||
since(newest.measuredAt.getTime()),
|
||||
)
|
||||
: GARMIN_SYNC_MAX_LOOKBACK_DAYS;
|
||||
const fromSuccess = status.lastSuccessAt
|
||||
? Math.min(
|
||||
GARMIN_SYNC_CATCHUP_MAX_DAYS,
|
||||
since(Date.parse(status.lastSuccessAt)),
|
||||
)
|
||||
: GARMIN_SYNC_MAX_LOOKBACK_DAYS;
|
||||
return Math.max(GARMIN_SYNC_LOOKBACK_DAYS, fromData, fromSuccess);
|
||||
}
|
||||
|
||||
export interface GarminSyncOptions {
|
||||
@@ -399,8 +502,8 @@ export async function syncUserGarmin(
|
||||
const creds = await getGarminCredentials(userId);
|
||||
if (!creds) return { imported: 0, failed: false };
|
||||
if (opts.skipWhenParked && (await isReauthRequired(userId, "garmin"))) {
|
||||
annotate({ action: { name: "garmin.sync.skipped_reauth" } });
|
||||
return { imported: 0, failed: false };
|
||||
const probed = await reprobeParkedGarminKey(userId, creds, opts.transport);
|
||||
if (!probed) return { imported: 0, failed: false };
|
||||
}
|
||||
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
@@ -417,14 +520,19 @@ export async function syncUserGarmin(
|
||||
region: creds.region,
|
||||
transport: opts.transport,
|
||||
});
|
||||
const unavailable = await readGarminUnavailable(userId);
|
||||
|
||||
let imported = 0;
|
||||
const failures: Array<{ resource: GarminResource; err: unknown }> = [];
|
||||
let insertedSleep: Date[] = [];
|
||||
|
||||
for (const resource of GARMIN_RESOURCES) {
|
||||
if (unavailable.has(resource)) continue;
|
||||
try {
|
||||
const read = await readGarminWindow(session, resource, window, tz);
|
||||
if (resource === "sleep") {
|
||||
await retireGarminSleepDays(userId, read.sleepEmptyDays);
|
||||
}
|
||||
imported += await upsertGarminMeasurements(userId, read.measurements, {
|
||||
onInserted: (rows) => {
|
||||
if (resource !== "sleep") return;
|
||||
@@ -440,6 +548,13 @@ export async function syncUserGarmin(
|
||||
await recordGarminSyncFailure(userId, err);
|
||||
throw markSyncFailureRecorded(err);
|
||||
}
|
||||
const code = garminUnavailableCode(resource, err);
|
||||
if (code) {
|
||||
// Garmin will not serve this resource to this account: noted once,
|
||||
// skipped from now on, and not a failure of the connection.
|
||||
await markGarminResourceUnavailable(userId, resource, code, today);
|
||||
continue;
|
||||
}
|
||||
failures.push({ resource, err });
|
||||
getEvent()?.addWarning(
|
||||
`garmin: ${resource} failed for ${userId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
@@ -449,14 +564,18 @@ export async function syncUserGarmin(
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort, off the ledger: most accounts track no cycle in Garmin.
|
||||
if (!failures.some((f) => isGarminRateLimit(f.err))) {
|
||||
// Best-effort and off the ledger, whatever happens: most accounts track no
|
||||
// cycle in Garmin, and a refusal here never parks the connection.
|
||||
if (
|
||||
!unavailable.has("cycle") &&
|
||||
!failures.some((f) => isGarminRateLimit(f.err))
|
||||
) {
|
||||
try {
|
||||
await syncGarminCycle(userId, session, today);
|
||||
} catch (err) {
|
||||
if (isGarminKeyRefusal(err)) {
|
||||
await recordGarminSyncFailure(userId, err);
|
||||
throw markSyncFailureRecorded(err);
|
||||
const code = garminUnavailableCode("cycle", err);
|
||||
if (code) {
|
||||
await markGarminResourceUnavailable(userId, "cycle", code, today);
|
||||
}
|
||||
getEvent()?.addWarning(
|
||||
`garmin: cycle import skipped for ${userId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
@@ -469,6 +588,7 @@ export async function syncUserGarmin(
|
||||
userId,
|
||||
session.learnedKeyExpiresAt,
|
||||
creds.keyExpiresAt,
|
||||
{ exchanged: session.proven },
|
||||
);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`garmin: key expiry note failed: ${err}`);
|
||||
@@ -483,6 +603,7 @@ export async function syncUserGarmin(
|
||||
imported,
|
||||
exchanges: session.exchanges,
|
||||
failures: failures.map((f) => f.resource),
|
||||
skipped: [...unavailable],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -507,3 +628,38 @@ export async function syncUserGarmin(
|
||||
await recordSyncSuccess(userId, "garmin");
|
||||
return { imported, failed: false };
|
||||
}
|
||||
|
||||
/** How long a parked key waits between two probes. */
|
||||
export const GARMIN_REPROBE_INTERVAL_MS = DAY_MS;
|
||||
|
||||
/**
|
||||
* A key parked on a refusal is asked about again once a day: one token
|
||||
* exchange, nothing else. A refusal can be a passing one (a firewall's 403,
|
||||
* a 401 while Garmin rotates something), and without a probe such a key
|
||||
* stayed parked until the person pasted it again. Accepted, the connection
|
||||
* is reconnected and the run goes on; refused, the refusal is recorded,
|
||||
* which also dates the next probe. Returns whether the run should go on.
|
||||
*/
|
||||
export async function reprobeParkedGarminKey(
|
||||
userId: string,
|
||||
creds: { apiKey: string; region: GarminRegion },
|
||||
transport?: GarminTransport,
|
||||
now: number = Date.now(),
|
||||
): Promise<boolean> {
|
||||
const status = await getIntegrationStatus(userId, "garmin");
|
||||
const last = status.lastAttemptAt ? Date.parse(status.lastAttemptAt) : 0;
|
||||
if (now - last < GARMIN_REPROBE_INTERVAL_MS) {
|
||||
annotate({ action: { name: "garmin.sync.skipped_reauth" } });
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await exchangeGarminKey(creds.apiKey, creds.region, transport);
|
||||
} catch (err) {
|
||||
await recordGarminSyncFailure(userId, err);
|
||||
annotate({ action: { name: "garmin.reprobe.refused" } });
|
||||
return false;
|
||||
}
|
||||
await markReconnected(userId, "garmin");
|
||||
annotate({ action: { name: "garmin.reprobe.accepted" } });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Resources Garmin refuses for one account for good.
|
||||
*
|
||||
* A 4xx other than 401 (the key) and 429 (the allowance) on one resource is
|
||||
* not an outage: the Personal API answers 403 or 404 for data an account does
|
||||
* not have (a cycle summary for someone who tracks no cycle) and will answer
|
||||
* the same tomorrow. Treated as a failure it kept the backfill from ever
|
||||
* completing and turned the connection red every hour. So such a refusal is
|
||||
* noted once on the progress column (`unavailable`), the resource counts as
|
||||
* done for the backfill and is skipped by the hourly sync, and the card lists
|
||||
* it in the details for a bug report. A new key clears the note.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import {
|
||||
GarminApiError,
|
||||
isGarminKeyRefusal,
|
||||
isGarminRateLimit,
|
||||
} from "./client";
|
||||
import {
|
||||
garminBackfillProgressJson,
|
||||
readGarminBackfillProgress,
|
||||
type GarminBackfillStep,
|
||||
} from "./backfill-progress";
|
||||
|
||||
/**
|
||||
* The code to note when `err` means Garmin will not serve `resource` to this
|
||||
* account; null for anything worth trying again. The cycle summary is
|
||||
* best-effort throughout: any refusal of it but the key's or the allowance's
|
||||
* counts.
|
||||
*/
|
||||
export function garminUnavailableCode(
|
||||
resource: GarminBackfillStep,
|
||||
err: unknown,
|
||||
): string | null {
|
||||
if (isGarminRateLimit(err)) return null;
|
||||
// The cycle leg never parks the connection: a refusal there, the key's
|
||||
// included, only takes the cycle out.
|
||||
if (isGarminKeyRefusal(err))
|
||||
return resource === "cycle" ? "key_refused" : null;
|
||||
if (err instanceof GarminApiError) {
|
||||
const status = err.httpStatus;
|
||||
if (status !== undefined && status >= 400 && status < 500) {
|
||||
return `http_${status}`;
|
||||
}
|
||||
if (resource === "cycle") return err.reason.slice(0, 32);
|
||||
return null;
|
||||
}
|
||||
return resource === "cycle" ? "error" : null;
|
||||
}
|
||||
|
||||
/** Note a refused resource on the progress column, once. */
|
||||
export async function markGarminResourceUnavailable(
|
||||
userId: string,
|
||||
resource: GarminBackfillStep,
|
||||
code: string,
|
||||
anchor: string,
|
||||
): Promise<void> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { garminBackfillProgress: true, garminConnectedAt: true },
|
||||
});
|
||||
if (!user) return;
|
||||
const progress = readGarminBackfillProgress(user.garminBackfillProgress) ?? {
|
||||
anchor,
|
||||
steps: {},
|
||||
};
|
||||
if (progress.unavailable?.[resource] === code) return;
|
||||
progress.unavailable = { ...progress.unavailable, [resource]: code };
|
||||
await prisma.user.updateMany({
|
||||
where: { id: userId, garminConnectedAt: user.garminConnectedAt },
|
||||
data: { garminBackfillProgress: garminBackfillProgressJson(progress) },
|
||||
});
|
||||
annotate({ meta: { "garmin.resource.unavailable": { resource, code } } });
|
||||
}
|
||||
|
||||
/** The resources already noted as refused for this account. */
|
||||
export async function readGarminUnavailable(
|
||||
userId: string,
|
||||
): Promise<ReadonlySet<GarminBackfillStep>> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { garminBackfillProgress: true },
|
||||
});
|
||||
const progress = readGarminBackfillProgress(user?.garminBackfillProgress);
|
||||
return new Set(
|
||||
Object.keys(progress?.unavailable ?? {}) as GarminBackfillStep[],
|
||||
);
|
||||
}
|
||||
@@ -46,9 +46,11 @@ import {
|
||||
readGarminCycleSummary,
|
||||
writeGarminPeriodDays,
|
||||
} from "@/lib/garmin/cycle-sync";
|
||||
import { garminUnavailableCode } from "@/lib/garmin/unavailable";
|
||||
import {
|
||||
GARMIN_RESOURCE_WINDOW_DAYS,
|
||||
readGarminWindow,
|
||||
retireGarminSleepDays,
|
||||
recordGarminSyncFailure,
|
||||
upsertGarminMeasurements,
|
||||
} from "@/lib/garmin/sync";
|
||||
@@ -123,7 +125,12 @@ function floorFor(
|
||||
step: GarminBackfillStep,
|
||||
progress: GarminBackfillProgress,
|
||||
): string | undefined {
|
||||
if (HISTORY_PROBES.includes(step)) return GARMIN_HISTORY_FLOOR;
|
||||
// Weigh-ins can predate the first watch (a scale-only account has no watch
|
||||
// at all), so the weight walk goes to the floor on its own and does not
|
||||
// wait for, or move, the learned history start.
|
||||
if (HISTORY_PROBES.includes(step) || step === "weight") {
|
||||
return GARMIN_HISTORY_FLOOR;
|
||||
}
|
||||
if (progress.historyStart === undefined) return undefined;
|
||||
if (progress.historyStart === null) return progress.anchor;
|
||||
const margin = shiftDateKey(progress.historyStart, -HISTORY_MARGIN_DAYS);
|
||||
@@ -137,18 +144,37 @@ function settleHistoryStart(progress: GarminBackfillProgress): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the progress note, only while the connection the slice started on is
|
||||
* still the stored one. A disconnect, or a new key, changes
|
||||
* `garminConnectedAt`; a slice still in flight then writes nothing, so it can
|
||||
* neither restore a cleared note nor stamp a new key's history as done.
|
||||
* Returns whether the write landed.
|
||||
*/
|
||||
async function saveProgress(
|
||||
userId: string,
|
||||
connectedAt: Date | null,
|
||||
progress: GarminBackfillProgress,
|
||||
): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { garminBackfillProgress: garminBackfillProgressJson(progress) },
|
||||
completedAt?: Date,
|
||||
): Promise<boolean> {
|
||||
const res = await prisma.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
garminApiKeyEncrypted: { not: null },
|
||||
garminConnectedAt: connectedAt,
|
||||
},
|
||||
data: {
|
||||
garminBackfillProgress: garminBackfillProgressJson(progress),
|
||||
...(completedAt ? { garminBackfillCompletedAt: completedAt } : {}),
|
||||
},
|
||||
});
|
||||
return res.count > 0;
|
||||
}
|
||||
|
||||
interface SliceContext {
|
||||
userId: string;
|
||||
/** The connection the slice started on (see `saveProgress`). */
|
||||
connectedAt: Date | null;
|
||||
tz: string;
|
||||
progress: GarminBackfillProgress;
|
||||
session: GarminSession;
|
||||
@@ -166,6 +192,8 @@ interface SliceWalk {
|
||||
retryAfterMs: number | undefined;
|
||||
failure: unknown;
|
||||
failures: GarminBackfillStep[];
|
||||
/** The connection changed under the slice; nothing more was written. */
|
||||
replaced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,6 +224,8 @@ async function readStepWindow(
|
||||
{ startDate, endDate: state.before },
|
||||
ctx.tz,
|
||||
);
|
||||
if (step === "sleep")
|
||||
await retireGarminSleepDays(userId, read.sleepEmptyDays);
|
||||
let written = await upsertGarminMeasurements(userId, read.measurements, {
|
||||
emitArrivals: false,
|
||||
});
|
||||
@@ -220,12 +250,18 @@ async function walkSlice(ctx: SliceContext): Promise<SliceWalk> {
|
||||
retryAfterMs: undefined,
|
||||
failure: undefined,
|
||||
failures: [],
|
||||
replaced: false,
|
||||
};
|
||||
const settle = async (state: { done?: boolean }) => {
|
||||
const save = async (): Promise<boolean> => {
|
||||
if (await saveProgress(userId, ctx.connectedAt, progress)) return true;
|
||||
out.replaced = true;
|
||||
return false;
|
||||
};
|
||||
const settle = async (state: { done?: boolean }): Promise<boolean> => {
|
||||
state.done = true;
|
||||
out.newlySettled += 1;
|
||||
settleHistoryStart(progress);
|
||||
await saveProgress(userId, progress);
|
||||
return save();
|
||||
};
|
||||
|
||||
walk: for (const step of GARMIN_BACKFILL_STEPS) {
|
||||
@@ -234,14 +270,17 @@ async function walkSlice(ctx: SliceContext): Promise<SliceWalk> {
|
||||
if (state.done) continue;
|
||||
const floor = floorFor(step, progress);
|
||||
if (floor === undefined) break;
|
||||
if (step === "cycle" && !ctx.cycleAvailable) {
|
||||
await settle(state);
|
||||
if (
|
||||
(step === "cycle" && !ctx.cycleAvailable) ||
|
||||
progress.unavailable?.[step]
|
||||
) {
|
||||
if (!(await settle(state))) break walk;
|
||||
continue;
|
||||
}
|
||||
let windows = 0;
|
||||
while (!state.done) {
|
||||
if (state.before <= floor) {
|
||||
await settle(state);
|
||||
if (!(await settle(state))) break walk;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
@@ -260,14 +299,25 @@ async function walkSlice(ctx: SliceContext): Promise<SliceWalk> {
|
||||
windows += 1;
|
||||
const written = await readStepWindow(ctx, step, state, floor);
|
||||
if (written === null) {
|
||||
await settle(state);
|
||||
if (!(await settle(state))) break walk;
|
||||
break;
|
||||
}
|
||||
out.imported += written;
|
||||
state.windows = (state.windows ?? 0) + 1;
|
||||
out.windowsWritten += 1;
|
||||
await saveProgress(userId, progress);
|
||||
if (!(await save())) break walk;
|
||||
} catch (err) {
|
||||
const code = garminUnavailableCode(step, err);
|
||||
if (code) {
|
||||
// Garmin will not serve this resource to this account: the step
|
||||
// is done, noted once, and does not hold the backfill open.
|
||||
progress.unavailable = { ...progress.unavailable, [step]: code };
|
||||
annotate({
|
||||
meta: { "garmin.resource.unavailable": { resource: step, code } },
|
||||
});
|
||||
if (!(await settle(state))) break walk;
|
||||
break;
|
||||
}
|
||||
if (isGarminKeyRefusal(err)) {
|
||||
await recordGarminSyncFailure(userId, err);
|
||||
out.failure = err;
|
||||
@@ -341,6 +391,7 @@ export async function runGarminBackfillForUser(
|
||||
});
|
||||
const walked = await walkSlice({
|
||||
userId,
|
||||
connectedAt: creds.connectedAt,
|
||||
tz,
|
||||
progress,
|
||||
session,
|
||||
@@ -352,12 +403,21 @@ export async function runGarminBackfillForUser(
|
||||
const { imported, windowsWritten, newlySettled, stopReason, failures } =
|
||||
walked;
|
||||
const { retryAfterMs, failure } = walked;
|
||||
if (walked.replaced) {
|
||||
// Disconnected or given a new key mid-slice: the new connection's own
|
||||
// chain (sent by its connect) takes it from here.
|
||||
annotate({
|
||||
action: { name: "garmin.backfill.replaced", details: { slice } },
|
||||
});
|
||||
return { imported, continued: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await rememberGarminKeyExpiry(
|
||||
userId,
|
||||
session.learnedKeyExpiresAt,
|
||||
creds.keyExpiresAt,
|
||||
{ exchanged: session.proven },
|
||||
);
|
||||
} catch (err) {
|
||||
// A convenience; the backfill does not depend on it.
|
||||
@@ -389,13 +449,16 @@ export async function runGarminBackfillForUser(
|
||||
});
|
||||
|
||||
if (settled && stopReason === null && failures.length === 0) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
garminBackfillCompletedAt: new Date(now()),
|
||||
garminBackfillProgress: garminBackfillProgressJson(progress),
|
||||
},
|
||||
});
|
||||
if (
|
||||
!(await saveProgress(
|
||||
userId,
|
||||
creds.connectedAt,
|
||||
progress,
|
||||
new Date(now()),
|
||||
))
|
||||
) {
|
||||
return { imported, continued: false };
|
||||
}
|
||||
annotate({
|
||||
action: {
|
||||
name: "garmin.backfill.complete",
|
||||
|
||||
@@ -185,6 +185,14 @@ export function metricKeyForType(
|
||||
return "stress";
|
||||
case "SLEEP_DURATION":
|
||||
return "sleep";
|
||||
case "BODY_BATTERY":
|
||||
return "bodyBattery";
|
||||
case "STRESS_LEVEL":
|
||||
return "stressLevel";
|
||||
case "TRAINING_READINESS":
|
||||
return "trainingReadiness";
|
||||
case "ACUTE_TRAINING_LOAD":
|
||||
return "acuteTrainingLoad";
|
||||
default:
|
||||
return cumulativeMetricKey(type);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,10 @@ import { polarCredentialsSchema } from "@/lib/validations/polar";
|
||||
import { stravaCredentialsSchema } from "@/lib/validations/strava";
|
||||
import { whoopCredentialsSchema } from "@/lib/validations/whoop";
|
||||
import { nightscoutConnectSchema } from "@/lib/validations/nightscout";
|
||||
import { garminConnectSchema } from "@/lib/validations/garmin";
|
||||
import {
|
||||
garminConnectSchema,
|
||||
garminKeyExpirySchema,
|
||||
} from "@/lib/validations/garmin";
|
||||
import { withingsCredentialsSchema } from "@/lib/validations/withings";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
@@ -626,10 +629,22 @@ const garminKeyExpiry = z
|
||||
"When the stored Garmin Personal API key expires, resolved on the server so every client warns alike. Garmin keys are valid for 30, 90, 180 or 365 days from creation and cannot be extended; a new key replaces the old.",
|
||||
});
|
||||
|
||||
const garminUnavailableResources = z
|
||||
.array(z.enum(["daily", "activities", "sleep", "hrv", "weight", "cycle"]))
|
||||
.describe(
|
||||
"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.",
|
||||
);
|
||||
|
||||
const garminKeyExpiryRequest = garminKeyExpirySchema.meta({
|
||||
id: "GarminKeyExpiryRequest",
|
||||
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.",
|
||||
});
|
||||
|
||||
const garminConnectRequest = garminConnectSchema.meta({
|
||||
id: "GarminConnectRequest",
|
||||
description:
|
||||
"The Personal API key from Garmin Connect (Settings, Personal API), trimmed before use, and optionally the validity picked when it was created. Garmin does not report a key's expiry, so the validity is what lets the card warn a week ahead until the key's last day reveals the real end.",
|
||||
"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.",
|
||||
});
|
||||
|
||||
const garminStatusResponse = z
|
||||
@@ -649,6 +664,7 @@ const garminStatusResponse = z
|
||||
})
|
||||
.optional()
|
||||
.describe("How far the history backfill has got."),
|
||||
unavailableResources: garminUnavailableResources.optional(),
|
||||
state: integrationLedgerState.optional().meta(legacy()),
|
||||
lastSuccessAt: isoDateTime().nullable().optional(),
|
||||
lastAttemptAt: isoDateTime().nullable().optional(),
|
||||
@@ -1273,6 +1289,10 @@ const integrationEnvelopeEntry = z
|
||||
.describe(
|
||||
"Garmin only. Share of the history walk done, 0 to 1; 1 once `backfillCompleted`. Null when no key is stored.",
|
||||
),
|
||||
unavailableResources: garminUnavailableResources
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Garmin only. Null when no key is stored."),
|
||||
retirement: z
|
||||
.object({
|
||||
endsOn: z
|
||||
@@ -1620,7 +1640,7 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["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.",
|
||||
"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: garminConnectRequest } },
|
||||
@@ -1744,6 +1764,36 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: garminKeyExpiryRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The expiry was stored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ keyExpiry: garminKeyExpiry }),
|
||||
"GarminKeyExpiryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "No Garmin key is stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/garmin/disconnect": {
|
||||
post: {
|
||||
tags: ["Integrations"],
|
||||
|
||||
@@ -6,5 +6,5 @@ export const recoveryEmptySchema = z
|
||||
.enum(RECOVERY_EMPTY_REASONS)
|
||||
.nullable()
|
||||
.describe(
|
||||
"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.",
|
||||
"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, 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.",
|
||||
);
|
||||
@@ -21,6 +21,10 @@ export const PROVENANCE_TYPES = [
|
||||
"VO2_MAX",
|
||||
"RECOVERY_SCORE",
|
||||
"STRESS_SCORE",
|
||||
"BODY_BATTERY",
|
||||
"STRESS_LEVEL",
|
||||
"TRAINING_READINESS",
|
||||
"ACUTE_TRAINING_LOAD",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { GARMIN_KEY_VALIDITY_DAYS } from "@/lib/garmin/key-expiry";
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
const validityDays = z
|
||||
.union(GARMIN_KEY_VALIDITY_DAYS.map((d) => z.literal(d)))
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* `POST /api/garmin/connect`. The key is what Garmin Connect shows once when
|
||||
* it is created (Settings, Personal API). It is trimmed before use: a copy
|
||||
* button's trailing newline would otherwise reach Garmin and answer as a
|
||||
* refused key. `validityDays` is the validity picked at creation; Garmin
|
||||
* does not report it, so it is the only way to warn ahead of the end until
|
||||
* the key's last day gives it away.
|
||||
* refused key. `region` picks the one Garmin service the key is sent to:
|
||||
* Garmin Connect, or Garmin's separate service for accounts in China; the
|
||||
* key never goes to the other. `validityDays` is the validity picked at
|
||||
* creation; Garmin does not report it, so it is the only way to warn ahead
|
||||
* of the end until the key's last day gives it away.
|
||||
*/
|
||||
export const garminConnectSchema = z.object({
|
||||
apiKey: z
|
||||
@@ -17,10 +25,23 @@ export const garminConnectSchema = z.object({
|
||||
.min(16, "The Garmin key is too short")
|
||||
.max(1024, "The Garmin key is too long")
|
||||
.regex(/^\S+$/, "The Garmin key cannot contain spaces"),
|
||||
validityDays: z
|
||||
.union(GARMIN_KEY_VALIDITY_DAYS.map((d) => z.literal(d)))
|
||||
.nullable()
|
||||
.optional(),
|
||||
region: z.enum(["global", "china"]).optional().default("global"),
|
||||
validityDays,
|
||||
});
|
||||
|
||||
export type GarminConnectInput = z.infer<typeof garminConnectSchema>;
|
||||
|
||||
/**
|
||||
* `PATCH /api/garmin/key-expiry`: correct the validity of the stored key
|
||||
* without pasting it again. `createdOn` is the day the key was created in
|
||||
* Garmin Connect (the validity counts from there); omitted, it counts from
|
||||
* the day the key was connected here. `validityDays: null` forgets the
|
||||
* estimate.
|
||||
*/
|
||||
export const garminKeyExpirySchema = z.object({
|
||||
validityDays,
|
||||
createdOn: z
|
||||
.string()
|
||||
.refine(isCalendarDateKey, "createdOn must be a calendar date (YYYY-MM-DD)")
|
||||
.optional(),
|
||||
});
|
||||
@@ -152,6 +152,14 @@ export const SOURCE_PRIORITY_METRIC_KEYS = [
|
||||
// RMSSD natively, Apple Health relays it from third-party apps; SDNN keeps
|
||||
// the `hrv` ladder, since the two measures are not interchangeable.
|
||||
"hrvRmssd",
|
||||
// Garmin's own day scores and the training figures a bridge pushes. Each
|
||||
// type has two producers in practice: the Garmin sync (or nobody, for the
|
||||
// training figures) and a person's own writes through an ingest token, a
|
||||
// hand entry or MCP. The ladders keep the pick deterministic.
|
||||
"bodyBattery",
|
||||
"stressLevel",
|
||||
"trainingReadiness",
|
||||
"acuteTrainingLoad",
|
||||
] as const;
|
||||
|
||||
export type SourcePriorityMetricKey =
|
||||
@@ -498,6 +506,15 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
"APPLE_HEALTH",
|
||||
"HEALTH_CONNECT",
|
||||
],
|
||||
// The device's own figure leads; what a person types, bridges or has an
|
||||
// assistant write is the fallback, in the order the hand-entered sources
|
||||
// hold on every other ladder (MANUAL, then EXTERNAL, MCP, TELEGRAM).
|
||||
bodyBattery: ["GARMIN", "MANUAL", "EXTERNAL", "MCP", "TELEGRAM"],
|
||||
stressLevel: ["GARMIN", "MANUAL", "EXTERNAL", "MCP", "TELEGRAM"],
|
||||
// No provider sync writes these; a bridge (EXTERNAL) is the usual
|
||||
// producer, and a hand-typed correction outranks it like everywhere else.
|
||||
trainingReadiness: ["MANUAL", "EXTERNAL", "MCP", "TELEGRAM"],
|
||||
acuteTrainingLoad: ["MANUAL", "EXTERNAL", "MCP", "TELEGRAM"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
@@ -75,6 +77,24 @@ const fake = vi.hoisted(() => ({
|
||||
rateLimitHit: -1,
|
||||
/** First day of the one logged cycle, relative to today. */
|
||||
cycleStart: "",
|
||||
/**
|
||||
* The weigh-in shape: `spec` is Garmin's document (kilograms, no
|
||||
* `samplePk`, a string instant), `live` the shape seen on the live
|
||||
* service (grams, a `samplePk`, epoch ms).
|
||||
*/
|
||||
weightShape: "spec" as "spec" | "live",
|
||||
/** Extra weigh-in days, beyond today (e.g. before the first watch day). */
|
||||
extraWeighDays: [] as string[],
|
||||
/** The status the cycle summary answers with; 200 for the made-up cycle. */
|
||||
cycleStatus: 200,
|
||||
/** The status the HRV range answers with; 200 for the made-up nights. */
|
||||
hrvStatus: 200,
|
||||
/** Sleep days Garmin no longer reports (deleted, or now a nap). */
|
||||
sleepGone: [] as string[],
|
||||
/** Host each request was addressed to before the rewrite. */
|
||||
hosts: [] as string[],
|
||||
/** Runs on each data request, before the answer. */
|
||||
onData: null as null | ((path: string) => Promise<void>),
|
||||
sends: [] as Array<{ queue: string; payload: unknown }>,
|
||||
}));
|
||||
|
||||
@@ -96,6 +116,7 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
||||
init?: RequestInit,
|
||||
opts?: Parameters<typeof actual.safeFetch>[2],
|
||||
) => {
|
||||
fake.hosts.push(new URL(url.toString()).host);
|
||||
const target = url
|
||||
.toString()
|
||||
.replace(
|
||||
@@ -131,6 +152,31 @@ function jwt(): string {
|
||||
|
||||
const TOKEN = jwt();
|
||||
|
||||
/** One weigh-in of 75.5 kg with 19 % body fat, in the configured shape. */
|
||||
function weighIn(day: string) {
|
||||
const at = Date.parse(`${day}T06:00:00Z`);
|
||||
return fake.weightShape === "spec"
|
||||
? {
|
||||
calendarDate: day,
|
||||
date: new Date(at).toISOString().replace("Z", ""),
|
||||
timestampGMT: new Date(at).toISOString(),
|
||||
weight: 75.5,
|
||||
bodyFat: 19,
|
||||
muscleMass: 34,
|
||||
boneMass: 3,
|
||||
}
|
||||
: {
|
||||
samplePk: 500 + Number(day.slice(8, 10)),
|
||||
calendarDate: day,
|
||||
date: at,
|
||||
timestampGMT: at,
|
||||
weight: 75_500,
|
||||
bodyFat: 19,
|
||||
muscleMass: 34_000,
|
||||
boneMass: 3_000,
|
||||
};
|
||||
}
|
||||
|
||||
/** The made-up history, generated per request. */
|
||||
function answer(path: string, q: URLSearchParams): unknown {
|
||||
const inHistory = (d: string) => d >= fake.historyFrom && d <= fake.today;
|
||||
@@ -156,7 +202,7 @@ function answer(path: string, q: URLSearchParams): unknown {
|
||||
}
|
||||
if (path === "/sleep-service/sleep/dailySleepSummariesByDate") {
|
||||
return daysBetween(q.get("startDate")!, q.get("endDate")!).map((d) =>
|
||||
inHistory(d)
|
||||
inHistory(d) && !fake.sleepGone.includes(d)
|
||||
? {
|
||||
calendarDate: d,
|
||||
sleepTimeSeconds: 25_200,
|
||||
@@ -173,6 +219,7 @@ function answer(path: string, q: URLSearchParams): unknown {
|
||||
);
|
||||
}
|
||||
if (path.startsWith("/hrv-service/hrv/daily/")) {
|
||||
if (fake.hrvStatus !== 200) return { status: fake.hrvStatus };
|
||||
const [, , , , from, to] = path.split("/");
|
||||
return {
|
||||
hrvSummaries: daysBetween(from!, to!)
|
||||
@@ -182,24 +229,14 @@ function answer(path: string, q: URLSearchParams): unknown {
|
||||
}
|
||||
if (path.startsWith("/weight-service/weight/range/")) {
|
||||
const [, , , , from, to] = path.split("/");
|
||||
const weighDay = fake.today;
|
||||
const days = [fake.today, ...fake.extraWeighDays].filter(
|
||||
(d) => d >= from! && d <= to!,
|
||||
);
|
||||
return {
|
||||
dailyWeightSummaries:
|
||||
weighDay >= from! && weighDay <= to!
|
||||
? [
|
||||
{
|
||||
summaryDate: weighDay,
|
||||
allWeightMetrics: [
|
||||
{
|
||||
samplePk: 501,
|
||||
timestampGMT: Date.parse(`${weighDay}T06:00:00Z`),
|
||||
weight: 75_500,
|
||||
bodyFat: 19,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
dailyWeightSummaries: days.map((d) => ({
|
||||
summaryDate: d,
|
||||
allWeightMetrics: [weighIn(d)],
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (path === "/activitylist-service/activities/search/activities") {
|
||||
@@ -221,6 +258,7 @@ function answer(path: string, q: URLSearchParams): unknown {
|
||||
: [];
|
||||
}
|
||||
if (path.startsWith("/periodichealth-service/menstrualcycle/summary/")) {
|
||||
if (fake.cycleStatus !== 200) return { status: fake.cycleStatus };
|
||||
const day = path.split("/").pop()!;
|
||||
const cycleStart = fake.cycleStart;
|
||||
return day >= cycleStart
|
||||
@@ -271,7 +309,17 @@ beforeAll(async () => {
|
||||
res.writeHead(429, { "retry-after": "900" }).end();
|
||||
return;
|
||||
}
|
||||
await fake.onData?.(u.pathname);
|
||||
const body = answer(u.pathname, u.searchParams);
|
||||
if (
|
||||
body &&
|
||||
typeof body === "object" &&
|
||||
"status" in body &&
|
||||
Object.keys(body).length === 1
|
||||
) {
|
||||
res.writeHead((body as { status: number }).status).end();
|
||||
return;
|
||||
}
|
||||
if (body === null) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
@@ -314,6 +362,13 @@ beforeEach(async () => {
|
||||
fake.today = new Date().toISOString().slice(0, 10);
|
||||
fake.historyFrom = addDays(fake.today, -20);
|
||||
fake.cycleStart = addDays(fake.today, -15);
|
||||
fake.weightShape = "spec";
|
||||
fake.extraWeighDays = [];
|
||||
fake.cycleStatus = 200;
|
||||
fake.hrvStatus = 200;
|
||||
fake.sleepGone = [];
|
||||
fake.hosts.length = 0;
|
||||
fake.onData = null;
|
||||
const { clearGarminTokenCache } = await import("@/lib/garmin/client");
|
||||
clearGarminTokenCache();
|
||||
await prisma.user.create({
|
||||
@@ -559,10 +614,11 @@ describe("Garmin connect and disconnect", () => {
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe("garmin.key_refused");
|
||||
expect(JSON.stringify(body)).not.toContain("w".repeat(24));
|
||||
// Tried the global service, then the China one.
|
||||
// Only the chosen service (global by default) was asked.
|
||||
expect(
|
||||
fake.requests.filter((r) => r.includes("personalapitoken")),
|
||||
).toHaveLength(2);
|
||||
).toHaveLength(1);
|
||||
expect(fake.hosts).toEqual(["personal.connectapi.garmin.com"]);
|
||||
const user = await getPrismaClient().user.findUnique({
|
||||
where: { id: USER_ID },
|
||||
select: { garminApiKeyEncrypted: true },
|
||||
@@ -644,3 +700,300 @@ describe("Garmin connect and disconnect", () => {
|
||||
expect(fake.requests.length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Garmin connect sends the key only to the chosen service", () => {
|
||||
it("contacts the China host alone when the person picks it", async () => {
|
||||
const { POST } = await import("@/app/api/garmin/connect/route");
|
||||
const res = await POST(
|
||||
new NextRequest("http://localhost/api/garmin/connect", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ apiKey: fake.key, region: "china" }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(fake.hosts).toEqual(["personal.connectapi.garmin.cn"]);
|
||||
const user = await getPrismaClient().user.findUnique({
|
||||
where: { id: USER_ID },
|
||||
select: { garminRegion: true },
|
||||
});
|
||||
expect(user?.garminRegion).toBe("china");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Garmin weigh-ins", () => {
|
||||
it.each(["spec", "live"] as const)(
|
||||
"writes a whole weigh-in from the %s shape",
|
||||
async (shape) => {
|
||||
await connectUser();
|
||||
fake.weightShape = shape;
|
||||
const { syncUserGarmin } = await import("@/lib/garmin/sync");
|
||||
await syncUserGarmin(USER_ID, { lookbackDays: 1 });
|
||||
const rows = await getPrismaClient().measurement.findMany({
|
||||
where: {
|
||||
userId: USER_ID,
|
||||
type: { in: ["WEIGHT", "BODY_FAT", "MUSCLE_MASS", "BONE_MASS"] },
|
||||
},
|
||||
select: { type: true, value: true, externalId: true },
|
||||
orderBy: { type: "asc" },
|
||||
});
|
||||
expect(
|
||||
rows
|
||||
.map((r) => [r.type, r.value] as const)
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
).toEqual([
|
||||
["BODY_FAT", 19],
|
||||
["BONE_MASS", 3],
|
||||
["MUSCLE_MASS", 34],
|
||||
["WEIGHT", 75.5],
|
||||
]);
|
||||
// One weigh-in, one id across its rows; a re-read lands on the same.
|
||||
expect(new Set(rows.map((r) => r.externalId!.split(":")[1])).size).toBe(
|
||||
1,
|
||||
);
|
||||
await syncUserGarmin(USER_ID, { lookbackDays: 1 });
|
||||
expect(
|
||||
await getPrismaClient().measurement.count({
|
||||
where: { userId: USER_ID, type: "WEIGHT" },
|
||||
}),
|
||||
).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("walks weigh-ins back past the first watch day", async () => {
|
||||
await connectUser();
|
||||
fake.historyFrom = addDays(fake.today, -20);
|
||||
fake.extraWeighDays = [addDays(fake.today, -600)];
|
||||
const { runGarminBackfillForUser } =
|
||||
await import("@/lib/jobs/garmin-backfill");
|
||||
for (let slice = 0; slice < 30; slice++) {
|
||||
const u = await getPrismaClient().user.findUnique({
|
||||
where: { id: USER_ID },
|
||||
select: { garminBackfillCompletedAt: true },
|
||||
});
|
||||
if (u?.garminBackfillCompletedAt) break;
|
||||
await runGarminBackfillForUser(USER_ID, () => false, slice);
|
||||
}
|
||||
const weights = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: USER_ID, type: "WEIGHT" },
|
||||
select: { measuredAt: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
expect(weights).toHaveLength(2);
|
||||
expect(weights[0]!.measuredAt.toISOString().slice(0, 10)).toBe(
|
||||
addDays(fake.today, -600),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Garmin resources the account does not have", () => {
|
||||
it("completes the backfill and keeps the connection green when the cycle summary is refused", async () => {
|
||||
await connectUser();
|
||||
fake.cycleStatus = 404;
|
||||
const { runGarminBackfillForUser } =
|
||||
await import("@/lib/jobs/garmin-backfill");
|
||||
const prisma = getPrismaClient();
|
||||
for (let slice = 0; slice < 30; slice++) {
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: USER_ID },
|
||||
select: { garminBackfillCompletedAt: true },
|
||||
});
|
||||
if (u?.garminBackfillCompletedAt) break;
|
||||
await runGarminBackfillForUser(USER_ID, () => false, slice);
|
||||
}
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: USER_ID },
|
||||
select: { garminBackfillCompletedAt: true, garminBackfillProgress: true },
|
||||
});
|
||||
expect(user?.garminBackfillCompletedAt).not.toBeNull();
|
||||
expect(
|
||||
(user?.garminBackfillProgress as { unavailable?: unknown }).unavailable,
|
||||
).toEqual({ cycle: "http_404" });
|
||||
|
||||
// The hourly sync skips it and stays green.
|
||||
const { syncUserGarmin } = await import("@/lib/garmin/sync");
|
||||
fake.requests.length = 0;
|
||||
const result = await syncUserGarmin(USER_ID, { lookbackDays: 1 });
|
||||
expect(result.failed).toBe(false);
|
||||
expect(fake.requests.some((r) => r.includes("menstrualcycle"))).toBe(false);
|
||||
const ledger = await prisma.integrationStatus.findFirst({
|
||||
where: { userId: USER_ID, integration: "garmin" },
|
||||
});
|
||||
expect(ledger?.state).toBe("connected");
|
||||
});
|
||||
|
||||
it("notes a resource the hourly sync is refused, skips it after, and stays green", async () => {
|
||||
await connectUser();
|
||||
fake.hrvStatus = 404;
|
||||
const { syncUserGarmin } = await import("@/lib/garmin/sync");
|
||||
const first = await syncUserGarmin(USER_ID, { lookbackDays: 1 });
|
||||
expect(first.failed).toBe(false);
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: USER_ID },
|
||||
select: { garminBackfillProgress: true },
|
||||
});
|
||||
expect(
|
||||
(user?.garminBackfillProgress as { unavailable?: unknown }).unavailable,
|
||||
).toEqual({ hrv: "http_404" });
|
||||
fake.requests.length = 0;
|
||||
await syncUserGarmin(USER_ID, { lookbackDays: 1 });
|
||||
expect(fake.requests.some((r) => r.includes("/hrv-service/"))).toBe(false);
|
||||
const ledger = await prisma.integrationStatus.findFirst({
|
||||
where: { userId: USER_ID, integration: "garmin" },
|
||||
});
|
||||
expect(ledger?.state).toBe("connected");
|
||||
});
|
||||
|
||||
it("never parks the connection on a refused cycle summary", async () => {
|
||||
await connectUser();
|
||||
fake.cycleStatus = 401;
|
||||
const { syncUserGarmin } = await import("@/lib/garmin/sync");
|
||||
const result = await syncUserGarmin(USER_ID, { lookbackDays: 1 });
|
||||
expect(result.failed).toBe(false);
|
||||
const ledger = await getPrismaClient().integrationStatus.findFirst({
|
||||
where: { userId: USER_ID, integration: "garmin" },
|
||||
});
|
||||
expect(ledger?.state).toBe("connected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Garmin parked key", () => {
|
||||
it("asks Garmin again once a day and reconnects when the key works", async () => {
|
||||
await connectUser();
|
||||
const prisma = getPrismaClient();
|
||||
fake.dataUnauthorized = 2;
|
||||
const { syncUserGarmin } = await import("@/lib/garmin/sync");
|
||||
await expect(
|
||||
syncUserGarmin(USER_ID, { lookbackDays: 1 }),
|
||||
).rejects.toThrow();
|
||||
// Within the day: skipped, nothing asked.
|
||||
const before = fake.requests.length;
|
||||
await syncUserGarmin(USER_ID, { skipWhenParked: true, lookbackDays: 1 });
|
||||
expect(fake.requests.length).toBe(before);
|
||||
// A day later: one probe, accepted, and the run goes on.
|
||||
await prisma.integrationStatus.updateMany({
|
||||
where: { userId: USER_ID, integration: "garmin" },
|
||||
data: { lastAttemptAt: new Date(Date.now() - 25 * 3_600_000) },
|
||||
});
|
||||
const result = await syncUserGarmin(USER_ID, {
|
||||
skipWhenParked: true,
|
||||
lookbackDays: 1,
|
||||
});
|
||||
expect(result.failed).toBe(false);
|
||||
expect(result.imported).toBeGreaterThan(0);
|
||||
const ledger = await prisma.integrationStatus.findFirst({
|
||||
where: { userId: USER_ID, integration: "garmin" },
|
||||
});
|
||||
expect(ledger?.state).toBe("connected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Garmin backfill and a reconnect mid-slice", () => {
|
||||
it("writes nothing once the connection under the slice was replaced", async () => {
|
||||
await connectUser();
|
||||
const prisma = getPrismaClient();
|
||||
const replacedAt = new Date(Date.now() + 1000);
|
||||
fake.onData = async () => {
|
||||
fake.onData = null;
|
||||
await prisma.user.update({
|
||||
where: { id: USER_ID },
|
||||
data: {
|
||||
garminConnectedAt: replacedAt,
|
||||
garminBackfillProgress: Prisma.DbNull,
|
||||
garminBackfillCompletedAt: null,
|
||||
},
|
||||
});
|
||||
};
|
||||
const { runGarminBackfillForUser } =
|
||||
await import("@/lib/jobs/garmin-backfill");
|
||||
const out = await runGarminBackfillForUser(USER_ID, () => false, 0);
|
||||
expect(out.continued).toBe(false);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: USER_ID },
|
||||
select: { garminBackfillProgress: true, garminBackfillCompletedAt: true },
|
||||
});
|
||||
expect(user).toEqual({
|
||||
garminBackfillProgress: null,
|
||||
garminBackfillCompletedAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Garmin catch-up and removed nights", () => {
|
||||
it("reaches back to the last successful sync after a long outage", async () => {
|
||||
await connectUser();
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.integrationStatus.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
integration: "garmin",
|
||||
state: "error_transient",
|
||||
lastSuccessAt: new Date(Date.now() - 60 * 86_400_000),
|
||||
},
|
||||
});
|
||||
const { resolveGarminLookbackDays } = await import("@/lib/garmin/sync");
|
||||
expect(await resolveGarminLookbackDays(USER_ID)).toBe(61);
|
||||
});
|
||||
|
||||
it("retires a night Garmin no longer reports", async () => {
|
||||
await connectUser();
|
||||
const { syncUserGarmin } = await import("@/lib/garmin/sync");
|
||||
await syncUserGarmin(USER_ID, { lookbackDays: 3 });
|
||||
const gone = addDays(fake.today, -1);
|
||||
const live = (day: string) =>
|
||||
getPrismaClient().measurement.count({
|
||||
where: {
|
||||
userId: USER_ID,
|
||||
deletedAt: null,
|
||||
externalId: { startsWith: `sleep:${day}:` },
|
||||
},
|
||||
});
|
||||
expect(await live(gone)).toBe(7);
|
||||
fake.sleepGone = [gone];
|
||||
await syncUserGarmin(USER_ID, { lookbackDays: 3 });
|
||||
expect(await live(gone)).toBe(0);
|
||||
expect(await live(addDays(fake.today, -2))).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Garmin key expiry", () => {
|
||||
it("clears an estimated end the key outlived, and takes a corrected validity", async () => {
|
||||
await connectUser();
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.update({
|
||||
where: { id: USER_ID },
|
||||
data: { garminKeyExpiresAt: new Date(Date.now() - 86_400_000) },
|
||||
});
|
||||
const { syncUserGarmin } = await import("@/lib/garmin/sync");
|
||||
await syncUserGarmin(USER_ID, { lookbackDays: 1 });
|
||||
expect(
|
||||
(
|
||||
await prisma.user.findUnique({
|
||||
where: { id: USER_ID },
|
||||
select: { garminKeyExpiresAt: true },
|
||||
})
|
||||
)?.garminKeyExpiresAt,
|
||||
).toBeNull();
|
||||
|
||||
const { PATCH } = await import("@/app/api/garmin/key-expiry/route");
|
||||
const res = await PATCH(
|
||||
new NextRequest("http://localhost/api/garmin/key-expiry", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
validityDays: 90,
|
||||
createdOn: addDays(fake.today, -10),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const stored = await prisma.user.findUnique({
|
||||
where: { id: USER_ID },
|
||||
select: { garminKeyExpiresAt: true },
|
||||
});
|
||||
expect(stored?.garminKeyExpiresAt?.toISOString()).toBe(
|
||||
`${addDays(fake.today, 80)}T12:00:00.000Z`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user