feat(ingest): port HK identifier mapping from k0rventen + dogsheep with MIT/Apache-2.0 attribution

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).
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 15:54:52 +02:00
1 parent 6a7da4b2cb
commit c05292f608
2 files changed
+258

No files matched your search

@@ -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<MeasurementType>([
// 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", () => {
@@ -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<string, AppleHealthMapping> = {
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<string>([
// 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;