feat(measurements): add average/max heart rate + sleep disturbance types

Add three MeasurementType values for WHOOP fields a coverage audit found
uncaptured: AVERAGE_HEART_RATE and MAX_HEART_RATE (the cycle score's
whole-day aggregates, kept distinct from spot PULSE / RESTING_HEART_RATE /
WALKING_HEART_RATE_AVERAGE) and SLEEP_DISTURBANCE_COUNT (the per-night
disturbance tally). Units: bpm / bpm / count.

Wiring mirrors the existing per-type surfaces, traced from VO2_MAX and the
v1.11.0 WHOOP-native score classes:

  - canonical enum + unit + plausibility range in
    src/lib/validations/measurement.ts (every ingest / analytics / rollup
    path derives from measurementTypeEnum.options, so the mean-based rollup
    tier picks the new types up automatically).
  - Prisma enum + migration 0125 (ALTER TYPE ADD VALUE IF NOT EXISTS,
    non-transactional, idempotent — same pattern as 0111).
  - category overlay (cardiovascular for the HR pair, sleep for the count).
  - list-view label key / Lucide icon / chart-colour.
  - i18n label key in all six locales.
  - chart-token allowlist + orphan-enum stripper.
  - PersonalRecord direction (HR pair null — no clean goal axis, like
    WALKING_HEART_RATE_AVERAGE; disturbance count MIN, like
    BREATHING_DISTURBANCES).
  - enum-coverage drift guard (count 60 -> 63, doctor-PDF exclusions for the
    device-derived signals) + apple-health HK-counterpart exclusion (these
    are WHOOP-only; HealthKit ships no first-class daily-aggregate HR
    quantity nor a disturbance tally).

The types render on the measurement list + Insights chart surfaces; like the
v1.11.0 WHOOP-native scores they get no dedicated Insights sub-page (the
sub-page map stays curated) and no LOINC / FHIR mapping (not clinical-grade).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 14:45:58 +02:00
1 parent 9a31be0ea8
commit ef188e0503
15 files changed
+150 -7

No files matched your search

