From 30c0f76a347a732e427e79535a2f1a1eb85c0760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 11 May 2026 00:17:56 +0200 Subject: [PATCH] feat(measurements): Apple Health identifier mapping helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `src/lib/measurements/apple-health-mapping.ts` translates inbound HealthKit samples into the row shape the `Measurement` model expects. Symmetrical to the Withings `MEASURE_TYPE_MAP` but keyed by HealthKit string identifier so the upcoming batch endpoint can do a single table lookup per entry. Each mapping carries its canonical DB unit, a unit-conversion helper (identity for SI-aligned types, ×100 for HealthKit's 0..1 fraction quantities), an aggregation hint for downstream summarisation, and flags the explicit-consent metrics (HRV, VO2 Max, sleep) so the audit trail can pick them out. Sleep is the one category type — the helper returns one row per stage with the new `sleepStage` column populated; per-stage minutes go straight in as `value`. Unknown identifiers return `null` so the batch endpoint can mark the entry as skipped instead of failing the whole batch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/apple-health-mapping.test.ts | 199 +++++++++++ src/lib/measurements/apple-health-mapping.ts | 318 ++++++++++++++++++ 2 files changed, 517 insertions(+) create mode 100644 src/lib/measurements/__tests__/apple-health-mapping.test.ts create mode 100644 src/lib/measurements/apple-health-mapping.ts diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts new file mode 100644 index 000000000..cb11c34d2 --- /dev/null +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + APPLE_HEALTH_SLEEP_STAGE_MAP, + APPLE_HEALTH_TYPE_MAP, + mapAppleHealthEntry, +} from "../apple-health-mapping"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; + +describe("APPLE_HEALTH_TYPE_MAP", () => { + it("only references measurement types that exist in the canonical enum", () => { + const enumValues = new Set(measurementTypeEnum.options); + for (const mapping of Object.values(APPLE_HEALTH_TYPE_MAP)) { + expect( + enumValues.has(mapping.measurementType), + `${mapping.hkIdentifier} → unknown MeasurementType ${mapping.measurementType}`, + ).toBe(true); + } + }); + + it("uses each mapping key as the canonical hkIdentifier", () => { + for (const [key, mapping] of Object.entries(APPLE_HEALTH_TYPE_MAP)) { + expect(mapping.hkIdentifier).toBe(key); + } + }); + + it("flags the explicit-consent metrics as privacy sensitive", () => { + const sensitive = Object.values(APPLE_HEALTH_TYPE_MAP) + .filter((m) => m.isPrivacySensitive) + .map((m) => m.hkIdentifier) + .sort(); + expect(sensitive).toEqual( + [ + "HKCategoryTypeIdentifierSleepAnalysis", + "HKQuantityTypeIdentifierHeartRateVariabilitySDNN", + "HKQuantityTypeIdentifierVO2Max", + ].sort(), + ); + }); + + it("converts oxygen saturation from 0..1 fraction to 0..100 percent", () => { + const mapping = APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierOxygenSaturation; + expect(mapping.convertToDbUnit(0.97)).toBeCloseTo(97); + }); + + it("converts body fat percentage from 0..1 fraction to 0..100 percent", () => { + const mapping = + APPLE_HEALTH_TYPE_MAP.HKQuantityTypeIdentifierBodyFatPercentage; + expect(mapping.convertToDbUnit(0.245)).toBeCloseTo(24.5); + }); + + it("uses identity conversion for the SI-aligned identifiers", () => { + for (const id of [ + "HKQuantityTypeIdentifierBodyMass", + "HKQuantityTypeIdentifierBloodPressureSystolic", + "HKQuantityTypeIdentifierBloodPressureDiastolic", + "HKQuantityTypeIdentifierHeartRate", + "HKQuantityTypeIdentifierRestingHeartRate", + "HKQuantityTypeIdentifierHeartRateVariabilitySDNN", + "HKQuantityTypeIdentifierStepCount", + "HKQuantityTypeIdentifierActiveEnergyBurned", + "HKQuantityTypeIdentifierFlightsClimbed", + "HKQuantityTypeIdentifierDistanceWalkingRunning", + "HKQuantityTypeIdentifierVO2Max", + "HKQuantityTypeIdentifierBloodGlucose", + "HKQuantityTypeIdentifierBodyTemperature", + ]) { + const mapping = APPLE_HEALTH_TYPE_MAP[id]; + expect(mapping.convertToDbUnit(42)).toBe(42); + } + }); +}); + +describe("APPLE_HEALTH_SLEEP_STAGE_MAP", () => { + it("maps the iOS 16+ stage codepoints to DB enum values", () => { + expect(APPLE_HEALTH_SLEEP_STAGE_MAP[0]).toBe("IN_BED"); + expect(APPLE_HEALTH_SLEEP_STAGE_MAP[1]).toBe("ASLEEP"); + expect(APPLE_HEALTH_SLEEP_STAGE_MAP[2]).toBe("AWAKE"); + expect(APPLE_HEALTH_SLEEP_STAGE_MAP[3]).toBe("CORE"); + expect(APPLE_HEALTH_SLEEP_STAGE_MAP[4]).toBe("DEEP"); + expect(APPLE_HEALTH_SLEEP_STAGE_MAP[5]).toBe("REM"); + }); + + it("does not double-claim a codepoint for two stages", () => { + const codepoints = Object.keys(APPLE_HEALTH_SLEEP_STAGE_MAP); + expect(new Set(codepoints).size).toBe(codepoints.length); + }); +}); + +describe("mapAppleHealthEntry", () => { + const ts = "2026-05-10T07:30:00.000Z"; + + it("returns null for an unknown identifier", () => { + expect( + mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierHallucinated", + value: 1, + unit: "kg", + startDate: ts, + endDate: ts, + }), + ).toBeNull(); + }); + + it("returns null for an invalid endDate", () => { + expect( + mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 80, + unit: "kg", + startDate: ts, + endDate: "not-a-date", + }), + ).toBeNull(); + }); + + it("anchors `takenAt` on the sample's endDate", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 81.5, + unit: "kg", + startDate: "2026-05-10T07:00:00.000Z", + endDate: ts, + }); + expect(out).not.toBeNull(); + expect(out!.takenAt.toISOString()).toBe(ts); + }); + + it("applies the unit conversion for fraction-shaped quantities", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation", + value: 0.97, + unit: "fraction", + startDate: ts, + endDate: ts, + }); + expect(out).not.toBeNull(); + expect(out!.type).toBe("OXYGEN_SATURATION"); + expect(out!.unit).toBe("%"); + expect(out!.value).toBeCloseTo(97); + expect(out!.sleepStage).toBeUndefined(); + }); + + it("yields a body weight row for a HKQuantityTypeIdentifierBodyMass sample", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 81.5, + unit: "kg", + startDate: ts, + endDate: ts, + }); + expect(out).toEqual({ + type: "WEIGHT", + value: 81.5, + unit: "kg", + takenAt: new Date(ts), + }); + }); + + it("yields a per-stage sleep row for a sleepAnalysis sample", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", + value: 95, // minutes spent in this stage + unit: "min", + startDate: "2026-05-10T01:00:00.000Z", + endDate: "2026-05-10T02:35:00.000Z", + sleepStage: 4, // DEEP + }); + expect(out).toEqual({ + type: "SLEEP_DURATION", + value: 95, + unit: "minutes", + takenAt: new Date("2026-05-10T02:35:00.000Z"), + sleepStage: "DEEP", + }); + }); + + it("returns null for a sleepAnalysis sample without a stage codepoint", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", + value: 60, + unit: "min", + startDate: ts, + endDate: ts, + }); + expect(out).toBeNull(); + }); + + it("returns null for a sleepAnalysis sample with an unknown stage codepoint", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", + value: 60, + unit: "min", + startDate: ts, + endDate: ts, + sleepStage: 99, + }); + expect(out).toBeNull(); + }); +}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts new file mode 100644 index 000000000..2e259e3c8 --- /dev/null +++ b/src/lib/measurements/apple-health-mapping.ts @@ -0,0 +1,318 @@ +/** + * Apple HealthKit identifier → HealthLog measurement mapping. + * + * Mirrors the Withings `MEASURE_TYPE_MAP` in `src/lib/withings/client.ts` + * but keyed by the HealthKit string identifier rather than a numeric + * type code. The `POST /api/measurements/batch` endpoint walks each + * inbound HealthKit sample through `mapAppleHealthEntry()` to produce + * the row shape the existing `Measurement` model expects. + * + * Adding a new identifier is a one-place change — append an entry to + * `APPLE_HEALTH_TYPE_MAP`. The unit-conversion helper, the canonical + * DB unit, and the aggregation hint all live alongside the identifier + * so future analytics work has a single discoverable surface. + */ +import type { + MeasurementType, + SleepStage, +} from "@/generated/prisma/client"; + +/** + * How a batch of HealthKit samples for the same identifier should be + * reduced into stored `Measurement` rows. The batch ingest endpoint + * stores every sample as-is in v1.4.23 — the aggregation hint is + * advisory metadata for downstream summarisation (analytics, AI + * Coach evidence chips). Future work can move the reduction earlier + * in the pipeline once we observe the production sample volume. + */ +export type AppleHealthAggregation = + | "sum" + | "mean" + | "latest" + | "max" + | "median"; + +export interface AppleHealthMapping { + /** HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`). */ + hkIdentifier: string; + /** HealthLog `MeasurementType` enum value. */ + measurementType: MeasurementType; + /** Apple's HK unit string (e.g. `kg`, `count/min`, `mL/(kg*min)`). */ + hkUnit: string; + /** HealthLog canonical DB unit (must match `getUnitForType()`). */ + dbUnit: string; + /** Convert a HK sample value into the canonical DB unit. Identity for most. */ + convertToDbUnit: (hkValue: number) => number; + /** Advisory aggregation hint — see `AppleHealthAggregation` doc. */ + aggregation: AppleHealthAggregation; + /** + * `true` for metrics behind an explicit consent screen (HRV, VO2 Max, + * sleep stages). The iOS app is responsible for asking; the server + * trusts the inbound batch. Recorded here so the server-side audit + * trail can flag privacy-sensitive ingest separately if needed. + */ + isPrivacySensitive?: boolean; + /** + * Numeric `HKCategoryValueSleepAnalysis` → `SleepStage` map, only + * populated for `HKCategoryTypeIdentifierSleepAnalysis`. Apple's + * value enum is integer-valued; we accept the integer in the inbound + * sample and look up the DB-side stage here. + */ + sleepStageMap?: Record; +} + +/** + * Numeric values of `HKCategoryValueSleepAnalysis`. Apple's enum is + * platform-versioned; the values below match the iOS 16+ codepoints. + * Legacy iOS 15- only emits `inBed` (0) and `asleepUnspecified` (1) — + * the latter maps to `ASLEEP` so old samples still round-trip. + * + * Source: Apple's `HealthKit/HKCategoryValueSleepAnalysis.h` header. + */ +export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record = { + 0: "IN_BED", + 1: "ASLEEP", // legacy iOS 15- `asleepUnspecified` + 2: "AWAKE", + 3: "CORE", // iOS 16+ `asleepCore` + 4: "DEEP", // iOS 16+ `asleepDeep` + 5: "REM", // iOS 16+ `asleepREM` +}; + +/** + * The mapping table. `convertToDbUnit` is identity for most metrics + * because we picked DB units that match Apple's defaults. Two + * exceptions: + * + * - `HKQuantityTypeIdentifierOxygenSaturation` ships as a 0..1 + * fraction; HealthLog stores percent (0..100). + * - `HKQuantityTypeIdentifierBodyFatPercentage` ships as a 0..1 + * fraction; HealthLog stores percent (0..100). + */ +export const APPLE_HEALTH_TYPE_MAP: Record = { + // ── Body composition ──────────────────────────────────────── + HKQuantityTypeIdentifierBodyMass: { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + measurementType: "WEIGHT", + hkUnit: "kg", + dbUnit: "kg", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + HKQuantityTypeIdentifierBodyFatPercentage: { + hkIdentifier: "HKQuantityTypeIdentifierBodyFatPercentage", + measurementType: "BODY_FAT", + hkUnit: "%", + dbUnit: "%", + // Apple ships 0..1 fraction; HealthLog stores 0..100. + convertToDbUnit: (v) => v * 100, + aggregation: "latest", + }, + HKQuantityTypeIdentifierBodyTemperature: { + hkIdentifier: "HKQuantityTypeIdentifierBodyTemperature", + measurementType: "BODY_TEMPERATURE", + hkUnit: "degC", + dbUnit: "celsius", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + + // ── Cardiovascular ────────────────────────────────────────── + HKQuantityTypeIdentifierBloodPressureSystolic: { + hkIdentifier: "HKQuantityTypeIdentifierBloodPressureSystolic", + measurementType: "BLOOD_PRESSURE_SYS", + hkUnit: "mmHg", + dbUnit: "mmHg", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + HKQuantityTypeIdentifierBloodPressureDiastolic: { + hkIdentifier: "HKQuantityTypeIdentifierBloodPressureDiastolic", + measurementType: "BLOOD_PRESSURE_DIA", + hkUnit: "mmHg", + dbUnit: "mmHg", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + HKQuantityTypeIdentifierHeartRate: { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + measurementType: "PULSE", + hkUnit: "count/min", + dbUnit: "bpm", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + HKQuantityTypeIdentifierRestingHeartRate: { + hkIdentifier: "HKQuantityTypeIdentifierRestingHeartRate", + measurementType: "RESTING_HEART_RATE", + hkUnit: "count/min", + dbUnit: "bpm", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + HKQuantityTypeIdentifierHeartRateVariabilitySDNN: { + hkIdentifier: "HKQuantityTypeIdentifierHeartRateVariabilitySDNN", + measurementType: "HEART_RATE_VARIABILITY", + hkUnit: "ms", + dbUnit: "ms", + convertToDbUnit: (v) => v, + aggregation: "mean", + isPrivacySensitive: true, + }, + + // ── Activity (cumulative) ─────────────────────────────────── + HKQuantityTypeIdentifierStepCount: { + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + // Reuses existing enum value (the W1 research recommended this + // over a parallel STEP_COUNT value to avoid analytics-side + // duplication; see `.planning/phase-W1-v1423-research.md`). + measurementType: "ACTIVITY_STEPS", + hkUnit: "count", + dbUnit: "steps", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + HKQuantityTypeIdentifierActiveEnergyBurned: { + hkIdentifier: "HKQuantityTypeIdentifierActiveEnergyBurned", + measurementType: "ACTIVE_ENERGY_BURNED", + hkUnit: "kcal", + dbUnit: "kcal", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + HKQuantityTypeIdentifierFlightsClimbed: { + hkIdentifier: "HKQuantityTypeIdentifierFlightsClimbed", + measurementType: "FLIGHTS_CLIMBED", + hkUnit: "count", + dbUnit: "flights", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + HKQuantityTypeIdentifierDistanceWalkingRunning: { + hkIdentifier: "HKQuantityTypeIdentifierDistanceWalkingRunning", + measurementType: "WALKING_RUNNING_DISTANCE", + hkUnit: "m", + dbUnit: "m", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, + + // ── Fitness ───────────────────────────────────────────────── + HKQuantityTypeIdentifierVO2Max: { + hkIdentifier: "HKQuantityTypeIdentifierVO2Max", + measurementType: "VO2_MAX", + hkUnit: "mL/(kg*min)", + dbUnit: "mL/(kg·min)", + convertToDbUnit: (v) => v, + aggregation: "latest", + isPrivacySensitive: true, + }, + + // ── Other in-scope metrics already mapped via existing enums ── + HKQuantityTypeIdentifierBloodGlucose: { + hkIdentifier: "HKQuantityTypeIdentifierBloodGlucose", + measurementType: "BLOOD_GLUCOSE", + hkUnit: "mg/dL", + dbUnit: "mg/dL", + convertToDbUnit: (v) => v, + aggregation: "latest", + }, + HKQuantityTypeIdentifierOxygenSaturation: { + hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation", + measurementType: "OXYGEN_SATURATION", + hkUnit: "fraction", + dbUnit: "%", + // Apple ships 0..1 fraction; HealthLog stores 0..100. + convertToDbUnit: (v) => v * 100, + aggregation: "latest", + }, + + // ── Sleep (category type) ─────────────────────────────────── + HKCategoryTypeIdentifierSleepAnalysis: { + hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", + measurementType: "SLEEP_DURATION", + hkUnit: "category", + dbUnit: "minutes", + // value here is duration-in-minutes (caller computes + // `endDate - startDate` and converts to minutes); the per-stage + // label arrives in `sleepStage` and is mapped via `sleepStageMap`. + convertToDbUnit: (v) => v, + aggregation: "sum", + isPrivacySensitive: true, + sleepStageMap: APPLE_HEALTH_SLEEP_STAGE_MAP, + }, +}; + +/** Input to `mapAppleHealthEntry()`. */ +export interface AppleHealthEntryInput { + hkIdentifier: string; + /** Numeric value as Apple delivers it (pre-conversion). */ + value: number; + /** Apple's unit string, captured for audit; not currently validated. */ + unit: string; + /** ISO timestamp string (e.g. HealthKit `startDate`). */ + startDate: string; + /** ISO timestamp string (e.g. HealthKit `endDate`). */ + endDate: string; + /** + * For `HKCategoryTypeIdentifierSleepAnalysis` only — the integer + * `HKCategoryValueSleepAnalysis` codepoint. Ignored for quantity + * types. + */ + sleepStage?: number; +} + +/** Output of `mapAppleHealthEntry()`. */ +export interface AppleHealthEntryOutput { + type: MeasurementType; + value: number; + unit: string; + takenAt: Date; + /** Set only for sleep entries. */ + sleepStage?: SleepStage; +} + +/** + * Map a single inbound HealthKit sample into the row shape the + * `Measurement` model expects. Returns `null` if the identifier is + * unknown — the caller should treat that as a "skipped" entry rather + * than a hard error so a single rogue sample doesn't poison a batch. + * + * - `takenAt` is the sample's `endDate` because that's the moment the + * measurement was completed (Apple's convention; matches our + * existing `measuredAt` semantics for Withings). + * - For sleep samples the inbound `value` is duration-in-minutes + * (caller computes `endDate - startDate` and divides by 60_000); we + * reproduce that contract here without re-deriving from the dates so + * the helper is decoupled from clock-skew between iOS and the server. + */ +export function mapAppleHealthEntry( + input: AppleHealthEntryInput, +): AppleHealthEntryOutput | null { + const mapping = APPLE_HEALTH_TYPE_MAP[input.hkIdentifier]; + if (!mapping) return null; + + const takenAt = new Date(input.endDate); + if (Number.isNaN(takenAt.getTime())) return null; + + const value = mapping.convertToDbUnit(input.value); + + if (mapping.sleepStageMap !== undefined) { + if (input.sleepStage === undefined) return null; + const stage = mapping.sleepStageMap[input.sleepStage]; + if (!stage) return null; + return { + type: mapping.measurementType, + value, + unit: mapping.dbUnit, + takenAt, + sleepStage: stage, + }; + } + + return { + type: mapping.measurementType, + value, + unit: mapping.dbUnit, + takenAt, + }; +}