diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bc8cb5bbd..8fbe742ce 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index c06e57647..f24eb7f65 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 3f7169300..d0cf7cd26 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 4fbcaa19d..f37f81050 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 9a8ca4b09..df15b2e71 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 68b53abe9..ca367ad3b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 917d7ecb9..c39329307 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0106_v1100_computed_scores/migration.sql b/prisma/migrations/0106_v1100_computed_scores/migration.sql new file mode 100644 index 000000000..6ca88ebd8 --- /dev/null +++ b/prisma/migrations/0106_v1100_computed_scores/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 91855915b..a8787dec8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 8117efb56..727a49812 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -96,6 +96,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { 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 = { @@ -182,6 +186,11 @@ export const MEASUREMENT_TYPE_ICONS: Record = { 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 = { @@ -259,4 +268,10 @@ export const MEASUREMENT_TYPE_COLORS: Record = { 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", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 1985c040c..60989c670 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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", () => { diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index c8969c458..b640da16b 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -181,6 +181,13 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { * 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", diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index cf9c2f131..0fb4e6df3 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -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:` 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" diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 2603f23af..1a45a173a 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -32,6 +32,13 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ "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", () => { diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index da203c360..d7c5ac283 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -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"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 8b80ff735..eedfab519 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -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; } } diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 7137e5e02..1ab1bca1a 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -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); + }); + }); }); diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 79574fc24..7c17e4ed1 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 = new Set([ + "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 = { WEIGHT: "kg", BLOOD_PRESSURE_SYS: "mmHg", @@ -200,6 +246,12 @@ const unitMap: Record = { 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 = { 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(),