From c05292f608446bb67a309df94589569e0911fac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 15:54:52 +0200 Subject: [PATCH] feat(ingest): port HK identifier mapping from k0rventen + dogsheep with MIT/Apache-2.0 attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends src/lib/measurements/apple-health-mapping.ts with the W8d mapping surface the v1.5 iOS-Swift session needs locked before it starts: - Three new APPLE_HEALTH_TYPE_MAP entries — HKQuantityTypeIdentifierEnvironmentalAudioExposure, HeadphoneAudioExposure, TimeInDaylight — wired to the matching MeasurementType values landed in Migration 0052. - HK_QUANTITY_TYPE_TO_MEASUREMENT: a compact derived view of the same data keyed by HK identifier so a caller that only needs the enum value can skip instantiating the full mapping record (XML import worker, iOS DTO routing). - HK_QUANTITY_TYPE_DEFERRED: the curated "we know about it, we are not mapping it yet" set. Covers iOS-17/18 running form, cycling, paddle / row / ski distances + speeds, sleep apnea, scored assessments (GAD-7 / PHQ-9), nutrition, mental-state, FHIR clinical, ECG, pregnancy / cycle. Each block carries its planned release window inline. File-header attribution credits the two open-source projects whose identifier lists were consulted as data — k0rventen/apple-health-grafana (MIT) and dogsheep/healthkit-to-sqlite (Apache-2.0); only the lookup strings were ported, not code. Test additions (src/lib/measurements/__tests__/apple-health-mapping.test.ts): - exhaustiveness — every MeasurementType outside the Withings-only set has an HK counterpart; - dedup — no identifier appears in both the mapping table and the deferred set; - identity-conversion check for the three new entries; - HK_QUANTITY_TYPE_TO_MEASUREMENT key + value parity with the full map; - deferred-set sanity (every entry starts with a real HK prefix). --- .../__tests__/apple-health-mapping.test.ts | 97 +++++++++++ src/lib/measurements/apple-health-mapping.ts | 161 ++++++++++++++++++ 2 files changed, 258 insertions(+) diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 8a1228e22..26bf6142e 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -2,9 +2,34 @@ import { describe, expect, it } from "vitest"; import { APPLE_HEALTH_SLEEP_STAGE_MAP, APPLE_HEALTH_TYPE_MAP, + HK_QUANTITY_TYPE_DEFERRED, + HK_QUANTITY_TYPE_TO_MEASUREMENT, mapAppleHealthEntry, } from "../apple-health-mapping"; import { measurementTypeEnum } from "@/lib/validations/measurement"; +import type { MeasurementType } from "@/generated/prisma/client"; + +/** + * MeasurementType values that Apple HealthKit has no native counterpart + * for. Listing them here means the exhaustiveness check below is a tight + * fence — additions to the canonical enum that genuinely don't have an HK + * twin (e.g. Withings-only metrics) stay out of the mapping table without + * silently failing the assertion. + */ +const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ + // Withings-only metrics (no HK identifier ships any of these as a + // first-class quantity — Apple delegates body-composition tail to + // third-party devices via the BodyMass/BodyFat pair). + "TOTAL_BODY_WATER", + "BONE_MASS", + "FAT_FREE_MASS", + "FAT_MASS", + "MUSCLE_MASS", + "SKIN_TEMPERATURE", + "PULSE_WAVE_VELOCITY", + "VASCULAR_AGE", + "VISCERAL_FAT", +]); describe("APPLE_HEALTH_TYPE_MAP", () => { it("only references measurement types that exist in the canonical enum", () => { @@ -35,6 +60,78 @@ describe("APPLE_HEALTH_TYPE_MAP", () => { "HKQuantityTypeIdentifierVO2Max", ].sort(), ); + // Audio exposure + time-in-daylight are environment / lifestyle + // metrics — Apple does not gate them behind an explicit consent + // screen beyond the bulk "Health share" prompt, so the mapping + // entries stay non-sensitive. + const newEntries = [ + "HKQuantityTypeIdentifierEnvironmentalAudioExposure", + "HKQuantityTypeIdentifierHeadphoneAudioExposure", + "HKQuantityTypeIdentifierTimeInDaylight", + ]; + for (const id of newEntries) { + expect(APPLE_HEALTH_TYPE_MAP[id]?.isPrivacySensitive).toBeUndefined(); + } + }); + + it("covers every MeasurementType that has a HealthKit counterpart", () => { + const mappedTypes = new Set( + Object.values(APPLE_HEALTH_TYPE_MAP).map((m) => m.measurementType), + ); + const expectedTypes = measurementTypeEnum.options.filter( + (t) => !MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART.has(t), + ); + for (const type of expectedTypes) { + expect( + mappedTypes.has(type), + `MeasurementType ${type} has no HealthKit mapping entry`, + ).toBe(true); + } + }); + + it("does not double-book a deferred identifier as a mapped one", () => { + for (const deferred of HK_QUANTITY_TYPE_DEFERRED) { + expect( + APPLE_HEALTH_TYPE_MAP[deferred], + `${deferred} is both deferred and mapped — pick one`, + ).toBeUndefined(); + } + }); + + it("emits an identity-conversion entry for the three v1.4.25 W8d additions", () => { + for (const id of [ + "HKQuantityTypeIdentifierEnvironmentalAudioExposure", + "HKQuantityTypeIdentifierHeadphoneAudioExposure", + "HKQuantityTypeIdentifierTimeInDaylight", + ]) { + const mapping = APPLE_HEALTH_TYPE_MAP[id]; + expect(mapping, `${id} mapping missing`).toBeDefined(); + expect(mapping!.convertToDbUnit(42)).toBe(42); + } + }); +}); + +describe("HK_QUANTITY_TYPE_TO_MEASUREMENT", () => { + it("mirrors APPLE_HEALTH_TYPE_MAP key-for-key", () => { + expect(Object.keys(HK_QUANTITY_TYPE_TO_MEASUREMENT).sort()).toEqual( + Object.keys(APPLE_HEALTH_TYPE_MAP).sort(), + ); + }); + + it("agrees with APPLE_HEALTH_TYPE_MAP on the measurement type per key", () => { + for (const [k, t] of Object.entries(HK_QUANTITY_TYPE_TO_MEASUREMENT)) { + expect(APPLE_HEALTH_TYPE_MAP[k]?.measurementType).toBe(t); + } + }); +}); + +describe("HK_QUANTITY_TYPE_DEFERRED", () => { + it("contains only valid HealthKit identifier strings", () => { + for (const id of HK_QUANTITY_TYPE_DEFERRED) { + expect(id).toMatch( + /^HK(Quantity|Category|Clinical|Series|Data|Scored|Electrocardiogram)/, + ); + } }); it("converts oxygen saturation from 0..1 fraction to 0..100 percent", () => { diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 591376e91..c56671e54 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -11,6 +11,17 @@ * `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. + * + * Identifier mappings derived from open-source reference projects: + * - k0rventen/apple-health-grafana (MIT) — `ingester/ingester.py` + * ROUTE_AND_FORMAT type map cross-checked against Apple's + * `HKQuantityTypeIdentifier` index. + * - dogsheep/healthkit-to-sqlite (Apache-2.0) — + * `healthkit_to_sqlite/utils.py` workout/sample identifier list. + * Only the lookup data (HKQuantityTypeIdentifier* string → HealthLog + * MeasurementType) was ported; no upstream code is incorporated. The + * unit, conversion, and aggregation columns are HealthLog-specific + * decisions checked against Apple's HKUnit documentation. */ import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; @@ -237,8 +248,158 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { isPrivacySensitive: true, sleepStageMap: APPLE_HEALTH_SLEEP_STAGE_MAP, }, + + // ── v1.4.25 W8d Apple Health server-prep ──────────────────── + // Environmental audio exposure — Watch + iPhone microphone, sampled + // every ~30 s while the Watch is worn. Apple's HK identifier is + // `HKQuantityTypeIdentifierEnvironmentalAudioExposure` despite the + // categorical name; the value is a continuous dBA SPL number. + HKQuantityTypeIdentifierEnvironmentalAudioExposure: { + hkIdentifier: "HKQuantityTypeIdentifierEnvironmentalAudioExposure", + measurementType: "AUDIO_EXPOSURE_ENV", + hkUnit: "dBASPL", + dbUnit: "dBA", + convertToDbUnit: (v) => v, + aggregation: "mean", + }, + // Headphone audio exposure — AirPods + supported Beats earbuds. + // Apple's "Reduce Loud Sounds" warning triggers at 80 dBA average + // over 7 days; we keep the canonical unit as plain dBA so the + // analytics layer doesn't have to thread a separate weighting flag. + HKQuantityTypeIdentifierHeadphoneAudioExposure: { + hkIdentifier: "HKQuantityTypeIdentifierHeadphoneAudioExposure", + measurementType: "AUDIO_EXPOSURE_HEADPHONE", + hkUnit: "dBASPL", + dbUnit: "dBA", + convertToDbUnit: (v) => v, + aggregation: "mean", + }, + // Time in daylight — iOS 17+ Health app metric, derived from Watch + // ambient-light + GPS. One sample per day in practice; the DB unit + // is minutes (Apple ships `min` directly). + HKQuantityTypeIdentifierTimeInDaylight: { + hkIdentifier: "HKQuantityTypeIdentifierTimeInDaylight", + measurementType: "TIME_IN_DAYLIGHT", + hkUnit: "min", + dbUnit: "minutes", + convertToDbUnit: (v) => v, + aggregation: "sum", + }, }; +/** + * Compact `HKQuantityTypeIdentifier* → MeasurementType` view of the + * same data carried by `APPLE_HEALTH_TYPE_MAP`. Exists so the iOS + * Swift session and the v1.4.26 XML-import worker can do a cheap + * lookup without instantiating the full `AppleHealthMapping` record + * — useful when all the caller wants is the canonical enum value + * for routing / filtering. Always stays a strict subset of the keys + * in the table above. + */ +export const HK_QUANTITY_TYPE_TO_MEASUREMENT: Record< + string, + import("@/generated/prisma/client").MeasurementType +> = Object.fromEntries( + Object.entries(APPLE_HEALTH_TYPE_MAP).map(([k, v]) => [k, v.measurementType]), +); + +/** + * HK identifiers the iOS app may emit that HealthLog deliberately does + * NOT map yet. Listing them here means the batch route can log a + * "deferred, not unknown" signal and the iOS DTO can decide upstream + * to skip the request altogether — both better than silently dropping + * the row inside `mapAppleHealthEntry()`. + * + * Each entry is paired with the planned-shipment release in + * `apple-health-ecosystem-scan.md` §7 (v1.5 baseline; later releases + * carry the long-tail). Refresh this set when a mapping moves out of + * defer status above so the test in + * `__tests__/apple-health-mapping.test.ts` flags double-bookings. + */ +export const HK_QUANTITY_TYPE_DEFERRED = new Set([ + // Body composition / vitals — v1.5 + "HKQuantityTypeIdentifierBodyMassIndex", // computed from weight + height — never stored + "HKQuantityTypeIdentifierHeight", // already on User.heightCm + "HKQuantityTypeIdentifierLeanBodyMass", + "HKQuantityTypeIdentifierRespiratoryRate", + "HKQuantityTypeIdentifierWalkingHeartRateAverage", + "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + "HKQuantityTypeIdentifierBasalEnergyBurned", + "HKQuantityTypeIdentifierAppleExerciseTime", // implied by Workout rows + "HKQuantityTypeIdentifierAppleStandTime", // implied by Workout rows + "HKCategoryTypeIdentifierAppleStandHour", // implied by Workout rows + // Running / walking form — v1.5+ + "HKQuantityTypeIdentifierWalkingSpeed", + "HKQuantityTypeIdentifierWalkingStepLength", + "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", + "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", + "HKQuantityTypeIdentifierStairAscentSpeed", + "HKQuantityTypeIdentifierStairDescentSpeed", + "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + "HKQuantityTypeIdentifierRunningSpeed", + "HKQuantityTypeIdentifierRunningPower", + "HKQuantityTypeIdentifierRunningStrideLength", + "HKQuantityTypeIdentifierRunningGroundContactTime", + "HKQuantityTypeIdentifierRunningVerticalOscillation", + // Cycling (iOS 17) — v1.5 + "HKQuantityTypeIdentifierCyclingCadence", + "HKQuantityTypeIdentifierCyclingFunctionalThresholdPower", + "HKQuantityTypeIdentifierCyclingPower", + "HKQuantityTypeIdentifierCyclingSpeed", + "HKQuantityTypeIdentifierDistanceCycling", + // Sport-specific distances + speeds (iOS 18) — v1.5+ + "HKQuantityTypeIdentifierDistanceCrossCountrySkiing", + "HKQuantityTypeIdentifierCrossCountrySkiingSpeed", + "HKQuantityTypeIdentifierDistancePaddleSports", + "HKQuantityTypeIdentifierPaddleSportsSpeed", + "HKQuantityTypeIdentifierDistanceRowing", + "HKQuantityTypeIdentifierRowingSpeed", + "HKQuantityTypeIdentifierDistanceSkatingSports", + "HKQuantityTypeIdentifierDistanceSwimming", + "HKQuantityTypeIdentifierSwimmingStrokeCount", + // Workout-effort scores (iOS 18) — v1.5+ + "HKQuantityTypeIdentifierEstimatedWorkoutEffortScore", + "HKQuantityTypeIdentifierWorkoutEffortScore", + "HKQuantityTypeIdentifierPhysicalEffort", + // Sleep apnea + breathing (iOS 18) — v1.5+ + "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + "HKCategoryTypeIdentifierSleepApneaEvent", + // Nutrition — Marc directive, indefinite hold + "HKQuantityTypeIdentifierDietaryWater", + "HKQuantityTypeIdentifierDietaryCaffeine", + "HKQuantityTypeIdentifierDietaryEnergyConsumed", + "HKQuantityTypeIdentifierDietaryCarbohydrates", + "HKQuantityTypeIdentifierDietaryProtein", + "HKQuantityTypeIdentifierDietaryFatTotal", + "HKQuantityTypeIdentifierDietarySugar", + "HKQuantityTypeIdentifierDietaryFiber", + "HKQuantityTypeIdentifierDietarySodium", + // Mental-state / mindfulness — v1.5 (route to existing mood model) + "HKCategoryTypeIdentifierMindfulSession", + "HKDataTypeIdentifierStateOfMind", + // Pregnancy / cycle — explicit hold (privacy stance) + "HKCategoryTypeIdentifierBleedingAfterPregnancy", + "HKCategoryTypeIdentifierBleedingDuringPregnancy", + "HKCategoryTypeIdentifierMenstrualFlow", + "HKCategoryTypeIdentifierIntermenstrualBleeding", + "HKCategoryTypeIdentifierCervicalMucusQuality", + "HKCategoryTypeIdentifierOvulationTestResult", + // Clinical (FHIR) — v1.6+ + "HKClinicalTypeIdentifierAllergyRecord", + "HKClinicalTypeIdentifierConditionRecord", + "HKClinicalTypeIdentifierImmunizationRecord", + "HKClinicalTypeIdentifierLabResultRecord", + "HKClinicalTypeIdentifierMedicationRecord", + "HKClinicalTypeIdentifierProcedureRecord", + "HKClinicalTypeIdentifierVitalSignRecord", + // ECG / waveforms — defer + "HKElectrocardiogramType", + // Scored assessments (iOS 18) — v1.5 (PHQ-9 / GAD-7 mapping TBD) + "HKScoredAssessmentTypeIdentifierGAD7", + "HKScoredAssessmentTypeIdentifierPHQ9", +] as const); + /** Input to `mapAppleHealthEntry()`. */ export interface AppleHealthEntryInput { hkIdentifier: string;