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:
Marc-André Bombeck committed 2026-06-02 22:07:28 +02:00
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
$$;
+51
View File
@@ -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",
};
+174 -16
View File
@@ -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;
}
+13
View File
@@ -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"],
]);
/**
+9
View File
@@ -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;
}
}
+50
View File
@@ -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(