From df6e182e0b41d44abb4852a3f052229f998c0ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 10:56:03 +0200 Subject: [PATCH] test(integrations): pin what each provider mapper makes of sanitised wire fixtures --- .../__fixtures__/daily-summaries.json | 94 ++ .../__tests__/__fixtures__/exercise.json | 102 ++ .../__tests__/__fixtures__/rollups.json | 72 + .../__tests__/__fixtures__/samples.json | 105 ++ .../__tests__/__fixtures__/sleep.json | 177 +++ .../__golden__/map-client-helpers.json | 604 ++++++++ .../__golden__/map-daily-summaries.json | 457 ++++++ .../__tests__/__golden__/map-exercise.json | 314 ++++ .../__tests__/__golden__/map-labels.json | 37 + .../__tests__/__golden__/map-rollups.json | 294 ++++ .../__tests__/__golden__/map-samples.json | 693 +++++++++ .../__tests__/__golden__/map-sleep.json | 758 ++++++++++ .../__tests__/map-fixtures.test.ts | 460 ++++++ .../__tests__/__fixtures__/entries.json | 81 + .../__tests__/__golden__/map-entries-url.json | 32 + .../__tests__/__golden__/map-entries.json | 72 + .../__golden__/map-non-array-payloads.json | 7 + .../__tests__/__golden__/map-sha1.json | 11 + .../nightscout/__tests__/map-fixtures.test.ts | 91 ++ .../__fixtures__/cardiovascular-age.json | 7 + .../__tests__/__fixtures__/cycle-phases.json | 65 + .../__fixtures__/daily-activity.json | 48 + .../__tests__/__fixtures__/daily-sleep.json | 18 + .../__tests__/__fixtures__/daily-spo2.json | 29 + .../__tests__/__fixtures__/heartrate.json | 17 + .../__tests__/__fixtures__/readiness.json | 71 + .../__tests__/__fixtures__/resilience.json | 11 + .../oura/__tests__/__fixtures__/sleep.json | 135 ++ .../oura/__tests__/__fixtures__/vo2-max.json | 18 + .../oura/__tests__/__fixtures__/workouts.json | 77 + .../__golden__/map-cardiovascular-age.json | 125 ++ .../__golden__/map-cycle-phases.json | 31 + .../__golden__/map-daily-activity.json | 272 ++++ .../__tests__/__golden__/map-daily-sleep.json | 167 +++ .../__tests__/__golden__/map-daily-spo2.json | 151 ++ .../__tests__/__golden__/map-heartrate.json | 68 + .../__tests__/__golden__/map-readiness.json | 406 ++++++ .../__tests__/__golden__/map-resilience.json | 293 ++++ .../__golden__/map-sleep-retired.json | 11 + .../oura/__tests__/__golden__/map-sleep.json | 1298 +++++++++++++++++ .../__tests__/__golden__/map-vo2-max.json | 138 ++ .../__tests__/__golden__/map-workouts.json | 86 ++ src/lib/oura/__tests__/map-fixtures.test.ts | 199 +++ .../__tests__/__fixtures__/activity.json | 35 + .../__tests__/__fixtures__/cardio-load.json | 14 + .../__tests__/__fixtures__/exercises.json | 53 + .../__fixtures__/nightly-recharge.json | 45 + .../polar/__tests__/__fixtures__/sleep.json | 63 + .../polar/__tests__/__fixtures__/spo2.json | 24 + .../__tests__/__golden__/map-activity.json | 260 ++++ .../__tests__/__golden__/map-cardio-load.json | 128 ++ .../__tests__/__golden__/map-exercises.json | 93 ++ .../__tests__/__golden__/map-helpers.json | 91 ++ .../__golden__/map-nightly-recharge.json | 436 ++++++ .../polar/__tests__/__golden__/map-sleep.json | 811 ++++++++++ .../polar/__tests__/__golden__/map-spo2.json | 32 + src/lib/polar/__tests__/map-fixtures.test.ts | 210 +++ .../__tests__/__fixtures__/activities.json | 166 +++ .../__tests__/__golden__/map-activities.json | 190 +++ .../__tests__/__golden__/map-rate-limit.json | 12 + .../__tests__/__golden__/map-sport.json | 16 + src/lib/strava/__tests__/map-fixtures.test.ts | 160 ++ .../whoop/__tests__/__fixtures__/body.json | 9 + .../whoop/__tests__/__fixtures__/cycle.json | 71 + .../__tests__/__fixtures__/recovery.json | 81 + .../whoop/__tests__/__fixtures__/sleep.json | 139 ++ .../whoop/__tests__/__golden__/map-body.json | 42 + .../whoop/__tests__/__golden__/map-cycle.json | 135 ++ .../__tests__/__golden__/map-recovery.json | 143 ++ .../whoop/__tests__/__golden__/map-sleep.json | 246 ++++ .../whoop/__tests__/__golden__/map-sport.json | 27 + src/lib/whoop/__tests__/map-fixtures.test.ts | 109 ++ 72 files changed, 12043 insertions(+) create mode 100644 src/lib/google-health/__tests__/__fixtures__/daily-summaries.json create mode 100644 src/lib/google-health/__tests__/__fixtures__/exercise.json create mode 100644 src/lib/google-health/__tests__/__fixtures__/rollups.json create mode 100644 src/lib/google-health/__tests__/__fixtures__/samples.json create mode 100644 src/lib/google-health/__tests__/__fixtures__/sleep.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-client-helpers.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-daily-summaries.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-exercise.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-labels.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-rollups.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-samples.json create mode 100644 src/lib/google-health/__tests__/__golden__/map-sleep.json create mode 100644 src/lib/google-health/__tests__/map-fixtures.test.ts create mode 100644 src/lib/nightscout/__tests__/__fixtures__/entries.json create mode 100644 src/lib/nightscout/__tests__/__golden__/map-entries-url.json create mode 100644 src/lib/nightscout/__tests__/__golden__/map-entries.json create mode 100644 src/lib/nightscout/__tests__/__golden__/map-non-array-payloads.json create mode 100644 src/lib/nightscout/__tests__/__golden__/map-sha1.json create mode 100644 src/lib/nightscout/__tests__/map-fixtures.test.ts create mode 100644 src/lib/oura/__tests__/__fixtures__/cardiovascular-age.json create mode 100644 src/lib/oura/__tests__/__fixtures__/cycle-phases.json create mode 100644 src/lib/oura/__tests__/__fixtures__/daily-activity.json create mode 100644 src/lib/oura/__tests__/__fixtures__/daily-sleep.json create mode 100644 src/lib/oura/__tests__/__fixtures__/daily-spo2.json create mode 100644 src/lib/oura/__tests__/__fixtures__/heartrate.json create mode 100644 src/lib/oura/__tests__/__fixtures__/readiness.json create mode 100644 src/lib/oura/__tests__/__fixtures__/resilience.json create mode 100644 src/lib/oura/__tests__/__fixtures__/sleep.json create mode 100644 src/lib/oura/__tests__/__fixtures__/vo2-max.json create mode 100644 src/lib/oura/__tests__/__fixtures__/workouts.json create mode 100644 src/lib/oura/__tests__/__golden__/map-cardiovascular-age.json create mode 100644 src/lib/oura/__tests__/__golden__/map-cycle-phases.json create mode 100644 src/lib/oura/__tests__/__golden__/map-daily-activity.json create mode 100644 src/lib/oura/__tests__/__golden__/map-daily-sleep.json create mode 100644 src/lib/oura/__tests__/__golden__/map-daily-spo2.json create mode 100644 src/lib/oura/__tests__/__golden__/map-heartrate.json create mode 100644 src/lib/oura/__tests__/__golden__/map-readiness.json create mode 100644 src/lib/oura/__tests__/__golden__/map-resilience.json create mode 100644 src/lib/oura/__tests__/__golden__/map-sleep-retired.json create mode 100644 src/lib/oura/__tests__/__golden__/map-sleep.json create mode 100644 src/lib/oura/__tests__/__golden__/map-vo2-max.json create mode 100644 src/lib/oura/__tests__/__golden__/map-workouts.json create mode 100644 src/lib/oura/__tests__/map-fixtures.test.ts create mode 100644 src/lib/polar/__tests__/__fixtures__/activity.json create mode 100644 src/lib/polar/__tests__/__fixtures__/cardio-load.json create mode 100644 src/lib/polar/__tests__/__fixtures__/exercises.json create mode 100644 src/lib/polar/__tests__/__fixtures__/nightly-recharge.json create mode 100644 src/lib/polar/__tests__/__fixtures__/sleep.json create mode 100644 src/lib/polar/__tests__/__fixtures__/spo2.json create mode 100644 src/lib/polar/__tests__/__golden__/map-activity.json create mode 100644 src/lib/polar/__tests__/__golden__/map-cardio-load.json create mode 100644 src/lib/polar/__tests__/__golden__/map-exercises.json create mode 100644 src/lib/polar/__tests__/__golden__/map-helpers.json create mode 100644 src/lib/polar/__tests__/__golden__/map-nightly-recharge.json create mode 100644 src/lib/polar/__tests__/__golden__/map-sleep.json create mode 100644 src/lib/polar/__tests__/__golden__/map-spo2.json create mode 100644 src/lib/polar/__tests__/map-fixtures.test.ts create mode 100644 src/lib/strava/__tests__/__fixtures__/activities.json create mode 100644 src/lib/strava/__tests__/__golden__/map-activities.json create mode 100644 src/lib/strava/__tests__/__golden__/map-rate-limit.json create mode 100644 src/lib/strava/__tests__/__golden__/map-sport.json create mode 100644 src/lib/strava/__tests__/map-fixtures.test.ts create mode 100644 src/lib/whoop/__tests__/__fixtures__/body.json create mode 100644 src/lib/whoop/__tests__/__fixtures__/cycle.json create mode 100644 src/lib/whoop/__tests__/__fixtures__/recovery.json create mode 100644 src/lib/whoop/__tests__/__fixtures__/sleep.json create mode 100644 src/lib/whoop/__tests__/__golden__/map-body.json create mode 100644 src/lib/whoop/__tests__/__golden__/map-cycle.json create mode 100644 src/lib/whoop/__tests__/__golden__/map-recovery.json create mode 100644 src/lib/whoop/__tests__/__golden__/map-sleep.json create mode 100644 src/lib/whoop/__tests__/__golden__/map-sport.json create mode 100644 src/lib/whoop/__tests__/map-fixtures.test.ts diff --git a/src/lib/google-health/__tests__/__fixtures__/daily-summaries.json b/src/lib/google-health/__tests__/__fixtures__/daily-summaries.json new file mode 100644 index 000000000..e5cc47305 --- /dev/null +++ b/src/lib/google-health/__tests__/__fixtures__/daily-summaries.json @@ -0,0 +1,94 @@ +[ + { + "name": "users/me/dataTypes/daily-oxygen-saturation/dataPoints/spo2-0001", + "dailyOxygenSaturation": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "averagePercentage": 96.5 + } + }, + { + "name": "users/me/dataTypes/daily-oxygen-saturation/dataPoints/spo2-0002", + "dailyOxygenSaturation": { + "date": "2026-06-02T00:00:00", + "averagePercentage": "97" + } + }, + { + "name": "users/me/dataTypes/daily-heart-rate-variability/dataPoints/hrv-0001", + "dailyHeartRateVariability": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "averageHeartRateVariabilityMilliseconds": 42.123456 + } + }, + { + "name": "users/me/dataTypes/daily-heart-rate-variability/dataPoints/hrv-0002", + "dailyHeartRateVariability": { + "date": { "year": 2026, "month": 12, "day": 31 }, + "averageHeartRateVariabilityMilliseconds": 0 + } + }, + { + "name": "users/me/dataTypes/daily-resting-heart-rate/dataPoints/rhr-0001", + "dailyRestingHeartRate": { + "date": "2026-06-03", + "beatsPerMinute": "55" + } + }, + { + "name": "users/me/dataTypes/daily-resting-heart-rate/dataPoints/rhr-0002", + "dailyRestingHeartRate": { + "date": { "year": 2026, "month": "6", "day": 4 }, + "beatsPerMinute": 57 + } + }, + { + "name": "users/me/dataTypes/daily-respiratory-rate/dataPoints/resp-0001", + "dailyRespiratoryRate": { + "date": { "year": 2026, "month": 3, "day": 29 }, + "breathsPerMinute": 14.8 + } + }, + { + "name": "users/me/dataTypes/daily-respiratory-rate/dataPoints/resp-0002", + "dailyRespiratoryRate": { + "date": { "year": 2026, "month": 3, "day": 30 }, + "dailyRespiratoryRateBpm": 15.1 + } + }, + { + "name": "users/me/dataTypes/daily-sleep-temperature-derivations/dataPoints/temp-0001", + "dailySleepTemperatureDerivations": { + "date": { "year": 2026, "month": 6, "day": 5 }, + "nightlyTemperatureCelsius": 34.42, + "baselineTemperatureCelsius": 34.1, + "relativeNightlyStddev30dCelsius": 0.3 + } + }, + { + "name": "users/me/dataTypes/daily-sleep-temperature-derivations/dataPoints/temp-0002", + "dailySleepTemperatureDerivations": { + "date": "garbage", + "nightlyTemperatureCelsius": 34.6 + } + }, + { + "name": "users/me/dataTypes/daily-vo2-max/dataPoints/vo2-0001", + "dailyVo2Max": { + "date": { "year": 2026, "month": 6, "day": 6 }, + "vo2Max": 47.25 + } + }, + { + "name": "users/me/dataTypes/daily-vo2-max/dataPoints/vo2-0002", + "dailyVo2Max": { + "vo2Max": "45" + } + }, + { + "name": "users/me/dataTypes/daily-vo2-max/dataPoints/vo2-0003", + "dailyVo2Max": { + "date": { "year": 2026, "month": 6, "day": 7 }, + "vo2Max": null + } + } +] diff --git a/src/lib/google-health/__tests__/__fixtures__/exercise.json b/src/lib/google-health/__tests__/__fixtures__/exercise.json new file mode 100644 index 000000000..32c3faaf0 --- /dev/null +++ b/src/lib/google-health/__tests__/__fixtures__/exercise.json @@ -0,0 +1,102 @@ +[ + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0001", + "exercise": { + "interval": { + "startTime": "2026-06-02T17:00:00Z", + "endTime": "2026-06-02T17:42:30Z", + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 2 }, + "time": { "hours": 19 } + } + }, + "exerciseType": "RUNNING", + "metricsSummary": { + "caloriesKcal": 412.6, + "distanceMillimeters": "7012345", + "averageHeartRateBeatsPerMinute": "151" + } + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0002", + "exercise": { + "interval": { + "startTime": "2026-06-03T07:00:00", + "endTime": "2026-06-03T08:00:00" + }, + "exerciseType": "strength training", + "metricsSummary": { + "caloriesKcal": "250", + "averageHeartRateBeatsPerMinute": 0 + } + } + }, + { + "exercise": { + "interval": { + "startTime": "2026-06-04T06:30:00+02:00", + "endTime": "2026-06-04T07:15:00+02:00" + }, + "exerciseType": " KITESURFING ", + "metricsSummary": { + "distanceMillimeters": -100, + "averageHeartRateBeatsPerMinute": "128.6" + } + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0004", + "exercise": { + "interval": { + "startTime": "2026-06-05T12:00:00Z", + "endTime": "2026-06-05T12:30:00Z" + }, + "exerciseType": " " + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0005", + "exercise": { + "interval": { + "startTime": "2026-06-06T12:00:00Z", + "endTime": "2026-06-06T12:00:00Z" + }, + "exerciseType": "WALKING" + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0006", + "exercise": { + "interval": { + "startTime": "2026-06-07T12:00:00Z" + }, + "exerciseType": "WALKING" + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0007", + "exercise": { + "interval": { + "startTime": "2026-06-08T18:00:00Z", + "endTime": "2026-06-08T19:05:00Z" + }, + "exerciseType": "Mountain-Biking", + "metricsSummary": { + "caloriesKcal": 0, + "distanceMillimeters": "0", + "averageHeartRateBeatsPerMinute": "x" + } + } + }, + { + "name": "users/me/dataTypes/exercise/dataPoints/exercise-0008", + "exercise": { + "interval": { + "startTime": "2026-06-09T20:00:00Z", + "endTime": "2026-06-09T20:20:00Z" + }, + "exerciseType": "STAIR_CLIMBER" + } + } +] diff --git a/src/lib/google-health/__tests__/__fixtures__/rollups.json b/src/lib/google-health/__tests__/__fixtures__/rollups.json new file mode 100644 index 000000000..cb0aeb807 --- /dev/null +++ b/src/lib/google-health/__tests__/__fixtures__/rollups.json @@ -0,0 +1,72 @@ +[ + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "time": {} + }, + "civilEndTime": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "time": { "hours": 23, "minutes": 59, "seconds": 59 } + }, + "steps": { "countSum": "8432" } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 2 }, + "time": {} + }, + "steps": { "countSum": "0" } + }, + { + "civilStartTime": "2026-06-03T00:00:00", + "steps": { "countSum": 10250 } + }, + { + "civilEndTime": { + "date": { "year": 2026, "month": 6, "day": 4 } + }, + "steps": { "countSum": "5000" } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "time": {} + }, + "distance": { "millimetersSum": "6240512" } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 2 }, + "time": {} + }, + "distance": { "millimetersSum": "-1" } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 1 }, + "time": {} + }, + "activeEnergyBurned": { "kcalSum": 512.75 } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 6, "day": 2 }, + "time": {} + }, + "activeEnergyBurned": {} + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 12, "day": 31 }, + "time": {} + }, + "floors": { "countSum": "12" } + }, + { + "civilStartTime": { + "date": { "year": 2026, "month": 1, "day": 1 }, + "time": {} + }, + "floors": { "countSum": "" } + } +] diff --git a/src/lib/google-health/__tests__/__fixtures__/samples.json b/src/lib/google-health/__tests__/__fixtures__/samples.json new file mode 100644 index 000000000..0f299ba2f --- /dev/null +++ b/src/lib/google-health/__tests__/__fixtures__/samples.json @@ -0,0 +1,105 @@ +[ + { + "name": "users/me/dataTypes/weight/dataPoints/weight-0001", + "weight": { + "sampleTime": { "physicalTime": "2026-06-02T06:15:00Z" }, + "weightGrams": 78400 + } + }, + { + "name": "users/me/dataTypes/weight/dataPoints/weight-0002", + "weight": { + "sampleTime": { "physicalTime": "2026-06-03T07:30:00+02:00" }, + "weightGrams": "78250.5" + } + }, + { + "name": "users/me/dataTypes/weight/dataPoints/weight-0003", + "weight": { + "sampleTime": { "physicalTime": "2026-06-04T23:45:00" }, + "weightGrams": 0 + } + }, + { + "name": "users/me/dataTypes/weight/dataPoints/weight-0004", + "weight": { + "sampleTime": { "physicalTime": "2026-06-04T23:45:00" }, + "weightGrams": 77900 + } + }, + { + "name": "users/me/dataTypes/body-fat/dataPoints/body-fat-0001", + "bodyFat": { + "sampleTime": { "physicalTime": "2026-06-02T06:15:00.000Z" }, + "percentage": 21.4 + } + }, + { + "name": "users/me/dataTypes/body-fat/dataPoints/body-fat-0002", + "bodyFat": { + "percentage": "20.9" + } + }, + { + "name": "users/me/dataTypes/heart-rate/dataPoints/heart-rate-0001", + "heartRate": { + "sampleTime": { "physicalTime": "2026-06-02T12:00:30-04:00" }, + "beatsPerMinute": "64" + } + }, + { + "name": "users/me/dataTypes/heart-rate/dataPoints/heart-rate-0002", + "heartRate": { + "sampleTime": { "physicalTime": "2026-06-02 00:10" }, + "beatsPerMinute": " " + } + }, + { + "name": "users/me/dataTypes/heart-rate/dataPoints/heart-rate-0003", + "heartRate": { + "sampleTime": { "physicalTime": "not-a-time" }, + "beatsPerMinute": 58 + } + }, + { + "name": "users/me/dataTypes/blood-glucose/dataPoints/blood-glucose-0001", + "bloodGlucose": { + "sampleTime": { "physicalTime": "2026-06-05T08:00:00Z" }, + "bloodGlucoseMilligramsPerDeciliter": 96.333333 + } + }, + { + "name": "users/me/dataTypes/blood-glucose/dataPoints/blood-glucose-0002", + "bloodGlucose": { + "sampleTime": { "physicalTime": "2026-06-05T09:00:00Z" }, + "bloodGlucoseMilligramsPerDeciliter": -5 + } + }, + { + "name": "users/me/dataTypes/core-body-temperature/dataPoints/core-temp-0001", + "coreBodyTemperature": { + "sampleTime": { "physicalTime": "2026-06-06T03:00:00" }, + "temperatureCelsius": 36.75 + } + }, + { + "name": "users/me/dataTypes/height/dataPoints/height-0001", + "height": { + "sampleTime": { "physicalTime": "2026-01-15T10:00:00Z" }, + "heightMillimeters": "1805" + } + }, + { + "name": "users/me/dataTypes/height/dataPoints/height-0002", + "height": { + "heightMillimeters": 1798 + } + }, + { + "name": "users/me/dataTypes/height/dataPoints/height-0003", + "height": { + "sampleTime": { "physicalTime": "2026-01-16T10:00:00Z" }, + "heightMillimeters": "0" + } + } +] diff --git a/src/lib/google-health/__tests__/__fixtures__/sleep.json b/src/lib/google-health/__tests__/__fixtures__/sleep.json new file mode 100644 index 000000000..60837464e --- /dev/null +++ b/src/lib/google-health/__tests__/__fixtures__/sleep.json @@ -0,0 +1,177 @@ +[ + { + "name": "users/me/dataTypes/sleep/dataPoints/sleep-0001", + "sleep": { + "interval": { + "startTime": "2026-06-01T22:30:00Z", + "endTime": "2026-06-02T06:10:00Z" + }, + "stages": [ + { + "startTime": "2026-06-01T22:30:00Z", + "startUtcOffset": "7200s", + "endTime": "2026-06-01T22:45:00Z", + "endUtcOffset": "7200s", + "type": "AWAKE" + }, + { + "startTime": "2026-06-01T22:45:00Z", + "endTime": "2026-06-02T00:15:00Z", + "type": "LIGHT" + }, + { + "startTime": "2026-06-02T00:15:00Z", + "endTime": "2026-06-02T01:20:30Z", + "type": "DEEP" + }, + { + "startTime": "2026-06-02T01:20:30Z", + "endTime": "2026-06-02T02:05:00Z", + "type": "REM" + }, + { + "startTime": "2026-06-02T02:05:00Z", + "endTime": "2026-06-02T06:10:00Z", + "type": "LIGHT" + } + ] + } + }, + { + "name": "users/me/dataTypes/sleep/dataPoints/sleep-0002", + "sleep": { + "interval": { + "startTime": "2026-06-02T23:50:00", + "endTime": "2026-06-03T07:00:00" + }, + "stages": [ + { + "startTime": "2026-06-02T23:50:00", + "endTime": "2026-06-03T00:20:00", + "type": "ASLEEP" + }, + { + "startTime": "2026-06-03T00:20:00", + "endTime": "2026-06-03T00:25:00", + "type": "RESTLESS" + }, + { + "startTime": "2026-06-03T00:25:00", + "endTime": "2026-06-03T07:00:00", + "type": "asleep" + } + ] + } + }, + { + "sleep": { + "interval": { + "startTime": "2026-06-04T22:00:00+02:00", + "endTime": "2026-06-05T05:30:00+02:00" + }, + "stages": [ + { + "startTime": "2026-06-04T22:00:00+02:00", + "endTime": "2026-06-05T01:00:00+02:00", + "type": "DEEP" + }, + { + "startTime": "2026-06-05T01:00:00+02:00", + "endTime": "2026-06-05T05:30:00+02:00", + "type": "SLEEP_STAGE_TYPE_UNSPECIFIED" + } + ] + } + }, + { + "name": "", + "sleep": { + "interval": { + "startTime": "2026-06-06T23:00:00Z" + }, + "stages": [ + { + "startTime": "2026-06-06T23:00:00Z", + "endTime": "2026-06-06T23:00:00Z", + "type": "LIGHT" + }, + { + "startTime": "2026-06-07T01:00:00Z", + "endTime": "2026-06-07T00:30:00Z", + "type": "REM" + }, + { + "startTime": "2026-06-07T01:00:00Z", + "type": "DEEP" + }, + { + "endTime": "2026-06-07T02:00:00Z", + "type": "DEEP" + }, + null, + "LIGHT", + { + "startTime": "2026-06-07T02:00:00Z", + "endTime": "2026-06-07T02:30:00Z" + }, + { + "startTime": "2026-06-07T02:00:00Z", + "endTime": "2026-06-07T02:30:00Z", + "type": 3 + } + ] + } + }, + { + "sleep": { + "stages": "not-a-list" + } + }, + { + "name": "users/me/dataTypes/sleep/dataPoints/sleep-0006", + "sleep": { + "stages": [ + { + "startTime": "2026-06-08T00:00:00Z", + "endTime": "2026-06-08T00:10:00Z", + "type": "NAP_STAGE_ALPHA" + }, + { + "startTime": "2026-06-08T00:10:00Z", + "endTime": "2026-06-08T00:20:00Z", + "type": "NAP_STAGE_BETA" + }, + { + "startTime": "2026-06-08T00:20:00Z", + "endTime": "2026-06-08T00:30:00Z", + "type": "NAP_STAGE_GAMMA" + }, + { + "startTime": "2026-06-08T00:30:00Z", + "endTime": "2026-06-08T00:40:00Z", + "type": "NAP_STAGE_ALPHA" + }, + { + "startTime": "2026-06-08T00:40:00Z", + "endTime": "2026-06-08T00:50:00Z", + "type": "NAP_STAGE_DELTA" + }, + { + "startTime": "2026-06-08T00:50:00Z", + "endTime": "2026-06-08T01:00:00Z", + "type": "NAP_STAGE_EPSILON" + }, + { + "startTime": "2026-06-08T01:00:00Z", + "endTime": "2026-06-08T01:10:00Z", + "type": "NAP_STAGE_ZETA_WITH_A_LABEL_MUCH_LONGER_THAN_FORTY_CHARACTERS" + }, + { + "startTime": "2026-06-08T01:10:00Z", + "endTime": "2026-06-08T01:40:00Z", + "type": "light" + } + ] + } + } +] diff --git a/src/lib/google-health/__tests__/__golden__/map-client-helpers.json b/src/lib/google-health/__tests__/__golden__/map-client-helpers.json new file mode 100644 index 000000000..e124b324e --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-client-helpers.json @@ -0,0 +1,604 @@ +{ + "resolveGoogleHealthUserId": [ + { "profile": { "name": "users/1234567890" }, "userId": "1234567890" }, + { "profile": { "name": "users/", "id": "id-0002" }, "userId": "id-0002" }, + { "profile": { "name": "plain-0003" }, "userId": "plain-0003" }, + { "profile": { "id": "id-0004" }, "userId": "id-0004" }, + { "profile": {}, "userId": "me" } + ], + "formatCivilBound": [ + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": null, + "bound": "2026-06-01T21:59:59" + }, + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": "Europe/Berlin", + "bound": "2026-06-01T23:59:59" + }, + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": "America/New_York", + "bound": "2026-06-01T17:59:59" + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": null, + "bound": "2026-06-01T22:00:00" + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": "Europe/Berlin", + "bound": "2026-06-02T00:00:00" + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": "America/New_York", + "bound": "2026-06-01T18:00:00" + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": null, + "bound": "2026-03-29T00:30:00" + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": "Europe/Berlin", + "bound": "2026-03-29T01:30:00" + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": "America/New_York", + "bound": "2026-03-28T20:30:00" + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": null, + "bound": "2026-12-31T23:30:15" + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": "Europe/Berlin", + "bound": "2027-01-01T00:30:15" + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": "America/New_York", + "bound": "2026-12-31T18:30:15" + } + ], + "civilDateInTz": [ + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": null, + "date": { "year": 2026, "month": 6, "day": 1 } + }, + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": "Europe/Berlin", + "date": { "year": 2026, "month": 6, "day": 1 } + }, + { + "instant": "2026-06-01T21:59:59.000Z", + "tz": "America/New_York", + "date": { "year": 2026, "month": 6, "day": 1 } + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": null, + "date": { "year": 2026, "month": 6, "day": 1 } + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": "Europe/Berlin", + "date": { "year": 2026, "month": 6, "day": 2 } + }, + { + "instant": "2026-06-01T22:00:00.000Z", + "tz": "America/New_York", + "date": { "year": 2026, "month": 6, "day": 1 } + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": null, + "date": { "year": 2026, "month": 3, "day": 29 } + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": "Europe/Berlin", + "date": { "year": 2026, "month": 3, "day": 29 } + }, + { + "instant": "2026-03-29T00:30:00.000Z", + "tz": "America/New_York", + "date": { "year": 2026, "month": 3, "day": 28 } + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": null, + "date": { "year": 2026, "month": 12, "day": 31 } + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": "Europe/Berlin", + "date": { "year": 2027, "month": 1, "day": 1 } + }, + { + "instant": "2026-12-31T23:30:15.500Z", + "tz": "America/New_York", + "date": { "year": 2026, "month": 12, "day": 31 } + } + ], + "incrementalFilter": [ + { + "dataType": "weight", + "style": "camel", + "tz": null, + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "weight", + "style": "camel", + "tz": "Europe/Berlin", + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "weight", + "style": "camel", + "tz": "America/New_York", + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "weight", + "style": "snake", + "tz": null, + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "weight", + "style": "snake", + "tz": "Europe/Berlin", + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "weight", + "style": "snake", + "tz": "America/New_York", + "filter": { + "field": "weight.sample_time.physical_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "camel", + "tz": null, + "filter": { + "field": "dailyHeartRateVariability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "camel", + "tz": "Europe/Berlin", + "filter": { + "field": "dailyHeartRateVariability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "camel", + "tz": "America/New_York", + "filter": { + "field": "dailyHeartRateVariability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "snake", + "tz": null, + "filter": { + "field": "daily_heart_rate_variability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "snake", + "tz": "Europe/Berlin", + "filter": { + "field": "daily_heart_rate_variability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "daily-heart-rate-variability", + "style": "snake", + "tz": "America/New_York", + "filter": { + "field": "daily_heart_rate_variability.date", + "bound": "2026-06-01" + } + }, + { + "dataType": "sleep", + "style": "camel", + "tz": null, + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "sleep", + "style": "camel", + "tz": "Europe/Berlin", + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "sleep", + "style": "camel", + "tz": "America/New_York", + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "sleep", + "style": "snake", + "tz": null, + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "sleep", + "style": "snake", + "tz": "Europe/Berlin", + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "sleep", + "style": "snake", + "tz": "America/New_York", + "filter": { + "field": "sleep.interval.end_time", + "bound": "2026-06-01T22:00:00.000Z" + } + }, + { + "dataType": "exercise", + "style": "camel", + "tz": null, + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-01T22:00:00" + } + }, + { + "dataType": "exercise", + "style": "camel", + "tz": "Europe/Berlin", + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-02T00:00:00" + } + }, + { + "dataType": "exercise", + "style": "camel", + "tz": "America/New_York", + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-01T18:00:00" + } + }, + { + "dataType": "exercise", + "style": "snake", + "tz": null, + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-01T22:00:00" + } + }, + { + "dataType": "exercise", + "style": "snake", + "tz": "Europe/Berlin", + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-02T00:00:00" + } + }, + { + "dataType": "exercise", + "style": "snake", + "tz": "America/New_York", + "filter": { + "field": "exercise.interval.civil_start_time", + "bound": "2026-06-01T18:00:00" + } + }, + { + "dataType": "steps", + "style": "camel", + "tz": null, + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + }, + { + "dataType": "steps", + "style": "camel", + "tz": "Europe/Berlin", + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + }, + { + "dataType": "steps", + "style": "camel", + "tz": "America/New_York", + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + }, + { + "dataType": "steps", + "style": "snake", + "tz": null, + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + }, + { + "dataType": "steps", + "style": "snake", + "tz": "Europe/Berlin", + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + }, + { + "dataType": "steps", + "style": "snake", + "tz": "America/New_York", + "filter": { + "threw": "Google Health data type steps reads via :dailyRollUp, not dataPoints.list" + } + } + ], + "incrementalFilterDefaultStyle": { + "field": "dailyRestingHeartRate.date", + "bound": "2026-06-01" + }, + "chunkCivilRange": [ + { + "start": { "year": 2026, "month": 1, "day": 1 }, + "end": { "year": 2026, "month": 7, "day": 1 }, + "maxDays": null, + "chunks": [ + { + "start": { "year": 2026, "month": 1, "day": 1 }, + "end": { "year": 2026, "month": 4, "day": 1 } + }, + { + "start": { "year": 2026, "month": 4, "day": 1 }, + "end": { "year": 2026, "month": 6, "day": 30 } + }, + { + "start": { "year": 2026, "month": 6, "day": 30 }, + "end": { "year": 2026, "month": 7, "day": 1 } + } + ] + }, + { + "start": { "year": 2026, "month": 2, "day": 20 }, + "end": { "year": 2026, "month": 3, "day": 10 }, + "maxDays": 14, + "chunks": [ + { + "start": { "year": 2026, "month": 2, "day": 20 }, + "end": { "year": 2026, "month": 3, "day": 6 } + }, + { + "start": { "year": 2026, "month": 3, "day": 6 }, + "end": { "year": 2026, "month": 3, "day": 10 } + } + ] + }, + { + "start": { "year": 2026, "month": 6, "day": 1 }, + "end": { "year": 2026, "month": 6, "day": 1 }, + "maxDays": null, + "chunks": [] + }, + { + "start": { "year": 2026, "month": 6, "day": 2 }, + "end": { "year": 2026, "month": 6, "day": 1 }, + "maxDays": null, + "chunks": [] + } + ], + "buildDailyRollUpBody": [ + { + "chunk": { + "start": { "year": 2026, "month": 1, "day": 1 }, + "end": { "year": 2026, "month": 4, "day": 1 } + }, + "pageToken": null, + "body": { + "range": { + "start": { + "date": { "year": 2026, "month": 1, "day": 1 }, + "time": { "hours": 0, "minutes": 0, "seconds": 0, "nanos": 0 } + }, + "end": { + "date": { "year": 2026, "month": 3, "day": 31 }, + "time": { "hours": 23, "minutes": 59, "seconds": 59, "nanos": 0 } + } + }, + "windowSizeDays": 1 + } + }, + { + "chunk": { + "start": { "year": 2026, "month": 2, "day": 20 }, + "end": { "year": 2026, "month": 3, "day": 1 } + }, + "pageToken": "page-token-0002", + "body": { + "range": { + "start": { + "date": { "year": 2026, "month": 2, "day": 20 }, + "time": { "hours": 0, "minutes": 0, "seconds": 0, "nanos": 0 } + }, + "end": { + "date": { "year": 2026, "month": 2, "day": 28 }, + "time": { "hours": 23, "minutes": 59, "seconds": 59, "nanos": 0 } + } + }, + "windowSizeDays": 1, + "pageToken": "page-token-0002" + } + }, + { + "chunk": { + "start": { "year": 2026, "month": 12, "day": 31 }, + "end": { "year": 2027, "month": 1, "day": 1 } + }, + "pageToken": "", + "body": { + "range": { + "start": { + "date": { "year": 2026, "month": 12, "day": 31 }, + "time": { "hours": 0, "minutes": 0, "seconds": 0, "nanos": 0 } + }, + "end": { + "date": { "year": 2026, "month": 12, "day": 31 }, + "time": { "hours": 23, "minutes": 59, "seconds": 59, "nanos": 0 } + } + }, + "windowSizeDays": 1 + } + } + ], + "extractGoogleApiErrorDetail": [ + { + "body": { + "error": { + "code": 400, + "status": "INVALID_ARGUMENT", + "message": "Invalid filter for https://health.googleapis.com/v4/users/me/dataTypes/weight/dataPoints?filter=weight.date", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.BadRequest", + "fieldViolations": [ + { + "field": "filter", + "description": "Unknown field weight.date" + }, + { "field": "page_size" }, + { "description": "Authorization: Bearer abc.def.ghi rejected" }, + { "field": "fourth", "description": "dropped past the third" } + ] + } + ] + } + }, + "detail": "INVALID_ARGUMENT: Invalid filter for https://health.googleapis.com/v4/users/me/dataTypes/weight/dataPoints?[REDACTED] [filter: Unknown field weight.date; page_size; Authorization: Bearer [REDACTED] rejected]" + }, + { + "body": { "error": { "status": "RESOURCE_EXHAUSTED" } }, + "detail": "RESOURCE_EXHAUSTED" + }, + { + "body": { + "error": { + "message": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + } + }, + "detail": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + { + "body": { + "error": { "details": [null, { "fieldViolations": [{}, 5] }] } + }, + "detail": null + }, + { "body": { "error": "invalid_grant" }, "detail": null }, + { "body": { "error": {} }, "detail": null }, + { "body": null, "detail": null }, + { "body": "plain text", "detail": null } + ], + "refusesEdgeFilter": [ + { "detail": null, "refuses": true }, + { "detail": "INVALID_ARGUMENT", "refuses": true }, + { "detail": "INVALID_ARGUMENT: Invalid filter", "refuses": true }, + { "detail": "INVALID_ARGUMENT: Invalid page token", "refuses": false }, + { "detail": "INVALID_ARGUMENT: page_size out of range", "refuses": false }, + { "detail": "INVALID_ARGUMENT: pageSize too large", "refuses": false }, + { "detail": "UNAUTHENTICATED: access token expired", "refuses": false }, + { "detail": "FAILED_PRECONDITION: something else", "refuses": false }, + { "detail": "Bad filter and bad token", "refuses": true } + ], + "googleHealthRetryDelayMs": [ + { "headers": { "retry-after": "30" }, "body": null, "delayMs": 30000 }, + { "headers": { "retry-after": "1.5" }, "body": null, "delayMs": 1500 }, + { + "headers": { "retry-after": "Wed, 01 Jul 2026 09:00:45 GMT" }, + "body": null, + "delayMs": 45000 + }, + { "headers": { "retry-after": "-3" }, "body": null, "delayMs": null }, + { + "headers": {}, + "body": { + "error": { + "details": [ + { "@type": "type.googleapis.com/google.rpc.ErrorInfo" }, + { + "@type": "type.googleapis.com/google.rpc.RetryInfo", + "retryDelay": "12.5s" + } + ] + } + }, + "delayMs": 12500 + }, + { + "headers": {}, + "body": { "error": { "details": [{ "retryDelay": "soon" }] } }, + "delayMs": null + }, + { "headers": {}, "body": null, "delayMs": null } + ] +} diff --git a/src/lib/google-health/__tests__/__golden__/map-daily-summaries.json b/src/lib/google-health/__tests__/__golden__/map-daily-summaries.json new file mode 100644 index 000000000..fabd41605 --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-daily-summaries.json @@ -0,0 +1,457 @@ +[ + { + "index": 0, + "key": "dailyOxygenSaturation", + "tz": null, + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.5, + "unit": "%", + "measuredAt": "2026-06-01T12:00:00.000Z", + "fieldTag": "2026-06-01:spo2" + } + ] + }, + { + "index": 0, + "key": "dailyOxygenSaturation", + "tz": "Europe/Berlin", + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.5, + "unit": "%", + "measuredAt": "2026-06-01T10:00:00.000Z", + "fieldTag": "2026-06-01:spo2" + } + ] + }, + { + "index": 0, + "key": "dailyOxygenSaturation", + "tz": "America/New_York", + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.5, + "unit": "%", + "measuredAt": "2026-06-01T16:00:00.000Z", + "fieldTag": "2026-06-01:spo2" + } + ] + }, + { + "index": 1, + "key": "dailyOxygenSaturation", + "tz": null, + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 97, + "unit": "%", + "measuredAt": "2026-06-02T12:00:00.000Z", + "fieldTag": "2026-06-02:spo2" + } + ] + }, + { + "index": 1, + "key": "dailyOxygenSaturation", + "tz": "Europe/Berlin", + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 97, + "unit": "%", + "measuredAt": "2026-06-02T10:00:00.000Z", + "fieldTag": "2026-06-02:spo2" + } + ] + }, + { + "index": 1, + "key": "dailyOxygenSaturation", + "tz": "America/New_York", + "output": [ + { + "type": "OXYGEN_SATURATION", + "value": 97, + "unit": "%", + "measuredAt": "2026-06-02T16:00:00.000Z", + "fieldTag": "2026-06-02:spo2" + } + ] + }, + { + "index": 2, + "key": "dailyHeartRateVariability", + "tz": null, + "output": [ + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-06-01T12:00:00.000Z", + "fieldTag": "2026-06-01:hrv" + } + ] + }, + { + "index": 2, + "key": "dailyHeartRateVariability", + "tz": "Europe/Berlin", + "output": [ + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-06-01T10:00:00.000Z", + "fieldTag": "2026-06-01:hrv" + } + ] + }, + { + "index": 2, + "key": "dailyHeartRateVariability", + "tz": "America/New_York", + "output": [ + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-06-01T16:00:00.000Z", + "fieldTag": "2026-06-01:hrv" + } + ] + }, + { "index": 3, "key": "dailyHeartRateVariability", "tz": null, "output": [] }, + { + "index": 3, + "key": "dailyHeartRateVariability", + "tz": "Europe/Berlin", + "output": [] + }, + { + "index": 3, + "key": "dailyHeartRateVariability", + "tz": "America/New_York", + "output": [] + }, + { + "index": 4, + "key": "dailyRestingHeartRate", + "tz": null, + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 55, + "unit": "bpm", + "measuredAt": "2026-06-03T12:00:00.000Z", + "fieldTag": "2026-06-03:rhr" + } + ] + }, + { + "index": 4, + "key": "dailyRestingHeartRate", + "tz": "Europe/Berlin", + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 55, + "unit": "bpm", + "measuredAt": "2026-06-03T10:00:00.000Z", + "fieldTag": "2026-06-03:rhr" + } + ] + }, + { + "index": 4, + "key": "dailyRestingHeartRate", + "tz": "America/New_York", + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 55, + "unit": "bpm", + "measuredAt": "2026-06-03T16:00:00.000Z", + "fieldTag": "2026-06-03:rhr" + } + ] + }, + { + "index": 5, + "key": "dailyRestingHeartRate", + "tz": null, + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 57, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:rhr" + } + ] + }, + { + "index": 5, + "key": "dailyRestingHeartRate", + "tz": "Europe/Berlin", + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 57, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:rhr" + } + ] + }, + { + "index": 5, + "key": "dailyRestingHeartRate", + "tz": "America/New_York", + "output": [ + { + "type": "RESTING_HEART_RATE", + "value": 57, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:rhr" + } + ] + }, + { + "index": 6, + "key": "dailyRespiratoryRate", + "tz": null, + "output": [ + { + "type": "RESPIRATORY_RATE", + "value": 14.8, + "unit": "breaths/min", + "measuredAt": "2026-03-29T12:00:00.000Z", + "fieldTag": "2026-03-29:resp_rate" + } + ] + }, + { + "index": 6, + "key": "dailyRespiratoryRate", + "tz": "Europe/Berlin", + "output": [ + { + "type": "RESPIRATORY_RATE", + "value": 14.8, + "unit": "breaths/min", + "measuredAt": "2026-03-29T10:00:00.000Z", + "fieldTag": "2026-03-29:resp_rate" + } + ] + }, + { + "index": 6, + "key": "dailyRespiratoryRate", + "tz": "America/New_York", + "output": [ + { + "type": "RESPIRATORY_RATE", + "value": 14.8, + "unit": "breaths/min", + "measuredAt": "2026-03-29T16:00:00.000Z", + "fieldTag": "2026-03-29:resp_rate" + } + ] + }, + { "index": 7, "key": "dailyRespiratoryRate", "tz": null, "output": [] }, + { + "index": 7, + "key": "dailyRespiratoryRate", + "tz": "Europe/Berlin", + "output": [] + }, + { + "index": 7, + "key": "dailyRespiratoryRate", + "tz": "America/New_York", + "output": [] + }, + { + "index": 8, + "key": "dailySleepTemperatureDerivations", + "tz": null, + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.42, + "unit": "celsius", + "measuredAt": "2026-06-05T12:00:00.000Z", + "fieldTag": "2026-06-05:wrist_temp" + } + ] + }, + { + "index": 8, + "key": "dailySleepTemperatureDerivations", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.42, + "unit": "celsius", + "measuredAt": "2026-06-05T10:00:00.000Z", + "fieldTag": "2026-06-05:wrist_temp" + } + ] + }, + { + "index": 8, + "key": "dailySleepTemperatureDerivations", + "tz": "America/New_York", + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.42, + "unit": "celsius", + "measuredAt": "2026-06-05T16:00:00.000Z", + "fieldTag": "2026-06-05:wrist_temp" + } + ] + }, + { + "index": 9, + "key": "dailySleepTemperatureDerivations", + "tz": null, + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.6, + "unit": "celsius", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:wrist_temp" + } + ] + }, + { + "index": 9, + "key": "dailySleepTemperatureDerivations", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.6, + "unit": "celsius", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:wrist_temp" + } + ] + }, + { + "index": 9, + "key": "dailySleepTemperatureDerivations", + "tz": "America/New_York", + "output": [ + { + "type": "WRIST_TEMPERATURE", + "value": 34.6, + "unit": "celsius", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:wrist_temp" + } + ] + }, + { + "index": 10, + "key": "dailyVo2Max", + "tz": null, + "output": [ + { + "type": "VO2_MAX", + "value": 47.25, + "unit": "mL/(kg·min)", + "measuredAt": "2026-06-06T12:00:00.000Z", + "fieldTag": "vo2_max:2026-06-06", + "cumulativeDaily": true + } + ] + }, + { + "index": 10, + "key": "dailyVo2Max", + "tz": "Europe/Berlin", + "output": [ + { + "type": "VO2_MAX", + "value": 47.25, + "unit": "mL/(kg·min)", + "measuredAt": "2026-06-06T10:00:00.000Z", + "fieldTag": "vo2_max:2026-06-06", + "cumulativeDaily": true + } + ] + }, + { + "index": 10, + "key": "dailyVo2Max", + "tz": "America/New_York", + "output": [ + { + "type": "VO2_MAX", + "value": 47.25, + "unit": "mL/(kg·min)", + "measuredAt": "2026-06-06T16:00:00.000Z", + "fieldTag": "vo2_max:2026-06-06", + "cumulativeDaily": true + } + ] + }, + { + "index": 11, + "key": "dailyVo2Max", + "tz": null, + "output": [ + { + "type": "VO2_MAX", + "value": 45, + "unit": "mL/(kg·min)", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "vo2_max:1970-01-01T00:00:00.000Z", + "cumulativeDaily": true + } + ] + }, + { + "index": 11, + "key": "dailyVo2Max", + "tz": "Europe/Berlin", + "output": [ + { + "type": "VO2_MAX", + "value": 45, + "unit": "mL/(kg·min)", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "vo2_max:1970-01-01T00:00:00.000Z", + "cumulativeDaily": true + } + ] + }, + { + "index": 11, + "key": "dailyVo2Max", + "tz": "America/New_York", + "output": [ + { + "type": "VO2_MAX", + "value": 45, + "unit": "mL/(kg·min)", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "vo2_max:1970-01-01T00:00:00.000Z", + "cumulativeDaily": true + } + ] + }, + { "index": 12, "key": "dailyVo2Max", "tz": null, "output": [] }, + { "index": 12, "key": "dailyVo2Max", "tz": "Europe/Berlin", "output": [] }, + { "index": 12, "key": "dailyVo2Max", "tz": "America/New_York", "output": [] } +] diff --git a/src/lib/google-health/__tests__/__golden__/map-exercise.json b/src/lib/google-health/__tests__/__golden__/map-exercise.json new file mode 100644 index 000000000..f1ea990c9 --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-exercise.json @@ -0,0 +1,314 @@ +[ + { + "index": 0, + "tz": null, + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0001", + "sportType": "running", + "sportTypeRaw": "RUNNING", + "startedAt": "2026-06-02T17:00:00.000Z", + "endedAt": "2026-06-02T17:42:30.000Z", + "durationSec": 2550, + "totalEnergyKcal": 413, + "totalDistanceM": 7012.35, + "avgHeartRate": 151, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 0, + "tz": "Europe/Berlin", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0001", + "sportType": "running", + "sportTypeRaw": "RUNNING", + "startedAt": "2026-06-02T17:00:00.000Z", + "endedAt": "2026-06-02T17:42:30.000Z", + "durationSec": 2550, + "totalEnergyKcal": 413, + "totalDistanceM": 7012.35, + "avgHeartRate": 151, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 0, + "tz": "America/New_York", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0001", + "sportType": "running", + "sportTypeRaw": "RUNNING", + "startedAt": "2026-06-02T17:00:00.000Z", + "endedAt": "2026-06-02T17:42:30.000Z", + "durationSec": 2550, + "totalEnergyKcal": 413, + "totalDistanceM": 7012.35, + "avgHeartRate": 151, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 1, + "tz": null, + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0002", + "sportType": "strength", + "sportTypeRaw": "strength training", + "startedAt": "2026-06-03T07:00:00.000Z", + "endedAt": "2026-06-03T08:00:00.000Z", + "durationSec": 3600, + "totalEnergyKcal": 250, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 1, + "tz": "Europe/Berlin", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0002", + "sportType": "strength", + "sportTypeRaw": "strength training", + "startedAt": "2026-06-03T05:00:00.000Z", + "endedAt": "2026-06-03T06:00:00.000Z", + "durationSec": 3600, + "totalEnergyKcal": 250, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 1, + "tz": "America/New_York", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0002", + "sportType": "strength", + "sportTypeRaw": "strength training", + "startedAt": "2026-06-03T11:00:00.000Z", + "endedAt": "2026-06-03T12:00:00.000Z", + "durationSec": 3600, + "totalEnergyKcal": 250, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 2, + "tz": null, + "workout": { + "externalId": "exercise:2026-06-04T04:30:00.000Z", + "sportType": "other", + "sportTypeRaw": "KITESURFING", + "startedAt": "2026-06-04T04:30:00.000Z", + "endedAt": "2026-06-04T05:15:00.000Z", + "durationSec": 2700, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": 129, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 2, + "tz": "Europe/Berlin", + "workout": { + "externalId": "exercise:2026-06-04T04:30:00.000Z", + "sportType": "other", + "sportTypeRaw": "KITESURFING", + "startedAt": "2026-06-04T04:30:00.000Z", + "endedAt": "2026-06-04T05:15:00.000Z", + "durationSec": 2700, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": 129, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 2, + "tz": "America/New_York", + "workout": { + "externalId": "exercise:2026-06-04T04:30:00.000Z", + "sportType": "other", + "sportTypeRaw": "KITESURFING", + "startedAt": "2026-06-04T04:30:00.000Z", + "endedAt": "2026-06-04T05:15:00.000Z", + "durationSec": 2700, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": 129, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 3, + "tz": null, + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0004", + "sportType": "other", + "sportTypeRaw": null, + "startedAt": "2026-06-05T12:00:00.000Z", + "endedAt": "2026-06-05T12:30:00.000Z", + "durationSec": 1800, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 3, + "tz": "Europe/Berlin", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0004", + "sportType": "other", + "sportTypeRaw": null, + "startedAt": "2026-06-05T12:00:00.000Z", + "endedAt": "2026-06-05T12:30:00.000Z", + "durationSec": 1800, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 3, + "tz": "America/New_York", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0004", + "sportType": "other", + "sportTypeRaw": null, + "startedAt": "2026-06-05T12:00:00.000Z", + "endedAt": "2026-06-05T12:30:00.000Z", + "durationSec": 1800, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { "index": 4, "tz": null, "workout": null }, + { "index": 4, "tz": "Europe/Berlin", "workout": null }, + { "index": 4, "tz": "America/New_York", "workout": null }, + { "index": 5, "tz": null, "workout": null }, + { "index": 5, "tz": "Europe/Berlin", "workout": null }, + { "index": 5, "tz": "America/New_York", "workout": null }, + { + "index": 6, + "tz": null, + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0007", + "sportType": "cycling", + "sportTypeRaw": "Mountain-Biking", + "startedAt": "2026-06-08T18:00:00.000Z", + "endedAt": "2026-06-08T19:05:00.000Z", + "durationSec": 3900, + "totalEnergyKcal": 0, + "totalDistanceM": 0, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 6, + "tz": "Europe/Berlin", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0007", + "sportType": "cycling", + "sportTypeRaw": "Mountain-Biking", + "startedAt": "2026-06-08T18:00:00.000Z", + "endedAt": "2026-06-08T19:05:00.000Z", + "durationSec": 3900, + "totalEnergyKcal": 0, + "totalDistanceM": 0, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 6, + "tz": "America/New_York", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0007", + "sportType": "cycling", + "sportTypeRaw": "Mountain-Biking", + "startedAt": "2026-06-08T18:00:00.000Z", + "endedAt": "2026-06-08T19:05:00.000Z", + "durationSec": 3900, + "totalEnergyKcal": 0, + "totalDistanceM": 0, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 7, + "tz": null, + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0008", + "sportType": "stairClimber", + "sportTypeRaw": "STAIR_CLIMBER", + "startedAt": "2026-06-09T20:00:00.000Z", + "endedAt": "2026-06-09T20:20:00.000Z", + "durationSec": 1200, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 7, + "tz": "Europe/Berlin", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0008", + "sportType": "stairClimber", + "sportTypeRaw": "STAIR_CLIMBER", + "startedAt": "2026-06-09T20:00:00.000Z", + "endedAt": "2026-06-09T20:20:00.000Z", + "durationSec": 1200, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + }, + { + "index": 7, + "tz": "America/New_York", + "workout": { + "externalId": "users/me/dataTypes/exercise/dataPoints/exercise-0008", + "sportType": "stairClimber", + "sportTypeRaw": "STAIR_CLIMBER", + "startedAt": "2026-06-09T20:00:00.000Z", + "endedAt": "2026-06-09T20:20:00.000Z", + "durationSec": 1200, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "minHeartRate": null + } + } +] diff --git a/src/lib/google-health/__tests__/__golden__/map-labels.json b/src/lib/google-health/__tests__/__golden__/map-labels.json new file mode 100644 index 000000000..aa6967ed7 --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-labels.json @@ -0,0 +1,37 @@ +{ + "sleepStages": [ + { "raw": "AWAKE", "stage": "AWAKE" }, + { "raw": "wake", "stage": "AWAKE" }, + { "raw": "LIGHT", "stage": "CORE" }, + { "raw": " core ", "stage": "CORE" }, + { "raw": "Deep", "stage": "DEEP" }, + { "raw": "REM", "stage": "REM" }, + { "raw": "ASLEEP", "stage": "ASLEEP" }, + { "raw": "RESTLESS", "stage": "AWAKE" }, + { "raw": "in bed", "stage": "IN_BED" }, + { "raw": "IN-BED", "stage": "IN_BED" }, + { "raw": "inbed", "stage": "IN_BED" }, + { "raw": "SLEEP_STAGE_TYPE_UNSPECIFIED", "stage": null }, + { "raw": "", "stage": null }, + { "raw": 42, "stage": null }, + { "raw": null, "stage": null } + ], + "sportTypes": [ + { "raw": "RUNNING", "sportType": "running" }, + { "raw": "TREADMILL_RUNNING", "sportType": "running" }, + { "raw": "STRENGTH_TRAINING", "sportType": "strength" }, + { "raw": "strength training", "sportType": "strength" }, + { "raw": "Mountain-Biking", "sportType": "cycling" }, + { "raw": "STAIR_CLIMBER", "sportType": "stairClimber" }, + { "raw": "STAIRCLIMBER", "sportType": "stairClimber" }, + { "raw": "HIGH INTENSITY INTERVAL TRAINING", "sportType": "hiit" }, + { "raw": "FOOTBALL", "sportType": "soccer" }, + { "raw": "PILATES", "sportType": "mindAndBody" }, + { "raw": "OTHER_WORKOUT", "sportType": "other" }, + { "raw": "KITESURFING", "sportType": "other" }, + { "raw": "", "sportType": "other" }, + { "raw": " ", "sportType": "other" }, + { "raw": null, "sportType": "other" }, + { "raw": 7, "sportType": "other" } + ] +} diff --git a/src/lib/google-health/__tests__/__golden__/map-rollups.json b/src/lib/google-health/__tests__/__golden__/map-rollups.json new file mode 100644 index 000000000..de87e3ddc --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-rollups.json @@ -0,0 +1,294 @@ +[ + { + "index": 0, + "key": "steps", + "tz": null, + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-06-01T12:00:00.000Z", + "fieldTag": "steps:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 0, + "key": "steps", + "tz": "Europe/Berlin", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-06-01T10:00:00.000Z", + "fieldTag": "steps:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 0, + "key": "steps", + "tz": "America/New_York", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-06-01T16:00:00.000Z", + "fieldTag": "steps:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 1, + "key": "steps", + "tz": null, + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-06-02T12:00:00.000Z", + "fieldTag": "steps:2026-06-02", + "cumulativeDaily": true + } + ] + }, + { + "index": 1, + "key": "steps", + "tz": "Europe/Berlin", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-06-02T10:00:00.000Z", + "fieldTag": "steps:2026-06-02", + "cumulativeDaily": true + } + ] + }, + { + "index": 1, + "key": "steps", + "tz": "America/New_York", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-06-02T16:00:00.000Z", + "fieldTag": "steps:2026-06-02", + "cumulativeDaily": true + } + ] + }, + { + "index": 2, + "key": "steps", + "tz": null, + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 10250, + "unit": "steps", + "measuredAt": "2026-06-03T12:00:00.000Z", + "fieldTag": "steps:2026-06-03", + "cumulativeDaily": true + } + ] + }, + { + "index": 2, + "key": "steps", + "tz": "Europe/Berlin", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 10250, + "unit": "steps", + "measuredAt": "2026-06-03T10:00:00.000Z", + "fieldTag": "steps:2026-06-03", + "cumulativeDaily": true + } + ] + }, + { + "index": 2, + "key": "steps", + "tz": "America/New_York", + "output": [ + { + "type": "ACTIVITY_STEPS", + "value": 10250, + "unit": "steps", + "measuredAt": "2026-06-03T16:00:00.000Z", + "fieldTag": "steps:2026-06-03", + "cumulativeDaily": true + } + ] + }, + { "index": 3, "key": "steps", "tz": null, "output": [] }, + { "index": 3, "key": "steps", "tz": "Europe/Berlin", "output": [] }, + { "index": 3, "key": "steps", "tz": "America/New_York", "output": [] }, + { + "index": 4, + "key": "distance", + "tz": null, + "output": [ + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 6240.51, + "unit": "m", + "measuredAt": "2026-06-01T12:00:00.000Z", + "fieldTag": "distance:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 4, + "key": "distance", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 6240.51, + "unit": "m", + "measuredAt": "2026-06-01T10:00:00.000Z", + "fieldTag": "distance:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 4, + "key": "distance", + "tz": "America/New_York", + "output": [ + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 6240.51, + "unit": "m", + "measuredAt": "2026-06-01T16:00:00.000Z", + "fieldTag": "distance:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { "index": 5, "key": "distance", "tz": null, "output": [] }, + { "index": 5, "key": "distance", "tz": "Europe/Berlin", "output": [] }, + { "index": 5, "key": "distance", "tz": "America/New_York", "output": [] }, + { + "index": 6, + "key": "activeEnergyBurned", + "tz": null, + "output": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.75, + "unit": "kcal", + "measuredAt": "2026-06-01T12:00:00.000Z", + "fieldTag": "active_energy:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 6, + "key": "activeEnergyBurned", + "tz": "Europe/Berlin", + "output": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.75, + "unit": "kcal", + "measuredAt": "2026-06-01T10:00:00.000Z", + "fieldTag": "active_energy:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { + "index": 6, + "key": "activeEnergyBurned", + "tz": "America/New_York", + "output": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.75, + "unit": "kcal", + "measuredAt": "2026-06-01T16:00:00.000Z", + "fieldTag": "active_energy:2026-06-01", + "cumulativeDaily": true + } + ] + }, + { "index": 7, "key": "activeEnergyBurned", "tz": null, "output": [] }, + { + "index": 7, + "key": "activeEnergyBurned", + "tz": "Europe/Berlin", + "output": [] + }, + { + "index": 7, + "key": "activeEnergyBurned", + "tz": "America/New_York", + "output": [] + }, + { + "index": 8, + "key": "floors", + "tz": null, + "output": [ + { + "type": "FLIGHTS_CLIMBED", + "value": 12, + "unit": "flights", + "measuredAt": "2026-12-31T12:00:00.000Z", + "fieldTag": "floors:2026-12-31", + "cumulativeDaily": true + } + ] + }, + { + "index": 8, + "key": "floors", + "tz": "Europe/Berlin", + "output": [ + { + "type": "FLIGHTS_CLIMBED", + "value": 12, + "unit": "flights", + "measuredAt": "2026-12-31T11:00:00.000Z", + "fieldTag": "floors:2026-12-31", + "cumulativeDaily": true + } + ] + }, + { + "index": 8, + "key": "floors", + "tz": "America/New_York", + "output": [ + { + "type": "FLIGHTS_CLIMBED", + "value": 12, + "unit": "flights", + "measuredAt": "2026-12-31T17:00:00.000Z", + "fieldTag": "floors:2026-12-31", + "cumulativeDaily": true + } + ] + }, + { "index": 9, "key": "floors", "tz": null, "output": [] }, + { "index": 9, "key": "floors", "tz": "Europe/Berlin", "output": [] }, + { "index": 9, "key": "floors", "tz": "America/New_York", "output": [] } +] diff --git a/src/lib/google-health/__tests__/__golden__/map-samples.json b/src/lib/google-health/__tests__/__golden__/map-samples.json new file mode 100644 index 000000000..a21d8c033 --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-samples.json @@ -0,0 +1,693 @@ +{ + "measurements": [ + { + "index": 0, + "key": "weight", + "tz": null, + "output": [ + { + "type": "WEIGHT", + "value": 78.4, + "unit": "kg", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:weight" + } + ] + }, + { + "index": 0, + "key": "weight", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WEIGHT", + "value": 78.4, + "unit": "kg", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:weight" + } + ] + }, + { + "index": 0, + "key": "weight", + "tz": "America/New_York", + "output": [ + { + "type": "WEIGHT", + "value": 78.4, + "unit": "kg", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:weight" + } + ] + }, + { + "index": 1, + "key": "weight", + "tz": null, + "output": [ + { + "type": "WEIGHT", + "value": 78.25, + "unit": "kg", + "measuredAt": "2026-06-03T05:30:00.000Z", + "fieldTag": "2026-06-03T05:30:00.000Z:weight" + } + ] + }, + { + "index": 1, + "key": "weight", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WEIGHT", + "value": 78.25, + "unit": "kg", + "measuredAt": "2026-06-03T05:30:00.000Z", + "fieldTag": "2026-06-03T05:30:00.000Z:weight" + } + ] + }, + { + "index": 1, + "key": "weight", + "tz": "America/New_York", + "output": [ + { + "type": "WEIGHT", + "value": 78.25, + "unit": "kg", + "measuredAt": "2026-06-03T05:30:00.000Z", + "fieldTag": "2026-06-03T05:30:00.000Z:weight" + } + ] + }, + { "index": 2, "key": "weight", "tz": null, "output": [] }, + { "index": 2, "key": "weight", "tz": "Europe/Berlin", "output": [] }, + { "index": 2, "key": "weight", "tz": "America/New_York", "output": [] }, + { + "index": 3, + "key": "weight", + "tz": null, + "output": [ + { + "type": "WEIGHT", + "value": 77.9, + "unit": "kg", + "measuredAt": "2026-06-04T23:45:00.000Z", + "fieldTag": "2026-06-04T23:45:00.000Z:weight" + } + ] + }, + { + "index": 3, + "key": "weight", + "tz": "Europe/Berlin", + "output": [ + { + "type": "WEIGHT", + "value": 77.9, + "unit": "kg", + "measuredAt": "2026-06-04T21:45:00.000Z", + "fieldTag": "2026-06-04T23:45:00.000Z:weight" + } + ] + }, + { + "index": 3, + "key": "weight", + "tz": "America/New_York", + "output": [ + { + "type": "WEIGHT", + "value": 77.9, + "unit": "kg", + "measuredAt": "2026-06-05T03:45:00.000Z", + "fieldTag": "2026-06-04T23:45:00.000Z:weight" + } + ] + }, + { + "index": 4, + "key": "bodyFat", + "tz": null, + "output": [ + { + "type": "BODY_FAT", + "value": 21.4, + "unit": "%", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:body_fat" + } + ] + }, + { + "index": 4, + "key": "bodyFat", + "tz": "Europe/Berlin", + "output": [ + { + "type": "BODY_FAT", + "value": 21.4, + "unit": "%", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:body_fat" + } + ] + }, + { + "index": 4, + "key": "bodyFat", + "tz": "America/New_York", + "output": [ + { + "type": "BODY_FAT", + "value": 21.4, + "unit": "%", + "measuredAt": "2026-06-02T06:15:00.000Z", + "fieldTag": "2026-06-02T06:15:00.000Z:body_fat" + } + ] + }, + { + "index": 5, + "key": "bodyFat", + "tz": null, + "output": [ + { + "type": "BODY_FAT", + "value": 20.9, + "unit": "%", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:body_fat" + } + ] + }, + { + "index": 5, + "key": "bodyFat", + "tz": "Europe/Berlin", + "output": [ + { + "type": "BODY_FAT", + "value": 20.9, + "unit": "%", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:body_fat" + } + ] + }, + { + "index": 5, + "key": "bodyFat", + "tz": "America/New_York", + "output": [ + { + "type": "BODY_FAT", + "value": 20.9, + "unit": "%", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:body_fat" + } + ] + }, + { + "index": 6, + "key": "heartRate", + "tz": null, + "output": [ + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "measuredAt": "2026-06-02T16:00:30.000Z", + "fieldTag": "2026-06-02T16:00:30.000Z:hr" + } + ] + }, + { + "index": 6, + "key": "heartRate", + "tz": "Europe/Berlin", + "output": [ + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "measuredAt": "2026-06-02T16:00:30.000Z", + "fieldTag": "2026-06-02T16:00:30.000Z:hr" + } + ] + }, + { + "index": 6, + "key": "heartRate", + "tz": "America/New_York", + "output": [ + { + "type": "PULSE", + "value": 64, + "unit": "bpm", + "measuredAt": "2026-06-02T16:00:30.000Z", + "fieldTag": "2026-06-02T16:00:30.000Z:hr" + } + ] + }, + { "index": 7, "key": "heartRate", "tz": null, "output": [] }, + { "index": 7, "key": "heartRate", "tz": "Europe/Berlin", "output": [] }, + { "index": 7, "key": "heartRate", "tz": "America/New_York", "output": [] }, + { + "index": 8, + "key": "heartRate", + "tz": null, + "output": [ + { + "type": "PULSE", + "value": 58, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:hr" + } + ] + }, + { + "index": 8, + "key": "heartRate", + "tz": "Europe/Berlin", + "output": [ + { + "type": "PULSE", + "value": 58, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:hr" + } + ] + }, + { + "index": 8, + "key": "heartRate", + "tz": "America/New_York", + "output": [ + { + "type": "PULSE", + "value": 58, + "unit": "bpm", + "measuredAt": "2026-07-01T09:00:00.000Z", + "fieldTag": "1970-01-01T00:00:00.000Z:hr" + } + ] + }, + { + "index": 9, + "key": "bloodGlucose", + "tz": null, + "output": [ + { + "type": "BLOOD_GLUCOSE", + "value": 96.33, + "unit": "mg/dL", + "measuredAt": "2026-06-05T08:00:00.000Z", + "fieldTag": "2026-06-05T08:00:00.000Z:glucose" + } + ] + }, + { + "index": 9, + "key": "bloodGlucose", + "tz": "Europe/Berlin", + "output": [ + { + "type": "BLOOD_GLUCOSE", + "value": 96.33, + "unit": "mg/dL", + "measuredAt": "2026-06-05T08:00:00.000Z", + "fieldTag": "2026-06-05T08:00:00.000Z:glucose" + } + ] + }, + { + "index": 9, + "key": "bloodGlucose", + "tz": "America/New_York", + "output": [ + { + "type": "BLOOD_GLUCOSE", + "value": 96.33, + "unit": "mg/dL", + "measuredAt": "2026-06-05T08:00:00.000Z", + "fieldTag": "2026-06-05T08:00:00.000Z:glucose" + } + ] + }, + { "index": 10, "key": "bloodGlucose", "tz": null, "output": [] }, + { "index": 10, "key": "bloodGlucose", "tz": "Europe/Berlin", "output": [] }, + { + "index": 10, + "key": "bloodGlucose", + "tz": "America/New_York", + "output": [] + }, + { + "index": 11, + "key": "coreBodyTemperature", + "tz": null, + "output": [ + { + "type": "BODY_TEMPERATURE", + "value": 36.75, + "unit": "celsius", + "measuredAt": "2026-06-06T03:00:00.000Z", + "fieldTag": "2026-06-06T03:00:00.000Z:core_temp" + } + ] + }, + { + "index": 11, + "key": "coreBodyTemperature", + "tz": "Europe/Berlin", + "output": [ + { + "type": "BODY_TEMPERATURE", + "value": 36.75, + "unit": "celsius", + "measuredAt": "2026-06-06T01:00:00.000Z", + "fieldTag": "2026-06-06T03:00:00.000Z:core_temp" + } + ] + }, + { + "index": 11, + "key": "coreBodyTemperature", + "tz": "America/New_York", + "output": [ + { + "type": "BODY_TEMPERATURE", + "value": 36.75, + "unit": "celsius", + "measuredAt": "2026-06-06T07:00:00.000Z", + "fieldTag": "2026-06-06T03:00:00.000Z:core_temp" + } + ] + }, + { + "index": 12, + "key": "height", + "tz": null, + "output": { "cm": 180.5, "sampledAt": "2026-01-15T10:00:00.000Z" } + }, + { + "index": 12, + "key": "height", + "tz": "Europe/Berlin", + "output": { "cm": 180.5, "sampledAt": "2026-01-15T10:00:00.000Z" } + }, + { + "index": 12, + "key": "height", + "tz": "America/New_York", + "output": { "cm": 180.5, "sampledAt": "2026-01-15T10:00:00.000Z" } + }, + { + "index": 13, + "key": "height", + "tz": null, + "output": { "cm": 179.8, "sampledAt": null } + }, + { + "index": 13, + "key": "height", + "tz": "Europe/Berlin", + "output": { "cm": 179.8, "sampledAt": null } + }, + { + "index": 13, + "key": "height", + "tz": "America/New_York", + "output": { "cm": 179.8, "sampledAt": null } + }, + { "index": 14, "key": "height", "tz": null, "output": null }, + { "index": 14, "key": "height", "tz": "Europe/Berlin", "output": null }, + { "index": 14, "key": "height", "tz": "America/New_York", "output": null } + ], + "sampleInstants": [ + { + "index": 0, + "dataType": "weight", + "tz": null, + "instant": "2026-06-02T06:15:00.000Z" + }, + { + "index": 0, + "dataType": "weight", + "tz": "Europe/Berlin", + "instant": "2026-06-02T06:15:00.000Z" + }, + { + "index": 0, + "dataType": "weight", + "tz": "America/New_York", + "instant": "2026-06-02T06:15:00.000Z" + }, + { + "index": 1, + "dataType": "weight", + "tz": null, + "instant": "2026-06-03T05:30:00.000Z" + }, + { + "index": 1, + "dataType": "weight", + "tz": "Europe/Berlin", + "instant": "2026-06-03T05:30:00.000Z" + }, + { + "index": 1, + "dataType": "weight", + "tz": "America/New_York", + "instant": "2026-06-03T05:30:00.000Z" + }, + { + "index": 2, + "dataType": "weight", + "tz": null, + "instant": "2026-06-04T23:45:00.000Z" + }, + { + "index": 2, + "dataType": "weight", + "tz": "Europe/Berlin", + "instant": "2026-06-04T21:45:00.000Z" + }, + { + "index": 2, + "dataType": "weight", + "tz": "America/New_York", + "instant": "2026-06-05T03:45:00.000Z" + }, + { + "index": 3, + "dataType": "weight", + "tz": null, + "instant": "2026-06-04T23:45:00.000Z" + }, + { + "index": 3, + "dataType": "weight", + "tz": "Europe/Berlin", + "instant": "2026-06-04T21:45:00.000Z" + }, + { + "index": 3, + "dataType": "weight", + "tz": "America/New_York", + "instant": "2026-06-05T03:45:00.000Z" + }, + { + "index": 4, + "dataType": "bodyFat", + "tz": null, + "instant": "2026-06-02T06:15:00.000Z" + }, + { + "index": 4, + "dataType": "bodyFat", + "tz": "Europe/Berlin", + "instant": "2026-06-02T06:15:00.000Z" + }, + { + "index": 4, + "dataType": "bodyFat", + "tz": "America/New_York", + "instant": "2026-06-02T06:15:00.000Z" + }, + { "index": 5, "dataType": "bodyFat", "tz": null, "instant": null }, + { + "index": 5, + "dataType": "bodyFat", + "tz": "Europe/Berlin", + "instant": null + }, + { + "index": 5, + "dataType": "bodyFat", + "tz": "America/New_York", + "instant": null + }, + { + "index": 6, + "dataType": "heartRate", + "tz": null, + "instant": "2026-06-02T16:00:30.000Z" + }, + { + "index": 6, + "dataType": "heartRate", + "tz": "Europe/Berlin", + "instant": "2026-06-02T16:00:30.000Z" + }, + { + "index": 6, + "dataType": "heartRate", + "tz": "America/New_York", + "instant": "2026-06-02T16:00:30.000Z" + }, + { + "index": 7, + "dataType": "heartRate", + "tz": null, + "instant": "2026-06-02T00:10:00.000Z" + }, + { + "index": 7, + "dataType": "heartRate", + "tz": "Europe/Berlin", + "instant": "2026-06-01T22:10:00.000Z" + }, + { + "index": 7, + "dataType": "heartRate", + "tz": "America/New_York", + "instant": "2026-06-02T04:10:00.000Z" + }, + { "index": 8, "dataType": "heartRate", "tz": null, "instant": null }, + { + "index": 8, + "dataType": "heartRate", + "tz": "Europe/Berlin", + "instant": null + }, + { + "index": 8, + "dataType": "heartRate", + "tz": "America/New_York", + "instant": null + }, + { + "index": 9, + "dataType": "bloodGlucose", + "tz": null, + "instant": "2026-06-05T08:00:00.000Z" + }, + { + "index": 9, + "dataType": "bloodGlucose", + "tz": "Europe/Berlin", + "instant": "2026-06-05T08:00:00.000Z" + }, + { + "index": 9, + "dataType": "bloodGlucose", + "tz": "America/New_York", + "instant": "2026-06-05T08:00:00.000Z" + }, + { + "index": 10, + "dataType": "bloodGlucose", + "tz": null, + "instant": "2026-06-05T09:00:00.000Z" + }, + { + "index": 10, + "dataType": "bloodGlucose", + "tz": "Europe/Berlin", + "instant": "2026-06-05T09:00:00.000Z" + }, + { + "index": 10, + "dataType": "bloodGlucose", + "tz": "America/New_York", + "instant": "2026-06-05T09:00:00.000Z" + }, + { + "index": 11, + "dataType": "coreBodyTemperature", + "tz": null, + "instant": "2026-06-06T03:00:00.000Z" + }, + { + "index": 11, + "dataType": "coreBodyTemperature", + "tz": "Europe/Berlin", + "instant": "2026-06-06T01:00:00.000Z" + }, + { + "index": 11, + "dataType": "coreBodyTemperature", + "tz": "America/New_York", + "instant": "2026-06-06T07:00:00.000Z" + }, + { + "index": 12, + "dataType": "height", + "tz": null, + "instant": "2026-01-15T10:00:00.000Z" + }, + { + "index": 12, + "dataType": "height", + "tz": "Europe/Berlin", + "instant": "2026-01-15T10:00:00.000Z" + }, + { + "index": 12, + "dataType": "height", + "tz": "America/New_York", + "instant": "2026-01-15T10:00:00.000Z" + }, + { "index": 13, "dataType": "height", "tz": null, "instant": null }, + { + "index": 13, + "dataType": "height", + "tz": "Europe/Berlin", + "instant": null + }, + { + "index": 13, + "dataType": "height", + "tz": "America/New_York", + "instant": null + }, + { + "index": 14, + "dataType": "height", + "tz": null, + "instant": "2026-01-16T10:00:00.000Z" + }, + { + "index": 14, + "dataType": "height", + "tz": "Europe/Berlin", + "instant": "2026-01-16T10:00:00.000Z" + }, + { + "index": 14, + "dataType": "height", + "tz": "America/New_York", + "instant": "2026-01-16T10:00:00.000Z" + } + ], + "sampleInstantOfDailyType": null +} diff --git a/src/lib/google-health/__tests__/__golden__/map-sleep.json b/src/lib/google-health/__tests__/__golden__/map-sleep.json new file mode 100644 index 000000000..a7ab91f8a --- /dev/null +++ b/src/lib/google-health/__tests__/__golden__/map-sleep.json @@ -0,0 +1,758 @@ +[ + { + "index": 0, + "tz": null, + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0001", + "windowStart": "2026-06-01T22:30:00.000Z", + "windowEnd": "2026-06-02T06:10:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ] + }, + { + "index": 0, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0001", + "windowStart": "2026-06-01T22:30:00.000Z", + "windowEnd": "2026-06-02T06:10:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ] + }, + { + "index": 0, + "tz": "America/New_York", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0001", + "windowStart": "2026-06-01T22:30:00.000Z", + "windowEnd": "2026-06-02T06:10:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-06-01T22:45:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:30:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-06-02T00:15:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-01T22:45:00.000Z", + "sleepStage": "CORE" + }, + { + "type": "SLEEP_DURATION", + "value": 65.5, + "unit": "minutes", + "measuredAt": "2026-06-02T01:20:30.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T00:15:00.000Z", + "sleepStage": "DEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 44.5, + "unit": "minutes", + "measuredAt": "2026-06-02T02:05:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T01:20:30.000Z", + "sleepStage": "REM" + }, + { + "type": "SLEEP_DURATION", + "value": 245, + "unit": "minutes", + "measuredAt": "2026-06-02T06:10:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0001:sleep:2026-06-02T02:05:00.000Z", + "sleepStage": "CORE" + } + ] + }, + { + "index": 1, + "tz": null, + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0002", + "windowStart": "2026-06-02T23:50:00.000Z", + "windowEnd": "2026-06-03T07:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-03T00:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T23:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-03T00:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T07:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:25:00.000Z", + "sleepStage": "ASLEEP" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-03T00:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T23:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-03T00:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T07:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T00:25:00.000Z", + "sleepStage": "ASLEEP" + } + ] + }, + { + "index": 1, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0002", + "windowStart": "2026-06-02T21:50:00.000Z", + "windowEnd": "2026-06-03T05:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-02T22:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T21:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-02T22:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T05:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:25:00.000Z", + "sleepStage": "ASLEEP" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-02T22:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T21:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-02T22:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T05:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-02T22:25:00.000Z", + "sleepStage": "ASLEEP" + } + ] + }, + { + "index": 1, + "tz": "America/New_York", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0002", + "windowStart": "2026-06-03T03:50:00.000Z", + "windowEnd": "2026-06-03T11:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-03T04:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T03:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-03T04:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T11:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:25:00.000Z", + "sleepStage": "ASLEEP" + } + ], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-03T04:20:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T03:50:00.000Z", + "sleepStage": "ASLEEP" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-06-03T04:25:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:20:00.000Z", + "sleepStage": "AWAKE" + }, + { + "type": "SLEEP_DURATION", + "value": 395, + "unit": "minutes", + "measuredAt": "2026-06-03T11:00:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0002:sleep:2026-06-03T04:25:00.000Z", + "sleepStage": "ASLEEP" + } + ] + }, + { + "index": 2, + "tz": null, + "detailed": { + "anchor": "2026-06-05T03:30:00.000Z", + "windowStart": "2026-06-04T20:00:00.000Z", + "windowEnd": "2026-06-04T23:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ], + "unknownStageSegments": 1, + "unknownStageLabels": ["SLEEP_STAGE_TYPE_UNSPECIFIED"] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ] + }, + { + "index": 2, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "2026-06-05T03:30:00.000Z", + "windowStart": "2026-06-04T20:00:00.000Z", + "windowEnd": "2026-06-04T23:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ], + "unknownStageSegments": 1, + "unknownStageLabels": ["SLEEP_STAGE_TYPE_UNSPECIFIED"] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ] + }, + { + "index": 2, + "tz": "America/New_York", + "detailed": { + "anchor": "2026-06-05T03:30:00.000Z", + "windowStart": "2026-06-04T20:00:00.000Z", + "windowEnd": "2026-06-04T23:00:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ], + "unknownStageSegments": 1, + "unknownStageLabels": ["SLEEP_STAGE_TYPE_UNSPECIFIED"] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 180, + "unit": "minutes", + "measuredAt": "2026-06-04T23:00:00.000Z", + "fieldTag": "2026-06-05T03:30:00.000Z:sleep:2026-06-04T20:00:00.000Z", + "sleepStage": "DEEP" + } + ] + }, + { + "index": 3, + "tz": null, + "detailed": { + "anchor": "2026-06-06T23:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 3, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "2026-06-06T23:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 3, + "tz": "America/New_York", + "detailed": { + "anchor": "2026-06-06T23:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 4, + "tz": null, + "detailed": { + "anchor": "1970-01-01T00:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 4, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "1970-01-01T00:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 4, + "tz": "America/New_York", + "detailed": { + "anchor": "1970-01-01T00:00:00.000Z", + "windowStart": null, + "windowEnd": null, + "rows": [], + "unknownStageSegments": 0, + "unknownStageLabels": [] + }, + "rows": [] + }, + { + "index": 5, + "tz": null, + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0006", + "windowStart": "2026-06-08T01:10:00.000Z", + "windowEnd": "2026-06-08T01:40:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 7, + "unknownStageLabels": [ + "NAP_STAGE_ALPHA", + "NAP_STAGE_BETA", + "NAP_STAGE_GAMMA", + "NAP_STAGE_DELTA", + "NAP_STAGE_EPSILON" + ] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ] + }, + { + "index": 5, + "tz": "Europe/Berlin", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0006", + "windowStart": "2026-06-08T01:10:00.000Z", + "windowEnd": "2026-06-08T01:40:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 7, + "unknownStageLabels": [ + "NAP_STAGE_ALPHA", + "NAP_STAGE_BETA", + "NAP_STAGE_GAMMA", + "NAP_STAGE_DELTA", + "NAP_STAGE_EPSILON" + ] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ] + }, + { + "index": 5, + "tz": "America/New_York", + "detailed": { + "anchor": "users/me/dataTypes/sleep/dataPoints/sleep-0006", + "windowStart": "2026-06-08T01:10:00.000Z", + "windowEnd": "2026-06-08T01:40:00.000Z", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ], + "unknownStageSegments": 7, + "unknownStageLabels": [ + "NAP_STAGE_ALPHA", + "NAP_STAGE_BETA", + "NAP_STAGE_GAMMA", + "NAP_STAGE_DELTA", + "NAP_STAGE_EPSILON" + ] + }, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-06-08T01:40:00.000Z", + "fieldTag": "users/me/dataTypes/sleep/dataPoints/sleep-0006:sleep:2026-06-08T01:10:00.000Z", + "sleepStage": "CORE" + } + ] + } +] diff --git a/src/lib/google-health/__tests__/map-fixtures.test.ts b/src/lib/google-health/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..4ddd7320f --- /dev/null +++ b/src/lib/google-health/__tests__/map-fixtures.test.ts @@ -0,0 +1,460 @@ +/** + * What each Google Health mapper makes of a fixed set of payloads, pinned in + * `__golden__/map-*.json`. + * + * The fixtures under `__fixtures__/` are synthetic data points in the + * documented v4 wire shape (spot samples, daily summaries, dailyRollUp + * windows, sleep sessions, exercise sessions), chosen to walk every branch the + * mappers have: int64 values as strings, zeros and negatives, missing or + * unparseable anchors, offset-less local times, unknown stage and sport + * labels. Every mapper that takes a zone runs once without one and once each + * for `Europe/Berlin` and `America/New_York`. The pure request helpers from + * `client.ts` run over small input tables in the test itself. + * + * The goldens were written before the mappers moved out of `client.ts` and + * `mappers.ts` into their own modules, so a green run proves the move changed + * no output. The clock is pinned because a sample without a parseable anchor + * falls back to the current time. + */ +import { format } from "prettier"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { + GOOGLE_HEALTH_DATA_TYPES, + googleHealthSampleInstant, + mapActiveEnergy, + mapBloodGlucose, + mapBodyFat, + mapCoreBodyTemperature, + mapDistance, + mapFloors, + mapGoogleHealthSleepStage, + mapGoogleHealthSportType, + mapHeartRate, + mapHeartRateVariability, + mapHeight, + mapOxygenSaturation, + mapRespiratoryRate, + mapRestingHeartRate, + mapSleepSession, + mapSleepSessionDetailed, + mapSteps, + mapVo2Max, + mapWeight, + mapWorkout, + mapWristTemperature, + type GoogleHealthDataPoint, + type GoogleHealthDataType, + type GoogleHealthRollupPoint, +} from "../mappers"; +import { + buildDailyRollUpBody, + chunkCivilRange, + civilDateInTz, + extractGoogleApiErrorDetail, + formatCivilBound, + googleHealthRetryDelayMs, + incrementalFilter, + refusesEdgeFilter, + resolveGoogleHealthUserId, +} from "../client"; +import dailySummariesJson from "./__fixtures__/daily-summaries.json"; +import exerciseJson from "./__fixtures__/exercise.json"; +import rollupsJson from "./__fixtures__/rollups.json"; +import samplesJson from "./__fixtures__/samples.json"; +import sleepJson from "./__fixtures__/sleep.json"; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +/** Captures a thrown error as data, so a refusal is part of the golden too. */ +function outcome(fn: () => T): T | { threw: string } { + try { + return fn(); + } catch (err) { + return { threw: err instanceof Error ? err.message : String(err) }; + } +} + +const samples: GoogleHealthDataPoint[] = samplesJson; +const dailySummaries: GoogleHealthDataPoint[] = dailySummariesJson; +const rollups: GoogleHealthRollupPoint[] = rollupsJson; +const sleepSessions: GoogleHealthDataPoint[] = sleepJson; +const exercises: GoogleHealthDataPoint[] = exerciseJson; + +const ZONES = [undefined, "Europe/Berlin", "America/New_York"] as const; + +type ZonedMapper = (point: P, tz?: string) => R; + +/** Each fixture point through the mapper for the union key it carries, in every zone. */ +function mapByKey

, R>( + points: P[], + mappers: Record>, +): Array<{ index: number; key: string; tz: string | null; output: R }> { + const out: Array<{ + index: number; + key: string; + tz: string | null; + output: R; + }> = []; + points.forEach((point, index) => { + for (const [key, mapper] of Object.entries(mappers)) { + if (!(key in point)) continue; + for (const tz of ZONES) { + out.push({ index, key, tz: tz ?? null, output: mapper(point, tz) }); + } + } + }); + if (out.length !== points.length * ZONES.length) { + throw new Error("a fixture point matched no mapper, or more than one"); + } + return out; +} + +const FIXED_NOW = new Date("2026-07-01T09:00:00.000Z"); + +beforeAll(() => { + vi.useFakeTimers({ now: FIXED_NOW, toFake: ["Date"] }); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +describe("Google Health mapper goldens", () => { + it("maps spot samples", async () => { + const output = { + measurements: mapByKey(samples, { + weight: mapWeight, + bodyFat: mapBodyFat, + heartRate: mapHeartRate, + bloodGlucose: mapBloodGlucose, + coreBodyTemperature: mapCoreBodyTemperature, + height: (point) => mapHeight(point), + }), + sampleInstants: samples.flatMap((point, index) => + Object.entries(GOOGLE_HEALTH_DATA_TYPES) + .filter(([, dt]) => dt.key in point) + .flatMap(([name, dt]) => + ZONES.map((tz) => ({ + index, + dataType: name, + tz: tz ?? null, + instant: googleHealthSampleInstant(point, dt, tz), + })), + ), + ), + sampleInstantOfDailyType: googleHealthSampleInstant( + dailySummaries[0]!, + GOOGLE_HEALTH_DATA_TYPES.oxygenSaturation, + ), + }; + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-samples.json", + ); + }); + + it("maps daily summaries", async () => { + const output = mapByKey(dailySummaries, { + dailyOxygenSaturation: mapOxygenSaturation, + dailyHeartRateVariability: mapHeartRateVariability, + dailyRestingHeartRate: mapRestingHeartRate, + dailyRespiratoryRate: mapRespiratoryRate, + dailySleepTemperatureDerivations: mapWristTemperature, + dailyVo2Max: mapVo2Max, + }); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-daily-summaries.json", + ); + }); + + it("maps dailyRollUp windows", async () => { + const output = mapByKey(rollups, { + steps: mapSteps, + distance: mapDistance, + activeEnergyBurned: mapActiveEnergy, + floors: mapFloors, + }); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-rollups.json", + ); + }); + + it("maps sleep sessions", async () => { + const output = sleepSessions.flatMap((point, index) => + ZONES.map((tz) => ({ + index, + tz: tz ?? null, + detailed: mapSleepSessionDetailed(point, tz), + rows: mapSleepSession(point, tz), + })), + ); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sleep.json", + ); + }); + + it("maps exercise sessions", async () => { + const output = exercises.flatMap((point, index) => + ZONES.map((tz) => ({ + index, + tz: tz ?? null, + workout: mapWorkout(point, tz), + })), + ); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-exercise.json", + ); + }); + + it("resolves sleep stage and sport labels", async () => { + const stageLabels: unknown[] = [ + "AWAKE", + "wake", + "LIGHT", + " core ", + "Deep", + "REM", + "ASLEEP", + "RESTLESS", + "in bed", + "IN-BED", + "inbed", + "SLEEP_STAGE_TYPE_UNSPECIFIED", + "", + 42, + null, + ]; + const sportLabels: unknown[] = [ + "RUNNING", + "TREADMILL_RUNNING", + "STRENGTH_TRAINING", + "strength training", + "Mountain-Biking", + "STAIR_CLIMBER", + "STAIRCLIMBER", + "HIGH INTENSITY INTERVAL TRAINING", + "FOOTBALL", + "PILATES", + "OTHER_WORKOUT", + "KITESURFING", + "", + " ", + null, + 7, + ]; + const output = { + sleepStages: stageLabels.map((raw) => ({ + raw, + stage: mapGoogleHealthSleepStage(raw), + })), + sportTypes: sportLabels.map((raw) => ({ + raw, + sportType: mapGoogleHealthSportType(raw), + })), + }; + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-labels.json", + ); + }); + + it("builds request values with the pure client helpers", async () => { + const instants = [ + new Date("2026-06-01T21:59:59.000Z"), + new Date("2026-06-01T22:00:00.000Z"), + new Date("2026-03-29T00:30:00.000Z"), + new Date("2026-12-31T23:30:15.500Z"), + ]; + const listTypes: GoogleHealthDataType[] = [ + GOOGLE_HEALTH_DATA_TYPES.weight, + GOOGLE_HEALTH_DATA_TYPES.heartRateVariability, + GOOGLE_HEALTH_DATA_TYPES.sleep, + GOOGLE_HEALTH_DATA_TYPES.exercise, + GOOGLE_HEALTH_DATA_TYPES.steps, + ]; + const errorBodies: unknown[] = [ + { + error: { + code: 400, + status: "INVALID_ARGUMENT", + message: + "Invalid filter for https://health.googleapis.com/v4/users/me/dataTypes/weight/dataPoints?filter=weight.date", + details: [ + { + "@type": "type.googleapis.com/google.rpc.BadRequest", + fieldViolations: [ + { field: "filter", description: "Unknown field weight.date" }, + { field: "page_size" }, + { description: "Authorization: Bearer abc.def.ghi rejected" }, + { field: "fourth", description: "dropped past the third" }, + ], + }, + ], + }, + }, + { error: { status: "RESOURCE_EXHAUSTED" } }, + { error: { message: "x".repeat(260) } }, + { error: { details: [null, { fieldViolations: [{}, 5] }] } }, + { error: "invalid_grant" }, + { error: {} }, + null, + "plain text", + ]; + const retryCases: Array<{ + headers: Record; + body: unknown; + }> = [ + { headers: { "retry-after": "30" }, body: null }, + { headers: { "retry-after": "1.5" }, body: null }, + { + headers: { "retry-after": "Wed, 01 Jul 2026 09:00:45 GMT" }, + body: null, + }, + { headers: { "retry-after": "-3" }, body: null }, + { + headers: {}, + body: { + error: { + details: [ + { "@type": "type.googleapis.com/google.rpc.ErrorInfo" }, + { + "@type": "type.googleapis.com/google.rpc.RetryInfo", + retryDelay: "12.5s", + }, + ], + }, + }, + }, + { + headers: {}, + body: { error: { details: [{ retryDelay: "soon" }] } }, + }, + { headers: {}, body: null }, + ]; + const output = { + resolveGoogleHealthUserId: [ + { name: "users/1234567890" }, + { name: "users/", id: "id-0002" }, + { name: "plain-0003" }, + { id: "id-0004" }, + {}, + ].map((profile) => ({ + profile, + userId: resolveGoogleHealthUserId(profile), + })), + formatCivilBound: instants.flatMap((instant) => + ZONES.map((tz) => ({ + instant, + tz: tz ?? null, + bound: formatCivilBound(instant, tz), + })), + ), + civilDateInTz: instants.flatMap((instant) => + ZONES.map((tz) => ({ + instant, + tz: tz ?? null, + date: civilDateInTz(instant, tz), + })), + ), + incrementalFilter: listTypes.flatMap((dataType) => + (["camel", "snake"] as const).flatMap((style) => + ZONES.map((tz) => ({ + dataType: dataType.path, + style, + tz: tz ?? null, + filter: outcome(() => + incrementalFilter(dataType, instants[1]!, tz, style), + ), + })), + ), + ), + incrementalFilterDefaultStyle: incrementalFilter( + GOOGLE_HEALTH_DATA_TYPES.restingHeartRate, + instants[0]!, + ), + chunkCivilRange: [ + { + start: { year: 2026, month: 1, day: 1 }, + end: { year: 2026, month: 7, day: 1 }, + }, + { + start: { year: 2026, month: 2, day: 20 }, + end: { year: 2026, month: 3, day: 10 }, + maxDays: 14, + }, + { + start: { year: 2026, month: 6, day: 1 }, + end: { year: 2026, month: 6, day: 1 }, + }, + { + start: { year: 2026, month: 6, day: 2 }, + end: { year: 2026, month: 6, day: 1 }, + }, + ].map(({ start, end, maxDays }) => ({ + start, + end, + maxDays: maxDays ?? null, + chunks: chunkCivilRange(start, end, maxDays), + })), + buildDailyRollUpBody: [ + { + chunk: { + start: { year: 2026, month: 1, day: 1 }, + end: { year: 2026, month: 4, day: 1 }, + }, + }, + { + chunk: { + start: { year: 2026, month: 2, day: 20 }, + end: { year: 2026, month: 3, day: 1 }, + }, + pageToken: "page-token-0002", + }, + { + chunk: { + start: { year: 2026, month: 12, day: 31 }, + end: { year: 2027, month: 1, day: 1 }, + }, + pageToken: "", + }, + ].map(({ chunk, pageToken }) => ({ + chunk, + pageToken: pageToken ?? null, + body: buildDailyRollUpBody(chunk, pageToken), + })), + extractGoogleApiErrorDetail: errorBodies.map((body) => ({ + body, + detail: extractGoogleApiErrorDetail(body) ?? null, + })), + refusesEdgeFilter: [ + undefined, + "INVALID_ARGUMENT", + "INVALID_ARGUMENT: Invalid filter", + "INVALID_ARGUMENT: Invalid page token", + "INVALID_ARGUMENT: page_size out of range", + "INVALID_ARGUMENT: pageSize too large", + "UNAUTHENTICATED: access token expired", + "FAILED_PRECONDITION: something else", + "Bad filter and bad token", + ].map((detail) => ({ + detail: detail ?? null, + refuses: refusesEdgeFilter(detail), + })), + googleHealthRetryDelayMs: retryCases.map(({ headers, body }) => ({ + headers, + body, + delayMs: + googleHealthRetryDelayMs( + new Response(null, { status: 429, headers }), + body, + FIXED_NOW.getTime(), + ) ?? null, + })), + }; + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-client-helpers.json", + ); + }); +}); diff --git a/src/lib/nightscout/__tests__/__fixtures__/entries.json b/src/lib/nightscout/__tests__/__fixtures__/entries.json new file mode 100644 index 000000000..4fce586f5 --- /dev/null +++ b/src/lib/nightscout/__tests__/__fixtures__/entries.json @@ -0,0 +1,81 @@ +[ + { + "_id": "6650a1b2c3d4e5f600000001", + "type": "sgv", + "sgv": 112, + "date": 1772445600000, + "dateString": "2026-03-02T10:00:00.000Z", + "direction": "Flat", + "noise": 1, + "device": "example-cgm" + }, + { + "_id": "6650a1b2c3d4e5f600000002", + "type": "sgv", + "sgv": 187.5, + "date": 1772445900000, + "dateString": "2026-03-02T10:05:00.000Z", + "direction": "SingleUp" + }, + { + "sgv": 95, + "date": 1772446200000, + "dateString": "2026-03-02T10:10:00.000Z" + }, + { + "_id": "6650a1b2c3d4e5f600000004", + "type": "mbg", + "mbg": 120, + "date": 1772446500000 + }, + { + "_id": "6650a1b2c3d4e5f600000005", + "type": "cal", + "slope": 850, + "intercept": 32000, + "date": 1772446800000 + }, + { + "_id": "6650a1b2c3d4e5f600000006", + "type": "sgv", + "sgv": null, + "date": 1772447100000 + }, + { + "_id": "6650a1b2c3d4e5f600000007", + "type": "sgv", + "sgv": 140 + }, + { + "_id": "6650a1b2c3d4e5f600000008", + "type": "sgv", + "sgv": 140, + "date": "1772447700000" + }, + { + "_id": "6650a1b2c3d4e5f600000009", + "type": "sgv", + "sgv": "130", + "date": 1772448000000 + }, + { + "_id": 42, + "type": "sgv", + "sgv": 78, + "date": 1772448300000 + }, + null, + "not-an-entry", + { + "_id": "6650a1b2c3d4e5f600000013", + "type": "sgv", + "sgv": 0, + "date": 0 + }, + { + "_id": "", + "type": "", + "sgv": 401, + "date": 1772448600000 + } +] diff --git a/src/lib/nightscout/__tests__/__golden__/map-entries-url.json b/src/lib/nightscout/__tests__/__golden__/map-entries-url.json new file mode 100644 index 000000000..de79fae7f --- /dev/null +++ b/src/lib/nightscout/__tests__/__golden__/map-entries-url.json @@ -0,0 +1,32 @@ +[ + { + "baseUrl": "https://ns.example.org", + "count": 288, + "token": null, + "url": "https://ns.example.org/api/v1/entries.json?count=288&type=sgv" + }, + { + "baseUrl": "https://ns.example.org/", + "count": 288, + "token": "reader-0123456789abcdef", + "url": "https://ns.example.org/api/v1/entries.json?count=288&type=sgv&token=reader-0123456789abcdef" + }, + { + "baseUrl": "https://ns.example.org///", + "count": 1, + "token": "", + "url": "https://ns.example.org/api/v1/entries.json?count=1&type=sgv" + }, + { + "baseUrl": "https://example.org/nightscout", + "count": 50, + "token": "token with spaces&more=1", + "url": "https://example.org/nightscout/api/v1/entries.json?count=50&type=sgv&token=token+with+spaces%26more%3D1" + }, + { + "baseUrl": "http://ns.example.org:1337", + "count": 0, + "token": "reader-token", + "url": "http://ns.example.org:1337/api/v1/entries.json?count=0&type=sgv&token=reader-token" + } +] diff --git a/src/lib/nightscout/__tests__/__golden__/map-entries.json b/src/lib/nightscout/__tests__/__golden__/map-entries.json new file mode 100644 index 000000000..e133e86bc --- /dev/null +++ b/src/lib/nightscout/__tests__/__golden__/map-entries.json @@ -0,0 +1,72 @@ +{ + "parsed": [ + { "id": "6650a1b2c3d4e5f600000001", "sgv": 112, "date": 1772445600000 }, + { "id": "6650a1b2c3d4e5f600000002", "sgv": 187.5, "date": 1772445900000 }, + { "id": null, "sgv": 95, "date": 1772446200000 }, + { "id": null, "sgv": 78, "date": 1772448300000 }, + { "id": "6650a1b2c3d4e5f600000013", "sgv": 0, "date": 0 }, + { "id": "", "sgv": 401, "date": 1772448600000 } + ], + "mapped": [ + { + "externalId": "ns:6650a1b2c3d4e5f600000001", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 112, + "unit": "mg/dL", + "measuredAt": "2026-03-02T10:00:00.000Z", + "externalId": "ns:6650a1b2c3d4e5f600000001" + } + }, + { + "externalId": "ns:6650a1b2c3d4e5f600000002", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 187.5, + "unit": "mg/dL", + "measuredAt": "2026-03-02T10:05:00.000Z", + "externalId": "ns:6650a1b2c3d4e5f600000002" + } + }, + { + "externalId": "ns:date:1772446200000", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 95, + "unit": "mg/dL", + "measuredAt": "2026-03-02T10:10:00.000Z", + "externalId": "ns:date:1772446200000" + } + }, + { + "externalId": "ns:date:1772448300000", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 78, + "unit": "mg/dL", + "measuredAt": "2026-03-02T10:45:00.000Z", + "externalId": "ns:date:1772448300000" + } + }, + { + "externalId": "ns:6650a1b2c3d4e5f600000013", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 0, + "unit": "mg/dL", + "measuredAt": "1970-01-01T00:00:00.000Z", + "externalId": "ns:6650a1b2c3d4e5f600000013" + } + }, + { + "externalId": "ns:date:1772448600000", + "measurement": { + "type": "BLOOD_GLUCOSE", + "value": 401, + "unit": "mg/dL", + "measuredAt": "2026-03-02T10:50:00.000Z", + "externalId": "ns:date:1772448600000" + } + } + ] +} diff --git a/src/lib/nightscout/__tests__/__golden__/map-non-array-payloads.json b/src/lib/nightscout/__tests__/__golden__/map-non-array-payloads.json new file mode 100644 index 000000000..d43da4394 --- /dev/null +++ b/src/lib/nightscout/__tests__/__golden__/map-non-array-payloads.json @@ -0,0 +1,7 @@ +[ + { "name": "error object", "parsed": [] }, + { "name": "login page", "parsed": [] }, + { "name": "null", "parsed": [] }, + { "name": "number", "parsed": [] }, + { "name": "empty array", "parsed": [] } +] diff --git a/src/lib/nightscout/__tests__/__golden__/map-sha1.json b/src/lib/nightscout/__tests__/__golden__/map-sha1.json new file mode 100644 index 000000000..62df38f8f --- /dev/null +++ b/src/lib/nightscout/__tests__/__golden__/map-sha1.json @@ -0,0 +1,11 @@ +[ + { "value": "", "sha1": "da39a3ee5e6b4b0d3255bfef95601890afd80709" }, + { + "value": "example-api-secret", + "sha1": "ae2d76b7397b1eed862868b41007d75ab043c31c" + }, + { + "value": "secret-with-umlaut-äöü", + "sha1": "7f13c4ff16a35003f36132c3ba41217605e390f0" + } +] diff --git a/src/lib/nightscout/__tests__/map-fixtures.test.ts b/src/lib/nightscout/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..96fb182e7 --- /dev/null +++ b/src/lib/nightscout/__tests__/map-fixtures.test.ts @@ -0,0 +1,91 @@ +/** + * What the Nightscout parse and mapping make of a fixed set of payloads, + * pinned in `__golden__/map-*.json`. + * + * `__fixtures__/entries.json` is a synthetic `/api/v1/entries.json` answer in + * the documented wire shape, mixing what an instance really returns: typed + * and untyped SGV rows, meter-BG and calibration rows, a missing value or + * timestamp, string-typed numbers, a non-string `_id`, non-object rows and + * zero values. The URL builder, the SHA1 helper and the non-array payloads + * run from small tables in this file. + * + * The golden was written before the mappers moved out of `client.ts`, so a + * green run is the proof that the move changed nothing. + */ +import { describe, expect, it } from "vitest"; +import { format } from "prettier"; + +import { + buildEntriesUrl, + externalIdFor, + mapSgvEntryToMeasurement, + parseSgvEntries, + sha1Hex, +} from "../client"; + +import entriesFixture from "./__fixtures__/entries.json"; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +describe("Nightscout mappers against the golden fixtures", () => { + it("parses SGV entries and maps each to a measurement", async () => { + const parsed = parseSgvEntries(entriesFixture); + const output = { + parsed, + mapped: parsed.map((entry) => ({ + externalId: externalIdFor(entry), + measurement: mapSgvEntryToMeasurement(entry), + })), + }; + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-entries.json", + ); + }); + + it("answers an empty list for payloads that are not an entry array", async () => { + const payloads: Array<[string, unknown]> = [ + ["error object", { status: 401, message: "Unauthorized" }], + ["login page", "Nightscout"], + ["null", null], + ["number", 7], + ["empty array", []], + ]; + const output = payloads.map(([name, payload]) => ({ + name, + parsed: parseSgvEntries(payload), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-non-array-payloads.json", + ); + }); + + it("builds the entries URL", async () => { + const inputs: Array<[string, number, string | undefined]> = [ + ["https://ns.example.org", 288, undefined], + ["https://ns.example.org/", 288, "reader-0123456789abcdef"], + ["https://ns.example.org///", 1, ""], + ["https://example.org/nightscout", 50, "token with spaces&more=1"], + ["http://ns.example.org:1337", 0, "reader-token"], + ]; + const output = inputs.map(([baseUrl, count, token]) => ({ + baseUrl, + count, + token: token ?? null, + url: buildEntriesUrl(baseUrl, count, token), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-entries-url.json", + ); + }); + + it("hashes the API secret", async () => { + const inputs = ["", "example-api-secret", "secret-with-umlaut-äöü"]; + const output = inputs.map((value) => ({ value, sha1: sha1Hex(value) })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sha1.json", + ); + }); +}); diff --git a/src/lib/oura/__tests__/__fixtures__/cardiovascular-age.json b/src/lib/oura/__tests__/__fixtures__/cardiovascular-age.json new file mode 100644 index 000000000..97ee2c448 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/cardiovascular-age.json @@ -0,0 +1,7 @@ +[ + { "id": "cv-age-0001", "day": "2026-03-02", "vascular_age": 38 }, + { "id": "cv-age-0002", "day": "2026-03-03", "vascular_age": null }, + { "id": "cv-age-0003", "day": "2026-03-04", "vascular_age": 0 }, + { "day": "2026-03-05", "vascular_age": 41.555 }, + { "id": "cv-age-0005", "day": "2026-03-06" } +] diff --git a/src/lib/oura/__tests__/__fixtures__/cycle-phases.json b/src/lib/oura/__tests__/__fixtures__/cycle-phases.json new file mode 100644 index 000000000..e77ed018a --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/cycle-phases.json @@ -0,0 +1,65 @@ +[ + { + "scenario": "luteal then follicular on adjacent days marks the follicular day", + "records": [ + { "id": "cycle-0001", "day": "2026-03-01", "phase": "luteal" }, + { "id": "cycle-0002", "day": "2026-03-02", "phase": "luteal" }, + { "id": "cycle-0003", "day": "2026-03-03", "phase": "follicular" }, + { "id": "cycle-0004", "day": "2026-03-04", "phase": "follicular" } + ] + }, + { + "scenario": "a gap between luteal and follicular marks nothing", + "records": [ + { "id": "cycle-0011", "day": "2026-03-10", "phase": "luteal" }, + { "id": "cycle-0012", "day": "2026-03-12", "phase": "follicular" } + ] + }, + { + "scenario": "a literal menstrual phase marks its own day", + "records": [ + { "id": "cycle-0021", "day": "2026-04-01", "phase": "menstrual" }, + { "id": "cycle-0022", "day": "2026-04-02", "phase": "Menstrual" }, + { "id": "cycle-0023", "day": "2026-04-03", "phase": "follicular" } + ] + }, + { + "scenario": "page order is not chronological", + "records": [ + { "id": "cycle-0033", "day": "2026-05-03", "phase": "follicular" }, + { "id": "cycle-0031", "day": "2026-05-01", "phase": "luteal" }, + { "id": "cycle-0032", "day": "2026-05-02", "phase": "LUTEAL" } + ] + }, + { + "scenario": "a null phase does not break the run, an unknown phase does", + "records": [ + { "id": "cycle-0041", "day": "2026-06-01", "phase": "luteal" }, + { "id": "cycle-0042", "day": "2026-06-02", "phase": null }, + { "id": "cycle-0043", "day": "2026-06-03", "phase": "follicular" }, + { "id": "cycle-0044", "day": "2026-06-10", "phase": "luteal" }, + { "id": "cycle-0045", "day": "2026-06-11", "phase": "ovulatory" }, + { "id": "cycle-0046", "day": "2026-06-12", "phase": "follicular" } + ] + }, + { + "scenario": "records without a day are dropped and duplicates collapse", + "records": [ + { "id": "cycle-0051", "day": "", "phase": "menstrual" }, + { "id": "cycle-0052", "day": "2026-07-01", "phase": "menstrual" }, + { "id": "cycle-0053", "day": "2026-07-01", "phase": "menstrual" }, + { "day": "2026-07-02" } + ] + }, + { + "scenario": "month boundary counts as adjacent", + "records": [ + { "id": "cycle-0061", "day": "2026-08-31", "phase": "luteal" }, + { "id": "cycle-0062", "day": "2026-09-01", "phase": "follicular" } + ] + }, + { + "scenario": "an empty series", + "records": [] + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/daily-activity.json b/src/lib/oura/__tests__/__fixtures__/daily-activity.json new file mode 100644 index 000000000..98f99eaa4 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/daily-activity.json @@ -0,0 +1,48 @@ +[ + { + "id": "activity-0001", + "day": "2026-03-02", + "timestamp": "2026-03-02T04:00:00-05:00", + "steps": 8432, + "active_calories": 412.7, + "total_calories": 2380, + "equivalent_walking_distance": 9100 + }, + { + "id": "activity-0002", + "day": "2026-03-03", + "timestamp": "2026-03-03T04:00:00-05:00", + "steps": 0, + "active_calories": 0 + }, + { + "id": "activity-0003", + "day": "2026-03-04", + "steps": null, + "active_calories": 250 + }, + { + "id": "activity-0004", + "day": "2026-03-05", + "steps": -5, + "active_calories": -1 + }, + { + "id": "activity-0005", + "day": "2026-03-06", + "steps": 1234.6, + "active_calories": 98.765 + }, + { + "id": "activity-0006", + "day": "2026-03-07", + "equivalent_walking_distance": 4200 + }, + { + "id": "activity-0007", + "day": "2026-03-08", + "timestamp": "not-a-date", + "steps": 5000, + "active_calories": 300 + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/daily-sleep.json b/src/lib/oura/__tests__/__fixtures__/daily-sleep.json new file mode 100644 index 000000000..31d04c8a8 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/daily-sleep.json @@ -0,0 +1,18 @@ +[ + { + "id": "daily-sleep-0001", + "day": "2026-03-02", + "timestamp": "2026-03-02T00:00:00+00:00", + "score": 84 + }, + { "id": "daily-sleep-0002", "day": "2026-03-03", "score": null }, + { "id": "daily-sleep-0003", "day": "2026-03-04", "score": 0 }, + { + "id": "daily-sleep-0004", + "day": "2026-03-05", + "timestamp": "not-a-date", + "score": 77 + }, + { "id": "daily-sleep-0005", "day": "2026-03-06", "score": 90.555 }, + { "id": "daily-sleep-0006", "day": "2026-03-07" } +] diff --git a/src/lib/oura/__tests__/__fixtures__/daily-spo2.json b/src/lib/oura/__tests__/__fixtures__/daily-spo2.json new file mode 100644 index 000000000..fc6602119 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/daily-spo2.json @@ -0,0 +1,29 @@ +[ + { + "id": "spo2-0001", + "day": "2026-03-02", + "spo2_percentage": { "average": 96.4 } + }, + { + "id": "spo2-0002", + "day": "2026-03-03", + "spo2_percentage": { "average": null } + }, + { "id": "spo2-0003", "day": "2026-03-04", "spo2_percentage": null }, + { "id": "spo2-0004", "day": "2026-03-05" }, + { + "id": "spo2-0005", + "day": "2026-03-06", + "spo2_percentage": { "average": 0 } + }, + { + "id": "spo2-0006", + "day": "2026-03-07", + "spo2_percentage": { "average": 97.125 } + }, + { + "id": "spo2-0007", + "day": "2026-03-08", + "spo2_percentage": {} + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/heartrate.json b/src/lib/oura/__tests__/__fixtures__/heartrate.json new file mode 100644 index 000000000..6fcb38a5c --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/heartrate.json @@ -0,0 +1,17 @@ +[ + { "bpm": 62, "source": "awake", "timestamp": "2026-03-02T08:00:00Z" }, + { "bpm": 19, "source": "rest", "timestamp": "2026-03-02T08:05:00Z" }, + { "bpm": 20, "source": "sleep", "timestamp": "2026-03-02T03:00:00Z" }, + { "bpm": 250, "source": "workout", "timestamp": "2026-03-02T17:30:00Z" }, + { "bpm": 251, "source": "workout", "timestamp": "2026-03-02T17:31:00Z" }, + { "bpm": 71.6, "source": "live", "timestamp": "2026-03-02T09:00:00.000Z" }, + { "bpm": null, "source": "awake", "timestamp": "2026-03-02T09:05:00Z" }, + { "bpm": 66, "source": "awake" }, + { "bpm": 66, "source": "awake", "timestamp": "not-a-date" }, + { + "bpm": 74, + "source": "session", + "timestamp": "2026-03-02T10:15:30+02:00" + }, + { "bpm": 55, "source": null, "timestamp": null } +] diff --git a/src/lib/oura/__tests__/__fixtures__/readiness.json b/src/lib/oura/__tests__/__fixtures__/readiness.json new file mode 100644 index 000000000..376b5d6d0 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/readiness.json @@ -0,0 +1,71 @@ +[ + { + "id": "readiness-0001", + "day": "2026-03-02", + "timestamp": "2026-03-02T00:00:00+00:00", + "score": 82, + "temperature_deviation": -0.21, + "temperature_trend_deviation": 0.05, + "contributors": { + "activity_balance": 78, + "body_temperature": 95, + "hrv_balance": 70, + "previous_day_activity": 84, + "previous_night": 80, + "recovery_index": 90, + "resting_heart_rate": 88, + "sleep_balance": 76 + } + }, + { + "id": "readiness-0002", + "day": "2026-03-03", + "score": 75, + "temperature_deviation": 0.35 + }, + { + "id": "readiness-0003", + "day": "2026-03-04", + "score": null, + "temperature_deviation": 0.1, + "contributors": null + }, + { + "id": "readiness-0004", + "day": "2026-03-05", + "timestamp": "2026-03-05T00:00:00+00:00", + "score": 0, + "temperature_deviation": 0 + }, + { + "id": "readiness-0005", + "day": "2026-03-06", + "timestamp": "2026-03-06T00:00:00+00:00", + "score": null, + "temperature_deviation": null + }, + { + "id": "readiness-0006", + "day": "2026-03-07", + "timestamp": "not-a-date", + "score": 70, + "temperature_deviation": 0.2 + }, + { + "id": "readiness-0007", + "day": "2026-03-08", + "timestamp": "2026-03-08T06:15:00+02:00", + "score": 88.456, + "temperature_deviation": -0.125 + }, + { + "id": "readiness-0008", + "day": "2026-03-29", + "score": 64 + }, + { + "id": "readiness-0009", + "day": "2026-11-01", + "temperature_deviation": 0.48 + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/resilience.json b/src/lib/oura/__tests__/__fixtures__/resilience.json new file mode 100644 index 000000000..bd47d1ef0 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/resilience.json @@ -0,0 +1,11 @@ +[ + { "id": "resilience-0001", "day": "2026-03-02", "level": "limited" }, + { "id": "resilience-0002", "day": "2026-03-03", "level": "adequate" }, + { "id": "resilience-0003", "day": "2026-03-04", "level": "Solid" }, + { "id": "resilience-0004", "day": "2026-03-05", "level": "STRONG" }, + { "id": "resilience-0005", "day": "2026-03-06", "level": "exceptional" }, + { "id": "resilience-0006", "day": "2026-03-07", "level": "unknown_band" }, + { "id": "resilience-0007", "day": "2026-03-08", "level": null }, + { "id": "resilience-0008", "day": "2026-03-09", "level": "" }, + { "day": "2026-03-10", "level": "solid" } +] diff --git a/src/lib/oura/__tests__/__fixtures__/sleep.json b/src/lib/oura/__tests__/__fixtures__/sleep.json new file mode 100644 index 000000000..91af9ec17 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/sleep.json @@ -0,0 +1,135 @@ +[ + { + "id": "sleep-0001", + "day": "2026-03-02", + "type": "long_sleep", + "bedtime_start": "2026-03-01T22:30:00+01:00", + "bedtime_end": "2026-03-02T06:40:00+01:00", + "total_sleep_duration": 26400, + "time_in_bed": 29400, + "efficiency": 91, + "light_sleep_duration": 14400, + "deep_sleep_duration": 5400, + "rem_sleep_duration": 6600, + "awake_time": 3000, + "average_heart_rate": 58.25, + "lowest_heart_rate": 52.4, + "average_hrv": 48.5, + "average_breath": 14.25, + "sleep_phase_5_min": "44222211113333222241" + }, + { + "id": "sleep-0002", + "day": "2026-03-03", + "type": "sleep", + "bedtime_start": "2026-03-02T23:00:00+01:00", + "bedtime_end": "2026-03-03T07:00:00+01:00", + "efficiency": 88, + "light_sleep_duration": 14400, + "deep_sleep_duration": 5400, + "rem_sleep_duration": 6300, + "awake_time": 1800, + "lowest_heart_rate": 0, + "average_hrv": 0, + "average_breath": null, + "sleep_phase_5_min": null + }, + { + "id": "sleep-0003", + "day": "2026-03-02", + "type": "late_nap", + "bedtime_start": "2026-03-02T14:10:00+01:00", + "bedtime_end": "2026-03-02T14:40:00+01:00", + "efficiency": 83.333, + "light_sleep_duration": 1500, + "deep_sleep_duration": 0, + "rem_sleep_duration": null, + "awake_time": 0, + "lowest_heart_rate": 61.6, + "average_hrv": 35.125, + "average_breath": 15 + }, + { + "id": "sleep-0004", + "day": "2026-03-04", + "type": "rest", + "bedtime_start": "2026-03-04T15:00:00+01:00", + "bedtime_end": "2026-03-04T15:45:00+01:00", + "efficiency": 70, + "light_sleep_duration": 2100, + "average_hrv": 40 + }, + { + "id": "sleep-0005", + "day": "2026-03-05", + "type": "deleted", + "bedtime_start": "2026-03-04T23:30:00+01:00", + "bedtime_end": "2026-03-05T06:30:00+01:00", + "efficiency": 90, + "sleep_phase_5_min": "2222" + }, + { + "id": "sleep-0006", + "day": "2026-03-06", + "light_sleep_duration": 12000, + "deep_sleep_duration": 4800, + "rem_sleep_duration": 5700, + "awake_time": 900, + "average_breath": 13.8, + "sleep_phase_5_min": "22113322" + }, + { + "id": "sleep-0007", + "day": "2026-03-07", + "type": "long_sleep", + "bedtime_start": "2026-03-06T23:15:00-05:00", + "bedtime_end": "2026-03-07T00:00:00-05:00", + "efficiency": 85, + "sleep_phase_5_min": "0011522" + }, + { + "id": "sleep-0008", + "day": "2026-03-08", + "type": "long_sleep", + "bedtime_start": "2026-03-07T23:00:00+00:00", + "bedtime_end": "2026-03-08T07:00:00+00:00", + "light_sleep_duration": 13200, + "deep_sleep_duration": 6000, + "rem_sleep_duration": 6000, + "awake_time": 1200, + "sleep_phase_5_min": "0000" + }, + { + "id": "sleep-0009", + "day": "2026-03-09", + "type": "long_sleep", + "bedtime_start": "2026-03-08T23:00:00+00:00", + "bedtime_end": "garbage", + "efficiency": 90, + "sleep_phase_5_min": "2222" + }, + { + "id": "sleep-0010", + "day": "2026-03-10", + "type": "sleep", + "bedtime_start": "not-a-date", + "bedtime_end": "2026-03-10T06:00:00+00:00", + "light_sleep_duration": -60, + "deep_sleep_duration": 3601, + "rem_sleep_duration": 4000, + "awake_time": null, + "average_hrv": -3, + "lowest_heart_rate": 49.5, + "sleep_phase_5_min": "1122" + }, + { + "id": "sleep-0011", + "day": "2026-03-11", + "type": "sleep", + "bedtime_start": "2026-03-10T22:00:00+00:00", + "bedtime_end": "2026-03-11T06:00:00+00:00", + "efficiency": 0, + "light_sleep_duration": 0, + "sleep_phase_5_min": "" + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/vo2-max.json b/src/lib/oura/__tests__/__fixtures__/vo2-max.json new file mode 100644 index 000000000..05411e3ac --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/vo2-max.json @@ -0,0 +1,18 @@ +[ + { + "id": "vo2-0001", + "day": "2026-03-02", + "timestamp": "2026-03-02T00:00:00+00:00", + "vo2_max": 42.3 + }, + { "id": "vo2-0002", "day": "2026-03-03", "vo2_max": null }, + { "id": "vo2-0003", "day": "2026-03-04", "vo2_max": 0 }, + { "id": "vo2-0004", "day": "2026-03-05", "vo2_max": -1 }, + { "id": "vo2-0005", "day": "2026-03-06", "vo2_max": 38.756 }, + { + "id": "vo2-0006", + "day": "2026-03-07", + "timestamp": "not-a-date", + "vo2_max": 40 + } +] diff --git a/src/lib/oura/__tests__/__fixtures__/workouts.json b/src/lib/oura/__tests__/__fixtures__/workouts.json new file mode 100644 index 000000000..0ee80fc55 --- /dev/null +++ b/src/lib/oura/__tests__/__fixtures__/workouts.json @@ -0,0 +1,77 @@ +[ + { + "id": "workout-0001", + "day": "2026-03-02", + "activity": "running", + "calories": 412, + "distance": 7520.5, + "start_datetime": "2026-03-02T07:00:00+01:00", + "end_datetime": "2026-03-02T07:45:30+01:00", + "intensity": "moderate", + "source": "confirmed", + "label": "Morning loop" + }, + { + "id": "workout-0002", + "day": "2026-03-03", + "activity": "underwater_basket_weaving", + "calories": 120.6, + "distance": null, + "start_datetime": "2026-03-03T18:00:00-05:00", + "end_datetime": "2026-03-03T18:30:00-05:00", + "intensity": "easy", + "source": "manual" + }, + { + "id": "workout-0003", + "activity": null, + "calories": null, + "distance": 0, + "start_datetime": "2026-03-04T12:00:00Z", + "end_datetime": "2026-03-04T12:20:00Z" + }, + { + "id": "workout-0004", + "activity": "cycling", + "start_datetime": "2026-03-05T10:00:00Z", + "end_datetime": "2026-03-05T09:59:59Z" + }, + { + "id": "", + "activity": "walking", + "start_datetime": "2026-03-06T10:00:00Z", + "end_datetime": "2026-03-06T10:30:00Z" + }, + { + "id": "workout-0006", + "activity": "walking", + "start_datetime": "not-a-date", + "end_datetime": "2026-03-07T10:30:00Z" + }, + { + "id": "workout-0007", + "activity": "Yoga", + "calories": -5, + "distance": -10, + "start_datetime": "2026-03-08T06:30:00Z", + "end_datetime": "2026-03-08T06:30:00Z", + "intensity": "hard", + "source": "autodetected" + }, + { + "id": "workout-0008", + "activity": "strength_training", + "calories": 0, + "start_datetime": "2026-03-09T17:00:00.250Z", + "end_datetime": "2026-03-09T17:50:00.749Z", + "source": "workout_heart_rate" + }, + { + "id": "workout-0009", + "activity": "hiking", + "calories": 980.4, + "distance": 15200, + "start_datetime": "2026-03-10T09:00:00+01:00", + "end_datetime": "not-a-date" + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-cardiovascular-age.json b/src/lib/oura/__tests__/__golden__/map-cardiovascular-age.json new file mode 100644 index 000000000..778dc4d3b --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-cardiovascular-age.json @@ -0,0 +1,125 @@ +[ + { + "id": "cv-age-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 38, + "unit": "years", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { + "id": "cv-age-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 38, + "unit": "years", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { + "id": "cv-age-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 38, + "unit": "years", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { "id": "cv-age-0002", "day": "2026-03-03", "zone": null, "rows": [] }, + { + "id": "cv-age-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "cv-age-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [] + }, + { "id": "cv-age-0003", "day": "2026-03-04", "zone": null, "rows": [] }, + { + "id": "cv-age-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "cv-age-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [] + }, + { + "id": null, + "day": "2026-03-05", + "zone": null, + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 41.56, + "unit": "years", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { + "id": null, + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 41.56, + "unit": "years", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { + "id": null, + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [ + { + "type": "VASCULAR_AGE", + "value": 41.56, + "unit": "years", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "vascular_age" + } + ] + }, + { "id": "cv-age-0005", "day": "2026-03-06", "zone": null, "rows": [] }, + { + "id": "cv-age-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "cv-age-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-cycle-phases.json b/src/lib/oura/__tests__/__golden__/map-cycle-phases.json new file mode 100644 index 000000000..22caa166b --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-cycle-phases.json @@ -0,0 +1,31 @@ +[ + { + "scenario": "luteal then follicular on adjacent days marks the follicular day", + "periodDays": ["2026-03-03"] + }, + { + "scenario": "a gap between luteal and follicular marks nothing", + "periodDays": [] + }, + { + "scenario": "a literal menstrual phase marks its own day", + "periodDays": ["2026-04-01", "2026-04-02"] + }, + { + "scenario": "page order is not chronological", + "periodDays": ["2026-05-03"] + }, + { + "scenario": "a null phase does not break the run, an unknown phase does", + "periodDays": [] + }, + { + "scenario": "records without a day are dropped and duplicates collapse", + "periodDays": ["2026-07-01"] + }, + { + "scenario": "month boundary counts as adjacent", + "periodDays": ["2026-09-01"] + }, + { "scenario": "an empty series", "periodDays": [] } +] diff --git a/src/lib/oura/__tests__/__golden__/map-daily-activity.json b/src/lib/oura/__tests__/__golden__/map-daily-activity.json new file mode 100644 index 000000000..daeaaaba7 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-daily-activity.json @@ -0,0 +1,272 @@ +[ + { + "id": "activity-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 412.7, + "unit": "kcal", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 412.7, + "unit": "kcal", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 8432, + "unit": "steps", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 412.7, + "unit": "kcal", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0002", + "day": "2026-03-03", + "zone": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T09:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0003", + "day": "2026-03-04", + "zone": null, + "rows": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 250, + "unit": "kcal", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 250, + "unit": "kcal", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [ + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 250, + "unit": "kcal", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { "id": "activity-0004", "day": "2026-03-05", "zone": null, "rows": [] }, + { + "id": "activity-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "activity-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "activity-0005", + "day": "2026-03-06", + "zone": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 1235, + "unit": "steps", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 98.77, + "unit": "kcal", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 1235, + "unit": "steps", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 98.77, + "unit": "kcal", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { + "id": "activity-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 1235, + "unit": "steps", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "steps" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 98.77, + "unit": "kcal", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "active_energy" + } + ] + }, + { "id": "activity-0006", "day": "2026-03-07", "zone": null, "rows": [] }, + { + "id": "activity-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "activity-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [] + }, + { "id": "activity-0007", "day": "2026-03-08", "zone": null, "rows": [] }, + { + "id": "activity-0007", + "day": "2026-03-08", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "activity-0007", + "day": "2026-03-08", + "zone": "America/New_York", + "rows": [] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-daily-sleep.json b/src/lib/oura/__tests__/__golden__/map-daily-sleep.json new file mode 100644 index 000000000..926e6ce9a --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-daily-sleep.json @@ -0,0 +1,167 @@ +[ + { + "id": "daily-sleep-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 84, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 84, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 84, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { "id": "daily-sleep-0002", "day": "2026-03-03", "zone": null, "rows": [] }, + { + "id": "daily-sleep-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "daily-sleep-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "daily-sleep-0003", + "day": "2026-03-04", + "zone": null, + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { "id": "daily-sleep-0004", "day": "2026-03-05", "zone": null, "rows": [] }, + { + "id": "daily-sleep-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "daily-sleep-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "daily-sleep-0005", + "day": "2026-03-06", + "zone": null, + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 90.56, + "unit": "score", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 90.56, + "unit": "score", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "id": "daily-sleep-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_SCORE", + "value": 90.56, + "unit": "score", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { "id": "daily-sleep-0006", "day": "2026-03-07", "zone": null, "rows": [] }, + { + "id": "daily-sleep-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "daily-sleep-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-daily-spo2.json b/src/lib/oura/__tests__/__golden__/map-daily-spo2.json new file mode 100644 index 000000000..6c6ff7f74 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-daily-spo2.json @@ -0,0 +1,151 @@ +[ + { + "id": "spo2-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.4, + "unit": "%", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { + "id": "spo2-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.4, + "unit": "%", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { + "id": "spo2-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 96.4, + "unit": "%", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { "id": "spo2-0002", "day": "2026-03-03", "zone": null, "rows": [] }, + { + "id": "spo2-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "spo2-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [] + }, + { "id": "spo2-0003", "day": "2026-03-04", "zone": null, "rows": [] }, + { + "id": "spo2-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "spo2-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [] + }, + { "id": "spo2-0004", "day": "2026-03-05", "zone": null, "rows": [] }, + { + "id": "spo2-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "spo2-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [] + }, + { "id": "spo2-0005", "day": "2026-03-06", "zone": null, "rows": [] }, + { + "id": "spo2-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "spo2-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "spo2-0006", + "day": "2026-03-07", + "zone": null, + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 97.13, + "unit": "%", + "measuredAt": "2026-03-07T12:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { + "id": "spo2-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 97.13, + "unit": "%", + "measuredAt": "2026-03-07T11:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { + "id": "spo2-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 97.13, + "unit": "%", + "measuredAt": "2026-03-07T17:00:00.000Z", + "fieldTag": "spo2" + } + ] + }, + { "id": "spo2-0007", "day": "2026-03-08", "zone": null, "rows": [] }, + { + "id": "spo2-0007", + "day": "2026-03-08", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "spo2-0007", + "day": "2026-03-08", + "zone": "America/New_York", + "rows": [] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-heartrate.json b/src/lib/oura/__tests__/__golden__/map-heartrate.json new file mode 100644 index 000000000..af2f27680 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-heartrate.json @@ -0,0 +1,68 @@ +[ + { + "timestamp": "2026-03-02T08:00:00Z", + "bpm": 62, + "row": { + "type": "PULSE", + "value": 62, + "unit": "bpm", + "measuredAt": "2026-03-02T08:00:00.000Z", + "fieldTag": "hr", + "externalId": "heartrate:2026-03-02T08:00:00.000Z" + } + }, + { "timestamp": "2026-03-02T08:05:00Z", "bpm": 19, "row": null }, + { + "timestamp": "2026-03-02T03:00:00Z", + "bpm": 20, + "row": { + "type": "PULSE", + "value": 20, + "unit": "bpm", + "measuredAt": "2026-03-02T03:00:00.000Z", + "fieldTag": "hr", + "externalId": "heartrate:2026-03-02T03:00:00.000Z" + } + }, + { + "timestamp": "2026-03-02T17:30:00Z", + "bpm": 250, + "row": { + "type": "PULSE", + "value": 250, + "unit": "bpm", + "measuredAt": "2026-03-02T17:30:00.000Z", + "fieldTag": "hr", + "externalId": "heartrate:2026-03-02T17:30:00.000Z" + } + }, + { "timestamp": "2026-03-02T17:31:00Z", "bpm": 251, "row": null }, + { + "timestamp": "2026-03-02T09:00:00.000Z", + "bpm": 71.6, + "row": { + "type": "PULSE", + "value": 72, + "unit": "bpm", + "measuredAt": "2026-03-02T09:00:00.000Z", + "fieldTag": "hr", + "externalId": "heartrate:2026-03-02T09:00:00.000Z" + } + }, + { "timestamp": "2026-03-02T09:05:00Z", "bpm": null, "row": null }, + { "timestamp": null, "bpm": 66, "row": null }, + { "timestamp": "not-a-date", "bpm": 66, "row": null }, + { + "timestamp": "2026-03-02T10:15:30+02:00", + "bpm": 74, + "row": { + "type": "PULSE", + "value": 74, + "unit": "bpm", + "measuredAt": "2026-03-02T08:15:30.000Z", + "fieldTag": "hr", + "externalId": "heartrate:2026-03-02T08:15:30.000Z" + } + }, + { "timestamp": null, "bpm": 55, "row": null } +] diff --git a/src/lib/oura/__tests__/__golden__/map-readiness.json b/src/lib/oura/__tests__/__golden__/map-readiness.json new file mode 100644 index 000000000..ceecdbd39 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-readiness.json @@ -0,0 +1,406 @@ +[ + { + "id": "readiness-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 82, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.21, + "unit": "celsius", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 82, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.21, + "unit": "celsius", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 82, + "unit": "score", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.21, + "unit": "celsius", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0002", + "day": "2026-03-03", + "zone": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 75, + "unit": "score", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.35, + "unit": "celsius", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 75, + "unit": "score", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.35, + "unit": "celsius", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 75, + "unit": "score", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.35, + "unit": "celsius", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0003", + "day": "2026-03-04", + "zone": null, + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.1, + "unit": "celsius", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.1, + "unit": "celsius", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.1, + "unit": "celsius", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0004", + "day": "2026-03-05", + "zone": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0, + "unit": "celsius", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0, + "unit": "celsius", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0, + "unit": "celsius", + "measuredAt": "2026-03-05T00:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { "id": "readiness-0005", "day": "2026-03-06", "zone": null, "rows": [] }, + { + "id": "readiness-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "readiness-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [] + }, + { "id": "readiness-0006", "day": "2026-03-07", "zone": null, "rows": [] }, + { + "id": "readiness-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "readiness-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "readiness-0007", + "day": "2026-03-08", + "zone": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 88.46, + "unit": "score", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.13, + "unit": "celsius", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0007", + "day": "2026-03-08", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 88.46, + "unit": "score", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.13, + "unit": "celsius", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0007", + "day": "2026-03-08", + "zone": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 88.46, + "unit": "score", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": -0.13, + "unit": "celsius", + "measuredAt": "2026-03-08T04:15:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0008", + "day": "2026-03-29", + "zone": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 64, + "unit": "score", + "measuredAt": "2026-03-29T12:00:00.000Z", + "fieldTag": "recovery" + } + ] + }, + { + "id": "readiness-0008", + "day": "2026-03-29", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 64, + "unit": "score", + "measuredAt": "2026-03-29T10:00:00.000Z", + "fieldTag": "recovery" + } + ] + }, + { + "id": "readiness-0008", + "day": "2026-03-29", + "zone": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 64, + "unit": "score", + "measuredAt": "2026-03-29T16:00:00.000Z", + "fieldTag": "recovery" + } + ] + }, + { + "id": "readiness-0009", + "day": "2026-11-01", + "zone": null, + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.48, + "unit": "celsius", + "measuredAt": "2026-11-01T12:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0009", + "day": "2026-11-01", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.48, + "unit": "celsius", + "measuredAt": "2026-11-01T11:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + }, + { + "id": "readiness-0009", + "day": "2026-11-01", + "zone": "America/New_York", + "rows": [ + { + "type": "BODY_TEMPERATURE_DEVIATION", + "value": 0.48, + "unit": "celsius", + "measuredAt": "2026-11-01T17:00:00.000Z", + "fieldTag": "temp_deviation" + } + ] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-resilience.json b/src/lib/oura/__tests__/__golden__/map-resilience.json new file mode 100644 index 000000000..dd7879350 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-resilience.json @@ -0,0 +1,293 @@ +[ + { + "id": "resilience-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 1, + "unit": "level", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 1, + "unit": "level", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 1, + "unit": "level", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0002", + "day": "2026-03-03", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 2, + "unit": "level", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 2, + "unit": "level", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 2, + "unit": "level", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0003", + "day": "2026-03-04", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0004", + "day": "2026-03-05", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 4, + "unit": "level", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 4, + "unit": "level", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 4, + "unit": "level", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0005", + "day": "2026-03-06", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 5, + "unit": "level", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 5, + "unit": "level", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": "resilience-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 5, + "unit": "level", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { "id": "resilience-0006", "day": "2026-03-07", "zone": null, "rows": [] }, + { + "id": "resilience-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "resilience-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [] + }, + { "id": "resilience-0007", "day": "2026-03-08", "zone": null, "rows": [] }, + { + "id": "resilience-0007", + "day": "2026-03-08", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "resilience-0007", + "day": "2026-03-08", + "zone": "America/New_York", + "rows": [] + }, + { "id": "resilience-0008", "day": "2026-03-09", "zone": null, "rows": [] }, + { + "id": "resilience-0008", + "day": "2026-03-09", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "resilience-0008", + "day": "2026-03-09", + "zone": "America/New_York", + "rows": [] + }, + { + "id": null, + "day": "2026-03-10", + "zone": null, + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-10T12:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": null, + "day": "2026-03-10", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-10T11:00:00.000Z", + "fieldTag": "resilience" + } + ] + }, + { + "id": null, + "day": "2026-03-10", + "zone": "America/New_York", + "rows": [ + { + "type": "RESILIENCE", + "value": 3, + "unit": "level", + "measuredAt": "2026-03-10T16:00:00.000Z", + "fieldTag": "resilience" + } + ] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-sleep-retired.json b/src/lib/oura/__tests__/__golden__/map-sleep-retired.json new file mode 100644 index 000000000..49c23ab16 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-sleep-retired.json @@ -0,0 +1,11 @@ +[ + { "type": "long_sleep", "retired": false }, + { "type": "sleep", "retired": false }, + { "type": "late_nap", "retired": false }, + { "type": "rest", "retired": true }, + { "type": "deleted", "retired": true }, + { "type": "REST", "retired": false }, + { "type": "", "retired": false }, + { "type": null, "retired": false }, + { "type": null, "retired": false } +] diff --git a/src/lib/oura/__tests__/__golden__/map-sleep.json b/src/lib/oura/__tests__/__golden__/map-sleep.json new file mode 100644 index 000000000..7d4cf8747 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-sleep.json @@ -0,0 +1,1298 @@ +[ + { + "id": "sleep-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-01T21:40:00.000Z", + "fieldTag": "seg:2026-03-01T21:30:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:30:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:00:00.000Z", + "fieldTag": "seg:2026-03-01T21:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:20:00.000Z", + "fieldTag": "seg:2026-03-01T22:00:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:40:00.000Z", + "fieldTag": "seg:2026-03-01T22:20:00.000Z", + "sleepStage": "REM", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:20:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T23:00:00.000Z", + "fieldTag": "seg:2026-03-01T22:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:05:00.000Z", + "fieldTag": "seg:2026-03-01T23:00:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:10:00.000Z", + "fieldTag": "seg:2026-03-01T23:05:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:05:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 91, + "unit": "%", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0001:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 48.5, + "unit": "ms", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0001:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 52, + "unit": "bpm", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0001:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0001:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-01T21:40:00.000Z", + "fieldTag": "seg:2026-03-01T21:30:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:30:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:00:00.000Z", + "fieldTag": "seg:2026-03-01T21:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:20:00.000Z", + "fieldTag": "seg:2026-03-01T22:00:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:40:00.000Z", + "fieldTag": "seg:2026-03-01T22:20:00.000Z", + "sleepStage": "REM", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:20:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T23:00:00.000Z", + "fieldTag": "seg:2026-03-01T22:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:05:00.000Z", + "fieldTag": "seg:2026-03-01T23:00:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:10:00.000Z", + "fieldTag": "seg:2026-03-01T23:05:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:05:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 91, + "unit": "%", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0001:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 48.5, + "unit": "ms", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0001:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 52, + "unit": "bpm", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0001:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0001:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-01T21:40:00.000Z", + "fieldTag": "seg:2026-03-01T21:30:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:30:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:00:00.000Z", + "fieldTag": "seg:2026-03-01T21:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T21:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:20:00.000Z", + "fieldTag": "seg:2026-03-01T22:00:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T22:40:00.000Z", + "fieldTag": "seg:2026-03-01T22:20:00.000Z", + "sleepStage": "REM", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:20:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-01T23:00:00.000Z", + "fieldTag": "seg:2026-03-01T22:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T22:40:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:05:00.000Z", + "fieldTag": "seg:2026-03-01T23:00:00.000Z", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:00:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 5, + "unit": "minutes", + "measuredAt": "2026-03-01T23:10:00.000Z", + "fieldTag": "seg:2026-03-01T23:05:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0001:seg:2026-03-01T23:05:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 91, + "unit": "%", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0001:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 48.5, + "unit": "ms", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0001:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 52, + "unit": "bpm", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0001:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T05:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0001:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0002", + "day": "2026-03-03", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0002:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0002:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0002:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0002:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 88, + "unit": "%", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0002:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0002:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0002:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0002:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0002:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 88, + "unit": "%", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0002:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0002:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0002:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0002:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0002:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 88, + "unit": "%", + "measuredAt": "2026-03-03T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0002:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0003", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 25, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0003:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0003:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0003:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 83.33, + "unit": "%", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0003:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 35.13, + "unit": "ms", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0003:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 62, + "unit": "bpm", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0003:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 15, + "unit": "breaths/min", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0003:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0003", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 25, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0003:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0003:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0003:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 83.33, + "unit": "%", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0003:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 35.13, + "unit": "ms", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0003:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 62, + "unit": "bpm", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0003:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 15, + "unit": "breaths/min", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0003:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0003", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 25, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0003:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0003:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0003:sleep_awake" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 83.33, + "unit": "%", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0003:sleep_eff" + }, + { + "type": "HRV_RMSSD", + "value": 35.13, + "unit": "ms", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "hrv_rmssd", + "externalId": "sleep:sleep-0003:hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 62, + "unit": "bpm", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0003:rhr" + }, + { + "type": "RESPIRATORY_RATE", + "value": 15, + "unit": "breaths/min", + "measuredAt": "2026-03-02T13:40:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0003:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0004", + "day": "2026-03-04", + "zone": null, + "rows": [], + "retired": true + }, + { + "id": "sleep-0004", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [], + "retired": true + }, + { + "id": "sleep-0004", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [], + "retired": true + }, + { + "id": "sleep-0005", + "day": "2026-03-05", + "zone": null, + "rows": [], + "retired": true + }, + { + "id": "sleep-0005", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [], + "retired": true + }, + { + "id": "sleep-0005", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [], + "retired": true + }, + { + "id": "sleep-0006", + "day": "2026-03-06", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 200, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0006:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0006:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 95, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0006:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0006:sleep_awake" + }, + { + "type": "RESPIRATORY_RATE", + "value": 13.8, + "unit": "breaths/min", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0006:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0006", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 200, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0006:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0006:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 95, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0006:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0006:sleep_awake" + }, + { + "type": "RESPIRATORY_RATE", + "value": 13.8, + "unit": "breaths/min", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0006:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0006", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 200, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0006:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0006:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 95, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0006:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0006:sleep_awake" + }, + { + "type": "RESPIRATORY_RATE", + "value": 13.8, + "unit": "breaths/min", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "resp_rate", + "externalId": "sleep:sleep-0006:resp_rate" + } + ], + "retired": false + }, + { + "id": "sleep-0007", + "day": "2026-03-07", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:35:00.000Z", + "fieldTag": "seg:2026-03-07T04:25:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:25:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:50:00.000Z", + "fieldTag": "seg:2026-03-07T04:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:40:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 85, + "unit": "%", + "measuredAt": "2026-03-07T05:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0007:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0007", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:35:00.000Z", + "fieldTag": "seg:2026-03-07T04:25:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:25:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:50:00.000Z", + "fieldTag": "seg:2026-03-07T04:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:40:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 85, + "unit": "%", + "measuredAt": "2026-03-07T05:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0007:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0007", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:35:00.000Z", + "fieldTag": "seg:2026-03-07T04:25:00.000Z", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:25:00.000Z" + }, + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-07T04:50:00.000Z", + "fieldTag": "seg:2026-03-07T04:40:00.000Z", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0007:seg:2026-03-07T04:40:00.000Z" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 85, + "unit": "%", + "measuredAt": "2026-03-07T05:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0007:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0008", + "day": "2026-03-08", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 220, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0008:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0008:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0008:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0008:sleep_awake" + } + ], + "retired": false + }, + { + "id": "sleep-0008", + "day": "2026-03-08", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 220, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0008:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0008:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0008:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0008:sleep_awake" + } + ], + "retired": false + }, + { + "id": "sleep-0008", + "day": "2026-03-08", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 220, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0008:sleep_core" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0008:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0008:sleep_rem" + }, + { + "type": "SLEEP_DURATION", + "value": 20, + "unit": "minutes", + "measuredAt": "2026-03-08T07:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "externalId": "sleep:sleep-0008:sleep_awake" + } + ], + "retired": false + }, + { + "id": "sleep-0009", + "day": "2026-03-09", + "zone": null, + "rows": [], + "retired": false + }, + { + "id": "sleep-0009", + "day": "2026-03-09", + "zone": "Europe/Berlin", + "rows": [], + "retired": false + }, + { + "id": "sleep-0009", + "day": "2026-03-09", + "zone": "America/New_York", + "rows": [], + "retired": false + }, + { + "id": "sleep-0010", + "day": "2026-03-10", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 60.02, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0010:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 66.67, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0010:sleep_rem" + }, + { + "type": "RESTING_HEART_RATE", + "value": 50, + "unit": "bpm", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0010:rhr" + } + ], + "retired": false + }, + { + "id": "sleep-0010", + "day": "2026-03-10", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 60.02, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0010:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 66.67, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0010:sleep_rem" + }, + { + "type": "RESTING_HEART_RATE", + "value": 50, + "unit": "bpm", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0010:rhr" + } + ], + "retired": false + }, + { + "id": "sleep-0010", + "day": "2026-03-10", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 60.02, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "externalId": "sleep:sleep-0010:sleep_deep" + }, + { + "type": "SLEEP_DURATION", + "value": 66.67, + "unit": "minutes", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "externalId": "sleep:sleep-0010:sleep_rem" + }, + { + "type": "RESTING_HEART_RATE", + "value": 50, + "unit": "bpm", + "measuredAt": "2026-03-10T06:00:00.000Z", + "fieldTag": "rhr", + "externalId": "sleep:sleep-0010:rhr" + } + ], + "retired": false + }, + { + "id": "sleep-0011", + "day": "2026-03-11", + "zone": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0011:sleep_core" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0011:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0011", + "day": "2026-03-11", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0011:sleep_core" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0011:sleep_eff" + } + ], + "retired": false + }, + { + "id": "sleep-0011", + "day": "2026-03-11", + "zone": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "externalId": "sleep:sleep-0011:sleep_core" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-11T06:00:00.000Z", + "fieldTag": "sleep_eff", + "externalId": "sleep:sleep-0011:sleep_eff" + } + ], + "retired": false + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-vo2-max.json b/src/lib/oura/__tests__/__golden__/map-vo2-max.json new file mode 100644 index 000000000..6a6e53dd8 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-vo2-max.json @@ -0,0 +1,138 @@ +[ + { + "id": "vo2-0001", + "day": "2026-03-02", + "zone": null, + "rows": [ + { + "type": "VO2_MAX", + "value": 42.3, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { + "id": "vo2-0001", + "day": "2026-03-02", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "VO2_MAX", + "value": 42.3, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { + "id": "vo2-0001", + "day": "2026-03-02", + "zone": "America/New_York", + "rows": [ + { + "type": "VO2_MAX", + "value": 42.3, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-02T00:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { "id": "vo2-0002", "day": "2026-03-03", "zone": null, "rows": [] }, + { + "id": "vo2-0002", + "day": "2026-03-03", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "vo2-0002", + "day": "2026-03-03", + "zone": "America/New_York", + "rows": [] + }, + { "id": "vo2-0003", "day": "2026-03-04", "zone": null, "rows": [] }, + { + "id": "vo2-0003", + "day": "2026-03-04", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "vo2-0003", + "day": "2026-03-04", + "zone": "America/New_York", + "rows": [] + }, + { "id": "vo2-0004", "day": "2026-03-05", "zone": null, "rows": [] }, + { + "id": "vo2-0004", + "day": "2026-03-05", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "vo2-0004", + "day": "2026-03-05", + "zone": "America/New_York", + "rows": [] + }, + { + "id": "vo2-0005", + "day": "2026-03-06", + "zone": null, + "rows": [ + { + "type": "VO2_MAX", + "value": 38.76, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { + "id": "vo2-0005", + "day": "2026-03-06", + "zone": "Europe/Berlin", + "rows": [ + { + "type": "VO2_MAX", + "value": 38.76, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { + "id": "vo2-0005", + "day": "2026-03-06", + "zone": "America/New_York", + "rows": [ + { + "type": "VO2_MAX", + "value": 38.76, + "unit": "mL/(kg·min)", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "vo2_max" + } + ] + }, + { "id": "vo2-0006", "day": "2026-03-07", "zone": null, "rows": [] }, + { + "id": "vo2-0006", + "day": "2026-03-07", + "zone": "Europe/Berlin", + "rows": [] + }, + { + "id": "vo2-0006", + "day": "2026-03-07", + "zone": "America/New_York", + "rows": [] + } +] diff --git a/src/lib/oura/__tests__/__golden__/map-workouts.json b/src/lib/oura/__tests__/__golden__/map-workouts.json new file mode 100644 index 000000000..3c1fc6806 --- /dev/null +++ b/src/lib/oura/__tests__/__golden__/map-workouts.json @@ -0,0 +1,86 @@ +[ + { + "id": "workout-0001", + "row": { + "externalId": "workout-0001", + "sportType": "running", + "startedAt": "2026-03-02T06:00:00.000Z", + "endedAt": "2026-03-02T06:45:30.000Z", + "durationSec": 2730, + "totalEnergyKcal": 412, + "totalDistanceM": 7520.5, + "metadata": { + "ouraActivity": "running", + "ouraIntensity": "moderate", + "ouraSource": "confirmed" + } + } + }, + { + "id": "workout-0002", + "row": { + "externalId": "workout-0002", + "sportType": "other", + "startedAt": "2026-03-03T23:00:00.000Z", + "endedAt": "2026-03-03T23:30:00.000Z", + "durationSec": 1800, + "totalEnergyKcal": 121, + "totalDistanceM": null, + "metadata": { + "ouraActivity": "underwater_basket_weaving", + "ouraIntensity": "easy", + "ouraSource": "manual" + } + } + }, + { + "id": "workout-0003", + "row": { + "externalId": "workout-0003", + "sportType": "other", + "startedAt": "2026-03-04T12:00:00.000Z", + "endedAt": "2026-03-04T12:20:00.000Z", + "durationSec": 1200, + "totalEnergyKcal": null, + "totalDistanceM": 0, + "metadata": {} + } + }, + { "id": "workout-0004", "row": null }, + { "id": "", "row": null }, + { "id": "workout-0006", "row": null }, + { + "id": "workout-0007", + "row": { + "externalId": "workout-0007", + "sportType": "yoga", + "startedAt": "2026-03-08T06:30:00.000Z", + "endedAt": "2026-03-08T06:30:00.000Z", + "durationSec": 0, + "totalEnergyKcal": null, + "totalDistanceM": null, + "metadata": { + "ouraActivity": "Yoga", + "ouraIntensity": "hard", + "ouraSource": "autodetected" + } + } + }, + { + "id": "workout-0008", + "row": { + "externalId": "workout-0008", + "sportType": "strength", + "startedAt": "2026-03-09T17:00:00.250Z", + "endedAt": "2026-03-09T17:50:00.749Z", + "durationSec": 3000, + "totalEnergyKcal": 0, + "totalDistanceM": null, + "metadata": { + "ouraActivity": "strength_training", + "ouraSource": "workout_heart_rate" + } + } + }, + { "id": "workout-0009", "row": null } +] diff --git a/src/lib/oura/__tests__/map-fixtures.test.ts b/src/lib/oura/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..aa808dbae --- /dev/null +++ b/src/lib/oura/__tests__/map-fixtures.test.ts @@ -0,0 +1,199 @@ +/** + * What each Oura mapper makes of a fixed set of payloads, pinned in + * `__golden__/map-.json`. + * + * The fixtures under `__fixtures__/` are synthetic records in the documented + * Oura v2 wire shape, chosen to walk every branch the mappers have: a missing + * or zero score, a rejected or deleted sleep period, a nap beside the main + * sleep, a hypnogram with unknown digits, a record without its instant, an + * unparseable timestamp, an out-of-band heart rate, an unknown activity. Every + * mapper that takes a zone runs without one and under two explicit zones, so + * the date-only anchors are recorded where they land. + * + * The golden was written before the mappers moved out of `client.ts`, so a + * green run is the proof that the move changed nothing. + */ +import { describe, expect, it } from "vitest"; +import { format } from "prettier"; + +import { + derivePeriodDaysFromCyclePhases, + isRetiredOuraSleepPeriod, + mapCardiovascularAge, + mapDailyActivity, + mapDailySleep, + mapDailySpo2, + mapHeartRateSample, + mapReadiness, + mapResilience, + mapSleep, + mapVo2Max, + type MappedMeasurement, + type OuraCardiovascularAge, + type OuraCyclePhase, + type OuraDailyActivity, + type OuraDailySleep, + type OuraDailySpo2, + type OuraHeartRateSample, + type OuraReadiness, + type OuraResilience, + type OuraSleep, + type OuraVo2Max, + type OuraWorkout, +} from "../client"; +import { mapOuraWorkout } from "../sync-workouts"; + +import cardiovascularAgeFixture from "./__fixtures__/cardiovascular-age.json"; +import cyclePhasesFixture from "./__fixtures__/cycle-phases.json"; +import dailyActivityFixture from "./__fixtures__/daily-activity.json"; +import dailySleepFixture from "./__fixtures__/daily-sleep.json"; +import dailySpo2Fixture from "./__fixtures__/daily-spo2.json"; +import heartRateFixture from "./__fixtures__/heartrate.json"; +import readinessFixture from "./__fixtures__/readiness.json"; +import resilienceFixture from "./__fixtures__/resilience.json"; +import sleepFixture from "./__fixtures__/sleep.json"; +import vo2MaxFixture from "./__fixtures__/vo2-max.json"; +import workoutsFixture from "./__fixtures__/workouts.json"; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +/** No zone (the mapper's own default), then two zones on either side of UTC. */ +const ZONES = [undefined, "Europe/Berlin", "America/New_York"] as const; + +/** Runs a zone-aware mapper over every record under every zone. */ +function acrossZones( + records: readonly T[], + map: (record: T, tz?: string) => MappedMeasurement[], +) { + return records.flatMap((record) => + ZONES.map((zone) => ({ + id: record.id ?? null, + day: record.day, + zone: zone ?? null, + rows: map(record, zone), + })), + ); +} + +describe("Oura mappers against the golden fixtures", () => { + it("maps daily readiness", async () => { + const records: OuraReadiness[] = readinessFixture; + await expect( + await golden(acrossZones(records, mapReadiness)), + ).toMatchFileSnapshot("./__golden__/map-readiness.json"); + }); + + it("maps sleep periods and flags retired ones", async () => { + const records: OuraSleep[] = sleepFixture; + const output = acrossZones(records, mapSleep).map((entry) => ({ + ...entry, + retired: isRetiredOuraSleepPeriod( + records.find((r) => r.id === entry.id) ?? {}, + ), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sleep.json", + ); + }); + + it("classifies every sleep type as retired or kept", async () => { + const types = [ + "long_sleep", + "sleep", + "late_nap", + "rest", + "deleted", + "REST", + "", + null, + undefined, + ]; + const output = types.map((type) => ({ + type: type ?? null, + retired: isRetiredOuraSleepPeriod({ type }), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sleep-retired.json", + ); + }); + + it("maps the daily sleep score", async () => { + const records: OuraDailySleep[] = dailySleepFixture; + await expect( + await golden(acrossZones(records, mapDailySleep)), + ).toMatchFileSnapshot("./__golden__/map-daily-sleep.json"); + }); + + it("maps daily SpO2", async () => { + const records: OuraDailySpo2[] = dailySpo2Fixture; + await expect( + await golden(acrossZones(records, mapDailySpo2)), + ).toMatchFileSnapshot("./__golden__/map-daily-spo2.json"); + }); + + it("maps daily activity", async () => { + const records: OuraDailyActivity[] = dailyActivityFixture; + await expect( + await golden(acrossZones(records, mapDailyActivity)), + ).toMatchFileSnapshot("./__golden__/map-daily-activity.json"); + }); + + it("maps VO2 max", async () => { + const records: OuraVo2Max[] = vo2MaxFixture; + await expect( + await golden(acrossZones(records, mapVo2Max)), + ).toMatchFileSnapshot("./__golden__/map-vo2-max.json"); + }); + + it("maps cardiovascular age", async () => { + const records: OuraCardiovascularAge[] = cardiovascularAgeFixture; + await expect( + await golden(acrossZones(records, mapCardiovascularAge)), + ).toMatchFileSnapshot("./__golden__/map-cardiovascular-age.json"); + }); + + it("maps the resilience level", async () => { + const records: OuraResilience[] = resilienceFixture; + await expect( + await golden(acrossZones(records, mapResilience)), + ).toMatchFileSnapshot("./__golden__/map-resilience.json"); + }); + + it("maps heart-rate samples", async () => { + const samples: OuraHeartRateSample[] = heartRateFixture; + const output = samples.map((sample) => ({ + timestamp: sample.timestamp ?? null, + bpm: sample.bpm ?? null, + row: mapHeartRateSample(sample), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-heartrate.json", + ); + }); + + it("derives period days from cycle phases", async () => { + const scenarios: Array<{ scenario: string; records: OuraCyclePhase[] }> = + cyclePhasesFixture; + const output = scenarios.map(({ scenario, records }) => ({ + scenario, + periodDays: derivePeriodDaysFromCyclePhases(records), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-cycle-phases.json", + ); + }); + + it("maps workouts", async () => { + const records: OuraWorkout[] = workoutsFixture; + const output = records.map((record) => ({ + id: record.id, + row: mapOuraWorkout(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-workouts.json", + ); + }); +}); diff --git a/src/lib/polar/__tests__/__fixtures__/activity.json b/src/lib/polar/__tests__/__fixtures__/activity.json new file mode 100644 index 000000000..a636a05a4 --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/activity.json @@ -0,0 +1,35 @@ +[ + { + "start_time": "2026-03-02T00:00:00", + "active_calories": 512.345, + "steps": 9876, + "calories": 2450, + "distance_from_steps": 7123.456 + }, + { + "start_time": "2026-03-03T00:00:00.000", + "active_calories": 0, + "steps": 0, + "distance_from_steps": 0 + }, + { + "start_time": "2026-03-04", + "steps": 4321.6, + "active_calories": null, + "distance_from_steps": null + }, + { + "start_time": "2026-03-05T00:00:00", + "steps": -5, + "active_calories": -10, + "distance_from_steps": -1 + }, + { + "active_calories": 300, + "steps": 5000 + }, + { + "start_time": "garbled-day", + "steps": 1000 + } +] diff --git a/src/lib/polar/__tests__/__fixtures__/cardio-load.json b/src/lib/polar/__tests__/__fixtures__/cardio-load.json new file mode 100644 index 000000000..581690768 --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/cardio-load.json @@ -0,0 +1,14 @@ +[ + { + "date": "2026-03-02", + "cardio_load": 87.654, + "strain": 1.2, + "tolerance": 0.9, + "cardio_load_ratio": 1.33 + }, + { "date": "2026-03-03", "cardio_load": 0 }, + { "date": "2026-03-04", "cardio_load": null, "strain": 0.4 }, + { "date": "2026-03-05", "cardio_load": -3 }, + { "date": "2026-03-06" }, + { "date": "not-a-date", "cardio_load": 50 } +] diff --git a/src/lib/polar/__tests__/__fixtures__/exercises.json b/src/lib/polar/__tests__/__fixtures__/exercises.json new file mode 100644 index 000000000..7fad9127c --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/exercises.json @@ -0,0 +1,53 @@ +[ + { + "id": "exercise-0001", + "start_time": "2026-03-02T07:00:00", + "start_time_utc_offset": 60, + "duration": "PT1H2M30S", + "sport": "RUNNING", + "detailed_sport_info": "TRAIL_RUNNING", + "calories": 645.5, + "distance": 10250.4, + "heart_rate": { "average": 148.6, "maximum": 176 }, + "training_load": 112.3, + "device": "Polar Vantage V3", + "device_id": "device-0001" + }, + { + "id": 4200017, + "start_time": "2026-03-03T18:30:00-05:00", + "start_time_utc_offset": -300, + "duration": "PT45M", + "sport": "CYCLING", + "calories": 0, + "distance": 0, + "heart_rate": { "average": 0, "maximum": null } + }, + { + "id": "exercise-0003", + "start_time": "2026-03-04T06:15:00Z", + "duration": "P1DT2H", + "detailed_sport_info": "SOMETHING_POLAR_INVENTED", + "heart_rate": null + }, + { + "id": "exercise-0004", + "start_time": "2026-03-05T12:00:00", + "duration": "not-a-duration", + "sport": "STRENGTH_TRAINING", + "calories": -5, + "distance": null, + "training_load": null + }, + { + "id": "exercise-0005", + "start_time": "2026-03-06T09:00:00", + "start_time_utc_offset": 330, + "duration": "PT30M15.6S", + "sport": "OTHER" + }, + { "id": null, "start_time": "2026-03-07T08:00:00", "duration": "PT20M" }, + { "id": " ", "start_time": "2026-03-07T08:00:00", "duration": "PT20M" }, + { "id": "exercise-0008", "start_time": "garbled", "duration": "PT20M" }, + { "id": "exercise-0009", "duration": "PT20M", "sport": "WALKING" } +] diff --git a/src/lib/polar/__tests__/__fixtures__/nightly-recharge.json b/src/lib/polar/__tests__/__fixtures__/nightly-recharge.json new file mode 100644 index 000000000..476531b95 --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/nightly-recharge.json @@ -0,0 +1,45 @@ +[ + { + "date": "2026-03-02", + "nightly_recharge_status": 4, + "ans_charge": 3.456, + "ans_charge_status": 3, + "heart_rate_avg": 55.6, + "heart_rate_variability_avg": 42.123, + "breathing_rate_avg": 14.25 + }, + { + "date": "2026-03-03", + "nightly_recharge_status": 1, + "ans_charge": -7.2, + "heart_rate_avg": 0, + "heart_rate_variability_avg": 0, + "breathing_rate_avg": 0 + }, + { + "date": "2026-03-04", + "nightly_recharge_status": 6, + "ans_charge": 0 + }, + { + "date": "2026-03-05", + "nightly_recharge_status": 9, + "heart_rate_avg": 61 + }, + { + "date": "2026-03-06", + "nightly_recharge_status": 0, + "ans_charge": null, + "heart_rate_avg": null, + "heart_rate_variability_avg": 38, + "breathing_rate_avg": null + }, + { + "date": "2026-03-07" + }, + { + "date": "not-a-date", + "nightly_recharge_status": 3, + "heart_rate_avg": 58 + } +] diff --git a/src/lib/polar/__tests__/__fixtures__/sleep.json b/src/lib/polar/__tests__/__fixtures__/sleep.json new file mode 100644 index 000000000..546e92e89 --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/sleep.json @@ -0,0 +1,63 @@ +[ + { + "date": "2026-03-02", + "sleep_start_time": "2026-03-01T23:10:00+01:00", + "sleep_end_time": "2026-03-02T07:05:00+01:00", + "light_sleep": 14400, + "deep_sleep": 5400, + "rem_sleep": 6300, + "total_interruption_duration": 2400, + "sleep_charge": 3, + "sleep_score": 82.5, + "heart_rate_samples": { "00:00": 58, "00:05": 56 } + }, + { + "date": "2026-03-03", + "sleep_start_time": "2026-03-02T22:30:00-05:00", + "sleep_end_time": "2026-03-03T06:00:00-05:00", + "light_sleep": 16200, + "deep_sleep": 0, + "rem_sleep": 7200, + "total_interruption_duration": 0 + }, + { + "date": "2026-03-04", + "light_sleep": 15000, + "deep_sleep": 4800, + "rem_sleep": 6000, + "total_interruption_duration": 1234, + "sleep_score": 76 + }, + { + "date": "2026-03-05", + "sleep_start_time": "2026-03-05T06:00:00Z", + "sleep_end_time": "2026-03-05T06:00:00Z", + "light_sleep": 0, + "deep_sleep": null, + "rem_sleep": 3000, + "sleep_score": null + }, + { + "date": "2026-03-06", + "sleep_start_time": "garbled", + "sleep_end_time": "2026-03-06T06:30:00Z", + "light_sleep": 13500, + "rem_sleep": 5400, + "total_interruption_duration": 900, + "sleep_score": 70 + }, + { + "date": "2026-03-07", + "sleep_start_time": "2026-03-06T23:00:00Z", + "sleep_end_time": "2026-03-07T06:45:00Z", + "light_sleep": 12345.6, + "deep_sleep": 4567, + "rem_sleep": 5678, + "total_interruption_duration": null, + "sleep_score": 0 + }, + { + "date": "not-a-date", + "light_sleep": 14000 + } +] diff --git a/src/lib/polar/__tests__/__fixtures__/spo2.json b/src/lib/polar/__tests__/__fixtures__/spo2.json new file mode 100644 index 000000000..a50b51ec4 --- /dev/null +++ b/src/lib/polar/__tests__/__fixtures__/spo2.json @@ -0,0 +1,24 @@ +[ + { + "source_device_id": "device-0001", + "test_time": 1772438400, + "blood_oxygen_percent": 97.456 + }, + { "test_time": 1772524800, "blood_oxygen_percent": 100 }, + { + "source_device_id": "device-0001", + "test_time": 1772611200, + "blood_oxygen_percent": 0 + }, + { + "source_device_id": "device-0001", + "test_time": 1772697600, + "blood_oxygen_percent": 100.5 + }, + { + "source_device_id": "device-0002", + "test_time": 1772784000, + "blood_oxygen_percent": null + }, + { "source_device_id": "device-0002", "blood_oxygen_percent": 95 } +] diff --git a/src/lib/polar/__tests__/__golden__/map-activity.json b/src/lib/polar/__tests__/__golden__/map-activity.json new file mode 100644 index 000000000..74c5117f6 --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-activity.json @@ -0,0 +1,260 @@ +[ + { + "record": "2026-03-02T00:00:00", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 9876, + "unit": "steps", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-02" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.35, + "unit": "kcal", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-02" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 7123.46, + "unit": "meters", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-02" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 9876, + "unit": "steps", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-02" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.35, + "unit": "kcal", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-02" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 7123.46, + "unit": "meters", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-02" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 9876, + "unit": "steps", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-02" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 512.35, + "unit": "kcal", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-02" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 7123.46, + "unit": "meters", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-02" + } + ] + } + ] + }, + { + "record": "2026-03-03T00:00:00.000", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-03" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-03" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 0, + "unit": "meters", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-03" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-03" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-03" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 0, + "unit": "meters", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-03" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 0, + "unit": "steps", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-03" + }, + { + "type": "ACTIVE_ENERGY_BURNED", + "value": 0, + "unit": "kcal", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "active_energy", + "day": "2026-03-03" + }, + { + "type": "WALKING_RUNNING_DISTANCE", + "value": 0, + "unit": "meters", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "distance", + "day": "2026-03-03" + } + ] + } + ] + }, + { + "record": "2026-03-04", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 4322, + "unit": "steps", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-04" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 4322, + "unit": "steps", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-04" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "ACTIVITY_STEPS", + "value": 4322, + "unit": "steps", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "steps", + "day": "2026-03-04" + } + ] + } + ] + }, + { + "record": "2026-03-05T00:00:00", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "(no start_time)", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "garbled-day", + "byZone": [ + { "tz": null, "rows": [] }, + { + "tz": "Europe/Berlin", + "rows": { "threw": "RangeError: Invalid time value" } + }, + { + "tz": "America/New_York", + "rows": { "threw": "RangeError: Invalid time value" } + } + ] + } +] diff --git a/src/lib/polar/__tests__/__golden__/map-cardio-load.json b/src/lib/polar/__tests__/__golden__/map-cardio-load.json new file mode 100644 index 000000000..5de9a25db --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-cardio-load.json @@ -0,0 +1,128 @@ +[ + { + "record": "2026-03-02", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 87.65, + "unit": "score", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-02" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 87.65, + "unit": "score", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-02" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 87.65, + "unit": "score", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-02" + } + ] + } + ] + }, + { + "record": "2026-03-03", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-03" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-03" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "CARDIO_LOAD", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "cardio_load", + "day": "2026-03-03" + } + ] + } + ] + }, + { + "record": "2026-03-04", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "2026-03-05", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "2026-03-06", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "not-a-date", + "byZone": [ + { "tz": null, "rows": [] }, + { + "tz": "Europe/Berlin", + "rows": { "threw": "RangeError: Invalid time value" } + }, + { + "tz": "America/New_York", + "rows": { "threw": "RangeError: Invalid time value" } + } + ] + } +] diff --git a/src/lib/polar/__tests__/__golden__/map-exercises.json b/src/lib/polar/__tests__/__golden__/map-exercises.json new file mode 100644 index 000000000..b6622c47e --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-exercises.json @@ -0,0 +1,93 @@ +[ + { + "id": "exercise-0001", + "row": { + "externalId": "exercise-0001", + "sportType": "running", + "startedAt": "2026-03-02T06:00:00.000Z", + "endedAt": "2026-03-02T07:02:30.000Z", + "durationSec": 3750, + "totalEnergyKcal": 645.5, + "totalDistanceM": 10250.4, + "avgHeartRate": 149, + "maxHeartRate": 176, + "elevationM": null, + "metadata": { + "polarSport": "RUNNING", + "polarDetailedSportInfo": "TRAIL_RUNNING", + "polarDevice": "Polar Vantage V3", + "polarDeviceId": "device-0001", + "trainingLoad": 112.3, + "startTimeUtcOffset": 60 + } + } + }, + { + "id": 4200017, + "row": { + "externalId": "4200017", + "sportType": "cycling", + "startedAt": "2026-03-03T23:30:00.000Z", + "endedAt": "2026-03-04T00:15:00.000Z", + "durationSec": 2700, + "totalEnergyKcal": 0, + "totalDistanceM": 0, + "avgHeartRate": 0, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "polarSport": "CYCLING", "startTimeUtcOffset": -300 } + } + }, + { + "id": "exercise-0003", + "row": { + "externalId": "exercise-0003", + "sportType": "other", + "startedAt": "2026-03-04T06:15:00.000Z", + "endedAt": "2026-03-05T08:15:00.000Z", + "durationSec": 93600, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "polarDetailedSportInfo": "SOMETHING_POLAR_INVENTED" } + } + }, + { + "id": "exercise-0004", + "row": { + "externalId": "exercise-0004", + "sportType": "strength", + "startedAt": "2026-03-05T12:00:00.000Z", + "endedAt": "2026-03-05T12:00:00.000Z", + "durationSec": 0, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "polarSport": "STRENGTH_TRAINING" } + } + }, + { + "id": "exercise-0005", + "row": { + "externalId": "exercise-0005", + "sportType": "other", + "startedAt": "2026-03-06T03:30:00.000Z", + "endedAt": "2026-03-06T04:00:16.000Z", + "durationSec": 1816, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "polarSport": "OTHER", "startTimeUtcOffset": 330 } + } + }, + { "id": null, "row": null }, + { "id": " ", "row": null }, + { "id": "exercise-0008", "row": null }, + { "id": "exercise-0009", "row": null } +] diff --git a/src/lib/polar/__tests__/__golden__/map-helpers.json b/src/lib/polar/__tests__/__golden__/map-helpers.json new file mode 100644 index 000000000..d4b40ddeb --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-helpers.json @@ -0,0 +1,91 @@ +{ + "normaliseExerciseList": [ + { "label": "bare array", "result": [{ "id": "exercise-0001" }] }, + { "label": "exercises key", "result": [{ "id": "exercise-0001" }] }, + { "label": "data key", "result": [{ "id": "exercise-0001" }] }, + { "label": "items key", "result": [{ "id": "exercise-0001" }] }, + { "label": "records key", "result": [{ "id": "exercise-0001" }] }, + { "label": "first known key wins", "result": [{ "id": "exercise-0001" }] }, + { "label": "empty known key", "result": [] }, + { "label": "unknown key", "result": [] }, + { "label": "known key not an array", "result": [] }, + { "label": "null", "result": [] }, + { "label": "empty string", "result": [] }, + { "label": "number", "result": [] } + ], + "combinePolarStart": [ + { + "startTime": "2026-03-02T07:00:00", + "offset": "60", + "result": "2026-03-02T06:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00", + "offset": "-300", + "result": "2026-03-02T12:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00", + "offset": "0", + "result": "2026-03-02T07:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00", + "offset": "null", + "result": "2026-03-02T07:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00", + "offset": "NaN", + "result": "2026-03-02T07:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00.500", + "offset": "330", + "result": "2026-03-02T01:30:00.500Z" + }, + { + "startTime": "2026-03-02T07:00:00Z", + "offset": "120", + "result": "2026-03-02T07:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00+02:00", + "offset": "120", + "result": "2026-03-02T05:00:00.000Z" + }, + { + "startTime": "2026-03-02T07:00:00-0500", + "offset": "-300", + "result": "2026-03-02T12:00:00.000Z" + }, + { + "startTime": " 2026-03-02T07:00:00 ", + "offset": "60", + "result": "2026-03-02T06:00:00.000Z" + }, + { "startTime": "garbled", "offset": "60", "result": null }, + { "startTime": "", "offset": "60", "result": null }, + { "startTime": " ", "offset": "60", "result": null }, + { "startTime": null, "offset": "60", "result": null }, + { "startTime": null, "offset": "(undefined)", "result": null } + ], + "parseIsoDuration": [ + { "raw": "PT2H44M30S", "seconds": 9870 }, + { "raw": "PT45S", "seconds": 45 }, + { "raw": "PT45M", "seconds": 2700 }, + { "raw": "P1DT2H", "seconds": 93600 }, + { "raw": "P2D", "seconds": 172800 }, + { "raw": "PT30M15.6S", "seconds": 1816 }, + { "raw": "PT0.4S", "seconds": 0 }, + { "raw": "pt1h", "seconds": 3600 }, + { "raw": " PT10M ", "seconds": 600 }, + { "raw": "PT", "seconds": 0 }, + { "raw": "P", "seconds": 0 }, + { "raw": "PT-5M", "seconds": 0 }, + { "raw": "1:02:30", "seconds": 0 }, + { "raw": "", "seconds": 0 }, + { "raw": null, "seconds": 0 }, + { "raw": "(undefined)", "seconds": 0 } + ] +} diff --git a/src/lib/polar/__tests__/__golden__/map-nightly-recharge.json b/src/lib/polar/__tests__/__golden__/map-nightly-recharge.json new file mode 100644 index 000000000..77bc0ffae --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-nightly-recharge.json @@ -0,0 +1,436 @@ +[ + { + "record": "2026-03-02", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 60, + "unit": "score", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-02" + }, + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-02" + }, + { + "type": "RESTING_HEART_RATE", + "value": 56, + "unit": "bpm", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-02" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "resp_rate", + "day": "2026-03-02" + }, + { + "type": "ANS_CHARGE", + "value": 3.46, + "unit": "score", + "measuredAt": "2026-03-02T12:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-02" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 60, + "unit": "score", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-02" + }, + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-02" + }, + { + "type": "RESTING_HEART_RATE", + "value": 56, + "unit": "bpm", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-02" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "resp_rate", + "day": "2026-03-02" + }, + { + "type": "ANS_CHARGE", + "value": 3.46, + "unit": "score", + "measuredAt": "2026-03-02T11:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-02" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 60, + "unit": "score", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-02" + }, + { + "type": "HRV_RMSSD", + "value": 42.12, + "unit": "ms", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-02" + }, + { + "type": "RESTING_HEART_RATE", + "value": 56, + "unit": "bpm", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-02" + }, + { + "type": "RESPIRATORY_RATE", + "value": 14.25, + "unit": "breaths/min", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "resp_rate", + "day": "2026-03-02" + }, + { + "type": "ANS_CHARGE", + "value": 3.46, + "unit": "score", + "measuredAt": "2026-03-02T17:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-02" + } + ] + } + ] + }, + { + "record": "2026-03-03", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-03" + }, + { + "type": "ANS_CHARGE", + "value": -7.2, + "unit": "score", + "measuredAt": "2026-03-03T12:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-03" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-03" + }, + { + "type": "ANS_CHARGE", + "value": -7.2, + "unit": "score", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-03" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-03" + }, + { + "type": "ANS_CHARGE", + "value": -7.2, + "unit": "score", + "measuredAt": "2026-03-03T17:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-03" + } + ] + } + ] + }, + { + "record": "2026-03-04", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-04" + }, + { + "type": "ANS_CHARGE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-04" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-04" + }, + { + "type": "ANS_CHARGE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-04" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-04" + }, + { + "type": "ANS_CHARGE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "ans_charge", + "day": "2026-03-04" + } + ] + } + ] + }, + { + "record": "2026-03-05", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-05" + }, + { + "type": "RESTING_HEART_RATE", + "value": 61, + "unit": "bpm", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-05" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-05" + }, + { + "type": "RESTING_HEART_RATE", + "value": 61, + "unit": "bpm", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-05" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-05" + }, + { + "type": "RESTING_HEART_RATE", + "value": 61, + "unit": "bpm", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "rhr", + "day": "2026-03-05" + } + ] + } + ] + }, + { + "record": "2026-03-06", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-06" + }, + { + "type": "HRV_RMSSD", + "value": 38, + "unit": "ms", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-06" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-06" + }, + { + "type": "HRV_RMSSD", + "value": 38, + "unit": "ms", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-06" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "recovery", + "day": "2026-03-06" + }, + { + "type": "HRV_RMSSD", + "value": 38, + "unit": "ms", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "hrv_rmssd", + "day": "2026-03-06" + } + ] + } + ] + }, + { + "record": "2026-03-07", + "byZone": [ + { "tz": null, "rows": [] }, + { "tz": "Europe/Berlin", "rows": [] }, + { "tz": "America/New_York", "rows": [] } + ] + }, + { + "record": "not-a-date", + "byZone": [ + { "tz": null, "rows": [] }, + { + "tz": "Europe/Berlin", + "rows": { "threw": "RangeError: Invalid time value" } + }, + { + "tz": "America/New_York", + "rows": { "threw": "RangeError: Invalid time value" } + } + ] + } +] diff --git a/src/lib/polar/__tests__/__golden__/map-sleep.json b/src/lib/polar/__tests__/__golden__/map-sleep.json new file mode 100644 index 000000000..5afdf9673 --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-sleep.json @@ -0,0 +1,811 @@ +[ + { + "record": "2026-03-02", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 40, + "unit": "minutes", + "measuredAt": "2026-03-01T22:50:00.000Z", + "fieldTag": "sleep_awake", + "externalId": "sleep:2026-03-02:seg:sleep_awake", + "sleepStage": "AWAKE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-02T02:50:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-02:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-02T04:20:00.000Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-02:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-02:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 475, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 82.5, + "unit": "%", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 40, + "unit": "minutes", + "measuredAt": "2026-03-01T22:50:00.000Z", + "fieldTag": "sleep_awake", + "externalId": "sleep:2026-03-02:seg:sleep_awake", + "sleepStage": "AWAKE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-02T02:50:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-02:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-02T04:20:00.000Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-02:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-02:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 475, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 82.5, + "unit": "%", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 40, + "unit": "minutes", + "measuredAt": "2026-03-01T22:50:00.000Z", + "fieldTag": "sleep_awake", + "externalId": "sleep:2026-03-02:seg:sleep_awake", + "sleepStage": "AWAKE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-02T02:50:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-02:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-02T04:20:00.000Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-02:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 105, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-02:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 475, + "unit": "minutes", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 82.5, + "unit": "%", + "measuredAt": "2026-03-02T06:05:00.000Z", + "fieldTag": "sleep_score" + } + ] + } + ] + }, + { + "record": "2026-03-03", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 270, + "unit": "minutes", + "measuredAt": "2026-03-03T08:00:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-03:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 120, + "unit": "minutes", + "measuredAt": "2026-03-03T10:00:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-03:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 450, + "unit": "minutes", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 270, + "unit": "minutes", + "measuredAt": "2026-03-03T08:00:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-03:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 120, + "unit": "minutes", + "measuredAt": "2026-03-03T10:00:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-03:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 450, + "unit": "minutes", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 270, + "unit": "minutes", + "measuredAt": "2026-03-03T08:00:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-03:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 120, + "unit": "minutes", + "measuredAt": "2026-03-03T10:00:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-03:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 450, + "unit": "minutes", + "measuredAt": "2026-03-03T11:00:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + } + ] + } + ] + }, + { + "record": "2026-03-04", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 20.57, + "unit": "minutes", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 250, + "unit": "minutes", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-04" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 76, + "unit": "%", + "measuredAt": "2026-03-04T12:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-04" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 20.57, + "unit": "minutes", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 250, + "unit": "minutes", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-04" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 76, + "unit": "%", + "measuredAt": "2026-03-04T11:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-04" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 20.57, + "unit": "minutes", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 250, + "unit": "minutes", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 80, + "unit": "minutes", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_deep", + "sleepStage": "DEEP", + "day": "2026-03-04" + }, + { + "type": "SLEEP_DURATION", + "value": 100, + "unit": "minutes", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-04" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 76, + "unit": "%", + "measuredAt": "2026-03-04T17:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-04" + } + ] + } + ] + }, + { + "record": "2026-03-05", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-05" + }, + { + "type": "SLEEP_DURATION", + "value": 50, + "unit": "minutes", + "measuredAt": "2026-03-05T12:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-05" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-05" + }, + { + "type": "SLEEP_DURATION", + "value": 50, + "unit": "minutes", + "measuredAt": "2026-03-05T11:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-05" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-05" + }, + { + "type": "SLEEP_DURATION", + "value": 50, + "unit": "minutes", + "measuredAt": "2026-03-05T17:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-05" + } + ] + } + ] + }, + { + "record": "2026-03-06", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 225, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-06" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 70, + "unit": "%", + "measuredAt": "2026-03-06T12:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-06" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 225, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-06" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 70, + "unit": "%", + "measuredAt": "2026-03-06T11:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-06" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 15, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_awake", + "sleepStage": "AWAKE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 225, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_core", + "sleepStage": "CORE", + "day": "2026-03-06" + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_rem", + "sleepStage": "REM", + "day": "2026-03-06" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 70, + "unit": "%", + "measuredAt": "2026-03-06T17:00:00.000Z", + "fieldTag": "sleep_score", + "day": "2026-03-06" + } + ] + } + ] + }, + { + "record": "2026-03-07", + "byZone": [ + { + "tz": null, + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 205.76, + "unit": "minutes", + "measuredAt": "2026-03-07T02:25:45.600Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-07:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 76.12, + "unit": "minutes", + "measuredAt": "2026-03-07T03:41:52.600Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-07:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 94.63, + "unit": "minutes", + "measuredAt": "2026-03-07T05:16:30.600Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-07:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 465, + "unit": "minutes", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "tz": "Europe/Berlin", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 205.76, + "unit": "minutes", + "measuredAt": "2026-03-07T02:25:45.600Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-07:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 76.12, + "unit": "minutes", + "measuredAt": "2026-03-07T03:41:52.600Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-07:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 94.63, + "unit": "minutes", + "measuredAt": "2026-03-07T05:16:30.600Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-07:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 465, + "unit": "minutes", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_score" + } + ] + }, + { + "tz": "America/New_York", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 205.76, + "unit": "minutes", + "measuredAt": "2026-03-07T02:25:45.600Z", + "fieldTag": "sleep_core", + "externalId": "sleep:2026-03-07:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 76.12, + "unit": "minutes", + "measuredAt": "2026-03-07T03:41:52.600Z", + "fieldTag": "sleep_deep", + "externalId": "sleep:2026-03-07:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 94.63, + "unit": "minutes", + "measuredAt": "2026-03-07T05:16:30.600Z", + "fieldTag": "sleep_rem", + "externalId": "sleep:2026-03-07:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 465, + "unit": "minutes", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-07T06:45:00.000Z", + "fieldTag": "sleep_score" + } + ] + } + ] + }, + { + "record": "not-a-date", + "byZone": [ + { "tz": null, "rows": [] }, + { + "tz": "Europe/Berlin", + "rows": { "threw": "RangeError: Invalid time value" } + }, + { + "tz": "America/New_York", + "rows": { "threw": "RangeError: Invalid time value" } + } + ] + } +] diff --git a/src/lib/polar/__tests__/__golden__/map-spo2.json b/src/lib/polar/__tests__/__golden__/map-spo2.json new file mode 100644 index 000000000..60e95dcbc --- /dev/null +++ b/src/lib/polar/__tests__/__golden__/map-spo2.json @@ -0,0 +1,32 @@ +[ + { + "test_time": 1772438400, + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 97.46, + "unit": "%", + "measuredAt": "2026-03-02T08:00:00.000Z", + "fieldTag": "spo2", + "externalId": "spo2:1772438400:device-0001" + } + ] + }, + { + "test_time": 1772524800, + "rows": [ + { + "type": "OXYGEN_SATURATION", + "value": 100, + "unit": "%", + "measuredAt": "2026-03-03T08:00:00.000Z", + "fieldTag": "spo2", + "externalId": "spo2:1772524800:unknown" + } + ] + }, + { "test_time": 1772611200, "rows": [] }, + { "test_time": 1772697600, "rows": [] }, + { "test_time": 1772784000, "rows": [] }, + { "test_time": null, "rows": [] } +] diff --git a/src/lib/polar/__tests__/map-fixtures.test.ts b/src/lib/polar/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..1cbe10126 --- /dev/null +++ b/src/lib/polar/__tests__/map-fixtures.test.ts @@ -0,0 +1,210 @@ +/** + * What each Polar mapper makes of a fixed set of payloads, pinned in + * `__golden__/`. + * + * The fixtures under `__fixtures__/` are synthetic payloads in the documented + * AccessLink wire shape: complete and sparse records, zero and negative + * values, missing or unparseable dates and sleep windows, offsets other than + * UTC, and exercise ids of either type. The date-keyed mappers run once per + * zone (none, Europe/Berlin, America/New_York), because the zone decides + * where a day's noon anchor lands. The goldens were written while the mappers + * still lived in `client.ts`, so a green run after they move proves the move + * changed nothing. + */ +import { format } from "prettier"; +import { describe, expect, it } from "vitest"; + +import { + combinePolarStart, + mapActivity, + mapCardioLoad, + mapExercise, + mapNightlyRecharge, + mapSleep, + mapSpo2, + normaliseExerciseList, + parseIsoDuration, + type PolarActivity, + type PolarCardioLoad, + type PolarExercise, + type PolarNightlyRecharge, + type PolarSleep, + type PolarSpo2, +} from "../client"; +import activityFixture from "./__fixtures__/activity.json"; +import cardioLoadFixture from "./__fixtures__/cardio-load.json"; +import exercisesFixture from "./__fixtures__/exercises.json"; +import nightlyRechargeFixture from "./__fixtures__/nightly-recharge.json"; +import sleepFixture from "./__fixtures__/sleep.json"; +import spo2Fixture from "./__fixtures__/spo2.json"; + +const recharges: readonly PolarNightlyRecharge[] = nightlyRechargeFixture; +const sleeps: readonly PolarSleep[] = sleepFixture; +const activities: readonly PolarActivity[] = activityFixture; +const cardioLoads: readonly PolarCardioLoad[] = cardioLoadFixture; +const spo2Tests: readonly PolarSpo2[] = spo2Fixture; +const exercises: readonly PolarExercise[] = exercisesFixture; + +const ZONES = [undefined, "Europe/Berlin", "America/New_York"] as const; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +/** + * A mapper's result, or the error it throws. An unparseable date throws once + * a zone is in play (the noon anchor is computed through `Intl`), and that + * behaviour is part of what the golden pins. + */ +function attempt(run: () => unknown): unknown { + try { + return run(); + } catch (err) { + return { + threw: err instanceof Error ? `${err.name}: ${err.message}` : String(err), + }; + } +} + +/** Every record mapped once per zone, labelled so a diff names its input. */ +function perZone( + records: readonly T[], + label: (record: T) => string, + map: (record: T, tz?: string) => unknown, +) { + return records.map((record) => ({ + record: label(record), + byZone: ZONES.map((tz) => ({ + tz: tz ?? null, + rows: attempt(() => map(record, tz)), + })), + })); +} + +describe("Polar mappers against the fixture payloads", () => { + it("maps nightly recharges", async () => { + const output = perZone(recharges, (r) => r.date, mapNightlyRecharge); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-nightly-recharge.json", + ); + }); + + it("maps sleeps", async () => { + const output = perZone(sleeps, (s) => s.date, mapSleep); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sleep.json", + ); + }); + + it("maps daily activity", async () => { + const output = perZone( + activities, + (a) => a.start_time ?? "(no start_time)", + mapActivity, + ); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-activity.json", + ); + }); + + it("maps cardio load", async () => { + const output = perZone(cardioLoads, (c) => c.date, mapCardioLoad); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-cardio-load.json", + ); + }); + + it("maps SpO2 tests", async () => { + const output = spo2Tests.map((record) => ({ + test_time: record.test_time ?? null, + rows: mapSpo2(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-spo2.json", + ); + }); + + it("maps exercises", async () => { + const output = exercises.map((record) => ({ + id: record.id ?? null, + row: mapExercise(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-exercises.json", + ); + }); + + it("normalises exercise list bodies, combines start times and parses durations", async () => { + const one = { id: "exercise-0001" }; + const bodies: Array<[string, unknown]> = [ + ["bare array", [one]], + ["exercises key", { exercises: [one] }], + ["data key", { data: [one] }], + ["items key", { items: [one] }], + ["records key", { records: [one] }], + ["first known key wins", { records: [{ id: "b" }], exercises: [one] }], + ["empty known key", { exercises: [] }], + ["unknown key", { results: [one] }], + ["known key not an array", { exercises: one }], + ["null", null], + ["empty string", ""], + ["number", 42], + ]; + const starts: Array< + [string | null | undefined, number | null | undefined] + > = [ + ["2026-03-02T07:00:00", 60], + ["2026-03-02T07:00:00", -300], + ["2026-03-02T07:00:00", 0], + ["2026-03-02T07:00:00", null], + ["2026-03-02T07:00:00", Number.NaN], + ["2026-03-02T07:00:00.500", 330], + ["2026-03-02T07:00:00Z", 120], + ["2026-03-02T07:00:00+02:00", 120], + ["2026-03-02T07:00:00-0500", -300], + [" 2026-03-02T07:00:00 ", 60], + ["garbled", 60], + ["", 60], + [" ", 60], + [null, 60], + [undefined, undefined], + ]; + const durations: Array = [ + "PT2H44M30S", + "PT45S", + "PT45M", + "P1DT2H", + "P2D", + "PT30M15.6S", + "PT0.4S", + "pt1h", + " PT10M ", + "PT", + "P", + "PT-5M", + "1:02:30", + "", + null, + undefined, + ]; + const output = { + normaliseExerciseList: bodies.map(([label, body]) => ({ + label, + result: normaliseExerciseList(body), + })), + combinePolarStart: starts.map(([startTime, offset]) => ({ + startTime: startTime ?? null, + offset: offset === undefined ? "(undefined)" : String(offset), + result: combinePolarStart(startTime, offset), + })), + parseIsoDuration: durations.map((raw) => ({ + raw: raw === undefined ? "(undefined)" : raw, + seconds: parseIsoDuration(raw), + })), + }; + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-helpers.json", + ); + }); +}); diff --git a/src/lib/strava/__tests__/__fixtures__/activities.json b/src/lib/strava/__tests__/__fixtures__/activities.json new file mode 100644 index 000000000..8a5085846 --- /dev/null +++ b/src/lib/strava/__tests__/__fixtures__/activities.json @@ -0,0 +1,166 @@ +[ + { + "label": "run with heart rate, power and a detail read", + "summary": { + "id": 10000000001, + "name": "Morning Run", + "distance": 10234.5, + "moving_time": 3120, + "elapsed_time": 3300, + "total_elevation_gain": 85.2, + "type": "Run", + "sport_type": "TrailRun", + "start_date": "2026-03-02T06:00:00Z", + "start_date_local": "2026-03-02T07:00:00Z", + "average_heartrate": 151.6, + "max_heartrate": 178.2, + "has_heartrate": true, + "average_watts": 245.3, + "weighted_average_watts": 252, + "device_watts": true, + "average_cadence": 86.5, + "trainer": false, + "commute": false, + "gear_id": "gear-0001" + }, + "detail": { + "id": 10000000001, + "start_date": "2026-03-02T06:00:00Z", + "calories": 712.4, + "description": "Easy loop around the park" + } + }, + { + "label": "summary-only ride without heart rate", + "summary": { + "id": 10000000002, + "name": "Lunch Ride", + "distance": 32500, + "moving_time": 4500, + "elapsed_time": 5100, + "total_elevation_gain": 210, + "type": "Ride", + "sport_type": "Ride", + "start_date": "2026-03-03T11:30:00Z", + "has_heartrate": false, + "average_watts": 180.5, + "device_watts": false, + "trainer": true, + "commute": true + } + }, + { + "label": "summary without a start, filled from the detail", + "summary": { + "id": 10000000003, + "name": "Afternoon Walk", + "sport_type": "Walk", + "start_date": null + }, + "detail": { + "id": 10000000003, + "start_date": "2026-03-04T15:00:00+01:00", + "distance": 4200, + "moving_time": 2700, + "elapsed_time": 3000, + "total_elevation_gain": 12, + "average_heartrate": 98.4, + "max_heartrate": 121.5, + "calories": 0, + "description": "" + } + }, + { + "label": "legacy type only", + "summary": { + "id": 10000000004, + "name": "Weekend Hike", + "type": "Hike", + "sport_type": null, + "start_date": "2026-03-07T08:00:00Z", + "moving_time": 10800, + "elapsed_time": null, + "distance": 14000.25 + }, + "detail": null + }, + { + "label": "unknown sport, moving time only", + "summary": { + "id": 10000000005, + "sport_type": "SomethingStravaAddedLater", + "start_date": "2026-03-08T18:00:00-05:00", + "moving_time": 1800 + } + }, + { + "label": "no sport label anywhere", + "summary": { + "id": 10000000006, + "start_date": "2026-03-09T07:00:00Z", + "elapsed_time": 1500 + } + }, + { + "label": "zero and negative values", + "summary": { + "id": 10000000007, + "name": "", + "distance": 0, + "moving_time": -10, + "elapsed_time": 0, + "total_elevation_gain": -3.5, + "sport_type": "Virtual Ride", + "start_date": "2026-03-10T19:00:00Z", + "average_heartrate": -1, + "max_heartrate": 0, + "gear_id": "" + }, + "detail": { + "id": 10000000007, + "calories": -12, + "sport_type": "VirtualRide" + } + }, + { + "label": "detail sport type used when the summary has none", + "summary": { + "id": 10000000008, + "start_date": "2026-03-11T05:45:00Z", + "moving_time": 2400, + "elapsed_time": 2460 + }, + "detail": { + "id": 10000000008, + "sport_type": "weight_training", + "average_heartrate": 112, + "calories": 250.5 + } + }, + { + "label": "no start anywhere", + "summary": { + "id": 10000000009, + "sport_type": "Swim", + "moving_time": 1800 + } + }, + { + "label": "unparseable start", + "summary": { + "id": 10000000010, + "sport_type": "Swim", + "start_date": "yesterday morning", + "moving_time": 1800 + } + }, + { + "label": "no durations at all", + "summary": { + "id": 10000000011, + "sport_type": "Yoga", + "start_date": "2026-03-12T20:00:00Z", + "has_heartrate": null + } + } +] diff --git a/src/lib/strava/__tests__/__golden__/map-activities.json b/src/lib/strava/__tests__/__golden__/map-activities.json new file mode 100644 index 000000000..38857de21 --- /dev/null +++ b/src/lib/strava/__tests__/__golden__/map-activities.json @@ -0,0 +1,190 @@ +[ + { + "label": "run with heart rate, power and a detail read", + "summaryHasHeartRate": true, + "row": { + "externalId": "10000000001", + "sportType": "running", + "startedAt": "2026-03-02T06:00:00.000Z", + "endedAt": "2026-03-02T06:55:00.000Z", + "durationSec": 3120, + "totalEnergyKcal": 712.4, + "totalDistanceM": 10234.5, + "avgHeartRate": 152, + "maxHeartRate": 178, + "elevationM": 85.2, + "metadata": { + "stravaName": "Morning Run", + "stravaDescription": "Easy loop around the park", + "elapsedTimeSec": 3300, + "stravaType": "TrailRun", + "averageWatts": 245.3, + "weightedAverageWatts": 252, + "deviceWatts": true, + "averageCadence": 86.5, + "trainer": false, + "commute": false, + "gearId": "gear-0001" + }, + "summaryOnly": false + } + }, + { + "label": "summary-only ride without heart rate", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000002", + "sportType": "cycling", + "startedAt": "2026-03-03T11:30:00.000Z", + "endedAt": "2026-03-03T12:55:00.000Z", + "durationSec": 4500, + "totalEnergyKcal": null, + "totalDistanceM": 32500, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": 210, + "metadata": { + "stravaName": "Lunch Ride", + "elapsedTimeSec": 5100, + "stravaType": "Ride", + "averageWatts": 180.5, + "deviceWatts": false, + "trainer": true, + "commute": true + }, + "summaryOnly": true + } + }, + { + "label": "summary without a start, filled from the detail", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000003", + "sportType": "walking", + "startedAt": "2026-03-04T14:00:00.000Z", + "endedAt": "2026-03-04T14:50:00.000Z", + "durationSec": 2700, + "totalEnergyKcal": 0, + "totalDistanceM": 4200, + "avgHeartRate": 98, + "maxHeartRate": 122, + "elevationM": 12, + "metadata": { + "stravaName": "Afternoon Walk", + "elapsedTimeSec": 3000, + "stravaType": "Walk" + }, + "summaryOnly": false + } + }, + { + "label": "legacy type only", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000004", + "sportType": "hiking", + "startedAt": "2026-03-07T08:00:00.000Z", + "endedAt": "2026-03-07T11:00:00.000Z", + "durationSec": 10800, + "totalEnergyKcal": null, + "totalDistanceM": 14000.25, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "stravaName": "Weekend Hike", "stravaType": "Hike" }, + "summaryOnly": true + } + }, + { + "label": "unknown sport, moving time only", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000005", + "sportType": "other", + "startedAt": "2026-03-08T23:00:00.000Z", + "endedAt": "2026-03-08T23:30:00.000Z", + "durationSec": 1800, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "stravaType": "SomethingStravaAddedLater" }, + "summaryOnly": true + } + }, + { + "label": "no sport label anywhere", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000006", + "sportType": "other", + "startedAt": "2026-03-09T07:00:00.000Z", + "endedAt": "2026-03-09T07:25:00.000Z", + "durationSec": 1500, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "elapsedTimeSec": 1500, "stravaType": "Workout" }, + "summaryOnly": true + } + }, + { + "label": "zero and negative values", + "summaryHasHeartRate": true, + "row": { + "externalId": "10000000007", + "sportType": "cycling", + "startedAt": "2026-03-10T19:00:00.000Z", + "endedAt": "2026-03-10T19:00:00.000Z", + "durationSec": 0, + "totalEnergyKcal": null, + "totalDistanceM": 0, + "avgHeartRate": null, + "maxHeartRate": 0, + "elevationM": null, + "metadata": { "elapsedTimeSec": 0, "stravaType": "Virtual Ride" }, + "summaryOnly": false + } + }, + { + "label": "detail sport type used when the summary has none", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000008", + "sportType": "strength", + "startedAt": "2026-03-11T05:45:00.000Z", + "endedAt": "2026-03-11T06:26:00.000Z", + "durationSec": 2400, + "totalEnergyKcal": 250.5, + "totalDistanceM": null, + "avgHeartRate": 112, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "elapsedTimeSec": 2460, "stravaType": "weight_training" }, + "summaryOnly": false + } + }, + { "label": "no start anywhere", "summaryHasHeartRate": false, "row": null }, + { "label": "unparseable start", "summaryHasHeartRate": false, "row": null }, + { + "label": "no durations at all", + "summaryHasHeartRate": false, + "row": { + "externalId": "10000000011", + "sportType": "yoga", + "startedAt": "2026-03-12T20:00:00.000Z", + "endedAt": "2026-03-12T20:00:00.000Z", + "durationSec": 0, + "totalEnergyKcal": null, + "totalDistanceM": null, + "avgHeartRate": null, + "maxHeartRate": null, + "elevationM": null, + "metadata": { "stravaType": "Yoga" }, + "summaryOnly": true + } + } +] diff --git a/src/lib/strava/__tests__/__golden__/map-rate-limit.json b/src/lib/strava/__tests__/__golden__/map-rate-limit.json new file mode 100644 index 000000000..a8295ae99 --- /dev/null +++ b/src/lib/strava/__tests__/__golden__/map-rate-limit.json @@ -0,0 +1,12 @@ +[ + { "label": "no headers", "seconds": 450 }, + { "label": "retry-after seconds", "seconds": 120 }, + { "label": "retry-after HTTP date", "seconds": 150 }, + { "label": "retry-after zero falls through", "seconds": 450 }, + { "label": "15-minute window spent", "seconds": 450 }, + { "label": "daily window spent", "seconds": 49950 }, + { "label": "daily read window spent", "seconds": 49950 }, + { "label": "malformed pair", "seconds": 450 }, + { "label": "exactly on a quarter hour", "seconds": 900 }, + { "label": "half a second before midnight, daily spent", "seconds": 1 } +] diff --git a/src/lib/strava/__tests__/__golden__/map-sport.json b/src/lib/strava/__tests__/__golden__/map-sport.json new file mode 100644 index 000000000..51fa47053 --- /dev/null +++ b/src/lib/strava/__tests__/__golden__/map-sport.json @@ -0,0 +1,16 @@ +[ + { "raw": "Run", "sportType": "running" }, + { "raw": "TrailRun", "sportType": "running" }, + { "raw": "VirtualRide", "sportType": "cycling" }, + { "raw": "Virtual Ride", "sportType": "cycling" }, + { "raw": "virtual_ride", "sportType": "cycling" }, + { "raw": " WeightTraining ", "sportType": "strength" }, + { "raw": "HighIntensityIntervalTraining", "sportType": "hiit" }, + { "raw": "Snowshoe", "sportType": "hiking" }, + { "raw": "Workout", "sportType": "other" }, + { "raw": "SomethingStravaAddedLater", "sportType": "other" }, + { "raw": "", "sportType": "other" }, + { "raw": " ", "sportType": "other" }, + { "raw": null, "sportType": "other" }, + { "raw": "(undefined)", "sportType": "other" } +] diff --git a/src/lib/strava/__tests__/map-fixtures.test.ts b/src/lib/strava/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..954ba650b --- /dev/null +++ b/src/lib/strava/__tests__/map-fixtures.test.ts @@ -0,0 +1,160 @@ +/** + * What each Strava mapper makes of a fixed set of payloads, pinned in + * `__golden__/`. + * + * The fixtures under `__fixtures__/` are synthetic `SummaryActivity` + * payloads in the documented v3 wire shape, each paired with the + * `DetailedActivity` the sync would read for it (or none): heart rate on the + * summary or only on the detail, a start only on the detail, legacy `type` + * labels, unknown sports, zero and negative values, and the records the + * mapper refuses. The rate-limit wait and the sport lookup take scalar + * inputs, so their cases are tables in this file. The goldens were written + * while the mappers still lived in `client.ts`, so a green run after they + * move proves the move changed nothing. + */ +import { format } from "prettier"; +import { describe, expect, it } from "vitest"; + +import { + mapActivity, + stravaRateLimitRetryAfterSeconds, + summaryHasHeartRate, + type StravaDetailedActivity, + type StravaSummaryActivity, +} from "../client"; +import { mapStravaSportType } from "../sport-map"; +import activitiesFixture from "./__fixtures__/activities.json"; + +interface ActivityCase { + label: string; + summary: StravaSummaryActivity; + detail?: StravaDetailedActivity | null; +} + +const activities: readonly ActivityCase[] = activitiesFixture; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +describe("Strava mappers against the fixture payloads", () => { + it("maps activities", async () => { + const output = activities.map(({ label, summary, detail }) => ({ + label, + summaryHasHeartRate: summaryHasHeartRate(summary), + row: mapActivity(summary, detail), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-activities.json", + ); + }); + + it("resolves sport labels", async () => { + const labels: Array = [ + "Run", + "TrailRun", + "VirtualRide", + "Virtual Ride", + "virtual_ride", + " WeightTraining ", + "HighIntensityIntervalTraining", + "Snowshoe", + "Workout", + "SomethingStravaAddedLater", + "", + " ", + null, + undefined, + ]; + const output = labels.map((raw) => ({ + raw: raw === undefined ? "(undefined)" : raw, + sportType: mapStravaSportType(raw), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sport.json", + ); + }); + + it("derives the wait after a rate limit", async () => { + const cases: Array<{ + label: string; + headers: Record; + now: string; + }> = [ + { label: "no headers", headers: {}, now: "2026-03-02T10:07:30.000Z" }, + { + label: "retry-after seconds", + headers: { "retry-after": "120" }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "retry-after HTTP date", + headers: { "retry-after": "Mon, 02 Mar 2026 10:10:00 GMT" }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "retry-after zero falls through", + headers: { "retry-after": "0" }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "15-minute window spent", + headers: { + "x-ratelimit-limit": "200,2000", + "x-ratelimit-usage": "200,450", + }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "daily window spent", + headers: { + "x-ratelimit-limit": "200,2000", + "x-ratelimit-usage": "120,2000", + }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "daily read window spent", + headers: { + "x-ratelimit-limit": "200,2000", + "x-ratelimit-usage": "40,500", + "x-readratelimit-limit": "100,1000", + "x-readratelimit-usage": "30,1001", + }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "malformed pair", + headers: { + "x-ratelimit-limit": "200", + "x-ratelimit-usage": "abc,2000", + }, + now: "2026-03-02T10:07:30.000Z", + }, + { + label: "exactly on a quarter hour", + headers: {}, + now: "2026-03-02T10:15:00.000Z", + }, + { + label: "half a second before midnight, daily spent", + headers: { + "x-ratelimit-limit": "200,2000", + "x-ratelimit-usage": "10,2500", + }, + now: "2026-03-02T23:59:59.500Z", + }, + ]; + const output = cases.map(({ label, headers, now }) => ({ + label, + seconds: stravaRateLimitRetryAfterSeconds( + new Headers(headers), + new Date(now), + ), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-rate-limit.json", + ); + }); +}); diff --git a/src/lib/whoop/__tests__/__fixtures__/body.json b/src/lib/whoop/__tests__/__fixtures__/body.json new file mode 100644 index 000000000..4858df927 --- /dev/null +++ b/src/lib/whoop/__tests__/__fixtures__/body.json @@ -0,0 +1,9 @@ +[ + { "height_meter": 1.8, "weight_kilogram": 78.456, "max_heart_rate": 192 }, + { "height_meter": 1.655, "weight_kilogram": 62.3, "max_heart_rate": 187.6 }, + {}, + { "weight_kilogram": 90 }, + { "height_meter": 0, "weight_kilogram": 0, "max_heart_rate": 0 }, + { "height_meter": -1.7, "weight_kilogram": -70, "max_heart_rate": -180 }, + { "height_meter": 1.72345, "max_heart_rate": 175.4 } +] diff --git a/src/lib/whoop/__tests__/__fixtures__/cycle.json b/src/lib/whoop/__tests__/__fixtures__/cycle.json new file mode 100644 index 000000000..2aef01573 --- /dev/null +++ b/src/lib/whoop/__tests__/__fixtures__/cycle.json @@ -0,0 +1,71 @@ +[ + { + "id": 900001, + "user_id": 10001, + "created_at": "2026-03-01T22:30:00.000Z", + "updated_at": "2026-03-02T22:10:00.000Z", + "start": "2026-03-01T22:30:00.000Z", + "end": "2026-03-02T22:05:00.000Z", + "score_state": "SCORED", + "score": { + "strain": 12.4567, + "kilojoule": 9876.543, + "average_heart_rate": 68.4, + "max_heart_rate": 171.6 + } + }, + { + "id": 900002, + "user_id": 10001, + "created_at": "2026-03-02T22:05:00.000Z", + "updated_at": "2026-03-03T09:00:00.000Z", + "start": "2026-03-02T22:05:00.000Z", + "end": null, + "score_state": "SCORED", + "score": { + "strain": 3.1, + "kilojoule": 2500, + "average_heart_rate": 61.5, + "max_heart_rate": 120.5 + } + }, + { + "id": 900003, + "user_id": 10001, + "created_at": "2026-03-03T22:00:00.000Z", + "updated_at": "2026-03-03T22:00:00.000Z", + "start": "2026-03-03T22:00:00.000Z", + "score_state": "PENDING_SCORE", + "score": null + }, + { + "id": 900004, + "user_id": 10001, + "created_at": "2026-03-04T23:15:00.000+01:00", + "updated_at": "2026-03-05T23:00:00.000+01:00", + "start": "2026-03-04T23:15:00.000+01:00", + "end": "2026-03-05T22:50:00.000+01:00", + "score_state": "SCORED", + "score": { + "strain": 0, + "kilojoule": 0, + "average_heart_rate": 0, + "max_heart_rate": 0 + } + }, + { + "id": 900005, + "user_id": 10001, + "created_at": "2026-03-05T21:00:00.000-05:00", + "updated_at": "2026-03-06T21:00:00.000-05:00", + "start": "2026-03-05T21:00:00.000-05:00", + "end": "2026-03-06T20:45:00.000-05:00", + "score_state": "SCORED", + "score": { + "strain": 21, + "kilojoule": 18000.005, + "average_heart_rate": 95, + "max_heart_rate": 198 + } + } +] diff --git a/src/lib/whoop/__tests__/__fixtures__/recovery.json b/src/lib/whoop/__tests__/__fixtures__/recovery.json new file mode 100644 index 000000000..c2ff504a9 --- /dev/null +++ b/src/lib/whoop/__tests__/__fixtures__/recovery.json @@ -0,0 +1,81 @@ +[ + { + "cycle_id": 900001, + "sleep_id": "sleep-0001", + "user_id": 10001, + "created_at": "2026-03-02T06:40:00.000Z", + "updated_at": "2026-03-02T06:45:00.000Z", + "score_state": "SCORED", + "score": { + "user_calibrating": false, + "recovery_score": 64, + "resting_heart_rate": 54, + "hrv_rmssd_milli": 48.2, + "spo2_percentage": 96.5, + "skin_temp_celsius": 33.4 + } + }, + { + "cycle_id": 900002, + "sleep_id": "sleep-0002", + "user_id": 10001, + "created_at": "2026-03-03T06:40:00.000Z", + "updated_at": "2026-03-03T06:41:00.000Z", + "score_state": "SCORED", + "score": { + "user_calibrating": false, + "recovery_score": 81, + "resting_heart_rate": 50, + "hrv_rmssd_milli": 61.73456 + } + }, + { + "cycle_id": 900003, + "sleep_id": "sleep-0003", + "user_id": 10001, + "created_at": "2026-03-04T06:40:00.000Z", + "updated_at": "2026-03-04T06:40:00.000Z", + "score_state": "PENDING_SCORE", + "score": null + }, + { + "cycle_id": 900004, + "sleep_id": "sleep-0004", + "user_id": 10001, + "created_at": "2026-03-05T06:40:00.000Z", + "updated_at": "2026-03-05T06:40:00.000Z", + "score_state": "UNSCORABLE", + "score": null + }, + { + "cycle_id": 900005, + "sleep_id": "sleep-0005", + "user_id": 10001, + "created_at": "2026-03-06T05:00:00.000Z", + "updated_at": "2026-03-06T07:15:30.000+02:00", + "score_state": "SCORED", + "score": { + "user_calibrating": true, + "recovery_score": 0, + "resting_heart_rate": 0, + "hrv_rmssd_milli": 0, + "spo2_percentage": 0, + "skin_temp_celsius": 0 + } + }, + { + "cycle_id": 900006, + "sleep_id": "sleep-0006", + "user_id": 10001, + "created_at": "2026-03-07T06:40:00.000Z", + "updated_at": "2026-03-07T01:30:00.000-05:00", + "score_state": "SCORED", + "score": { + "user_calibrating": false, + "recovery_score": 100, + "resting_heart_rate": 41.666, + "hrv_rmssd_milli": 132.005, + "skin_temp_celsius": 34.125 + } + } +] diff --git a/src/lib/whoop/__tests__/__fixtures__/sleep.json b/src/lib/whoop/__tests__/__fixtures__/sleep.json new file mode 100644 index 000000000..e58450e61 --- /dev/null +++ b/src/lib/whoop/__tests__/__fixtures__/sleep.json @@ -0,0 +1,139 @@ +[ + { + "id": "sleep-0001", + "cycle_id": 900001, + "user_id": 10001, + "created_at": "2026-03-02T06:40:00.000Z", + "updated_at": "2026-03-02T06:45:00.000Z", + "start": "2026-03-01T22:30:00.000Z", + "end": "2026-03-02T06:30:00.000Z", + "nap": false, + "score_state": "SCORED", + "score": { + "stage_summary": { + "total_in_bed_time_milli": 28800000, + "total_awake_time_milli": 1800000, + "total_light_sleep_time_milli": 14400000, + "total_slow_wave_sleep_time_milli": 5400000, + "total_rem_sleep_time_milli": 7200000, + "disturbance_count": 7 + }, + "sleep_needed": { + "baseline_milli": 27000000, + "need_from_sleep_debt_milli": 1800000, + "need_from_recent_strain_milli": 900000, + "need_from_recent_nap_milli": -600000 + }, + "respiratory_rate": 15.234, + "sleep_performance_percentage": 88, + "sleep_consistency_percentage": 72, + "sleep_efficiency_percentage": 93.75 + } + }, + { + "id": "sleep-0002", + "user_id": 10001, + "created_at": "2026-03-02T14:50:00.000Z", + "updated_at": "2026-03-02T14:52:00.000Z", + "start": "2026-03-02T14:00:00.000Z", + "end": "2026-03-02T14:45:00.000Z", + "nap": true, + "score_state": "SCORED", + "score": { + "stage_summary": { + "total_in_bed_time_milli": 2700000, + "total_awake_time_milli": 0, + "total_light_sleep_time_milli": 2100000, + "total_slow_wave_sleep_time_milli": 600000, + "total_rem_sleep_time_milli": 0 + }, + "sleep_needed": { + "baseline_milli": 0, + "need_from_sleep_debt_milli": 0, + "need_from_recent_strain_milli": 0, + "need_from_recent_nap_milli": 0 + } + } + }, + { + "id": "sleep-0003", + "cycle_id": 900003, + "user_id": 10001, + "created_at": "2026-03-04T06:40:00.000Z", + "updated_at": "2026-03-04T06:40:00.000Z", + "start": "2026-03-03T23:00:00.000Z", + "end": "2026-03-04T06:30:00.000Z", + "nap": false, + "score_state": "PENDING_SCORE", + "score": null + }, + { + "id": "sleep-0004", + "cycle_id": 900004, + "user_id": 10001, + "created_at": "2026-03-05T07:10:00.000+01:00", + "updated_at": "2026-03-05T07:12:00.000+01:00", + "start": "2026-03-04T23:40:00.000+01:00", + "end": "2026-03-05T07:05:00.000+01:00", + "nap": false, + "score_state": "SCORED", + "score": { + "stage_summary": { + "total_in_bed_time_milli": 26700000, + "total_awake_time_milli": 2345678, + "total_light_sleep_time_milli": 12345678, + "total_slow_wave_sleep_time_milli": 4567890, + "total_rem_sleep_time_milli": 6543210, + "disturbance_count": 0 + }, + "sleep_needed": { + "baseline_milli": 28123456, + "need_from_sleep_debt_milli": 0, + "need_from_recent_strain_milli": 456789, + "need_from_recent_nap_milli": 0 + }, + "respiratory_rate": 0, + "sleep_performance_percentage": 0, + "sleep_efficiency_percentage": 100 + } + }, + { + "id": "sleep-0005", + "cycle_id": 900005, + "user_id": 10001, + "created_at": "2026-03-06T06:00:00.000-05:00", + "updated_at": "2026-03-06T06:05:00.000-05:00", + "start": "2026-03-05T22:15:00.000-05:00", + "end": "2026-03-06T05:45:00.000-05:00", + "nap": false, + "score_state": "SCORED", + "score": { + "stage_summary": { + "total_in_bed_time_milli": 0, + "total_awake_time_milli": 0, + "total_light_sleep_time_milli": 0, + "total_slow_wave_sleep_time_milli": 0, + "total_rem_sleep_time_milli": 0 + }, + "sleep_needed": { + "baseline_milli": 25200000, + "need_from_sleep_debt_milli": 3600000, + "need_from_recent_strain_milli": 0, + "need_from_recent_nap_milli": 0 + }, + "sleep_consistency_percentage": 55 + } + }, + { + "id": "sleep-0006", + "cycle_id": 900006, + "user_id": 10001, + "created_at": "2026-03-07T08:00:00.000Z", + "updated_at": "2026-03-07T08:00:00.000Z", + "start": "2026-03-07T00:10:00.000Z", + "end": "2026-03-07T07:50:00.000Z", + "nap": false, + "score_state": "UNSCORABLE", + "score": null + } +] diff --git a/src/lib/whoop/__tests__/__golden__/map-body.json b/src/lib/whoop/__tests__/__golden__/map-body.json new file mode 100644 index 000000000..fb4116598 --- /dev/null +++ b/src/lib/whoop/__tests__/__golden__/map-body.json @@ -0,0 +1,42 @@ +[ + { + "input": { + "height_meter": 1.8, + "weight_kilogram": 78.456, + "max_heart_rate": 192 + }, + "mapped": { "weightKg": 78.46, "maxHeartRate": 192, "heightCm": 180 } + }, + { + "input": { + "height_meter": 1.655, + "weight_kilogram": 62.3, + "max_heart_rate": 187.6 + }, + "mapped": { "weightKg": 62.3, "maxHeartRate": 188, "heightCm": 165.5 } + }, + { + "input": {}, + "mapped": { "weightKg": null, "maxHeartRate": null, "heightCm": null } + }, + { + "input": { "weight_kilogram": 90 }, + "mapped": { "weightKg": 90, "maxHeartRate": null, "heightCm": null } + }, + { + "input": { "height_meter": 0, "weight_kilogram": 0, "max_heart_rate": 0 }, + "mapped": { "weightKg": null, "maxHeartRate": null, "heightCm": null } + }, + { + "input": { + "height_meter": -1.7, + "weight_kilogram": -70, + "max_heart_rate": -180 + }, + "mapped": { "weightKg": null, "maxHeartRate": null, "heightCm": null } + }, + { + "input": { "height_meter": 1.72345, "max_heart_rate": 175.4 }, + "mapped": { "weightKg": null, "maxHeartRate": 175, "heightCm": 172.35 } + } +] diff --git a/src/lib/whoop/__tests__/__golden__/map-cycle.json b/src/lib/whoop/__tests__/__golden__/map-cycle.json new file mode 100644 index 000000000..5ac64675a --- /dev/null +++ b/src/lib/whoop/__tests__/__golden__/map-cycle.json @@ -0,0 +1,135 @@ +[ + { + "id": 900001, + "rows": [ + { + "type": "DAY_STRAIN", + "value": 12.46, + "unit": "score", + "measuredAt": "2026-03-01T22:30:00.000Z", + "fieldTag": "day_strain" + }, + { + "type": "ENERGY_EXPENDITURE_KJ", + "value": 9876.54, + "unit": "kJ", + "measuredAt": "2026-03-01T22:30:00.000Z", + "fieldTag": "energy_kj" + }, + { + "type": "AVERAGE_HEART_RATE", + "value": 68, + "unit": "bpm", + "measuredAt": "2026-03-01T22:30:00.000Z", + "fieldTag": "avg_hr" + }, + { + "type": "MAX_HEART_RATE", + "value": 172, + "unit": "bpm", + "measuredAt": "2026-03-01T22:30:00.000Z", + "fieldTag": "max_hr" + } + ] + }, + { + "id": 900002, + "rows": [ + { + "type": "DAY_STRAIN", + "value": 3.1, + "unit": "score", + "measuredAt": "2026-03-02T22:05:00.000Z", + "fieldTag": "day_strain" + }, + { + "type": "ENERGY_EXPENDITURE_KJ", + "value": 2500, + "unit": "kJ", + "measuredAt": "2026-03-02T22:05:00.000Z", + "fieldTag": "energy_kj" + }, + { + "type": "AVERAGE_HEART_RATE", + "value": 62, + "unit": "bpm", + "measuredAt": "2026-03-02T22:05:00.000Z", + "fieldTag": "avg_hr" + }, + { + "type": "MAX_HEART_RATE", + "value": 121, + "unit": "bpm", + "measuredAt": "2026-03-02T22:05:00.000Z", + "fieldTag": "max_hr" + } + ] + }, + { "id": 900003, "rows": [] }, + { + "id": 900004, + "rows": [ + { + "type": "DAY_STRAIN", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-04T22:15:00.000Z", + "fieldTag": "day_strain" + }, + { + "type": "ENERGY_EXPENDITURE_KJ", + "value": 0, + "unit": "kJ", + "measuredAt": "2026-03-04T22:15:00.000Z", + "fieldTag": "energy_kj" + }, + { + "type": "AVERAGE_HEART_RATE", + "value": 0, + "unit": "bpm", + "measuredAt": "2026-03-04T22:15:00.000Z", + "fieldTag": "avg_hr" + }, + { + "type": "MAX_HEART_RATE", + "value": 0, + "unit": "bpm", + "measuredAt": "2026-03-04T22:15:00.000Z", + "fieldTag": "max_hr" + } + ] + }, + { + "id": 900005, + "rows": [ + { + "type": "DAY_STRAIN", + "value": 21, + "unit": "score", + "measuredAt": "2026-03-06T02:00:00.000Z", + "fieldTag": "day_strain" + }, + { + "type": "ENERGY_EXPENDITURE_KJ", + "value": 18000.01, + "unit": "kJ", + "measuredAt": "2026-03-06T02:00:00.000Z", + "fieldTag": "energy_kj" + }, + { + "type": "AVERAGE_HEART_RATE", + "value": 95, + "unit": "bpm", + "measuredAt": "2026-03-06T02:00:00.000Z", + "fieldTag": "avg_hr" + }, + { + "type": "MAX_HEART_RATE", + "value": 198, + "unit": "bpm", + "measuredAt": "2026-03-06T02:00:00.000Z", + "fieldTag": "max_hr" + } + ] + } +] diff --git a/src/lib/whoop/__tests__/__golden__/map-recovery.json b/src/lib/whoop/__tests__/__golden__/map-recovery.json new file mode 100644 index 000000000..79fdcc46c --- /dev/null +++ b/src/lib/whoop/__tests__/__golden__/map-recovery.json @@ -0,0 +1,143 @@ +[ + { + "sleep_id": "sleep-0001", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 64, + "unit": "score", + "measuredAt": "2026-03-02T06:45:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "HRV_RMSSD", + "value": 48.2, + "unit": "ms", + "measuredAt": "2026-03-02T06:45:00.000Z", + "fieldTag": "hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 54, + "unit": "bpm", + "measuredAt": "2026-03-02T06:45:00.000Z", + "fieldTag": "rhr" + }, + { + "type": "OXYGEN_SATURATION", + "value": 96.5, + "unit": "%", + "measuredAt": "2026-03-02T06:45:00.000Z", + "fieldTag": "spo2" + }, + { + "type": "SKIN_TEMPERATURE", + "value": 33.4, + "unit": "celsius", + "measuredAt": "2026-03-02T06:45:00.000Z", + "fieldTag": "skin_temp" + } + ] + }, + { + "sleep_id": "sleep-0002", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 81, + "unit": "score", + "measuredAt": "2026-03-03T06:41:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "HRV_RMSSD", + "value": 61.73, + "unit": "ms", + "measuredAt": "2026-03-03T06:41:00.000Z", + "fieldTag": "hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 50, + "unit": "bpm", + "measuredAt": "2026-03-03T06:41:00.000Z", + "fieldTag": "rhr" + } + ] + }, + { "sleep_id": "sleep-0003", "rows": [] }, + { "sleep_id": "sleep-0004", "rows": [] }, + { + "sleep_id": "sleep-0005", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 0, + "unit": "score", + "measuredAt": "2026-03-06T05:15:30.000Z", + "fieldTag": "recovery" + }, + { + "type": "HRV_RMSSD", + "value": 0, + "unit": "ms", + "measuredAt": "2026-03-06T05:15:30.000Z", + "fieldTag": "hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 0, + "unit": "bpm", + "measuredAt": "2026-03-06T05:15:30.000Z", + "fieldTag": "rhr" + }, + { + "type": "OXYGEN_SATURATION", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-06T05:15:30.000Z", + "fieldTag": "spo2" + }, + { + "type": "SKIN_TEMPERATURE", + "value": 0, + "unit": "celsius", + "measuredAt": "2026-03-06T05:15:30.000Z", + "fieldTag": "skin_temp" + } + ] + }, + { + "sleep_id": "sleep-0006", + "rows": [ + { + "type": "RECOVERY_SCORE", + "value": 100, + "unit": "score", + "measuredAt": "2026-03-07T06:30:00.000Z", + "fieldTag": "recovery" + }, + { + "type": "HRV_RMSSD", + "value": 132.01, + "unit": "ms", + "measuredAt": "2026-03-07T06:30:00.000Z", + "fieldTag": "hrv_rmssd" + }, + { + "type": "RESTING_HEART_RATE", + "value": 41.67, + "unit": "bpm", + "measuredAt": "2026-03-07T06:30:00.000Z", + "fieldTag": "rhr" + }, + { + "type": "SKIN_TEMPERATURE", + "value": 34.13, + "unit": "celsius", + "measuredAt": "2026-03-07T06:30:00.000Z", + "fieldTag": "skin_temp" + } + ] + } +] diff --git a/src/lib/whoop/__tests__/__golden__/map-sleep.json b/src/lib/whoop/__tests__/__golden__/map-sleep.json new file mode 100644 index 000000000..d5082d292 --- /dev/null +++ b/src/lib/whoop/__tests__/__golden__/map-sleep.json @@ -0,0 +1,246 @@ +[ + { + "id": "sleep-0001", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 30, + "unit": "minutes", + "measuredAt": "2026-03-01T23:00:00.000Z", + "fieldTag": "sleep_awake", + "externalId": "sleep-0001:seg:sleep_awake", + "sleepStage": "AWAKE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 240, + "unit": "minutes", + "measuredAt": "2026-03-02T03:00:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep-0001:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 90, + "unit": "minutes", + "measuredAt": "2026-03-02T04:30:00.000Z", + "fieldTag": "sleep_deep", + "externalId": "sleep-0001:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 120, + "unit": "minutes", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_rem", + "externalId": "sleep-0001:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 480, + "unit": "minutes", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_NEED", + "value": 485, + "unit": "minutes", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_need" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 88, + "unit": "%", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_perf" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 93.75, + "unit": "%", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_eff" + }, + { + "type": "SLEEP_CONSISTENCY", + "value": 72, + "unit": "%", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "sleep_consistency" + }, + { + "type": "RESPIRATORY_RATE", + "value": 15.23, + "unit": "breaths/min", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "resp_rate" + }, + { + "type": "SLEEP_DISTURBANCE_COUNT", + "value": 7, + "unit": "count", + "measuredAt": "2026-03-02T06:30:00.000Z", + "fieldTag": "disturbances" + } + ] + }, + { + "id": "sleep-0002", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 35, + "unit": "minutes", + "measuredAt": "2026-03-02T14:35:00.000Z", + "fieldTag": "sleep_core", + "externalId": "sleep-0002:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 10, + "unit": "minutes", + "measuredAt": "2026-03-02T14:45:00.000Z", + "fieldTag": "sleep_deep", + "externalId": "sleep-0002:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 45, + "unit": "minutes", + "measuredAt": "2026-03-02T14:45:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_NEED", + "value": 0, + "unit": "minutes", + "measuredAt": "2026-03-02T14:45:00.000Z", + "fieldTag": "sleep_need" + } + ] + }, + { "id": "sleep-0003", "rows": [] }, + { + "id": "sleep-0004", + "rows": [ + { + "type": "SLEEP_DURATION", + "value": 39.09, + "unit": "minutes", + "measuredAt": "2026-03-04T23:19:05.678Z", + "fieldTag": "sleep_awake", + "externalId": "sleep-0004:seg:sleep_awake", + "sleepStage": "AWAKE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 205.76, + "unit": "minutes", + "measuredAt": "2026-03-05T02:44:51.356Z", + "fieldTag": "sleep_core", + "externalId": "sleep-0004:seg:sleep_core", + "sleepStage": "CORE", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 76.13, + "unit": "minutes", + "measuredAt": "2026-03-05T04:00:59.246Z", + "fieldTag": "sleep_deep", + "externalId": "sleep-0004:seg:sleep_deep", + "sleepStage": "DEEP", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 109.05, + "unit": "minutes", + "measuredAt": "2026-03-05T05:50:02.456Z", + "fieldTag": "sleep_rem", + "externalId": "sleep-0004:seg:sleep_rem", + "sleepStage": "REM", + "reconstructed": true + }, + { + "type": "SLEEP_DURATION", + "value": 445, + "unit": "minutes", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "sleep_in_bed", + "sleepStage": "IN_BED" + }, + { + "type": "SLEEP_NEED", + "value": 476.34, + "unit": "minutes", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "sleep_need" + }, + { + "type": "SLEEP_PERFORMANCE", + "value": 0, + "unit": "%", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "sleep_perf" + }, + { + "type": "SLEEP_EFFICIENCY", + "value": 100, + "unit": "%", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "sleep_eff" + }, + { + "type": "RESPIRATORY_RATE", + "value": 0, + "unit": "breaths/min", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "resp_rate" + }, + { + "type": "SLEEP_DISTURBANCE_COUNT", + "value": 0, + "unit": "count", + "measuredAt": "2026-03-05T06:05:00.000Z", + "fieldTag": "disturbances" + } + ] + }, + { + "id": "sleep-0005", + "rows": [ + { + "type": "SLEEP_NEED", + "value": 480, + "unit": "minutes", + "measuredAt": "2026-03-06T10:45:00.000Z", + "fieldTag": "sleep_need" + }, + { + "type": "SLEEP_CONSISTENCY", + "value": 55, + "unit": "%", + "measuredAt": "2026-03-06T10:45:00.000Z", + "fieldTag": "sleep_consistency" + } + ] + }, + { "id": "sleep-0006", "rows": [] } +] diff --git a/src/lib/whoop/__tests__/__golden__/map-sport.json b/src/lib/whoop/__tests__/__golden__/map-sport.json new file mode 100644 index 000000000..2a0d6fe44 --- /dev/null +++ b/src/lib/whoop/__tests__/__golden__/map-sport.json @@ -0,0 +1,27 @@ +[ + { "sportId": 0, "sportName": "Running", "sportType": "running" }, + { "sportId": 1, "sportName": null, "sportType": "cycling" }, + { + "sportId": 52, + "sportName": "ignored when the id resolves", + "sportType": "hiking" + }, + { "sportId": 9999, "sportName": "Hiking/Rucking", "sportType": "hiking" }, + { "sportId": null, "sportName": "hiking_rucking", "sportType": "hiking" }, + { "sportId": null, "sportName": " Track & Field ", "sportType": "running" }, + { + "sportId": null, + "sportName": "Functional Fitness", + "sportType": "crossTraining" + }, + { "sportId": null, "sportName": "whoop_sport_63", "sportType": "other" }, + { + "sportId": null, + "sportName": "Underwater Basket Weaving", + "sportType": "other" + }, + { "sportId": null, "sportName": " ", "sportType": "other" }, + { "sportId": 9999, "sportName": null, "sportType": "other" }, + { "sportId": -1, "sportName": null, "sportType": "other" }, + { "sportId": null, "sportName": null, "sportType": "other" } +] diff --git a/src/lib/whoop/__tests__/map-fixtures.test.ts b/src/lib/whoop/__tests__/map-fixtures.test.ts new file mode 100644 index 000000000..590fae371 --- /dev/null +++ b/src/lib/whoop/__tests__/map-fixtures.test.ts @@ -0,0 +1,109 @@ +/** + * What each WHOOP mapper makes of a fixed set of payloads, pinned in + * `__golden__/`. + * + * The fixtures under `__fixtures__/` are synthetic payloads in the documented + * v2 wire shape: scored and unscored records, naps, absent optional fields, + * zero values, offsets other than UTC, and body values that must be refused. + * The goldens were written while the mappers still lived in `client.ts`, so a + * green run after they move proves the move changed nothing. + * + * The workout row is built inside `upsertWhoopWorkout`, next to the database + * write, so only its pure sport lookup (`mapWhoopSportType`) is pinned here. + */ +import { format } from "prettier"; +import { describe, expect, it } from "vitest"; + +import { + mapBody, + mapCycle, + mapRecovery, + mapSleep, + type WhoopBodyMeasurement, + type WhoopCycle, + type WhoopRecovery, + type WhoopSleep, +} from "../client"; +import { mapWhoopSportType } from "../sport-map"; +import bodyFixture from "./__fixtures__/body.json"; +import cycleFixture from "./__fixtures__/cycle.json"; +import recoveryFixture from "./__fixtures__/recovery.json"; +import sleepFixture from "./__fixtures__/sleep.json"; + +const recoveries: readonly WhoopRecovery[] = recoveryFixture; +const sleeps: readonly WhoopSleep[] = sleepFixture; +const cycles: readonly WhoopCycle[] = cycleFixture; +const bodies: readonly WhoopBodyMeasurement[] = bodyFixture; + +/** The golden is written in the repository's JSON format, so the format check passes on it unchanged. */ +async function golden(value: unknown): Promise { + return format(JSON.stringify(value), { parser: "json" }); +} + +describe("WHOOP mappers against the fixture payloads", () => { + it("maps recoveries", async () => { + const output = recoveries.map((record) => ({ + sleep_id: record.sleep_id, + rows: mapRecovery(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-recovery.json", + ); + }); + + it("maps sleeps", async () => { + const output = sleeps.map((record) => ({ + id: record.id, + rows: mapSleep(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sleep.json", + ); + }); + + it("maps cycles", async () => { + const output = cycles.map((record) => ({ + id: record.id, + rows: mapCycle(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-cycle.json", + ); + }); + + it("maps body measurements", async () => { + const output = bodies.map((record) => ({ + input: record, + mapped: mapBody(record), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-body.json", + ); + }); + + it("resolves workout sports", async () => { + const inputs: Array<[number | undefined, string | undefined]> = [ + [0, "Running"], + [1, undefined], + [52, "ignored when the id resolves"], + [9999, "Hiking/Rucking"], + [undefined, "hiking_rucking"], + [undefined, " Track & Field "], + [undefined, "Functional Fitness"], + [undefined, "whoop_sport_63"], + [undefined, "Underwater Basket Weaving"], + [undefined, " "], + [9999, undefined], + [-1, undefined], + [undefined, undefined], + ]; + const output = inputs.map(([sportId, sportName]) => ({ + sportId: sportId ?? null, + sportName: sportName ?? null, + sportType: mapWhoopSportType(sportId, sportName), + })); + await expect(await golden(output)).toMatchFileSnapshot( + "./__golden__/map-sport.json", + ); + }); +});