mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge branch 'feat/v147-garmin' into release/v1.47.0
This commit is contained in:
commit
8a89d5cfc3
138 files changed
+9536
-465
No files matched your search
@@ -30,6 +30,24 @@
|
||||
Each injected dose now carries a chip with the site next to its status,
|
||||
in the table and on the mobile cards, so the rotation can be read back
|
||||
from the history. Doses without a site look as before.
|
||||
- **Garmin, directly.** Garmin Connect now offers a Personal API, and
|
||||
HealthLog reads it: create a key in Garmin Connect (Settings, Personal
|
||||
API), paste it into the new Garmin card, and steps, distance, floors,
|
||||
active calories, resting heart rate, sleep stages and sleep score,
|
||||
overnight SpO₂, breathing rate and HRV, weigh-ins with body composition,
|
||||
activities and logged period days sync every hour, with the whole
|
||||
history filled in behind. Body Battery and Garmin's all-day stress level
|
||||
arrive as types of their own (`BODY_BATTERY`, `STRESS_LEVEL`), never mixed
|
||||
into the computed scores. Garmin keys expire after 30 to 365 days; the
|
||||
card warns a week ahead and asks for a new key when Garmin stops
|
||||
accepting the old one. Accounts on Garmin's China service work too; the
|
||||
key goes only to the service you pick. The key is stored encrypted and
|
||||
only ever sent to Garmin. (#1333)
|
||||
- **Training Readiness and acute training load.** The Personal API does
|
||||
not carry them, so they are new types (`TRAINING_READINESS`,
|
||||
`ACUTE_TRAINING_LOAD`) a Home Assistant bridge can push through an ingest
|
||||
token, and they chart on the recovery page next to Body Battery. The Home
|
||||
Assistant guide has the automation. (#1333)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -96,6 +114,8 @@
|
||||
- @Oguz3003 for staying with the Raspberry Pi 4 until it reads PDFs
|
||||
(#1311).
|
||||
- @mills1975 for asking how blood pressure differs over the day (#1331).
|
||||
- @kruemelro for asking for a direct Garmin connection on Garmin's new
|
||||
Personal API (#1333).
|
||||
|
||||
## [1.46.6] — 2026-11-02
|
||||
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
<https://docs.healthlog.dev>.
|
||||
- [`integrations/`](./integrations/) — per-integration reference:
|
||||
Withings, WHOOP, Fitbit, Google Health, Strava, Nightscout, Dawarich and
|
||||
the Garmin route; the Apple Health, Health Connect, document and data
|
||||
Garmin; the Apple Health, Health Connect, document and data
|
||||
imports; AI providers; and Home Assistant with its blueprint. Oura and
|
||||
Polar are covered on the docs site only.
|
||||
- [`adr/`](./adr/) — architecture decision records.
|
||||
|
||||
+1262
-172
File diff suppressed because it is too large.
Load diff
@@ -314,7 +314,7 @@ id for debugging.
|
||||
|
||||
## Garmin owners
|
||||
|
||||
Garmin has no direct connector for a self-hosted instance. Garmin Connect writes
|
||||
to Apple Health automatically, so the import path on this page is how Garmin data
|
||||
reaches HealthLog on iOS. See [garmin.md](./garmin.md) for what comes through and
|
||||
what Garmin withholds.
|
||||
Garmin Connect writes to Apple Health automatically, so the import path on this
|
||||
page brings Garmin data in on iOS. The direct [Garmin](./garmin.md) integration
|
||||
reads more (Body Battery, stress, Garmin's sleep score) with a key from your
|
||||
own Garmin account.
|
||||
+102
-40
@@ -1,52 +1,114 @@
|
||||
# Garmin (via Apple Health or Google Health Connect)
|
||||
# Garmin
|
||||
|
||||
**There is no direct Garmin connector for a self-hosted HealthLog instance, and
|
||||
there cannot be one.** Garmin's data-sharing program is business-partner-only:
|
||||
it is approval-gated, requires a public callback endpoint, and is not the
|
||||
"register your own app, paste a key" model the wearable integrations here rely
|
||||
on. So instead of a broken half-integration, HealthLog reads Garmin data the
|
||||
honest way — through the phone health platform your Garmin already feeds.
|
||||
HealthLog reads Garmin Connect directly through Garmin's **Personal API**: you
|
||||
create a key for your own account in Garmin Connect, paste it into HealthLog,
|
||||
and the server pulls your data every hour. Nothing is pushed by Garmin, and
|
||||
nothing is written back.
|
||||
|
||||
## The path that works
|
||||
The Personal API is for your own data only. Each person on an instance
|
||||
creates and pastes their own key; there is no shared app and no operator
|
||||
setup.
|
||||
|
||||
```
|
||||
Garmin device → Garmin Connect → Apple Health (iOS) ─┐
|
||||
→ Google Health Connect ├─→ HealthLog
|
||||
(Android) ─┘
|
||||
```
|
||||
## Create a key
|
||||
|
||||
- **iOS:** Garmin Connect writes to Apple Health automatically. Import it with
|
||||
the existing [Apple Health](./apple-health.md) path (the `export.zip` upload or
|
||||
the native client's background sync).
|
||||
- **Android:** since Garmin Connect 5.14.1 (July 2025) the app writes to **Google
|
||||
Health Connect**. Connect HealthLog through the existing
|
||||
[Google Health](./google-health.md) integration.
|
||||
1. Open [Garmin Connect → Settings → Personal API](https://connect.garmin.com/app/settings/personalApiToken).
|
||||
2. Create a key and choose how long it stays valid: 30, 90, 180 days or one
|
||||
year.
|
||||
3. Copy it right away. Garmin shows the key once.
|
||||
|
||||
No extra code, no Garmin developer account, no approval queue — if your Garmin
|
||||
already syncs to your phone's health platform, HealthLog already reads it.
|
||||
## Connect
|
||||
|
||||
Open **Settings → Integrations → Garmin**, paste the key, and pick the same
|
||||
validity you chose in Garmin Connect. HealthLog checks the key with Garmin
|
||||
before storing anything, so a mistyped or revoked key is refused on the spot.
|
||||
The key is stored encrypted at rest and is never shown again, logged or sent
|
||||
anywhere but Garmin.
|
||||
|
||||
Accounts registered with Garmin's China service work too: pick **Garmin
|
||||
Connect China** as the service. The key is only ever sent to the service you
|
||||
pick.
|
||||
|
||||
After connecting, the last 30 days arrive within a minute and the rest of your
|
||||
history follows in the background. The card says "Older history is still
|
||||
loading" until it is done.
|
||||
|
||||
## Key expiry
|
||||
|
||||
Garmin keys expire and cannot be extended. Garmin does not report a key's end
|
||||
date through the API, so HealthLog works it out from the validity you picked
|
||||
at connect, and replaces that estimate with Garmin's own date once the key is
|
||||
in its last day (the short-lived tokens it buys are cut off at the key's end).
|
||||
|
||||
From seven days before the end the card says when the key expires. When it
|
||||
has expired, or you revoked it in Garmin Connect, syncing stops and the card
|
||||
asks for a new key: create one, open **Replace key** in the card's menu and
|
||||
paste it. Nothing imported is lost. If you did not pick a validity, the card
|
||||
says that it cannot warn ahead; **Key validity** in the card's menu sets it
|
||||
afterwards, together with the day you created the key, without pasting the
|
||||
key again. A key Garmin turned down is asked about again once a day, so a
|
||||
refusal that passes on its own clears without you doing anything.
|
||||
|
||||
## What comes through
|
||||
|
||||
Everything Garmin shares with Apple Health / Health Connect flows in: steps,
|
||||
heart rate (including beat-to-beat variability), sleep stages (REM / deep /
|
||||
light), calories (active and total), distance, weight and body composition,
|
||||
cadence, and — partially — blood-oxygen saturation.
|
||||
| Garmin data | HealthLog type | Unit |
|
||||
| --------------------------------------------------------------------- | -------------------------- | ------------- |
|
||||
| Steps | `ACTIVITY_STEPS` | steps |
|
||||
| Step distance | `WALKING_RUNNING_DISTANCE` | m |
|
||||
| Floors climbed | `FLIGHTS_CLIMBED` | flights |
|
||||
| Active calories | `ACTIVE_ENERGY_BURNED` | kcal |
|
||||
| Resting heart rate | `RESTING_HEART_RATE` | bpm |
|
||||
| All-day average stress level | `STRESS_LEVEL` | 0–100 |
|
||||
| Body Battery (the day's high) | `BODY_BATTERY` | 0–100 |
|
||||
| Sleep stages (light, deep, REM, awake) | `SLEEP_DURATION` per stage | minutes |
|
||||
| Sleep score | `SLEEP_SCORE` | 0–100 |
|
||||
| Average overnight SpO₂ | `OXYGEN_SATURATION` | % |
|
||||
| Average overnight breathing rate | `RESPIRATORY_RATE` | breaths/min |
|
||||
| Overnight HRV (RMSSD) | `HRV_RMSSD` | ms |
|
||||
| Weight, BMI, body fat, body water, muscle and bone mass, visceral fat | the body-composition types | kg, %, rating |
|
||||
| Activities | workouts | |
|
||||
| Logged period days | the cycle tracker | |
|
||||
|
||||
## What Garmin withholds
|
||||
Notes:
|
||||
|
||||
These are Garmin's **proprietary** scores. Garmin does **not** share them with
|
||||
Apple Health or Health Connect, so no self-hosted integration — and no amount of
|
||||
configuration — can obtain them. They are only available inside Garmin Connect
|
||||
itself:
|
||||
- Garmin's HRV is RMSSD and lands on `HRV_RMSSD`. Garmin reports no SDNN, so
|
||||
nothing is written to `HEART_RATE_VARIABILITY`.
|
||||
- Stress and Body Battery are Garmin's own scores and keep types of their own.
|
||||
They are not mixed into HealthLog's computed stress score.
|
||||
- Days you did not wear the watch import nothing, not zeros.
|
||||
- Period days are only added on days you have not logged yourself; your own
|
||||
entries are never changed. Cycles Garmin only predicts are not imported.
|
||||
- Weigh-ins are read back to the start of Garmin Connect, also from before
|
||||
your first watch, so a scale-only account gets its whole history.
|
||||
- A night you delete in Garmin Connect, or that Garmin turns into a nap, is
|
||||
removed here on the next sync.
|
||||
- Data Garmin does not provide for your account (a menstrual-cycle summary
|
||||
when you track none, for example) is skipped, does not count as an error,
|
||||
and is listed under **Details for a bug report** on the card.
|
||||
- When the same data also arrives through Apple Health or Health Connect, the
|
||||
source order in **Settings → Sources** decides which one a chart shows.
|
||||
Garmin ranks with the other watches.
|
||||
|
||||
- Body Battery
|
||||
- HRV Status
|
||||
- VO₂ max
|
||||
- Training Load and Training Effect
|
||||
- Recovery Time
|
||||
- Intensity Minutes
|
||||
- Stress score
|
||||
- Running dynamics
|
||||
## What the Personal API does not carry
|
||||
|
||||
If you rely on those specific numbers, keep the Garmin Connect app for them;
|
||||
everything else lands in HealthLog through the platform path above.
|
||||
Training Readiness, Training Status, acute training load and recovery time
|
||||
are not part of the Personal API. If you read them elsewhere, for example
|
||||
through a Home Assistant integration, push them through an ingest token: see
|
||||
[Home Assistant](./home-assistant.md#garmin-training-figures). They land as
|
||||
`TRAINING_READINESS` and `ACUTE_TRAINING_LOAD` and show on the recovery page
|
||||
beside Body Battery.
|
||||
|
||||
## Limits
|
||||
|
||||
- Garmin sets how many requests a key may make. HealthLog reads one resource
|
||||
at a time and stops the hour's run at Garmin's first rate-limit answer; the
|
||||
history backfill waits as long as Garmin asks before it goes on.
|
||||
- Disconnecting removes the key from HealthLog. It stays valid at Garmin until
|
||||
you revoke it there.
|
||||
|
||||
## Without a key
|
||||
|
||||
Garmin Connect also writes to Apple Health on iOS and to Google Health Connect
|
||||
on Android, so the [Apple Health](./apple-health.md) and
|
||||
[Google Health](./google-health.md) paths keep working for anyone who prefers
|
||||
not to create a key. Body Battery, stress and Garmin's sleep score only come
|
||||
through the Personal API.
|
||||
@@ -315,7 +315,7 @@ Previously synced measurements stay in the database.
|
||||
|
||||
## Garmin owners
|
||||
|
||||
Garmin has no direct connector for a self-hosted instance. Since Garmin Connect
|
||||
5.14.1 (July 2025) the app writes to Google Health Connect, so this integration
|
||||
is how Garmin data reaches HealthLog on Android. See [garmin.md](./garmin.md) for
|
||||
what comes through and what Garmin withholds.
|
||||
Since Garmin Connect 5.14.1 (July 2025) the app writes to Google Health Connect,
|
||||
so this path brings Garmin data in on Android. The direct [Garmin](./garmin.md)
|
||||
integration reads more (Body Battery, stress, Garmin's sleep score) with a key
|
||||
from your own Garmin account.
|
||||
@@ -149,6 +149,38 @@ Units are derived server-side from the type, so send the number in the unit
|
||||
above and do not send a `unit` field. The full enum is in
|
||||
`docs/api/openapi.yaml` under `MeasurementType`.
|
||||
|
||||
## Garmin training figures
|
||||
|
||||
The Garmin Personal API does not carry Training Readiness or the acute
|
||||
training load (see [Garmin](./garmin.md)). If Home Assistant reads them, for
|
||||
example through a Garmin Connect integration, send them through the same
|
||||
ingest token. They have types of their own and appear on the recovery page:
|
||||
|
||||
| What the sensor reports | `type` | Unit expected |
|
||||
| -------------------------- | --------------------- | ------------- |
|
||||
| Garmin Training Readiness | `TRAINING_READINESS` | score 0–100 |
|
||||
| Garmin acute training load | `ACUTE_TRAINING_LOAD` | load, no unit |
|
||||
|
||||
Both change about once a day. This automation pushes Training Readiness
|
||||
whenever the sensor changes, through the `healthlog_measurement` command
|
||||
above; the acute load is the same with its own sensor and type:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "HealthLog: Garmin training readiness"
|
||||
triggers:
|
||||
- trigger: state
|
||||
entity_id: sensor.garmin_training_readiness
|
||||
conditions:
|
||||
- condition: template
|
||||
value_template: "{{ trigger.to_state.state | is_number }}"
|
||||
actions:
|
||||
- action: rest_command.healthlog_measurement
|
||||
data:
|
||||
type: TRAINING_READINESS
|
||||
value: "{{ trigger.to_state.state | float }}"
|
||||
```
|
||||
|
||||
## Room climate
|
||||
|
||||
Indoor CO₂, temperature and humidity are their own types, kept apart from the
|
||||
|
||||
+72
-11
@@ -969,7 +969,12 @@
|
||||
"durationMinutesOnly": "{minutes} Min.",
|
||||
"durationInvalid": "Gib eine Dauer ein, zum Beispiel 7,5, 7:30 oder 7h 30m.",
|
||||
"durationTooLong": "Ein Eintrag kann nicht länger als 24 Stunden sein.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Stresslevel",
|
||||
"typeTrainingReadiness": "Trainingsbereitschaft",
|
||||
"typeAcuteTrainingLoad": "Akute Trainingsbelastung",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Was HealthLog erfassen kann",
|
||||
@@ -4759,7 +4764,8 @@
|
||||
"unitScore": "Punkte",
|
||||
"unitMinutes": "Min.",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "Belastung"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Schlafqualität",
|
||||
@@ -4781,7 +4787,11 @@
|
||||
"cardioLoadExplainer": "Ein Trainingslast-Näherungswert, der die akut aufgebaute kardiovaskuläre Belastung abbildet. Vergleiche ihn mit deinen eigenen letzten Tagen.",
|
||||
"averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
|
||||
"maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
|
||||
"energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet."
|
||||
"energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet.",
|
||||
"bodyBatteryExplainer": "Deine Energiereserve auf Garmins Skala von 0 bis 100, der höchste Stand des Tages. Ruhe und Schlaf laden sie auf, Stress und Bewegung zehren davon.",
|
||||
"trainingReadinessExplainer": "Wie bereit dein Körper für eine harte Einheit ist, auf Garmins Skala von 0 bis 100. Der Wert kommt über eine von dir eingerichtete Brücke, weil Garmin ihn nicht direkt teilt.",
|
||||
"stressLevelExplainer": "Garmins durchschnittliches Stresslevel des Tages auf einer Skala von 0 bis 100, abgeleitet aus deiner Herzfrequenzvariabilität. Niedriger heißt ruhiger.",
|
||||
"acuteTrainingLoadExplainer": "Garmins akute Trainingsbelastung: die gewichtete Belastung deiner Aktivitäten der letzten sieben Tage. Der Wert kommt über eine von dir eingerichtete Brücke. Vergleiche ihn mit deinen eigenen letzten Wochen."
|
||||
},
|
||||
"loadError": "Deine Erholungswerte konnten nicht geladen werden."
|
||||
},
|
||||
@@ -5688,7 +5698,11 @@
|
||||
"recovery": "Erholung",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Schlafdefizit",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Stresslevel",
|
||||
"trainingReadiness": "Trainingsbereitschaft",
|
||||
"acuteTrainingLoad": "Akute Trainingsbelastung"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5706,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Externes Gerät",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Uhr",
|
||||
@@ -7500,11 +7515,6 @@
|
||||
"lastEnteredHere": "Zuletzt hier eingetragen {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte (Body Battery, Training Load, HRV-Status, VO₂max) werden von Garmin an keine der beiden Plattformen weitergegeben.",
|
||||
"docsLink": "Zur Anleitung"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Glukose-Referenzband",
|
||||
"description": "Gegen welches Band deine Blutzuckerwerte bewertet werden.",
|
||||
@@ -7704,7 +7714,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Du wirst abgemeldet und dein Konto wird sofort gesperrt. Nach {days} Tagen wird es endgültig gelöscht, mit allen Gesundheitsdaten, Passkeys, Sessions und Audit-Einträgen. Bis dahin kann ein Admin dieses Servers die Löschung rückgängig machen. Kopien in den Sicherungen dieses Servers verfallen nach ihrer Aufbewahrungsfrist.",
|
||||
"deleteAccountScheduled": "Dein Konto ist gesperrt und wird am {date} endgültig gelöscht. Du wirst abgemeldet.",
|
||||
"dangerZoneSuccessGrace": "Deine Gesundheitsakte wurde gelöscht. Ein Admin dieses Servers kann sie bis zum {date} wiederherstellen.",
|
||||
"dangerZoneRestoreLimits": "Auch Zugriffe enden: was du mit anderen teilst, was andere mit dir teilen, und jede Rolle als betreuende Person. Eine Wiederherstellung bringt die Akte so zurück, wie sie jetzt ist. Was nach dem Löschen hinzukommt, wird dabei ersetzt, und Integrationsverbindungen, Freigaben, Betreuungsrollen und Freigabe-Links kommen nicht zurück; sie müssen neu eingerichtet werden."
|
||||
"dangerZoneRestoreLimits": "Auch Zugriffe enden: was du mit anderen teilst, was andere mit dir teilen, und jede Rolle als betreuende Person. Eine Wiederherstellung bringt die Akte so zurück, wie sie jetzt ist. Was nach dem Löschen hinzukommt, wird dabei ersetzt, und Integrationsverbindungen, Freigaben, Betreuungsrollen und Freigabe-Links kommen nicht zurück; sie müssen neu eingerichtet werden.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Füge einen Garmin-Personal-API-Schlüssel ein, um Schlaf, HRV, Body Battery, Stress, Schritte, Gewicht und Aktivitäten aus Garmin Connect zu synchronisieren.",
|
||||
"garminSync": "Jetzt synchronisieren",
|
||||
"garminSyncResult": "{count} Einträge synchronisiert",
|
||||
"garminSyncFailed": "Synchronisierung fehlgeschlagen",
|
||||
"garminViewData": "Erholung ansehen",
|
||||
"garminReplaceKey": "Schlüssel ersetzen",
|
||||
"garminDisconnect": "Trennen",
|
||||
"garminDisconnectTitle": "Garmin trennen?",
|
||||
"garminDisconnectDescription": "Künftige Synchronisierungen enden, und der Schlüssel wird von diesem Server entfernt. Bereits importierte Daten bleiben in deinem Verlauf. Bei Garmin bleibt der Schlüssel gültig, bis du ihn dort widerrufst.",
|
||||
"garminKeyValidUntil": "Schlüssel gültig bis {date}",
|
||||
"garminKeyExpired": "Dein Garmin-Schlüssel ist am {date} abgelaufen. Erstelle in Garmin Connect einen neuen Schlüssel und füge ihn hier ein, damit die Synchronisierung weiterläuft.",
|
||||
"garminKeyExpiresToday": "Dein Garmin-Schlüssel läuft heute ab. Erstelle in Garmin Connect einen neuen Schlüssel und füge ihn hier ein, damit die Synchronisierung nicht stoppt.",
|
||||
"garminKeyExpiring": "Dein Garmin-Schlüssel läuft am {date} ab. Erstelle vorher in Garmin Connect einen neuen Schlüssel und füge ihn hier ein.",
|
||||
"garminKeyExpiryUnknown": "Garmin verrät nicht, wann dieser Schlüssel abläuft. Ersetze den Schlüssel und wähle die Gültigkeit, die du in Garmin Connect eingestellt hast, dann warnt dich diese Karte eine Woche vorher.",
|
||||
"garminConnected": "Garmin verbunden. Die erste Synchronisierung läuft an.",
|
||||
"garminKeyHowTo": "Öffne in Garmin Connect die Einstellungen, dann Personal API, und erstelle einen Schlüssel. Garmin zeigt ihn nur einmal an, kopiere ihn also sofort.",
|
||||
"garminKeySettingsLink": "Garmin Connect öffnen",
|
||||
"garminApiKey": "Personal-API-Schlüssel",
|
||||
"garminApiKeyPlaceholder": "Schlüssel einfügen",
|
||||
"garminApiKeySavedPlaceholder": "Gespeichert. Füge einen neuen Schlüssel ein, um ihn zu ersetzen",
|
||||
"garminKeyValidity": "Gewählte Gültigkeit",
|
||||
"garminKeyValidityUnknown": "Weiß ich nicht",
|
||||
"garminKeyValidityDays": "{count} Tage",
|
||||
"garminKeyValidityHelp": "Garmin meldet nicht, wann ein Schlüssel abläuft. Mit der Gültigkeit warnt dich diese Karte eine Woche, bevor der Schlüssel nicht mehr funktioniert.",
|
||||
"garminConnect": "Verbinden",
|
||||
"garminUpdate": "Änderungen speichern",
|
||||
"garminRegion": "Garmin-Dienst",
|
||||
"garminRegionGlobal": "Garmin Connect (global)",
|
||||
"garminRegionChina": "Garmin Connect China",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin hat diesen Schlüssel nicht angenommen. Prüfe, ob er vollständig kopiert ist, zum gewählten Dienst gehört und weder abgelaufen noch widerrufen ist.",
|
||||
"rateLimited": "Garmin begrenzt gerade die Anfragen. Versuche es in ein paar Minuten noch einmal.",
|
||||
"unreachable": "Garmin ist nicht erreichbar. Versuche es in ein paar Minuten noch einmal.",
|
||||
"invalid": "Dieser Schlüssel sieht nicht richtig aus. Füge ihn noch einmal ohne Leerzeichen ein.",
|
||||
"generic": "Das hat nicht geklappt. Versuche es noch einmal."
|
||||
},
|
||||
"garminKeyValidityMenu": "Gültigkeit des Schlüssels",
|
||||
"garminValiditySaved": "Gültigkeit gespeichert.",
|
||||
"garminKeyCreatedOn": "Erstellt am",
|
||||
"garminKeyCreatedOnHelp": "Der Tag, an dem du den Schlüssel in Garmin Connect erstellt hast. Leer gelassen zählt die Gültigkeit ab dem Tag, an dem du ihn hier verbunden hast.",
|
||||
"garminUnavailable": "Garmin stellt für dein Konto diese Daten nicht bereit: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "Tagesübersichten",
|
||||
"activities": "Aktivitäten",
|
||||
"sleep": "Schlaf",
|
||||
"hrv": "HRV",
|
||||
"weight": "Wiegungen",
|
||||
"cycle": "Menstruationszyklus"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+72
-11
@@ -969,7 +969,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Enter a duration like 7.5, 7:30 or 7h 30m.",
|
||||
"durationTooLong": "One entry can't be longer than 24 hours.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Stress level",
|
||||
"typeTrainingReadiness": "Training readiness",
|
||||
"typeAcuteTrainingLoad": "Acute training load",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "What HealthLog can track",
|
||||
@@ -4759,7 +4764,8 @@
|
||||
"unitScore": "score",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "load"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Sleep quality",
|
||||
@@ -4781,7 +4787,11 @@
|
||||
"cardioLoadExplainer": "A training-load proxy that tracks the acute cardiovascular load you have built up. Compare it with your own recent days.",
|
||||
"averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.",
|
||||
"maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.",
|
||||
"energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively."
|
||||
"energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively.",
|
||||
"bodyBatteryExplainer": "Your energy reserve on Garmin's 0 to 100 scale, the day's highest charge. Rest and sleep fill it, stress and activity drain it.",
|
||||
"trainingReadinessExplainer": "How ready your body is for a hard session, on Garmin's 0 to 100 scale. It arrives from a bridge you set up, since Garmin does not share it directly.",
|
||||
"stressLevelExplainer": "Garmin's all-day average stress level on a 0 to 100 scale, read from your heart rate variability. Lower is calmer.",
|
||||
"acuteTrainingLoadExplainer": "Garmin's acute training load: the weighted load of your last seven days of activity. It arrives from a bridge you set up. Compare it with your own recent weeks."
|
||||
},
|
||||
"loadError": "Your recovery readings could not be loaded."
|
||||
},
|
||||
@@ -5688,7 +5698,11 @@
|
||||
"recovery": "Recovery",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Sleep debt",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Stress level",
|
||||
"trainingReadiness": "Training readiness",
|
||||
"acuteTrainingLoad": "Acute training load"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5706,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "External device",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Watch",
|
||||
@@ -7500,11 +7515,6 @@
|
||||
"lastEnteredHere": "Last entered here {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores (Body Battery, Training Load, HRV Status, VO₂ max) are not shared by Garmin to either platform.",
|
||||
"docsLink": "Read the guide"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Glucose reference band",
|
||||
"description": "Which band your blood-glucose readings are judged against.",
|
||||
@@ -7704,7 +7714,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "You are signed out and your account is locked right away. After {days} days it is erased for good, with all health data, passkeys, sessions and audit entries. Until then, an admin of this server can undo the deletion. Copies in this server's backups age out after their retention period.",
|
||||
"deleteAccountScheduled": "Your account is locked and will be erased on {date}. Signing you out.",
|
||||
"dangerZoneSuccessGrace": "Your health record has been deleted. An admin of this server can restore it until {date}.",
|
||||
"dangerZoneRestoreLimits": "Access ends too: what you share with others, what they share with you, and any Guardian role. A restore puts the record back as it is now. Anything added after the deletion is replaced, and integration connections, sharing, Guardian roles and share links do not come back; they have to be set up again."
|
||||
"dangerZoneRestoreLimits": "Access ends too: what you share with others, what they share with you, and any Guardian role. A restore puts the record back as it is now. Anything added after the deletion is replaced, and integration connections, sharing, Guardian roles and share links do not come back; they have to be set up again.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Paste a Garmin Personal API key to sync sleep, HRV, Body Battery, stress, steps, weight and activities from Garmin Connect.",
|
||||
"garminSync": "Sync now",
|
||||
"garminSyncResult": "{count} entries synchronized",
|
||||
"garminSyncFailed": "Sync failed",
|
||||
"garminViewData": "View your recovery",
|
||||
"garminReplaceKey": "Replace key",
|
||||
"garminDisconnect": "Disconnect",
|
||||
"garminDisconnectTitle": "Disconnect Garmin?",
|
||||
"garminDisconnectDescription": "Future syncs stop and the key is removed from this server. Data already imported stays in your history. The key keeps working at Garmin until you revoke it there.",
|
||||
"garminKeyValidUntil": "Key valid until {date}",
|
||||
"garminKeyExpired": "Your Garmin key expired on {date}. Create a new key in Garmin Connect and paste it here to resume syncing.",
|
||||
"garminKeyExpiresToday": "Your Garmin key expires today. Create a new key in Garmin Connect and paste it here so syncing does not stop.",
|
||||
"garminKeyExpiring": "Your Garmin key expires on {date}. Create a new key in Garmin Connect and paste it here before then.",
|
||||
"garminKeyExpiryUnknown": "Garmin does not say when this key expires. Replace the key and choose the validity you picked in Garmin Connect, and this card will warn you a week ahead.",
|
||||
"garminConnected": "Garmin connected. The first sync is on its way.",
|
||||
"garminKeyHowTo": "In Garmin Connect, open Settings, then Personal API, and create a key. Garmin shows it only once, so copy it straight away.",
|
||||
"garminKeySettingsLink": "Open Garmin Connect",
|
||||
"garminApiKey": "Personal API key",
|
||||
"garminApiKeyPlaceholder": "Paste the key",
|
||||
"garminApiKeySavedPlaceholder": "Saved. Paste a new key to replace it",
|
||||
"garminKeyValidity": "Validity you picked",
|
||||
"garminKeyValidityUnknown": "I don't know",
|
||||
"garminKeyValidityDays": "{count} days",
|
||||
"garminKeyValidityHelp": "Garmin does not report when a key expires. With the validity, this card warns you a week before the key stops working.",
|
||||
"garminConnect": "Connect",
|
||||
"garminUpdate": "Save changes",
|
||||
"garminRegion": "Garmin service",
|
||||
"garminRegionGlobal": "Garmin Connect (global)",
|
||||
"garminRegionChina": "Garmin Connect China",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin did not accept this key. Check that it was copied whole, belongs to the chosen service and has not expired or been revoked.",
|
||||
"rateLimited": "Garmin is limiting requests right now. Try again in a few minutes.",
|
||||
"unreachable": "Garmin cannot be reached. Try again in a few minutes.",
|
||||
"invalid": "That key does not look right. Paste it again, without spaces.",
|
||||
"generic": "That did not work. Try again."
|
||||
},
|
||||
"garminKeyValidityMenu": "Key validity",
|
||||
"garminValiditySaved": "Validity saved.",
|
||||
"garminKeyCreatedOn": "Created on",
|
||||
"garminKeyCreatedOnHelp": "The day you created the key in Garmin Connect. Leave it empty to count from the day you connected it here.",
|
||||
"garminUnavailable": "Garmin does not provide this data for your account: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "daily summaries",
|
||||
"activities": "activities",
|
||||
"sleep": "sleep",
|
||||
"hrv": "HRV",
|
||||
"weight": "weigh-ins",
|
||||
"cycle": "menstrual cycle"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+72
-11
@@ -969,7 +969,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Escribe una duración como 7,5, 7:30 o 7h 30m.",
|
||||
"durationTooLong": "Una entrada no puede durar más de 24 horas.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Nivel de estrés",
|
||||
"typeTrainingReadiness": "Preparación para entrenar",
|
||||
"typeAcuteTrainingLoad": "Carga de entrenamiento aguda",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Lo que HealthLog puede registrar",
|
||||
@@ -4759,7 +4764,8 @@
|
||||
"unitScore": "puntos",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "ppm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "carga"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Calidad del sueño",
|
||||
@@ -4781,7 +4787,11 @@
|
||||
"cardioLoadExplainer": "Una aproximación de la carga de entrenamiento que sigue la carga cardiovascular acumulada. Compárala con tus propios días recientes.",
|
||||
"averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.",
|
||||
"maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.",
|
||||
"energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa."
|
||||
"energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa.",
|
||||
"bodyBatteryExplainer": "Tu reserva de energía en la escala de 0 a 100 de Garmin, la carga más alta del día. El descanso y el sueño la llenan, el estrés y la actividad la vacían.",
|
||||
"trainingReadinessExplainer": "Lo preparado que está tu cuerpo para una sesión dura, en la escala de 0 a 100 de Garmin. Llega a través de un puente que configuraste, porque Garmin no la comparte directamente.",
|
||||
"stressLevelExplainer": "El nivel de estrés medio del día según Garmin, en una escala de 0 a 100, a partir de la variabilidad de tu frecuencia cardíaca. Más bajo es más tranquilo.",
|
||||
"acuteTrainingLoadExplainer": "La carga de entrenamiento aguda de Garmin: la carga ponderada de tu actividad de los últimos siete días. Llega a través de un puente que configuraste. Compárala con tus propias semanas recientes."
|
||||
},
|
||||
"loadError": "No se pudieron cargar tus valores de recuperación."
|
||||
},
|
||||
@@ -5688,7 +5698,11 @@
|
||||
"recovery": "Recuperación",
|
||||
"stress": "Estrés",
|
||||
"sleepDebt": "Deuda de sueño",
|
||||
"hrvRmssd": "VFC (RMSSD)"
|
||||
"hrvRmssd": "VFC (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Nivel de estrés",
|
||||
"trainingReadiness": "Preparación para entrenar",
|
||||
"acuteTrainingLoad": "Carga de entrenamiento aguda"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5706,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo externo",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Reloj",
|
||||
@@ -7500,11 +7515,6 @@
|
||||
"lastEnteredHere": "Último registro aquí {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin (Body Battery, Training Load, estado de HRV, VO₂ máx.) no las comparte Garmin con ninguna de las dos plataformas.",
|
||||
"docsLink": "Leer la guía"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Banda de referencia de glucosa",
|
||||
"description": "Con qué banda se evalúan tus valores de glucosa en sangre.",
|
||||
@@ -7704,7 +7714,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Se cierra tu sesión y tu cuenta se bloquea de inmediato. Al cabo de {days} días se borra para siempre, con todos tus datos de salud, llaves de acceso, sesiones y entradas de auditoría. Hasta entonces, un administrador de este servidor puede deshacer la eliminación. Las copias en las copias de seguridad de este servidor caducan al terminar su periodo de conservación.",
|
||||
"deleteAccountScheduled": "Tu cuenta está bloqueada y se borrará el {date}. Cerrando tu sesión.",
|
||||
"dangerZoneSuccessGrace": "Tu historial de salud se ha eliminado. Un administrador de este servidor puede restaurarlo hasta el {date}.",
|
||||
"dangerZoneRestoreLimits": "También termina el acceso: lo que compartes con otras personas, lo que ellas comparten contigo y cualquier papel de persona responsable. Una restauración devuelve el historial tal como está ahora. Lo que se añada después de la eliminación se sustituye, y las conexiones de integración, lo compartido, los papeles de persona responsable y los enlaces compartidos no vuelven; hay que configurarlos de nuevo."
|
||||
"dangerZoneRestoreLimits": "También termina el acceso: lo que compartes con otras personas, lo que ellas comparten contigo y cualquier papel de persona responsable. Una restauración devuelve el historial tal como está ahora. Lo que se añada después de la eliminación se sustituye, y las conexiones de integración, lo compartido, los papeles de persona responsable y los enlaces compartidos no vuelven; hay que configurarlos de nuevo.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Pega una clave de la Garmin Personal API para sincronizar sueño, VFC, Body Battery, estrés, pasos, peso y actividades desde Garmin Connect.",
|
||||
"garminSync": "Sincronizar ahora",
|
||||
"garminSyncResult": "{count} entradas sincronizadas",
|
||||
"garminSyncFailed": "Error de sincronización",
|
||||
"garminViewData": "Ver tu recuperación",
|
||||
"garminReplaceKey": "Reemplazar la clave",
|
||||
"garminDisconnect": "Desconectar",
|
||||
"garminDisconnectTitle": "¿Desconectar Garmin?",
|
||||
"garminDisconnectDescription": "Las sincronizaciones futuras se detienen y la clave se elimina de este servidor. Los datos ya importados se quedan en tu historial. La clave sigue funcionando en Garmin hasta que la revoques allí.",
|
||||
"garminKeyValidUntil": "Clave válida hasta el {date}",
|
||||
"garminKeyExpired": "Tu clave de Garmin caducó el {date}. Crea una clave nueva en Garmin Connect y pégala aquí para reanudar la sincronización.",
|
||||
"garminKeyExpiresToday": "Tu clave de Garmin caduca hoy. Crea una clave nueva en Garmin Connect y pégala aquí para que la sincronización no se detenga.",
|
||||
"garminKeyExpiring": "Tu clave de Garmin caduca el {date}. Crea una clave nueva en Garmin Connect y pégala aquí antes de esa fecha.",
|
||||
"garminKeyExpiryUnknown": "Garmin no indica cuándo caduca esta clave. Reemplaza la clave y elige la validez que seleccionaste en Garmin Connect, y esta tarjeta te avisará con una semana de antelación.",
|
||||
"garminConnected": "Garmin conectado. La primera sincronización está en marcha.",
|
||||
"garminKeyHowTo": "En Garmin Connect, abre Configuración, luego Personal API, y crea una clave. Garmin solo la muestra una vez, así que cópiala enseguida.",
|
||||
"garminKeySettingsLink": "Abrir Garmin Connect",
|
||||
"garminApiKey": "Clave de la Personal API",
|
||||
"garminApiKeyPlaceholder": "Pega la clave",
|
||||
"garminApiKeySavedPlaceholder": "Guardada. Pega una clave nueva para reemplazarla",
|
||||
"garminKeyValidity": "Validez elegida",
|
||||
"garminKeyValidityUnknown": "No lo sé",
|
||||
"garminKeyValidityDays": "{count} días",
|
||||
"garminKeyValidityHelp": "Garmin no informa de cuándo caduca una clave. Con la validez, esta tarjeta te avisa una semana antes de que la clave deje de funcionar.",
|
||||
"garminConnect": "Conectar",
|
||||
"garminUpdate": "Guardar cambios",
|
||||
"garminRegion": "Servicio de Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (global)",
|
||||
"garminRegionChina": "Garmin Connect China",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin no aceptó esta clave. Comprueba que se copió entera, que pertenece al servicio elegido y que no ha caducado ni se ha revocado.",
|
||||
"rateLimited": "Garmin está limitando las solicitudes. Inténtalo de nuevo en unos minutos.",
|
||||
"unreachable": "No se puede contactar con Garmin. Inténtalo de nuevo en unos minutos.",
|
||||
"invalid": "Esa clave no parece correcta. Pégala de nuevo, sin espacios.",
|
||||
"generic": "No ha funcionado. Inténtalo de nuevo."
|
||||
},
|
||||
"garminKeyValidityMenu": "Validez de la clave",
|
||||
"garminValiditySaved": "Validez guardada.",
|
||||
"garminKeyCreatedOn": "Creada el",
|
||||
"garminKeyCreatedOnHelp": "El día en que creaste la clave en Garmin Connect. Si lo dejas vacío, se cuenta desde el día en que la conectaste aquí.",
|
||||
"garminUnavailable": "Garmin no ofrece estos datos para tu cuenta: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "resúmenes diarios",
|
||||
"activities": "actividades",
|
||||
"sleep": "sueño",
|
||||
"hrv": "VFC",
|
||||
"weight": "pesajes",
|
||||
"cycle": "ciclo menstrual"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+72
-11
@@ -969,7 +969,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Saisissez une durée comme 7,5, 7:30 ou 7h 30m.",
|
||||
"durationTooLong": "Une saisie ne peut pas dépasser 24 heures.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Niveau de stress",
|
||||
"typeTrainingReadiness": "Disponibilité à l'entraînement",
|
||||
"typeAcuteTrainingLoad": "Charge d'entraînement aiguë",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Ce que HealthLog peut suivre",
|
||||
@@ -4759,7 +4764,8 @@
|
||||
"unitScore": "points",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "charge"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Qualité du sommeil",
|
||||
@@ -4781,7 +4787,11 @@
|
||||
"cardioLoadExplainer": "Une estimation de la charge d'entraînement qui suit la charge cardiovasculaire accumulée. Comparez-la à vos propres journées récentes.",
|
||||
"averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
|
||||
"maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
|
||||
"energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement."
|
||||
"energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement.",
|
||||
"bodyBatteryExplainer": "Votre réserve d'énergie sur l'échelle de 0 à 100 de Garmin, le niveau le plus haut de la journée. Le repos et le sommeil la rechargent, le stress et l'activité la vident.",
|
||||
"trainingReadinessExplainer": "À quel point votre corps est prêt pour une séance intense, sur l'échelle de 0 à 100 de Garmin. La valeur arrive par une passerelle que vous avez configurée, car Garmin ne la partage pas directement.",
|
||||
"stressLevelExplainer": "Le niveau de stress moyen de la journée selon Garmin, sur une échelle de 0 à 100, tiré de la variabilité de votre fréquence cardiaque. Plus bas signifie plus calme.",
|
||||
"acuteTrainingLoadExplainer": "La charge d'entraînement aiguë de Garmin : la charge pondérée de vos activités des sept derniers jours. Elle arrive par une passerelle que vous avez configurée. Comparez-la à vos propres semaines récentes."
|
||||
},
|
||||
"loadError": "Vos valeurs de récupération n'ont pas pu être chargées."
|
||||
},
|
||||
@@ -5688,7 +5698,11 @@
|
||||
"recovery": "Récupération",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Dette de sommeil",
|
||||
"hrvRmssd": "VFC (RMSSD)"
|
||||
"hrvRmssd": "VFC (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Niveau de stress",
|
||||
"trainingReadiness": "Disponibilité à l'entraînement",
|
||||
"acuteTrainingLoad": "Charge d'entraînement aiguë"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5706,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Appareil externe",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Montre",
|
||||
@@ -7500,11 +7515,6 @@
|
||||
"lastEnteredHere": "Dernière saisie ici {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin n'a pas de connexion directe ici. Connectez-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin (Body Battery, Training Load, statut VFC, VO₂ max) ne sont partagés par Garmin avec aucune des deux plateformes.",
|
||||
"docsLink": "Lire le guide"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Plage de référence du glucose",
|
||||
"description": "La plage à laquelle vos glycémies sont comparées.",
|
||||
@@ -7704,7 +7714,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Vous êtes déconnecté et votre compte est verrouillé immédiatement. Au bout de {days} jours, il est effacé définitivement, avec toutes vos données de santé, vos clés d’accès, vos sessions et vos entrées d’audit. D’ici là, un administrateur de ce serveur peut annuler la suppression. Les copies dans les sauvegardes de ce serveur expirent à la fin de leur durée de conservation.",
|
||||
"deleteAccountScheduled": "Votre compte est verrouillé et sera effacé le {date}. Déconnexion en cours.",
|
||||
"dangerZoneSuccessGrace": "Votre dossier de santé a été supprimé. Un administrateur de ce serveur peut le restaurer jusqu’au {date}.",
|
||||
"dangerZoneRestoreLimits": "Les accès prennent fin aussi : ce que vous partagez avec d’autres, ce qu’ils partagent avec vous, et tout rôle de personne responsable. Une restauration remet le dossier tel qu’il est maintenant. Ce qui est ajouté après la suppression est remplacé, et les connexions d’intégration, les partages, les rôles de personne responsable et les liens de partage ne reviennent pas ; il faut les configurer à nouveau."
|
||||
"dangerZoneRestoreLimits": "Les accès prennent fin aussi : ce que vous partagez avec d’autres, ce qu’ils partagent avec vous, et tout rôle de personne responsable. Une restauration remet le dossier tel qu’il est maintenant. Ce qui est ajouté après la suppression est remplacé, et les connexions d’intégration, les partages, les rôles de personne responsable et les liens de partage ne reviennent pas ; il faut les configurer à nouveau.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Collez une clé Garmin Personal API pour synchroniser le sommeil, la VFC, la Body Battery, le stress, les pas, le poids et les activités depuis Garmin Connect.",
|
||||
"garminSync": "Synchroniser maintenant",
|
||||
"garminSyncResult": "{count} entrées synchronisées",
|
||||
"garminSyncFailed": "Échec de la synchronisation",
|
||||
"garminViewData": "Voir votre récupération",
|
||||
"garminReplaceKey": "Remplacer la clé",
|
||||
"garminDisconnect": "Déconnecter",
|
||||
"garminDisconnectTitle": "Déconnecter Garmin ?",
|
||||
"garminDisconnectDescription": "Les synchronisations futures s'arrêtent et la clé est supprimée de ce serveur. Les données déjà importées restent dans votre historique. La clé reste valide chez Garmin jusqu'à ce que vous la révoquiez.",
|
||||
"garminKeyValidUntil": "Clé valide jusqu'au {date}",
|
||||
"garminKeyExpired": "Votre clé Garmin a expiré le {date}. Créez une nouvelle clé dans Garmin Connect et collez-la ici pour reprendre la synchronisation.",
|
||||
"garminKeyExpiresToday": "Votre clé Garmin expire aujourd'hui. Créez une nouvelle clé dans Garmin Connect et collez-la ici pour que la synchronisation continue.",
|
||||
"garminKeyExpiring": "Votre clé Garmin expire le {date}. Créez une nouvelle clé dans Garmin Connect et collez-la ici avant cette date.",
|
||||
"garminKeyExpiryUnknown": "Garmin n'indique pas quand cette clé expire. Remplacez la clé et choisissez la validité sélectionnée dans Garmin Connect : cette carte vous préviendra une semaine avant.",
|
||||
"garminConnected": "Garmin connecté. La première synchronisation est en route.",
|
||||
"garminKeyHowTo": "Dans Garmin Connect, ouvrez Paramètres, puis Personal API, et créez une clé. Garmin ne l'affiche qu'une fois : copiez-la tout de suite.",
|
||||
"garminKeySettingsLink": "Ouvrir Garmin Connect",
|
||||
"garminApiKey": "Clé Personal API",
|
||||
"garminApiKeyPlaceholder": "Collez la clé",
|
||||
"garminApiKeySavedPlaceholder": "Enregistrée. Collez une nouvelle clé pour la remplacer",
|
||||
"garminKeyValidity": "Validité choisie",
|
||||
"garminKeyValidityUnknown": "Je ne sais pas",
|
||||
"garminKeyValidityDays": "{count} jours",
|
||||
"garminKeyValidityHelp": "Garmin n'indique pas quand une clé expire. Avec la validité, cette carte vous prévient une semaine avant que la clé cesse de fonctionner.",
|
||||
"garminConnect": "Connecter",
|
||||
"garminUpdate": "Enregistrer les modifications",
|
||||
"garminRegion": "Service Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (international)",
|
||||
"garminRegionChina": "Garmin Connect Chine",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin n'a pas accepté cette clé. Vérifiez qu'elle a été copiée en entier, qu'elle appartient au service choisi et qu'elle n'a ni expiré ni été révoquée.",
|
||||
"rateLimited": "Garmin limite les requêtes en ce moment. Réessayez dans quelques minutes.",
|
||||
"unreachable": "Impossible de joindre Garmin. Réessayez dans quelques minutes.",
|
||||
"invalid": "Cette clé ne semble pas correcte. Collez-la à nouveau, sans espaces.",
|
||||
"generic": "Cela n'a pas fonctionné. Réessayez."
|
||||
},
|
||||
"garminKeyValidityMenu": "Validité de la clé",
|
||||
"garminValiditySaved": "Validité enregistrée.",
|
||||
"garminKeyCreatedOn": "Créée le",
|
||||
"garminKeyCreatedOnHelp": "Le jour où vous avez créé la clé dans Garmin Connect. Laissé vide, le décompte part du jour où vous l'avez connectée ici.",
|
||||
"garminUnavailable": "Garmin ne fournit pas ces données pour votre compte : {list}.",
|
||||
"garminResource": {
|
||||
"daily": "résumés quotidiens",
|
||||
"activities": "activités",
|
||||
"sleep": "sommeil",
|
||||
"hrv": "VFC",
|
||||
"weight": "pesées",
|
||||
"cycle": "cycle menstruel"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+72
-11
@@ -969,7 +969,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Inserisci una durata come 7,5, 7:30 o 7h 30m.",
|
||||
"durationTooLong": "Una voce non può superare le 24 ore.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Livello di stress",
|
||||
"typeTrainingReadiness": "Prontezza all'allenamento",
|
||||
"typeAcuteTrainingLoad": "Carico di allenamento acuto",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Cosa può monitorare HealthLog",
|
||||
@@ -4759,7 +4764,8 @@
|
||||
"unitScore": "punti",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "carico"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Qualità del sonno",
|
||||
@@ -4781,7 +4787,11 @@
|
||||
"cardioLoadExplainer": "Una stima del carico di allenamento che traccia il carico cardiovascolare accumulato. Confrontala con i tuoi giorni recenti.",
|
||||
"averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
|
||||
"maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
|
||||
"energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo."
|
||||
"energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo.",
|
||||
"bodyBatteryExplainer": "La tua riserva di energia sulla scala da 0 a 100 di Garmin, il livello più alto della giornata. Riposo e sonno la ricaricano, stress e attività la consumano.",
|
||||
"trainingReadinessExplainer": "Quanto il tuo corpo è pronto per una sessione impegnativa, sulla scala da 0 a 100 di Garmin. Arriva tramite un ponte che hai configurato, perché Garmin non la condivide direttamente.",
|
||||
"stressLevelExplainer": "Il livello di stress medio della giornata secondo Garmin, su una scala da 0 a 100, ricavato dalla variabilità della frequenza cardiaca. Più basso significa più calmo.",
|
||||
"acuteTrainingLoadExplainer": "Il carico di allenamento acuto di Garmin: il carico ponderato delle tue attività degli ultimi sette giorni. Arriva tramite un ponte che hai configurato. Confrontalo con le tue settimane recenti."
|
||||
},
|
||||
"loadError": "Non è stato possibile caricare i tuoi valori di recupero."
|
||||
},
|
||||
@@ -5688,7 +5698,11 @@
|
||||
"recovery": "Recupero",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Debito di sonno",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Livello di stress",
|
||||
"trainingReadiness": "Prontezza all'allenamento",
|
||||
"acuteTrainingLoad": "Carico di allenamento acuto"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5706,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo esterno",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Orologio",
|
||||
@@ -7500,11 +7515,6 @@
|
||||
"lastEnteredHere": "Ultimo inserimento qui {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin (Body Battery, Training Load, stato HRV, VO₂ max) non vengono condivisi da Garmin con nessuna delle due piattaforme.",
|
||||
"docsLink": "Leggi la guida"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Banda di riferimento della glicemia",
|
||||
"description": "La banda con cui vengono valutati i tuoi valori glicemici.",
|
||||
@@ -7704,7 +7714,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Vieni disconnesso e il tuo account viene bloccato subito. Dopo {days} giorni viene cancellato definitivamente, con tutti i dati sanitari, le passkey, le sessioni e le voci di audit. Fino ad allora un amministratore di questo server può annullare l’eliminazione. Le copie nei backup di questo server scadono al termine del loro periodo di conservazione.",
|
||||
"deleteAccountScheduled": "Il tuo account è bloccato e verrà cancellato il {date}. Disconnessione in corso.",
|
||||
"dangerZoneSuccessGrace": "La tua cartella sanitaria è stata eliminata. Un amministratore di questo server può ripristinarla fino al {date}.",
|
||||
"dangerZoneRestoreLimits": "Finisce anche l’accesso: ciò che condividi con altri, ciò che altri condividono con te e ogni ruolo di persona responsabile. Un ripristino riporta la cartella com’è adesso. Ciò che si aggiunge dopo l’eliminazione viene sostituito, e le connessioni di integrazione, le condivisioni, i ruoli di persona responsabile e i link di condivisione non tornano; vanno configurati di nuovo."
|
||||
"dangerZoneRestoreLimits": "Finisce anche l’accesso: ciò che condividi con altri, ciò che altri condividono con te e ogni ruolo di persona responsabile. Un ripristino riporta la cartella com’è adesso. Ciò che si aggiunge dopo l’eliminazione viene sostituito, e le connessioni di integrazione, le condivisioni, i ruoli di persona responsabile e i link di condivisione non tornano; vanno configurati di nuovo.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Incolla una chiave Garmin Personal API per sincronizzare sonno, HRV, Body Battery, stress, passi, peso e attività da Garmin Connect.",
|
||||
"garminSync": "Sincronizza ora",
|
||||
"garminSyncResult": "{count} voci sincronizzate",
|
||||
"garminSyncFailed": "Sincronizzazione non riuscita",
|
||||
"garminViewData": "Vedi il tuo recupero",
|
||||
"garminReplaceKey": "Sostituisci la chiave",
|
||||
"garminDisconnect": "Disconnetti",
|
||||
"garminDisconnectTitle": "Disconnettere Garmin?",
|
||||
"garminDisconnectDescription": "Le sincronizzazioni future si fermano e la chiave viene rimossa da questo server. I dati già importati restano nella tua cronologia. La chiave resta valida su Garmin finché non la revochi lì.",
|
||||
"garminKeyValidUntil": "Chiave valida fino al {date}",
|
||||
"garminKeyExpired": "La tua chiave Garmin è scaduta il {date}. Crea una nuova chiave in Garmin Connect e incollala qui per riprendere la sincronizzazione.",
|
||||
"garminKeyExpiresToday": "La tua chiave Garmin scade oggi. Crea una nuova chiave in Garmin Connect e incollala qui perché la sincronizzazione non si fermi.",
|
||||
"garminKeyExpiring": "La tua chiave Garmin scade il {date}. Crea prima una nuova chiave in Garmin Connect e incollala qui.",
|
||||
"garminKeyExpiryUnknown": "Garmin non dice quando scade questa chiave. Sostituisci la chiave e scegli la validità impostata in Garmin Connect: questa scheda ti avviserà una settimana prima.",
|
||||
"garminConnected": "Garmin collegato. La prima sincronizzazione è in arrivo.",
|
||||
"garminKeyHowTo": "In Garmin Connect apri Impostazioni, poi Personal API, e crea una chiave. Garmin la mostra una sola volta, quindi copiala subito.",
|
||||
"garminKeySettingsLink": "Apri Garmin Connect",
|
||||
"garminApiKey": "Chiave Personal API",
|
||||
"garminApiKeyPlaceholder": "Incolla la chiave",
|
||||
"garminApiKeySavedPlaceholder": "Salvata. Incolla una nuova chiave per sostituirla",
|
||||
"garminKeyValidity": "Validità scelta",
|
||||
"garminKeyValidityUnknown": "Non lo so",
|
||||
"garminKeyValidityDays": "{count} giorni",
|
||||
"garminKeyValidityHelp": "Garmin non segnala quando una chiave scade. Con la validità, questa scheda ti avvisa una settimana prima che la chiave smetta di funzionare.",
|
||||
"garminConnect": "Collega",
|
||||
"garminUpdate": "Salva modifiche",
|
||||
"garminRegion": "Servizio Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (globale)",
|
||||
"garminRegionChina": "Garmin Connect Cina",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin non ha accettato questa chiave. Controlla che sia stata copiata per intero, che appartenga al servizio scelto e che non sia scaduta o revocata.",
|
||||
"rateLimited": "Garmin sta limitando le richieste. Riprova tra qualche minuto.",
|
||||
"unreachable": "Garmin non è raggiungibile. Riprova tra qualche minuto.",
|
||||
"invalid": "Questa chiave non sembra corretta. Incollala di nuovo, senza spazi.",
|
||||
"generic": "Non ha funzionato. Riprova."
|
||||
},
|
||||
"garminKeyValidityMenu": "Validità della chiave",
|
||||
"garminValiditySaved": "Validità salvata.",
|
||||
"garminKeyCreatedOn": "Creata il",
|
||||
"garminKeyCreatedOnHelp": "Il giorno in cui hai creato la chiave in Garmin Connect. Se lo lasci vuoto, si conta dal giorno in cui l'hai collegata qui.",
|
||||
"garminUnavailable": "Garmin non fornisce questi dati per il tuo account: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "riepiloghi giornalieri",
|
||||
"activities": "attività",
|
||||
"sleep": "sonno",
|
||||
"hrv": "HRV",
|
||||
"weight": "pesate",
|
||||
"cycle": "ciclo mestruale"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
|
||||
+72
-11
@@ -969,7 +969,12 @@
|
||||
"durationMinutesOnly": "{minutes}분",
|
||||
"durationInvalid": "7.5, 7:30, 7h 30m처럼 시간을 입력해 주세요.",
|
||||
"durationTooLong": "한 번에 24시간보다 길게 입력할 수 없어요.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "바디 배터리",
|
||||
"typeStressLevel": "스트레스 수준",
|
||||
"typeTrainingReadiness": "트레이닝 준비도",
|
||||
"typeAcuteTrainingLoad": "급성 훈련 부하",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "HealthLog가 기록할 수 있는 것",
|
||||
@@ -4759,7 +4764,8 @@
|
||||
"unitScore": "점",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "부하"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "수면 품질",
|
||||
@@ -4781,7 +4787,11 @@
|
||||
"cardioLoadExplainer": "쌓인 급성 심혈관 부하를 따라가는 훈련 부하 지표예요. 내 최근 며칠과 비교해 보세요.",
|
||||
"averageHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 평균 심박수예요.",
|
||||
"maxHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 최고 심박수예요.",
|
||||
"energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄)."
|
||||
"energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄).",
|
||||
"bodyBatteryExplainer": "Garmin의 0~100 척도로 본 에너지 비축량으로, 하루 중 가장 높았던 값입니다. 휴식과 수면은 채우고, 스트레스와 활동은 소모합니다.",
|
||||
"trainingReadinessExplainer": "Garmin의 0~100 척도로 본 고강도 운동에 대한 준비 정도입니다. Garmin이 직접 공유하지 않으므로 직접 설정한 브리지를 통해 들어옵니다.",
|
||||
"stressLevelExplainer": "심박변이도에서 읽어낸 Garmin의 하루 평균 스트레스 수준(0~100)입니다. 낮을수록 차분한 상태입니다.",
|
||||
"acuteTrainingLoadExplainer": "Garmin의 급성 훈련 부하로, 최근 7일 동안의 활동 부하를 가중 합산한 값입니다. 직접 설정한 브리지를 통해 들어옵니다. 최근 몇 주의 내 기록과 비교해 보세요."
|
||||
},
|
||||
"loadError": "회복 값을 불러오지 못했어요."
|
||||
},
|
||||
@@ -5688,7 +5698,11 @@
|
||||
"recovery": "회복",
|
||||
"stress": "스트레스",
|
||||
"sleepDebt": "수면 부채",
|
||||
"hrvRmssd": "심박변이도 (RMSSD)"
|
||||
"hrvRmssd": "심박변이도 (RMSSD)",
|
||||
"bodyBattery": "바디 배터리",
|
||||
"stressLevel": "스트레스 수준",
|
||||
"trainingReadiness": "트레이닝 준비도",
|
||||
"acuteTrainingLoad": "급성 훈련 부하"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5706,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "외부 기기",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "워치",
|
||||
@@ -7500,11 +7515,6 @@
|
||||
"lastEnteredHere": "여기에서 마지막 입력 {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "여기서는 Garmin에 직접 연결할 수 없어요. Apple Health(iOS)나 Google Health Connect(Android)를 거쳐 연결하세요. Body Battery, Training Load, HRV Status, VO₂ max 같은 Garmin 고유 점수는 Garmin이 두 플랫폼 어디에도 공유하지 않아요.",
|
||||
"docsLink": "가이드 보기"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "혈당 참고 구간",
|
||||
"description": "혈당 측정값을 어떤 구간에 견줘 판단할지 정해요.",
|
||||
@@ -7704,7 +7714,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "바로 로그아웃되고 계정이 잠겨요. {days}일 뒤에는 모든 건강 데이터, 패스키, 세션, 감사 기록과 함께 완전히 지워져요. 그때까지는 이 서버의 관리자가 삭제를 취소할 수 있어요. 이 서버 백업에 남은 사본은 보관 기간이 지나면 사라져요.",
|
||||
"deleteAccountScheduled": "계정이 잠겼고 {date}에 완전히 지워져요. 로그아웃하는 중이에요.",
|
||||
"dangerZoneSuccessGrace": "건강 기록이 삭제됐어요. {date}까지는 이 서버의 관리자가 복원할 수 있어요.",
|
||||
"dangerZoneRestoreLimits": "접근 권한도 끝나요: 다른 사람과 공유한 것, 다른 사람이 나와 공유한 것, 보호자 역할 모두요. 복원하면 기록이 지금 상태로 돌아와요. 삭제 후에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 역할, 공유 링크는 돌아오지 않아서 다시 설정해야 해요."
|
||||
"dangerZoneRestoreLimits": "접근 권한도 끝나요: 다른 사람과 공유한 것, 다른 사람이 나와 공유한 것, 보호자 역할 모두요. 복원하면 기록이 지금 상태로 돌아와요. 삭제 후에 추가된 것은 대체되고, 연동 연결, 공유, 보호자 역할, 공유 링크는 돌아오지 않아서 다시 설정해야 해요.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Garmin Personal API 키를 붙여 넣으면 Garmin Connect의 수면, HRV, 바디 배터리, 스트레스, 걸음 수, 체중, 활동이 동기화됩니다.",
|
||||
"garminSync": "지금 동기화",
|
||||
"garminSyncResult": "{count}개 항목 동기화됨",
|
||||
"garminSyncFailed": "동기화 실패",
|
||||
"garminViewData": "회복 보기",
|
||||
"garminReplaceKey": "키 교체",
|
||||
"garminDisconnect": "연결 해제",
|
||||
"garminDisconnectTitle": "Garmin 연결을 해제할까요?",
|
||||
"garminDisconnectDescription": "앞으로의 동기화가 중지되고 키가 이 서버에서 삭제됩니다. 이미 가져온 데이터는 기록에 남습니다. 키는 Garmin에서 취소할 때까지 계속 유효합니다.",
|
||||
"garminKeyValidUntil": "키 유효 기간: {date}까지",
|
||||
"garminKeyExpired": "Garmin 키가 {date}에 만료되었습니다. Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으면 동기화가 다시 시작됩니다.",
|
||||
"garminKeyExpiresToday": "Garmin 키가 오늘 만료됩니다. 동기화가 멈추지 않도록 Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으세요.",
|
||||
"garminKeyExpiring": "Garmin 키가 {date}에 만료됩니다. 그 전에 Garmin Connect에서 새 키를 만들어 여기에 붙여 넣으세요.",
|
||||
"garminKeyExpiryUnknown": "Garmin은 이 키의 만료일을 알려 주지 않습니다. 키를 교체하면서 Garmin Connect에서 고른 유효 기간을 선택하면 이 카드가 일주일 전에 알려 드립니다.",
|
||||
"garminConnected": "Garmin이 연결되었습니다. 첫 동기화가 시작됩니다.",
|
||||
"garminKeyHowTo": "Garmin Connect에서 설정, Personal API 순으로 열고 키를 만드세요. Garmin은 키를 한 번만 보여 주므로 바로 복사해 두세요.",
|
||||
"garminKeySettingsLink": "Garmin Connect 열기",
|
||||
"garminApiKey": "Personal API 키",
|
||||
"garminApiKeyPlaceholder": "키 붙여 넣기",
|
||||
"garminApiKeySavedPlaceholder": "저장됨. 교체하려면 새 키를 붙여 넣으세요",
|
||||
"garminKeyValidity": "선택한 유효 기간",
|
||||
"garminKeyValidityUnknown": "모르겠음",
|
||||
"garminKeyValidityDays": "{count}일",
|
||||
"garminKeyValidityHelp": "Garmin은 키 만료일을 알려 주지 않습니다. 유효 기간을 알려 주면 키가 작동을 멈추기 일주일 전에 이 카드가 알려 드립니다.",
|
||||
"garminConnect": "연결",
|
||||
"garminUpdate": "변경 사항 저장",
|
||||
"garminRegion": "Garmin 서비스",
|
||||
"garminRegionGlobal": "Garmin Connect (글로벌)",
|
||||
"garminRegionChina": "Garmin Connect 중국",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin이 이 키를 받아들이지 않았습니다. 키를 빠짐없이 복사했는지, 선택한 서비스의 키인지, 만료되거나 취소되지 않았는지 확인하세요.",
|
||||
"rateLimited": "Garmin이 지금 요청을 제한하고 있습니다. 몇 분 후에 다시 시도하세요.",
|
||||
"unreachable": "Garmin에 연결할 수 없습니다. 몇 분 후에 다시 시도하세요.",
|
||||
"invalid": "키 형식이 올바르지 않습니다. 공백 없이 다시 붙여 넣으세요.",
|
||||
"generic": "실패했습니다. 다시 시도하세요."
|
||||
},
|
||||
"garminKeyValidityMenu": "키 유효 기간",
|
||||
"garminValiditySaved": "유효 기간을 저장했습니다.",
|
||||
"garminKeyCreatedOn": "생성일",
|
||||
"garminKeyCreatedOnHelp": "Garmin Connect에서 키를 만든 날입니다. 비워 두면 여기에 연결한 날부터 계산합니다.",
|
||||
"garminUnavailable": "Garmin이 내 계정에 이 데이터를 제공하지 않습니다: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "일일 요약",
|
||||
"activities": "활동",
|
||||
"sleep": "수면",
|
||||
"hrv": "HRV",
|
||||
"weight": "체중 측정",
|
||||
"cycle": "생리 주기"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "관리자",
|
||||
|
||||
+72
-11
@@ -969,7 +969,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Voer een duur in, bijvoorbeeld 7,5, 7:30 of 7h 30m.",
|
||||
"durationTooLong": "Eén registratie kan niet langer zijn dan 24 uur.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Stressniveau",
|
||||
"typeTrainingReadiness": "Trainingsgereedheid",
|
||||
"typeAcuteTrainingLoad": "Acute trainingsbelasting",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Wat HealthLog kan bijhouden",
|
||||
@@ -4759,7 +4764,8 @@
|
||||
"unitScore": "score",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "belasting"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Slaapkwaliteit",
|
||||
@@ -4778,7 +4784,11 @@
|
||||
"cardioLoadExplainer": "Een indicatie van trainingsbelasting die de acute cardiovasculaire belasting volgt die je hebt opgebouwd. Vergelijk het met je eigen recente dagen.",
|
||||
"averageHeartRateExplainer": "Je gemiddelde hartslag over de hele dag of cyclus, zoals gerapporteerd door je wearable.",
|
||||
"maxHeartRateExplainer": "Je hoogste hartslag over de hele dag of cyclus, zoals gerapporteerd door je wearable.",
|
||||
"energyExpenditureKjExplainer": "De energie die je lichaam over de dag heeft verbruikt, in kilojoule, zoals je apparaat die zelf rapporteert."
|
||||
"energyExpenditureKjExplainer": "De energie die je lichaam over de dag heeft verbruikt, in kilojoule, zoals je apparaat die zelf rapporteert.",
|
||||
"bodyBatteryExplainer": "Je energiereserve op de schaal van 0 tot 100 van Garmin, het hoogste niveau van de dag. Rust en slaap vullen hem aan, stress en activiteit putten hem uit.",
|
||||
"trainingReadinessExplainer": "Hoe klaar je lichaam is voor een zware training, op de schaal van 0 tot 100 van Garmin. De waarde komt via een brug die je zelf hebt ingesteld, omdat Garmin hem niet rechtstreeks deelt.",
|
||||
"stressLevelExplainer": "Garmins gemiddelde stressniveau over de dag op een schaal van 0 tot 100, afgeleid uit je hartslagvariabiliteit. Lager is rustiger.",
|
||||
"acuteTrainingLoadExplainer": "Garmins acute trainingsbelasting: de gewogen belasting van je activiteiten van de afgelopen zeven dagen. De waarde komt via een brug die je zelf hebt ingesteld. Vergelijk hem met je eigen recente weken."
|
||||
},
|
||||
"emptyAction": "Integraties openen",
|
||||
"emptyOuraNoResilience": "Oura is verbonden, en het veerkrachtniveau verschijnt hier zodra Oura er een levert.",
|
||||
@@ -5688,7 +5698,11 @@
|
||||
"recovery": "Herstel",
|
||||
"stress": "Stress",
|
||||
"sleepDebt": "Slaaptekort",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Stressniveau",
|
||||
"trainingReadiness": "Trainingsgereedheid",
|
||||
"acuteTrainingLoad": "Acute trainingsbelasting"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5706,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Extern apparaat",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Horloge",
|
||||
@@ -7500,11 +7515,6 @@
|
||||
"lastEnteredHere": "Laatst hier ingevoerd {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin heeft hier geen directe koppeling. Koppel via Apple Health (iOS) of Google Health Connect (Android). De eigen scores van Garmin (Body Battery, Training Load, HRV Status, VO₂ max) deelt Garmin met geen van beide platforms.",
|
||||
"docsLink": "Lees de handleiding"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Referentiebereik voor glucose",
|
||||
"description": "Aan welk bereik je bloedglucosemetingen worden getoetst.",
|
||||
@@ -7704,7 +7714,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Je wordt uitgelogd en je account wordt meteen vergrendeld. Na {days} dagen wordt het definitief gewist, met alle gezondheidsgegevens, passkeys, sessies en auditregels. Tot dan kan een beheerder van deze server de verwijdering ongedaan maken. Kopieën in de back-ups van deze server verlopen na hun bewaartermijn.",
|
||||
"deleteAccountScheduled": "Je account is vergrendeld en wordt op {date} gewist. Je wordt uitgelogd.",
|
||||
"dangerZoneSuccessGrace": "Je gezondheidsdossier is verwijderd. Een beheerder van deze server kan het tot {date} terugzetten.",
|
||||
"dangerZoneRestoreLimits": "Ook toegang eindigt: wat je met anderen deelt, wat anderen met jou delen en elke rol als verzorger. Terugzetten brengt het dossier terug zoals het nu is. Wat na het verwijderen is toegevoegd, wordt vervangen, en koppelingen, delen, verzorgersrollen en deellinks komen niet terug; die moeten opnieuw worden ingesteld."
|
||||
"dangerZoneRestoreLimits": "Ook toegang eindigt: wat je met anderen deelt, wat anderen met jou delen en elke rol als verzorger. Terugzetten brengt het dossier terug zoals het nu is. Wat na het verwijderen is toegevoegd, wordt vervangen, en koppelingen, delen, verzorgersrollen en deellinks komen niet terug; die moeten opnieuw worden ingesteld.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Plak een Garmin Personal API-sleutel om slaap, HRV, Body Battery, stress, stappen, gewicht en activiteiten uit Garmin Connect te synchroniseren.",
|
||||
"garminSync": "Nu synchroniseren",
|
||||
"garminSyncResult": "{count} items gesynchroniseerd",
|
||||
"garminSyncFailed": "Synchronisatie mislukt",
|
||||
"garminViewData": "Je herstel bekijken",
|
||||
"garminReplaceKey": "Sleutel vervangen",
|
||||
"garminDisconnect": "Ontkoppelen",
|
||||
"garminDisconnectTitle": "Garmin ontkoppelen?",
|
||||
"garminDisconnectDescription": "Toekomstige synchronisaties stoppen en de sleutel wordt van deze server verwijderd. Al geïmporteerde gegevens blijven in je geschiedenis. Bij Garmin blijft de sleutel werken tot je hem daar intrekt.",
|
||||
"garminKeyValidUntil": "Sleutel geldig tot {date}",
|
||||
"garminKeyExpired": "Je Garmin-sleutel is op {date} verlopen. Maak in Garmin Connect een nieuwe sleutel en plak die hier om weer te synchroniseren.",
|
||||
"garminKeyExpiresToday": "Je Garmin-sleutel verloopt vandaag. Maak in Garmin Connect een nieuwe sleutel en plak die hier zodat het synchroniseren niet stopt.",
|
||||
"garminKeyExpiring": "Je Garmin-sleutel verloopt op {date}. Maak vóór die datum in Garmin Connect een nieuwe sleutel en plak die hier.",
|
||||
"garminKeyExpiryUnknown": "Garmin vertelt niet wanneer deze sleutel verloopt. Vervang de sleutel en kies de geldigheid die je in Garmin Connect hebt gekozen, dan waarschuwt deze kaart je een week van tevoren.",
|
||||
"garminConnected": "Garmin gekoppeld. De eerste synchronisatie is onderweg.",
|
||||
"garminKeyHowTo": "Open in Garmin Connect de instellingen, dan Personal API, en maak een sleutel. Garmin toont hem maar één keer, dus kopieer hem meteen.",
|
||||
"garminKeySettingsLink": "Garmin Connect openen",
|
||||
"garminApiKey": "Personal API-sleutel",
|
||||
"garminApiKeyPlaceholder": "Plak de sleutel",
|
||||
"garminApiKeySavedPlaceholder": "Opgeslagen. Plak een nieuwe sleutel om hem te vervangen",
|
||||
"garminKeyValidity": "Gekozen geldigheid",
|
||||
"garminKeyValidityUnknown": "Weet ik niet",
|
||||
"garminKeyValidityDays": "{count} dagen",
|
||||
"garminKeyValidityHelp": "Garmin meldt niet wanneer een sleutel verloopt. Met de geldigheid waarschuwt deze kaart je een week voordat de sleutel stopt met werken.",
|
||||
"garminConnect": "Koppelen",
|
||||
"garminUpdate": "Wijzigingen opslaan",
|
||||
"garminRegion": "Garmin-dienst",
|
||||
"garminRegionGlobal": "Garmin Connect (wereldwijd)",
|
||||
"garminRegionChina": "Garmin Connect China",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin heeft deze sleutel niet geaccepteerd. Controleer of hij volledig is gekopieerd, bij de gekozen dienst hoort en niet verlopen of ingetrokken is.",
|
||||
"rateLimited": "Garmin beperkt op dit moment het aantal verzoeken. Probeer het over een paar minuten opnieuw.",
|
||||
"unreachable": "Garmin is niet bereikbaar. Probeer het over een paar minuten opnieuw.",
|
||||
"invalid": "Die sleutel ziet er niet goed uit. Plak hem opnieuw, zonder spaties.",
|
||||
"generic": "Dat is niet gelukt. Probeer het opnieuw."
|
||||
},
|
||||
"garminKeyValidityMenu": "Geldigheid van de sleutel",
|
||||
"garminValiditySaved": "Geldigheid opgeslagen.",
|
||||
"garminKeyCreatedOn": "Aangemaakt op",
|
||||
"garminKeyCreatedOnHelp": "De dag waarop je de sleutel in Garmin Connect hebt gemaakt. Leeg gelaten telt de geldigheid vanaf de dag dat je hem hier hebt gekoppeld.",
|
||||
"garminUnavailable": "Garmin levert deze gegevens niet voor je account: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "dagoverzichten",
|
||||
"activities": "activiteiten",
|
||||
"sleep": "slaap",
|
||||
"hrv": "HRV",
|
||||
"weight": "wegingen",
|
||||
"cycle": "menstruatiecyclus"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Beheer",
|
||||
|
||||
+72
-11
@@ -969,7 +969,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Wpisz czas trwania, np. 7,5, 7:30 lub 7h 30m.",
|
||||
"durationTooLong": "Jeden wpis nie może przekraczać 24 godzin.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Poziom stresu",
|
||||
"typeTrainingReadiness": "Gotowość do treningu",
|
||||
"typeAcuteTrainingLoad": "Ostre obciążenie treningowe",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Co HealthLog potrafi śledzić",
|
||||
@@ -4759,7 +4764,8 @@
|
||||
"unitScore": "pkt",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "ud./min",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "obciążenie"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Jakość snu",
|
||||
@@ -4781,7 +4787,11 @@
|
||||
"cardioLoadExplainer": "Przybliżenie obciążenia treningowego, które śledzi nagromadzone obciążenie sercowo-naczyniowe. Porównuj je z własnymi ostatnimi dniami.",
|
||||
"averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
|
||||
"maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
|
||||
"energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia."
|
||||
"energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia.",
|
||||
"bodyBatteryExplainer": "Twoja rezerwa energii w skali Garmina od 0 do 100, najwyższy poziom dnia. Odpoczynek i sen ją uzupełniają, stres i aktywność ją zużywają.",
|
||||
"trainingReadinessExplainer": "Jak bardzo Twoje ciało jest gotowe na ciężki trening, w skali Garmina od 0 do 100. Wartość przychodzi przez skonfigurowany przez Ciebie most, bo Garmin nie udostępnia jej bezpośrednio.",
|
||||
"stressLevelExplainer": "Średni całodzienny poziom stresu według Garmina w skali od 0 do 100, wyliczany ze zmienności rytmu serca. Niżej znaczy spokojniej.",
|
||||
"acuteTrainingLoadExplainer": "Ostre obciążenie treningowe Garmina: ważone obciążenie z Twoich aktywności z ostatnich siedmiu dni. Wartość przychodzi przez skonfigurowany przez Ciebie most. Porównuj je ze swoimi ostatnimi tygodniami."
|
||||
},
|
||||
"loadError": "Nie udało się załadować Twoich wartości regeneracji."
|
||||
},
|
||||
@@ -5688,7 +5698,11 @@
|
||||
"recovery": "Regeneracja",
|
||||
"stress": "Stres",
|
||||
"sleepDebt": "Dług snu",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Poziom stresu",
|
||||
"trainingReadiness": "Gotowość do treningu",
|
||||
"acuteTrainingLoad": "Ostre obciążenie treningowe"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5706,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Urządzenie zewnętrzne",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Zegarek",
|
||||
@@ -7500,11 +7515,6 @@
|
||||
"lastEnteredHere": "Ostatni wpis tutaj {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina (Body Battery, Training Load, status HRV, VO₂ max) nie są udostępniane przez Garmina żadnej z tych platform.",
|
||||
"docsLink": "Przeczytaj przewodnik"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Zakres referencyjny glukozy",
|
||||
"description": "Do jakiego zakresu porównywane są twoje wyniki glukozy we krwi.",
|
||||
@@ -7704,7 +7714,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Zostaniesz wylogowany, a twoje konto od razu zablokowane. Po {days} dniach zostanie trwale usunięte, razem ze wszystkimi danymi zdrowotnymi, kluczami dostępu, sesjami i wpisami audytu. Do tego czasu administrator tego serwera może cofnąć usunięcie. Kopie w kopiach zapasowych tego serwera wygasają po upływie okresu przechowywania.",
|
||||
"deleteAccountScheduled": "Twoje konto jest zablokowane i zostanie usunięte {date}. Trwa wylogowywanie.",
|
||||
"dangerZoneSuccessGrace": "Twoja dokumentacja zdrowotna została usunięta. Administrator tego serwera może ją przywrócić do {date}.",
|
||||
"dangerZoneRestoreLimits": "Kończy się też dostęp: to, co udostępniasz innym, to, co inni udostępniają tobie, i każda rola osoby opiekującej się. Przywrócenie odtwarza dokumentację taką, jaka jest teraz. To, co dojdzie po usunięciu, zostaje zastąpione, a połączenia integracji, udostępnienia, role opiekunów i linki udostępniania nie wracają; trzeba je skonfigurować od nowa."
|
||||
"dangerZoneRestoreLimits": "Kończy się też dostęp: to, co udostępniasz innym, to, co inni udostępniają tobie, i każda rola osoby opiekującej się. Przywrócenie odtwarza dokumentację taką, jaka jest teraz. To, co dojdzie po usunięciu, zostaje zastąpione, a połączenia integracji, udostępnienia, role opiekunów i linki udostępniania nie wracają; trzeba je skonfigurować od nowa.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Wklej klucz Garmin Personal API, aby synchronizować sen, HRV, Body Battery, stres, kroki, wagę i aktywności z Garmin Connect.",
|
||||
"garminSync": "Synchronizuj teraz",
|
||||
"garminSyncResult": "Zsynchronizowano wpisy: {count}",
|
||||
"garminSyncFailed": "Synchronizacja nie powiodła się",
|
||||
"garminViewData": "Zobacz regenerację",
|
||||
"garminReplaceKey": "Zastąp klucz",
|
||||
"garminDisconnect": "Odłącz",
|
||||
"garminDisconnectTitle": "Odłączyć Garmin?",
|
||||
"garminDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane, a klucz usunięty z tego serwera. Zaimportowane dane zostają w Twojej historii. Klucz działa w Garminie, dopóki go tam nie unieważnisz.",
|
||||
"garminKeyValidUntil": "Klucz ważny do {date}",
|
||||
"garminKeyExpired": "Twój klucz Garmin wygasł {date}. Utwórz nowy klucz w Garmin Connect i wklej go tutaj, aby wznowić synchronizację.",
|
||||
"garminKeyExpiresToday": "Twój klucz Garmin wygasa dzisiaj. Utwórz nowy klucz w Garmin Connect i wklej go tutaj, aby synchronizacja się nie zatrzymała.",
|
||||
"garminKeyExpiring": "Twój klucz Garmin wygasa {date}. Przed tym dniem utwórz nowy klucz w Garmin Connect i wklej go tutaj.",
|
||||
"garminKeyExpiryUnknown": "Garmin nie podaje, kiedy ten klucz wygaśnie. Zastąp klucz i wybierz ważność ustawioną w Garmin Connect, a ta karta ostrzeże Cię tydzień wcześniej.",
|
||||
"garminConnected": "Połączono z Garmin. Pierwsza synchronizacja jest w drodze.",
|
||||
"garminKeyHowTo": "W Garmin Connect otwórz Ustawienia, następnie Personal API, i utwórz klucz. Garmin pokazuje go tylko raz, więc od razu go skopiuj.",
|
||||
"garminKeySettingsLink": "Otwórz Garmin Connect",
|
||||
"garminApiKey": "Klucz Personal API",
|
||||
"garminApiKeyPlaceholder": "Wklej klucz",
|
||||
"garminApiKeySavedPlaceholder": "Zapisano. Wklej nowy klucz, aby go zastąpić",
|
||||
"garminKeyValidity": "Wybrana ważność",
|
||||
"garminKeyValidityUnknown": "Nie wiem",
|
||||
"garminKeyValidityDays": "{count} dni",
|
||||
"garminKeyValidityHelp": "Garmin nie informuje, kiedy klucz wygasa. Dzięki ważności ta karta ostrzeże Cię tydzień przed tym, jak klucz przestanie działać.",
|
||||
"garminConnect": "Połącz",
|
||||
"garminUpdate": "Zapisz zmiany",
|
||||
"garminRegion": "Usługa Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (globalny)",
|
||||
"garminRegionChina": "Garmin Connect Chiny",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin nie przyjął tego klucza. Sprawdź, czy został skopiowany w całości, należy do wybranej usługi i nie wygasł ani nie został unieważniony.",
|
||||
"rateLimited": "Garmin ogranicza teraz liczbę zapytań. Spróbuj ponownie za kilka minut.",
|
||||
"unreachable": "Nie można połączyć się z Garmin. Spróbuj ponownie za kilka minut.",
|
||||
"invalid": "Ten klucz wygląda nieprawidłowo. Wklej go ponownie, bez spacji.",
|
||||
"generic": "Nie udało się. Spróbuj ponownie."
|
||||
},
|
||||
"garminKeyValidityMenu": "Ważność klucza",
|
||||
"garminValiditySaved": "Zapisano ważność.",
|
||||
"garminKeyCreatedOn": "Utworzono",
|
||||
"garminKeyCreatedOnHelp": "Dzień, w którym utworzono klucz w Garmin Connect. Jeśli zostawisz puste, ważność liczy się od dnia połączenia go tutaj.",
|
||||
"garminUnavailable": "Garmin nie udostępnia tych danych dla Twojego konta: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "podsumowania dzienne",
|
||||
"activities": "aktywności",
|
||||
"sleep": "sen",
|
||||
"hrv": "HRV",
|
||||
"weight": "pomiary wagi",
|
||||
"cycle": "cykl menstruacyjny"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administracja",
|
||||
|
||||
+72
-11
@@ -969,7 +969,12 @@
|
||||
"durationMinutesOnly": "{minutes} min",
|
||||
"durationInvalid": "Insira uma duração como 7,5, 7:30 ou 7h 30m.",
|
||||
"durationTooLong": "Uma entrada não pode durar mais de 24 horas.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Nível de estresse",
|
||||
"typeTrainingReadiness": "Prontidão para treinar",
|
||||
"typeAcuteTrainingLoad": "Carga de treino aguda",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "O que o HealthLog pode acompanhar",
|
||||
@@ -4759,7 +4764,8 @@
|
||||
"unitScore": "pontuação",
|
||||
"unitMinutes": "min",
|
||||
"unitBpm": "bpm",
|
||||
"unitKj": "kJ"
|
||||
"unitKj": "kJ",
|
||||
"unitLoad": "carga"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Qualidade do sono",
|
||||
@@ -4781,7 +4787,11 @@
|
||||
"cardioLoadExplainer": "Um proxy de carga de treino que acompanha a carga cardiovascular aguda que você acumulou. Compare com os seus próprios dias recentes.",
|
||||
"averageHeartRateExplainer": "Sua frequência cardíaca média ao longo do dia ou ciclo inteiro, conforme relatado pelo seu wearable.",
|
||||
"maxHeartRateExplainer": "Seu pico de frequência cardíaca ao longo do dia ou ciclo inteiro, conforme relatado pelo seu wearable.",
|
||||
"energyExpenditureKjExplainer": "A energia que seu corpo gastou ao longo do dia, em quilojoules, como seu dispositivo a reporta nativamente."
|
||||
"energyExpenditureKjExplainer": "A energia que seu corpo gastou ao longo do dia, em quilojoules, como seu dispositivo a reporta nativamente.",
|
||||
"bodyBatteryExplainer": "Sua reserva de energia na escala de 0 a 100 da Garmin, a carga mais alta do dia. Descanso e sono a recarregam, estresse e atividade a consomem.",
|
||||
"trainingReadinessExplainer": "O quanto seu corpo está pronto para um treino pesado, na escala de 0 a 100 da Garmin. O valor chega por uma ponte que você configurou, pois a Garmin não o compartilha diretamente.",
|
||||
"stressLevelExplainer": "O nível médio de estresse do dia segundo a Garmin, numa escala de 0 a 100, lido da variabilidade da sua frequência cardíaca. Mais baixo é mais calmo.",
|
||||
"acuteTrainingLoadExplainer": "A carga de treino aguda da Garmin: a carga ponderada das suas atividades dos últimos sete dias. Chega por uma ponte que você configurou. Compare com as suas próprias semanas recentes."
|
||||
},
|
||||
"loadError": "Não foi possível carregar as suas leituras de recuperação."
|
||||
},
|
||||
@@ -5688,7 +5698,11 @@
|
||||
"recovery": "Recuperação",
|
||||
"stress": "Estresse",
|
||||
"sleepDebt": "Déficit de sono",
|
||||
"hrvRmssd": "VFC (RMSSD)"
|
||||
"hrvRmssd": "VFC (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Nível de estresse",
|
||||
"trainingReadiness": "Prontidão para treinar",
|
||||
"acuteTrainingLoad": "Carga de treino aguda"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5706,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Dispositivo externo",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Relógio",
|
||||
@@ -7500,11 +7515,6 @@
|
||||
"lastEnteredHere": "Último registro feito aqui {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "O Garmin não tem conexão direta aqui. Conecte pelo Apple Health (iOS) ou pelo Google Health Connect (Android). As pontuações próprias da Garmin (Body Battery, Training Load, HRV Status, VO₂ máx.) não são compartilhadas pela Garmin com nenhuma das duas plataformas.",
|
||||
"docsLink": "Ler o guia"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Faixa de referência da glicose",
|
||||
"description": "Com qual faixa as suas leituras de glicose são comparadas.",
|
||||
@@ -7704,7 +7714,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Você sai da sessão e sua conta é bloqueada na hora. Depois de {days} dias, ela é apagada de vez, com todos os dados de saúde, chaves de acesso, sessões e registros de auditoria. Até lá, um administrador deste servidor pode desfazer a exclusão. As cópias nos backups deste servidor expiram depois do período de retenção.",
|
||||
"deleteAccountScheduled": "Sua conta está bloqueada e será apagada em {date}. Saindo da sessão.",
|
||||
"dangerZoneSuccessGrace": "Seu registro de saúde foi excluído. Um administrador deste servidor pode restaurá-lo até {date}.",
|
||||
"dangerZoneRestoreLimits": "O acesso também termina: o que você compartilha com outras pessoas, o que elas compartilham com você e qualquer papel de guardião. Uma restauração traz o registro de volta como está agora. O que for adicionado depois da exclusão é substituído, e conexões de integração, compartilhamentos, papéis de guardião e links de compartilhamento não voltam; precisam ser configurados de novo."
|
||||
"dangerZoneRestoreLimits": "O acesso também termina: o que você compartilha com outras pessoas, o que elas compartilham com você e qualquer papel de guardião. Uma restauração traz o registro de volta como está agora. O que for adicionado depois da exclusão é substituído, e conexões de integração, compartilhamentos, papéis de guardião e links de compartilhamento não voltam; precisam ser configurados de novo.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Cole uma chave da Garmin Personal API para sincronizar sono, VFC, Body Battery, estresse, passos, peso e atividades do Garmin Connect.",
|
||||
"garminSync": "Sincronizar agora",
|
||||
"garminSyncResult": "{count} registros sincronizados",
|
||||
"garminSyncFailed": "Falha na sincronização",
|
||||
"garminViewData": "Ver sua recuperação",
|
||||
"garminReplaceKey": "Substituir chave",
|
||||
"garminDisconnect": "Desconectar",
|
||||
"garminDisconnectTitle": "Desconectar a Garmin?",
|
||||
"garminDisconnectDescription": "As próximas sincronizações param e a chave é removida deste servidor. Os dados já importados continuam no seu histórico. A chave continua válida na Garmin até você revogá-la lá.",
|
||||
"garminKeyValidUntil": "Chave válida até {date}",
|
||||
"garminKeyExpired": "Sua chave Garmin expirou em {date}. Crie uma nova chave no Garmin Connect e cole-a aqui para retomar a sincronização.",
|
||||
"garminKeyExpiresToday": "Sua chave Garmin expira hoje. Crie uma nova chave no Garmin Connect e cole-a aqui para a sincronização não parar.",
|
||||
"garminKeyExpiring": "Sua chave Garmin expira em {date}. Antes disso, crie uma nova chave no Garmin Connect e cole-a aqui.",
|
||||
"garminKeyExpiryUnknown": "A Garmin não informa quando esta chave expira. Substitua a chave e escolha a validade definida no Garmin Connect, e este cartão avisará você com uma semana de antecedência.",
|
||||
"garminConnected": "Garmin conectada. A primeira sincronização está a caminho.",
|
||||
"garminKeyHowTo": "No Garmin Connect, abra Configurações, depois Personal API, e crie uma chave. A Garmin mostra a chave só uma vez, então copie-a na hora.",
|
||||
"garminKeySettingsLink": "Abrir o Garmin Connect",
|
||||
"garminApiKey": "Chave da Personal API",
|
||||
"garminApiKeyPlaceholder": "Cole a chave",
|
||||
"garminApiKeySavedPlaceholder": "Salva. Cole uma nova chave para substituí-la",
|
||||
"garminKeyValidity": "Validade escolhida",
|
||||
"garminKeyValidityUnknown": "Não sei",
|
||||
"garminKeyValidityDays": "{count} dias",
|
||||
"garminKeyValidityHelp": "A Garmin não informa quando uma chave expira. Com a validade, este cartão avisa você uma semana antes de a chave parar de funcionar.",
|
||||
"garminConnect": "Conectar",
|
||||
"garminUpdate": "Salvar alterações",
|
||||
"garminRegion": "Serviço Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (global)",
|
||||
"garminRegionChina": "Garmin Connect China",
|
||||
"garminErrors": {
|
||||
"keyRefused": "A Garmin não aceitou esta chave. Verifique se ela foi copiada inteira, pertence ao serviço escolhido e não expirou nem foi revogada.",
|
||||
"rateLimited": "A Garmin está limitando as solicitações agora. Tente de novo em alguns minutos.",
|
||||
"unreachable": "Não foi possível alcançar a Garmin. Tente de novo em alguns minutos.",
|
||||
"invalid": "Essa chave não parece correta. Cole-a de novo, sem espaços.",
|
||||
"generic": "Não deu certo. Tente de novo."
|
||||
},
|
||||
"garminKeyValidityMenu": "Validade da chave",
|
||||
"garminValiditySaved": "Validade salva.",
|
||||
"garminKeyCreatedOn": "Criada em",
|
||||
"garminKeyCreatedOnHelp": "O dia em que você criou a chave no Garmin Connect. Deixe vazio para contar a partir do dia em que a conectou aqui.",
|
||||
"garminUnavailable": "A Garmin não fornece estes dados para sua conta: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "resumos diários",
|
||||
"activities": "atividades",
|
||||
"sleep": "sono",
|
||||
"hrv": "VFC",
|
||||
"weight": "pesagens",
|
||||
"cycle": "ciclo menstrual"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administração",
|
||||
|
||||
+72
-11
@@ -969,7 +969,12 @@
|
||||
"durationMinutesOnly": "{minutes} мин",
|
||||
"durationInvalid": "Введите длительность, например 7,5, 7:30 или 7ч 30м.",
|
||||
"durationTooLong": "Одна запись не может быть длиннее 24 часов.",
|
||||
"sourceHealthConnect": "Health Connect"
|
||||
"sourceHealthConnect": "Health Connect",
|
||||
"typeBodyBattery": "Body Battery",
|
||||
"typeStressLevel": "Уровень стресса",
|
||||
"typeTrainingReadiness": "Готовность к тренировке",
|
||||
"typeAcuteTrainingLoad": "Острая тренировочная нагрузка",
|
||||
"sourceGarmin": "Garmin"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Что умеет отслеживать HealthLog",
|
||||
@@ -4759,7 +4764,8 @@
|
||||
"unitScore": "балл",
|
||||
"unitMinutes": "мин",
|
||||
"unitBpm": "уд/мин",
|
||||
"unitKj": "кДж"
|
||||
"unitKj": "кДж",
|
||||
"unitLoad": "нагрузка"
|
||||
},
|
||||
"sleepQuality": {
|
||||
"title": "Качество сна",
|
||||
@@ -4781,7 +4787,11 @@
|
||||
"cardioLoadExplainer": "Показатель тренировочной нагрузки, отражающий накопленную острую сердечно-сосудистую нагрузку. Сравнивайте его со своими недавними днями.",
|
||||
"averageHeartRateExplainer": "Ваша средняя ЧСС за весь день или цикл по данным носимого устройства.",
|
||||
"maxHeartRateExplainer": "Ваша пиковая ЧСС за весь день или цикл по данным носимого устройства.",
|
||||
"energyExpenditureKjExplainer": "Энергия, которую ваш организм потратил за день, в килоджоулях, как её передаёт устройство."
|
||||
"energyExpenditureKjExplainer": "Энергия, которую ваш организм потратил за день, в килоджоулях, как её передаёт устройство.",
|
||||
"bodyBatteryExplainer": "Ваш запас энергии по шкале Garmin от 0 до 100, наивысший уровень за день. Отдых и сон его пополняют, стресс и активность расходуют.",
|
||||
"trainingReadinessExplainer": "Насколько ваше тело готово к тяжёлой тренировке, по шкале Garmin от 0 до 100. Значение поступает через настроенный вами мост, потому что Garmin не передаёт его напрямую.",
|
||||
"stressLevelExplainer": "Средний уровень стресса за день по шкале Garmin от 0 до 100, рассчитанный по вариабельности сердечного ритма. Чем ниже, тем спокойнее.",
|
||||
"acuteTrainingLoadExplainer": "Острая тренировочная нагрузка Garmin: взвешенная нагрузка ваших тренировок за последние семь дней. Значение поступает через настроенный вами мост. Сравнивайте его со своими последними неделями."
|
||||
},
|
||||
"loadError": "Не удалось загрузить данные восстановления."
|
||||
},
|
||||
@@ -5688,7 +5698,11 @@
|
||||
"recovery": "Восстановление",
|
||||
"stress": "Стресс",
|
||||
"sleepDebt": "Дефицит сна",
|
||||
"hrvRmssd": "HRV (RMSSD)"
|
||||
"hrvRmssd": "HRV (RMSSD)",
|
||||
"bodyBattery": "Body Battery",
|
||||
"stressLevel": "Уровень стресса",
|
||||
"trainingReadiness": "Готовность к тренировке",
|
||||
"acuteTrainingLoad": "Острая тренировочная нагрузка"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
@@ -5706,7 +5720,8 @@
|
||||
"GOOGLE_HEALTH": "Google Health",
|
||||
"STRAVA": "Strava",
|
||||
"EXTERNAL": "Внешнее устройство",
|
||||
"HEALTH_CONNECT": "Health Connect"
|
||||
"HEALTH_CONNECT": "Health Connect",
|
||||
"GARMIN": "Garmin"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Часы",
|
||||
@@ -7500,11 +7515,6 @@
|
||||
"lastEnteredHere": "Последний ввод здесь {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "У Garmin здесь нет прямого подключения. Подключайтесь через Apple Health (iOS) или Google Health Connect (Android). Собственные показатели Garmin (Body Battery, Training Load, HRV Status, VO₂ max) Garmin не передаёт ни на одну из платформ.",
|
||||
"docsLink": "Прочитать руководство"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Референсный диапазон глюкозы",
|
||||
"description": "По какому диапазону оцениваются ваши измерения глюкозы крови.",
|
||||
@@ -7704,7 +7714,58 @@
|
||||
"deleteAccountConfirmDescriptionGrace": "Вы выходите из аккаунта, и он сразу блокируется. Через {days} дней он стирается навсегда вместе со всеми данными о здоровье, ключами доступа, сеансами и записями аудита. До этого администратор этого сервера может отменить удаление. Копии в резервных копиях этого сервера истекают по истечении срока хранения.",
|
||||
"deleteAccountScheduled": "Ваш аккаунт заблокирован и будет стёрт {date}. Выполняется выход.",
|
||||
"dangerZoneSuccessGrace": "Ваша история здоровья удалена. Администратор этого сервера может восстановить её до {date}.",
|
||||
"dangerZoneRestoreLimits": "Доступ тоже завершается: то, чем вы делитесь с другими, то, чем они делятся с вами, и любая роль опекуна. Восстановление возвращает запись в том виде, в каком она сейчас. Всё, что добавлено после удаления, заменяется, а подключения интеграций, общий доступ, роли опекуна и ссылки общего доступа не возвращаются; их нужно настраивать заново."
|
||||
"dangerZoneRestoreLimits": "Доступ тоже завершается: то, чем вы делитесь с другими, то, чем они делятся с вами, и любая роль опекуна. Восстановление возвращает запись в том виде, в каком она сейчас. Всё, что добавлено после удаления, заменяется, а подключения интеграций, общий доступ, роли опекуна и ссылки общего доступа не возвращаются; их нужно настраивать заново.",
|
||||
"garmin": "Garmin",
|
||||
"garminTag": "Personal API",
|
||||
"garminDescription": "Вставьте ключ Garmin Personal API, чтобы синхронизировать сон, ВСР, Body Battery, стресс, шаги, вес и тренировки из Garmin Connect.",
|
||||
"garminSync": "Синхронизировать сейчас",
|
||||
"garminSyncResult": "Синхронизировано записей: {count}",
|
||||
"garminSyncFailed": "Сбой синхронизации",
|
||||
"garminViewData": "Посмотреть восстановление",
|
||||
"garminReplaceKey": "Заменить ключ",
|
||||
"garminDisconnect": "Отключить",
|
||||
"garminDisconnectTitle": "Отключить Garmin?",
|
||||
"garminDisconnectDescription": "Будущие синхронизации прекратятся, ключ будет удалён с этого сервера. Уже импортированные данные останутся в истории. В Garmin ключ действует, пока вы его там не отзовёте.",
|
||||
"garminKeyValidUntil": "Ключ действует до {date}",
|
||||
"garminKeyExpired": "Срок действия вашего ключа Garmin истёк {date}. Создайте новый ключ в Garmin Connect и вставьте его сюда, чтобы возобновить синхронизацию.",
|
||||
"garminKeyExpiresToday": "Срок действия вашего ключа Garmin истекает сегодня. Создайте новый ключ в Garmin Connect и вставьте его сюда, чтобы синхронизация не остановилась.",
|
||||
"garminKeyExpiring": "Срок действия вашего ключа Garmin истекает {date}. До этого создайте новый ключ в Garmin Connect и вставьте его сюда.",
|
||||
"garminKeyExpiryUnknown": "Garmin не сообщает, когда истекает срок действия ключа. Замените ключ и выберите срок, указанный в Garmin Connect, и эта карточка предупредит вас за неделю.",
|
||||
"garminConnected": "Garmin подключён. Первая синхронизация уже началась.",
|
||||
"garminKeyHowTo": "В Garmin Connect откройте «Настройки», затем Personal API, и создайте ключ. Garmin показывает его только один раз, поэтому сразу скопируйте его.",
|
||||
"garminKeySettingsLink": "Открыть Garmin Connect",
|
||||
"garminApiKey": "Ключ Personal API",
|
||||
"garminApiKeyPlaceholder": "Вставьте ключ",
|
||||
"garminApiKeySavedPlaceholder": "Сохранён. Вставьте новый ключ, чтобы заменить его",
|
||||
"garminKeyValidity": "Выбранный срок действия",
|
||||
"garminKeyValidityUnknown": "Не знаю",
|
||||
"garminKeyValidityDays": "{count} дней",
|
||||
"garminKeyValidityHelp": "Garmin не сообщает, когда истекает срок действия ключа. Зная срок, эта карточка предупредит вас за неделю до того, как ключ перестанет работать.",
|
||||
"garminConnect": "Подключить",
|
||||
"garminUpdate": "Сохранить изменения",
|
||||
"garminRegion": "Сервис Garmin",
|
||||
"garminRegionGlobal": "Garmin Connect (глобальный)",
|
||||
"garminRegionChina": "Garmin Connect Китай",
|
||||
"garminErrors": {
|
||||
"keyRefused": "Garmin не принял этот ключ. Проверьте, что он скопирован целиком, относится к выбранному сервису и не истёк и не отозван.",
|
||||
"rateLimited": "Garmin сейчас ограничивает запросы. Попробуйте снова через несколько минут.",
|
||||
"unreachable": "Garmin недоступен. Попробуйте снова через несколько минут.",
|
||||
"invalid": "Ключ выглядит неправильно. Вставьте его ещё раз без пробелов.",
|
||||
"generic": "Не получилось. Попробуйте ещё раз."
|
||||
},
|
||||
"garminKeyValidityMenu": "Срок действия ключа",
|
||||
"garminValiditySaved": "Срок действия сохранён.",
|
||||
"garminKeyCreatedOn": "Дата создания",
|
||||
"garminKeyCreatedOnHelp": "День, когда вы создали ключ в Garmin Connect. Если оставить пустым, срок считается со дня подключения здесь.",
|
||||
"garminUnavailable": "Garmin не предоставляет эти данные для вашего аккаунта: {list}.",
|
||||
"garminResource": {
|
||||
"daily": "дневные сводки",
|
||||
"activities": "тренировки",
|
||||
"sleep": "сон",
|
||||
"hrv": "ВСР",
|
||||
"weight": "взвешивания",
|
||||
"cycle": "менструальный цикл"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Администрирование",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Garmin Personal API (#1333): a per-user key pasted once, a new measurement
|
||||
-- source for the rows the sync writes, Garmin's own day scores (Body Battery,
|
||||
-- stress level), and two types a bridge pushes through the ingest token
|
||||
-- because the Personal API does not carry them (Training Readiness, acute
|
||||
-- training load).
|
||||
--
|
||||
-- One DO block, so the lock wait is bounded and the file is one statement
|
||||
-- that rolls back whole. Adding an enum value inside a transaction is fine
|
||||
-- since PostgreSQL 12 as long as the value is not used in it, and nothing
|
||||
-- here uses them.
|
||||
--
|
||||
-- Purely additive: nullable columns, no default, no backfill. IF NOT EXISTS
|
||||
-- keeps a replay harmless. Enum values cannot be dropped in PostgreSQL, so
|
||||
-- there is no down path for them; dropping the six columns undoes the rest.
|
||||
--
|
||||
-- @retry-on-lock-timeout
|
||||
|
||||
DO $migration$
|
||||
BEGIN
|
||||
PERFORM set_config('lock_timeout', '5s', true);
|
||||
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'GARMIN';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_BATTERY';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRESS_LEVEL';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TRAINING_READINESS';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ACUTE_TRAINING_LOAD';
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "garmin_api_key_encrypted" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "garmin_key_expires_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "garmin_connected_at" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "garmin_region" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "garmin_backfill_progress" JSONB,
|
||||
ADD COLUMN IF NOT EXISTS "garmin_backfill_completed_at" TIMESTAMP(3);
|
||||
END
|
||||
$migration$;
|
||||
@@ -320,6 +320,23 @@ model User {
|
||||
/// account another record already holds is refused
|
||||
/// (`src/lib/integrations/oauth-kit/account-link.ts`). Cleared on disconnect.
|
||||
ouraUserId String? @map("oura_user_id")
|
||||
|
||||
// v1.47 — Garmin Personal API (#1333, per-user, encrypted at app level). The
|
||||
// person creates a key in Garmin Connect and pastes it once; the sync
|
||||
// exchanges it for a short-lived token on every run, so the key is the only
|
||||
// credential at rest. The token itself is never stored.
|
||||
/// Sealed Garmin Personal API key.
|
||||
garminApiKeyEncrypted String? @map("garmin_api_key_encrypted")
|
||||
/// v1.47 — when the Garmin key stops working: learned from the token's own expiry once it is capped by the key's, else the validity the person picked at connect counted from then. Null when neither is known. Drives the card's expiry warning.
|
||||
garminKeyExpiresAt DateTime? @map("garmin_key_expires_at")
|
||||
/// v1.47 — when the current Garmin key was connected; the anchor the picked validity counts from.
|
||||
garminConnectedAt DateTime? @map("garmin_connected_at")
|
||||
/// v1.47 — which Garmin service accepted the key at connect: `global` (personal.connectapi.garmin.com) or `china` (…garmin.cn). Not a secret; the sync reads it to pick the host.
|
||||
garminRegion String? @map("garmin_region")
|
||||
/// v1.47 — how far the Garmin history backfill has got, per resource (see `src/lib/garmin/backfill-progress.ts`). Cleared on connect and disconnect.
|
||||
garminBackfillProgress Json? @map("garmin_backfill_progress")
|
||||
/// v1.47 — the Garmin history backfill finished (discovery predicate `garmin_backfill_completed_at IS NULL`). Cleared on connect.
|
||||
garminBackfillCompletedAt DateTime? @map("garmin_backfill_completed_at")
|
||||
/// v1.44 — which provider apps on this record were copied from a
|
||||
/// guardian at connect, and from whom: `{ "<provider>": "<guardianUserId>" }`.
|
||||
/// A managed profile has no settings page for its own app, so a guardian's is
|
||||
@@ -1783,6 +1800,13 @@ enum MeasurementType {
|
||||
INDOOR_TEMPERATURE // celsius — indoor air temperature. Distinct from every body temperature type.
|
||||
INDOOR_HUMIDITY // percent (0–100) — indoor relative humidity.
|
||||
AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis.
|
||||
// v1.47 — Garmin Personal API (#1333). Garmin's own scores keep types of
|
||||
// their own, so a device-native figure never shares a bucket with the
|
||||
// COMPUTED proxies or another vendor's score of a different scale.
|
||||
BODY_BATTERY // score (0–100) — Garmin Body Battery, the day's highest charge. Source GARMIN.
|
||||
STRESS_LEVEL // score (0–100) — Garmin's all-day average stress level. Device-native and kept apart from the COMPUTED STRESS_SCORE, whose readers are single-source.
|
||||
TRAINING_READINESS // score (0–100) — Garmin Training Readiness. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL).
|
||||
ACUTE_TRAINING_LOAD // load (unit-less) — Garmin acute training load. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL).
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
@@ -2018,6 +2042,11 @@ enum MeasurementSource {
|
||||
// iOS DTO must mirror this spelling so the /api/sync/changes delta feed
|
||||
// decodes the rows.
|
||||
HEALTH_CONNECT
|
||||
// v1.47 — Garmin Personal API (#1333). Server-owned: only the Garmin sync
|
||||
// writes these rows, from the key the user pasted, so the batch +
|
||||
// single-POST surfaces reject it like the other providers. iOS DTO must
|
||||
// mirror this spelling so the /api/sync/changes delta feed decodes the rows.
|
||||
GARMIN
|
||||
|
||||
@@map("measurement_source")
|
||||
}
|
||||
|
||||
@@ -308,6 +308,7 @@ async function main() {
|
||||
"nightscoutTokenEncrypted",
|
||||
"dawarichUrlEncrypted",
|
||||
"dawarichApiKeyEncrypted",
|
||||
"garminApiKeyEncrypted",
|
||||
"polarAccessTokenEncrypted",
|
||||
"polarUserIdEncrypted",
|
||||
"polarClientIdEncrypted",
|
||||
|
||||
@@ -116,6 +116,8 @@ const BACKFILL_STAMPS: Record<string, string> = {
|
||||
"stamps the account's backfill-completed marker",
|
||||
"src/lib/jobs/oura-backfill.ts > user":
|
||||
"writes the account's backfill progress note and completed marker",
|
||||
"src/lib/jobs/garmin-backfill.ts > user":
|
||||
"writes the account's backfill progress note and completed marker",
|
||||
"src/lib/jobs/document-content-index-backfill.ts > documentContentIndex":
|
||||
"the index row is rebuilt content; its updatedAt is the rebuild time",
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts > biomarker":
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"lib/daily/digest.ts": 1317,
|
||||
"lib/daily/load-digest.ts": 1000,
|
||||
"lib/daily/today-overview.ts": 838,
|
||||
"lib/dashboard-layout.ts": 971,
|
||||
"lib/dashboard-layout.ts": 976,
|
||||
"lib/dashboard/snapshot.ts": 1550,
|
||||
"lib/day/records.ts": 1174,
|
||||
"lib/export/backup-plan.ts": 853,
|
||||
@@ -62,7 +62,7 @@
|
||||
"lib/insights/correlations.ts": 1026,
|
||||
"lib/insights/features.ts": 1875,
|
||||
"lib/insights/mood-aggregates.ts": 1053,
|
||||
"lib/integrations/status.ts": 1445,
|
||||
"lib/integrations/status.ts": 1448,
|
||||
"lib/jobs/coach-nudge.ts": 1084,
|
||||
"lib/jobs/insight-pregenerate.ts": 1189,
|
||||
"lib/jobs/medication-intake-import.ts": 819,
|
||||
@@ -70,7 +70,7 @@
|
||||
"lib/jobs/reaction-line.ts": 803,
|
||||
"lib/managed-profiles/handover.ts": 986,
|
||||
"lib/mcp/record-search.ts": 1397,
|
||||
"lib/mcp/rich-reads.ts": 1467,
|
||||
"lib/mcp/rich-reads.ts": 1471,
|
||||
"lib/measurements/dense-intraday-retention.ts": 1016,
|
||||
"lib/measurements/import-apple-health-export.ts": 1722,
|
||||
"lib/medications/scheduling/cadence.ts": 976,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
|
||||
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 259,
|
||||
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
|
||||
"app/api/integrations/status/route.ts :: apiHandler(arg)": 451,
|
||||
"app/api/integrations/status/route.ts :: apiHandler(arg)": 476,
|
||||
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239,
|
||||
"app/api/labs/ocr/commit/route.ts :: commitOcrRows": 280,
|
||||
"app/api/labs/route.ts :: postLabResult": 242,
|
||||
@@ -293,7 +293,7 @@
|
||||
"lib/insights/features.ts :: extractFeatures": 796,
|
||||
"lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246,
|
||||
"lib/insights/status-provider.ts :: runStatusCompletion": 215,
|
||||
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227,
|
||||
"lib/integrations/connection-health-load.ts :: loadConnectionFacts": 229,
|
||||
"lib/integrations/status.ts :: recordSyncFailure": 292,
|
||||
"lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300,
|
||||
"lib/jobs/coach-nudge.ts :: findTriggerForUser": 279,
|
||||
@@ -335,7 +335,7 @@
|
||||
"lib/notifications/senders/apns.ts :: sendViaApns": 299,
|
||||
"lib/notifications/senders/web-push.ts :: sendViaWebPush": 268,
|
||||
"lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239,
|
||||
"lib/personal-records/pr-direction.ts :: getPRDirection": 202,
|
||||
"lib/personal-records/pr-direction.ts :: getPRDirection": 208,
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272,
|
||||
"lib/strava/sync.ts :: runStravaSync": 219,
|
||||
"lib/support/report.ts :: collectSupportReportFacts": 332,
|
||||
|
||||
@@ -1378,7 +1378,6 @@
|
||||
"components/settings/integrations/__tests__/apple-health-card.test.tsx": 2,
|
||||
"components/settings/integrations/apple-health-card.tsx": 1,
|
||||
"components/settings/integrations/connections-panel.tsx": 5,
|
||||
"components/settings/integrations/garmin-info-note.tsx": 1,
|
||||
"components/settings/integrations/notification-channels-panel.tsx": 1,
|
||||
"components/settings/integrations/setup-guide-link.tsx": 2,
|
||||
"components/settings/integrations/shared.tsx": 3,
|
||||
|
||||
@@ -125,6 +125,7 @@ const NO_REFETCH_ALLOWLIST = [
|
||||
// Background integration-sync completion on the Settings surface — the user
|
||||
// is configuring an integration, not acting on the hero; convergence via the
|
||||
// daily reads' own poll/focus refetch is the intended behaviour.
|
||||
"components/settings/integrations/garmin-card.tsx",
|
||||
"components/settings/integrations/google-health-card.tsx",
|
||||
"components/settings/integrations/integration-actions.tsx",
|
||||
"components/settings/integrations/nightscout-card.tsx",
|
||||
|
||||
@@ -719,6 +719,14 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/integrations/garmin-card.tsx",
|
||||
destroys:
|
||||
"the stored Garmin Personal API key — reconnecting means pasting a key again, and a key Garmin showed only once may mean creating a new one",
|
||||
triggers: ["components/settings/integrations/integration-card.tsx"],
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/integrations/dawarich-card.tsx",
|
||||
destroys:
|
||||
|
||||
@@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => {
|
||||
it("reads a plausible catalogue", () => {
|
||||
// Sanity floor: a silently degraded catalogue would pass everything below
|
||||
// vacuously.
|
||||
expect(ALL_LEAF_IDS.length).toBe(106);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86);
|
||||
expect(ALL_LEAF_IDS.length).toBe(110);
|
||||
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(90);
|
||||
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
|
||||
});
|
||||
|
||||
|
||||
@@ -110,6 +110,14 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
"settings.integrations.oura.scope.items.vo2max",
|
||||
// Brand / product / proper nouns.
|
||||
"settings.googleHealth",
|
||||
// Garmin's own names: the Body Battery score and the Personal API product,
|
||||
// which Garmin Connect shows under the same English name in every locale.
|
||||
"measurements.typeBodyBattery",
|
||||
"settings.garminTag",
|
||||
"settings.sections.sources.metrics.bodyBattery",
|
||||
// Garmin's own service names.
|
||||
"settings.garminRegionGlobal",
|
||||
"settings.garminRegionChina",
|
||||
"admin.glitchtipDsn",
|
||||
"admin.integrationWebPush",
|
||||
"admin.offlineGeoEnabled",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -158,6 +158,7 @@ describe("record settings contract", () => {
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
dawarich: false,
|
||||
garmin: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
@@ -189,6 +190,7 @@ describe("record settings contract", () => {
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
dawarich: false,
|
||||
garmin: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
|
||||
@@ -417,6 +417,10 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/settings/integrations/nightscout-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
// The connect form's "connected" line, the Nightscout form's twin.
|
||||
"src/components/settings/integrations/garmin-card.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
// A paused connection resumed.
|
||||
"src/components/settings/integrations/oauth-integration-card.tsx": {
|
||||
"text-success": 1,
|
||||
|
||||
@@ -115,6 +115,15 @@ const PROVIDER_INGESTS: ProviderIngest[] = [
|
||||
writeFile: "lib/import/health-connect/import.ts",
|
||||
metadataKey: "healthConnectExerciseType",
|
||||
},
|
||||
{
|
||||
provider: "Garmin",
|
||||
mapper: "mapGarminSportType",
|
||||
mapperFile: "lib/garmin/sport-map.ts",
|
||||
captureFile: "lib/garmin/sync-workouts.ts",
|
||||
captureField: "a.activityType?.typeKey",
|
||||
writeFile: "lib/garmin/sync-workouts.ts",
|
||||
metadataKey: "garminActivity",
|
||||
},
|
||||
{
|
||||
provider: "Oura",
|
||||
mapper: "mapOuraSportType",
|
||||
|
||||
@@ -663,6 +663,13 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
|
||||
state: GOOGLE_HEALTH_STATE_EXCEPTION,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "garmin",
|
||||
path: join(ROOT, "src/app/api/garmin/sync/route.ts"),
|
||||
responseClass: "resolved",
|
||||
card: join(SYNC_CARD_ROOT, "integration-actions.tsx"),
|
||||
exceptions: {},
|
||||
},
|
||||
{
|
||||
id: "nightscout",
|
||||
path: join(ROOT, "src/app/api/nightscout/sync/route.ts"),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
import { GARMIN_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
|
||||
import { GARMIN_BACKFILL_QUEUE } from "@/lib/jobs/garmin-backfill-queue";
|
||||
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
|
||||
import { enqueueWhenBossReady } from "@/lib/jobs/boss-ready";
|
||||
import {
|
||||
clearGarminTokenCache,
|
||||
exchangeGarminKey,
|
||||
GarminApiError,
|
||||
type GarminToken,
|
||||
} from "@/lib/garmin/client";
|
||||
import {
|
||||
estimateGarminKeyExpiry,
|
||||
resolveGarminKeyExpiry,
|
||||
} from "@/lib/garmin/key-expiry";
|
||||
import { garminConnectSchema } from "@/lib/validations/garmin";
|
||||
|
||||
/**
|
||||
* Connect (or replace) the caller's Garmin Personal API key (#1333).
|
||||
*
|
||||
* The key is validated by buying a token with it before anything is stored:
|
||||
* a mistyped, revoked or expired key answers here, not on the first cron
|
||||
* tick. Garmin runs a separate service for accounts in China; the person
|
||||
* picks one, only that host is contacted, and the choice is kept so the
|
||||
* sync talks to the same host.
|
||||
*
|
||||
* On success the key is sealed at rest, the expiry is noted (Garmin's own
|
||||
* when the token reveals it, else the validity the person picked), the
|
||||
* history backfill is reopened, the `garmin` ledger is reset to connected,
|
||||
* and a first sync plus the backfill are sent best-effort. The key is never
|
||||
* logged, audited or echoed: the audit row carries only the region.
|
||||
*
|
||||
* Rate-limited 10/60 s per user: every attempt is an outbound exchange.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "garmin.connect" } });
|
||||
|
||||
const rl = await checkRateLimit(`garmin-connect:${user.id}`, 10, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many connection attempts", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = await parseJsonBody(request, garminConnectSchema, {
|
||||
maxBytes: 16 * 1024,
|
||||
message: "A valid Garmin Personal API key is required",
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
const { apiKey, region, validityDays } = parsed.data;
|
||||
|
||||
// Only the service the person chose is asked: the key is theirs to send,
|
||||
// and a key for one service means nothing to the other.
|
||||
let token: GarminToken | null = null;
|
||||
let lastError: unknown = null;
|
||||
try {
|
||||
token = await exchangeGarminKey(apiKey, region);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
annotate({
|
||||
meta: {
|
||||
"garmin.connect.refused":
|
||||
lastError instanceof GarminApiError ? lastError.reason : "network",
|
||||
},
|
||||
});
|
||||
if (lastError instanceof GarminApiError && lastError.keyRefused) {
|
||||
return apiError(
|
||||
"Garmin did not accept this key. Check that it was copied whole and has not expired or been revoked.",
|
||||
422,
|
||||
{ errorCode: "garmin.key_refused" },
|
||||
);
|
||||
}
|
||||
if (lastError instanceof GarminApiError && lastError.httpStatus === 429) {
|
||||
return apiError(
|
||||
"Garmin is limiting requests right now. Try again in a few minutes.",
|
||||
422,
|
||||
{ errorCode: "garmin.rate_limited" },
|
||||
);
|
||||
}
|
||||
return apiError(
|
||||
"Could not reach Garmin. Try again in a few minutes.",
|
||||
422,
|
||||
{ errorCode: "garmin.unreachable" },
|
||||
);
|
||||
}
|
||||
|
||||
const connectedAt = new Date();
|
||||
const keyExpiresAt =
|
||||
token.keyExpiresAt !== null
|
||||
? new Date(token.keyExpiresAt)
|
||||
: validityDays
|
||||
? estimateGarminKeyExpiry(connectedAt, validityDays)
|
||||
: null;
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
// Field by field; never the parsed body.
|
||||
garminApiKeyEncrypted: encrypt(apiKey),
|
||||
garminRegion: region,
|
||||
garminConnectedAt: connectedAt,
|
||||
garminKeyExpiresAt: keyExpiresAt,
|
||||
// A new key reopens the history walk from today.
|
||||
garminBackfillProgress: Prisma.DbNull,
|
||||
garminBackfillCompletedAt: null,
|
||||
},
|
||||
});
|
||||
clearGarminTokenCache(user.id);
|
||||
|
||||
await markReconnected(user.id, "garmin");
|
||||
await auditLog("garmin.connect", { userId: user.id, details: { region } });
|
||||
|
||||
await enqueueWhenBossReady("garmin-sync", async (boss) => {
|
||||
await boss
|
||||
.send(GARMIN_SYNC_QUEUE, { userId: user.id })
|
||||
.catch((err) =>
|
||||
getEvent()?.addWarning(`garmin-sync connect enqueue failed: ${err}`),
|
||||
);
|
||||
await boss
|
||||
.send(
|
||||
GARMIN_BACKFILL_QUEUE,
|
||||
{ userId: user.id, enqueuedAt: new Date().toISOString() },
|
||||
integrationBackfillSourceOptions(`garmin-backfill|${user.id}`),
|
||||
)
|
||||
.catch((err) =>
|
||||
getEvent()?.addWarning(
|
||||
`garmin-backfill connect enqueue failed: ${err}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
connected: true,
|
||||
region,
|
||||
keyExpiry: resolveGarminKeyExpiry(keyExpiresAt),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
import { clearGarminTokenCache } from "@/lib/garmin/client";
|
||||
|
||||
/**
|
||||
* Disconnect the caller's Garmin key.
|
||||
*
|
||||
* Clears the sealed key and everything stored about it (region, expiry,
|
||||
* backfill progress) and parks the `garmin` ledger at `disconnected`. Rows
|
||||
* already imported stay: disconnecting stops future syncs, it does not
|
||||
* delete history. The key stays valid at Garmin until it is revoked there.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "garmin.disconnect" } });
|
||||
|
||||
const rl = await checkRateLimit(`garmin-disconnect:${user.id}`, 20, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { garminApiKeyEncrypted: true },
|
||||
});
|
||||
if (!dbUser?.garminApiKeyEncrypted) {
|
||||
return apiError("No Garmin connection", 404);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
garminApiKeyEncrypted: null,
|
||||
garminRegion: null,
|
||||
garminConnectedAt: null,
|
||||
garminKeyExpiresAt: null,
|
||||
garminBackfillProgress: Prisma.DbNull,
|
||||
garminBackfillCompletedAt: null,
|
||||
},
|
||||
});
|
||||
clearGarminTokenCache(user.id);
|
||||
|
||||
await auditLog("garmin.disconnect", { userId: user.id });
|
||||
await markDisconnected(user.id, "garmin");
|
||||
|
||||
return apiSuccess({ disconnected: true });
|
||||
});
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
import {
|
||||
INTEGRATION_CADENCE,
|
||||
resolveSyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { resolveGarminKeyExpiry } from "@/lib/garmin/key-expiry";
|
||||
import {
|
||||
garminBackfillShare,
|
||||
garminUnavailableResources,
|
||||
readGarminBackfillProgress,
|
||||
} from "@/lib/garmin/backfill-progress";
|
||||
import { isGarminRegion } from "@/lib/garmin/client";
|
||||
|
||||
/**
|
||||
* Garmin connection status for the current user.
|
||||
*
|
||||
* `configured` is whether a key is stored. The ledger snapshot and the
|
||||
* server-resolved health come off the shared `garmin` IntegrationKey, the
|
||||
* same as every sibling. `keyExpiry` is the one Garmin-specific fact: when
|
||||
* the key stops working, and whether that is close, resolved here so web and
|
||||
* iOS warn alike. The key itself is never returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "garmin.status" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
garminApiKeyEncrypted: true,
|
||||
garminRegion: true,
|
||||
garminKeyExpiresAt: true,
|
||||
garminBackfillProgress: true,
|
||||
garminBackfillCompletedAt: true,
|
||||
},
|
||||
});
|
||||
if (!dbUser?.garminApiKeyEncrypted) {
|
||||
return apiSuccess({ connected: false, configured: false });
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "garmin");
|
||||
const progress = readGarminBackfillProgress(dbUser.garminBackfillProgress);
|
||||
return apiSuccess({
|
||||
connected: true,
|
||||
configured: true,
|
||||
region: isGarminRegion(dbUser.garminRegion)
|
||||
? dbUser.garminRegion
|
||||
: "global",
|
||||
keyExpiry: resolveGarminKeyExpiry(dbUser.garminKeyExpiresAt),
|
||||
backfill: {
|
||||
complete: dbUser.garminBackfillCompletedAt !== null,
|
||||
share: dbUser.garminBackfillCompletedAt
|
||||
? 1
|
||||
: garminBackfillShare(progress),
|
||||
},
|
||||
unavailableResources: garminUnavailableResources(progress),
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
lastError: status.lastError,
|
||||
syncHealth: resolveSyncVerdict({
|
||||
connected: true,
|
||||
configured: true,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
failingSinceAt: status.failingSince,
|
||||
cadence: INTEGRATION_CADENCE["garmin"],
|
||||
}),
|
||||
health: await loadOneConnectionHealth(user.id, "garmin"),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { syncUserGarmin } from "@/lib/garmin/sync";
|
||||
import { isGarminKeyRefusal } from "@/lib/garmin/client";
|
||||
import { resolveSyncOutcome } from "@/lib/outcome/written-outcome";
|
||||
|
||||
/**
|
||||
* Manually trigger a Garmin sync for the current user.
|
||||
*
|
||||
* Reads the recent window of every resource; the history is the backfill's.
|
||||
* `imported` counts rows written this run and `outcome` says how that count
|
||||
* reads. No body. The error arm answers with a fixed message: the classified
|
||||
* failure is already on the `garmin` ledger.
|
||||
*
|
||||
* Rate-limited 5/60 s per user, like the sibling sync routes.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "garmin.sync" } });
|
||||
|
||||
const rl = await checkRateLimit(`garmin-sync:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many sync requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncUserGarmin(user.id);
|
||||
return apiSuccess(resolveSyncOutcome(result));
|
||||
} catch (error) {
|
||||
if (isGarminKeyRefusal(error)) {
|
||||
return apiError(
|
||||
"Garmin no longer accepts the stored key. Create a new one and connect again.",
|
||||
422,
|
||||
{ errorCode: "garmin.key_refused" },
|
||||
);
|
||||
}
|
||||
return apiError("Garmin sync failed", 502);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getGarminCredentials } from "@/lib/garmin/credentials";
|
||||
import { exchangeGarminKey, GarminApiError } from "@/lib/garmin/client";
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Cheap live probe of the stored Garmin key: buy one token with it,
|
||||
* through the same exchange the sync uses. Nothing is written; the token is
|
||||
* dropped.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "garmin.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`garmin-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many test requests", 429, {
|
||||
errorCode: "rate_limited_self",
|
||||
});
|
||||
}
|
||||
|
||||
const creds = await getGarminCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError("Garmin not connected", 422, {
|
||||
errorCode: "not_configured",
|
||||
});
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await exchangeGarminKey(creds.apiKey, creds.region);
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
latencyMs: Math.round(performance.now() - start),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof GarminApiError) {
|
||||
const code = e.keyRefused
|
||||
? "credentials_rejected"
|
||||
: e.httpStatus === 429
|
||||
? "rate_limited"
|
||||
: e.httpStatus !== undefined && e.httpStatus >= 500
|
||||
? "upstream_error"
|
||||
: "connection_failed";
|
||||
annotate({
|
||||
meta: { garmin_test_status: e.httpStatus, garmin_test_code: code },
|
||||
});
|
||||
return apiError(
|
||||
code === "credentials_rejected"
|
||||
? "Garmin rejected the key"
|
||||
: code === "rate_limited"
|
||||
? "Garmin rate-limited the request"
|
||||
: code === "upstream_error"
|
||||
? "Garmin returned an error"
|
||||
: "Garmin connection failed",
|
||||
502,
|
||||
{ errorCode: code },
|
||||
);
|
||||
}
|
||||
const isTimeout = e instanceof SafeFetchError && e.kind === "timeout";
|
||||
annotate({
|
||||
meta: { garmin_test_code: isTimeout ? "timeout" : "connection_failed" },
|
||||
});
|
||||
return apiError(
|
||||
isTimeout ? "Garmin request timed out" : "Garmin connection failed",
|
||||
502,
|
||||
{ errorCode: isTimeout ? "timeout" : "connection_failed" },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -62,6 +62,10 @@ const METRIC_MODULE: Partial<Record<MetricStatusMetricId, ModuleKey>> = {
|
||||
DAY_STRAIN: "recovery",
|
||||
WORKOUT_STRAIN: "recovery",
|
||||
CARDIO_LOAD: "recovery",
|
||||
BODY_BATTERY: "recovery",
|
||||
STRESS_LEVEL: "recovery",
|
||||
TRAINING_READINESS: "recovery",
|
||||
ACUTE_TRAINING_LOAD: "recovery",
|
||||
AVERAGE_HEART_RATE: "recovery",
|
||||
MAX_HEART_RATE: "recovery",
|
||||
ENERGY_EXPENDITURE_KJ: "recovery",
|
||||
|
||||
@@ -340,7 +340,7 @@ describe("/api/integrations/status — the freshness read failed", () => {
|
||||
expect(envelope.metricFreshnessDegraded).toBe(true);
|
||||
// Still fail-soft: the rest of the envelope survives, all eight entries
|
||||
// present, because a freshness hiccup is never worth the settings surface.
|
||||
expect(envelope.integrations).toHaveLength(9);
|
||||
expect(envelope.integrations).toHaveLength(10);
|
||||
for (const entry of envelope.integrations) {
|
||||
expect(entry.metricFreshness).toEqual([]);
|
||||
}
|
||||
@@ -472,6 +472,7 @@ describe("/api/integrations/status — the unified connection health", () => {
|
||||
strava: null,
|
||||
nightscout: null,
|
||||
dawarich: null,
|
||||
garmin: null,
|
||||
"apple-health": null,
|
||||
} as never);
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
|
||||
|
||||
@@ -15,6 +15,17 @@
|
||||
* callers; the web cards no longer hit them.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isGarminRegion, type GarminRegion } from "@/lib/garmin/client";
|
||||
import {
|
||||
resolveGarminKeyExpiry,
|
||||
type GarminKeyExpiry,
|
||||
} from "@/lib/garmin/key-expiry";
|
||||
import {
|
||||
garminBackfillShare,
|
||||
garminUnavailableResources,
|
||||
readGarminBackfillProgress,
|
||||
type GarminBackfillStep,
|
||||
} from "@/lib/garmin/backfill-progress";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -83,6 +94,7 @@ export const GET = apiHandler(async () => {
|
||||
stravaStatus,
|
||||
nightscoutStatus,
|
||||
dawarichStatus,
|
||||
garminStatus,
|
||||
dbUser,
|
||||
withingsConn,
|
||||
whoopConn,
|
||||
@@ -108,6 +120,7 @@ export const GET = apiHandler(async () => {
|
||||
getIntegrationStatus(user.id, "strava"),
|
||||
getIntegrationStatus(user.id, "nightscout"),
|
||||
getIntegrationStatus(user.id, "dawarich"),
|
||||
getIntegrationStatus(user.id, "garmin"),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
@@ -135,6 +148,12 @@ export const GET = apiHandler(async () => {
|
||||
nightscoutTokenEncrypted: true,
|
||||
nightscoutAllowPrivateHost: true,
|
||||
dawarichUrlEncrypted: true,
|
||||
// Garmin: the connect marker and the facts its card warns from.
|
||||
garminApiKeyEncrypted: true,
|
||||
garminRegion: true,
|
||||
garminKeyExpiresAt: true,
|
||||
garminBackfillProgress: true,
|
||||
garminBackfillCompletedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.withingsConnection.findUnique({
|
||||
@@ -194,6 +213,7 @@ export const GET = apiHandler(async () => {
|
||||
"strava",
|
||||
"nightscout",
|
||||
"dawarich",
|
||||
"garmin",
|
||||
]),
|
||||
// See `metricFreshnessDegraded` below.
|
||||
getSourceMetricFreshness(user.id).catch(() => {
|
||||
@@ -319,6 +339,7 @@ export const GET = apiHandler(async () => {
|
||||
const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted;
|
||||
const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted;
|
||||
const dawarichConfigured = !!dbUser?.dawarichUrlEncrypted;
|
||||
const garmin = garminExtras(dbUser);
|
||||
|
||||
if (metricFreshnessDegraded) {
|
||||
annotate({ meta: { integrations_metric_freshness_degraded: true } });
|
||||
@@ -512,6 +533,21 @@ export const GET = apiHandler(async () => {
|
||||
configured: dawarichConfigured,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & DawarichExtras,
|
||||
{
|
||||
...publish(garminStatus),
|
||||
connected: garmin.connected,
|
||||
configured: garmin.configured,
|
||||
region: garmin.region,
|
||||
keyExpiry: garmin.keyExpiry,
|
||||
backfillCompleted: garmin.backfillCompleted,
|
||||
backfillShare: garmin.backfillShare,
|
||||
unavailableResources: garmin.unavailableResources,
|
||||
health: health["garmin"],
|
||||
...resolve("garmin", garminStatus, {
|
||||
connected: garmin.connected,
|
||||
configured: garmin.configured,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & GarminExtras,
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -617,6 +653,59 @@ interface NightscoutExtras {
|
||||
allowPrivateHost: boolean;
|
||||
}
|
||||
|
||||
// Garmin: the connect marker plus the key's expiry, the region its
|
||||
// key was accepted on, and how far the history backfill has got.
|
||||
interface GarminExtras {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
region: GarminRegion | null;
|
||||
keyExpiry: GarminKeyExpiry | null;
|
||||
backfillCompleted: boolean | null;
|
||||
backfillShare: number | null;
|
||||
unavailableResources: GarminBackfillStep[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Garmin entry's own facts. A Personal API key the person pastes once is
|
||||
* the connect marker; the key is never on the wire, only when it expires,
|
||||
* resolved here so every client warns alike.
|
||||
*/
|
||||
function garminExtras(
|
||||
dbUser: {
|
||||
garminApiKeyEncrypted: string | null;
|
||||
garminRegion: string | null;
|
||||
garminKeyExpiresAt: Date | null;
|
||||
garminBackfillProgress: unknown;
|
||||
garminBackfillCompletedAt: Date | null;
|
||||
} | null,
|
||||
): GarminExtras {
|
||||
const configured = !!dbUser?.garminApiKeyEncrypted;
|
||||
if (!dbUser || !configured) {
|
||||
return {
|
||||
connected: false,
|
||||
configured: false,
|
||||
region: null,
|
||||
keyExpiry: null,
|
||||
backfillCompleted: null,
|
||||
backfillShare: null,
|
||||
unavailableResources: null,
|
||||
};
|
||||
}
|
||||
const complete = dbUser.garminBackfillCompletedAt !== null;
|
||||
const progress = readGarminBackfillProgress(dbUser.garminBackfillProgress);
|
||||
return {
|
||||
connected: true,
|
||||
configured: true,
|
||||
region: isGarminRegion(dbUser.garminRegion)
|
||||
? dbUser.garminRegion
|
||||
: "global",
|
||||
keyExpiry: resolveGarminKeyExpiry(dbUser.garminKeyExpiresAt),
|
||||
backfillCompleted: complete,
|
||||
backfillShare: complete ? 1 : garminBackfillShare(progress),
|
||||
unavailableResources: garminUnavailableResources(progress),
|
||||
};
|
||||
}
|
||||
|
||||
// v1.43 — Dawarich: the connect marker only; the card reads `health` and its
|
||||
// own status route.
|
||||
interface DawarichExtras {
|
||||
|
||||
@@ -87,6 +87,9 @@ export const GET = apiHandler(async () => {
|
||||
// Not offered on a managed profile (no managed integrations before
|
||||
// v1.44); listed so the map stays total.
|
||||
dawarich: false,
|
||||
// Garmin is a key the record's owner pastes on their own settings page;
|
||||
// a managed profile has none to offer.
|
||||
garmin: false,
|
||||
};
|
||||
// The one server-resolved state per connection, from the same loader the
|
||||
// owner's own envelope uses, so a guardian reads the record's connections in
|
||||
|
||||
@@ -117,6 +117,10 @@ const GENERIC_METRIC_SCOPE_EXPECTATION: Readonly<
|
||||
AVERAGE_HEART_RATE: null,
|
||||
BREATHING_DISTURBANCES: null,
|
||||
CARDIO_LOAD: null,
|
||||
BODY_BATTERY: null,
|
||||
STRESS_LEVEL: null,
|
||||
TRAINING_READINESS: null,
|
||||
ACUTE_TRAINING_LOAD: null,
|
||||
CARDIO_RECOVERY: null,
|
||||
DAY_STRAIN: null,
|
||||
ENERGY_EXPENDITURE_KJ: null,
|
||||
|
||||
@@ -177,6 +177,10 @@ const STATUS_METRIC_SCOPE_SOURCE = {
|
||||
DAY_STRAIN: null,
|
||||
WORKOUT_STRAIN: null,
|
||||
CARDIO_LOAD: null,
|
||||
BODY_BATTERY: null,
|
||||
STRESS_LEVEL: null,
|
||||
TRAINING_READINESS: null,
|
||||
ACUTE_TRAINING_LOAD: null,
|
||||
AVERAGE_HEART_RATE: null,
|
||||
MAX_HEART_RATE: null,
|
||||
ENERGY_EXPENDITURE_KJ: null,
|
||||
|
||||
@@ -58,46 +58,17 @@ const EMPTY_COPY = {
|
||||
} as const satisfies Record<RecoveryEmptyReason, string>;
|
||||
|
||||
/**
|
||||
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
|
||||
* sub-page.
|
||||
*
|
||||
* The page now reads exactly like every other metric page: each present
|
||||
* device-native signal (WHOOP / Polar / Oura recharge, strain, load, and
|
||||
* whole-day cardiac / energy figures) renders as a canonical block — a
|
||||
* short explanatory line, the max / median / mean stat strip, the same
|
||||
* `<HealthChartDynamic>` chart with the identical 7 / 30 / 90 / All toggle,
|
||||
* and a per-chart AI assessment (`<MetricStatusCard>`). The composite
|
||||
* RECOVERY_SCORE keeps its anatomy view at `/insights/scores/recovery`.
|
||||
*
|
||||
* B3 — the redundant "Recovery score" link block that used to lead the
|
||||
* page is gone: this surface is reached from the overview already, so the
|
||||
* cross-link was duplicate ("doppelt gemoppelt").
|
||||
*
|
||||
* Each block is data-gated (a signal with zero readings is dropped) and a
|
||||
* group with no present block hides entirely, so a non-wearable account
|
||||
* sees only the calm empty note. Reads the shared analytics summaries slice
|
||||
* (no extra round-trip); server-authoritative throughout.
|
||||
* The page's chart blocks, grouped: recharge, strain, cardio. A block only
|
||||
* renders when its summary has data, so a provider-specific type costs
|
||||
* nothing on an account without that provider.
|
||||
*/
|
||||
export function RecoverySection() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, error, refetch } =
|
||||
useInsightsAnalytics("SLEEP_DURATION");
|
||||
// v1.18.1 — Rest Mode parity. The annotation rides the thick analytics
|
||||
// payload's `healthScore` block (server-authoritative; iOS mirrors it). We
|
||||
// read it off the same `["analytics"]` cache the insights mother page
|
||||
// primes, so navigating in is a free hit and a direct URL load pays one
|
||||
// thick fetch. Read-only — the score itself stays untouched.
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const restMode = ((
|
||||
analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null
|
||||
)?.restMode ?? null) as RestModeAnnotation | null;
|
||||
|
||||
const summaries = data?.summaries;
|
||||
function recoveryBlockGroups(
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
): BlockGroup[] {
|
||||
const unitScore = t("insights.deviceScore.unitScore");
|
||||
const unitBpm = t("insights.deviceScore.unitBpm");
|
||||
const unitKj = t("insights.deviceScore.unitKj");
|
||||
|
||||
const groups: BlockGroup[] = [
|
||||
return [
|
||||
{
|
||||
key: "recharge",
|
||||
blocks: [
|
||||
@@ -112,6 +83,36 @@ export function RecoverySection() {
|
||||
unit: unitScore,
|
||||
fractionDigits: 1,
|
||||
},
|
||||
{
|
||||
type: "BODY_BATTERY",
|
||||
chartKey: "bodyBattery",
|
||||
statusMetric: "BODY_BATTERY",
|
||||
title: t("measurements.typeBodyBattery"),
|
||||
explainer: t("insights.recovery.block.bodyBatteryExplainer"),
|
||||
icon: Battery,
|
||||
color: "var(--chart-2)",
|
||||
unit: unitScore,
|
||||
},
|
||||
{
|
||||
type: "TRAINING_READINESS",
|
||||
chartKey: "trainingReadiness",
|
||||
statusMetric: "TRAINING_READINESS",
|
||||
title: t("measurements.typeTrainingReadiness"),
|
||||
explainer: t("insights.recovery.block.trainingReadinessExplainer"),
|
||||
icon: HeartPulse,
|
||||
color: "var(--chart-4)",
|
||||
unit: unitScore,
|
||||
},
|
||||
{
|
||||
type: "STRESS_LEVEL",
|
||||
chartKey: "stressLevel",
|
||||
statusMetric: "STRESS_LEVEL",
|
||||
title: t("measurements.typeStressLevel"),
|
||||
explainer: t("insights.recovery.block.stressLevelExplainer"),
|
||||
icon: Gauge,
|
||||
color: "var(--chart-3)",
|
||||
unit: unitScore,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -149,6 +150,16 @@ export function RecoverySection() {
|
||||
color: "var(--chart-1)",
|
||||
unit: unitScore,
|
||||
},
|
||||
{
|
||||
type: "ACUTE_TRAINING_LOAD",
|
||||
chartKey: "acuteTrainingLoad",
|
||||
statusMetric: "ACUTE_TRAINING_LOAD",
|
||||
title: t("measurements.typeAcuteTrainingLoad"),
|
||||
explainer: t("insights.recovery.block.acuteTrainingLoadExplainer"),
|
||||
icon: TrendingUp,
|
||||
color: "var(--chart-5)",
|
||||
unit: t("insights.deviceScore.unitLoad"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -187,6 +198,45 @@ export function RecoverySection() {
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
|
||||
* sub-page.
|
||||
*
|
||||
* The page now reads exactly like every other metric page: each present
|
||||
* device-native signal (WHOOP / Polar / Oura recharge, strain, load, and
|
||||
* whole-day cardiac / energy figures) renders as a canonical block — a
|
||||
* short explanatory line, the max / median / mean stat strip, the same
|
||||
* `<HealthChartDynamic>` chart with the identical 7 / 30 / 90 / All toggle,
|
||||
* and a per-chart AI assessment (`<MetricStatusCard>`). The composite
|
||||
* RECOVERY_SCORE keeps its anatomy view at `/insights/scores/recovery`.
|
||||
*
|
||||
* B3 — the redundant "Recovery score" link block that used to lead the
|
||||
* page is gone: this surface is reached from the overview already, so the
|
||||
* cross-link was duplicate ("doppelt gemoppelt").
|
||||
*
|
||||
* Each block is data-gated (a signal with zero readings is dropped) and a
|
||||
* group with no present block hides entirely, so a non-wearable account
|
||||
* sees only the calm empty note. Reads the shared analytics summaries slice
|
||||
* (no extra round-trip); server-authoritative throughout.
|
||||
*/
|
||||
export function RecoverySection() {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, error, refetch } =
|
||||
useInsightsAnalytics("SLEEP_DURATION");
|
||||
// v1.18.1 — Rest Mode parity. The annotation rides the thick analytics
|
||||
// payload's `healthScore` block (server-authoritative; iOS mirrors it). We
|
||||
// read it off the same `["analytics"]` cache the insights mother page
|
||||
// primes, so navigating in is a free hit and a direct URL load pays one
|
||||
// thick fetch. Read-only — the score itself stays untouched.
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const restMode = ((
|
||||
analyticsQuery.data?.healthScore as { restMode?: RestModeAnnotation } | null
|
||||
)?.restMode ?? null) as RestModeAnnotation | null;
|
||||
|
||||
const summaries = data?.summaries;
|
||||
const groups = recoveryBlockGroups(t);
|
||||
|
||||
// Whether the page has anything to show, and why not, is the server's
|
||||
// answer (`recoveryEmpty`), so web and iOS read one decision.
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Scale,
|
||||
Heart,
|
||||
Activity,
|
||||
Battery,
|
||||
Droplets,
|
||||
Droplet,
|
||||
Moon,
|
||||
@@ -142,6 +143,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
INDOOR_CO2: "measurements.typeIndoorCo2",
|
||||
INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature",
|
||||
INDOOR_HUMIDITY: "measurements.typeIndoorHumidity",
|
||||
BODY_BATTERY: "measurements.typeBodyBattery",
|
||||
STRESS_LEVEL: "measurements.typeStressLevel",
|
||||
TRAINING_READINESS: "measurements.typeTrainingReadiness",
|
||||
ACUTE_TRAINING_LOAD: "measurements.typeAcuteTrainingLoad",
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -289,6 +294,11 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
INDOOR_CO2: Wind,
|
||||
INDOOR_TEMPERATURE: Thermometer,
|
||||
INDOOR_HUMIDITY: Droplets,
|
||||
// Garmin scores, read beside the other device-native scores.
|
||||
BODY_BATTERY: Battery,
|
||||
STRESS_LEVEL: Gauge,
|
||||
TRAINING_READINESS: HeartPulse,
|
||||
ACUTE_TRAINING_LOAD: TrendingUp,
|
||||
};
|
||||
|
||||
// The colour map lives in its own icon-free module so a surface that only
|
||||
|
||||
@@ -142,4 +142,10 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
INDOOR_CO2: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
INDOOR_TEMPERATURE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
INDOOR_HUMIDITY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
|
||||
// Garmin scores take the device-native score family (chart-1),
|
||||
// like ANS charge and cardio load.
|
||||
BODY_BATTERY: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
STRESS_LEVEL: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
TRAINING_READINESS: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
ACUTE_TRAINING_LOAD: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
|
||||
};
|
||||
@@ -102,6 +102,7 @@ const PROVIDERS = [
|
||||
"oura",
|
||||
"strava",
|
||||
"nightscout",
|
||||
"garmin",
|
||||
] as const;
|
||||
|
||||
function health(overrides: Record<string, unknown> = {}) {
|
||||
@@ -176,7 +177,7 @@ describe("IntegrationsSection — one status display per card", () => {
|
||||
expect(html).not.toContain("Last attempt");
|
||||
// The seven other providers and Apple Health are not connected and say so
|
||||
// once each.
|
||||
expect(count(html, ">Not connected.<")).toBe(8);
|
||||
expect(count(html, ">Not connected.<")).toBe(9);
|
||||
});
|
||||
|
||||
it("never shows the stored provider error or a failure counter", () => {
|
||||
@@ -247,17 +248,12 @@ describe("IntegrationsSection — one status display per card", () => {
|
||||
});
|
||||
|
||||
const html = render();
|
||||
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(8);
|
||||
expect(count(html, 'data-slot="integration-setup-guide"')).toBe(9);
|
||||
for (const provider of PROVIDERS) {
|
||||
expect(html).toContain(`data-testid="${provider}-setup-guide"`);
|
||||
expect(html).toContain(
|
||||
`href="https://docs.healthlog.dev/guides/connect-${provider}/"`,
|
||||
);
|
||||
}
|
||||
// Garmin has no connector; its note goes straight to the section of the
|
||||
// Google Health guide that explains the route, not through a redirect.
|
||||
expect(html).toContain(
|
||||
'href="https://docs.healthlog.dev/guides/connect-google-health/#garmin-owners"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -194,9 +194,9 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
expect(groupChip(html, "medications")).toBe("0/5");
|
||||
// ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS.
|
||||
expect(groupChip(html, "history")).toBe("0/5");
|
||||
expect(groupChip(html, "cardio")).toBe("0/15");
|
||||
expect(groupChip(html, "cardio")).toBe("0/16");
|
||||
expect(groupChip(html, "activity")).toBe("0/12");
|
||||
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
|
||||
expect(groupChip(html, "sleepRecovery")).toBe("0/19");
|
||||
expect(groupChip(html, "mobility")).toBe("0/11");
|
||||
expect(groupChip(html, "environment")).toBe("0/7");
|
||||
});
|
||||
|
||||
@@ -87,10 +87,13 @@ describe("structural guard: cards read health, not the retired fields", () => {
|
||||
|
||||
/**
|
||||
* `.state` is also the name of `health.state` and of a Google Health sync
|
||||
* run's progress state. Those bases are the allowed readers; any other base
|
||||
* reading `.state` is a ledger state finding its way back onto a card.
|
||||
* run's progress state, and of the Garmin key's expiry state (a server-
|
||||
* resolved fact of its own, not a ledger state). Those bases are the
|
||||
* allowed readers; any other base reading `.state` is a ledger state
|
||||
* finding its way back onto a card.
|
||||
*/
|
||||
const STATE_READERS = /(^|\.)(health|progress|syncProgress|record)$/;
|
||||
const STATE_READERS =
|
||||
/(^|\.)(health|progress|syncProgress|record|keyExpiry)$/;
|
||||
|
||||
const files = [
|
||||
...walkSourceFiles(join(SRC, CARDS), { floor: 15 })
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Garmin on the shared integration card.
|
||||
*
|
||||
* 1. Not connected: the key form, the link to Garmin Connect's key page,
|
||||
* the validity choice and Connect, inside the shared card.
|
||||
* 2. Connected: the same menu as Nightscout, a refused key opens the form,
|
||||
* and disconnect asks first and invalidates the envelope.
|
||||
* 3. The key's end: a sentence in the body while it is close, past or
|
||||
* unknown, and the date in the details while it is comfortably valid.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const invalidateSpy = vi.fn();
|
||||
const capturedDisconnectOnSuccess: { fn: (() => void) | null } = { fn: null };
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
capturedDisconnectOnSuccess.fn = onSuccess ?? null;
|
||||
return { mutate: vi.fn(), isPending: false };
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: invalidateSpy }),
|
||||
}));
|
||||
|
||||
let lastCardProps: IntegrationCardProps | null = null;
|
||||
vi.mock("../integration-card", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../integration-card")>();
|
||||
return {
|
||||
...actual,
|
||||
IntegrationCard: (props: IntegrationCardProps) => {
|
||||
lastCardProps = props;
|
||||
return createElement(actual.IntegrationCard, props);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { GarminKeyExpiry } from "@/lib/garmin/key-expiry";
|
||||
import type { IntegrationCardProps } from "../integration-card";
|
||||
import { GarminCard, GARMIN_KEY_SETTINGS_URL } from "../garmin-card";
|
||||
import type { IntegrationStatusViewModel } from "../shared";
|
||||
import { healthFixture } from "./health-fixture";
|
||||
|
||||
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<GarminCard
|
||||
viewModel={
|
||||
viewModel
|
||||
? ({
|
||||
integration: "garmin",
|
||||
...viewModel,
|
||||
} as IntegrationStatusViewModel)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function connected(keyExpiry: GarminKeyExpiry | null) {
|
||||
return render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
health: healthFixture(),
|
||||
keyExpiry,
|
||||
metricFreshness: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe("GarminCard on the shared card", () => {
|
||||
it("shows the key form inside the shared card while not connected", () => {
|
||||
const html = render({ connected: false, configured: false, health: null });
|
||||
expect(html).toContain('data-slot="integration-card"');
|
||||
expect(html).toContain('data-state="not_connected"');
|
||||
expect(html).toContain('id="garmin-api-key"');
|
||||
expect(html).toContain('id="garmin-key-validity"');
|
||||
expect(html).toContain(`href="${GARMIN_KEY_SETTINGS_URL}"`);
|
||||
expect(html).toContain('data-testid="garmin-connect"');
|
||||
expect(html).toContain(
|
||||
'href="https://docs.healthlog.dev/guides/connect-garmin/"',
|
||||
);
|
||||
// The service: Garmin Connect (global) first, China second.
|
||||
expect(html).toContain('id="garmin-region"');
|
||||
expect(html).toMatch(
|
||||
/<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", () => {
|
||||
render({
|
||||
connected: true,
|
||||
configured: true,
|
||||
health: healthFixture({
|
||||
state: "action_required",
|
||||
reason: "reauth_required",
|
||||
action: "reconnect",
|
||||
}),
|
||||
});
|
||||
expect((lastCardProps?.menu ?? []).map((i) => i.id)).toEqual([
|
||||
"sync",
|
||||
"test",
|
||||
"view-data",
|
||||
"credentials",
|
||||
"validity",
|
||||
"setup-guide",
|
||||
"disconnect",
|
||||
]);
|
||||
expect(lastCardProps?.primary?.onReconnect).toBeTypeOf("function");
|
||||
const disconnect = lastCardProps?.menu?.find((i) => i.id === "disconnect");
|
||||
expect(disconnect?.destructive).toBe(true);
|
||||
expect(disconnect?.confirm?.title).toBe("Disconnect Garmin?");
|
||||
invalidateSpy.mockClear();
|
||||
capturedDisconnectOnSuccess.fn?.();
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
});
|
||||
|
||||
it("warns in the body when the key is about to expire", () => {
|
||||
const html = connected({
|
||||
state: "expiring",
|
||||
expiresAt: "2026-11-08T00:00:00.000Z",
|
||||
daysLeft: 3,
|
||||
});
|
||||
expect(html).toMatch(
|
||||
/data-testid="garmin-key-expiry"[^>]*data-state="expiring"/,
|
||||
);
|
||||
expect(html).toContain("Your Garmin key expires on");
|
||||
expect(html).not.toContain('data-testid="garmin-key-valid-until"');
|
||||
});
|
||||
|
||||
it("says when the key expires today and when it has expired", () => {
|
||||
expect(
|
||||
connected({
|
||||
state: "expiring",
|
||||
expiresAt: "2026-11-04T20:00:00.000Z",
|
||||
daysLeft: 0,
|
||||
}),
|
||||
).toContain("Your Garmin key expires today.");
|
||||
expect(
|
||||
connected({
|
||||
state: "expired",
|
||||
expiresAt: "2026-11-01T00:00:00.000Z",
|
||||
daysLeft: null,
|
||||
}),
|
||||
).toContain("Your Garmin key expired on");
|
||||
});
|
||||
|
||||
it("asks for the validity when the end is unknown", () => {
|
||||
expect(
|
||||
connected({ state: "unknown", expiresAt: null, daysLeft: null }),
|
||||
).toContain("Garmin does not say when this key expires.");
|
||||
});
|
||||
|
||||
it("keeps a valid key's date in the details, not the body", () => {
|
||||
const html = connected({
|
||||
state: "ok",
|
||||
expiresAt: "2027-01-30T00:00:00.000Z",
|
||||
daysLeft: 87,
|
||||
});
|
||||
expect(html).not.toContain('data-testid="garmin-key-expiry"');
|
||||
const details = html.slice(html.indexOf('data-slot="integration-details"'));
|
||||
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"/);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/**
|
||||
* `<ConnectionsPanel>` — the connected-services half of Settings →
|
||||
* Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Nightscout.
|
||||
* Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Garmin / Nightscout.
|
||||
*
|
||||
* v1.18.0 — extracted out of `integrations-section.tsx` so the section
|
||||
* file can host the Connections / Channels / Sources sub-tabs without growing
|
||||
@@ -20,7 +20,7 @@ import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
|
||||
import { DawarichCard } from "@/components/settings/integrations/dawarich-card";
|
||||
import { FitbitRetirementNotice } from "@/components/settings/integrations/fitbit-retirement-notice";
|
||||
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
|
||||
import { GarminCard } from "@/components/settings/integrations/garmin-card";
|
||||
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
|
||||
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
|
||||
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
|
||||
@@ -203,6 +203,7 @@ const CARD_BACKED_INTEGRATIONS: ReadonlySet<IntegrationKey> = new Set([
|
||||
"strava",
|
||||
"nightscout",
|
||||
"dawarich",
|
||||
"garmin",
|
||||
]);
|
||||
|
||||
export function ConnectionsPanel({
|
||||
@@ -403,6 +404,9 @@ export function ConnectionsPanel({
|
||||
callbackUrl={callbackUrls.strava}
|
||||
/>
|
||||
</div>
|
||||
<div id="garmin" className="scroll-mt-28">
|
||||
<GarminCard viewModel={pickStatus(integrationStatus, "garmin")} />
|
||||
</div>
|
||||
<div id="nightscout" className="scroll-mt-28">
|
||||
<NightscoutCard
|
||||
viewModel={pickStatus(integrationStatus, "nightscout")}
|
||||
@@ -426,12 +430,6 @@ export function ConnectionsPanel({
|
||||
<IntegrationFallbackRow status={entry} />
|
||||
</div>
|
||||
))}
|
||||
{/* Garmin: no direct connector (business-partner-only). A quiet
|
||||
non-connector note pointing to the Apple Health / Health Connect
|
||||
path — not an OAuthProviderCard (nothing to connect). */}
|
||||
<div id="garmin" className="scroll-mt-28">
|
||||
<GarminInfoNote />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
"use client";
|
||||
|
||||
// Garmin, through the Personal API (#1333). Like Nightscout this is a
|
||||
// credential the person pastes once rather than an OAuth round-trip: they
|
||||
// create a key in Garmin Connect and HealthLog buys short-lived tokens with
|
||||
// it on every sync. The card reads the consolidated `/api/integrations/status`
|
||||
// envelope like every sibling and renders the shared integration card. The
|
||||
// one Garmin-specific fact it adds is the key's end: Garmin keys expire after
|
||||
// the validity picked at creation, and the card says so ahead of time.
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
CalendarClock,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Link2,
|
||||
Loader2,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Unlink,
|
||||
Watch,
|
||||
} from "lucide-react";
|
||||
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { 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,
|
||||
} from "@/lib/garmin/key-expiry";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
ActionResultLines,
|
||||
useConnectionTest,
|
||||
useStandardSync,
|
||||
} from "./integration-actions";
|
||||
import {
|
||||
IntegrationCard,
|
||||
type IntegrationMenuAction,
|
||||
} from "./integration-card";
|
||||
import { MetricFreshnessList } from "./metric-freshness-list";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
integrationDocsHref,
|
||||
} from "./setup-guide-link";
|
||||
import { failureStreakFor, type IntegrationStatusViewModel } from "./shared";
|
||||
|
||||
/** 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,
|
||||
}: {
|
||||
viewModel?: IntegrationStatusViewModel;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
const status = viewModel;
|
||||
const connected = status?.connected ?? false;
|
||||
|
||||
const sync = useStandardSync({
|
||||
provider: "garmin",
|
||||
i18nPrefix: "settings.garmin",
|
||||
acceptsFullSync: false,
|
||||
});
|
||||
const test = useConnectionTest("/api/garmin/test");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [validityEditing, setValidityEditing] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement | null>(null);
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/garmin/disconnect");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// An expired or revoked key is resolved by pasting a new one: the button
|
||||
// opens the form and puts the cursor in the key field.
|
||||
function openForm() {
|
||||
setEditing(true);
|
||||
requestAnimationFrame(() => {
|
||||
formRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
formRef.current
|
||||
?.querySelector<HTMLInputElement>("#garmin-api-key")
|
||||
?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
const form = <GarminForm formRef={formRef} connected={connected} />;
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<IntegrationCard
|
||||
provider="garmin"
|
||||
providerName={t("settings.garmin")}
|
||||
icon={Watch}
|
||||
titleAccessory={<TagChip>{t("settings.garminTag")}</TagChip>}
|
||||
description={
|
||||
<IntegrationCardDescription
|
||||
i18nPrefix="settings.garmin"
|
||||
provider="garmin"
|
||||
/>
|
||||
}
|
||||
loading={viewModel === undefined}
|
||||
connected={false}
|
||||
health={null}
|
||||
setup={form}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const keyExpiry = status?.keyExpiry ?? null;
|
||||
const expiryNote = garminKeyExpiryNote(keyExpiry, t, fmt.date);
|
||||
|
||||
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
|
||||
provider="garmin"
|
||||
providerName={t("settings.garmin")}
|
||||
icon={Watch}
|
||||
titleAccessory={<TagChip>{t("settings.garminTag")}</TagChip>}
|
||||
connected
|
||||
health={status?.health ?? null}
|
||||
historyLoading={status?.backfillCompleted === false}
|
||||
primary={{ onReconnect: openForm, onResume: openForm }}
|
||||
menu={menu}
|
||||
diagnosticsExtra={[
|
||||
...(streak
|
||||
? [
|
||||
t("settings.integrationCard.details.failureStreak", {
|
||||
count: streak.count,
|
||||
threshold: streak.threshold,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(unavailableNote ? [unavailableNote] : []),
|
||||
]}
|
||||
details={
|
||||
<>
|
||||
{keyExpiry?.state === "ok" && keyExpiry.expiresAt ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="garmin-key-valid-until"
|
||||
>
|
||||
{t("settings.garminKeyValidUntil", {
|
||||
date: fmt.date(keyExpiry.expiresAt),
|
||||
})}
|
||||
</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}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{expiryNote ? (
|
||||
// The one fact the generic sentence cannot carry: the key has an end
|
||||
// date the person chose, and only a new key moves it. Said in the
|
||||
// body, not as a banner, like Nightscout's operator note.
|
||||
<p
|
||||
className="text-sm"
|
||||
data-testid="garmin-key-expiry"
|
||||
data-state={keyExpiry?.state}
|
||||
>
|
||||
{expiryNote}
|
||||
</p>
|
||||
) : null}
|
||||
<ActionResultLines provider="garmin" sync={sync} test={test} />
|
||||
{editing ? form : null}
|
||||
{validityEditing ? <GarminValidityForm /> : null}
|
||||
</IntegrationCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The body sentence for a key that has expired, is about to, or whose end is
|
||||
* unknown; null while it is comfortably valid.
|
||||
*/
|
||||
export function garminKeyExpiryNote(
|
||||
keyExpiry: GarminKeyExpiry | null | undefined,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
formatDate: (iso: string) => string,
|
||||
): string | null {
|
||||
if (!keyExpiry) return null;
|
||||
const { expiresAt, daysLeft } = keyExpiry;
|
||||
switch (keyExpiry.state) {
|
||||
case "expired":
|
||||
return t("settings.garminKeyExpired", {
|
||||
date: expiresAt ? formatDate(expiresAt) : "",
|
||||
});
|
||||
case "expiring":
|
||||
return daysLeft === 0
|
||||
? t("settings.garminKeyExpiresToday")
|
||||
: t("settings.garminKeyExpiring", {
|
||||
date: expiresAt ? formatDate(expiresAt) : "",
|
||||
});
|
||||
case "unknown":
|
||||
return t("settings.garminKeyExpiryUnknown");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
}: {
|
||||
formRef: React.RefObject<HTMLFormElement | null>;
|
||||
connected: boolean;
|
||||
}) {
|
||||
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<{
|
||||
kind: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
async function handleConnect(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/garmin/connect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
apiKey: apiKey.trim(),
|
||||
region,
|
||||
validityDays: validity ? Number(validity) : null,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setMsg({ kind: "success", text: t("settings.garminConnected") });
|
||||
setApiKey("");
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
setMsg({ kind: "error", text: t(await garminErrorKey(res)) });
|
||||
}
|
||||
} catch {
|
||||
setMsg({ kind: "error", text: t("common.networkError") });
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleConnect}
|
||||
className="space-y-3"
|
||||
data-testid="garmin-form"
|
||||
>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.garminKeyHowTo")}{" "}
|
||||
<a
|
||||
href={GARMIN_KEY_SETTINGS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="garmin-key-settings-link"
|
||||
className="hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
|
||||
>
|
||||
{t("settings.garminKeySettingsLink")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="garmin-api-key">{t("settings.garminApiKey")}</Label>
|
||||
<PasswordInput
|
||||
id="garmin-api-key"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={
|
||||
connected
|
||||
? t("settings.garminApiKeySavedPlaceholder")
|
||||
: t("settings.garminApiKeyPlaceholder")
|
||||
}
|
||||
maxLength={1024}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
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>
|
||||
<ValiditySelect
|
||||
id="garmin-key-validity"
|
||||
value={validity}
|
||||
onChange={setValidity}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.garminKeyValidityHelp")}
|
||||
</p>
|
||||
</div>
|
||||
<FormMessage msg={msg} />
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant={connected ? "outline" : "default"}
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
disabled={saving || !apiKey.trim()}
|
||||
data-slot={connected ? undefined : "integration-primary-action"}
|
||||
data-action={connected ? undefined : "connect"}
|
||||
data-testid="garmin-connect"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : connected ? (
|
||||
<Save className="size-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<Link2 className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{connected ? t("settings.garminUpdate") : t("settings.garminConnect")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// v1.28.x — Garmin is NOT a direct connector: Garmin's developer program is
|
||||
// business-partner-only (approval-gated, needs public callbacks), so the
|
||||
// self-hosted BYO model cannot register a paste-your-key Garmin app. The honest
|
||||
// path is Apple Health (iOS) or Google Health Connect (Android), which already
|
||||
// works with zero extra code. This quiet, non-connector info note pre-empts the
|
||||
// recurring "where's Garmin?" question and points to the runbook.
|
||||
|
||||
import { ExternalLink, Watch } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { GARMIN_DOCS_HREF } from "@/components/settings/integrations/setup-guide-link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function GarminInfoNote() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SettingsCard data-testid="garmin-info">
|
||||
<SettingsCardHeader
|
||||
icon={Watch}
|
||||
title={t("settings.garminInfo.title")}
|
||||
description={
|
||||
<p className="text-muted-foreground">
|
||||
{t("settings.garminInfo.body")}{" "}
|
||||
<a
|
||||
href={GARMIN_DOCS_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="garmin-docs-link"
|
||||
data-slot="garmin-docs-link"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
|
||||
>
|
||||
{t("settings.garminInfo.docsLink")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export const INTEGRATION_DISPLAY_NAMES: Record<IntegrationKey, string> = {
|
||||
"google-health": "Google Health",
|
||||
strava: "Strava",
|
||||
dawarich: "Dawarich",
|
||||
garmin: "Garmin",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,9 +25,6 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
*/
|
||||
export const DOCS_GUIDES_BASE = "https://docs.healthlog.dev/guides";
|
||||
|
||||
/** Garmin has no connector; the Google Health guide explains the route. */
|
||||
export const GARMIN_DOCS_HREF = `${DOCS_GUIDES_BASE}/connect-google-health/#garmin-owners`;
|
||||
|
||||
export type IntegrationDocsProvider =
|
||||
| "whoop"
|
||||
| "withings"
|
||||
@@ -37,7 +34,8 @@ export type IntegrationDocsProvider =
|
||||
| "nightscout"
|
||||
| "google-health"
|
||||
| "strava"
|
||||
| "dawarich";
|
||||
| "dawarich"
|
||||
| "garmin";
|
||||
|
||||
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
|
||||
return `${DOCS_GUIDES_BASE}/connect-${provider}/`;
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { ConnectionHealth } from "@/lib/integrations/connection-health";
|
||||
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { PublishedFitbitRetirement } from "@/lib/fitbit/retirement";
|
||||
import type { GarminKeyExpiry } from "@/lib/garmin/key-expiry";
|
||||
import type { GarminBackfillStep } from "@/lib/garmin/backfill-progress";
|
||||
|
||||
export type IntegrationKey =
|
||||
| "withings"
|
||||
@@ -30,7 +32,9 @@ export type IntegrationKey =
|
||||
// status and inherits the shared verdict.
|
||||
| "nightscout"
|
||||
// v1.43 — Dawarich movement context; publishes `health` only.
|
||||
| "dawarich";
|
||||
| "dawarich"
|
||||
// Garmin Personal API; a pasted key, like Nightscout.
|
||||
| "garmin";
|
||||
/**
|
||||
* One envelope entry as the cards read it. Since v1.42.4 the cards render
|
||||
* `health` alone; the older health-shaped fields (ledger `state`, `lastError`,
|
||||
@@ -54,7 +58,7 @@ export interface IntegrationStatusViewModel {
|
||||
failureThreshold?: number;
|
||||
configured?: boolean;
|
||||
connected?: boolean;
|
||||
/** WHOOP / Fitbit / Google Health: older history still loading. */
|
||||
/** WHOOP / Fitbit / Google Health / Garmin: older history still loading. */
|
||||
backfillCompleted?: boolean | null;
|
||||
/**
|
||||
* Google Health: data types whose history backfill ended before the start
|
||||
@@ -70,6 +74,10 @@ export interface IntegrationStatusViewModel {
|
||||
hasOwnCredentials?: boolean;
|
||||
/** Nightscout: a stored token. */
|
||||
hasToken?: boolean;
|
||||
/** Garmin: when the stored key stops working, resolved on the server. */
|
||||
keyExpiry?: GarminKeyExpiry | null;
|
||||
/** 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> = {
|
||||
|
||||
@@ -124,6 +124,11 @@ const EXPECTED_TYPES = [
|
||||
"INDOOR_CO2",
|
||||
"INDOOR_TEMPERATURE",
|
||||
"INDOOR_HUMIDITY",
|
||||
// ── Garmin Personal API + bridged training figures ──
|
||||
"BODY_BATTERY",
|
||||
"STRESS_LEVEL",
|
||||
"TRAINING_READINESS",
|
||||
"ACUTE_TRAINING_LOAD",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -149,7 +149,7 @@ export const SETTINGS_CARDS: ReadonlyArray<SettingsCardEntry> = [
|
||||
{
|
||||
section: "integrations",
|
||||
anchor: "garmin",
|
||||
titleKey: "settings.garminInfo.title",
|
||||
titleKey: "settings.garmin",
|
||||
},
|
||||
{
|
||||
section: "integrations",
|
||||
|
||||
@@ -169,6 +169,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
// v1.43 — Dawarich connection (URL and API key, like the Nightscout pair).
|
||||
{ model: "User", field: "dawarichUrlEncrypted", kind: "string" },
|
||||
{ model: "User", field: "dawarichApiKeyEncrypted", kind: "string" },
|
||||
// Garmin Personal API key.
|
||||
{ model: "User", field: "garminApiKeyEncrypted", kind: "string" },
|
||||
{ model: "User", field: "polarAccessTokenEncrypted", kind: "string" },
|
||||
{ model: "User", field: "polarUserIdEncrypted", kind: "string" },
|
||||
{ model: "User", field: "polarClientIdEncrypted", kind: "string" },
|
||||
|
||||
@@ -322,6 +322,11 @@ export const CHART_OVERLAY_KEYS = [
|
||||
"averageHeartRate",
|
||||
"maxHeartRate",
|
||||
"energyExpenditureKj",
|
||||
// The Garmin scores and the bridged training figures.
|
||||
"bodyBattery",
|
||||
"stressLevel",
|
||||
"trainingReadiness",
|
||||
"acuteTrainingLoad",
|
||||
// v1.25 — chart-overlay slots for the physical / clinical signal sub-pages.
|
||||
// One per chart surface; camel-case keys mirror the existing convention.
|
||||
"gripStrength",
|
||||
|
||||
@@ -465,6 +465,12 @@ export const USER_RESET = {
|
||||
dawarichApiKeyEncrypted: null,
|
||||
dawarichPrefsEncrypted: null,
|
||||
dawarichAllowPrivateHost: false,
|
||||
garminApiKeyEncrypted: null,
|
||||
garminRegion: null,
|
||||
garminConnectedAt: null,
|
||||
garminKeyExpiresAt: null,
|
||||
garminBackfillProgress: Prisma.DbNull,
|
||||
garminBackfillCompletedAt: null,
|
||||
polarAccessTokenEncrypted: null,
|
||||
polarUserIdEncrypted: null,
|
||||
polarClientIdEncrypted: null,
|
||||
|
||||
@@ -113,6 +113,12 @@ export const USER_COLUMNS_OUTSIDE_SETTINGS = {
|
||||
dawarichApiKeyEncrypted: "CREDENTIAL",
|
||||
dawarichPrefsEncrypted: "CREDENTIAL",
|
||||
dawarichAllowPrivateHost: "CREDENTIAL",
|
||||
// The Garmin key, and the facts that only mean something beside it: the
|
||||
// service that accepted it, when it was connected and when it ends.
|
||||
garminApiKeyEncrypted: "CREDENTIAL",
|
||||
garminRegion: "CREDENTIAL",
|
||||
garminConnectedAt: "CREDENTIAL",
|
||||
garminKeyExpiresAt: "CREDENTIAL",
|
||||
polarAccessTokenEncrypted: "CREDENTIAL",
|
||||
polarUserIdEncrypted: "CREDENTIAL",
|
||||
polarClientIdEncrypted: "CREDENTIAL",
|
||||
@@ -150,6 +156,9 @@ export const USER_COLUMNS_OUTSIDE_SETTINGS = {
|
||||
// provider; a carried note would claim history this host never imported.
|
||||
ouraBackfillProgress: "OPERATIONAL",
|
||||
ouraBackfillCompletedAt: "OPERATIONAL",
|
||||
// Same for Garmin: a restored account reads its Garmin history again.
|
||||
garminBackfillProgress: "OPERATIONAL",
|
||||
garminBackfillCompletedAt: "OPERATIONAL",
|
||||
healthKitLastSyncedAt: "OPERATIONAL",
|
||||
healthKitLastSyncTrigger: "OPERATIONAL",
|
||||
healthKitLastBackgroundSyncAt: "OPERATIONAL",
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
chunkGarminRange,
|
||||
clearGarminTokenCache,
|
||||
exchangeGarminKey,
|
||||
extractGarminToken,
|
||||
GarminApiError,
|
||||
GarminSession,
|
||||
garminTokenExpiry,
|
||||
GARMIN_TOKEN_PATH,
|
||||
isGarminKeyRefusal,
|
||||
isGarminRateLimit,
|
||||
} from "../client";
|
||||
|
||||
const NOW = Date.parse("2026-11-04T08:00:00Z");
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
/** 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");
|
||||
const claims =
|
||||
iatSeconds === undefined
|
||||
? { exp: expSeconds, n: nonce }
|
||||
: { exp: expSeconds, iat: iatSeconds, n: nonce };
|
||||
return `${enc({ alg: "none" })}.${enc(claims)}.sig`;
|
||||
}
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
auth: string | null;
|
||||
}
|
||||
|
||||
/** A scripted Garmin: each request takes the next answer for its path kind. */
|
||||
function fakeGarmin(script: {
|
||||
token?: Array<() => Response>;
|
||||
data?: Array<() => Response>;
|
||||
}) {
|
||||
const calls: Call[] = [];
|
||||
const tokens = [...(script.token ?? [])];
|
||||
const data = [...(script.data ?? [])];
|
||||
const fetch = (async (url: string | URL, init?: RequestInit) => {
|
||||
const u = url.toString();
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({ url: u, auth: headers.get("authorization") });
|
||||
const next = u.includes(GARMIN_TOKEN_PATH) ? tokens.shift() : data.shift();
|
||||
if (!next) throw new Error(`unexpected request ${u}`);
|
||||
return next();
|
||||
}) as unknown as typeof import("@/lib/safe-fetch").safeFetch;
|
||||
return { fetch, calls };
|
||||
}
|
||||
|
||||
const tokenResponse = (value: string) => () =>
|
||||
new Response(value, { status: 200 });
|
||||
const json =
|
||||
(body: unknown, status = 200) =>
|
||||
() =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
afterEach(() => clearGarminTokenCache());
|
||||
|
||||
describe("extractGarminToken", () => {
|
||||
it("reads a bare token, a quoted one and a JSON wrapper", () => {
|
||||
expect(extractGarminToken(" abc.def.ghi\n")).toBe("abc.def.ghi");
|
||||
expect(extractGarminToken('"abc.def.ghi"')).toBe("abc.def.ghi");
|
||||
expect(extractGarminToken('{"access_token":"abc.def.ghi"}')).toBe(
|
||||
"abc.def.ghi",
|
||||
);
|
||||
expect(extractGarminToken("{not json")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("garminTokenExpiry", () => {
|
||||
it("reads exp in ms and ignores tokens without one", () => {
|
||||
expect(garminTokenExpiry(jwt(1_800_000_000))).toBe(1_800_000_000_000);
|
||||
expect(garminTokenExpiry("opaque")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeGarminKey", () => {
|
||||
it("sends the key as ApiKey and returns a token with its refresh point", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({ token: [tokenResponse(jwt(exp))] });
|
||||
const token = await exchangeGarminKey("k".repeat(20), "global", {
|
||||
fetch: g.fetch,
|
||||
now: () => NOW,
|
||||
});
|
||||
expect(g.calls[0]!.auth).toBe(`ApiKey ${"k".repeat(20)}`);
|
||||
expect(g.calls[0]!.url).toBe(
|
||||
`https://personal.connectapi.garmin.com${GARMIN_TOKEN_PATH}`,
|
||||
);
|
||||
expect(token.expiresAt).toBe(exp * 1000);
|
||||
// An hour before expiry for a regular day-long token.
|
||||
expect(token.refreshAt).toBe(exp * 1000 - HOUR);
|
||||
// A regular token says nothing about the key's end.
|
||||
expect(token.keyExpiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it("learns the key's end from a token capped short of a day", async () => {
|
||||
const exp = Math.floor((NOW + 5 * HOUR) / 1000);
|
||||
const g = fakeGarmin({ token: [tokenResponse(jwt(exp))] });
|
||||
const token = await exchangeGarminKey("k".repeat(20), "global", {
|
||||
fetch: g.fetch,
|
||||
now: () => NOW,
|
||||
});
|
||||
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) => {
|
||||
const g = fakeGarmin({
|
||||
token: [() => new Response("", { status })],
|
||||
});
|
||||
const err = await exchangeGarminKey("k".repeat(20), "china", {
|
||||
fetch: g.fetch,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(GarminApiError);
|
||||
expect(isGarminKeyRefusal(err)).toBe(true);
|
||||
expect(err.classification).toBe("reauth_required");
|
||||
expect(g.calls[0]!.url).toContain("connectapi.garmin.cn");
|
||||
},
|
||||
);
|
||||
|
||||
it("carries Retry-After on a rate-limited exchange", async () => {
|
||||
const g = fakeGarmin({
|
||||
token: [
|
||||
() =>
|
||||
new Response("", { status: 429, headers: { "retry-after": "120" } }),
|
||||
],
|
||||
});
|
||||
const err = await exchangeGarminKey("k".repeat(20), "global", {
|
||||
fetch: g.fetch,
|
||||
}).catch((e) => e);
|
||||
expect(isGarminRateLimit(err)).toBe(true);
|
||||
expect(err.retryAfterMs).toBe(120_000);
|
||||
expect(isGarminKeyRefusal(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a 200 that is not a token, without echoing the body", async () => {
|
||||
const g = fakeGarmin({
|
||||
token: [() => new Response("<html>maintenance</html>", { status: 200 })],
|
||||
});
|
||||
const err = await exchangeGarminKey("k".repeat(20), "global", {
|
||||
fetch: g.fetch,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(GarminApiError);
|
||||
expect(err.reason).toBe("token_not_jwt");
|
||||
expect(String(err.message)).not.toContain("maintenance");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GarminSession", () => {
|
||||
const key = "secret-key-0123456789";
|
||||
|
||||
function session(fetch: ReturnType<typeof fakeGarmin>["fetch"], now = NOW) {
|
||||
return new GarminSession({
|
||||
userId: "u1",
|
||||
apiKey: key,
|
||||
region: "global",
|
||||
transport: { fetch, now: () => now },
|
||||
});
|
||||
}
|
||||
|
||||
it("caches the token across calls and sessions", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp))],
|
||||
data: [json({ a: 1 }), json({ a: 2 })],
|
||||
});
|
||||
expect(await session(g.fetch).get("x", "/p")).toEqual({ a: 1 });
|
||||
expect(await session(g.fetch).get("x", "/p")).toEqual({ a: 2 });
|
||||
const exchanges = g.calls.filter((c) => c.url.includes(GARMIN_TOKEN_PATH));
|
||||
expect(exchanges).toHaveLength(1);
|
||||
expect(g.calls[1]!.auth).toBe(`Bearer ${jwt(exp)}`);
|
||||
});
|
||||
|
||||
it("buys a new token once the cached one is near its end", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp, "a")), tokenResponse(jwt(exp + 10, "b"))],
|
||||
data: [json({}), json({})],
|
||||
});
|
||||
await session(g.fetch).get("x", "/p");
|
||||
await session(g.fetch, NOW + 23.5 * HOUR).get("x", "/p");
|
||||
expect(
|
||||
g.calls.filter((c) => c.url.includes(GARMIN_TOKEN_PATH)),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not reuse another key's token", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp, "a")), tokenResponse(jwt(exp, "b"))],
|
||||
data: [json({}), json({})],
|
||||
});
|
||||
await session(g.fetch).get("x", "/p");
|
||||
await new GarminSession({
|
||||
userId: "u1",
|
||||
apiKey: "another-key-0123456789",
|
||||
region: "global",
|
||||
transport: { fetch: g.fetch, now: () => NOW },
|
||||
}).get("x", "/p");
|
||||
expect(
|
||||
g.calls.filter((c) => c.url.includes(GARMIN_TOKEN_PATH)),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("re-exchanges once on a 401 and retries", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp, "a")), tokenResponse(jwt(exp, "b"))],
|
||||
data: [() => new Response("", { status: 401 }), json({ ok: true })],
|
||||
});
|
||||
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")}`);
|
||||
});
|
||||
|
||||
it("treats a second 401 as the key's end and stops", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp, "a")), tokenResponse(jwt(exp, "b"))],
|
||||
data: [
|
||||
() => new Response("", { status: 401 }),
|
||||
() => new Response("", { status: 401 }),
|
||||
],
|
||||
});
|
||||
const err = await session(g.fetch)
|
||||
.get("x", "/p")
|
||||
.catch((e) => e);
|
||||
expect(isGarminKeyRefusal(err)).toBe(true);
|
||||
// Exactly one retry: two data calls, two exchanges, nothing more.
|
||||
expect(g.calls).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("surfaces a 429 with its wait and does not retry", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp))],
|
||||
data: [
|
||||
() =>
|
||||
new Response("", { status: 429, headers: { "retry-after": "30" } }),
|
||||
],
|
||||
});
|
||||
const err = (await session(g.fetch)
|
||||
.get("x", "/p")
|
||||
.catch((e) => e)) as GarminApiError;
|
||||
expect(isGarminRateLimit(err)).toBe(true);
|
||||
expect(err.retryAfterMs).toBe(30_000);
|
||||
expect(g.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("answers null for 204 and an empty body", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp))],
|
||||
data: [
|
||||
() => new Response(null, { status: 204 }),
|
||||
() => new Response("", { status: 200 }),
|
||||
],
|
||||
});
|
||||
const s = session(g.fetch);
|
||||
expect(await s.get("x", "/p")).toBeNull();
|
||||
expect(await s.get("x", "/p")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts the query on the URL and drops empty values", async () => {
|
||||
const exp = Math.floor((NOW + 24 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp))],
|
||||
data: [json([])],
|
||||
});
|
||||
await session(g.fetch).get("x", "/p", {
|
||||
startDate: "2026-10-01",
|
||||
none: undefined,
|
||||
flag: true,
|
||||
});
|
||||
expect(g.calls[1]!.url).toBe(
|
||||
"https://personal.connectapi.garmin.com/p?startDate=2026-10-01&flag=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("remembers the key's end when a token reveals it", async () => {
|
||||
const exp = Math.floor((NOW + 3 * HOUR) / 1000);
|
||||
const g = fakeGarmin({
|
||||
token: [tokenResponse(jwt(exp))],
|
||||
data: [json({})],
|
||||
});
|
||||
const s = session(g.fetch);
|
||||
await s.get("x", "/p");
|
||||
expect(s.learnedKeyExpiresAt).toBe(exp * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chunkGarminRange", () => {
|
||||
it("splits newest first into windows of at most maxDays, both ends counted", () => {
|
||||
expect(chunkGarminRange("2026-01-01", "2026-03-01", 28)).toEqual([
|
||||
{ startDate: "2026-02-02", endDate: "2026-03-01" },
|
||||
{ startDate: "2026-01-05", endDate: "2026-02-01" },
|
||||
{ startDate: "2026-01-01", endDate: "2026-01-04" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a year of daily summaries in one 365-day request", () => {
|
||||
const chunks = chunkGarminRange("2025-11-05", "2026-11-04", 365);
|
||||
expect(chunks).toEqual([
|
||||
{ startDate: "2025-11-05", endDate: "2026-11-04" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns one single-day window and nothing for an inverted range", () => {
|
||||
expect(chunkGarminRange("2026-05-05", "2026-05-05", 31)).toEqual([
|
||||
{ startDate: "2026-05-05", endDate: "2026-05-05" },
|
||||
]);
|
||||
expect(chunkGarminRange("2026-05-06", "2026-05-05", 31)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Mapping per Garmin resource, over made-up records shaped from Garmin's
|
||||
* OpenAPI document (Personal API 1.0.0). No value here comes from a real
|
||||
* account.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
mapGarminDailySummary,
|
||||
mapGarminHrv,
|
||||
mapGarminSleep,
|
||||
mapGarminWeightRange,
|
||||
parseGarminInstant,
|
||||
} from "../map";
|
||||
import { mapGarminActivity } from "../sync-workouts";
|
||||
import { mapGarminSportType } from "../sport-map";
|
||||
import { garminCycleDay, periodFromGarminCycleSummary } from "../cycle-sync";
|
||||
import { estimateGarminKeyExpiry, resolveGarminKeyExpiry } from "../key-expiry";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
function byType(rows: Array<{ type: string; value: number }>) {
|
||||
return Object.fromEntries(rows.map((r) => [r.type, r.value]));
|
||||
}
|
||||
|
||||
describe("parseGarminInstant", () => {
|
||||
it("reads epoch ms and zone-less GMT strings", () => {
|
||||
expect(parseGarminInstant(1_792_560_600_000)?.toISOString()).toBe(
|
||||
"2026-10-21T05:30:00.000Z",
|
||||
);
|
||||
expect(parseGarminInstant("2026-10-22 05:15:02")?.toISOString()).toBe(
|
||||
"2026-10-22T05:15:02.000Z",
|
||||
);
|
||||
expect(parseGarminInstant("2026-10-22T05:40:00.0")?.toISOString()).toBe(
|
||||
"2026-10-22T05:40:00.000Z",
|
||||
);
|
||||
expect(parseGarminInstant(null)).toBeNull();
|
||||
expect(parseGarminInstant("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminDailySummary", () => {
|
||||
const day = {
|
||||
calendarDate: "2026-10-22",
|
||||
includesWellnessData: true,
|
||||
movement: {
|
||||
steps: { value: 8421, distanceInMeters: 6410.5 },
|
||||
floorsAscended: { value: 7 },
|
||||
},
|
||||
calories: { burnedActive: 512 },
|
||||
heartRate: { restingValue: 55 },
|
||||
stress: { avgLevel: 31 },
|
||||
bodyBattery: { maxValue: 88 },
|
||||
};
|
||||
|
||||
it("maps every daily field to its type and unit, keyed on the day", () => {
|
||||
const rows = mapGarminDailySummary(day, TZ);
|
||||
expect(byType(rows)).toEqual({
|
||||
ACTIVITY_STEPS: 8421,
|
||||
WALKING_RUNNING_DISTANCE: 6410.5,
|
||||
FLIGHTS_CLIMBED: 7,
|
||||
ACTIVE_ENERGY_BURNED: 512,
|
||||
RESTING_HEART_RATE: 55,
|
||||
STRESS_LEVEL: 31,
|
||||
BODY_BATTERY: 88,
|
||||
});
|
||||
const units = Object.fromEntries(rows.map((r) => [r.type, r.unit]));
|
||||
expect(units).toMatchObject({
|
||||
ACTIVITY_STEPS: "steps",
|
||||
WALKING_RUNNING_DISTANCE: "m",
|
||||
FLIGHTS_CLIMBED: "flights",
|
||||
ACTIVE_ENERGY_BURNED: "kcal",
|
||||
RESTING_HEART_RATE: "bpm",
|
||||
STRESS_LEVEL: "score",
|
||||
BODY_BATTERY: "score",
|
||||
});
|
||||
expect(rows.map((r) => r.externalId)).toContain(
|
||||
"daily:2026-10-22:body_battery",
|
||||
);
|
||||
// Local noon of the date in the user's zone (CEST, UTC+2).
|
||||
expect(rows[0]!.measuredAt.toISOString()).toBe("2026-10-22T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("writes nothing for a day the watch recorded nothing", () => {
|
||||
expect(
|
||||
mapGarminDailySummary({ ...day, includesWellnessData: false }, TZ),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
mapGarminDailySummary({ ...day, includesWellnessData: null }, TZ),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops Garmin's negative not-enough-data sentinels", () => {
|
||||
const rows = mapGarminDailySummary(
|
||||
{ ...day, stress: { avgLevel: -1 }, bodyBattery: { maxValue: -2 } },
|
||||
TZ,
|
||||
);
|
||||
expect(rows.map((r) => r.type)).not.toContain("STRESS_LEVEL");
|
||||
expect(rows.map((r) => r.type)).not.toContain("BODY_BATTERY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminSleep", () => {
|
||||
const night = {
|
||||
calendarDate: "2026-10-22",
|
||||
sleepTimeSeconds: 25_200,
|
||||
sleepEndTimestampGMT: 1_792_647_000_000,
|
||||
deepSleepSeconds: 5_400,
|
||||
lightSleepSeconds: 13_800,
|
||||
remSleepSeconds: 6_000,
|
||||
awakeSleepSeconds: 900,
|
||||
averageSpO2Value: 95,
|
||||
averageRespirationValue: 14.5,
|
||||
sleepScores: { overall: { value: 79, qualifierKey: "FAIR" } },
|
||||
};
|
||||
|
||||
it("maps stage totals onto the wake instant, light as CORE", () => {
|
||||
const rows = mapGarminSleep(night, TZ);
|
||||
const stages = rows
|
||||
.filter((r) => r.type === "SLEEP_DURATION")
|
||||
.map((r) => [r.sleepStage, r.value]);
|
||||
expect(stages).toEqual([
|
||||
["CORE", 230],
|
||||
["DEEP", 90],
|
||||
["REM", 100],
|
||||
["AWAKE", 15],
|
||||
]);
|
||||
for (const r of rows) {
|
||||
expect(r.measuredAt.getTime()).toBe(1_792_647_000_000);
|
||||
}
|
||||
expect(byType(rows.filter((r) => r.type !== "SLEEP_DURATION"))).toEqual({
|
||||
SLEEP_SCORE: 79,
|
||||
OXYGEN_SATURATION: 95,
|
||||
RESPIRATORY_RATE: 14.5,
|
||||
});
|
||||
expect(new Set(rows.map((r) => r.externalId)).size).toBe(rows.length);
|
||||
});
|
||||
|
||||
it("maps a no-sleep day (null date, all-null fields) to nothing", () => {
|
||||
expect(
|
||||
mapGarminSleep(
|
||||
{
|
||||
calendarDate: null,
|
||||
sleepTimeSeconds: null,
|
||||
deepSleepSeconds: null,
|
||||
},
|
||||
TZ,
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(mapGarminSleep({ ...night, sleepTimeSeconds: 0 }, TZ)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminHrv", () => {
|
||||
it("maps the overnight average to HRV_RMSSD and skips empty nights", () => {
|
||||
const rows = mapGarminHrv(
|
||||
{
|
||||
hrvSummaries: [
|
||||
{ calendarDate: "2026-10-21", lastNightAvg: 41 },
|
||||
{ calendarDate: "2026-10-22", lastNightAvg: null },
|
||||
{ calendarDate: null, lastNightAvg: 50 },
|
||||
],
|
||||
},
|
||||
TZ,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: "HRV_RMSSD",
|
||||
value: 41,
|
||||
unit: "ms",
|
||||
externalId: "hrv:2026-10-21:hrv_rmssd",
|
||||
});
|
||||
expect(mapGarminHrv(null, TZ)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminWeightRange", () => {
|
||||
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: [
|
||||
{
|
||||
summaryDate: "2026-10-20",
|
||||
allWeightMetrics: [
|
||||
{
|
||||
samplePk: 11,
|
||||
timestampGMT: 1_792_474_200_000,
|
||||
weight: 80_000,
|
||||
muscleMass: 36_400,
|
||||
boneMass: 3_200,
|
||||
sourceType: "INDEX_SCALE",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(byType(rows)).toEqual({
|
||||
WEIGHT: 80,
|
||||
MUSCLE_MASS: 36.4,
|
||||
BONE_MASS: 3.2,
|
||||
});
|
||||
expect(rows.every((r) => r.externalId.startsWith("weight:11:"))).toBe(true);
|
||||
expect(rows.every((r) => r.deviceType === "scale")).toBe(true);
|
||||
});
|
||||
|
||||
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 };
|
||||
expect(
|
||||
mapGarminWeightRange({
|
||||
dailyWeightSummaries: [{ latestWeight: w, allWeightMetrics: [w] }],
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
byType(
|
||||
mapGarminWeightRange({
|
||||
dailyWeightSummaries: [{ latestWeight: { ...w, samplePk: 6 } }],
|
||||
}),
|
||||
),
|
||||
).toEqual({ WEIGHT: 70 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminActivity", () => {
|
||||
it("maps an activity to a workout row", () => {
|
||||
const row = mapGarminActivity({
|
||||
activityId: 10_000_000_001,
|
||||
startTimeGMT: "2026-10-22 05:15:02",
|
||||
activityType: { typeKey: "trail_running" },
|
||||
distance: 8012.4,
|
||||
duration: 2701.3,
|
||||
elapsedDuration: 2750.5,
|
||||
elevationGain: 54,
|
||||
calories: 610.4,
|
||||
averageHR: 148,
|
||||
maxHR: 171,
|
||||
steps: 7400,
|
||||
aerobicTrainingEffect: 3.2,
|
||||
activityTrainingLoad: 112.5,
|
||||
});
|
||||
expect(row).toMatchObject({
|
||||
externalId: "10000000001",
|
||||
sportType: "running",
|
||||
durationSec: 2701,
|
||||
totalEnergyKcal: 610,
|
||||
totalDistanceM: 8012.4,
|
||||
avgHeartRate: 148,
|
||||
maxHeartRate: 171,
|
||||
stepCount: 7400,
|
||||
elevationM: 54,
|
||||
metadata: {
|
||||
garminActivity: "trail_running",
|
||||
garminAerobicEffect: 3.2,
|
||||
garminTrainingLoad: 112.5,
|
||||
},
|
||||
});
|
||||
expect(row!.startedAt.toISOString()).toBe("2026-10-22T05:15:02.000Z");
|
||||
expect(row!.endedAt.getTime() - row!.startedAt.getTime()).toBe(2_750_500);
|
||||
});
|
||||
|
||||
it("drops an activity without id, start or duration", () => {
|
||||
expect(
|
||||
mapGarminActivity({ startTimeGMT: "2026-10-22 05:15:02" }),
|
||||
).toBeNull();
|
||||
expect(mapGarminActivity({ activityId: 1, duration: 60 })).toBeNull();
|
||||
expect(
|
||||
mapGarminActivity({
|
||||
activityId: 1,
|
||||
beginTimestamp: 1_792_560_000_000,
|
||||
duration: 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapGarminSportType", () => {
|
||||
it.each([
|
||||
["running", "running"],
|
||||
["road_biking", "cycling"],
|
||||
["lap_swimming", "swimming"],
|
||||
["kayaking_v2", "rowing"],
|
||||
["strength_training", "strength"],
|
||||
["yoga", "yoga"],
|
||||
["resort_skiing", "other"],
|
||||
["something_new", "other"],
|
||||
[null, "other"],
|
||||
])("%s → %s", (key, expected) => {
|
||||
expect(mapGarminSportType(key)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle summary", () => {
|
||||
it("reads a cycle date bare or wrapped", () => {
|
||||
expect(garminCycleDay("2026-10-10")).toBe("2026-10-10");
|
||||
expect(garminCycleDay({ localDate: "2026-10-10" })).toBe("2026-10-10");
|
||||
expect(garminCycleDay({ date: "2026-10-10T00:00:00Z" })).toBe("2026-10-10");
|
||||
expect(garminCycleDay(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("derives the logged period days, never in the future", () => {
|
||||
expect(
|
||||
periodFromGarminCycleSummary(
|
||||
{ startDate: "2026-10-10", periodLength: 5, predictedCycle: false },
|
||||
"2026-11-04",
|
||||
),
|
||||
).toEqual({
|
||||
start: "2026-10-10",
|
||||
days: [
|
||||
"2026-10-10",
|
||||
"2026-10-11",
|
||||
"2026-10-12",
|
||||
"2026-10-13",
|
||||
"2026-10-14",
|
||||
],
|
||||
});
|
||||
expect(
|
||||
periodFromGarminCycleSummary(
|
||||
{ startDate: "2026-11-03", periodLength: 5 },
|
||||
"2026-11-04",
|
||||
)?.days,
|
||||
).toEqual(["2026-11-03", "2026-11-04"]);
|
||||
});
|
||||
|
||||
it("imports nothing from a predicted cycle or a pregnancy", () => {
|
||||
expect(
|
||||
periodFromGarminCycleSummary(
|
||||
{ startDate: "2026-10-10", periodLength: 5, predictedCycle: true },
|
||||
"2026-11-04",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
periodFromGarminCycleSummary(
|
||||
{ startDate: "2026-10-10", periodLength: 5, pregnancyCycle: true },
|
||||
"2026-11-04",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(periodFromGarminCycleSummary({}, "2026-11-04")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("key expiry", () => {
|
||||
const now = new Date("2026-11-04T12:00:00Z");
|
||||
|
||||
it("estimates the end from the picked validity", () => {
|
||||
expect(
|
||||
estimateGarminKeyExpiry(
|
||||
new Date("2026-11-01T00:00:00Z"),
|
||||
90,
|
||||
).toISOString(),
|
||||
).toBe("2027-01-30T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("is ok well ahead, expiring inside the last week, expired after", () => {
|
||||
expect(
|
||||
resolveGarminKeyExpiry(new Date("2026-12-01T00:00:00Z"), now).state,
|
||||
).toBe("ok");
|
||||
const soon = resolveGarminKeyExpiry(new Date("2026-11-08T00:00:00Z"), now);
|
||||
expect(soon).toMatchObject({ state: "expiring", daysLeft: 3 });
|
||||
expect(
|
||||
resolveGarminKeyExpiry(new Date("2026-11-04T18:00:00Z"), now).daysLeft,
|
||||
).toBe(0);
|
||||
expect(
|
||||
resolveGarminKeyExpiry(new Date("2026-11-01T00:00:00Z"), now),
|
||||
).toMatchObject({ state: "expired", daysLeft: null });
|
||||
expect(resolveGarminKeyExpiry(null, now)).toEqual({
|
||||
state: "unknown",
|
||||
expiresAt: null,
|
||||
daysLeft: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* The `garminBackfillProgress` column: how far the Garmin history backfill
|
||||
* has got, carried from one slice to the next.
|
||||
*
|
||||
* Each resource is walked from the day the backfill began (`anchor`) back
|
||||
* toward the start of the account's history, one window at a time; after a
|
||||
* window is written its start becomes the resource's `before`, so a slice
|
||||
* that stops (its budget, a rate limit, a failed window) loses at most the
|
||||
* window it was in. The menstrual cycle is walked cycle by cycle instead, its
|
||||
* `before` being the day before the last cycle it read.
|
||||
*
|
||||
* Where history starts is learned: the daily summaries and the activities
|
||||
* are walked first, back to `GARMIN_HISTORY_FLOOR`, and the oldest day either
|
||||
* returned (`oldestSeen`) becomes `historyStart`. The other resources stop a
|
||||
* week before it, so an account with three years of history costs three
|
||||
* years of requests, not eighteen.
|
||||
*
|
||||
* Read leniently: a malformed entry is dropped and that resource starts over
|
||||
* from the anchor, which is safe because every write is keyed.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
/** Garmin Connect went live in 2008; nothing it holds is older. */
|
||||
export const GARMIN_HISTORY_FLOOR = "2008-01-01";
|
||||
|
||||
export const GARMIN_BACKFILL_STEPS = [
|
||||
"daily",
|
||||
"activities",
|
||||
"sleep",
|
||||
"hrv",
|
||||
"weight",
|
||||
"cycle",
|
||||
] as const;
|
||||
|
||||
export type GarminBackfillStep = (typeof GARMIN_BACKFILL_STEPS)[number];
|
||||
|
||||
export interface GarminBackfillStepState {
|
||||
/** Upper bound (YYYY-MM-DD) of the next window to read. */
|
||||
before: string;
|
||||
done?: boolean;
|
||||
windows?: number;
|
||||
}
|
||||
|
||||
export interface GarminBackfillProgress {
|
||||
anchor: string;
|
||||
oldestSeen?: string;
|
||||
/** Learned once both probes are done; null when they found nothing. */
|
||||
historyStart?: string | null;
|
||||
/**
|
||||
* 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>>;
|
||||
}
|
||||
|
||||
const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
const stateSchema = z.object({
|
||||
before: day,
|
||||
done: z.boolean().optional().catch(undefined),
|
||||
windows: z.number().int().min(0).optional().catch(undefined),
|
||||
});
|
||||
|
||||
export function readGarminBackfillProgress(
|
||||
value: unknown,
|
||||
): GarminBackfillProgress | null {
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const anchor = day.safeParse(raw.anchor);
|
||||
if (!anchor.success) return null;
|
||||
const rawSteps =
|
||||
raw.steps && typeof raw.steps === "object"
|
||||
? (raw.steps as Record<string, unknown>)
|
||||
: {};
|
||||
const steps: GarminBackfillProgress["steps"] = {};
|
||||
for (const name of GARMIN_BACKFILL_STEPS) {
|
||||
const parsed = stateSchema.safeParse(rawSteps[name]);
|
||||
if (!parsed.success) continue;
|
||||
steps[name] = {
|
||||
before: parsed.data.before,
|
||||
...(parsed.data.done ? { done: true } : {}),
|
||||
...(parsed.data.windows !== undefined
|
||||
? { windows: parsed.data.windows }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const 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);
|
||||
return {
|
||||
anchor: anchor.data,
|
||||
...(oldestSeen.success ? { oldestSeen: oldestSeen.data } : {}),
|
||||
...(historyStart === null
|
||||
? { historyStart: null }
|
||||
: historyStart.success
|
||||
? { historyStart: historyStart.data }
|
||||
: {}),
|
||||
...(Object.keys(unavailable).length > 0 ? { unavailable } : {}),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
export function garminBackfillProgressJson(progress: GarminBackfillProgress) {
|
||||
return {
|
||||
anchor: progress.anchor,
|
||||
...(progress.oldestSeen ? { oldestSeen: progress.oldestSeen } : {}),
|
||||
...(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,
|
||||
{
|
||||
before: s!.before,
|
||||
...(s!.done ? { done: true } : {}),
|
||||
...(s!.windows !== undefined ? { windows: s!.windows } : {}),
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 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,
|
||||
): number {
|
||||
if (!progress) return 0;
|
||||
const done = GARMIN_BACKFILL_STEPS.filter(
|
||||
(s) => progress.steps[s]?.done,
|
||||
).length;
|
||||
return done / GARMIN_BACKFILL_STEPS.length;
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* Garmin Personal API transport: the key-for-token exchange, the bearer GET,
|
||||
* and the date-range rules of each resource.
|
||||
*
|
||||
* The Personal API is pull-only and read-only. A person creates a key in
|
||||
* Garmin Connect (Settings, Personal API) and pastes it here. Every data call
|
||||
* needs a short-lived JWT the key buys at
|
||||
* `POST /personalapitoken-service/personal-api/personal-api-key/token`
|
||||
* (`Authorization: ApiKey <key>`, the body is the bare JWT). A token lives 24
|
||||
* hours, or less once the key itself is about to expire, and there is no
|
||||
* refresh token: a new one is simply bought again with the key.
|
||||
*
|
||||
* Tokens are cached in this process only, per account and per key (a changed
|
||||
* key never reuses the old key's token). A data call refused with 401 buys one
|
||||
* fresh token and retries once; a second refusal, or a refusal at the
|
||||
* exchange itself, means the key was revoked or has expired, and surfaces as
|
||||
* `reauth_required` so the card asks for a new one.
|
||||
*
|
||||
* Egress goes through `safeFetch` to two fixed hosts (global and China), so
|
||||
* there is no user-supplied host to pin. Answers are read with a byte cap.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import { readBodyCapped } from "@/lib/http/read-capped";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { retryAfterMsOf } from "@/lib/integrations/oauth-kit/retry-after";
|
||||
import {
|
||||
classifyHttpStatus,
|
||||
IntegrationApiError,
|
||||
type IntegrationClassification,
|
||||
} from "@/lib/integrations/http-status-classifier";
|
||||
|
||||
export const GARMIN_HOSTS = {
|
||||
global: "https://personal.connectapi.garmin.com",
|
||||
china: "https://personal.connectapi.garmin.cn",
|
||||
} as const;
|
||||
|
||||
export type GarminRegion = keyof typeof GARMIN_HOSTS;
|
||||
|
||||
export const GARMIN_REGIONS = Object.keys(GARMIN_HOSTS) as GarminRegion[];
|
||||
|
||||
export function isGarminRegion(value: unknown): value is GarminRegion {
|
||||
return value === "global" || value === "china";
|
||||
}
|
||||
|
||||
export const GARMIN_TOKEN_PATH =
|
||||
"/personalapitoken-service/personal-api/personal-api-key/token";
|
||||
|
||||
export const GARMIN_PATHS = {
|
||||
dailySummaries: "/usersummary-service/v2/dailysummaries",
|
||||
sleepSummaries: "/sleep-service/sleep/dailySleepSummariesByDate",
|
||||
hrvDaily: "/hrv-service/hrv/daily",
|
||||
weightRange: "/weight-service/weight/range",
|
||||
activities: "/activitylist-service/activities/search/activities",
|
||||
cycleSummary: "/periodichealth-service/menstrualcycle/summary",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The widest request each resource accepts, in calendar days counting both
|
||||
* ends. Daily summaries, sleep and the body-battery report carry documented
|
||||
* maxima (365, 31, 31); HRV and weigh-ins document none, so they take the
|
||||
* sleep figure rather than find the limit by being refused. Activities are
|
||||
* paged and filtered by start date, so their window is only a slice size.
|
||||
*/
|
||||
export const GARMIN_RANGE_DAYS = {
|
||||
dailySummaries: 365,
|
||||
sleepSummaries: 31,
|
||||
hrvDaily: 31,
|
||||
weightRange: 31,
|
||||
activities: 90,
|
||||
} as const;
|
||||
|
||||
/** Page size of the activity search (Garmin's own default is 99). */
|
||||
export const GARMIN_ACTIVITY_PAGE = 99;
|
||||
|
||||
/** Ceiling on one answer. A year of daily summaries is about a megabyte. */
|
||||
export const GARMIN_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** A regular token lives 24 h; anything markedly shorter is capped by the key. */
|
||||
const KEY_CAPPED_BELOW_MS = 23 * 60 * 60 * 1000;
|
||||
/** Replace a token an hour before it expires, never before half its life. */
|
||||
const REFRESH_WINDOW_MS = 60 * 60 * 1000;
|
||||
/** Used only when a token carries no readable `exp`. */
|
||||
const FALLBACK_LIFETIME_MS = 23 * 60 * 60 * 1000;
|
||||
const JWT_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
|
||||
|
||||
export class GarminApiError extends IntegrationApiError {
|
||||
/** How long Garmin asked to be left alone (a 429's `Retry-After`). */
|
||||
readonly retryAfterMs: number | undefined;
|
||||
/** The key itself was refused: revoked, deleted or expired. */
|
||||
readonly keyRefused: boolean;
|
||||
|
||||
constructor(opts: {
|
||||
verb: string;
|
||||
classification: IntegrationClassification;
|
||||
httpStatus: number | undefined;
|
||||
reason: string;
|
||||
retryAfterMs?: number;
|
||||
keyRefused?: boolean;
|
||||
}) {
|
||||
const { retryAfterMs, keyRefused, ...rest } = opts;
|
||||
super({ vendor: "Garmin", ...rest });
|
||||
this.name = "GarminApiError";
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
this.keyRefused = keyRefused ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/** True for a refusal that a new key, not a retry, resolves. */
|
||||
export function isGarminKeyRefusal(err: unknown): boolean {
|
||||
return err instanceof GarminApiError && err.keyRefused;
|
||||
}
|
||||
|
||||
/** True for Garmin's rate limit. */
|
||||
export function isGarminRateLimit(err: unknown): err is GarminApiError {
|
||||
return err instanceof GarminApiError && err.httpStatus === 429;
|
||||
}
|
||||
|
||||
/** The token text: the bare JWT, or one wrapped in JSON or quotes. */
|
||||
export function extractGarminToken(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed.startsWith("{")) return trimmed.replace(/^"|"$/g, "");
|
||||
try {
|
||||
const json = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const candidate = json.access_token ?? json.accessToken ?? json.token;
|
||||
return typeof candidate === "string" ? candidate.trim() : "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 { exp: undefined, iat: undefined };
|
||||
try {
|
||||
const claims = JSON.parse(
|
||||
Buffer.from(payload, "base64url").toString("utf8"),
|
||||
) 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 { 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. */
|
||||
expiresAt: number;
|
||||
/** Epoch ms after which a new token is bought. */
|
||||
refreshAt: number;
|
||||
/**
|
||||
* The key's own expiry, when the token gave it away: a token Garmin caps
|
||||
* at the key's end lives markedly less than the regular day.
|
||||
*/
|
||||
keyExpiresAt: number | null;
|
||||
}
|
||||
|
||||
function refreshTime(now: number, expiresAt: number): number {
|
||||
const lifetime = expiresAt - now;
|
||||
if (lifetime <= 0) return now;
|
||||
return now + Math.max(lifetime - REFRESH_WINDOW_MS, lifetime / 2);
|
||||
}
|
||||
|
||||
export interface GarminTransport {
|
||||
fetch?: typeof safeFetch;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy a token with the key. Throws a `GarminApiError` whose `keyRefused` is
|
||||
* set when Garmin turned the key down (401 for a key it never issued, 404 for
|
||||
* a deleted one, 403 for good measure).
|
||||
*/
|
||||
export async function exchangeGarminKey(
|
||||
apiKey: string,
|
||||
region: GarminRegion,
|
||||
transport: GarminTransport = {},
|
||||
): Promise<GarminToken> {
|
||||
const doFetch = transport.fetch ?? safeFetch;
|
||||
const now = transport.now ?? Date.now;
|
||||
const start = performance.now();
|
||||
const res = await doFetch(`${GARMIN_HOSTS[region]}${GARMIN_TOKEN_PATH}`, {
|
||||
method: "POST",
|
||||
headers: { Accept: "*/*", Authorization: `ApiKey ${apiKey}` },
|
||||
});
|
||||
const status = res.status;
|
||||
getEvent()?.addExternalCall({
|
||||
service: "garmin",
|
||||
method: "token",
|
||||
duration_ms: Math.round(performance.now() - start),
|
||||
status,
|
||||
error: status === 200 ? undefined : `http_${status}`,
|
||||
});
|
||||
if (status === 401 || status === 403 || status === 404) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new GarminApiError({
|
||||
verb: "token",
|
||||
classification: "reauth_required",
|
||||
httpStatus: status,
|
||||
reason: "key_refused",
|
||||
keyRefused: true,
|
||||
});
|
||||
}
|
||||
if (status !== 200) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
const verdict = classifyHttpStatus(status);
|
||||
throw new GarminApiError({
|
||||
verb: "token",
|
||||
classification: verdict.classification,
|
||||
httpStatus: status,
|
||||
reason: verdict.reason,
|
||||
...(status === 429 || status === 503
|
||||
? { retryAfterMs: retryAfterMsOf(res) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
const read = await readBodyCapped(res, 64 * 1024);
|
||||
const value = read.ok ? extractGarminToken(read.text) : "";
|
||||
if (!JWT_SHAPE.test(value)) {
|
||||
// A maintenance page served with 200, say. Never cached, never echoed.
|
||||
throw new GarminApiError({
|
||||
verb: "token",
|
||||
classification: "transient",
|
||||
httpStatus: status,
|
||||
reason: "token_not_jwt",
|
||||
});
|
||||
}
|
||||
const issuedAt = now();
|
||||
const { exp, 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 &&
|
||||
lifetime !== undefined &&
|
||||
lifetime < KEY_CAPPED_BELOW_MS
|
||||
? exp
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
interface CachedToken extends GarminToken {
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
/** Process-local token cache, per account. Never persisted. */
|
||||
const tokenCache = new Map<string, CachedToken>();
|
||||
|
||||
/** Forget cached tokens (one account's, or all). Disconnect and tests call it. */
|
||||
export function clearGarminTokenCache(userId?: string): void {
|
||||
if (userId === undefined) tokenCache.clear();
|
||||
else tokenCache.delete(userId);
|
||||
}
|
||||
|
||||
function keyFingerprint(apiKey: string, region: GarminRegion): string {
|
||||
return createHash("sha256").update(`${region}\n${apiKey}`).digest("hex");
|
||||
}
|
||||
|
||||
export type GarminQuery = Record<
|
||||
string,
|
||||
string | number | boolean | undefined | null
|
||||
>;
|
||||
|
||||
/**
|
||||
* One account's session against the Personal API. Holds the key only for as
|
||||
* long as the session object lives, buys tokens through the shared cache, and
|
||||
* remembers what a token said about the key's expiry so the caller can
|
||||
* persist it.
|
||||
*/
|
||||
export class GarminSession {
|
||||
private readonly userId: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly region: GarminRegion;
|
||||
private readonly transport: GarminTransport;
|
||||
private readonly fingerprint: string;
|
||||
/** The key's expiry, once a token revealed it. */
|
||||
learnedKeyExpiresAt: number | null = null;
|
||||
/** Number of key exchanges this session made (for the record and tests). */
|
||||
exchanges = 0;
|
||||
/** Garmin accepted the key in this session: a token bought or a read answered. */
|
||||
proven = false;
|
||||
|
||||
constructor(opts: {
|
||||
userId: string;
|
||||
apiKey: string;
|
||||
region: GarminRegion;
|
||||
transport?: GarminTransport;
|
||||
}) {
|
||||
this.userId = opts.userId;
|
||||
this.apiKey = opts.apiKey;
|
||||
this.region = opts.region;
|
||||
this.transport = opts.transport ?? {};
|
||||
this.fingerprint = keyFingerprint(opts.apiKey, opts.region);
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
return (this.transport.now ?? Date.now)();
|
||||
}
|
||||
|
||||
/** A usable token: the cached one while fresh, else a new one. */
|
||||
async token(force = false): Promise<string> {
|
||||
const cached = tokenCache.get(this.userId);
|
||||
if (
|
||||
!force &&
|
||||
cached &&
|
||||
cached.fingerprint === this.fingerprint &&
|
||||
this.now() < cached.refreshAt
|
||||
) {
|
||||
return cached.value;
|
||||
}
|
||||
const fresh = await exchangeGarminKey(
|
||||
this.apiKey,
|
||||
this.region,
|
||||
this.transport,
|
||||
);
|
||||
this.exchanges += 1;
|
||||
this.proven = true;
|
||||
if (fresh.keyExpiresAt !== null) {
|
||||
this.learnedKeyExpiresAt = fresh.keyExpiresAt;
|
||||
}
|
||||
tokenCache.set(this.userId, { ...fresh, fingerprint: this.fingerprint });
|
||||
return fresh.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a resource. Returns the parsed body, or null for an empty answer
|
||||
* (204, or no body). A 401 buys one new token and retries; a second 401
|
||||
* is the key's end.
|
||||
*/
|
||||
async get<T>(
|
||||
verb: string,
|
||||
path: string,
|
||||
query: GarminQuery = {},
|
||||
): Promise<T | null> {
|
||||
const params = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null) params.set(k, String(v));
|
||||
}
|
||||
const qs = params.toString();
|
||||
const url = `${GARMIN_HOSTS[this.region]}${path}${qs ? `?${qs}` : ""}`;
|
||||
|
||||
let token = await this.token();
|
||||
let res = await this.call(verb, url, token);
|
||||
if (res.status === 401) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
token = await this.token(true);
|
||||
res = await this.call(verb, url, token);
|
||||
if (res.status === 401) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
tokenCache.delete(this.userId);
|
||||
throw new GarminApiError({
|
||||
verb,
|
||||
classification: "reauth_required",
|
||||
httpStatus: 401,
|
||||
reason: "key_refused",
|
||||
keyRefused: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (res.status >= 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(() => {});
|
||||
const verdict = classifyHttpStatus(res.status);
|
||||
throw new GarminApiError({
|
||||
verb,
|
||||
// A 403 on a data call with a token the exchange just accepted is a
|
||||
// resource the account cannot read, not a dead key.
|
||||
classification:
|
||||
res.status === 403 ? "persistent" : verdict.classification,
|
||||
httpStatus: res.status,
|
||||
reason: verdict.reason,
|
||||
...(res.status === 429 || res.status === 503
|
||||
? { retryAfterMs: retryAfterMsOf(res, this.now()) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
const read = await readBodyCapped(res, GARMIN_MAX_BODY_BYTES);
|
||||
if (!read.ok) {
|
||||
throw new GarminApiError({
|
||||
verb,
|
||||
classification: "transient",
|
||||
httpStatus: res.status,
|
||||
reason: "body_too_large",
|
||||
});
|
||||
}
|
||||
if (read.text.trim() === "") return null;
|
||||
try {
|
||||
return JSON.parse(read.text) as T;
|
||||
} catch {
|
||||
throw new GarminApiError({
|
||||
verb,
|
||||
classification: "transient",
|
||||
httpStatus: res.status,
|
||||
reason: "body_not_json",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async call(
|
||||
verb: string,
|
||||
url: string,
|
||||
token: string,
|
||||
): Promise<Response> {
|
||||
const doFetch = this.transport.fetch ?? safeFetch;
|
||||
const start = performance.now();
|
||||
const res = await doFetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", Authorization: `Bearer ${token}` },
|
||||
});
|
||||
getEvent()?.addExternalCall({
|
||||
service: "garmin",
|
||||
method: verb,
|
||||
duration_ms: Math.round(performance.now() - start),
|
||||
status: res.status,
|
||||
error: res.ok ? undefined : `http_${res.status}`,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
// ── date windows ────────────────────────────────────────────────────
|
||||
|
||||
/** `day` moved by `delta` calendar days (pure key arithmetic). */
|
||||
export function shiftGarminDay(day: string, delta: number): string {
|
||||
return shiftDateKey(day, delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `[startDate, endDate]` (both inclusive) into windows of at most
|
||||
* `maxDays` calendar days, newest first. An inverted range yields nothing.
|
||||
*/
|
||||
export function chunkGarminRange(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
maxDays: number,
|
||||
): Array<{ startDate: string; endDate: string }> {
|
||||
if (maxDays < 1) throw new Error("maxDays must be at least 1");
|
||||
const out: Array<{ startDate: string; endDate: string }> = [];
|
||||
let end = endDate;
|
||||
while (end >= startDate) {
|
||||
const candidate = shiftGarminDay(end, -(maxDays - 1));
|
||||
const start = candidate < startDate ? startDate : candidate;
|
||||
out.push({ startDate: start, endDate: end });
|
||||
end = shiftGarminDay(start, -1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The per-account Garmin connection: one Personal API key the person pasted,
|
||||
* sealed at rest under the fail-closed `*Encrypted` convention, beside the
|
||||
* host region it was accepted on and what is known about when it expires.
|
||||
* This helper is the single read+decrypt path the sync, the backfill and the
|
||||
* routes share; nothing else decrypts the key.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
|
||||
import { isGarminRegion, type GarminRegion } from "./client";
|
||||
|
||||
export interface GarminCredentials {
|
||||
apiKey: string;
|
||||
region: GarminRegion;
|
||||
keyExpiresAt: Date | null;
|
||||
connectedAt: Date | null;
|
||||
}
|
||||
|
||||
/** Fetch and decrypt the account's Garmin key; null when none is stored. */
|
||||
export async function getGarminCredentials(
|
||||
userId: string,
|
||||
): Promise<GarminCredentials | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
garminApiKeyEncrypted: true,
|
||||
garminRegion: true,
|
||||
garminKeyExpiresAt: true,
|
||||
garminConnectedAt: true,
|
||||
},
|
||||
});
|
||||
if (!user?.garminApiKeyEncrypted) return null;
|
||||
return {
|
||||
apiKey: decrypt(user.garminApiKeyEncrypted),
|
||||
region: isGarminRegion(user.garminRegion) ? user.garminRegion : "global",
|
||||
keyExpiresAt: user.garminKeyExpiresAt,
|
||||
connectedAt: user.garminConnectedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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: next },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Garmin's menstrual-cycle summary → HealthLog's cycle tracker, as an
|
||||
* ADDITIONAL, SUBORDINATE source, with the same write rule as Oura's cycle
|
||||
* import (`src/lib/oura/cycle-sync.ts`): a day is only ever CREATED when no
|
||||
* `CycleDayLog` row exists for it at all (any source), and never updated, so
|
||||
* a person's own entry always wins and an hourly poll can never demote it.
|
||||
* No `MenstrualCycle` span is created; cycle spans stay an explicit action.
|
||||
*
|
||||
* `GET /periodichealth-service/menstrualcycle/summary/{date}` describes the
|
||||
* cycle a date falls in: its start and its period length. A cycle Garmin
|
||||
* only predicts, and a pregnancy, carry nothing to import. The period days of
|
||||
* a logged cycle are `start … start + periodLength − 1`, each written as the
|
||||
* conservative `flow: LIGHT` marker (logged, intensity unknown), never in the
|
||||
* future.
|
||||
*
|
||||
* Gated behind the cycle module (`isCycleAvailableForUser`), checked before
|
||||
* Garmin is asked, so an account with cycle tracking off costs no request.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
|
||||
|
||||
import { GARMIN_PATHS, shiftGarminDay, type GarminSession } from "./client";
|
||||
import type { GarminCycleDate, GarminCycleSummary } from "./wire-types";
|
||||
|
||||
const GARMIN_PERIOD_FLOW = "LIGHT" as const;
|
||||
|
||||
/** The longest period a single summary may claim before it is not believed. */
|
||||
const MAX_PERIOD_DAYS = 14;
|
||||
|
||||
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** A cycle date as Garmin sends it: bare, or `{ localDate, date }`. */
|
||||
export function garminCycleDay(value: GarminCycleDate): string | null {
|
||||
if (typeof value === "string") {
|
||||
const day = value.slice(0, 10);
|
||||
return DAY_KEY.test(day) ? day : null;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
if (typeof value.localDate === "string" && DAY_KEY.test(value.localDate)) {
|
||||
return value.localDate;
|
||||
}
|
||||
if (typeof value.date === "string") {
|
||||
const day = value.date.slice(0, 10);
|
||||
return DAY_KEY.test(day) ? day : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface GarminCyclePeriod {
|
||||
/** First day of the cycle. */
|
||||
start: string;
|
||||
/** The period's days, oldest first, none after `today`. */
|
||||
days: string[];
|
||||
}
|
||||
|
||||
/** The logged period a summary describes, or null when there is none. */
|
||||
export function periodFromGarminCycleSummary(
|
||||
summary: GarminCycleSummary | null,
|
||||
today: string,
|
||||
): GarminCyclePeriod | null {
|
||||
if (!summary || summary.predictedCycle === true) return null;
|
||||
if (summary.pregnancyCycle === true) return null;
|
||||
const start = garminCycleDay(summary.startDate);
|
||||
if (!start || start > today) return null;
|
||||
const length = summary.periodLength;
|
||||
if (typeof length !== "number" || !Number.isFinite(length)) return null;
|
||||
const n = Math.round(length);
|
||||
if (n < 1 || n > MAX_PERIOD_DAYS) return { start, days: [] };
|
||||
const days: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const day = shiftGarminDay(start, i);
|
||||
if (day > today) break;
|
||||
days.push(day);
|
||||
}
|
||||
return { start, days };
|
||||
}
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"code" in err &&
|
||||
(err as { code?: unknown }).code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
/** Create the period days no row covers yet. Returns how many were created. */
|
||||
export async function writeGarminPeriodDays(
|
||||
userId: string,
|
||||
days: readonly string[],
|
||||
): Promise<number> {
|
||||
if (days.length === 0) return 0;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = user?.timezone ?? null;
|
||||
let created = 0;
|
||||
for (const day of days) {
|
||||
try {
|
||||
await prisma.cycleDayLog.create({
|
||||
data: {
|
||||
userId,
|
||||
date: day,
|
||||
tz,
|
||||
source: "GARMIN",
|
||||
flow: GARMIN_PERIOD_FLOW,
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
} catch (err) {
|
||||
// A row already exists: the person's own, an import's, or this
|
||||
// module's earlier write. Garmin never overwrites.
|
||||
if (isUniqueViolation(err)) continue;
|
||||
getEvent()?.addWarning(
|
||||
`garmin: cycle day-log create failed for ${userId} ${day}: ${err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Read the cycle summary for one date (null when Garmin has none). */
|
||||
export async function readGarminCycleSummary(
|
||||
session: GarminSession,
|
||||
day: string,
|
||||
): Promise<GarminCycleSummary | null> {
|
||||
return session.get<GarminCycleSummary>(
|
||||
"cycleSummary",
|
||||
`${GARMIN_PATHS.cycleSummary}/${day}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The hourly leg: the cycle `today` falls in. Returns days created; throws
|
||||
* what the fetch throws, and the caller keeps it off the connection's ledger
|
||||
* (a person who does not track a cycle in Garmin Connect is the common case).
|
||||
*/
|
||||
export async function syncGarminCycle(
|
||||
userId: string,
|
||||
session: GarminSession,
|
||||
today: string,
|
||||
): Promise<number> {
|
||||
if (!(await isCycleAvailableForUser(userId))) return 0;
|
||||
const summary = await readGarminCycleSummary(session, today);
|
||||
const period = periodFromGarminCycleSummary(summary, today);
|
||||
if (!period) return 0;
|
||||
return writeGarminPeriodDays(userId, period.days);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* When a Garmin key stops working, and how close that is.
|
||||
*
|
||||
* Garmin does not publish a key's expiry anywhere the Personal API can read:
|
||||
* the key is opaque and the token's `exp` is the token's own day, until the
|
||||
* key's last day, when Garmin caps the token at the key's end. So the expiry
|
||||
* is known from one of two places, in this order:
|
||||
*
|
||||
* 1. learned: a token lived markedly less than a day (`./client.ts`), and
|
||||
* its `exp` is the key's end;
|
||||
* 2. estimated: the validity the person picked when creating the key (30,
|
||||
* 90, 180 or 365 days), counted from the day it was connected here.
|
||||
*
|
||||
* Neither known: the state is `unknown`, and the card asks for the validity.
|
||||
* The warning starts a week ahead, which is when Garmin's own reminder mail
|
||||
* goes out.
|
||||
*/
|
||||
|
||||
/** The validities Garmin Connect offers when a key is created, in days. */
|
||||
export const GARMIN_KEY_VALIDITY_DAYS = [30, 90, 180, 365] as const;
|
||||
|
||||
export type GarminKeyValidityDays = (typeof GARMIN_KEY_VALIDITY_DAYS)[number];
|
||||
|
||||
/** How long before the end the card starts warning. */
|
||||
export const GARMIN_KEY_WARNING_DAYS = 7;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type GarminKeyExpiryState = "ok" | "expiring" | "expired" | "unknown";
|
||||
|
||||
export interface GarminKeyExpiry {
|
||||
state: GarminKeyExpiryState;
|
||||
/** ISO instant, or null when unknown. */
|
||||
expiresAt: string | null;
|
||||
/** Whole days left (0 on the last day), or null when unknown or past. */
|
||||
daysLeft: number | null;
|
||||
}
|
||||
|
||||
/** The expiry a picked validity gives, counted from `connectedAt`. */
|
||||
export function estimateGarminKeyExpiry(
|
||||
connectedAt: Date,
|
||||
validityDays: GarminKeyValidityDays,
|
||||
): Date {
|
||||
return new Date(connectedAt.getTime() + validityDays * DAY_MS);
|
||||
}
|
||||
|
||||
/** Resolve the expiry state at `now`. */
|
||||
export function resolveGarminKeyExpiry(
|
||||
expiresAt: Date | null,
|
||||
now: Date = new Date(),
|
||||
): GarminKeyExpiry {
|
||||
if (!expiresAt) return { state: "unknown", expiresAt: null, daysLeft: null };
|
||||
const left = expiresAt.getTime() - now.getTime();
|
||||
if (left <= 0) {
|
||||
return {
|
||||
state: "expired",
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
daysLeft: null,
|
||||
};
|
||||
}
|
||||
const daysLeft = Math.floor(left / DAY_MS);
|
||||
return {
|
||||
state: daysLeft < GARMIN_KEY_WARNING_DAYS ? "expiring" : "ok",
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
daysLeft,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Garmin records to measurement rows: pure functions over the wire shapes
|
||||
* (`./wire-types.ts`), no I/O. `__tests__/map.test.ts` pins what each makes
|
||||
* of made-up records shaped from Garmin's OpenAPI document.
|
||||
*
|
||||
* Every row carries its own `externalId`, keyed on the record's calendar day
|
||||
* (or the weigh-in's sample id) and a field tag, so a re-read of the same day
|
||||
* lands on the same row and Garmin's later re-scoring overwrites in place.
|
||||
*
|
||||
* What maps where:
|
||||
*
|
||||
* daily summary steps, step distance, floors, active kcal, resting HR,
|
||||
* average stress level (STRESS_LEVEL), Body Battery high
|
||||
* sleep summary per-stage minutes (light→CORE, deep, REM, awake) stamped
|
||||
* on the wake instant, the overall sleep score, the night's
|
||||
* average SpO2 and breathing rate
|
||||
* HRV `lastNightAvg` (RMSSD) → HRV_RMSSD; Garmin reports no SDNN
|
||||
* weigh-ins weight, BMI, body fat, body water, muscle, bone, visceral
|
||||
*
|
||||
* Masses are 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,
|
||||
GarminHrvEnvelope,
|
||||
GarminInstant,
|
||||
GarminSleepSummary,
|
||||
GarminWeighIn,
|
||||
GarminWeightRangeEnvelope,
|
||||
} from "./wire-types";
|
||||
|
||||
export interface GarminMappedMeasurement {
|
||||
type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
measuredAt: Date;
|
||||
externalId: string;
|
||||
sleepStage?: "CORE" | "DEEP" | "REM" | "AWAKE";
|
||||
deviceType?: "watch" | "scale";
|
||||
}
|
||||
|
||||
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function isDay(value: unknown): value is string {
|
||||
return typeof value === "string" && DAY_KEY.test(value);
|
||||
}
|
||||
|
||||
function num(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An instant as Garmin sends it. Epoch ms numbers, or a GMT wall-clock
|
||||
* string without a zone (`2026-10-22T05:40:00.0`, `2026-10-22 05:15:02`),
|
||||
* which is read as UTC because every field it is used for is a `GMT` one.
|
||||
*/
|
||||
export function parseGarminInstant(value: GarminInstant): Date | null {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
if (typeof value !== "string" || value.trim() === "") return null;
|
||||
let text = value.trim().replace(" ", "T");
|
||||
if (!/(Z|[+-]\d{2}:?\d{2})$/.test(text)) text = `${text}Z`;
|
||||
const d = new Date(text);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/** A date-only record's instant: local noon of its date in the user's zone. */
|
||||
function dayAnchor(day: string, tz: string | undefined): Date | null {
|
||||
const d = canonicalDailyTimestamp(day, tz);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function scoreValue(v: number | null): number | null {
|
||||
// Garmin marks "not enough data" with negative sentinels (-1, -2).
|
||||
if (v === null || v < 0 || v > 100) return null;
|
||||
return Math.round(v);
|
||||
}
|
||||
|
||||
/** One day of `v2/dailysummaries`. A day the watch recorded nothing maps to nothing. */
|
||||
export function mapGarminDailySummary(
|
||||
d: GarminDailySummary,
|
||||
tz?: string,
|
||||
): GarminMappedMeasurement[] {
|
||||
if (!isDay(d.calendarDate)) return [];
|
||||
// A summary exists for every calendar day, worn or not; only a day with
|
||||
// wellness data carries readings. Otherwise a year before the first watch
|
||||
// would import as a year of zero steps.
|
||||
if (d.includesWellnessData !== true) return [];
|
||||
const day = d.calendarDate;
|
||||
const measuredAt = dayAnchor(day, tz);
|
||||
if (!measuredAt) return [];
|
||||
const out: GarminMappedMeasurement[] = [];
|
||||
const push = (
|
||||
type: string,
|
||||
value: number | null,
|
||||
unit: string,
|
||||
tag: string,
|
||||
) => {
|
||||
if (value === null) return;
|
||||
out.push({
|
||||
type,
|
||||
value,
|
||||
unit,
|
||||
measuredAt,
|
||||
externalId: `daily:${day}:${tag}`,
|
||||
deviceType: "watch",
|
||||
});
|
||||
};
|
||||
|
||||
const steps = num(d.movement?.steps?.value);
|
||||
push(
|
||||
"ACTIVITY_STEPS",
|
||||
steps !== null && steps >= 0 ? Math.round(steps) : null,
|
||||
"steps",
|
||||
"steps",
|
||||
);
|
||||
const distance = num(d.movement?.steps?.distanceInMeters);
|
||||
push(
|
||||
"WALKING_RUNNING_DISTANCE",
|
||||
distance !== null && distance >= 0 ? importValue(distance) : null,
|
||||
"m",
|
||||
"distance",
|
||||
);
|
||||
const floors = num(d.movement?.floorsAscended?.value);
|
||||
push(
|
||||
"FLIGHTS_CLIMBED",
|
||||
floors !== null && floors >= 0 ? Math.round(floors) : null,
|
||||
"flights",
|
||||
"floors",
|
||||
);
|
||||
const active = num(d.calories?.burnedActive);
|
||||
push(
|
||||
"ACTIVE_ENERGY_BURNED",
|
||||
active !== null && active >= 0 ? importValue(active) : null,
|
||||
"kcal",
|
||||
"active_energy",
|
||||
);
|
||||
const rhr = num(d.heartRate?.restingValue);
|
||||
push(
|
||||
"RESTING_HEART_RATE",
|
||||
rhr !== null && rhr > 0 ? Math.round(rhr) : null,
|
||||
"bpm",
|
||||
"rhr",
|
||||
);
|
||||
push("STRESS_LEVEL", scoreValue(num(d.stress?.avgLevel)), "score", "stress");
|
||||
push(
|
||||
"BODY_BATTERY",
|
||||
scoreValue(num(d.bodyBattery?.maxValue)),
|
||||
"score",
|
||||
"body_battery",
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One night of `dailySleepSummariesByDate`. Days without sleep come back as
|
||||
* an object whose `calendarDate` is null; those, and nights with no measured
|
||||
* sleep, map to nothing.
|
||||
*
|
||||
* Garmin gives stage totals, not a timeline, so the four stage rows are
|
||||
* stamped on the wake instant (the shape Oura's stage-total fallback writes),
|
||||
* and the per-night scalars sit there too.
|
||||
*/
|
||||
export function mapGarminSleep(
|
||||
s: GarminSleepSummary,
|
||||
tz?: string,
|
||||
): GarminMappedMeasurement[] {
|
||||
if (!isDay(s.calendarDate)) return [];
|
||||
const sleepSeconds = num(s.sleepTimeSeconds);
|
||||
if (sleepSeconds === null || sleepSeconds <= 0) return [];
|
||||
const day = s.calendarDate;
|
||||
const measuredAt =
|
||||
parseGarminInstant(s.sleepEndTimestampGMT) ?? dayAnchor(day, tz);
|
||||
if (!measuredAt) return [];
|
||||
const out: GarminMappedMeasurement[] = [];
|
||||
|
||||
const stages: Array<
|
||||
[number | null, NonNullable<GarminMappedMeasurement["sleepStage"]>, string]
|
||||
> = [
|
||||
[num(s.lightSleepSeconds), "CORE", "sleep_core"],
|
||||
[num(s.deepSleepSeconds), "DEEP", "sleep_deep"],
|
||||
[num(s.remSleepSeconds), "REM", "sleep_rem"],
|
||||
[num(s.awakeSleepSeconds), "AWAKE", "sleep_awake"],
|
||||
];
|
||||
for (const [sec, stage, tag] of stages) {
|
||||
if (sec === null || sec < 0) continue;
|
||||
out.push({
|
||||
type: "SLEEP_DURATION",
|
||||
value: importValue(sec / 60),
|
||||
unit: "minutes",
|
||||
measuredAt,
|
||||
externalId: `sleep:${day}:${tag}`,
|
||||
sleepStage: stage,
|
||||
deviceType: "watch",
|
||||
});
|
||||
}
|
||||
|
||||
const score = scoreValue(num(s.sleepScores?.overall?.value ?? null));
|
||||
if (score !== null) {
|
||||
out.push({
|
||||
type: "SLEEP_SCORE",
|
||||
value: score,
|
||||
unit: "score",
|
||||
measuredAt,
|
||||
externalId: `sleep:${day}:sleep_score`,
|
||||
deviceType: "watch",
|
||||
});
|
||||
}
|
||||
const spo2 = num(s.averageSpO2Value);
|
||||
if (spo2 !== null && spo2 > 0) {
|
||||
out.push({
|
||||
type: "OXYGEN_SATURATION",
|
||||
value: importValue(spo2),
|
||||
unit: "%",
|
||||
measuredAt,
|
||||
externalId: `sleep:${day}:spo2`,
|
||||
deviceType: "watch",
|
||||
});
|
||||
}
|
||||
const breath = num(s.averageRespirationValue);
|
||||
if (breath !== null && breath > 0) {
|
||||
out.push({
|
||||
type: "RESPIRATORY_RATE",
|
||||
value: importValue(breath),
|
||||
unit: "breaths/min",
|
||||
measuredAt,
|
||||
externalId: `sleep:${day}:resp_rate`,
|
||||
deviceType: "watch",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The HRV daily envelope: each night's average (RMSSD) → `HRV_RMSSD`. */
|
||||
export function mapGarminHrv(
|
||||
env: GarminHrvEnvelope | null,
|
||||
tz?: string,
|
||||
): GarminMappedMeasurement[] {
|
||||
const out: GarminMappedMeasurement[] = [];
|
||||
for (const h of env?.hrvSummaries ?? []) {
|
||||
if (!isDay(h?.calendarDate)) continue;
|
||||
const v = num(h.lastNightAvg);
|
||||
if (v === null || v <= 0) continue;
|
||||
const measuredAt = dayAnchor(h.calendarDate, tz);
|
||||
if (!measuredAt) continue;
|
||||
out.push({
|
||||
type: "HRV_RMSSD",
|
||||
value: importValue(v),
|
||||
unit: "ms",
|
||||
measuredAt,
|
||||
externalId: `hrv:${h.calendarDate}:hrv_rmssd`,
|
||||
deviceType: "watch",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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 { 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 rows: GarminMappedMeasurement[] = [];
|
||||
const push = (type: string, value: number | null, unit: string) => {
|
||||
if (value === null) return;
|
||||
rows.push({
|
||||
type,
|
||||
value,
|
||||
unit,
|
||||
measuredAt,
|
||||
externalId: `weight:${id}:${type.toLowerCase()}`,
|
||||
...(deviceType ? { deviceType } : {}),
|
||||
});
|
||||
};
|
||||
push("WEIGHT", importValue(kg), "kg");
|
||||
const bmi = num(w.bmi);
|
||||
push(
|
||||
"BODY_MASS_INDEX",
|
||||
bmi !== null && bmi > 0 ? importValue(bmi) : null,
|
||||
"kg/m²",
|
||||
);
|
||||
const fat = num(w.bodyFat);
|
||||
push("BODY_FAT", fat !== null && fat > 0 ? importValue(fat) : null, "%");
|
||||
const water = num(w.bodyWater);
|
||||
push(
|
||||
"TOTAL_BODY_WATER",
|
||||
water !== null && water > 0 ? 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",
|
||||
);
|
||||
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. 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
|
||||
: day?.latestWeight
|
||||
? [day.latestWeight]
|
||||
: [];
|
||||
for (const w of entries) {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Garmin activity `activityType.typeKey` → HealthLog canonical
|
||||
* `WorkoutSportType`.
|
||||
*
|
||||
* Garmin's keys are lowercase snake_case (`running`, `trail_running`,
|
||||
* `road_biking`, `lap_swimming`, `strength_training`, ...), and some carry a
|
||||
* version suffix (`kayaking_v2`). The bucket choices follow the Oura, Polar
|
||||
* and Strava tables (paddle sports → rowing, racket sports → tennis,
|
||||
* functional training → crossTraining, mobility and breath work →
|
||||
* mindAndBody, snow sports → other), so one real session lands in one bucket
|
||||
* whichever source reported it. An unknown key lands on `"other"`; the row
|
||||
* still persists and the raw key travels in `Workout.metadata.garminActivity`.
|
||||
*/
|
||||
import type { WorkoutSportType } from "@/lib/validations/workout";
|
||||
|
||||
const GARMIN_SPORT_TABLE: ReadonlyArray<readonly [string, WorkoutSportType]> = [
|
||||
// running
|
||||
["running", "running"],
|
||||
["trail_running", "running"],
|
||||
["treadmill_running", "running"],
|
||||
["track_running", "running"],
|
||||
["indoor_running", "running"],
|
||||
["street_running", "running"],
|
||||
["virtual_run", "running"],
|
||||
["ultra_run", "running"],
|
||||
// cycling
|
||||
["cycling", "cycling"],
|
||||
["road_biking", "cycling"],
|
||||
["mountain_biking", "cycling"],
|
||||
["gravel_cycling", "cycling"],
|
||||
["indoor_cycling", "cycling"],
|
||||
["virtual_ride", "cycling"],
|
||||
["cyclocross", "cycling"],
|
||||
["e_bike_fitness", "cycling"],
|
||||
["e_bike_mountain", "cycling"],
|
||||
["bmx", "cycling"],
|
||||
["track_cycling", "cycling"],
|
||||
// walking / hiking
|
||||
["walking", "walking"],
|
||||
["casual_walking", "walking"],
|
||||
["speed_walking", "walking"],
|
||||
["indoor_walking", "walking"],
|
||||
["hiking", "hiking"],
|
||||
["mountaineering", "hiking"],
|
||||
// water
|
||||
["swimming", "swimming"],
|
||||
["lap_swimming", "swimming"],
|
||||
["open_water_swimming", "swimming"],
|
||||
["rowing", "rowing"],
|
||||
["indoor_rowing", "rowing"],
|
||||
["kayaking", "rowing"],
|
||||
["paddling", "rowing"],
|
||||
["stand_up_paddleboarding", "rowing"],
|
||||
// machines
|
||||
["elliptical", "elliptical"],
|
||||
["stair_climbing", "stairClimber"],
|
||||
["floor_climbing", "stairClimber"],
|
||||
// strength / intervals / functional
|
||||
["strength_training", "strength"],
|
||||
["hiit", "hiit"],
|
||||
["indoor_cardio", "mixedCardio"],
|
||||
["cardio", "mixedCardio"],
|
||||
["fitness_equipment", "crossTraining"],
|
||||
["bouldering", "crossTraining"],
|
||||
["rock_climbing", "crossTraining"],
|
||||
["indoor_climbing", "crossTraining"],
|
||||
// mind and body
|
||||
["yoga", "yoga"],
|
||||
["pilates", "mindAndBody"],
|
||||
["breathwork", "mindAndBody"],
|
||||
["meditation", "mindAndBody"],
|
||||
["mobility", "mindAndBody"],
|
||||
// dance
|
||||
["dance", "dance"],
|
||||
// ball and racket sports
|
||||
["golf", "golf"],
|
||||
["tennis", "tennis"],
|
||||
["table_tennis", "tennis"],
|
||||
["squash", "tennis"],
|
||||
["padel", "tennis"],
|
||||
["pickleball", "tennis"],
|
||||
["racquetball", "tennis"],
|
||||
["badminton", "badminton"],
|
||||
["basketball", "basketball"],
|
||||
["soccer", "soccer"],
|
||||
// persisted outside a bucket
|
||||
["resort_skiing", "other"],
|
||||
["backcountry_skiing", "other"],
|
||||
["cross_country_classic_skiing", "other"],
|
||||
["cross_country_skate_skiing", "other"],
|
||||
["resort_snowboarding", "other"],
|
||||
["inline_skating", "other"],
|
||||
["surfing", "other"],
|
||||
["boxing", "other"],
|
||||
["multi_sport", "other"],
|
||||
["triathlon", "other"],
|
||||
["other", "other"],
|
||||
];
|
||||
|
||||
/** Fold case, drop a `_v2`-style suffix and separators. */
|
||||
function normaliseGarminTypeKey(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/_v\d+$/, "")
|
||||
.replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
const GARMIN_SPORT_MAP: ReadonlyMap<string, WorkoutSportType> = new Map(
|
||||
GARMIN_SPORT_TABLE.map(([key, canonical]) => [
|
||||
normaliseGarminTypeKey(key),
|
||||
canonical,
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve a Garmin `typeKey` to a canonical `WorkoutSportType`; `"other"` for
|
||||
* an absent, blank or unknown key. Never returns the raw key.
|
||||
*/
|
||||
export function mapGarminSportType(
|
||||
typeKey: string | null | undefined,
|
||||
): WorkoutSportType {
|
||||
if (typeof typeKey !== "string" || typeKey.trim() === "") return "other";
|
||||
return GARMIN_SPORT_MAP.get(normaliseGarminTypeKey(typeKey)) ?? "other";
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Garmin activities → `Workout` rows (`source = GARMIN`).
|
||||
*
|
||||
* Each activity becomes one row keyed on `(userId, source, externalId)` with
|
||||
* Garmin's `activityId` as `externalId`, so a re-read (the hourly window, a
|
||||
* backfill slice, an activity edited in Garmin Connect) overwrites in place.
|
||||
* The write is the `createManyAndReturn` + `skipDuplicates`, update on
|
||||
* conflict shape the Oura, Polar, WHOOP and Strava ingests use; the arrival
|
||||
* event fires only for a fresh insert.
|
||||
*
|
||||
* Cross-source dedup is not done here: a Garmin run and the same run relayed
|
||||
* through Apple Health stay two rows, and the read-time
|
||||
* `pickCanonicalWorkoutRows` collapses the twin by the source ladder.
|
||||
*
|
||||
* The activity's name and description are the person's own free text and are
|
||||
* not imported; neither are its start coordinates.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import type { WorkoutSportType } from "@/lib/validations/workout";
|
||||
|
||||
import { parseGarminInstant } from "./map";
|
||||
import { mapGarminSportType } from "./sport-map";
|
||||
import type { GarminActivity } from "./wire-types";
|
||||
|
||||
export interface GarminWorkoutRow {
|
||||
externalId: string;
|
||||
sportType: WorkoutSportType;
|
||||
startedAt: Date;
|
||||
endedAt: Date;
|
||||
durationSec: number;
|
||||
totalEnergyKcal: number | null;
|
||||
totalDistanceM: number | null;
|
||||
avgHeartRate: number | null;
|
||||
maxHeartRate: number | null;
|
||||
stepCount: number | null;
|
||||
elevationM: number | null;
|
||||
metadata: Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
function nonNegative(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
|
||||
}
|
||||
|
||||
function wholeOrNull(v: unknown): number | null {
|
||||
const n = nonNegative(v);
|
||||
return n === null ? null : Math.round(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one activity, or null when it has no id, no start, or no positive
|
||||
* duration.
|
||||
*/
|
||||
export function mapGarminActivity(a: GarminActivity): GarminWorkoutRow | null {
|
||||
const id =
|
||||
typeof a.activityId === "number" || typeof a.activityId === "string"
|
||||
? String(a.activityId)
|
||||
: "";
|
||||
if (id === "") return null;
|
||||
const startedAt =
|
||||
parseGarminInstant(a.beginTimestamp) ?? parseGarminInstant(a.startTimeGMT);
|
||||
if (!startedAt) return null;
|
||||
// The timer duration is the workout; the elapsed one includes pauses.
|
||||
const duration = nonNegative(a.duration) ?? nonNegative(a.elapsedDuration);
|
||||
if (duration === null || duration <= 0) return null;
|
||||
const elapsed = nonNegative(a.elapsedDuration);
|
||||
const span = elapsed !== null && elapsed >= duration ? elapsed : duration;
|
||||
const endedAt = new Date(startedAt.getTime() + Math.round(span * 1000));
|
||||
const typeKey =
|
||||
typeof a.activityType?.typeKey === "string" ? a.activityType.typeKey : null;
|
||||
const metadata: Prisma.InputJsonValue = {
|
||||
...(typeKey ? { garminActivity: typeKey } : {}),
|
||||
...(nonNegative(a.aerobicTrainingEffect) !== null
|
||||
? { garminAerobicEffect: a.aerobicTrainingEffect as number }
|
||||
: {}),
|
||||
...(nonNegative(a.anaerobicTrainingEffect) !== null
|
||||
? { garminAnaerobicEffect: a.anaerobicTrainingEffect as number }
|
||||
: {}),
|
||||
...(nonNegative(a.activityTrainingLoad) !== null
|
||||
? { garminTrainingLoad: a.activityTrainingLoad as number }
|
||||
: {}),
|
||||
};
|
||||
const kcal = nonNegative(a.calories);
|
||||
const avgHr = wholeOrNull(a.averageHR);
|
||||
const maxHr = wholeOrNull(a.maxHR);
|
||||
return {
|
||||
externalId: id,
|
||||
sportType: mapGarminSportType(typeKey),
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationSec: Math.round(duration),
|
||||
totalEnergyKcal: kcal === null ? null : Math.round(kcal),
|
||||
totalDistanceM: nonNegative(a.distance),
|
||||
avgHeartRate: avgHr && avgHr > 0 ? avgHr : null,
|
||||
maxHeartRate: maxHr && maxHr > 0 ? maxHr : null,
|
||||
stepCount: wholeOrNull(a.steps),
|
||||
elevationM: nonNegative(a.elevationGain),
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert mapped Garmin workouts. Every row is attempted; the first failure is
|
||||
* rethrown afterwards so a partly written batch is never reported clean.
|
||||
* `emitArrivals: false` (the history backfill) writes without arrival events.
|
||||
*/
|
||||
export async function upsertGarminWorkouts(
|
||||
userId: string,
|
||||
rows: readonly GarminWorkoutRow[],
|
||||
opts: { emitArrivals?: boolean } = {},
|
||||
): Promise<number> {
|
||||
if (rows.length === 0) return 0;
|
||||
const emitArrivals = opts.emitArrivals ?? true;
|
||||
let imported = 0;
|
||||
let firstError: unknown;
|
||||
let hadError = false;
|
||||
for (const r of rows) {
|
||||
const data = {
|
||||
sportType: r.sportType,
|
||||
startedAt: r.startedAt,
|
||||
endedAt: r.endedAt,
|
||||
durationSec: r.durationSec,
|
||||
totalEnergyKcal: r.totalEnergyKcal,
|
||||
totalDistanceM: r.totalDistanceM,
|
||||
avgHeartRate: r.avgHeartRate,
|
||||
maxHeartRate: r.maxHeartRate,
|
||||
stepCount: r.stepCount,
|
||||
elevationM: r.elevationM,
|
||||
metadata: r.metadata,
|
||||
};
|
||||
try {
|
||||
const [inserted] = await prisma.workout.createManyAndReturn({
|
||||
data: { userId, source: "GARMIN", externalId: r.externalId, ...data },
|
||||
skipDuplicates: true,
|
||||
select: { id: true, startedAt: true },
|
||||
});
|
||||
if (inserted) {
|
||||
if (emitArrivals) {
|
||||
void emitInsertedWorkoutArrival(userId, inserted, "garmin").catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await prisma.workout.update({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId,
|
||||
source: "GARMIN",
|
||||
externalId: r.externalId,
|
||||
},
|
||||
},
|
||||
data,
|
||||
});
|
||||
}
|
||||
imported += 1;
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`garmin: failed to upsert workout: ${err}`);
|
||||
if (!hadError) {
|
||||
firstError = err;
|
||||
hadError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hadError) throw firstError;
|
||||
annotate({
|
||||
action: { name: "garmin.workout.ingest", details: { imported } },
|
||||
});
|
||||
return imported;
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* Garmin Personal API sync: the hourly incremental read, and the resource
|
||||
* readers the history backfill (`src/lib/jobs/garmin-backfill.ts`) shares.
|
||||
*
|
||||
* Resources, each read over a window of the user's calendar days and chunked
|
||||
* to the resource's documented maximum (`GARMIN_RANGE_DAYS`):
|
||||
*
|
||||
* daily `v2/dailysummaries` steps, distance, floors, active kcal,
|
||||
* resting HR, stress level, Body Battery
|
||||
* sleep sleep summaries stage minutes, sleep score, SpO2,
|
||||
* breathing rate
|
||||
* hrv HRV daily summaries overnight RMSSD
|
||||
* weight weight range (all) weight and body composition
|
||||
* activities activity search (paged) workouts
|
||||
*
|
||||
* plus the menstrual-cycle summary of today, best-effort and off the ledger
|
||||
* (`./cycle-sync.ts`). Rows are tagged `source = GARMIN`; the measurement
|
||||
* write goes through the shared reconciler, so a re-read of the same day
|
||||
* overwrites in place (Garmin finalises a night's figures after waking).
|
||||
*
|
||||
* The resources are read one after another, not in parallel: the Personal
|
||||
* API is a per-person allowance, and a rate limit stops the run on the spot
|
||||
* (the remaining resources wait for the next tick) rather than spending more
|
||||
* requests into it. A refused key stops the run too and parks the connection
|
||||
* at `reauth_required`; nothing else can fix it but a new key.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
getIntegrationStatus,
|
||||
isReauthRequired,
|
||||
markReconnected,
|
||||
markSyncFailureRecorded,
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
toFailureKind,
|
||||
type FailureKind,
|
||||
} from "@/lib/integrations/status";
|
||||
import { classifyIntegrationError } from "@/lib/integrations/http-status-classifier";
|
||||
import type { InsertedMeasurementArrivalRow } from "@/lib/arrivals/measurement-emit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { afterProviderWrite } from "@/lib/integrations/sync-kit/after-write";
|
||||
import { 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";
|
||||
import {
|
||||
MeasurementReconciliationError,
|
||||
reconcileExternalMeasurement,
|
||||
} from "@/lib/measurements/reconcile-external-measurement";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
import {
|
||||
chunkGarminRange,
|
||||
exchangeGarminKey,
|
||||
shiftGarminDay,
|
||||
GARMIN_ACTIVITY_PAGE,
|
||||
GARMIN_PATHS,
|
||||
GARMIN_RANGE_DAYS,
|
||||
GarminApiError,
|
||||
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,
|
||||
mapGarminSleep,
|
||||
mapGarminWeightRange,
|
||||
type GarminMappedMeasurement,
|
||||
} from "./map";
|
||||
import {
|
||||
mapGarminActivity,
|
||||
upsertGarminWorkouts,
|
||||
type GarminWorkoutRow,
|
||||
} from "./sync-workouts";
|
||||
import type {
|
||||
GarminActivity,
|
||||
GarminDailySummaryEnvelope,
|
||||
GarminHrvEnvelope,
|
||||
GarminSleepSummary,
|
||||
GarminWeightRangeEnvelope,
|
||||
} from "./wire-types";
|
||||
|
||||
export const GARMIN_RESOURCES = [
|
||||
"daily",
|
||||
"sleep",
|
||||
"hrv",
|
||||
"weight",
|
||||
"activities",
|
||||
] as const;
|
||||
|
||||
export type GarminResource = (typeof GARMIN_RESOURCES)[number];
|
||||
|
||||
/** The widest single request per resource, in calendar days. */
|
||||
export const GARMIN_RESOURCE_WINDOW_DAYS: Record<GarminResource, number> = {
|
||||
daily: GARMIN_RANGE_DAYS.dailySummaries,
|
||||
sleep: GARMIN_RANGE_DAYS.sleepSummaries,
|
||||
hrv: GARMIN_RANGE_DAYS.hrvDaily,
|
||||
weight: GARMIN_RANGE_DAYS.weightRange,
|
||||
activities: GARMIN_RANGE_DAYS.activities,
|
||||
};
|
||||
|
||||
/** Floor of the incremental window: covers Garmin's re-scoring of recent nights. */
|
||||
export const GARMIN_SYNC_LOOKBACK_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Ceiling of the incremental window. A longer gap is history and belongs to
|
||||
* the backfill, which a connect starts and boot discovery resumes.
|
||||
*/
|
||||
export const GARMIN_SYNC_MAX_LOOKBACK_DAYS = 30;
|
||||
|
||||
/** Upper bound on pages of one activity window (99 each). */
|
||||
const MAX_ACTIVITY_PAGES = 50;
|
||||
|
||||
/** Readings per write transaction. */
|
||||
const WRITE_CHUNK = 400;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function classifyGarminFailure(err: unknown): FailureKind {
|
||||
return toFailureKind(classifyIntegrationError(err, "Garmin"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a whole-connection Garmin failure on the `garmin` ledger with its
|
||||
* classification and HTTP code. The message is Garmin's status and verb,
|
||||
* never a body and never the key: `GarminApiError` is built from neither.
|
||||
*/
|
||||
export async function recordGarminSyncFailure(
|
||||
userId: string,
|
||||
err: unknown,
|
||||
): Promise<void> {
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "garmin",
|
||||
kind: classifyGarminFailure(err),
|
||||
message:
|
||||
err instanceof GarminApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.name
|
||||
: "unknown_error",
|
||||
errorCode:
|
||||
err instanceof GarminApiError && err.httpStatus != null
|
||||
? String(err.httpStatus)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** What one resource window yields. */
|
||||
export interface GarminWindowRead {
|
||||
measurements: GarminMappedMeasurement[];
|
||||
workouts: GarminWorkoutRow[];
|
||||
/** Oldest day the window returned real data for. */
|
||||
oldestDay?: string;
|
||||
/**
|
||||
* 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(
|
||||
a: string | undefined,
|
||||
b: string | undefined,
|
||||
): string | undefined {
|
||||
if (b === undefined) return a;
|
||||
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 },
|
||||
): Promise<GarminActivity[]> {
|
||||
// Offset paging: the cursor is the next `start`; a short page is the last.
|
||||
const walk = await walkPages<number, GarminActivity>(
|
||||
"garmin.activities",
|
||||
MAX_ACTIVITY_PAGES,
|
||||
async (cursor) => {
|
||||
const start = cursor ?? 0;
|
||||
const body = await session.get<
|
||||
GarminActivity[] | { activityList?: GarminActivity[] }
|
||||
>("activities", GARMIN_PATHS.activities, {
|
||||
startDate: window.startDate,
|
||||
endDate: window.endDate,
|
||||
start,
|
||||
limit: GARMIN_ACTIVITY_PAGE,
|
||||
excludeChildren: true,
|
||||
sortOrder: "desc",
|
||||
});
|
||||
const items = Array.isArray(body)
|
||||
? body
|
||||
: Array.isArray(body?.activityList)
|
||||
? body.activityList
|
||||
: [];
|
||||
return {
|
||||
items,
|
||||
next:
|
||||
items.length < GARMIN_ACTIVITY_PAGE
|
||||
? null
|
||||
: start + GARMIN_ACTIVITY_PAGE,
|
||||
};
|
||||
},
|
||||
);
|
||||
return walk.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and map one window of one resource. The window may be wider than the
|
||||
* resource's maximum; it is chunked here.
|
||||
*/
|
||||
export async function readGarminWindow(
|
||||
session: GarminSession,
|
||||
resource: GarminResource,
|
||||
window: { startDate: string; endDate: string },
|
||||
tz: string,
|
||||
): Promise<GarminWindowRead> {
|
||||
const out: GarminWindowRead = {
|
||||
measurements: [],
|
||||
workouts: [],
|
||||
sleepEmptyDays: [],
|
||||
};
|
||||
const chunks = chunkGarminRange(
|
||||
window.startDate,
|
||||
window.endDate,
|
||||
GARMIN_RESOURCE_WINDOW_DAYS[resource],
|
||||
);
|
||||
for (const chunk of chunks) {
|
||||
switch (resource) {
|
||||
case "daily": {
|
||||
const env = await session.get<GarminDailySummaryEnvelope>(
|
||||
"dailySummaries",
|
||||
GARMIN_PATHS.dailySummaries,
|
||||
chunk,
|
||||
);
|
||||
for (const d of env?.data ?? []) {
|
||||
const rows = mapGarminDailySummary(d, tz);
|
||||
if (rows.length > 0) {
|
||||
out.measurements.push(...rows);
|
||||
out.oldestDay = minDay(out.oldestDay, d.calendarDate ?? undefined);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sleep": {
|
||||
const nights = await session.get<
|
||||
GarminSleepSummary[] | GarminSleepSummary
|
||||
>("sleepSummaries", GARMIN_PATHS.sleepSummaries, chunk);
|
||||
const list = Array.isArray(nights) ? nights : nights ? [nights] : [];
|
||||
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": {
|
||||
const env = await session.get<GarminHrvEnvelope>(
|
||||
"hrvDaily",
|
||||
`${GARMIN_PATHS.hrvDaily}/${chunk.startDate}/${chunk.endDate}`,
|
||||
);
|
||||
out.measurements.push(...mapGarminHrv(env, tz));
|
||||
break;
|
||||
}
|
||||
case "weight": {
|
||||
const env = await session.get<GarminWeightRangeEnvelope>(
|
||||
"weightRange",
|
||||
`${GARMIN_PATHS.weightRange}/${chunk.startDate}/${chunk.endDate}`,
|
||||
{ includeAll: true },
|
||||
);
|
||||
out.measurements.push(...mapGarminWeightRange(env));
|
||||
break;
|
||||
}
|
||||
case "activities": {
|
||||
for (const a of await readActivities(session, chunk)) {
|
||||
const row = mapGarminActivity(a);
|
||||
if (!row) continue;
|
||||
out.workouts.push(row);
|
||||
out.oldestDay = minDay(out.oldestDay, userDayKey(row.startedAt, tz));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write mapped Garmin readings through the shared reconciler, in chunks so a
|
||||
* year of daily summaries never rides one long transaction. Returns how many
|
||||
* readings were written (the plausibility gate's refusals are not counted).
|
||||
*/
|
||||
export async function upsertGarminMeasurements(
|
||||
userId: string,
|
||||
readings: readonly GarminMappedMeasurement[],
|
||||
opts: {
|
||||
emitArrivals?: boolean;
|
||||
onInserted?: (rows: InsertedMeasurementArrivalRow[]) => void;
|
||||
} = {},
|
||||
): Promise<number> {
|
||||
if (readings.length === 0) return 0;
|
||||
let imported = 0;
|
||||
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
|
||||
const insertedRows: InsertedMeasurementArrivalRow[] = [];
|
||||
|
||||
for (let i = 0; i < readings.length; i += WRITE_CHUNK) {
|
||||
const chunk = readings.slice(i, i + WRITE_CHUNK);
|
||||
const verdicts = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const outcomes = [];
|
||||
for (const r of chunk) {
|
||||
const verdict = await reconcileExternalMeasurement(
|
||||
tx,
|
||||
{
|
||||
userId,
|
||||
type: r.type as MeasurementType,
|
||||
source: "GARMIN",
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
measuredAt: r.measuredAt,
|
||||
externalId: r.externalId,
|
||||
sleepStage: r.sleepStage ?? null,
|
||||
deviceType: r.deviceType ?? null,
|
||||
},
|
||||
{ exactExternalMatch: "update" },
|
||||
);
|
||||
if (verdict.status === "failed") {
|
||||
throw new MeasurementReconciliationError(verdict);
|
||||
}
|
||||
outcomes.push(verdict);
|
||||
}
|
||||
return outcomes;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
chunk.forEach((reading, index) => {
|
||||
const verdict = verdicts[index]!;
|
||||
if (verdict.status === "rejected_range") return;
|
||||
for (const dirty of verdict.dirtyIdentities ?? []) touched.push(dirty);
|
||||
imported += 1;
|
||||
touched.push({
|
||||
type: reading.type as MeasurementType,
|
||||
measuredAt: reading.measuredAt,
|
||||
});
|
||||
if (verdict.status === "inserted") insertedRows.push(verdict.row);
|
||||
});
|
||||
}
|
||||
|
||||
opts.onInserted?.(insertedRows);
|
||||
await afterProviderWrite({
|
||||
userId,
|
||||
provider: "garmin",
|
||||
touched,
|
||||
...(opts.emitArrivals !== false
|
||||
? { arrivals: { rows: insertedRows, source: "garmin" } }
|
||||
: {}),
|
||||
});
|
||||
return imported;
|
||||
}
|
||||
|
||||
/** 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. 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, 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 {
|
||||
lookbackDays?: number;
|
||||
/** Skip a connection parked on a refused key (the hourly cohort does). */
|
||||
skipWhenParked?: boolean;
|
||||
/** Test seam: the transport the session uses. */
|
||||
transport?: GarminTransport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync one account's recent Garmin data. `imported` counts rows written
|
||||
* (measurements and workouts); `failed` is true when a resource did not
|
||||
* settle. An account without a key is a clean no-op.
|
||||
*/
|
||||
export async function syncUserGarmin(
|
||||
userId: string,
|
||||
opts: GarminSyncOptions = {},
|
||||
): Promise<SyncWriteResult> {
|
||||
const creds = await getGarminCredentials(userId);
|
||||
if (!creds) return { imported: 0, failed: false };
|
||||
if (opts.skipWhenParked && (await isReauthRequired(userId, "garmin"))) {
|
||||
const probed = await reprobeParkedGarminKey(userId, creds, opts.transport);
|
||||
if (!probed) return { imported: 0, failed: false };
|
||||
}
|
||||
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const today = userDayKey(new Date(), tz);
|
||||
const lookbackDays =
|
||||
opts.lookbackDays ?? (await resolveGarminLookbackDays(userId));
|
||||
const window = {
|
||||
startDate: shiftDateKey(today, -lookbackDays),
|
||||
endDate: today,
|
||||
};
|
||||
const session = new GarminSession({
|
||||
userId,
|
||||
apiKey: creds.apiKey,
|
||||
region: creds.region,
|
||||
transport: opts.transport,
|
||||
});
|
||||
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;
|
||||
insertedSleep = rows
|
||||
.filter((row) => row.type === "SLEEP_DURATION")
|
||||
.map((row) => row.measuredAt);
|
||||
},
|
||||
});
|
||||
imported += await upsertGarminWorkouts(userId, read.workouts);
|
||||
} catch (err) {
|
||||
if (isGarminKeyRefusal(err)) {
|
||||
// The key is gone: nothing else in this run can succeed.
|
||||
await recordGarminSyncFailure(userId, err);
|
||||
throw markSyncFailureRecorded(err);
|
||||
}
|
||||
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)}`,
|
||||
);
|
||||
// A rate limit is for the whole allowance: stop asking until next tick.
|
||||
if (isGarminRateLimit(err)) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await rememberGarminKeyExpiry(
|
||||
userId,
|
||||
session.learnedKeyExpiresAt,
|
||||
creds.keyExpiresAt,
|
||||
{ exchanged: session.proven },
|
||||
);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(`garmin: key expiry note failed: ${err}`);
|
||||
}
|
||||
|
||||
void maybeEnqueueMorningRefresh(userId, insertedSleep).catch(() => {});
|
||||
if (imported > 0) invalidateUserMeasurements(userId);
|
||||
annotate({
|
||||
meta: {
|
||||
"garmin.sync": {
|
||||
lookbackDays,
|
||||
imported,
|
||||
exchanges: session.exchanges,
|
||||
failures: failures.map((f) => f.resource),
|
||||
skipped: [...unavailable],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (failures.length > 0) {
|
||||
const first = failures[0]!.err;
|
||||
await recordSyncFailure({
|
||||
userId,
|
||||
integration: "garmin",
|
||||
kind: classifyGarminFailure(first),
|
||||
message: `partial sync failure (${failures.map((f) => f.resource).join(", ")}): ${
|
||||
first instanceof GarminApiError ? first.message : "error"
|
||||
}`,
|
||||
errorCode:
|
||||
first instanceof GarminApiError && first.httpStatus != null
|
||||
? String(first.httpStatus)
|
||||
: undefined,
|
||||
});
|
||||
return { imported, failed: true };
|
||||
}
|
||||
|
||||
await recordSyncSuccess(userId, "garmin");
|
||||
return { imported, failed: false };
|
||||
}
|
||||
|
||||
/** 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[],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Garmin Personal API wire shapes, the parts the sync reads.
|
||||
*
|
||||
* Taken from Garmin's published OpenAPI document (Personal API 1.0.0). Every
|
||||
* field is optional and nullable on purpose: a day without data comes back as
|
||||
* an object whose fields are all null, and the live service has been seen to
|
||||
* send epoch milliseconds where the document says a date-time string (sleep
|
||||
* window instants, weigh-in instants). The mappers in `./map.ts` accept both.
|
||||
*/
|
||||
|
||||
/** An instant as Garmin sends it: epoch ms, or an ISO-like string without zone. */
|
||||
export type GarminInstant = number | string | null | undefined;
|
||||
|
||||
/** `GET /usersummary-service/v2/dailysummaries` → `{ data: [...] }`. */
|
||||
export interface GarminDailySummary {
|
||||
calendarDate?: string | null;
|
||||
includesWellnessData?: boolean | null;
|
||||
movement?: {
|
||||
steps?: { value?: number | null; distanceInMeters?: number | null } | null;
|
||||
floorsAscended?: { value?: number | null } | null;
|
||||
} | null;
|
||||
calories?: { burnedActive?: number | null } | null;
|
||||
heartRate?: { restingValue?: number | null } | null;
|
||||
stress?: { avgLevel?: number | null } | null;
|
||||
bodyBattery?: { maxValue?: number | null } | null;
|
||||
}
|
||||
|
||||
export interface GarminDailySummaryEnvelope {
|
||||
data?: GarminDailySummary[] | null;
|
||||
}
|
||||
|
||||
/** One entry of `GET /sleep-service/sleep/dailySleepSummariesByDate`. */
|
||||
export interface GarminSleepSummary {
|
||||
id?: number | string | null;
|
||||
calendarDate?: string | null;
|
||||
sleepTimeSeconds?: number | null;
|
||||
sleepEndTimestampGMT?: GarminInstant;
|
||||
deepSleepSeconds?: number | null;
|
||||
lightSleepSeconds?: number | null;
|
||||
remSleepSeconds?: number | null;
|
||||
awakeSleepSeconds?: number | null;
|
||||
averageSpO2Value?: number | null;
|
||||
averageRespirationValue?: number | null;
|
||||
sleepScores?: Record<
|
||||
string,
|
||||
{ value?: number | null; qualifierKey?: string | null } | null
|
||||
> | null;
|
||||
}
|
||||
|
||||
/** `GET /hrv-service/hrv/daily/{fromDate}/{toDate}`. */
|
||||
export interface GarminHrvEnvelope {
|
||||
hrvSummaries?: Array<{
|
||||
calendarDate?: string | null;
|
||||
/** Average overnight HRV. Garmin measures RMSSD. */
|
||||
lastNightAvg?: number | null;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
/** One weigh-in. Masses are grams on the live service, whatever the document says. */
|
||||
export interface GarminWeighIn {
|
||||
samplePk?: number | string | null;
|
||||
date?: GarminInstant;
|
||||
timestampGMT?: GarminInstant;
|
||||
calendarDate?: string | null;
|
||||
weight?: number | null;
|
||||
bmi?: number | null;
|
||||
bodyFat?: number | null;
|
||||
bodyWater?: number | null;
|
||||
boneMass?: number | null;
|
||||
muscleMass?: number | null;
|
||||
visceralFat?: number | null;
|
||||
sourceType?: string | null;
|
||||
}
|
||||
|
||||
/** `GET /weight-service/weight/range/{start}/{end}?includeAll=true`. */
|
||||
export interface GarminWeightRangeEnvelope {
|
||||
dailyWeightSummaries?: Array<{
|
||||
summaryDate?: string | null;
|
||||
latestWeight?: GarminWeighIn | null;
|
||||
allWeightMetrics?: GarminWeighIn[] | null;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
/** One entry of `GET /activitylist-service/activities/search/activities`. */
|
||||
export interface GarminActivity {
|
||||
activityId?: number | string | null;
|
||||
startTimeGMT?: string | null;
|
||||
beginTimestamp?: number | null;
|
||||
activityType?: { typeKey?: string | null } | null;
|
||||
distance?: number | null;
|
||||
duration?: number | null;
|
||||
elapsedDuration?: number | null;
|
||||
movingDuration?: number | null;
|
||||
elevationGain?: number | null;
|
||||
calories?: number | null;
|
||||
averageHR?: number | null;
|
||||
maxHR?: number | null;
|
||||
steps?: number | null;
|
||||
aerobicTrainingEffect?: number | null;
|
||||
anaerobicTrainingEffect?: number | null;
|
||||
activityTrainingLoad?: number | null;
|
||||
}
|
||||
|
||||
/** A date the menstrual-cycle service sends either bare or wrapped. */
|
||||
export type GarminCycleDate =
|
||||
| string
|
||||
| { localDate?: string | null; date?: string | null }
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
/** `GET /periodichealth-service/menstrualcycle/summary/{calendarDate}`. */
|
||||
export interface GarminCycleSummary {
|
||||
startDate?: GarminCycleDate;
|
||||
periodLength?: number | null;
|
||||
predictedCycle?: boolean | null;
|
||||
pregnancyCycle?: boolean | null;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export const MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS: Record<
|
||||
NIGHTSCOUT: "settings.sections.sources.sourceLabels.NIGHTSCOUT",
|
||||
POLAR: "settings.sections.sources.sourceLabels.POLAR",
|
||||
OURA: "settings.sections.sources.sourceLabels.OURA",
|
||||
GARMIN: "settings.sections.sources.sourceLabels.GARMIN",
|
||||
TELEGRAM: "settings.sections.sources.sourceLabels.TELEGRAM",
|
||||
MCP: "settings.sections.sources.sourceLabels.MCP",
|
||||
GOOGLE_HEALTH: "settings.sections.sources.sourceLabels.GOOGLE_HEALTH",
|
||||
@@ -65,6 +66,7 @@ export const MEASUREMENT_SOURCE_LIST_LABEL_KEYS: Record<
|
||||
NIGHTSCOUT: "measurements.sourceNightscout",
|
||||
POLAR: "measurements.sourcePolar",
|
||||
OURA: "measurements.sourceOura",
|
||||
GARMIN: "measurements.sourceGarmin",
|
||||
TELEGRAM: "measurements.sourceTelegram",
|
||||
MCP: "measurements.sourceMcp",
|
||||
GOOGLE_HEALTH: "measurements.sourceGoogleHealth",
|
||||
|
||||
@@ -149,6 +149,11 @@ export const ALLOWED_CHART_TOKENS = [
|
||||
"metric:INDOOR_CO2",
|
||||
"metric:INDOOR_TEMPERATURE",
|
||||
"metric:INDOOR_HUMIDITY",
|
||||
// Garmin scores and the bridged training figures.
|
||||
"metric:BODY_BATTERY",
|
||||
"metric:STRESS_LEVEL",
|
||||
"metric:TRAINING_READINESS",
|
||||
"metric:ACUTE_TRAINING_LOAD",
|
||||
"metric:GRIP_STRENGTH",
|
||||
"metric:PAIN_NRS",
|
||||
"metric:WAIST_CIRCUMFERENCE",
|
||||
|
||||
@@ -141,6 +141,11 @@ export type MetricStatusMetricId =
|
||||
| "AVERAGE_HEART_RATE"
|
||||
| "MAX_HEART_RATE"
|
||||
| "ENERGY_EXPENDITURE_KJ"
|
||||
// Garmin's day scores and the bridged training figures.
|
||||
| "BODY_BATTERY"
|
||||
| "STRESS_LEVEL"
|
||||
| "TRAINING_READINESS"
|
||||
| "ACUTE_TRAINING_LOAD"
|
||||
// v1.25 — physical / clinical signals promoted to detail pages. Grip
|
||||
// strength + waist carry a sex-aware band via the `norms.ts` tables the
|
||||
// generic generator already threads through `lookupNormalRange`.
|
||||
@@ -602,6 +607,44 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
|
||||
direction: "target-band",
|
||||
archetype: "activity-fitness",
|
||||
},
|
||||
// ── Garmin ──
|
||||
// Body Battery — Garmin's 0–100 energy reserve; the day's highest charge.
|
||||
BODY_BATTERY: {
|
||||
id: "BODY_BATTERY",
|
||||
measurementType: "BODY_BATTERY",
|
||||
displayName: "Body Battery",
|
||||
unit: "score",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
// Stress level — Garmin's 0–100 all-day average; lower is calmer.
|
||||
STRESS_LEVEL: {
|
||||
id: "STRESS_LEVEL",
|
||||
measurementType: "STRESS_LEVEL",
|
||||
displayName: "Stress level",
|
||||
unit: "score",
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
// Training readiness — Garmin's 0–100 morning readiness, bridged in.
|
||||
TRAINING_READINESS: {
|
||||
id: "TRAINING_READINESS",
|
||||
measurementType: "TRAINING_READINESS",
|
||||
displayName: "Training readiness",
|
||||
unit: "score",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
// Acute training load — Garmin's seven-day load, bridged in. Descriptive
|
||||
// against the personal baseline like cardio load.
|
||||
ACUTE_TRAINING_LOAD: {
|
||||
id: "ACUTE_TRAINING_LOAD",
|
||||
measurementType: "ACUTE_TRAINING_LOAD",
|
||||
displayName: "Acute training load",
|
||||
unit: "load",
|
||||
direction: "target-band",
|
||||
archetype: "activity-fitness",
|
||||
},
|
||||
// Cardio load — training-load proxy (acute cardiovascular load).
|
||||
// Descriptive against the personal baseline.
|
||||
CARDIO_LOAD: {
|
||||
|
||||
@@ -26,6 +26,10 @@ export const RECOVERY_SUMMARY_TYPES = [
|
||||
"DAY_STRAIN",
|
||||
"WORKOUT_STRAIN",
|
||||
"CARDIO_LOAD",
|
||||
"BODY_BATTERY",
|
||||
"STRESS_LEVEL",
|
||||
"TRAINING_READINESS",
|
||||
"ACUTE_TRAINING_LOAD",
|
||||
"AVERAGE_HEART_RATE",
|
||||
"MAX_HEART_RATE",
|
||||
"ENERGY_EXPENDITURE_KJ",
|
||||
|
||||
@@ -184,6 +184,7 @@ export async function loadConnectionFacts(
|
||||
stravaAccessTokenEncrypted: true,
|
||||
nightscoutUrlEncrypted: true,
|
||||
dawarichUrlEncrypted: true,
|
||||
garminApiKeyEncrypted: true,
|
||||
healthKitLastSyncedAt: true,
|
||||
},
|
||||
}),
|
||||
@@ -245,6 +246,7 @@ export async function loadConnectionFacts(
|
||||
strava: !!dbUser?.stravaAccessTokenEncrypted,
|
||||
nightscout: !!dbUser?.nightscoutUrlEncrypted,
|
||||
dawarich: !!dbUser?.dawarichUrlEncrypted,
|
||||
garmin: !!dbUser?.garminApiKeyEncrypted,
|
||||
// Apple Health has nothing to connect; it is "connected" once the phone
|
||||
// has delivered anything (a live batch, or rows from the export import).
|
||||
"apple-health": false,
|
||||
|
||||
@@ -44,6 +44,7 @@ export const CONNECTION_KEYS: readonly ConnectionKey[] = [
|
||||
"strava",
|
||||
"nightscout",
|
||||
"dawarich",
|
||||
"garmin",
|
||||
"apple-health",
|
||||
];
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export const INTEGRATION_MEASUREMENT_SOURCE: Partial<
|
||||
polar: "POLAR",
|
||||
oura: "OURA",
|
||||
"google-health": "GOOGLE_HEALTH",
|
||||
garmin: "GARMIN",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,7 @@ export const INTEGRATION_WORKOUT_SOURCE: Partial<
|
||||
whoop: "WHOOP",
|
||||
fitbit: "FITBIT",
|
||||
"google-health": "GOOGLE_HEALTH",
|
||||
garmin: "GARMIN",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function getSourcesInUse(
|
||||
polarAccessTokenEncrypted: true,
|
||||
ouraAccessTokenEncrypted: true,
|
||||
stravaAccessTokenEncrypted: true,
|
||||
garminApiKeyEncrypted: true,
|
||||
},
|
||||
}),
|
||||
prisma.withingsConnection.findUnique({
|
||||
@@ -79,6 +80,7 @@ export async function getSourcesInUse(
|
||||
if (user?.polarAccessTokenEncrypted) inUse.add("POLAR");
|
||||
if (user?.ouraAccessTokenEncrypted) inUse.add("OURA");
|
||||
if (user?.stravaAccessTokenEncrypted) inUse.add("STRAVA");
|
||||
if (user?.garminApiKeyEncrypted) inUse.add("GARMIN");
|
||||
|
||||
return RANKABLE_SOURCES.filter((s) => inUse.has(s));
|
||||
}
|
||||
@@ -63,7 +63,9 @@ export type IntegrationKey =
|
||||
// v1.28.x — Strava OAuth workout source.
|
||||
| "strava"
|
||||
// v1.43 — Dawarich movement context (daily values, nightly).
|
||||
| "dawarich";
|
||||
| "dawarich"
|
||||
// Garmin Personal API (a pasted key, hourly poll).
|
||||
| "garmin";
|
||||
|
||||
/**
|
||||
* Failure kinds carried into `recordSyncFailure`.
|
||||
@@ -1281,6 +1283,7 @@ const INTEGRATION_LABELS: Record<IntegrationKey, string> = {
|
||||
"google-health": "Google Health",
|
||||
strava: "Strava",
|
||||
dawarich: "Dawarich",
|
||||
garmin: "Garmin",
|
||||
};
|
||||
|
||||
const FAILURE_KIND_COPY: Record<
|
||||
|
||||
@@ -126,6 +126,7 @@ export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
|
||||
"google-health": POLLED_CADENCE,
|
||||
strava: POLLED_CADENCE,
|
||||
dawarich: NIGHTLY_CADENCE,
|
||||
garmin: POLLED_CADENCE,
|
||||
};
|
||||
|
||||
export interface SyncVerdictInput {
|
||||
|
||||
@@ -62,7 +62,7 @@ export const INCIDENT_AFTER_MS = DAY_MS;
|
||||
*
|
||||
* - 24 h for wearables and CGMs whose cloud receives data on its own
|
||||
* (Fitbit, Google Health, Polar, Nightscout).
|
||||
* - 48 h for Oura and WHOOP. Their rings and straps upload only when the
|
||||
* - 48 h for Oura, WHOOP and Garmin. Their rings and straps upload only when the
|
||||
* phone app is opened, so a day without opening it is ordinary and must
|
||||
* not page anyone.
|
||||
* - 48 h for Apple Health. iOS defers background delivery when the phone is
|
||||
@@ -86,6 +86,9 @@ export const SOURCE_PROFILES: Record<ConnectionKey, SourceProfile> = {
|
||||
polar: { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
nightscout: { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
dawarich: { kind: "on_demand", silenceWindowMs: DAY_MS },
|
||||
// A Garmin watch uploads through the phone app like the rings and straps,
|
||||
// so a day without opening it is ordinary.
|
||||
garmin: { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
|
||||
"apple-health": { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
|
||||
};
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ const LABELS: Record<ConnectionKey, string> = {
|
||||
strava: "Strava",
|
||||
nightscout: "Nightscout",
|
||||
dawarich: "Dawarich",
|
||||
garmin: "Garmin",
|
||||
"apple-health": "Apple Health",
|
||||
};
|
||||
|
||||
@@ -145,6 +146,7 @@ async function listWatchedUserIds(): Promise<string[]> {
|
||||
{ stravaAccessTokenEncrypted: { not: null } },
|
||||
{ nightscoutUrlEncrypted: { not: null } },
|
||||
{ dawarichUrlEncrypted: { not: null } },
|
||||
{ garminApiKeyEncrypted: { not: null } },
|
||||
{ healthKitLastSyncedAt: { not: null } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -68,6 +68,11 @@ vi.mock("@/lib/jobs/oura-backfill", async (importOriginal) => ({
|
||||
enqueueBootTimeOuraBackfill: async (stagger: number) =>
|
||||
recordStagger("oura-backfill", stagger),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/garmin-backfill", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/jobs/garmin-backfill")>()),
|
||||
enqueueBootTimeGarminBackfill: async (stagger: number) =>
|
||||
recordStagger("garmin-backfill", stagger),
|
||||
}));
|
||||
|
||||
import {
|
||||
INTEGRATION_BACKFILL_ADMISSION_GROUP,
|
||||
@@ -94,6 +99,7 @@ import {
|
||||
} from "../integration-poll-queues";
|
||||
import { LAB_BIOMARKER_BACKFILL_QUEUE } from "../lab-biomarker-backfill";
|
||||
import { OURA_BACKFILL_QUEUE } from "../oura-backfill-queue";
|
||||
import { GARMIN_BACKFILL_QUEUE } from "../garmin-backfill-queue";
|
||||
import {
|
||||
groupNamed,
|
||||
jobOf,
|
||||
@@ -113,6 +119,7 @@ const providerSources = [
|
||||
"strava-backfill.ts",
|
||||
"../fitbit/jobs/sleep-repair.ts",
|
||||
"oura-backfill.ts",
|
||||
"garmin-backfill.ts",
|
||||
].map((file) => readFileSync(join(__dirname, "..", file), "utf8"));
|
||||
|
||||
describe("shared full-history backfill admission", () => {
|
||||
@@ -127,9 +134,10 @@ describe("shared full-history backfill admission", () => {
|
||||
"strava-backfill",
|
||||
"fitbit-sleep-repair",
|
||||
"oura-backfill",
|
||||
"garmin-backfill",
|
||||
]);
|
||||
expect(INTEGRATION_BACKFILL_BOOT_ORDER.map(bootStaggerSecondsFor)).toEqual([
|
||||
30, 60, 90, 120, 150, 180, 210, 240, 270,
|
||||
30, 60, 90, 120, 150, 180, 210, 240, 270, 300,
|
||||
]);
|
||||
expect(INTEGRATION_BACKFILL_STAGGER_SECONDS).toBe(30);
|
||||
|
||||
@@ -232,6 +240,7 @@ describe("shared full-history backfill admission", () => {
|
||||
[STRAVA_BACKFILL_QUEUE, "strava-backfill"],
|
||||
[FITBIT_SLEEP_REPAIR_QUEUE, "fitbit-sleep-repair"],
|
||||
[OURA_BACKFILL_QUEUE, "oura-backfill"],
|
||||
[GARMIN_BACKFILL_QUEUE, "garmin-backfill"],
|
||||
];
|
||||
for (const [queue, kind] of sources) {
|
||||
const { boss, sends } = recordingBoss();
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The queue facts of `garmin-backfill.ts`: its name and concurrency, apart
|
||||
* from the handler so the worker registry and every sender can read them
|
||||
* without loading the job.
|
||||
*/
|
||||
export const GARMIN_BACKFILL_QUEUE = "garmin-backfill";
|
||||
|
||||
/** One account at a time; the shared lane bounds the instance. */
|
||||
export const GARMIN_BACKFILL_CONCURRENCY = 1;
|
||||
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* The Garmin history backfill: every resource's history, read once,
|
||||
* in resumable slices through the shared integration backfill lane.
|
||||
*
|
||||
* The hourly sync reads a window of recent days; everything older is this
|
||||
* job's. A connect clears `garminBackfillCompletedAt` and sends the first
|
||||
* slice; boot discovery re-offers every connection whose stamp is unset. A
|
||||
* slice walks the resources in `GARMIN_BACKFILL_STEPS` order, window by
|
||||
* window, noting after every window where it got to
|
||||
* (`../garmin/backfill-progress.ts`). It ends on its own budget, on the
|
||||
* job's, when another heavy job wants the lane, or on Garmin's rate limit
|
||||
* (waiting at least as long as `Retry-After` asks), and the next slice picks
|
||||
* up at the window it stopped before.
|
||||
*
|
||||
* A refused key ends the chain: the connection is parked at
|
||||
* `reauth_required` and a reconnect starts a new chain where this one
|
||||
* stopped.
|
||||
*/
|
||||
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
enqueueIntegrationBackfillAdmission,
|
||||
INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS,
|
||||
integrationBackfillSourceOptions,
|
||||
} from "@/lib/jobs/integration-backfill-admission";
|
||||
import { LANE_YIELD_DELAY_SECONDS, laneWanted } from "@/lib/jobs/heavy-lane";
|
||||
import { reportJobProgress } from "@/lib/jobs/job-observer";
|
||||
import { isReauthRequired } from "@/lib/integrations/status";
|
||||
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
GarminSession,
|
||||
isGarminKeyRefusal,
|
||||
isGarminRateLimit,
|
||||
type GarminTransport,
|
||||
} from "@/lib/garmin/client";
|
||||
import {
|
||||
getGarminCredentials,
|
||||
rememberGarminKeyExpiry,
|
||||
} from "@/lib/garmin/credentials";
|
||||
import {
|
||||
periodFromGarminCycleSummary,
|
||||
readGarminCycleSummary,
|
||||
writeGarminPeriodDays,
|
||||
} from "@/lib/garmin/cycle-sync";
|
||||
import { garminUnavailableCode } from "@/lib/garmin/unavailable";
|
||||
import {
|
||||
GARMIN_RESOURCE_WINDOW_DAYS,
|
||||
readGarminWindow,
|
||||
retireGarminSleepDays,
|
||||
recordGarminSyncFailure,
|
||||
upsertGarminMeasurements,
|
||||
} from "@/lib/garmin/sync";
|
||||
import { upsertGarminWorkouts } from "@/lib/garmin/sync-workouts";
|
||||
import {
|
||||
GARMIN_BACKFILL_STEPS,
|
||||
GARMIN_HISTORY_FLOOR,
|
||||
garminBackfillProgressJson,
|
||||
readGarminBackfillProgress,
|
||||
type GarminBackfillProgress,
|
||||
type GarminBackfillStep,
|
||||
type GarminBackfillStepState,
|
||||
} from "@/lib/garmin/backfill-progress";
|
||||
import { GARMIN_BACKFILL_QUEUE } from "./garmin-backfill-queue";
|
||||
|
||||
/** One slice: five minutes of walking, then the rest is the next slice's. */
|
||||
export const GARMIN_BACKFILL_SLICE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** The pause after a rate limit that named no wait. */
|
||||
export const GARMIN_BACKFILL_THROTTLED_DELAY_SECONDS = 15 * 60;
|
||||
|
||||
/**
|
||||
* The highest slice a chain may reach. Eighteen years of history is a few
|
||||
* hundred windows in all; a slice writes dozens. A chain past this count
|
||||
* counts progress it does not make.
|
||||
*/
|
||||
export const GARMIN_BACKFILL_MAX_SLICE = 500;
|
||||
|
||||
/** The resources that find where history starts; walked first. */
|
||||
const HISTORY_PROBES: readonly GarminBackfillStep[] = ["daily", "activities"];
|
||||
|
||||
/** How far before the learned history start the other resources reach. */
|
||||
const HISTORY_MARGIN_DAYS = 7;
|
||||
|
||||
/** Window size of the history walk per resource (the probes read wide). */
|
||||
const BACKFILL_WINDOW_DAYS: Record<
|
||||
Exclude<GarminBackfillStep, "cycle">,
|
||||
number
|
||||
> = {
|
||||
daily: GARMIN_RESOURCE_WINDOW_DAYS.daily,
|
||||
activities: 365,
|
||||
sleep: 93,
|
||||
hrv: 93,
|
||||
weight: 186,
|
||||
};
|
||||
|
||||
/** Cycles read per slice at most (one request each). */
|
||||
const MAX_CYCLES_PER_SLICE = 60;
|
||||
|
||||
export interface GarminBackfillPayload {
|
||||
userId: string;
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
export interface GarminBackfillSliceOptions {
|
||||
sliceMs?: number;
|
||||
now?: () => number;
|
||||
transport?: GarminTransport;
|
||||
}
|
||||
|
||||
type StopReason = "budget" | "lane_wanted" | "rate_limited";
|
||||
|
||||
function minDay(
|
||||
a: string | undefined,
|
||||
b: string | undefined,
|
||||
): string | undefined {
|
||||
if (b === undefined) return a;
|
||||
return a === undefined || b < a ? b : a;
|
||||
}
|
||||
|
||||
function floorFor(
|
||||
step: GarminBackfillStep,
|
||||
progress: GarminBackfillProgress,
|
||||
): string | undefined {
|
||||
// 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);
|
||||
return margin < GARMIN_HISTORY_FLOOR ? GARMIN_HISTORY_FLOOR : margin;
|
||||
}
|
||||
|
||||
function settleHistoryStart(progress: GarminBackfillProgress): void {
|
||||
if (progress.historyStart !== undefined) return;
|
||||
if (HISTORY_PROBES.every((s) => progress.steps[s]?.done)) {
|
||||
progress.historyStart = progress.oldestSeen ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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;
|
||||
cycleAvailable: boolean;
|
||||
shouldStop: () => boolean;
|
||||
now: () => number;
|
||||
deadline: number;
|
||||
}
|
||||
|
||||
interface SliceWalk {
|
||||
imported: number;
|
||||
windowsWritten: number;
|
||||
newlySettled: number;
|
||||
stopReason: StopReason | null;
|
||||
retryAfterMs: number | undefined;
|
||||
failure: unknown;
|
||||
failures: GarminBackfillStep[];
|
||||
/** The connection changed under the slice; nothing more was written. */
|
||||
replaced: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one window of a step and write it. Returns rows written, or null when
|
||||
* the step reached its end (the cycle walk found no older cycle).
|
||||
*/
|
||||
async function readStepWindow(
|
||||
ctx: SliceContext,
|
||||
step: GarminBackfillStep,
|
||||
state: GarminBackfillStepState,
|
||||
floor: string,
|
||||
): Promise<number | null> {
|
||||
const { userId, progress, session } = ctx;
|
||||
if (step === "cycle") {
|
||||
const summary = await readGarminCycleSummary(session, state.before);
|
||||
const period = periodFromGarminCycleSummary(summary, progress.anchor);
|
||||
// No logged cycle at or before this day: the walk is over.
|
||||
if (!period || period.start > state.before) return null;
|
||||
await writeGarminPeriodDays(userId, period.days);
|
||||
state.before = shiftDateKey(period.start, -1);
|
||||
return 0;
|
||||
}
|
||||
const shifted = shiftDateKey(state.before, -(BACKFILL_WINDOW_DAYS[step] - 1));
|
||||
const startDate = shifted < floor ? floor : shifted;
|
||||
const read = await readGarminWindow(
|
||||
session,
|
||||
step,
|
||||
{ startDate, endDate: state.before },
|
||||
ctx.tz,
|
||||
);
|
||||
if (step === "sleep")
|
||||
await retireGarminSleepDays(userId, read.sleepEmptyDays);
|
||||
let written = await upsertGarminMeasurements(userId, read.measurements, {
|
||||
emitArrivals: false,
|
||||
});
|
||||
written += await upsertGarminWorkouts(userId, read.workouts, {
|
||||
emitArrivals: false,
|
||||
});
|
||||
if (HISTORY_PROBES.includes(step)) {
|
||||
progress.oldestSeen = minDay(progress.oldestSeen, read.oldestDay);
|
||||
}
|
||||
state.before = shiftDateKey(startDate, -1);
|
||||
return written;
|
||||
}
|
||||
|
||||
/** Walk the steps until the slice's budget, the lane, a limit or the end. */
|
||||
async function walkSlice(ctx: SliceContext): Promise<SliceWalk> {
|
||||
const { userId, progress } = ctx;
|
||||
const out: SliceWalk = {
|
||||
imported: 0,
|
||||
windowsWritten: 0,
|
||||
newlySettled: 0,
|
||||
stopReason: null,
|
||||
retryAfterMs: undefined,
|
||||
failure: undefined,
|
||||
failures: [],
|
||||
replaced: false,
|
||||
};
|
||||
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);
|
||||
return save();
|
||||
};
|
||||
|
||||
walk: for (const step of GARMIN_BACKFILL_STEPS) {
|
||||
const state = progress.steps[step] ?? { before: progress.anchor };
|
||||
progress.steps[step] = state;
|
||||
if (state.done) continue;
|
||||
const floor = floorFor(step, progress);
|
||||
if (floor === undefined) break;
|
||||
if (
|
||||
(step === "cycle" && !ctx.cycleAvailable) ||
|
||||
progress.unavailable?.[step]
|
||||
) {
|
||||
if (!(await settle(state))) break walk;
|
||||
continue;
|
||||
}
|
||||
let windows = 0;
|
||||
while (!state.done) {
|
||||
if (state.before <= floor) {
|
||||
if (!(await settle(state))) break walk;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
ctx.shouldStop() ||
|
||||
ctx.now() >= ctx.deadline ||
|
||||
(step === "cycle" && windows >= MAX_CYCLES_PER_SLICE)
|
||||
) {
|
||||
out.stopReason = "budget";
|
||||
break walk;
|
||||
}
|
||||
if (laneWanted()) {
|
||||
out.stopReason = "lane_wanted";
|
||||
break walk;
|
||||
}
|
||||
try {
|
||||
windows += 1;
|
||||
const written = await readStepWindow(ctx, step, state, floor);
|
||||
if (written === null) {
|
||||
if (!(await settle(state))) break walk;
|
||||
break;
|
||||
}
|
||||
out.imported += written;
|
||||
state.windows = (state.windows ?? 0) + 1;
|
||||
out.windowsWritten += 1;
|
||||
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;
|
||||
out.failures.push(step);
|
||||
break walk;
|
||||
}
|
||||
if (isGarminRateLimit(err)) {
|
||||
out.stopReason = "rate_limited";
|
||||
out.retryAfterMs = err.retryAfterMs;
|
||||
break walk;
|
||||
}
|
||||
// One resource's failure stays with it; the window is read again
|
||||
// next slice.
|
||||
out.failures.push(step);
|
||||
out.failure ??= err;
|
||||
getEvent()?.addWarning(
|
||||
`garmin backfill: ${step} before ${state.before} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
continue walk;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one slice of the backfill for one account. Returns how many rows it
|
||||
* wrote and whether it sent the next slice.
|
||||
*/
|
||||
export async function runGarminBackfillForUser(
|
||||
userId: string,
|
||||
shouldStop: () => boolean = () => false,
|
||||
slice: number = 0,
|
||||
options: GarminBackfillSliceOptions = {},
|
||||
): Promise<{ imported: number; continued: boolean }> {
|
||||
if (slice > GARMIN_BACKFILL_MAX_SLICE) {
|
||||
throw new Error(
|
||||
`garmin backfill for user ${userId} reached slice ${slice}, past the ceiling of ${GARMIN_BACKFILL_MAX_SLICE}`,
|
||||
);
|
||||
}
|
||||
const now = options.now ?? Date.now;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { garminBackfillProgress: true, garminBackfillCompletedAt: true },
|
||||
});
|
||||
if (!user || user.garminBackfillCompletedAt) {
|
||||
return { imported: 0, continued: false };
|
||||
}
|
||||
const creds = await getGarminCredentials(userId);
|
||||
if (!creds) {
|
||||
// Disconnected since the job was sent: nothing to read, and a reconnect
|
||||
// reopens the backfill.
|
||||
return { imported: 0, continued: false };
|
||||
}
|
||||
if (await isReauthRequired(userId, "garmin")) {
|
||||
annotate({
|
||||
action: { name: "garmin.backfill.skipped_reauth", details: { slice } },
|
||||
});
|
||||
return { imported: 0, continued: false };
|
||||
}
|
||||
|
||||
const tz = await resolveUserTimezone(userId);
|
||||
const progress: GarminBackfillProgress = readGarminBackfillProgress(
|
||||
user.garminBackfillProgress,
|
||||
) ?? { anchor: userDayKey(new Date(now()), tz), steps: {} };
|
||||
const session = new GarminSession({
|
||||
userId,
|
||||
apiKey: creds.apiKey,
|
||||
region: creds.region,
|
||||
transport: options.transport,
|
||||
});
|
||||
const walked = await walkSlice({
|
||||
userId,
|
||||
connectedAt: creds.connectedAt,
|
||||
tz,
|
||||
progress,
|
||||
session,
|
||||
cycleAvailable: await isCycleAvailableForUser(userId),
|
||||
shouldStop,
|
||||
now,
|
||||
deadline: now() + (options.sliceMs ?? GARMIN_BACKFILL_SLICE_MS),
|
||||
});
|
||||
const { imported, windowsWritten, newlySettled, stopReason, failures } =
|
||||
walked;
|
||||
const { retryAfterMs, failure } = walked;
|
||||
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.
|
||||
getEvent()?.addWarning(`garmin backfill: key expiry note failed: ${err}`);
|
||||
}
|
||||
|
||||
const settled = GARMIN_BACKFILL_STEPS.every((s) => progress.steps[s]?.done);
|
||||
annotate({
|
||||
meta: {
|
||||
"garmin.backfill.slice": {
|
||||
slice,
|
||||
stopReason,
|
||||
failures,
|
||||
windowsWritten,
|
||||
imported,
|
||||
historyStart: progress.historyStart ?? null,
|
||||
edges: Object.fromEntries(
|
||||
Object.entries(progress.steps).map(([n, s]) => [
|
||||
n,
|
||||
s!.done ? "done" : s!.before,
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
reportJobProgress({
|
||||
garmin_backfill_slice: slice,
|
||||
garmin_backfill_windows: windowsWritten,
|
||||
});
|
||||
|
||||
if (settled && stopReason === null && failures.length === 0) {
|
||||
if (
|
||||
!(await saveProgress(
|
||||
userId,
|
||||
creds.connectedAt,
|
||||
progress,
|
||||
new Date(now()),
|
||||
))
|
||||
) {
|
||||
return { imported, continued: false };
|
||||
}
|
||||
annotate({
|
||||
action: {
|
||||
name: "garmin.backfill.complete",
|
||||
details: { imported, slice },
|
||||
},
|
||||
});
|
||||
return { imported, continued: false };
|
||||
}
|
||||
|
||||
if (isGarminKeyRefusal(failure)) {
|
||||
// Parked; a reconnect sends the next chain.
|
||||
return { imported, continued: false };
|
||||
}
|
||||
|
||||
const progressed = windowsWritten > 0 || newlySettled > 0;
|
||||
if (progressed || stopReason === "rate_limited") {
|
||||
const delaySeconds =
|
||||
stopReason === "rate_limited"
|
||||
? Math.max(
|
||||
INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS,
|
||||
retryAfterMs !== undefined
|
||||
? Math.ceil(retryAfterMs / 1000)
|
||||
: GARMIN_BACKFILL_THROTTLED_DELAY_SECONDS,
|
||||
)
|
||||
: stopReason === "lane_wanted"
|
||||
? LANE_YIELD_DELAY_SECONDS
|
||||
: INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS;
|
||||
if (await sendNextSlice(userId, slice, delaySeconds)) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "garmin.backfill.continued",
|
||||
details: { slice, stopReason, windowsWritten, delaySeconds },
|
||||
},
|
||||
});
|
||||
return { imported, continued: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`garmin backfill incomplete for user ${userId} (slice ${slice}${stopReason ? `, stopped on ${stopReason}` : ""}${failure instanceof Error ? `: ${failure.message}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function sendNextSlice(
|
||||
userId: string,
|
||||
slice: number,
|
||||
delaySeconds: number,
|
||||
): Promise<boolean> {
|
||||
if (slice + 1 > GARMIN_BACKFILL_MAX_SLICE) return false;
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return false;
|
||||
const sent = await enqueueIntegrationBackfillAdmission(
|
||||
boss,
|
||||
{
|
||||
kind: "garmin-backfill",
|
||||
data: { userId, enqueuedAt: new Date().toISOString(), slice: slice + 1 },
|
||||
},
|
||||
delaySeconds,
|
||||
);
|
||||
return Boolean(sent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time discovery: every live account with a Garmin key whose backfill is
|
||||
* not stamped. Idempotent across reboots; errors come back in the result so
|
||||
* the worker boot never fails on a miss.
|
||||
*/
|
||||
export async function enqueueBootTimeGarminBackfill(
|
||||
startAfterSeconds: number = 0,
|
||||
): Promise<{ enqueued: number; skipped: number; error: string | null }> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return { enqueued: 0, skipped: 0, error: null };
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
garminBackfillCompletedAt: null,
|
||||
garminApiKeyEncrypted: { not: null },
|
||||
...ACTIVE_ACCOUNT,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const { id: userId } of users) {
|
||||
const payload: GarminBackfillPayload = {
|
||||
userId,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(
|
||||
GARMIN_BACKFILL_QUEUE,
|
||||
payload,
|
||||
integrationBackfillSourceOptions(
|
||||
`garmin-backfill|${userId}`,
|
||||
startAfterSeconds,
|
||||
),
|
||||
);
|
||||
if (jobId) enqueued += 1;
|
||||
else skipped += 1;
|
||||
}
|
||||
return { enqueued, skipped, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const INTEGRATION_BACKFILL_BOOT_ORDER = [
|
||||
// provider's boot stagger stays exactly where it was.
|
||||
"fitbit-sleep-repair",
|
||||
"oura-backfill",
|
||||
"garmin-backfill",
|
||||
] as const;
|
||||
|
||||
export type IntegrationBackfillKind =
|
||||
|
||||
@@ -23,6 +23,7 @@ import { runGoogleHealthSleepRepairForUser } from "@/lib/jobs/google-health-slee
|
||||
import { runFitbitSleepRepairForUser } from "@/lib/fitbit/jobs/sleep-repair";
|
||||
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
|
||||
import { runOuraBackfillForUser } from "@/lib/jobs/oura-backfill";
|
||||
import { runGarminBackfillForUser } from "@/lib/jobs/garmin-backfill";
|
||||
import { runSleepTimelineBackfillForUser } from "@/lib/jobs/sleep-timeline-backfill";
|
||||
import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill";
|
||||
import {
|
||||
@@ -159,6 +160,20 @@ async function runAdmitted(
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "garmin-backfill": {
|
||||
const { imported, continued } = await runGarminBackfillForUser(
|
||||
userId,
|
||||
shouldStop,
|
||||
data.slice ?? 0,
|
||||
);
|
||||
totals.imported += imported;
|
||||
if (continued) totals.continued += 1;
|
||||
workerLog(
|
||||
"info",
|
||||
`[garmin-backfill] user=${userId} imported=${imported}${continued ? " continued" : ""}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* a queue without loading the sync it feeds.
|
||||
*
|
||||
* The hourly minutes are staggered so the polls do not land on one boss poll:
|
||||
* used are :00 :05 :08 :11 :13 :15 :17 :18 :20 :35 :41 :47 :50.
|
||||
* used are :00 :05 :08 :11 :13 :15 :17 :18 :20 :23 :35 :41 :47 :50.
|
||||
*/
|
||||
|
||||
/** Withings measure catch-net (the webhook is primary). */
|
||||
@@ -73,6 +73,10 @@ export const POLAR_SYNC_CRON = "13 * * * *";
|
||||
export const OURA_SYNC_QUEUE = "oura-sync";
|
||||
export const OURA_SYNC_CRON = "15 * * * *";
|
||||
|
||||
/** Garmin Personal API poll (no webhooks exist for the personal tier). */
|
||||
export const GARMIN_SYNC_QUEUE = "garmin-sync";
|
||||
export const GARMIN_SYNC_CRON = "23 * * * *";
|
||||
|
||||
/** Strava poll (the webhook is not wired). */
|
||||
export const STRAVA_SYNC_QUEUE = "strava-sync";
|
||||
export const STRAVA_SYNC_CRON = "17 * * * *";
|
||||
|
||||
@@ -166,6 +166,8 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
"strava-sync": cohortSync("Strava"),
|
||||
"strava-backfill": forwarder,
|
||||
"oura-backfill": forwarder,
|
||||
"garmin-sync": cohortSync("Garmin"),
|
||||
"garmin-backfill": forwarder,
|
||||
|
||||
// ── Status and insights ────────────────────────────────────────────
|
||||
"insights-general-status": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The third-party provider sync queues: Withings, WHOOP, Fitbit, Google
|
||||
* Health, Nightscout, Polar, Oura, Strava, the full-history backfills and
|
||||
* Health, Nightscout, Polar, Oura, Strava, Garmin, the full-history backfills and
|
||||
* repairs that run through the shared admission lane, the integration
|
||||
* watchdog, and the OAuth-state and handoff prunes.
|
||||
*
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
type IntegrationBackfillKind,
|
||||
} from "@/lib/jobs/integration-backfill-admission";
|
||||
import {
|
||||
GARMIN_SYNC_CRON,
|
||||
GARMIN_SYNC_QUEUE,
|
||||
GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON,
|
||||
GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
GOOGLE_HEALTH_SYNC_CRON,
|
||||
@@ -83,6 +85,10 @@ import {
|
||||
OURA_BACKFILL_CONCURRENCY,
|
||||
OURA_BACKFILL_QUEUE,
|
||||
} from "@/lib/jobs/oura-backfill-queue";
|
||||
import {
|
||||
GARMIN_BACKFILL_CONCURRENCY,
|
||||
GARMIN_BACKFILL_QUEUE,
|
||||
} from "@/lib/jobs/garmin-backfill-queue";
|
||||
import { cronIsTheRetry } from "@/lib/jobs/reminder/registrar-shared";
|
||||
import {
|
||||
SLEEP_TIMELINE_BACKFILL_CONCURRENCY,
|
||||
@@ -295,6 +301,18 @@ const jobs: readonly JobDefinition[] = [
|
||||
OURA_BACKFILL_CONCURRENCY,
|
||||
"oura-backfill",
|
||||
),
|
||||
defineJob({
|
||||
queue: GARMIN_SYNC_QUEUE,
|
||||
cron: GARMIN_SYNC_CRON,
|
||||
work: { localConcurrency: 1 },
|
||||
load: async () =>
|
||||
(await import("@/lib/jobs/reminder/garmin-sync")).handleGarminSync,
|
||||
}),
|
||||
backfillSource(
|
||||
GARMIN_BACKFILL_QUEUE,
|
||||
GARMIN_BACKFILL_CONCURRENCY,
|
||||
"garmin-backfill",
|
||||
),
|
||||
defineJob({
|
||||
queue: STRAVA_SYNC_QUEUE,
|
||||
cron: STRAVA_SYNC_CRON,
|
||||
@@ -376,6 +394,11 @@ const discovery: readonly BootDiscovery[] = [
|
||||
stagger,
|
||||
),
|
||||
),
|
||||
backfillDiscovery("garmin-backfill", async (stagger) =>
|
||||
(await import("@/lib/jobs/garmin-backfill")).enqueueBootTimeGarminBackfill(
|
||||
stagger,
|
||||
),
|
||||
),
|
||||
backfillDiscovery("lab-biomarker-backfill", async (stagger) =>
|
||||
(
|
||||
await import("@/lib/jobs/lab-biomarker-backfill")
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user