From 341061de5c9a21ea502f3b325e0ee485807f92f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 4 Nov 2026 23:45:55 +0100 Subject: [PATCH] feat(measurements): store resting energy as its own metric HealthKit's basalEnergyBurned sat in the deferred set, so the batch route refused it per entry and acceptedTypes left it out. It now maps to a new BASAL_ENERGY_BURNED type in kcal (migration 0493), beside active energy and never added to it. It is cumulative like active energy: summed per day on the read path and in the rollups, drained into one daily row, imported from export.zip, listed and charted with its own label in every locale, part of the report selection and the backup, and readable over MCP. It carries no personal record direction and feeds no score, insight or Coach snapshot. Health Connect is left unmapped on purpose: its basal metabolic rate is a rate at one instant, not a day's energy. No other integration reports a resting figure of its own. Refs #1340 --- CHANGELOG.md | 5 + docs/api/openapi.yaml | 19 +- docs/integrations/data-import.md | 1 + messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + messages/ru.json | 1 + .../0493_basal_energy_burned/migration.sql | 10 + prisma/schema.prisma | 3 + ...doctor-report-control-gating-guard.test.ts | 4 +- src/components/day/use-day-value-format.ts | 1 + .../measurements/measurement-list-meta.ts | 2 + .../measurements/measurement-type-colors.ts | 1 + .../__tests__/report-selection-panel.test.tsx | 2 +- .../measurement-type-enum-coverage.test.ts | 4 +- src/lib/import/health-connect/mapper.ts | 8 + src/lib/insights/chart-tokens.ts | 1 + .../__tests__/apple-health-mapping.test.ts | 2 + .../healthkit-accepted-types.test.ts | 4 +- .../import-apple-health-export.test.ts | 4 +- src/lib/measurements/apple-health-mapping.ts | 16 +- src/lib/measurements/categories.ts | 1 + src/lib/measurements/display-transform.ts | 1 + src/lib/measurements/type-label-keys.ts | 1 + src/lib/measurements/unit-map.ts | 1 + src/lib/measurements/value-ranges.ts | 3 + src/lib/modules/measurement-scope.ts | 1 + src/lib/personal-records/pr-direction.ts | 8 +- .../__tests__/catalogue-guard.test.ts | 4 +- src/lib/report-selection/catalogue.ts | 2 + .../fixtures/expected-measurement-loinc.json | 6 + src/lib/signals/registry.ts | 24 +++ src/lib/validations/measurement.ts | 2 + .../__golden__/backup-parity/refusals.json | 2 +- tests/integration/basal-energy-ingest.test.ts | 172 ++++++++++++++++++ .../analytics.02-summaries-cold.json.snap | 17 +- .../analytics.03-summaries-warm.json.snap | 17 +- .../analytics.04-default-cold.json.snap | 17 +- .../analytics.05-default-warm.json.snap | 17 +- ...alytics.06-summaries-modules-off.json.snap | 17 +- ...cs.07-default-de-personal-target.json.snap | 17 +- .../analytics.08-empty-account.json.snap | 17 +- 48 files changed, 419 insertions(+), 25 deletions(-) create mode 100644 prisma/migrations/0493_basal_energy_burned/migration.sql create mode 100644 tests/integration/basal-energy-ingest.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f5436773f..2cc569fa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ - `GET /api/biomarkers/{id}/series` returns one lab marker's line, the same points and window the timeline draws for it, under the Labs module. The iOS app can show a marker's chart with the timeline switched off. (#1342) +- Resting energy from Apple Health is stored as a metric of its own, + `BASAL_ENERGY_BURNED` in kcal, next to active energy and never added to + it. It used to be skipped. It is summed per day, charted, listed with your + measurements, included in backups and exports, and readable over MCP. It + feeds no score and no insight. (#1340) ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a83cd903e..0e0af558f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4610,6 +4610,7 @@ paths: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED - in: query name: from schema: @@ -5531,6 +5532,7 @@ paths: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED description: The metric. required: true description: The metric. @@ -5660,6 +5662,7 @@ paths: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED description: The metric. required: true description: The metric. @@ -28533,7 +28536,7 @@ components: type: number const: 2 leaves: - maxItems: 110 + maxItems: 111 type: array items: type: string @@ -28595,7 +28598,7 @@ components: type: number const: 2 leaves: - maxItems: 110 + maxItems: 111 type: array items: type: string @@ -29474,6 +29477,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED value: type: number measuredAt: @@ -31207,6 +31211,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED biomarkerId: type: string minLength: 1 @@ -31706,7 +31711,7 @@ components: type: number const: 2 leaves: - maxItems: 110 + maxItems: 111 type: array items: type: string @@ -32908,6 +32913,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature). InsightsSettingsPutRequest: @@ -42213,6 +42219,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED value: type: number valueMin: @@ -42980,6 +42987,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED - type: "null" biomarkerName: anyOf: @@ -45577,6 +45585,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED dateKey: type: string pattern: ^\d{4}-\d{2}-\d{2}$ @@ -46776,6 +46785,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED - type: "null" intervalDays: anyOf: @@ -47001,6 +47011,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED metricSlot: type: - string @@ -53312,6 +53323,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED date: type: string format: date @@ -53591,6 +53603,7 @@ components: - STRESS_LEVEL - TRAINING_READINESS - ACUTE_TRAINING_LOAD + - BASAL_ENERGY_BURNED from: type: string format: date diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index af378a9d3..65d4c11b7 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -194,6 +194,7 @@ hand-authored. | `WALKING_ASYMMETRY` | percent (0–100) | | `WALKING_DOUBLE_SUPPORT` | percent (0–100) | | `ACTIVE_ENERGY_BURNED` | kcal | +| `BASAL_ENERGY_BURNED` | kcal | | `ENERGY_EXPENDITURE_KJ` | kJ | | `VO2_MAX` | mL/(kg·min) | | `PULSE_WAVE_VELOCITY` | m/s | diff --git a/messages/de.json b/messages/de.json index 679099eef..27a85f03f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Herzfrequenzvariabilität", "typeRestingHeartRate": "Ruheherzfrequenz", "typeActiveEnergyBurned": "Aktive Energie", + "typeBasalEnergyBurned": "Ruheenergie", "typeFlightsClimbed": "Treppen", "typeWalkingRunningDistance": "Geh- und Laufstrecke", "typeVo2Max": "VO₂max", diff --git a/messages/en.json b/messages/en.json index 9b216d13c..db0a99616 100644 --- a/messages/en.json +++ b/messages/en.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Heart-rate variability", "typeRestingHeartRate": "Resting heart rate", "typeActiveEnergyBurned": "Active energy", + "typeBasalEnergyBurned": "Resting energy", "typeFlightsClimbed": "Flights climbed", "typeWalkingRunningDistance": "Walking + running distance", "typeVo2Max": "VO₂ max", diff --git a/messages/es.json b/messages/es.json index 11642bb05..fa997d617 100644 --- a/messages/es.json +++ b/messages/es.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Variabilidad de la frecuencia cardiaca", "typeRestingHeartRate": "Frecuencia cardíaca en reposo", "typeActiveEnergyBurned": "Energía activa", + "typeBasalEnergyBurned": "Energía en reposo", "typeFlightsClimbed": "Escaleras", "typeWalkingRunningDistance": "Distancia a pie y corriendo", "typeVo2Max": "VO₂ máx", diff --git a/messages/fr.json b/messages/fr.json index e1fc6d686..f6dfc8411 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Variabilité de la fréquence cardiaque", "typeRestingHeartRate": "Fréquence cardiaque au repos", "typeActiveEnergyBurned": "Énergie active", + "typeBasalEnergyBurned": "Énergie au repos", "typeFlightsClimbed": "Étages", "typeWalkingRunningDistance": "Distance à pied et en course", "typeVo2Max": "VO₂ max", diff --git a/messages/it.json b/messages/it.json index 0bc50c2c7..3eaba8330 100644 --- a/messages/it.json +++ b/messages/it.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Variabilità della frequenza cardiaca", "typeRestingHeartRate": "Frequenza cardiaca a riposo", "typeActiveEnergyBurned": "Energia attiva", + "typeBasalEnergyBurned": "Energia a riposo", "typeFlightsClimbed": "Piani saliti", "typeWalkingRunningDistance": "Distanza a piedi e di corsa", "typeVo2Max": "VO₂ max", diff --git a/messages/ko.json b/messages/ko.json index 665a02dd8..3c9c8da33 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "심박변이도", "typeRestingHeartRate": "안정 시 심박수", "typeActiveEnergyBurned": "활동 에너지", + "typeBasalEnergyBurned": "휴식 에너지", "typeFlightsClimbed": "오른 층수", "typeWalkingRunningDistance": "걷기 + 달리기 거리", "typeVo2Max": "VO₂ max", diff --git a/messages/nl.json b/messages/nl.json index c029834f3..2baaa034f 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Hartslagvariabiliteit", "typeRestingHeartRate": "Rusthartslag", "typeActiveEnergyBurned": "Actieve energie", + "typeBasalEnergyBurned": "Rustenergie", "typeFlightsClimbed": "Beklommen verdiepingen", "typeWalkingRunningDistance": "Afstand wandelen + hardlopen", "typeVo2Max": "VO₂max", diff --git a/messages/pl.json b/messages/pl.json index 1bd886c92..ca998a9eb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Zmienność rytmu serca", "typeRestingHeartRate": "Tętno spoczynkowe", "typeActiveEnergyBurned": "Aktywna energia", + "typeBasalEnergyBurned": "Energia spoczynkowa", "typeFlightsClimbed": "Pokonane piętra", "typeWalkingRunningDistance": "Dystans marszu i biegu", "typeVo2Max": "VO₂ max", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 7b673743d..2c0220883 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Variabilidade da frequência cardíaca", "typeRestingHeartRate": "Frequência cardíaca em repouso", "typeActiveEnergyBurned": "Energia ativa", + "typeBasalEnergyBurned": "Energia em repouso", "typeFlightsClimbed": "Andares subidos", "typeWalkingRunningDistance": "Distância caminhada + corrida", "typeVo2Max": "VO2 máx", diff --git a/messages/ru.json b/messages/ru.json index 8a0760e91..8e1669b48 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -819,6 +819,7 @@ "typeHeartRateVariability": "Вариабельность сердечного ритма", "typeRestingHeartRate": "ЧСС в покое", "typeActiveEnergyBurned": "Активная энергия", + "typeBasalEnergyBurned": "Энергия в покое", "typeFlightsClimbed": "Этажи", "typeWalkingRunningDistance": "Расстояние ходьбы + бега", "typeVo2Max": "VO₂ max", diff --git a/prisma/migrations/0493_basal_energy_burned/migration.sql b/prisma/migrations/0493_basal_energy_burned/migration.sql new file mode 100644 index 000000000..93b642ba3 --- /dev/null +++ b/prisma/migrations/0493_basal_energy_burned/migration.sql @@ -0,0 +1,10 @@ +-- Extend `measurement_type` with resting energy, which sat in the deferred +-- list until now: +-- +-- BASAL_ENERGY_BURNED <- HKQuantityTypeIdentifierBasalEnergyBurned (kcal) +-- +-- Purely additive: no existing row carries the value. IF NOT EXISTS keeps a +-- replay on a partially applied database harmless. Enum values cannot be +-- dropped in PostgreSQL, so there is no down path. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BASAL_ENERGY_BURNED'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 950f86b99..de35a4906 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1807,6 +1807,9 @@ enum MeasurementType { STRESS_LEVEL // score (0–100) — Garmin's all-day average stress level. Device-native and kept apart from the COMPUTED STRESS_SCORE, whose readers are single-source. TRAINING_READINESS // score (0–100) — Garmin Training Readiness. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL). ACUTE_TRAINING_LOAD // load (unit-less) — Garmin acute training load. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL). + // v1.47.1 — resting energy (#1340). Its own type beside ACTIVE_ENERGY_BURNED, + // never folded into it and never summed with it into a total. + BASAL_ENERGY_BURNED // kcal — HealthKit `basalEnergyBurned`, the energy the body uses at rest; cumulative, daily SUM. @@map("measurement_type") } diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 0845b7580..52bf07926 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -70,8 +70,8 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(110); - expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(90); + expect(ALL_LEAF_IDS.length).toBe(111); + expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(91); expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20); }); diff --git a/src/components/day/use-day-value-format.ts b/src/components/day/use-day-value-format.ts index 4600ce951..87e53ab95 100644 --- a/src/components/day/use-day-value-format.ts +++ b/src/components/day/use-day-value-format.ts @@ -17,6 +17,7 @@ const WHOLE_NUMBER_TYPES: ReadonlySet = new Set([ "ACTIVITY_STEPS", "FLIGHTS_CLIMBED", "ACTIVE_ENERGY_BURNED", + "BASAL_ENERGY_BURNED", "TIME_IN_DAYLIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index c42d2733a..979843697 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -55,6 +55,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", RESTING_HEART_RATE: "measurements.typeRestingHeartRate", ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned", + BASAL_ENERGY_BURNED: "measurements.typeBasalEnergyBurned", FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed", WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance", VO2_MAX: "measurements.typeVo2Max", @@ -165,6 +166,7 @@ export const MEASUREMENT_TYPE_ICONS: Record = { HEART_RATE_VARIABILITY: HeartPulse, RESTING_HEART_RATE: Heart, ACTIVE_ENERGY_BURNED: Flame, + BASAL_ENERGY_BURNED: Flame, FLIGHTS_CLIMBED: TrendingUp, WALKING_RUNNING_DISTANCE: Footprints, VO2_MAX: Gauge, diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts index cf0bdd8a1..cd317846c 100644 --- a/src/components/measurements/measurement-type-colors.ts +++ b/src/components/measurements/measurement-type-colors.ts @@ -20,6 +20,7 @@ export const MEASUREMENT_TYPE_COLORS: Record = { HEART_RATE_VARIABILITY: "bg-chart-5/10 text-chart-5 dark:bg-chart-5/20", RESTING_HEART_RATE: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20", ACTIVE_ENERGY_BURNED: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", + BASAL_ENERGY_BURNED: "bg-chart-4/10 text-chart-4 dark:bg-chart-4/20", FLIGHTS_CLIMBED: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", WALKING_RUNNING_DISTANCE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20", VO2_MAX: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20", diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index a91463cec..d9eb39e9f 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -195,7 +195,7 @@ describe(" — the first run", () => { // ALLERGIES, ILLNESS_EPISODES, VISITS, SURGICAL_HISTORY, IMMUNIZATIONS. expect(groupChip(html, "history")).toBe("0/5"); expect(groupChip(html, "cardio")).toBe("0/16"); - expect(groupChip(html, "activity")).toBe("0/12"); + expect(groupChip(html, "activity")).toBe("0/13"); expect(groupChip(html, "sleepRecovery")).toBe("0/19"); expect(groupChip(html, "mobility")).toBe("0/11"); expect(groupChip(html, "environment")).toBe("0/7"); diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index e219696ce..f65401533 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -129,10 +129,12 @@ const EXPECTED_TYPES = [ "STRESS_LEVEL", "TRAINING_READINESS", "ACUTE_TRAINING_LOAD", + // ── resting energy (#1340) ── + "BASAL_ENERGY_BURNED", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 86 canonical measurement types", () => { + it("exposes the 91 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); diff --git a/src/lib/import/health-connect/mapper.ts b/src/lib/import/health-connect/mapper.ts index 8334942ca..05a218d55 100644 --- a/src/lib/import/health-connect/mapper.ts +++ b/src/lib/import/health-connect/mapper.ts @@ -254,6 +254,14 @@ export interface DailySumSpec { factor: number; } +/* + * `basal_metabolic_rate_record_table` is deliberately not summed here. A + * BasalMetabolicRateRecord is a rate (power) at one instant, written when an + * app recomputes it, not an interval of energy spent; summing it per day would + * count every recomputation as another day of resting energy, and spreading + * one rate over 24 hours would invent a figure the record does not hold. + * BASAL_ENERGY_BURNED stays an Apple Health day total. + */ export const DAILY_SUM_SPECS: readonly DailySumSpec[] = [ { table: "steps_record_table", diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 5b28c6a11..f35d6d054 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -39,6 +39,7 @@ export const ALLOWED_CHART_TOKENS = [ "metric:HEART_RATE_VARIABILITY", "metric:RESTING_HEART_RATE", "metric:ACTIVE_ENERGY_BURNED", + "metric:BASAL_ENERGY_BURNED", "metric:FLIGHTS_CLIMBED", "metric:WALKING_RUNNING_DISTANCE", "metric:VO2_MAX", diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 4107d841b..5e858fd51 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -783,6 +783,8 @@ describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => { [ "HKQuantityTypeIdentifierStepCount", "HKQuantityTypeIdentifierActiveEnergyBurned", + // Resting energy (#1340). + "HKQuantityTypeIdentifierBasalEnergyBurned", "HKQuantityTypeIdentifierFlightsClimbed", "HKQuantityTypeIdentifierDistanceWalkingRunning", "HKQuantityTypeIdentifierTimeInDaylight", diff --git a/src/lib/measurements/__tests__/healthkit-accepted-types.test.ts b/src/lib/measurements/__tests__/healthkit-accepted-types.test.ts index dce617d84..a24ccf39c 100644 --- a/src/lib/measurements/__tests__/healthkit-accepted-types.test.ts +++ b/src/lib/measurements/__tests__/healthkit-accepted-types.test.ts @@ -22,7 +22,9 @@ describe("acceptedHealthKitTypes", () => { expect(HK_QUANTITY_TYPE_DEFERRED.has(id), id).toBe(false); } expect(accepted).not.toContain("HKQuantityTypeIdentifierHeight"); - expect(accepted).not.toContain("HKQuantityTypeIdentifierBasalEnergyBurned"); + // Resting energy (#1340) left the deferred set for a type of + // its own, so the client is told the server takes it. + expect(accepted).toContain("HKQuantityTypeIdentifierBasalEnergyBurned"); }); it("lists only identifiers the batch mapper turns into a row", () => { diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index 5c994f5f2..2ceb05fc1 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -687,8 +687,8 @@ async function importCumulativeFixture(xml: string) { } describe("streamParseExportXml — cumulative source-day estimates", () => { - it("covers exactly the ten cumulative HealthKit measurement types", () => { - expect(CUMULATIVE_HK_TYPES.size).toBe(10); + it("covers exactly the eleven cumulative HealthKit measurement types", () => { + expect(CUMULATIVE_HK_TYPES.size).toBe(11); expect( new Set(allCumulativeMappings.map((mapping) => mapping.measurementType)), ).toEqual(CUMULATIVE_HK_TYPES); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 6e8c9cf78..02a7931a0 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -380,6 +380,17 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { convertToDbUnit: (v) => v, aggregation: "sum", }, + // Resting energy (#1340). A type of its own, never folded into + // ACTIVE_ENERGY_BURNED and never added to it: the two are different + // quantities, and no total is derived from them. + HKQuantityTypeIdentifierBasalEnergyBurned: { + hkIdentifier: "HKQuantityTypeIdentifierBasalEnergyBurned", + measurementType: "BASAL_ENERGY_BURNED", + hkUnit: "kcal", + dbUnit: "kcal", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, HKQuantityTypeIdentifierFlightsClimbed: { hkIdentifier: "HKQuantityTypeIdentifierFlightsClimbed", measurementType: "FLIGHTS_CLIMBED", @@ -894,6 +905,8 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet = new Set([ "ACTIVITY_STEPS", "ACTIVE_ENERGY_BURNED", + // Resting energy (#1340) accrues through the day the same way. + "BASAL_ENERGY_BURNED", "FLIGHTS_CLIMBED", "WALKING_RUNNING_DISTANCE", "TIME_IN_DAYLIGHT", @@ -1183,7 +1196,8 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierHeight", // already on User.heightCm // v1.10.0 — `HeartRateRecoveryOneMinute` + `AppleSleepingWristTemperature` // moved into the mapping table (CARDIO_RECOVERY + WRIST_TEMPERATURE). - "HKQuantityTypeIdentifierBasalEnergyBurned", + // `BasalEnergyBurned` moved into the mapping table as BASAL_ENERGY_BURNED + // (#1340). // Running / walking form — v1.5+ // v1.5.5 — `WalkingStepLength` + `WalkingSpeed` moved into the // mapping table (raw SI on the wire — metres and m/s respectively). diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 437442d26..1855bf5f3 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -89,6 +89,7 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // ── Activity (cumulative + fitness + mobility) ── ["ACTIVITY_STEPS", "activity"], ["ACTIVE_ENERGY_BURNED", "activity"], + ["BASAL_ENERGY_BURNED", "activity"], ["FLIGHTS_CLIMBED", "activity"], ["WALKING_RUNNING_DISTANCE", "activity"], ["VO2_MAX", "activity"], diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index c5066b275..891eeffaa 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -334,6 +334,7 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet = new Set([ // kcal + daylight minutes — whole units are the meaningful display // grain for these day-cumulative counts. "ACTIVE_ENERGY_BURNED", + "BASAL_ENERGY_BURNED", "TIME_IN_DAYLIGHT", // v1.44 — the minute rings, mindful minutes and stood hours. "EXERCISE_MINUTES", diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts index b18aa4084..b8d7315e8 100644 --- a/src/lib/measurements/type-label-keys.ts +++ b/src/lib/measurements/type-label-keys.ts @@ -29,6 +29,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", RESTING_HEART_RATE: "measurements.typeRestingHeartRate", ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned", + BASAL_ENERGY_BURNED: "measurements.typeBasalEnergyBurned", FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed", WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance", VO2_MAX: "measurements.typeVo2Max", diff --git a/src/lib/measurements/unit-map.ts b/src/lib/measurements/unit-map.ts index f1ecfa3f1..a9eef07f9 100644 --- a/src/lib/measurements/unit-map.ts +++ b/src/lib/measurements/unit-map.ts @@ -28,6 +28,7 @@ const unitMap: Record = { HEART_RATE_VARIABILITY: "ms", RESTING_HEART_RATE: "bpm", ACTIVE_ENERGY_BURNED: "kcal", + BASAL_ENERGY_BURNED: "kcal", FLIGHTS_CLIMBED: "flights", WALKING_RUNNING_DISTANCE: "m", VO2_MAX: "mL/(kg·min)", diff --git a/src/lib/measurements/value-ranges.ts b/src/lib/measurements/value-ranges.ts index 1bedd4b78..f03dd6b96 100644 --- a/src/lib/measurements/value-ranges.ts +++ b/src/lib/measurements/value-ranges.ts @@ -38,6 +38,9 @@ export const VALUE_RANGES: Record = { // Active energy kcal/sample — a daily-rollup sample tops out around // 6–8000 kcal even for ultra-endurance days. ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 }, + // Resting energy kcal/sample — a whole day at rest sits around 1200–3000 + // kcal; the same ceiling as active energy leaves room for a large body. + BASAL_ENERGY_BURNED: { min: 0, max: 10000 }, // Flights — vertical-feet/3 ≈ flights; cap generous. FLIGHTS_CLIMBED: { min: 0, max: 1000 }, // Walking/running distance per sample (metres). 200 km covers an diff --git a/src/lib/modules/measurement-scope.ts b/src/lib/modules/measurement-scope.ts index eef50035c..689b00085 100644 --- a/src/lib/modules/measurement-scope.ts +++ b/src/lib/modules/measurement-scope.ts @@ -166,6 +166,7 @@ export const UNSCOPED_REVIEWED_TYPES: ReadonlySet = "WALKING_RUNNING_DISTANCE", "FLIGHTS_CLIMBED", "ACTIVE_ENERGY_BURNED", + "BASAL_ENERGY_BURNED", "ENERGY_EXPENDITURE_KJ", // v1.44 — the exercise and stand rings are ambient movement like steps, // not workout sessions. diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index c8bce2edf..0281e5f2c 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -185,10 +185,10 @@ export function getPRDirection( case "OXYGEN_SATURATION": case "SLEEP_DURATION": case "FAT_FREE_MASS": - // v1.4.30 — audio-exposure events fire on a threshold-cross, not - // on a goal axis. A "record" for "fewest loud-listening days in - // a row" is interesting but better surfaced as a streak than a - // PR direction. Defer. + // Resting energy follows body size, not effort. + case "BASAL_ENERGY_BURNED": + // v1.4.30 — audio-exposure events fire on a threshold-cross, not on a + // goal axis; a "fewest loud days" record reads better as a streak. case "AUDIO_EXPOSURE_EVENT": // v1.5.5 — homeostatic / display-only metrics. Respiratory rate // is goal-neutral (homeostatic) and walking HR average is fitness diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index b2e716604..b46c8b72b 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -63,9 +63,9 @@ function resolves(key: string): boolean { describe("report selection catalogue", () => { it("carries every measurement type and every structured leaf", () => { - expect(MEASUREMENT_LEAF_IDS).toHaveLength(90); + expect(MEASUREMENT_LEAF_IDS).toHaveLength(91); expect(STRUCTURED_LEAF_IDS).toHaveLength(20); - expect(ALL_LEAF_IDS).toHaveLength(110); + expect(ALL_LEAF_IDS).toHaveLength(111); }); it("resolves a label for every leaf", () => { diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index c7bc9bca9..69e8c001b 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -164,6 +164,7 @@ export const MEASUREMENT_LEAF_GROUP: Record = { WALKING_RUNNING_DISTANCE: "activity", FLIGHTS_CLIMBED: "activity", ACTIVE_ENERGY_BURNED: "activity", + BASAL_ENERGY_BURNED: "activity", ENERGY_EXPENDITURE_KJ: "activity", STRAIN_SCORE: "activity", DAY_STRAIN: "activity", @@ -286,6 +287,7 @@ export const LEAF_MODULE: Partial> = { // Activity / movement series and the training-load scores. ACTIVITY_STEPS: "workouts", ACTIVE_ENERGY_BURNED: "workouts", + BASAL_ENERGY_BURNED: "workouts", EXERCISE_MINUTES: "workouts", STAND_MINUTES: "workouts", STAND_HOURS: "workouts", diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index 1c1051344..6a09e748b 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -83,6 +83,12 @@ "unit": "kcal", "category": "activity" }, + "BASAL_ENERGY_BURNED": { + "loinc": "HKQuantityTypeIdentifierBasalEnergyBurned", + "display": "Resting energy", + "unit": "kcal", + "category": "activity" + }, "WALKING_SPEED": { "loinc": "HKQuantityTypeIdentifierWalkingSpeed", "display": "Walking speed", diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index 4725626a2..d807b5c25 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -520,6 +520,30 @@ export const SIGNALS: Record = { category: "activity", }, }, + // Resting energy (#1340). Stored, charted and exported; read + // through MCP like active energy, but kept off the Coach snapshot and the + // correlations, and no score or total is built from it. + BASAL_ENERGY: { + key: "BASAL_ENERGY", + kind: "measurement", + source: { measurementType: "BASAL_ENERGY_BURNED" }, + displayName: "Resting energy", + unit: "kcal/day", + direction: "target-band", + archetype: "activity-fitness", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: false, + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierBasalEnergyBurned", + display: "Resting energy", + unit: "kcal", + category: "activity", + }, + }, FLIGHTS_CLIMBED: { key: "FLIGHTS_CLIMBED", kind: "measurement", diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 704fe1a7b..df2b1b383 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -169,6 +169,8 @@ export const measurementTypeEnum = z.enum([ // them through the ingest token (`measurements:write`, source EXTERNAL). "TRAINING_READINESS", "ACUTE_TRAINING_LOAD", + // ── resting energy (#1340), HealthKit `basalEnergyBurned` ── + "BASAL_ENERGY_BURNED", ]); /** diff --git a/tests/integration/__golden__/backup-parity/refusals.json b/tests/integration/__golden__/backup-parity/refusals.json index 90dcf66df..356704fd6 100644 --- a/tests/integration/__golden__/backup-parity/refusals.json +++ b/tests/integration/__golden__/backup-parity/refusals.json @@ -68,7 +68,7 @@ "backupId": "", "ownerId": "round-trip-owner", "reason": "schema_invalid", - "message": "measurements.0.type: Invalid option: expected one of \"WEIGHT\"|\"BLOOD_PRESSURE_SYS\"|\"BLOOD_PRESSURE_DIA\"|\"PULSE\"|\"BODY_FAT\"|\"SLEEP_DURATION\"|\"ACTIVITY_STEPS\"|\"BLOOD_GLUCOSE\"|\"TOTAL_BODY_WATER\"|\"BONE_MASS\"|\"OXYGEN_SATURATION\"|\"HEART_RATE_VARIABILITY\"|\"RESTING_HEART_RATE\"|\"ACTIVE_ENERGY_BURNED\"|\"FLIGHTS_CLIMBED\"|\"WALKING_RUNNING_DISTANCE\"|\"VO2_MAX\"|\"BODY_TEMPERATURE\"|\"FAT_FREE_MASS\"|\"FAT_MASS\"|\"MUSCLE_MASS\"|\"SKIN_TEMPERATURE\"|\"PULSE_WAVE_VELOCITY\"|\"VASCULAR_AGE\"|\"VISCERAL_FAT\"|\"AUDIO_EXPOSURE_ENV\"|\"AUDIO_EXPOSURE_HEADPHONE\"|\"TIME_IN_DAYLIGHT\"|\"WALKING_STEADINESS\"|\"AUDIO_EXPOSURE_EVENT\"|\"RESPIRATORY_RATE\"|\"BODY_MASS_INDEX\"|\"LEAN_BODY_MASS\"|\"WALKING_HEART_RATE_AVERAGE\"|\"WALKING_ASYMMETRY\"|\"WALKING_DOUBLE_SUPPORT\"|\"WALKING_STEP_LENGTH\"|\"WALKING_SPEED\"|\"CARDIO_RECOVERY\"|\"WRIST_TEMPERATURE\"|\"FALL_COUNT\"|\"SIX_MINUTE_WALK_DISTANCE\"|\"STAIR_ASCENT_SPEED\"|\"STAIR_DESCENT_SPEED\"|\"BREATHING_DISTURBANCES\"|\"IRREGULAR_RHYTHM_NOTIFICATION\"|\"HIGH_HEART_RATE_EVENT\"|\"LOW_HEART_RATE_EVENT\"|\"WALKING_STEADINESS_EVENT\"|\"BREATHING_DISTURBANCE_EVENT\"|\"RECOVERY_SCORE\"|\"STRESS_SCORE\"|\"STRAIN_SCORE\"|\"HRV_RMSSD\"|\"DAY_STRAIN\"|\"WORKOUT_STRAIN\"|\"SLEEP_PERFORMANCE\"|\"SLEEP_EFFICIENCY\"|\"SLEEP_CONSISTENCY\"|\"SLEEP_NEED\"|\"ENERGY_EXPENDITURE_KJ\"|\"AVERAGE_HEART_RATE\"|\"MAX_HEART_RATE\"|\"SLEEP_DISTURBANCE_COUNT\"|\"ANS_CHARGE\"|\"CARDIO_LOAD\"|\"SLEEP_SCORE\"|\"BODY_TEMPERATURE_DEVIATION\"|\"RESILIENCE\"|\"PHQ9_SCORE\"|\"GAD7_SCORE\"|\"GRIP_STRENGTH\"|\"PAIN_NRS\"|\"WAIST_CIRCUMFERENCE\"|\"WAIST_TO_HEIGHT\"|\"WHO5_SCORE\"|\"SCI_SCORE\"|\"EXERCISE_MINUTES\"|\"STAND_MINUTES\"|\"STAND_HOURS\"|\"MINDFUL_MINUTES\"|\"STATE_OF_MIND_VALENCE\"|\"INDOOR_CO2\"|\"INDOOR_TEMPERATURE\"|\"INDOOR_HUMIDITY\"|\"AFIB_BURDEN\"|\"BODY_BATTERY\"|\"STRESS_LEVEL\"|\"TRAINING_READINESS\"|\"ACUTE_TRAINING_LOAD\"" + "message": "measurements.0.type: Invalid option: expected one of \"WEIGHT\"|\"BLOOD_PRESSURE_SYS\"|\"BLOOD_PRESSURE_DIA\"|\"PULSE\"|\"BODY_FAT\"|\"SLEEP_DURATION\"|\"ACTIVITY_STEPS\"|\"BLOOD_GLUCOSE\"|\"TOTAL_BODY_WATER\"|\"BONE_MASS\"|\"OXYGEN_SATURATION\"|\"HEART_RATE_VARIABILITY\"|\"RESTING_HEART_RATE\"|\"ACTIVE_ENERGY_BURNED\"|\"FLIGHTS_CLIMBED\"|\"WALKING_RUNNING_DISTANCE\"|\"VO2_MAX\"|\"BODY_TEMPERATURE\"|\"FAT_FREE_MASS\"|\"FAT_MASS\"|\"MUSCLE_MASS\"|\"SKIN_TEMPERATURE\"|\"PULSE_WAVE_VELOCITY\"|\"VASCULAR_AGE\"|\"VISCERAL_FAT\"|\"AUDIO_EXPOSURE_ENV\"|\"AUDIO_EXPOSURE_HEADPHONE\"|\"TIME_IN_DAYLIGHT\"|\"WALKING_STEADINESS\"|\"AUDIO_EXPOSURE_EVENT\"|\"RESPIRATORY_RATE\"|\"BODY_MASS_INDEX\"|\"LEAN_BODY_MASS\"|\"WALKING_HEART_RATE_AVERAGE\"|\"WALKING_ASYMMETRY\"|\"WALKING_DOUBLE_SUPPORT\"|\"WALKING_STEP_LENGTH\"|\"WALKING_SPEED\"|\"CARDIO_RECOVERY\"|\"WRIST_TEMPERATURE\"|\"FALL_COUNT\"|\"SIX_MINUTE_WALK_DISTANCE\"|\"STAIR_ASCENT_SPEED\"|\"STAIR_DESCENT_SPEED\"|\"BREATHING_DISTURBANCES\"|\"IRREGULAR_RHYTHM_NOTIFICATION\"|\"HIGH_HEART_RATE_EVENT\"|\"LOW_HEART_RATE_EVENT\"|\"WALKING_STEADINESS_EVENT\"|\"BREATHING_DISTURBANCE_EVENT\"|\"RECOVERY_SCORE\"|\"STRESS_SCORE\"|\"STRAIN_SCORE\"|\"HRV_RMSSD\"|\"DAY_STRAIN\"|\"WORKOUT_STRAIN\"|\"SLEEP_PERFORMANCE\"|\"SLEEP_EFFICIENCY\"|\"SLEEP_CONSISTENCY\"|\"SLEEP_NEED\"|\"ENERGY_EXPENDITURE_KJ\"|\"AVERAGE_HEART_RATE\"|\"MAX_HEART_RATE\"|\"SLEEP_DISTURBANCE_COUNT\"|\"ANS_CHARGE\"|\"CARDIO_LOAD\"|\"SLEEP_SCORE\"|\"BODY_TEMPERATURE_DEVIATION\"|\"RESILIENCE\"|\"PHQ9_SCORE\"|\"GAD7_SCORE\"|\"GRIP_STRENGTH\"|\"PAIN_NRS\"|\"WAIST_CIRCUMFERENCE\"|\"WAIST_TO_HEIGHT\"|\"WHO5_SCORE\"|\"SCI_SCORE\"|\"EXERCISE_MINUTES\"|\"STAND_MINUTES\"|\"STAND_HOURS\"|\"MINDFUL_MINUTES\"|\"STATE_OF_MIND_VALENCE\"|\"INDOOR_CO2\"|\"INDOOR_TEMPERATURE\"|\"INDOOR_HUMIDITY\"|\"AFIB_BURDEN\"|\"BODY_BATTERY\"|\"STRESS_LEVEL\"|\"TRAINING_READINESS\"|\"ACUTE_TRAINING_LOAD\"|\"BASAL_ENERGY_BURNED\"" } } ], diff --git a/tests/integration/basal-energy-ingest.test.ts b/tests/integration/basal-energy-ingest.test.ts new file mode 100644 index 000000000..ed1608faf --- /dev/null +++ b/tests/integration/basal-energy-ingest.test.ts @@ -0,0 +1,172 @@ +/** + * Resting energy from HealthKit (#1340), against real Postgres: + * `HKQuantityTypeIdentifierBasalEnergyBurned` used to sit in the deferred set + * and was refused per entry. It is a type of its own now. The batch route + * stores it as `BASAL_ENERGY_BURNED`, apart from active energy, the row comes + * back out of a full backup restore unchanged, and `GET + * /api/integrations/healthkit` tells the app the server takes it. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { POST as RESTORE } from "./restore-job-driver"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserData: vi.fn() }; +}); + +const BASAL = "HKQuantityTypeIdentifierBasalEnergyBurned"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function signIn(): Promise { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "basal-energy-owner", + email: "basal-energy-owner@example.test", + timezone: "UTC", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return owner.id; +} + +describe("resting energy ingest (#1340)", () => { + it("lists the identifier in acceptedTypes", async () => { + await signIn(); + const { GET } = await import("@/app/api/integrations/healthkit/route"); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { acceptedTypes: string[] } }; + expect(body.data.acceptedTypes).toContain(BASAL); + }); + + it("stores a batch entry as BASAL_ENERGY_BURNED and restores it from a backup", async () => { + const userId = await signIn(); + const prisma = getPrismaClient(); + const { POST } = await import("@/app/api/measurements/batch/route"); + const at = "2026-10-01T10:00:00.000Z"; + const res = await POST( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + entries: [ + { + hkIdentifier: BASAL, + value: 1650, + unit: "kcal", + startDate: at, + endDate: at, + externalId: `stats:${BASAL}:2026-10-01`, + }, + { + hkIdentifier: "HKQuantityTypeIdentifierActiveEnergyBurned", + value: 420, + unit: "kcal", + startDate: at, + endDate: at, + externalId: + "stats:HKQuantityTypeIdentifierActiveEnergyBurned:2026-10-01", + }, + ], + }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { entries: Array<{ status: string }> }; + }; + expect(body.data.entries.map((e) => e.status)).toEqual([ + "inserted", + "inserted", + ]); + + const select = { + type: true, + value: true, + unit: true, + source: true, + externalId: true, + measuredAt: true, + } as const; + const stored = await prisma.measurement.findMany({ + where: { userId, deletedAt: null }, + orderBy: { type: "asc" }, + select, + }); + expect( + stored.map(({ type, value, unit }) => ({ type, value, unit })), + ).toEqual([ + { type: "ACTIVE_ENERGY_BURNED", value: 420, unit: "kcal" }, + { type: "BASAL_ENERGY_BURNED", value: 1650, unit: "kcal" }, + ]); + + const { payload: built } = await buildFullBackupPayload(prisma, userId, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await prisma.dataBackup.create({ + data: { + userId, + type: "BASAL_ENERGY_ROUND_TRIP", + data: encrypt(JSON.stringify(parsed)), + }, + }); + await prisma.measurement.deleteMany({ where: { userId } }); + + const restore = await RESTORE( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(restore.status).toBe(200); + + const restored = await prisma.measurement.findMany({ + where: { userId, deletedAt: null }, + orderBy: { type: "asc" }, + select, + }); + expect(restored).toEqual(stored); + }); +}); diff --git a/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap index 6024670bf..6f0a8bc35 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.02-summaries-cold.json.snap @@ -1343,6 +1343,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BASAL_ENERGY_BURNED": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": null, @@ -1469,7 +1483,8 @@ "BODY_BATTERY": null, "STRESS_LEVEL": null, "TRAINING_READINESS": null, - "ACUTE_TRAINING_LOAD": null + "ACUTE_TRAINING_LOAD": null, + "BASAL_ENERGY_BURNED": null }, "sleepSourceDiscrepancy": null, "recoveryEmpty": "general" diff --git a/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap index 2b513bcba..fd8eae42c 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.03-summaries-warm.json.snap @@ -1343,6 +1343,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BASAL_ENERGY_BURNED": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": null, @@ -1469,7 +1483,8 @@ "BODY_BATTERY": null, "STRESS_LEVEL": null, "TRAINING_READINESS": null, - "ACUTE_TRAINING_LOAD": null + "ACUTE_TRAINING_LOAD": null, + "BASAL_ENERGY_BURNED": null }, "sleepSourceDiscrepancy": null, "recoveryEmpty": "general" diff --git a/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap index 8cfb9de3d..20b1ec632 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.04-default-cold.json.snap @@ -1343,6 +1343,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BASAL_ENERGY_BURNED": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": 25.9, @@ -2485,7 +2499,8 @@ "BODY_BATTERY": null, "STRESS_LEVEL": null, "TRAINING_READINESS": null, - "ACUTE_TRAINING_LOAD": null + "ACUTE_TRAINING_LOAD": null, + "BASAL_ENERGY_BURNED": null } }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap index 0c6d65c8f..070bee47c 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.05-default-warm.json.snap @@ -1343,6 +1343,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BASAL_ENERGY_BURNED": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": 25.9, @@ -2485,7 +2499,8 @@ "BODY_BATTERY": null, "STRESS_LEVEL": null, "TRAINING_READINESS": null, - "ACUTE_TRAINING_LOAD": null + "ACUTE_TRAINING_LOAD": null, + "BASAL_ENERGY_BURNED": null } }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap index b613d8eec..0080cc059 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.06-summaries-modules-off.json.snap @@ -1193,6 +1193,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BASAL_ENERGY_BURNED": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": null, @@ -1301,7 +1315,8 @@ "BODY_BATTERY": null, "STRESS_LEVEL": null, "TRAINING_READINESS": null, - "ACUTE_TRAINING_LOAD": null + "ACUTE_TRAINING_LOAD": null, + "BASAL_ENERGY_BURNED": null }, "sleepSourceDiscrepancy": null, "recoveryEmpty": "general" diff --git a/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap index fd7861c6c..929cc4bc8 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.07-default-de-personal-target.json.snap @@ -1193,6 +1193,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BASAL_ENERGY_BURNED": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": 25.9, @@ -2149,7 +2163,8 @@ "BODY_BATTERY": null, "STRESS_LEVEL": null, "TRAINING_READINESS": null, - "ACUTE_TRAINING_LOAD": null + "ACUTE_TRAINING_LOAD": null, + "BASAL_ENERGY_BURNED": null } }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap b/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap index 81028211a..e05ff02a7 100644 --- a/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap +++ b/tests/integration/routes/parity/__snapshots__/analytics.08-empty-account.json.snap @@ -1263,6 +1263,20 @@ "slope30": null, "avg30LastMonth": null, "avg30LastYear": null + }, + "BASAL_ENERGY_BURNED": { + "count": 0, + "latest": null, + "min": null, + "max": null, + "mean": null, + "median": null, + "avg7": null, + "avg30": null, + "slope7": null, + "slope30": null, + "avg30LastMonth": null, + "avg30LastYear": null } }, "bmi": null, @@ -1632,7 +1646,8 @@ "BODY_BATTERY": null, "STRESS_LEVEL": null, "TRAINING_READINESS": null, - "ACUTE_TRAINING_LOAD": null + "ACUTE_TRAINING_LOAD": null, + "BASAL_ENERGY_BURNED": null } }, "error": null