feat(measurements): add Sleep Score and body-temperature deviation types

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:09:33 +02:00
1 parent 44eed87e89
commit 3cdbab908c
17 files changed
+133 -1

No files matched your search

+10
View File
@@ -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:
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
@@ -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';
+13
View File
@@ -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")
}
@@ -114,6 +114,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
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<string, LucideIcon> = {
@@ -225,6 +228,9 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
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<string, string> = {
@@ -326,4 +332,9 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
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",
};
@@ -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", () => {
+5
View File
@@ -188,6 +188,11 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
* 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",
+8
View File
@@ -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:<TYPE>` 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"
@@ -60,6 +60,13 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
"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", () => {
+7
View File
@@ -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"],
]);
/**
+6
View File
@@ -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;
}
}
+22
View File
@@ -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<string, string> = {
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<string, { min: number; max: number }> = {
// 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(