feat(measurements): add a COMPUTED source and first-class score types

Add a server-owned COMPUTED measurement source for rows a server engine
derives rather than a device or a person reports, plus three first-class
0-100 score types — RECOVERY_SCORE, STRESS_SCORE, STRAIN_SCORE (unit
"score", plausibility range 0..100).

COMPUTED is read-only from a client's perspective: it joins the read /
decode enum so the native client can decode the rows it queries and
charts, but every client-facing write surface rejects it exactly like
WITHINGS and IMPORT — the batch ingest allowlist stays {APPLE_HEALTH,
MANUAL} and the single-entry POST 422s a COMPUTED source.

All three score types thread through every type-exhaustiveness site: the
unit + plausibility-range maps, the PR-direction resolver (scores carry
no record direction, like the device events), the category overlay (a
new "scores" cluster), the list-view label / icon / colour maps with
locale strings across all six bundles, and the insight chart-token
allowlist + orphan-enum stripper. They are not clinical vitals, so they
stay out of the doctor-report PDF.

Only the recovery score is computed today; the stress and strain types
are defined now so the engines that compute them later need no schema
change. Migration 0106 is purely additive (ADD VALUE IF NOT EXISTS).
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 22:31:21 +02:00
1 parent 51fc48f93c
commit 33b75361f3
18 files changed
+374 -9

No files matched your search

