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
This commit is contained in:
Marc-André Bombeck committed 2026-11-04 23:45:55 +01:00
1 parent 4894f5be5b
commit 341061de5c
48 files changed
+419 -25

No files matched your search

+5
View File
@@ -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
+16 -3
View File
@@ -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
+1
View File
@@ -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 |
+1
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Herzfrequenzvariabilität",
"typeRestingHeartRate": "Ruheherzfrequenz",
"typeActiveEnergyBurned": "Aktive Energie",
"typeBasalEnergyBurned": "Ruheenergie",
"typeFlightsClimbed": "Treppen",
"typeWalkingRunningDistance": "Geh- und Laufstrecke",
"typeVo2Max": "VO₂max",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "심박변이도",
"typeRestingHeartRate": "안정 시 심박수",
"typeActiveEnergyBurned": "활동 에너지",
"typeBasalEnergyBurned": "휴식 에너지",
"typeFlightsClimbed": "오른 층수",
"typeWalkingRunningDistance": "걷기 + 달리기 거리",
"typeVo2Max": "VO₂ max",
+1
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Hartslagvariabiliteit",
"typeRestingHeartRate": "Rusthartslag",
"typeActiveEnergyBurned": "Actieve energie",
"typeBasalEnergyBurned": "Rustenergie",
"typeFlightsClimbed": "Beklommen verdiepingen",
"typeWalkingRunningDistance": "Afstand wandelen + hardlopen",
"typeVo2Max": "VO₂max",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -819,6 +819,7 @@
"typeHeartRateVariability": "Вариабельность сердечного ритма",
"typeRestingHeartRate": "ЧСС в покое",
"typeActiveEnergyBurned": "Активная энергия",
"typeBasalEnergyBurned": "Энергия в покое",
"typeFlightsClimbed": "Этажи",
"typeWalkingRunningDistance": "Расстояние ходьбы + бега",
"typeVo2Max": "VO₂ max",
@@ -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';
+3
View File
@@ -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")
}
@@ -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);
});
@@ -17,6 +17,7 @@ const WHOLE_NUMBER_TYPES: ReadonlySet<string> = new Set([
"ACTIVITY_STEPS",
"FLIGHTS_CLIMBED",
"ACTIVE_ENERGY_BURNED",
"BASAL_ENERGY_BURNED",
"TIME_IN_DAYLIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
@@ -55,6 +55,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
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<string, LucideIcon> = {
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,
@@ -20,6 +20,7 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
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",
@@ -195,7 +195,7 @@ describe("<HealthRecordExportPanel> — 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");
@@ -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(),
);
+8
View File
@@ -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",
+1
View File
@@ -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",
@@ -783,6 +783,8 @@ describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => {
[
"HKQuantityTypeIdentifierStepCount",
"HKQuantityTypeIdentifierActiveEnergyBurned",
// Resting energy (#1340).
"HKQuantityTypeIdentifierBasalEnergyBurned",
"HKQuantityTypeIdentifierFlightsClimbed",
"HKQuantityTypeIdentifierDistanceWalkingRunning",
"HKQuantityTypeIdentifierTimeInDaylight",
@@ -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", () => {
@@ -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);
+15 -1
View File
@@ -380,6 +380,17 @@ export const APPLE_HEALTH_TYPE_MAP: Record<string, AppleHealthMapping> = {
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<MeasurementType> =
new Set<MeasurementType>([
"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<string>([
"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).
+1
View File
@@ -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"],
@@ -334,6 +334,7 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet<string> = 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",
+1
View File
@@ -29,6 +29,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<MeasurementType, string> = {
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",
+1
View File
@@ -28,6 +28,7 @@ const unitMap: Record<string, string> = {
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)",
+3
View File
@@ -38,6 +38,9 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// 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
+1
View File
@@ -166,6 +166,7 @@ export const UNSCOPED_REVIEWED_TYPES: ReadonlySet<MeasurementType> =
"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.
+4 -4
View File
@@ -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
@@ -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", () => {
+2
View File
@@ -164,6 +164,7 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
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<Record<ReportLeafId, ModuleKey>> = {
// 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",
@@ -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",
+24
View File
@@ -520,6 +520,30 @@ export const SIGNALS: Record<string, SignalDefinition> = {
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",
+2
View File
@@ -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",
]);
/**
@@ -68,7 +68,7 @@
"backupId": "<id:1>",
"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\""
}
}
],
@@ -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<typeof import("@/lib/cache/invalidate")>();
return { ...actual, invalidateUserData: vi.fn() };
});
const BASAL = "HKQuantityTypeIdentifierBasalEnergyBurned";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
async function signIn(): Promise<string> {
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<typeof RESTORE>[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);
});
});
@@ -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"
@@ -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"
@@ -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
@@ -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
@@ -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"
@@ -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
@@ -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