diff --git a/messages/de.json b/messages/de.json index b8e95477c..bf9eb7544 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 82405daa4..12615cafd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 5cff9817f..037b7afba 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index e855a5951..2503a8966 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 328707c98..edeec0b39 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 8727a1bbf..42c8a52bb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0125_v1128_whoop_extra_metrics/migration.sql b/prisma/migrations/0125_v1128_whoop_extra_metrics/migration.sql new file mode 100644 index 000000000..58c713efe --- /dev/null +++ b/prisma/migrations/0125_v1128_whoop_extra_metrics/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e07cb488e..0fd25f1a6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index ad4d1ab39..b171fd450 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -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 = { 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 = { @@ -213,6 +218,13 @@ export const MEASUREMENT_TYPE_ICONS: Record = { 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 = { @@ -308,4 +320,10 @@ export const MEASUREMENT_TYPE_COLORS: Record = { 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", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 020e05f6f..dfeef8cac 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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", () => { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 1518857b5..960d28924 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -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:` 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" diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 602f1cdee..d178f693c 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -52,6 +52,14 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ "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", () => { diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 4377904fd..df0db5f29 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -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"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 33cfb7432..91d2f82fa 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -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; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 595826da8..0b843fc5b 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 = { // 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 = { // 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(