From 3cdbab908c003a3f18997c28af1c5a1c3c1496ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 00:09:33 +0200 Subject: [PATCH] feat(measurements): add Sleep Score and body-temperature deviation types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additive measurement_type values for the Oura coverage completion: - SLEEP_SCORE — the headline 0-100 sleep-quality score (unit score), kept apart from the WHOOP sleep sub-scores so two vendors' headline numbers never share a bucket. - BODY_TEMPERATURE_DEVIATION — a signed nightly body-temperature offset from the personal baseline (celsius). It is a deviation, not an absolute reading, so it cannot share the body / skin / wrist temperature buckets. Migration 0165 extends the enum (additive, no backfill). Register both in the validation enum, units, plausibility ranges, category overlay, list-UI label/icon/colour maps, chart-token allowlist, and the PR-direction switch (both resolve to no record direction). Both are descriptive signals, not clinical vitals, so they stay out of the doctor-report PDF. Localise the two list labels across all six locales and regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 10 +++++++++ messages/de.json | 2 ++ messages/en.json | 2 ++ messages/es.json | 2 ++ messages/fr.json | 2 ++ messages/it.json | 2 ++ messages/pl.json | 2 ++ .../0165_v1171_oura_coverage/migration.sql | 20 +++++++++++++++++ prisma/schema.prisma | 13 +++++++++++ .../measurements/measurement-list-meta.ts | 11 ++++++++++ .../measurement-type-enum-coverage.test.ts | 13 ++++++++++- src/lib/doctor-report-pdf-core.ts | 5 +++++ src/lib/insights/chart-tokens.ts | 8 +++++++ .../__tests__/apple-health-mapping.test.ts | 7 ++++++ src/lib/measurements/categories.ts | 7 ++++++ src/lib/personal-records/pr-direction.ts | 6 +++++ src/lib/validations/measurement.ts | 22 +++++++++++++++++++ 17 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0165_v1171_oura_coverage/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f06755f3a..fad161b93 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1569,6 +1569,8 @@ paths: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION - in: query name: from schema: @@ -1743,6 +1745,8 @@ paths: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION value: type: number measuredAt: @@ -4372,6 +4376,8 @@ paths: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -8831,6 +8837,8 @@ components: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION value: type: number unit: @@ -9260,6 +9268,8 @@ components: - AVERAGE_HEART_RATE - MAX_HEART_RATE - SLEEP_DISTURBANCE_COUNT + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION value: type: number unit: diff --git a/messages/de.json b/messages/de.json index a00c1da74..30886d964 100644 --- a/messages/de.json +++ b/messages/de.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Durchschnittlicher Puls", "typeMaxHeartRate": "Maximaler Puls", "typeSleepDisturbanceCount": "Schlafstörungen", + "typeSleepScore": "Schlafscore", + "typeBodyTemperatureDeviation": "Körpertemperaturabweichung", "filterBySource": "Nach Quelle filtern", "sourceComputed": "Berechnet", "sourceWhoop": "WHOOP", diff --git a/messages/en.json b/messages/en.json index 982cde664..b31b33f01 100644 --- a/messages/en.json +++ b/messages/en.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Average heart rate", "typeMaxHeartRate": "Maximum heart rate", "typeSleepDisturbanceCount": "Sleep disturbances", + "typeSleepScore": "Sleep score", + "typeBodyTemperatureDeviation": "Body temperature deviation", "filterBySource": "Filter by source", "sourceComputed": "Computed", "sourceWhoop": "WHOOP", diff --git a/messages/es.json b/messages/es.json index d8a572cc4..4f484ffa0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Frecuencia cardíaca media", "typeMaxHeartRate": "Frecuencia cardíaca máxima", "typeSleepDisturbanceCount": "Interrupciones del sueño", + "typeSleepScore": "Puntuación de sueño", + "typeBodyTemperatureDeviation": "Desviación de temperatura corporal", "filterBySource": "Filtrar por fuente", "sourceComputed": "Calculado", "sourceWhoop": "WHOOP", diff --git a/messages/fr.json b/messages/fr.json index 52f5b9d53..d5f399c88 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Fréquence cardiaque moyenne", "typeMaxHeartRate": "Fréquence cardiaque maximale", "typeSleepDisturbanceCount": "Perturbations du sommeil", + "typeSleepScore": "Score de sommeil", + "typeBodyTemperatureDeviation": "Écart de température corporelle", "filterBySource": "Filtrer par source", "sourceComputed": "Calculé", "sourceWhoop": "WHOOP", diff --git a/messages/it.json b/messages/it.json index 7c50a8d29..4a2f1d911 100644 --- a/messages/it.json +++ b/messages/it.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Frequenza cardiaca media", "typeMaxHeartRate": "Frequenza cardiaca massima", "typeSleepDisturbanceCount": "Disturbi del sonno", + "typeSleepScore": "Punteggio del sonno", + "typeBodyTemperatureDeviation": "Deviazione della temperatura corporea", "filterBySource": "Filtra per fonte", "sourceComputed": "Calcolato", "sourceWhoop": "WHOOP", diff --git a/messages/pl.json b/messages/pl.json index f96c44bb7..bce360ade 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -550,6 +550,8 @@ "typeAverageHeartRate": "Średnie tętno", "typeMaxHeartRate": "Maksymalne tętno", "typeSleepDisturbanceCount": "Zakłócenia snu", + "typeSleepScore": "Wynik snu", + "typeBodyTemperatureDeviation": "Odchylenie temperatury ciała", "filterBySource": "Filtruj według źródła", "sourceComputed": "Obliczone", "sourceWhoop": "WHOOP", diff --git a/prisma/migrations/0165_v1171_oura_coverage/migration.sql b/prisma/migrations/0165_v1171_oura_coverage/migration.sql new file mode 100644 index 000000000..037c7c58f --- /dev/null +++ b/prisma/migrations/0165_v1171_oura_coverage/migration.sql @@ -0,0 +1,20 @@ +-- v1.17.1 — Oura coverage completion (additive). +-- +-- Two new `measurement_type` enum values so the Oura sync can store signals it +-- already fetches (or now fetches) but had no home for: +-- +-- 1. `SLEEP_SCORE` — Oura's headline 0–100 Sleep Score from the +-- `daily_sleep` collection (distinct from the per-stage `sleep` detail). +-- Kept apart from the WHOOP `SLEEP_PERFORMANCE` / `SLEEP_EFFICIENCY` +-- sub-scores so two vendors' headline numbers never share a bucket. +-- 2. `BODY_TEMPERATURE_DEVIATION` — Oura's nightly body-temperature deviation +-- from the user's personal baseline (`daily_readiness.temperature_deviation`), +-- a SIGNED °C offset (illness / luteal-phase / stress signal). It is NOT an +-- absolute reading, so it cannot share the BODY_TEMPERATURE / +-- SKIN_TEMPERATURE / WRIST_TEMPERATURE absolute-temperature buckets. +-- +-- Purely-additive: two enum extensions, no backfill, no existing row touched, +-- no new tables or columns. Both stored as `source = OURA`. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_SCORE'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_TEMPERATURE_DEVIATION'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c95adf984..dbed75e86 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -774,6 +774,19 @@ enum MeasurementType { 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. + // ── v1.17.1 — Oura coverage completion (additive) ── + // Oura's headline 0–100 Sleep Score from the `daily_sleep` collection + // (distinct from the per-stage `sleep` detail). Stored as `source = OURA`, + // unit `score`. Kept apart from SLEEP_PERFORMANCE / SLEEP_EFFICIENCY (WHOOP + // sub-scores) so two vendors' headline numbers never share a bucket. + SLEEP_SCORE // score (0–100) — Oura `daily_sleep.score`. Per-night sleep quality headline. + // Oura nightly body-temperature deviation from the user's personal baseline + // (`daily_readiness.temperature_deviation`), in °C. NOT an absolute + // temperature — a signed offset (illness / luteal-phase / stress signal), + // so it cannot share the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE + // absolute-reading buckets. Plausible band is small and centred on 0. + BODY_TEMPERATURE_DEVIATION // celsius (signed deviation) — Oura `daily_readiness.temperature_deviation`. + @@map("measurement_type") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index b171fd450..9640a7a8c 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -114,6 +114,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { AVERAGE_HEART_RATE: "measurements.typeAverageHeartRate", MAX_HEART_RATE: "measurements.typeMaxHeartRate", SLEEP_DISTURBANCE_COUNT: "measurements.typeSleepDisturbanceCount", + // ── v1.17.1 — Oura coverage completion ── + SLEEP_SCORE: "measurements.typeSleepScore", + BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -225,6 +228,9 @@ export const MEASUREMENT_TYPE_ICONS: Record = { AVERAGE_HEART_RATE: HeartPulse, MAX_HEART_RATE: Heart, SLEEP_DISTURBANCE_COUNT: Waves, + // ── v1.17.1 — Oura coverage completion ── + SLEEP_SCORE: Moon, + BODY_TEMPERATURE_DEVIATION: Thermometer, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -326,4 +332,9 @@ export const MEASUREMENT_TYPE_COLORS: Record = { 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", + // ── v1.17.1 — Oura coverage completion ── + // chart-2 (sleep family) for the Sleep Score; chart-5 (metabolic/thermal) + // for the body-temperature deviation. + SLEEP_SCORE: "bg-chart-2/20 text-chart-2", + BODY_TEMPERATURE_DEVIATION: "bg-chart-5/20 text-chart-5", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index dfeef8cac..e0f4fa2f4 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -90,10 +90,13 @@ const EXPECTED_TYPES = [ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // ── v1.17.1 — Oura coverage completion ── + "SLEEP_SCORE", + "BODY_TEMPERATURE_DEVIATION", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 63 canonical measurement types", () => { + it("exposes the 65 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -215,6 +218,14 @@ describe("measurementTypeEnum coverage", () => { "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Oura coverage completion. The Sleep Score is a nightly + // derived composite and the body-temperature deviation is a signed + // baseline offset (illness / cycle / stress signal), not a measured + // clinical vital — both surface on the Insights / measurement-list + // surfaces and never belong in the clinical vitals PDF. See + // doctor-report-pdf-core.ts for the matching exclusion rationale. + "SLEEP_SCORE", + "BODY_TEMPERATURE_DEVIATION", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index db9bd0fb9..36ea49480 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -188,6 +188,11 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { * and carry a "descriptive, not a clinical assessment" disclaimer. They stay * out of this table by design (paired with `PDF_VITAL_EXCLUSIONS` in * `measurement-type-enum-coverage.test.ts`). + * + * v1.17.1 — Oura coverage completion. `SLEEP_SCORE` (a nightly derived + * composite) and `BODY_TEMPERATURE_DEVIATION` (a signed baseline offset, not an + * absolute reading) are excluded for the same reason as the other derived / + * lifestyle signals — they are descriptive, not measured clinical vitals. */ export const DOCTOR_REPORT_VITAL_TYPES = [ "WEIGHT", diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 5c1e104cf..fb1b6c882 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -115,6 +115,10 @@ export const ALLOWED_CHART_TOKENS = [ "metric:AVERAGE_HEART_RATE", "metric:MAX_HEART_RATE", "metric:SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Oura coverage completion. Both are continuous nightly series, so + // each carries a `metric:` token and renders through the generic chart. + "metric:SLEEP_SCORE", + "metric:BODY_TEMPERATURE_DEVIATION", ] as const; export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number]; @@ -230,6 +234,10 @@ const ORPHAN_ENUMS = [ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Oura coverage completion, same shape: strip the bare enum name + // if the model drops it into prose. + "SLEEP_SCORE", + "BODY_TEMPERATURE_DEVIATION", ] 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 2687f37b9..59e8b9228 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -60,6 +60,13 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // v1.17.1 — Oura coverage completion. The Sleep Score and the body-temperature + // deviation ingest server-side from the Oura API (source = OURA), never from + // HealthKit: Apple ships no headline 0–100 sleep score and surfaces only an + // absolute wrist temperature, not a signed baseline deviation. No HK mapping + // by design. + "SLEEP_SCORE", + "BODY_TEMPERATURE_DEVIATION", ]); describe("APPLE_HEALTH_TYPE_MAP", () => { diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index df0db5f29..3002cbdf9 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -188,6 +188,13 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["AVERAGE_HEART_RATE", "cardiovascular"], ["MAX_HEART_RATE", "cardiovascular"], ["SLEEP_DISTURBANCE_COUNT", "sleep"], + + // ── v1.17.1 — Oura coverage completion ── + // The Sleep Score is a per-night sleep-quality index → sleep cluster. + ["SLEEP_SCORE", "sleep"], + // Body-temperature deviation is an overnight thermal signal that pairs with + // skin / wrist temperature in the metabolic cluster. + ["BODY_TEMPERATURE_DEVIATION", "metabolic"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 91d2f82fa..eb2ed0fdb 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -176,6 +176,12 @@ export function getPRDirection( // WALKING_HEART_RATE_AVERAGE case above. Defer to a null PR direction. case "AVERAGE_HEART_RATE": case "MAX_HEART_RATE": + // v1.17.1 — Oura coverage. The Sleep Score is a nightly derived composite + // (like the other sleep-quality indices), and the body-temperature + // deviation is a homeostatic signed offset centred on 0 — neither has a + // goal axis, so both stay null like the rest of the derived/homeostatic set. + case "SLEEP_SCORE": + case "BODY_TEMPERATURE_DEVIATION": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index a10432006..c0d80f33e 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -100,6 +100,15 @@ export const measurementTypeEnum = z.enum([ "AVERAGE_HEART_RATE", "MAX_HEART_RATE", "SLEEP_DISTURBANCE_COUNT", + // ── v1.17.1 — Oura coverage completion (additive) ── + // Oura's headline 0–100 Sleep Score (`daily_sleep.score`), distinct from the + // WHOOP SLEEP_PERFORMANCE / SLEEP_EFFICIENCY sub-scores. The body-temperature + // deviation is a SIGNED °C offset from the user's baseline + // (`daily_readiness.temperature_deviation`), not an absolute reading, so it + // never shares the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE + // buckets. Both ingest server-side as `source = OURA`. + "SLEEP_SCORE", + "BODY_TEMPERATURE_DEVIATION", ]); /** @@ -312,6 +321,11 @@ const unitMap: Record = { MAX_HEART_RATE: "bpm", // Per-night sleep disturbance tally — a plain integer count. SLEEP_DISTURBANCE_COUNT: "count", + // ── v1.17.1 — Oura coverage completion ── + // Oura's headline 0–100 Sleep Score — bare "score" like the other 0–100 scores. + SLEEP_SCORE: "score", + // Signed body-temperature deviation in °C (Oura nightly baseline offset). + BODY_TEMPERATURE_DEVIATION: "celsius", }; export function getUnitForType(type: string): string { @@ -487,6 +501,14 @@ const VALUE_RANGES: Record = { // 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 }, + // ── v1.17.1 — Oura coverage completion ── + // Sleep Score (0–100), same band as the other score classes. + SLEEP_SCORE: { min: 0, max: 100 }, + // Body-temperature deviation (°C) — a signed nightly offset centred on 0. + // Oura clamps its own display near ±1 °C; ±5 °C is a generous band that + // rejects an obvious sensor glitch while keeping every real illness / + // luteal-phase swing. + BODY_TEMPERATURE_DEVIATION: { min: -5, max: 5 }, }; export function validateMeasurementRange(