+4 -1
View File
@@ -473,7 +473,10 @@
"typeSleepEfficiency": "Schlafeffizienz",
"typeSleepConsistency": "Schlafkonsistenz",
"typeSleepNeed": "Schlafbedarf",
"typeEnergyExpenditureKj": "Energieverbrauch"
"typeEnergyExpenditureKj": "Energieverbrauch",
"typeAverageHeartRate": "Durchschnittlicher Puls",
"typeMaxHeartRate": "Maximaler Puls",
"typeSleepDisturbanceCount": "Schlafstörungen"
},
"mood": {
"title": "Stimmung",
+4 -1
View File
@@ -473,7 +473,10 @@
"typeSleepEfficiency": "Sleep efficiency",
"typeSleepConsistency": "Sleep consistency",
"typeSleepNeed": "Sleep need",
"typeEnergyExpenditureKj": "Energy expenditure"
"typeEnergyExpenditureKj": "Energy expenditure",
"typeAverageHeartRate": "Average heart rate",
"typeMaxHeartRate": "Maximum heart rate",
"typeSleepDisturbanceCount": "Sleep disturbances"
},
"mood": {
"title": "Mood",
+4 -1
View File
@@ -473,7 +473,10 @@
"typeSleepEfficiency": "Eficiencia del sueño",
"typeSleepConsistency": "Consistencia del sueño",
"typeSleepNeed": "Necesidad de sueño",
"typeEnergyExpenditureKj": "Gasto energético"
"typeEnergyExpenditureKj": "Gasto energético",
"typeAverageHeartRate": "Frecuencia cardíaca media",
"typeMaxHeartRate": "Frecuencia cardíaca máxima",
"typeSleepDisturbanceCount": "Interrupciones del sueño"
},
"mood": {
"title": "Estado de ánimo",
+4 -1
View File
@@ -473,7 +473,10 @@
"typeSleepEfficiency": "Efficacité du sommeil",
"typeSleepConsistency": "Régularité du sommeil",
"typeSleepNeed": "Besoin de sommeil",
"typeEnergyExpenditureKj": "Dépense énergétique"
"typeEnergyExpenditureKj": "Dépense énergétique",
"typeAverageHeartRate": "Fréquence cardiaque moyenne",
"typeMaxHeartRate": "Fréquence cardiaque maximale",
"typeSleepDisturbanceCount": "Perturbations du sommeil"
},
"mood": {
"title": "Humeur",
+4 -1
View File
@@ -473,7 +473,10 @@
"typeSleepEfficiency": "Efficienza del sonno",
"typeSleepConsistency": "Costanza del sonno",
"typeSleepNeed": "Fabbisogno di sonno",
"typeEnergyExpenditureKj": "Dispendio energetico"
"typeEnergyExpenditureKj": "Dispendio energetico",
"typeAverageHeartRate": "Frequenza cardiaca media",
"typeMaxHeartRate": "Frequenza cardiaca massima",
"typeSleepDisturbanceCount": "Disturbi del sonno"
},
"mood": {
"title": "Umore",
+4 -1
View File
@@ -473,7 +473,10 @@
"typeSleepEfficiency": "Efektywność snu",
"typeSleepConsistency": "Regularność snu",
"typeSleepNeed": "Zapotrzebowanie na sen",
"typeEnergyExpenditureKj": "Wydatek energetyczny"
"typeEnergyExpenditureKj": "Wydatek energetyczny",
"typeAverageHeartRate": "Średnie tętno",
"typeMaxHeartRate": "Maksymalne tętno",
"typeSleepDisturbanceCount": "Zakłócenia snu"
},
"mood": {
"title": "Nastrój",
@@ -0,0 +1,22 @@
-- v1.12.8 — WHOOP cycle + sleep coverage completion (additive).
--
-- Purely-additive: three new `measurement_type` enum members for WHOOP fields
-- a coverage audit found were read-and-dropped (cycle average / max heart
-- rate) or never fetched (per-night sleep disturbance count). No backfill, no
-- existing row touched.
--
-- AVERAGE_HEART_RATE / MAX_HEART_RATE land the WHOOP `cycle.score`
-- average_heart_rate / max_heart_rate (previously typed but never emitted by
-- `mapCycle`). They stay distinct from spot PULSE, RESTING_HEART_RATE, and
-- WALKING_HEART_RATE_AVERAGE — these are the day's whole-cycle aggregates.
--
-- SLEEP_DISTURBANCE_COUNT lands the WHOOP `sleep.score.stage_summary`
-- disturbance_count (per-night tally).
--
-- Postgres enum-add is non-transactional; the `IF NOT EXISTS` guards make a
-- rerun safe. Forward-only — Postgres cannot remove an enum value, so the
-- three new members stay; with no rows carrying them they are inert.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AVERAGE_HEART_RATE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'MAX_HEART_RATE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_DISTURBANCE_COUNT';
+10
View File
@@ -650,6 +650,16 @@ enum MeasurementType {
SLEEP_NEED // minutes — WHOOP `sleep.score.sleep_needed.baseline_milli` (+ debt / strain / nap components; ms→min). The model's recommended sleep duration for the night.
ENERGY_EXPENDITURE_KJ // kJ — WHOOP `cycle.score.kilojoule`. Day energy expenditure in kilojoules (WHOOP reports kJ natively; kept in kJ rather than converted to kcal so the device-native value round-trips).
// ── v1.12.8 — WHOOP cycle + sleep coverage completion (additive) ──
// Three previously-uncaptured WHOOP fields. AVERAGE_HEART_RATE / MAX_HEART_RATE
// ride the cycle score (already fetched, previously dropped); kept distinct
// from spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE because
// they are the day's whole-cycle aggregates. SLEEP_DISTURBANCE_COUNT is the
// per-night `stage_summary.disturbance_count`.
AVERAGE_HEART_RATE // bpm — WHOOP `cycle.score.average_heart_rate`. The day's whole-cycle average heart rate.
MAX_HEART_RATE // bpm — WHOOP `cycle.score.max_heart_rate`. The day's whole-cycle peak heart rate.
SLEEP_DISTURBANCE_COUNT // count — WHOOP `sleep.score.stage_summary.disturbance_count`. Per-night tally of sleep disturbances.
@@map("measurement_type")
}
@@ -34,6 +34,7 @@ import {
Headphones,
Sun,
PersonStanding,
Waves,
type LucideIcon,
} from "lucide-react";
@@ -109,6 +110,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
SLEEP_CONSISTENCY: "measurements.typeSleepConsistency",
SLEEP_NEED: "measurements.typeSleepNeed",
ENERGY_EXPENDITURE_KJ: "measurements.typeEnergyExpenditureKj",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
AVERAGE_HEART_RATE: "measurements.typeAverageHeartRate",
MAX_HEART_RATE: "measurements.typeMaxHeartRate",
SLEEP_DISTURBANCE_COUNT: "measurements.typeSleepDisturbanceCount",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -213,6 +218,13 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
SLEEP_CONSISTENCY: Moon,
SLEEP_NEED: Moon,
ENERGY_EXPENDITURE_KJ: Flame,
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
// HeartPulse + Heart carry the daily-aggregate cardiac pair (same family
// as the other heart-rate signals); Waves reads as the per-night
// sleep-disturbance signal.
AVERAGE_HEART_RATE: HeartPulse,
MAX_HEART_RATE: Heart,
SLEEP_DISTURBANCE_COUNT: Waves,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -308,4 +320,10 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
SLEEP_CONSISTENCY: "bg-chart-2/20 text-chart-2",
SLEEP_NEED: "bg-chart-2/20 text-chart-2",
ENERGY_EXPENDITURE_KJ: "bg-chart-4/20 text-chart-4",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
// chart-3 (cardio family) for the daily-aggregate heart-rate pair;
// chart-2 (sleep/activity family) for the per-night disturbance count.
AVERAGE_HEART_RATE: "bg-chart-3/20 text-chart-3",
MAX_HEART_RATE: "bg-chart-3/20 text-chart-3",
SLEEP_DISTURBANCE_COUNT: "bg-chart-2/20 text-chart-2",
};
@@ -86,10 +86,14 @@ const EXPECTED_TYPES = [
"SLEEP_CONSISTENCY",
"SLEEP_NEED",
"ENERGY_EXPENDITURE_KJ",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"SLEEP_DISTURBANCE_COUNT",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 60 canonical measurement types", () => {
it("exposes the 63 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -202,6 +206,15 @@ describe("measurementTypeEnum coverage", () => {
"SLEEP_CONSISTENCY",
"SLEEP_NEED",
"ENERGY_EXPENDITURE_KJ",
// v1.12.8 — WHOOP cycle + sleep coverage completion. The daily-aggregate
// heart-rate pair and the per-night disturbance count are device-derived
// WHOOP signals, not measured clinical vitals; they surface on the
// measurement list + Insights chart surfaces with a "descriptive, not
// clinical" framing and never belong in the clinical vitals PDF. See
// doctor-report-pdf-core.ts for the matching exclusion rationale.
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"SLEEP_DISTURBANCE_COUNT",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+11
View File
@@ -109,6 +109,12 @@ export const ALLOWED_CHART_TOKENS = [
"metric:SLEEP_CONSISTENCY",
"metric:SLEEP_NEED",
"metric:ENERGY_EXPENDITURE_KJ",
// v1.12.8 — WHOOP cycle + sleep coverage completion. Continuous daily
// series, so each carries a `metric:<TYPE>` token and renders through the
// generic chart renderer.
"metric:AVERAGE_HEART_RATE",
"metric:MAX_HEART_RATE",
"metric:SLEEP_DISTURBANCE_COUNT",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -219,6 +225,11 @@ const ORPHAN_ENUMS = [
"SLEEP_CONSISTENCY",
"SLEEP_NEED",
"ENERGY_EXPENDITURE_KJ",
// v1.12.8 — WHOOP cycle + sleep coverage completion, same shape: strip the
// bare enum name if the model drops it into prose.
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"SLEEP_DISTURBANCE_COUNT",
] as const;
// `\b` boundaries keep ordinary English prose untouched — "weight"
@@ -52,6 +52,14 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
"SLEEP_CONSISTENCY",
"SLEEP_NEED",
"ENERGY_EXPENDITURE_KJ",
// v1.12.8 — WHOOP cycle + sleep coverage completion. These ingest
// server-side from the WHOOP API (source = WHOOP), never from HealthKit:
// Apple ships no first-class daily-aggregate average / max heart-rate
// quantity, and the per-night disturbance count is WHOOP-specific (Apple
// ships sleep stages, not a disturbance tally). No HK mapping by design.
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"SLEEP_DISTURBANCE_COUNT",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
+8
View File
@@ -180,6 +180,14 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
// Day energy expenditure is a cumulative activity metric (kJ analogue of
// ACTIVE_ENERGY_BURNED).
["ENERGY_EXPENDITURE_KJ", "activity"],
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
// Daily average / max heart rate join the derived cardiac surface
// alongside RESTING_HEART_RATE + HRV (whole-cycle aggregates, not spot
// readings). The per-night disturbance count sits in the sleep cluster.
["AVERAGE_HEART_RATE", "cardiovascular"],
["MAX_HEART_RATE", "cardiovascular"],
["SLEEP_DISTURBANCE_COUNT", "sleep"],
]);
/**
+10
View File
@@ -98,6 +98,10 @@ export function getPRDirection(
// events, and fewer is unambiguously the goal.
case "FALL_COUNT":
case "BREATHING_DISTURBANCES":
// v1.12.8 — the per-night sleep disturbance count reads MIN: the
// "record" is the night with the fewest disturbances, and fewer is
// unambiguously the goal (mirrors BREATHING_DISTURBANCES).
case "SLEEP_DISTURBANCE_COUNT":
return PersonalRecordDirection.MIN;
// Explicitly no PersonalRecord — see comment block above.
@@ -166,6 +170,12 @@ export function getPRDirection(
case "SLEEP_CONSISTENCY":
case "SLEEP_NEED":
case "ENERGY_EXPENDITURE_KJ":
// v1.12.8 — the daily average / max heart rate are whole-cycle
// aggregates without a clean goal axis: a higher value can mean either
// a harder-effort day or an ailing one, exactly like the
// WALKING_HEART_RATE_AVERAGE case above. Defer to a null PR direction.
case "AVERAGE_HEART_RATE":
case "MAX_HEART_RATE":
return null;
}
}
+25
View File
@@ -90,6 +90,15 @@ export const measurementTypeEnum = z.enum([
"SLEEP_CONSISTENCY",
"SLEEP_NEED",
"ENERGY_EXPENDITURE_KJ",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion (additive) ──
// Daily average / max heart rate ride WHOOP's cycle score (previously
// fetched but dropped). Kept distinct from spot PULSE / RESTING_HEART_RATE /
// WALKING_HEART_RATE_AVERAGE: these are the day's whole-cycle aggregates,
// not a point-in-time or context-specific reading. Sleep disturbance count
// is the per-night WHOOP `stage_summary.disturbance_count`.
"AVERAGE_HEART_RATE",
"MAX_HEART_RATE",
"SLEEP_DISTURBANCE_COUNT",
]);
/**
@@ -283,6 +292,13 @@ const unitMap: Record<string, string> = {
// Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the
// device value round-trips rather than being converted to kcal).
ENERGY_EXPENDITURE_KJ: "kJ",
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
// Daily average / max heart rate bpm — whole-cycle aggregates, distinct
// from the spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE.
AVERAGE_HEART_RATE: "bpm",
MAX_HEART_RATE: "bpm",
// Per-night sleep disturbance tally — a plain integer count.
SLEEP_DISTURBANCE_COUNT: "count",
};
export function getUnitForType(type: string): string {
@@ -449,6 +465,15 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// Day energy expenditure in kJ — 50 000 kJ (~12 000 kcal) is a generous
// ceiling over any plausible ultra-endurance day.
ENERGY_EXPENDITURE_KJ: { min: 0, max: 50000 },
// ── v1.12.8 — WHOOP cycle + sleep coverage completion ──
// Daily average / max heart rate bpm — same plausibility band as the
// spot PULSE: endurance athletes touch the 30s at rest, severe
// tachycardia caps below 300.
AVERAGE_HEART_RATE: { min: 20, max: 300 },
MAX_HEART_RATE: { min: 20, max: 300 },
// Per-night sleep disturbance count — 0 is an undisturbed night; 200 is a
// generous ceiling over any plausible severely-fragmented night.
SLEEP_DISTURBANCE_COUNT: { min: 0, max: 200 },
};
export function validateMeasurementRange(