mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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;
|
||||
|
||||
Reference in new issue
Block a user