mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(healthkit): add a categorical device-event row class
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.
This commit is contained in:
1 parent
635e64ace7
commit
b06b1cada4
7 files changed
+416
-16
No files matched your search
@@ -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
|
||||
$$;
|
||||
@@ -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
|
||||
|
||||
@@ -90,6 +90,12 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
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<string, LucideIcon> = {
|
||||
@@ -167,6 +173,15 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
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<string, string> = {
|
||||
@@ -235,4 +250,13 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
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",
|
||||
};
|
||||
@@ -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<number, SleepStage>;
|
||||
/**
|
||||
* 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<number, RhythmClassification>;
|
||||
/**
|
||||
* 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<string, AppleHealthMapping> = {
|
||||
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<string>([
|
||||
"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<string>([
|
||||
"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<string>([
|
||||
"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;
|
||||
}
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> = new Set<string>([
|
||||
"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<string, string> = {
|
||||
// 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<string, { min: number; max: number }> = {
|
||||
// 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(
|
||||
|
||||
Reference in new issue
Block a user