mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
9a31be0ea8
commit
ef188e0503
15 files changed
+150
-7
No files matched your search
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user