+105
View File
@@ -390,6 +390,9 @@ paths:
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
- in: query
name: from
schema:
@@ -535,6 +538,9 @@ paths:
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
value:
type: number
measuredAt:
@@ -552,6 +558,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
glucoseContext:
type: string
enum:
@@ -801,6 +808,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
externalId:
type: string
maxLength: 128
@@ -1411,6 +1419,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
activeEnergy:
maxItems: 8
type: array
@@ -1421,6 +1430,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
walkingRunningDistance:
maxItems: 8
type: array
@@ -1431,6 +1441,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
flightsClimbed:
maxItems: 8
type: array
@@ -1441,6 +1452,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
sleep:
maxItems: 8
type: array
@@ -1451,6 +1463,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
weight:
maxItems: 8
type: array
@@ -1461,6 +1474,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bloodPressure:
maxItems: 8
type: array
@@ -1471,6 +1485,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
pulse:
maxItems: 8
type: array
@@ -1481,6 +1496,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyFat:
maxItems: 8
type: array
@@ -1491,6 +1507,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyTemperature:
maxItems: 8
type: array
@@ -1501,6 +1518,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
spo2:
maxItems: 8
type: array
@@ -1511,6 +1529,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
hrv:
maxItems: 8
type: array
@@ -1521,6 +1540,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
restingHeartRate:
maxItems: 8
type: array
@@ -1531,6 +1551,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
vo2Max:
maxItems: 8
type: array
@@ -1541,6 +1562,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
metricPriority:
type: object
properties:
@@ -1554,6 +1576,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
activeEnergy:
maxItems: 8
type: array
@@ -1564,6 +1587,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
walkingRunningDistance:
maxItems: 8
type: array
@@ -1574,6 +1598,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
flightsClimbed:
maxItems: 8
type: array
@@ -1584,6 +1609,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
sleep:
maxItems: 8
type: array
@@ -1594,6 +1620,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
weight:
maxItems: 8
type: array
@@ -1604,6 +1631,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bloodPressure:
maxItems: 8
type: array
@@ -1614,6 +1642,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
pulse:
maxItems: 8
type: array
@@ -1624,6 +1653,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyFat:
maxItems: 8
type: array
@@ -1634,6 +1664,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyTemperature:
maxItems: 8
type: array
@@ -1644,6 +1675,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
spo2:
maxItems: 8
type: array
@@ -1654,6 +1686,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
hrv:
maxItems: 8
type: array
@@ -1664,6 +1697,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
restingHeartRate:
maxItems: 8
type: array
@@ -1674,6 +1708,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
vo2Max:
maxItems: 8
type: array
@@ -1684,6 +1719,7 @@ paths:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
deviceTypePriority:
type: object
properties:
@@ -2107,6 +2143,9 @@ paths:
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
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."
@@ -3788,6 +3827,9 @@ components:
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
value:
type: number
unit:
@@ -3803,6 +3845,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
notes:
anyOf:
- type: string
@@ -4090,6 +4133,9 @@ components:
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
value:
type: number
unit:
@@ -4105,6 +4151,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
notes:
anyOf:
- type: string
@@ -4322,6 +4369,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
externalId:
anyOf:
- type: string
@@ -4424,6 +4472,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
externalId:
anyOf:
- type: string
@@ -5685,6 +5734,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
activeEnergy:
maxItems: 8
type: array
@@ -5695,6 +5745,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
walkingRunningDistance:
maxItems: 8
type: array
@@ -5705,6 +5756,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
flightsClimbed:
maxItems: 8
type: array
@@ -5715,6 +5767,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
sleep:
maxItems: 8
type: array
@@ -5725,6 +5778,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
weight:
maxItems: 8
type: array
@@ -5735,6 +5789,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bloodPressure:
maxItems: 8
type: array
@@ -5745,6 +5800,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
pulse:
maxItems: 8
type: array
@@ -5755,6 +5811,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyFat:
maxItems: 8
type: array
@@ -5765,6 +5822,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyTemperature:
maxItems: 8
type: array
@@ -5775,6 +5833,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
spo2:
maxItems: 8
type: array
@@ -5785,6 +5844,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
hrv:
maxItems: 8
type: array
@@ -5795,6 +5855,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
restingHeartRate:
maxItems: 8
type: array
@@ -5805,6 +5866,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
vo2Max:
maxItems: 8
type: array
@@ -5815,6 +5877,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
metricPriority:
type: object
properties:
@@ -5828,6 +5891,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
activeEnergy:
maxItems: 8
type: array
@@ -5838,6 +5902,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
walkingRunningDistance:
maxItems: 8
type: array
@@ -5848,6 +5913,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
flightsClimbed:
maxItems: 8
type: array
@@ -5858,6 +5924,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
sleep:
maxItems: 8
type: array
@@ -5868,6 +5935,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
weight:
maxItems: 8
type: array
@@ -5878,6 +5946,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bloodPressure:
maxItems: 8
type: array
@@ -5888,6 +5957,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
pulse:
maxItems: 8
type: array
@@ -5898,6 +5968,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyFat:
maxItems: 8
type: array
@@ -5908,6 +5979,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyTemperature:
maxItems: 8
type: array
@@ -5918,6 +5990,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
spo2:
maxItems: 8
type: array
@@ -5928,6 +6001,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
hrv:
maxItems: 8
type: array
@@ -5938,6 +6012,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
restingHeartRate:
maxItems: 8
type: array
@@ -5948,6 +6023,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
vo2Max:
maxItems: 8
type: array
@@ -5958,6 +6034,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
additionalProperties: false
deviceTypePriority:
type: object
@@ -6017,6 +6094,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
activeEnergy:
maxItems: 8
type: array
@@ -6027,6 +6105,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
walkingRunningDistance:
maxItems: 8
type: array
@@ -6037,6 +6116,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
flightsClimbed:
maxItems: 8
type: array
@@ -6047,6 +6127,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
sleep:
maxItems: 8
type: array
@@ -6057,6 +6138,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
weight:
maxItems: 8
type: array
@@ -6067,6 +6149,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bloodPressure:
maxItems: 8
type: array
@@ -6077,6 +6160,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
pulse:
maxItems: 8
type: array
@@ -6087,6 +6171,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyFat:
maxItems: 8
type: array
@@ -6097,6 +6182,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyTemperature:
maxItems: 8
type: array
@@ -6107,6 +6193,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
spo2:
maxItems: 8
type: array
@@ -6117,6 +6204,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
hrv:
maxItems: 8
type: array
@@ -6127,6 +6215,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
restingHeartRate:
maxItems: 8
type: array
@@ -6137,6 +6226,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
vo2Max:
maxItems: 8
type: array
@@ -6147,6 +6237,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
metricPriority:
type: object
properties:
@@ -6160,6 +6251,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
activeEnergy:
maxItems: 8
type: array
@@ -6170,6 +6262,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
walkingRunningDistance:
maxItems: 8
type: array
@@ -6180,6 +6273,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
flightsClimbed:
maxItems: 8
type: array
@@ -6190,6 +6284,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
sleep:
maxItems: 8
type: array
@@ -6200,6 +6295,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
weight:
maxItems: 8
type: array
@@ -6210,6 +6306,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bloodPressure:
maxItems: 8
type: array
@@ -6220,6 +6317,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
pulse:
maxItems: 8
type: array
@@ -6230,6 +6328,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyFat:
maxItems: 8
type: array
@@ -6240,6 +6339,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
bodyTemperature:
maxItems: 8
type: array
@@ -6250,6 +6350,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
spo2:
maxItems: 8
type: array
@@ -6260,6 +6361,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
hrv:
maxItems: 8
type: array
@@ -6270,6 +6372,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
restingHeartRate:
maxItems: 8
type: array
@@ -6280,6 +6383,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
vo2Max:
maxItems: 8
type: array
@@ -6290,6 +6394,7 @@ components:
- WITHINGS
- IMPORT
- APPLE_HEALTH
- COMPUTED
additionalProperties: false
deviceTypePriority:
type: object
+4 -1
View File
@@ -437,7 +437,10 @@
"typeHighHeartRateEvent": "Hinweis auf hohe Herzfrequenz",
"typeLowHeartRateEvent": "Hinweis auf niedrige Herzfrequenz",
"typeWalkingSteadinessEvent": "Hinweis zur Gehstabilität",
"typeBreathingDisturbanceEvent": "Atemstörung"
"typeBreathingDisturbanceEvent": "Atemstörung",
"typeRecoveryScore": "Erholungs-Score",
"typeStressScore": "Stress-Score",
"typeStrainScore": "Belastungs-Score"
},
"mood": {
"title": "Stimmung",
+4 -1
View File
@@ -437,7 +437,10 @@
"typeHighHeartRateEvent": "High heart rate notification",
"typeLowHeartRateEvent": "Low heart rate notification",
"typeWalkingSteadinessEvent": "Walking steadiness alert",
"typeBreathingDisturbanceEvent": "Breathing disturbance"
"typeBreathingDisturbanceEvent": "Breathing disturbance",
"typeRecoveryScore": "Recovery score",
"typeStressScore": "Stress score",
"typeStrainScore": "Strain score"
},
"mood": {
"title": "Mood",
+4 -1
View File
@@ -437,7 +437,10 @@
"typeHighHeartRateEvent": "High heart rate notification",
"typeLowHeartRateEvent": "Low heart rate notification",
"typeWalkingSteadinessEvent": "Walking steadiness alert",
"typeBreathingDisturbanceEvent": "Breathing disturbance"
"typeBreathingDisturbanceEvent": "Breathing disturbance",
"typeRecoveryScore": "Recovery score",
"typeStressScore": "Stress score",
"typeStrainScore": "Strain score"
},
"mood": {
"title": "Estado de ánimo",
+4 -1
View File
@@ -437,7 +437,10 @@
"typeHighHeartRateEvent": "High heart rate notification",
"typeLowHeartRateEvent": "Low heart rate notification",
"typeWalkingSteadinessEvent": "Walking steadiness alert",
"typeBreathingDisturbanceEvent": "Breathing disturbance"
"typeBreathingDisturbanceEvent": "Breathing disturbance",
"typeRecoveryScore": "Recovery score",
"typeStressScore": "Stress score",
"typeStrainScore": "Strain score"
},
"mood": {
"title": "Humeur",
+4 -1
View File
@@ -437,7 +437,10 @@
"typeHighHeartRateEvent": "High heart rate notification",
"typeLowHeartRateEvent": "Low heart rate notification",
"typeWalkingSteadinessEvent": "Walking steadiness alert",
"typeBreathingDisturbanceEvent": "Breathing disturbance"
"typeBreathingDisturbanceEvent": "Breathing disturbance",
"typeRecoveryScore": "Recovery score",
"typeStressScore": "Stress score",
"typeStrainScore": "Strain score"
},
"mood": {
"title": "Umore",
+4 -1
View File
@@ -437,7 +437,10 @@
"typeHighHeartRateEvent": "High heart rate notification",
"typeLowHeartRateEvent": "Low heart rate notification",
"typeWalkingSteadinessEvent": "Walking steadiness alert",
"typeBreathingDisturbanceEvent": "Breathing disturbance"
"typeBreathingDisturbanceEvent": "Breathing disturbance",
"typeRecoveryScore": "Recovery score",
"typeStressScore": "Stress score",
"typeStrainScore": "Strain score"
},
"mood": {
"title": "Nastrój",
@@ -0,0 +1,37 @@
-- v1.10.0 — computed scores (WX-C): COMPUTED source + the three `*_SCORE`
-- measurement types.
--
-- Two purely-additive enum extensions, no new columns and no backfill:
--
-- 1. A new `measurement_source` value `COMPUTED` for server-derived rows.
-- A `COMPUTED` Measurement is minted by a nightly engine from the user's
-- already-stored signals (the Recovery score is the first); it is NEVER
-- ingested from a client. The client-facing batch + single-POST write
-- surfaces reject it exactly like `WITHINGS` / `IMPORT`. It is part of
-- the enum so the read/response shapes (and the iOS decoder) can decode
-- the rows it surfaces.
--
-- 2. Three new `measurement_type` values — the server-derived wellness
-- scores `RECOVERY_SCORE`, `STRESS_SCORE`, `STRAIN_SCORE` (0–100, unit
-- `score`). Only RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE +
-- STRAIN_SCORE are defined now so the later engines that compute them
-- need no schema change. These are NOT clinical vitals — they never
-- appear in the doctor-report PDF.
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility:
-- Postgres does not support removing an enum value, so the four new
-- members stay; with no rows carrying them (a `COMPUTED` source / a
-- `*_SCORE` type) after the nightly job is disabled they are inert.
-- ── 1. measurement_source — append the COMPUTED server-owned source ────
-- Pure additive; no existing row carries this value. The `IF NOT EXISTS`
-- guard keeps the migration idempotent across a partially-applied environment.
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'COMPUTED';
-- ── 2. measurement_type — append the three computed-score classes ──────
-- Pure additive; no existing row carries any of these values.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RECOVERY_SCORE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRESS_SCORE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STRAIN_SCORE';
+18
View File
@@ -558,6 +558,16 @@ enum MeasurementType {
LOW_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierLowHeartRateEvent`. The watch detected a sustained low heart rate below the user's configured threshold.
WALKING_STEADINESS_EVENT // HealthKit `HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`. The device flagged low / very-low walking steadiness (a falls-risk mobility signal); the severity rides in `rhythmClassification`.
BREATHING_DISTURBANCE_EVENT // HealthKit `HKCategoryTypeIdentifierSleepApneaEvent` + `AppleSleepingBreathingDisturbances`. The device flagged an elevated breathing-disturbance / possible sleep-apnea signal during sleep. SCREENING SIGNAL ONLY — never a HealthLog diagnosis.
// v1.10.0 — computed scores (WX-C)
// Server-derived wellness scores (0–100, unit `score`). Each is minted by a
// nightly engine from the user's already-stored signals and persisted as a
// `COMPUTED`-source Measurement row — never ingested from a client. Only
// RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE + STRAIN_SCORE are
// defined now so the later engines that compute them need no schema change.
// These are NOT clinical vitals — they never appear in the doctor-report PDF.
RECOVERY_SCORE // Daily recovery / readiness proxy. Higher = more recovered. Blends RHR + HRV (SDNN) + sleep + respiratory-rate deviation from the personal baseline (Plews 2013 / Buchheit 2014 lineage). Descriptive, not a clinical or training-recovery assessment.
STRESS_SCORE // Reserved for the later strain/stress engine. Higher = more physiological stress. No engine computes it in v1.10.0.
STRAIN_SCORE // Reserved for the later strain/stress engine. Higher = more cardiovascular strain (workout-load lineage). No engine computes it in v1.10.0.
@@map("measurement_type")
}
@@ -606,6 +616,14 @@ enum MeasurementSource {
WITHINGS
IMPORT
APPLE_HEALTH // v1.4.23 — HealthKit batch ingest. iOS DTO mirrors this exact spelling (not "HEALTHKIT").
// v1.10.0 — computed scores (WX-C)
// Server-derived rows. A `COMPUTED` measurement is minted by a nightly
// engine from the user's already-stored signals (the Recovery score is the
// first); it is NEVER ingested from a client. Server-owned / read-only:
// the client-facing batch + single-POST write surfaces reject it exactly
// like WITHINGS / IMPORT, but it is part of the read enum so iOS can decode
// the rows it queries / charts.
COMPUTED
@@map("measurement_source")
}
@@ -96,6 +96,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
LOW_HEART_RATE_EVENT: "measurements.typeLowHeartRateEvent",
WALKING_STEADINESS_EVENT: "measurements.typeWalkingSteadinessEvent",
BREATHING_DISTURBANCE_EVENT: "measurements.typeBreathingDisturbanceEvent",
// ── v1.10.0 — computed scores (WX-C) ──
RECOVERY_SCORE: "measurements.typeRecoveryScore",
STRESS_SCORE: "measurements.typeStressScore",
STRAIN_SCORE: "measurements.typeStrainScore",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -182,6 +186,11 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
LOW_HEART_RATE_EVENT: HeartPulse,
WALKING_STEADINESS_EVENT: Footprints,
BREATHING_DISTURBANCE_EVENT: Wind,
// ── v1.10.0 — computed scores (WX-C) ──
// Gauge reads as a composite "index / score" dial for all three.
RECOVERY_SCORE: Gauge,
STRESS_SCORE: Gauge,
STRAIN_SCORE: Gauge,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -259,4 +268,10 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
LOW_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3",
WALKING_STEADINESS_EVENT: "bg-chart-2/20 text-chart-2",
BREATHING_DISTURBANCE_EVENT: "bg-chart-5/20 text-chart-5",
// ── v1.10.0 — computed scores (WX-C) ──
// chart-1 (Dracula purple) marks the server-derived composites as their
// own visual group, distinct from the raw-signal families above.
RECOVERY_SCORE: "bg-chart-1/20 text-chart-1",
STRESS_SCORE: "bg-chart-1/20 text-chart-1",
STRAIN_SCORE: "bg-chart-1/20 text-chart-1",
};
@@ -73,10 +73,14 @@ const EXPECTED_TYPES = [
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
// ── v1.10.0 — computed scores (WX-C) ──
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 50 canonical measurement types", () => {
it("exposes the 53 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -168,6 +172,14 @@ describe("measurementTypeEnum coverage", () => {
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
// v1.10.0 — computed scores (WX-C). Server-derived wellness scores
// (Recovery / Stress / Strain) are 0–100 composites recomputed nightly,
// not measured clinical vitals. They surface on their own Insights
// cluster with a "descriptive, not clinical" disclaimer and never belong
// in the clinical vitals PDF. See doctor-report-pdf-core.ts.
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+7
View File
@@ -181,6 +181,13 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
* table for clinical readability. Glucose ships separately via
* per-context `glucoseStats`. Sleep + activity are intentionally
* excluded from a clinical-focused report.
*
* v1.10.0 — computed scores (WX-C). The server-derived `*_SCORE` types
* (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE) are NOT clinical vitals:
* they are 0–100 composites recomputed nightly from the underlying signals
* 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`).
*/
export const DOCTOR_REPORT_VITAL_TYPES = [
"WEIGHT",
+13
View File
@@ -89,6 +89,14 @@ export const ALLOWED_CHART_TOKENS = [
"metric:STAIR_ASCENT_SPEED",
"metric:STAIR_DESCENT_SPEED",
"metric:BREATHING_DISTURBANCES",
// v1.10.0 — computed scores (WX-C). Server-derived 0–100 wellness scores.
// Unlike the categorical EVENT classes they ARE a continuous daily series,
// so they carry a `metric:<TYPE>` token and render through the generic
// chart renderer. Only RECOVERY_SCORE is computed in v1.10.0; the other
// two tokens are reserved for the later engines.
"metric:RECOVERY_SCORE",
"metric:STRESS_SCORE",
"metric:STRAIN_SCORE",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -184,6 +192,11 @@ const ORPHAN_ENUMS = [
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
"BREATHING_DISTURBANCES",
// v1.10.0 — computed scores (WX-C), same shape. Strip the bare enum name
// if the model drops it into prose (mirrors the MOOD_SCORE precedent).
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
] as const;
// `\b` boundaries keep ordinary English prose untouched — "weight"
@@ -32,6 +32,13 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
"PULSE_WAVE_VELOCITY",
"VASCULAR_AGE",
"VISCERAL_FAT",
// v1.10.0 — computed scores (WX-C). Server-derived wellness scores
// (Recovery / Stress / Strain) are minted by a nightly engine from the
// user's already-stored signals; HealthKit ships no identifier for them
// and they are never ingested, so they have no HK mapping by design.
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
+13 -1
View File
@@ -42,7 +42,11 @@ export type MeasurementCategory =
| "hearing"
| "environment"
| "cardiovascular"
| "metabolic";
| "metabolic"
// v1.10.0 — computed scores (WX-C). Server-derived wellness scores
// (Recovery / Stress / Strain). Their own presentation cluster — they are
// composite indices, not a raw signal in any of the above buckets.
| "scores";
/**
* Per-MeasurementType → category mapping. The map is exhaustive over
@@ -153,6 +157,14 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["LOW_HEART_RATE_EVENT", "cardiovascular"],
["WALKING_STEADINESS_EVENT", "activity"],
["BREATHING_DISTURBANCE_EVENT", "sleep"],
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived wellness scores share their own presentation cluster.
// The category is only for list grouping — scores never participate in
// trend/rollup analytics (they ARE a nightly-computed composite).
["RECOVERY_SCORE", "scores"],
["STRESS_SCORE", "scores"],
["STRAIN_SCORE", "scores"],
]);
/**
+8
View File
@@ -144,6 +144,14 @@ export function getPRDirection(
case "LOW_HEART_RATE_EVENT":
case "WALKING_STEADINESS_EVENT":
case "BREATHING_DISTURBANCE_EVENT":
// v1.10.0 — computed scores (WX-C). The server-derived wellness scores
// are themselves derived composites recomputed nightly from the
// underlying signals; a "personal record" on a derived score would
// double-count the PRs of its own inputs and read as noise. They
// explicitly have no PR direction, like the categorical events above.
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
return null;
}
}
@@ -271,4 +271,50 @@ describe("measurement validation", () => {
expect(parsed.success).toBe(true);
});
});
// v1.10.0 — computed scores (WX-C).
describe("computed scores", () => {
it("canonical unit is 'score' for each computed-score type", () => {
expect(getUnitForType("RECOVERY_SCORE")).toBe("score");
expect(getUnitForType("STRESS_SCORE")).toBe("score");
expect(getUnitForType("STRAIN_SCORE")).toBe("score");
});
it("plausibility range is 0..100 for a computed score", () => {
expect(validateMeasurementRange("RECOVERY_SCORE", 0)).toBeNull();
expect(validateMeasurementRange("RECOVERY_SCORE", 100)).toBeNull();
expect(validateMeasurementRange("RECOVERY_SCORE", -1)).not.toBeNull();
expect(validateMeasurementRange("RECOVERY_SCORE", 101)).not.toBeNull();
});
it("rejects the server-owned COMPUTED source on a client write", () => {
const parsed = createMeasurementSchema.safeParse({
type: "RECOVERY_SCORE",
value: 72,
measuredAt: "2026-06-02T12:00:00Z",
source: "COMPUTED",
});
expect(parsed.success).toBe(false);
if (!parsed.success) {
const issue = parsed.error.issues.find((i) =>
i.path.includes("source"),
);
expect(issue, "expected a source-path rejection").toBeDefined();
expect(issue!.message).toMatch(/server-owned/i);
}
});
it("still accepts a client-writable source for a score-typed manual row", () => {
// The COMPUTED source is the only one this refine blocks; a MANUAL
// write of any type stays valid (the source guard is orthogonal to
// whether a UI ever offers manual score entry).
const parsed = createMeasurementSchema.safeParse({
type: "RECOVERY_SCORE",
value: 72,
measuredAt: "2026-06-02T12:00:00Z",
source: "MANUAL",
});
expect(parsed.success).toBe(true);
});
});
});
+68 -1
View File
@@ -66,6 +66,29 @@ export const measurementTypeEnum = z.enum([
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived wellness scores (0–100, unit `score`). Minted by a
// nightly engine from the user's already-stored signals and persisted as a
// `COMPUTED`-source row — never ingested from a client (the batch +
// single-POST write surfaces reject the COMPUTED source). Only
// RECOVERY_SCORE is computed in v1.10.0; the other two are defined now so
// the later engines that compute them need no schema change.
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
]);
/**
* v1.10.0 — computed scores (WX-C). The closed set of server-derived
* `*_SCORE` MeasurementTypes. Each is a 0–100 wellness score persisted with
* `source = COMPUTED` by a nightly engine — never ingested from a client.
* Kept as a `Set` so read/write paths can branch on "is this a computed
* score?" the same way `EVENT_MEASUREMENT_TYPES` gates the event rows.
*/
export const SCORE_MEASUREMENT_TYPES: ReadonlySet<string> = new Set<string>([
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
]);
/**
@@ -104,8 +127,31 @@ export const measurementSourceEnum = z.enum([
"WITHINGS",
"IMPORT",
"APPLE_HEALTH",
// v1.10.0 — computed scores (WX-C). Server-owned / read-only: a `COMPUTED`
// row is minted by a nightly engine, never written by a client. It is part
// of this enum so the read/response shapes (and the iOS decoder) can decode
// the rows it surfaces; the client-facing write surfaces reject it — see
// `WRITABLE_MEASUREMENT_SOURCES` and the batch route's `batchSourceEnum`.
"COMPUTED",
]);
/**
* v1.10.0 — computed scores (WX-C). The subset of `MeasurementSource` a
* client may attribute on a write. `COMPUTED` is server-owned (a nightly
* engine mints it) and `WITHINGS` / `IMPORT` are owned by the Withings
* webhook + CSV importer respectively, so all three are excluded — letting a
* client forge a row attributed to them would pollute the per-source
* canonical picker with rows the server never produced. The single-entry
* POST validates `source` against this set; the batch route mirrors the same
* exclusion with its own narrower `{APPLE_HEALTH, MANUAL}` allowlist.
*/
export const WRITABLE_MEASUREMENT_SOURCES = [
"MANUAL",
"WITHINGS",
"IMPORT",
"APPLE_HEALTH",
] as const;
const unitMap: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
@@ -200,6 +246,12 @@ const unitMap: Record<string, string> = {
LOW_HEART_RATE_EVENT: "event",
WALKING_STEADINESS_EVENT: "event",
BREATHING_DISTURBANCE_EVENT: "event",
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived 0–100 wellness scores. The canonical unit is the bare
// "score" so the value reads sensibly anywhere it surfaces.
RECOVERY_SCORE: "score",
STRESS_SCORE: "score",
STRAIN_SCORE: "score",
};
export function getUnitForType(type: string): string {
@@ -343,6 +395,12 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
LOW_HEART_RATE_EVENT: { min: 1, max: 1 },
WALKING_STEADINESS_EVENT: { min: 1, max: 1 },
BREATHING_DISTURBANCE_EVENT: { min: 1, max: 1 },
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived 0–100 scores. The plausibility band pins them to the
// score range so a malformed store (a stray negative or > 100) is rejected.
RECOVERY_SCORE: { min: 0, max: 100 },
STRESS_SCORE: { min: 0, max: 100 },
STRAIN_SCORE: { min: 0, max: 100 },
};
export function validateMeasurementRange(
@@ -395,7 +453,16 @@ export const createMeasurementSchema = z
"Blood glucose measurements require a context (fasting/postprandial/random/bedtime); other types must not set one.",
path: ["glucoseContext"],
},
);
)
// v1.10.0 — computed scores (WX-C). The `COMPUTED` source is server-owned:
// a nightly engine mints those rows, a client never does. Reject it here
// so the single-entry POST cannot forge a server-derived row (the batch
// route enforces the same exclusion through its `{APPLE_HEALTH, MANUAL}`
// allowlist).
.refine((data) => data.source !== "COMPUTED", {
message: "The COMPUTED source is server-owned and cannot be set by a client.",
path: ["source"],
});
export const updateMeasurementSchema = z.object({
value: z.number().min(0).max(500000).optional(),