From b06b1cada4acf0b95914a81f6c28c91c438055d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:00:03 +0200 Subject: [PATCH] feat(healthkit): add a categorical device-event row class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a discrete EVENT row class to the Measurement model for the device-flagged health notifications a wearable produces on its own — irregular-rhythm, high/low heart rate, walking-steadiness and breathing-disturbance alerts. Following the sleep-stage / glucose-context nullable-context precedent, this is an additive nullable column on the existing table, not a new storage paradigm. Five new MeasurementType values carry the event class and a new rhythm_classification enum column carries the device's own verdict / severity (migration 0105, with a CHECK that pins the column to the event types). An event row stores value 1 and the device verdict; it never participates in trend, rollup, chart or personal-record analytics. For the irregular-rhythm class, HealthLog ingests only the classification RESULT the device's own certified on-device algorithm already produced. It never ingests a raw ECG waveform, never re-classifies, and never derives a verdict of its own. The Apple Health batch ingest maps the five category identifiers, with the device's HKCategoryValue codepoint resolved to the stored classification; an unknown codepoint degrades to a fallback verdict rather than dropping the event. --- .../migration.sql | 95 +++++++++ prisma/schema.prisma | 51 +++++ .../measurements/measurement-list-meta.ts | 24 +++ src/lib/measurements/apple-health-mapping.ts | 190 ++++++++++++++++-- src/lib/measurements/categories.ts | 13 ++ src/lib/personal-records/pr-direction.ts | 9 + src/lib/validations/measurement.ts | 50 +++++ 7 files changed, 416 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/0105_v1100_categorical_event_rows/migration.sql diff --git a/prisma/migrations/0105_v1100_categorical_event_rows/migration.sql b/prisma/migrations/0105_v1100_categorical_event_rows/migration.sql new file mode 100644 index 000000000..6fe46a211 --- /dev/null +++ b/prisma/migrations/0105_v1100_categorical_event_rows/migration.sql @@ -0,0 +1,95 @@ +-- v1.10.0 — categorical EVENT row class + device rhythm classification. +-- +-- Adds a discrete EVENT row class to the existing `Measurement` model, +-- following the `sleep_stage` / `glucose_context` nullable-context +-- precedent (migration 0021 + 0036) — an additive nullable column on the +-- existing table, NOT a new storage paradigm. An EVENT row stores ONE +-- on-device notification the user's wearable already produced (Apple Watch +-- irregular-rhythm / high-HR / low-HR / walking-steadiness, ScanWatch AFib +-- screening, sleep breathing-disturbance). `value` is always 1; the +-- device's own verdict / severity rides in the new `rhythm_classification` +-- column. +-- +-- ── Regulatory framing (load-bearing) ────────────────────────────────── +-- HealthLog stores ONLY the classification RESULT the device's +-- FDA-cleared / CE-marked on-device algorithm emitted. It never ingests a +-- raw ECG waveform, never re-classifies, and never produces a HealthLog +-- diagnosis. The surface is awareness/screening of the DEVICE's decision. +-- +-- Two schema objects: +-- +-- 1. Five new `measurement_type` enum values (the EVENT classes). +-- 2. A new `rhythm_classification` enum + a nullable column carrying the +-- device verdict / severity, NULL for every continuous measurement. +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: +-- ALTER TABLE "measurements" DROP CONSTRAINT IF EXISTS "measurements_rhythm_classification_requires_event_type"; +-- ALTER TABLE "measurements" DROP COLUMN IF EXISTS "rhythm_classification"; +-- DROP TYPE IF EXISTS "rhythm_classification"; +-- Postgres does not support removing an enum value, so the five new +-- `measurement_type` members stay; with no rows carrying them after a +-- column drop they are inert. + +-- ── 1. measurement_type — append the five EVENT classes ──────────────── +-- Pure additive; no existing row carries any of these values. The +-- `IF NOT EXISTS` guard keeps the migration idempotent across a +-- partially-applied environment. +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'IRREGULAR_RHYTHM_NOTIFICATION'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'HIGH_HEART_RATE_EVENT'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'LOW_HEART_RATE_EVENT'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WALKING_STEADINESS_EVENT'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BREATHING_DISTURBANCE_EVENT'; + +-- ── 2. rhythm_classification — the device verdict / severity enum ────── +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'rhythm_classification') THEN + CREATE TYPE "rhythm_classification" AS ENUM ( + 'IRREGULAR', + 'NOT_DETECTED', + 'INCONCLUSIVE', + 'LOW', + 'VERY_LOW', + 'FIRED' + ); + END IF; +END +$$; + +-- Nullable column. NULL for every continuous measurement; non-NULL only +-- for the EVENT classes. Mirrors the `sleep_stage` additive-column shape. +ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "rhythm_classification" "rhythm_classification"; + +-- Enforce: rhythm_classification is only set for the EVENT-class rows. +-- Mirrors the `measurements_sleep_stage_requires_type` CHECK (migration +-- 0036) and the glucose_context CHECK (migration 0021). +-- +-- The comparison casts `type` to text so the predicate references the +-- newly-added enum members as string literals. Postgres forbids using an +-- enum value in the same transaction that adds it (the ALTER TYPE ... ADD +-- VALUE statements above run in this same migration transaction); the +-- `::text` cast sidesteps that restriction without weakening the check. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'measurements_rhythm_classification_requires_event_type' + ) THEN + ALTER TABLE "measurements" + ADD CONSTRAINT "measurements_rhythm_classification_requires_event_type" + CHECK ( + "rhythm_classification" IS NULL + OR "type"::text IN ( + 'IRREGULAR_RHYTHM_NOTIFICATION', + 'HIGH_HEART_RATE_EVENT', + 'LOW_HEART_RATE_EVENT', + 'WALKING_STEADINESS_EVENT', + 'BREATHING_DISTURBANCE_EVENT' + ) + ); + END IF; +END +$$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e7e26b7b..91855915b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -544,10 +544,52 @@ enum MeasurementType { STAIR_ASCENT_SPEED // metres per second — HealthKit `stairAscentSpeed`. Mobility section gait metric measured while climbing stairs, higher-better. STAIR_DESCENT_SPEED // metres per second — HealthKit `stairDescentSpeed`. Mobility section gait metric measured while descending stairs, higher-better. BREATHING_DISTURBANCES // count — HealthKit `appleSleepingBreathingDisturbances` (iOS 18+). Per-night sleep-breathing-disturbance index Apple classifies as NotElevated / Elevated; lower-better. + // v1.10.0 — categorical events (WX-B) + // Device-flagged EVENT rows. These are NOT continuous measurements — + // each row records a discrete on-device notification the user's wearable + // produced (Apple Watch / Withings ScanWatch). `value` is always 1 (one + // fired event); the device's verdict / severity lands in the + // `rhythmClassification` column (see the `RhythmClassification` enum and + // the CHECK constraint in migration 0105). HealthLog never re-derives or + // re-classifies the underlying signal — it only stores and surfaces the + // result the device's own certified algorithm already emitted. + IRREGULAR_RHYTHM_NOTIFICATION // HealthKit `HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening. The certified on-device algorithm flagged a possible irregular rhythm. AWARENESS ONLY — never a HealthLog diagnosis. + HIGH_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierHighHeartRateEvent`. The watch detected a sustained high heart rate above the user's configured threshold while apparently inactive. + 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. @@map("measurement_type") } +/// v1.10.0 — categorical events (WX-B) +/// The device's own classification RESULT / severity for an EVENT row. +/// Non-NULL only for the categorical EVENT `MeasurementType`s +/// (IRREGULAR_RHYTHM_NOTIFICATION, HIGH/LOW_HEART_RATE_EVENT, +/// WALKING_STEADINESS_EVENT, BREATHING_DISTURBANCE_EVENT), NULL for every +/// continuous measurement — mirroring the `GlucoseContext` / `SleepStage` +/// nullable-context precedent. +/// +/// HealthLog stores ONLY the verdict the device's FDA-cleared / CE-marked +/// on-device algorithm already produced. It never re-classifies a raw +/// signal and never emits a diagnosis of its own. `IRREGULAR` means the +/// device flagged a possible irregular rhythm; `NOT_DETECTED` / +/// `INCONCLUSIVE` carry the device's own non-positive verdicts so the +/// awareness timeline can be honest rather than only ever showing alarms. +/// `LOW` / `VERY_LOW` carry the walking-steadiness severity; `FIRED` is the +/// neutral "this event occurred" verdict for the high/low-HR + breathing +/// events that have no finer device-side grading. +enum RhythmClassification { + IRREGULAR // device flagged a possible irregular rhythm (AFib screening) + NOT_DETECTED // device's algorithm ran and did not flag an irregular rhythm + INCONCLUSIVE // device could not classify (poor signal / out-of-range HR) + LOW // walking-steadiness: low + VERY_LOW // walking-steadiness: very low + FIRED // neutral "event occurred" verdict (high/low-HR, breathing disturbance) + + @@map("rhythm_classification") +} + /// Context for glucose measurements — affects which target range applies. /// NULL for non-glucose measurements. enum GlucoseContext { @@ -608,6 +650,15 @@ model Measurement { // only for `type = SLEEP_DURATION`. Mirrors the glucoseContext // pattern. A single night produces ~5 rows (one per stage). sleepStage SleepStage? @map("sleep_stage") + // v1.10.0 — categorical events (WX-B). The device's own classification + // verdict / severity for an EVENT row. NULL for every continuous + // measurement; non-NULL only for the categorical EVENT `MeasurementType`s. + // Mirrors the `sleepStage` / `glucoseContext` nullable-context columns. A + // CHECK constraint (migration 0105) enforces the type pairing so a + // continuous row can never carry a classification. NOT part of any unique + // index — dedup stays on `(userId, type, source, externalId)` keyed by the + // device's `HKSample.uuid`, so a re-posted event collapses to a duplicate. + rhythmClassification RhythmClassification? @map("rhythm_classification") // v1.4.25 W8c — device-type tag for the cross-source canonical // picker. Stored as a free-text string (kept simple — Prisma enum // would require coordinated client + server bumps every time we diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 1d270b042..8117efb56 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -90,6 +90,12 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { STAIR_ASCENT_SPEED: "measurements.typeStairAscentSpeed", STAIR_DESCENT_SPEED: "measurements.typeStairDescentSpeed", BREATHING_DISTURBANCES: "measurements.typeBreathingDisturbances", + // ── v1.10.0 — categorical events (WX-B) ── + IRREGULAR_RHYTHM_NOTIFICATION: "measurements.typeIrregularRhythmNotification", + HIGH_HEART_RATE_EVENT: "measurements.typeHighHeartRateEvent", + LOW_HEART_RATE_EVENT: "measurements.typeLowHeartRateEvent", + WALKING_STEADINESS_EVENT: "measurements.typeWalkingSteadinessEvent", + BREATHING_DISTURBANCE_EVENT: "measurements.typeBreathingDisturbanceEvent", }; export const MEASUREMENT_TYPE_ICONS: Record = { @@ -167,6 +173,15 @@ export const MEASUREMENT_TYPE_ICONS: Record = { STAIR_ASCENT_SPEED: Gauge, STAIR_DESCENT_SPEED: Gauge, BREATHING_DISTURBANCES: Wind, + // ── v1.10.0 — categorical events (WX-B) ── + // Activity carries the irregular-rhythm trace shape; HeartPulse the + // high/low-HR cardio pair; Footprints the steadiness/mobility family; + // Wind the breathing family (SpO2 + respiratory rate already use it). + IRREGULAR_RHYTHM_NOTIFICATION: Activity, + HIGH_HEART_RATE_EVENT: HeartPulse, + LOW_HEART_RATE_EVENT: HeartPulse, + WALKING_STEADINESS_EVENT: Footprints, + BREATHING_DISTURBANCE_EVENT: Wind, }; export const MEASUREMENT_TYPE_COLORS: Record = { @@ -235,4 +250,13 @@ export const MEASUREMENT_TYPE_COLORS: Record = { STAIR_ASCENT_SPEED: "bg-chart-2/20 text-chart-2", STAIR_DESCENT_SPEED: "bg-chart-2/20 text-chart-2", BREATHING_DISTURBANCES: "bg-chart-5/20 text-chart-5", + // ── v1.10.0 — categorical events (WX-B) ── + // chart-3 (cardio family) carries the rhythm + heart-rate events; + // chart-2 (activity/mobility) carries the steadiness event; chart-5 + // (respiratory/pulse family) carries the breathing event. + IRREGULAR_RHYTHM_NOTIFICATION: "bg-chart-3/20 text-chart-3", + HIGH_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3", + 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", }; diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 813eb1a3b..e564cab9b 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -23,7 +23,11 @@ * unit, conversion, and aggregation columns are HealthLog-specific * decisions checked against Apple's HKUnit documentation. */ -import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; +import type { + MeasurementType, + SleepStage, + RhythmClassification, +} from "@/generated/prisma/client"; /** * How a batch of HealthKit samples for the same identifier should be @@ -67,8 +71,54 @@ export interface AppleHealthMapping { * sample and look up the DB-side stage here. */ sleepStageMap?: Record; + /** + * v1.10.0 — categorical events (WX-B). For the EVENT-class HealthKit + * category identifiers (irregular-rhythm / high-HR / low-HR / + * walking-steadiness / breathing-disturbance), maps the inbound + * `HKCategoryValue` integer codepoint to the device's verdict / + * severity (`RhythmClassification`). + * + * HealthLog NEVER re-derives this — the value is the result the + * device's own certified on-device algorithm already produced. When a + * mapping carries an `eventClassificationMap`, `mapAppleHealthEntry` + * resolves the codepoint to a `RhythmClassification`, forcing the + * stored `value` to 1 (one fired event) regardless of the inbound + * number. A codepoint absent from the map falls back to the entry's + * `fallbackClassification` so an unseen Apple codepoint is recorded as + * a (gracefully degraded) event rather than dropped. + */ + eventClassificationMap?: Record; + /** + * v1.10.0 — fallback verdict for an EVENT identifier whose inbound + * codepoint is missing / unknown. Only set alongside + * `eventClassificationMap`. The high/low-HR + breathing events have a + * single neutral `FIRED` verdict and a `notApplicable` (0) codepoint; + * they rely on this fallback rather than enumerating the codepoint. + */ + fallbackClassification?: RhythmClassification; } +/** + * v1.10.0 — categorical events (WX-B). + * + * `HKCategoryValueAppleWalkingSteadinessEvent` codepoints → the device's + * own severity verdict. Apple grades the falls-risk mobility flag as + * initial/repeat × low/very-low; HealthLog collapses the initial/repeat + * distinction (it is a re-notification of the same severity) into the two + * severity bands the awareness timeline surfaces. + * + * Source: Apple's `HKCategoryValueAppleWalkingSteadinessEvent` enum. + */ +export const APPLE_HEALTH_WALKING_STEADINESS_EVENT_MAP: Record< + number, + RhythmClassification +> = { + 1: "LOW", // initialLow + 2: "LOW", // repeatLow + 3: "VERY_LOW", // initialVeryLow + 4: "VERY_LOW", // repeatVeryLow +}; + /** * Numeric values of `HKCategoryValueSleepAnalysis`. Apple's enum is * platform-versioned; the values below match the iOS 16+ codepoints. @@ -370,6 +420,82 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { aggregation: "sum", }, + // ── v1.10.0 — categorical events (WX-B) ────────────────────── + // Device-flagged EVENT classes. Each is a discrete on-device + // notification, NOT a continuous reading. HealthLog ingests ONLY the + // classification RESULT the device's own certified algorithm produced + // — it never sees a raw ECG waveform, never re-classifies, never emits + // a diagnosis. The stored `value` is always 1 (one fired event); the + // device verdict / severity lands in `rhythmClassification`. + // + // Irregular-rhythm notification — Apple Watch's FDA-cleared / CE-marked + // irregular-rhythm-notification feature (and ScanWatch's certified AFib + // screening, which the iOS bridge maps onto this same identifier). The + // sample firing IS the device's "possible irregular rhythm" verdict; + // there is no finer HK gradation, so it resolves to `IRREGULAR`. + HKCategoryTypeIdentifierIrregularHeartRhythmEvent: { + hkIdentifier: "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", + measurementType: "IRREGULAR_RHYTHM_NOTIFICATION", + hkUnit: "event", + dbUnit: "event", + convertToDbUnit: () => 1, + aggregation: "sum", + isPrivacySensitive: true, + eventClassificationMap: { 0: "IRREGULAR" }, + fallbackClassification: "IRREGULAR", + }, + // High-HR event — sustained high heart rate above the user-configured + // threshold while apparently inactive. Single neutral `FIRED` verdict. + HKCategoryTypeIdentifierHighHeartRateEvent: { + hkIdentifier: "HKCategoryTypeIdentifierHighHeartRateEvent", + measurementType: "HIGH_HEART_RATE_EVENT", + hkUnit: "event", + dbUnit: "event", + convertToDbUnit: () => 1, + aggregation: "sum", + isPrivacySensitive: true, + fallbackClassification: "FIRED", + }, + // Low-HR event — sustained low heart rate below the user-configured + // threshold. Single neutral `FIRED` verdict. + HKCategoryTypeIdentifierLowHeartRateEvent: { + hkIdentifier: "HKCategoryTypeIdentifierLowHeartRateEvent", + measurementType: "LOW_HEART_RATE_EVENT", + hkUnit: "event", + dbUnit: "event", + convertToDbUnit: () => 1, + aggregation: "sum", + isPrivacySensitive: true, + fallbackClassification: "FIRED", + }, + // Walking-steadiness event — the falls-risk mobility flag. Apple grades + // it low / very-low (initial + repeat); the severity rides in + // `rhythmClassification` via `APPLE_HEALTH_WALKING_STEADINESS_EVENT_MAP`. + HKCategoryTypeIdentifierAppleWalkingSteadinessEvent: { + hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", + measurementType: "WALKING_STEADINESS_EVENT", + hkUnit: "event", + dbUnit: "event", + convertToDbUnit: () => 1, + aggregation: "sum", + isPrivacySensitive: true, + eventClassificationMap: APPLE_HEALTH_WALKING_STEADINESS_EVENT_MAP, + fallbackClassification: "LOW", + }, + // Breathing-disturbance / sleep-apnea event — the device flagged an + // elevated breathing-disturbance signal during sleep. Screening signal + // only; single neutral `FIRED` verdict. + HKCategoryTypeIdentifierSleepApneaEvent: { + hkIdentifier: "HKCategoryTypeIdentifierSleepApneaEvent", + measurementType: "BREATHING_DISTURBANCE_EVENT", + hkUnit: "event", + dbUnit: "event", + convertToDbUnit: () => 1, + aggregation: "sum", + isPrivacySensitive: true, + fallbackClassification: "FIRED", + }, + // ── v1.5.5 iOS-coord — six previously-deferred identifiers ─── // Background: each entry below sat in `HK_QUANTITY_TYPE_DEFERRED`. // `mapAppleHealthEntry()` returned null; the batch route emitted @@ -747,11 +873,10 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierWorkoutEffortScore", "HKQuantityTypeIdentifierPhysicalEffort", // Sleep apnea + breathing (iOS 18) — v1.5+ - // v1.10.0 — `AppleSleepingBreathingDisturbances` moved into the - // mapping table (BREATHING_DISTURBANCES). The category-type sleep-apnea - // EVENT stays deferred — it belongs in the event class, not the - // quantity map. - "HKCategoryTypeIdentifierSleepApneaEvent", + // v1.10.0 — both signals now mapped, so neither stays deferred: + // `AppleSleepingBreathingDisturbances` (the per-night quantity) → + // BREATHING_DISTURBANCES, and `HKCategoryTypeIdentifierSleepApneaEvent` + // (the fired screening event) → BREATHING_DISTURBANCE_EVENT. // Nutrition — Marc directive, indefinite hold "HKQuantityTypeIdentifierDietaryWater", "HKQuantityTypeIdentifierDietaryCaffeine", @@ -792,11 +917,11 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierAtrialFibrillationBurden", "HKQuantityTypeIdentifierPeripheralPerfusionIndex", // Mobility (iOS 15+) — `AppleWalkingSteadiness` moved into the - // mapping table in v1.4.30 (R-F T1.5); `NumberOfTimesFallen` moved - // into the mapping table in v1.10.0 (FALL_COUNT). The walking- - // steadiness EVENT stays deferred — it is a category-type event, not - // a quantity, so it belongs in the event class rather than this map. - "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", + // mapping table in v1.4.30 (R-F T1.5). In v1.10.0 both remaining + // signals moved in too, so neither stays deferred: `NumberOfTimesFallen` + // → FALL_COUNT (the continuous count), and + // `AppleWalkingSteadinessEvent` → WALKING_STEADINESS_EVENT (the fired + // severity event). // Respiratory / pulmonary clinical (iOS 17) — pair with FHIR clinical // bucket; no Measurement enum mapping today. "HKQuantityTypeIdentifierForcedExpiratoryVolume1", @@ -810,11 +935,11 @@ export const HK_QUANTITY_TYPE_DEFERRED = new Set([ "HKQuantityTypeIdentifierBloodAlcoholContent", "HKQuantityTypeIdentifierNikeFuel", // legacy Nike+iPod fitness points // Heart-rhythm event flags (iOS 9+ but watch-detected; iOS 18 - // refreshed surfaces) — would land as Measurement annotations rather - // than rows; defer until the annotation column lands. - "HKCategoryTypeIdentifierLowHeartRateEvent", - "HKCategoryTypeIdentifierHighHeartRateEvent", - "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", + // refreshed surfaces). v1.10.0 — `LowHeartRateEvent`, + // `HighHeartRateEvent` + `IrregularHeartRhythmEvent` moved into the + // mapping table as the LOW/HIGH_HEART_RATE_EVENT + + // IRREGULAR_RHYTHM_NOTIFICATION categorical events. + // `LowCardioFitnessEvent` stays deferred (no awareness surface yet). "HKCategoryTypeIdentifierLowCardioFitnessEvent", // Audio-exposure events (iOS 13+) — Environmental + Headphone // moved into the mapping table in v1.4.30 (R-F T1.4) as @@ -857,6 +982,17 @@ export interface AppleHealthEntryInput { * types. */ sleepStage?: number; + /** + * v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue` + * codepoint for an EVENT-class category sample (irregular-rhythm, + * high/low-HR, walking-steadiness, breathing-disturbance). The device + * already classified the signal; this is the codepoint of that + * verdict, looked up via the mapping's `eventClassificationMap`. + * Ignored for every non-event identifier. Falls back to the wire + * `sleepStage` field when omitted so an iOS build that reuses the + * existing category-value slot still round-trips. + */ + categoryValue?: number; } /** Output of `mapAppleHealthEntry()`. */ @@ -867,6 +1003,12 @@ export interface AppleHealthEntryOutput { takenAt: Date; /** Set only for sleep entries. */ sleepStage?: SleepStage; + /** + * v1.10.0 — set only for the categorical EVENT classes. The device's + * own verdict / severity for the fired event. NULL/undefined for every + * continuous measurement. + */ + rhythmClassification?: RhythmClassification; } /** @@ -908,5 +1050,21 @@ export function mapAppleHealthEntry( out.sleepStage = stage; } + // v1.10.0 — categorical events (WX-B). Resolve the device's verdict + // from the inbound codepoint. The `value` is already pinned to 1 by the + // mapping's `convertToDbUnit: () => 1`. We accept the codepoint from + // `categoryValue`, falling back to the legacy `sleepStage` category slot + // so an iOS build reusing that wire field still resolves. An unknown + // codepoint degrades to the mapping's `fallbackClassification` rather + // than dropping the event — the device fired it, so it happened. + if (mapping.fallbackClassification) { + const codepoint = input.categoryValue ?? input.sleepStage; + const fromMap = + codepoint !== undefined && mapping.eventClassificationMap + ? mapping.eventClassificationMap[codepoint] + : undefined; + out.rhythmClassification = fromMap ?? mapping.fallbackClassification; + } + return out; } diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 15956c8df..da203c360 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -140,6 +140,19 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["STAIR_DESCENT_SPEED", "activity"], // Breathing disturbances is a per-night sleep-breathing index. ["BREATHING_DISTURBANCES", "sleep"], + + // ── v1.10.0 — categorical events (WX-B) ── + // Device-flagged EVENT rows slot into the category their signal + // belongs to: the rhythm + heart-rate notifications join the + // cardiovascular surface, the walking-steadiness alert joins the + // activity/mobility cluster, and the breathing-disturbance flag joins + // sleep (it fires during sleep). They never participate in + // trend/rollup analytics — the category is only for list grouping. + ["IRREGULAR_RHYTHM_NOTIFICATION", "cardiovascular"], + ["HIGH_HEART_RATE_EVENT", "cardiovascular"], + ["LOW_HEART_RATE_EVENT", "cardiovascular"], + ["WALKING_STEADINESS_EVENT", "activity"], + ["BREATHING_DISTURBANCE_EVENT", "sleep"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 27f37ca57..8b80ff735 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -135,6 +135,15 @@ export function getPRDirection( // a baseline deviation, not a goal axis; neither a higher nor a // lower reading is an achievement. Defer to a null PR direction. case "WRIST_TEMPERATURE": + // v1.10.0 — categorical events (WX-B). Device-flagged EVENT rows are + // discrete occurrences (value is always 1), never a goal axis. A + // "personal record" for a health-notification event would be both + // meaningless and alarming, so they explicitly have no PR direction. + case "IRREGULAR_RHYTHM_NOTIFICATION": + case "HIGH_HEART_RATE_EVENT": + case "LOW_HEART_RATE_EVENT": + case "WALKING_STEADINESS_EVENT": + case "BREATHING_DISTURBANCE_EVENT": return null; } } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index ae9e7e616..79574fc24 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -58,6 +58,38 @@ export const measurementTypeEnum = z.enum([ "STAIR_ASCENT_SPEED", "STAIR_DESCENT_SPEED", "BREATHING_DISTURBANCES", + // ── v1.10.0 — categorical events (WX-B) ── + // Discrete device-flagged EVENT rows (value is always 1). The device's + // own verdict / severity rides in the `rhythmClassification` column. + "IRREGULAR_RHYTHM_NOTIFICATION", + "HIGH_HEART_RATE_EVENT", + "LOW_HEART_RATE_EVENT", + "WALKING_STEADINESS_EVENT", + "BREATHING_DISTURBANCE_EVENT", +]); + +/** + * v1.10.0 — categorical events (WX-B). The closed set of EVENT-class + * MeasurementTypes. An EVENT row carries `value = 1`, an optional + * `rhythmClassification` verdict, and never participates in trend / rollup + * analytics (it is a discrete occurrence, not a continuous reading). Kept + * as a `Set` so ingest + read paths can branch on "is this an event row?". + */ +export const EVENT_MEASUREMENT_TYPES: ReadonlySet = new Set([ + "IRREGULAR_RHYTHM_NOTIFICATION", + "HIGH_HEART_RATE_EVENT", + "LOW_HEART_RATE_EVENT", + "WALKING_STEADINESS_EVENT", + "BREATHING_DISTURBANCE_EVENT", +]); + +export const rhythmClassificationEnum = z.enum([ + "IRREGULAR", + "NOT_DETECTED", + "INCONCLUSIVE", + "LOW", + "VERY_LOW", + "FIRED", ]); export const glucoseContextEnum = z.enum([ @@ -159,6 +191,15 @@ const unitMap: Record = { // Per-night breathing-disturbance index — a unitless count Apple // classifies as NotElevated / Elevated. BREATHING_DISTURBANCES: "count", + // ── v1.10.0 — categorical events (WX-B) ── + // EVENT rows are dimensionless occurrences (value is always 1). The + // canonical unit is the bare "event" so any accidental numeric surfacing + // reads sensibly; the awareness timeline never displays the value. + IRREGULAR_RHYTHM_NOTIFICATION: "event", + HIGH_HEART_RATE_EVENT: "event", + LOW_HEART_RATE_EVENT: "event", + WALKING_STEADINESS_EVENT: "event", + BREATHING_DISTURBANCE_EVENT: "event", }; export function getUnitForType(type: string): string { @@ -293,6 +334,15 @@ const VALUE_RANGES: Record = { // undisturbed night and the 1000 ceiling is generous headroom over // any plausible severe-apnea night. BREATHING_DISTURBANCES: { min: 0, max: 1000 }, + // ── v1.10.0 — categorical events (WX-B) ── + // EVENT rows carry a fixed `value = 1` (one fired event). The range pins + // the value so a malformed ingest (value 0, or a stray sensor number) + // is rejected rather than stored as a phantom event. + IRREGULAR_RHYTHM_NOTIFICATION: { min: 1, max: 1 }, + HIGH_HEART_RATE_EVENT: { min: 1, max: 1 }, + LOW_HEART_RATE_EVENT: { min: 1, max: 1 }, + WALKING_STEADINESS_EVENT: { min: 1, max: 1 }, + BREATHING_DISTURBANCE_EVENT: { min: 1, max: 1 }, }; export function validateMeasurementRange(