diff --git a/src/__tests__/apple-health-unit-audit-guard.test.ts b/src/__tests__/apple-health-unit-audit-guard.test.ts new file mode 100644 index 000000000..49e162269 --- /dev/null +++ b/src/__tests__/apple-health-unit-audit-guard.test.ts @@ -0,0 +1,165 @@ +/** + * Structural guard on the Apple Health mapping table's unit column + * (issue #944). + * + * `AppleHealthMapping.hkUnit` records the unit HealthLog ASSUMES a + * HealthKit reading arrives in, and `convertToDbUnit` is written against + * that assumption. Apple's `export.xml` does not honour it: every quantity + * `` carries the account's own display unit, so a metric archive + * reports walking distance in `km` and an imperial one in `mi`. For ten + * years those readings were stored as if the number were already metres — + * the whole history understated by a factor of a thousand — because the + * mapper accepted `input.unit` and never read it. + * + * So an entry now has exactly two legal shapes: + * + * 1. its `hkUnit` is one `convertHkValue()` knows a factor for, in which + * case `mapAppleHealthEntry()` converts a differing record unit into + * it before `convertToDbUnit` runs; or + * 2. it carries a written `unitFixedReason` saying why no unit attribute + * Apple could write would change the reading (a dimensionless count, + * a pinned event, a logarithmic dB level, a compound rate). + * + * The reason arm alone would be a guard that cannot fail: an entry could + * declare a convertible unit while the mapper quietly ignored it. The + * behavioural block below therefore feeds every convertible entry a + * reading in a SIBLING unit and asserts the stored number moved by the + * documented factor — so deleting the conversion turns this guard red, + * not just the one reproduction test. + * + * A tripwire, not a proof: a reviewer who waves through a wrong factor + * defeats it. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + APPLE_HEALTH_TYPE_MAP, + mapAppleHealthEntry, +} from "@/lib/measurements/apple-health-mapping"; +import { + convertHkValue, + isConvertibleHkUnit, +} from "@/lib/measurements/hk-units"; + +const ENTRIES = Object.values(APPLE_HEALTH_TYPE_MAP); + +/** + * Units from every family the table touches. The behavioural block picks + * the first one that converts into an entry's `hkUnit` and actually moves + * the number, so a new family only needs one representative here. + */ +const SIBLING_UNITS = [ + "km", + "mi", + "cm", + "lb", + "st", + "kJ", + "cal", + "degF", + "K", + "mmol<180.156>/L", + "kPa", + "km/hr", + "hr", + "ms", +]; + +function siblingUnitFor(hkUnit: string): string | null { + for (const candidate of SIBLING_UNITS) { + const converted = convertHkValue(1, candidate, hkUnit); + if (converted !== null && converted !== 1) return candidate; + } + return null; +} + +describe("Apple Health mapping — the record's own unit (issue #944)", () => { + it("examines the whole mapping table", () => { + // An empty match set is the failure mode this repo keeps rediscovering: + // a guard is green because it matched nothing, not because nothing is + // wrong. Pin the floor. + expect(ENTRIES.length).toBeGreaterThan(40); + }); + + it.each(ENTRIES)( + "$hkIdentifier either converts its record unit or says why it cannot", + (mapping) => { + if (isConvertibleHkUnit(mapping.hkUnit)) { + expect(mapping.unitFixedReason).toBeUndefined(); + return; + } + expect( + mapping.unitFixedReason, + `${mapping.hkIdentifier} pins hkUnit "${mapping.hkUnit}" with no conversion and no written reason`, + ).toBeTypeOf("string"); + // Long enough to be a sentence rather than a shrug. + expect(mapping.unitFixedReason!.length).toBeGreaterThan(40); + }, + ); + + const convertible = ENTRIES.filter((mapping) => + isConvertibleHkUnit(mapping.hkUnit), + ); + + it("has convertible entries to exercise", () => { + expect(convertible.length).toBeGreaterThan(10); + }); + + it.each(convertible)( + "$hkIdentifier honours a sibling unit on the record itself", + (mapping) => { + const sibling = siblingUnitFor(mapping.hkUnit); + expect( + sibling, + `no sibling unit listed for the family of "${mapping.hkUnit}"`, + ).not.toBeNull(); + + const base = { + hkIdentifier: mapping.hkIdentifier, + value: 2, + startDate: "2026-05-14T08:00:00.000Z", + endDate: "2026-05-14T08:30:00.000Z", + }; + const native = mapAppleHealthEntry({ ...base, unit: mapping.hkUnit }); + const foreign = mapAppleHealthEntry({ ...base, unit: sibling! }); + expect(native).not.toBeNull(); + expect(foreign).not.toBeNull(); + + const expected = mapping.convertToDbUnit( + convertHkValue(2, sibling!, mapping.hkUnit)!, + ); + expect(foreign!.value).toBeCloseTo(expected, 9); + expect(foreign!.value).not.toBeCloseTo(native!.value, 9); + }, + ); + + it("keeps a unit it cannot place from rescaling the reading", () => { + const out = mapAppleHealthEntry({ + hkIdentifier: "HKQuantityTypeIdentifierDistanceWalkingRunning", + value: 1234, + unit: "furlong", + startDate: "2026-05-14T08:00:00.000Z", + endDate: "2026-05-14T08:30:00.000Z", + }); + expect(out?.value).toBe(1234); + }); + + it("leaves the km/mi factors in one shared module", () => { + // The workout path used to carry its own copy of the km/mi branch; the + // duplicate is what let the `` path stay wrong for ten years. + const shared = join(process.cwd(), "src/lib/measurements/hk-units.ts"); + const owners = [ + "src/lib/measurements/apple-health-mapping.ts", + "src/lib/measurements/import-apple-health-export.ts", + ]; + expect(readFileSync(shared, "utf8")).toContain("1609.344"); + for (const rel of owners) { + const source = readFileSync(join(process.cwd(), rel), "utf8"); + expect(source, `${rel} re-implements a length factor`).not.toMatch( + /1609\.344|\* 1000\b/, + ); + } + }); +}); diff --git a/src/lib/measurements/__tests__/hk-units.test.ts b/src/lib/measurements/__tests__/hk-units.test.ts new file mode 100644 index 000000000..b1162fb6b --- /dev/null +++ b/src/lib/measurements/__tests__/hk-units.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; + +import { + convertHkValue, + hkDistanceToMetres, + isConvertibleHkUnit, +} from "../hk-units"; + +describe("convertHkValue", () => { + it("converts the km reading Apple writes on a metric archive", () => { + expect(convertHkValue(2.484, "km", "m")).toBeCloseTo(2484, 9); + }); + + it("converts the mi reading Apple writes on an imperial archive", () => { + expect(convertHkValue(1.543, "mi", "m")).toBeCloseTo(2483.217792, 9); + }); + + it("returns the reading untouched when the units already agree", () => { + expect(convertHkValue(2.484, "m", "m")).toBe(2.484); + }); + + it("converts mass, energy and temperature the same way", () => { + expect(convertHkValue(180, "lb", "kg")).toBeCloseTo(81.6466266, 6); + expect(convertHkValue(1000, "kJ", "kcal")).toBeCloseTo(239.005736, 6); + expect(convertHkValue(98.6, "degF", "degC")).toBeCloseTo(37, 9); + }); + + it("strips the molar mass Apple annotates a glucose unit with", () => { + expect(convertHkValue(5.5, "mmol<180.156>/L", "mg/dL")).toBeCloseTo( + 99.0858, + 4, + ); + }); + + it("keeps the small calorie apart from the large one", () => { + expect(convertHkValue(1, "Cal", "kcal")).toBe(1); + expect(convertHkValue(1000, "cal", "kcal")).toBeCloseTo(1, 9); + }); + + it("refuses a unit it cannot place rather than guessing", () => { + expect(convertHkValue(1, "furlong", "m")).toBeNull(); + expect(convertHkValue(1, "", "m")).toBeNull(); + expect(convertHkValue(1, undefined, "m")).toBeNull(); + }); + + it("refuses to cross families", () => { + expect(convertHkValue(1, "kg", "m")).toBeNull(); + expect(convertHkValue(1, "min", "kcal")).toBeNull(); + }); + + it("refuses a non-finite reading", () => { + expect(convertHkValue(Number.NaN, "km", "m")).toBeNull(); + }); +}); + +describe("hkDistanceToMetres", () => { + it("converts the workout distance units Apple ships", () => { + expect(hkDistanceToMetres(6.5, "km")).toBeCloseTo(6500, 9); + expect(hkDistanceToMetres(3, "mi")).toBeCloseTo(4828.032, 9); + expect(hkDistanceToMetres(500, "m")).toBe(500); + }); + + it("falls back to the raw number for a missing unit attribute", () => { + expect(hkDistanceToMetres(500, "")).toBe(500); + expect(hkDistanceToMetres(500, undefined)).toBe(500); + }); +}); + +describe("isConvertibleHkUnit", () => { + it("recognises the units the mapping table pins", () => { + expect(isConvertibleHkUnit("m")).toBe(true); + expect(isConvertibleHkUnit("kg")).toBe(true); + expect(isConvertibleHkUnit("count")).toBe(false); + expect(isConvertibleHkUnit("event")).toBe(false); + expect(isConvertibleHkUnit("%")).toBe(false); + }); +}); diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index e665b18bd..4e760acc0 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -28,6 +28,7 @@ import type { SleepStage, RhythmClassification, } from "@/generated/prisma/client"; +import { convertHkValue } from "./hk-units"; /** * How a batch of HealthKit samples for the same identifier should be @@ -47,6 +48,21 @@ export interface AppleHealthMapping { measurementType: MeasurementType; /** Apple's HK unit string (e.g. `kg`, `count/min`, `mL/(kg*min)`). */ hkUnit: string; + /** + * issue #944 — why this entry's `hkUnit` can be trusted as fixed. + * + * Apple's `export.xml` writes the ACCOUNT's display unit on every + * quantity ``, not the unit this table assumes, so + * `mapAppleHealthEntry()` converts the reading from the record's own + * unit into `hkUnit` before `convertToDbUnit` runs. That only works for + * a unit `convertHkValue()` knows a factor for. An entry whose `hkUnit` + * is NOT convertible (a dimensionless count, a pinned event, a + * logarithmic dB level, a compound rate) must say here why no record + * unit can change its reading — the structural guard + * `src/__tests__/apple-health-unit-audit-guard.test.ts` fails on an + * entry that offers neither. + */ + unitFixedReason?: string; /** HealthLog canonical DB unit (must match `getUnitForType()`). */ dbUnit: string; /** Convert a HK sample value into the canonical DB unit. Identity for most. */ @@ -189,6 +205,28 @@ export const APPLE_HEALTH_SLEEP_STAGE_MAP: Record = { * metric: check Apple's HK unit; percent → ×100 path, m/m·s/kg/etc * → identity path. */ +/** + * issue #944 — the written reasons a mapping entry may cite for keeping a + * fixed `hkUnit`. Each one answers the same question: what could Apple + * possibly write in this record's `unit` attribute that would change the + * reading? Shared constants rather than free text so the audit reads as + * one decision per class instead of forty near-duplicates. + */ +const UNIT_FIXED_DIMENSIONLESS_COUNT = + "Dimensionless tally: Apple emits `count` in every locale and HKUnit has no sibling spelling for it, so no record unit can rescale the reading."; +const UNIT_FIXED_PER_MINUTE = + "Per-minute rate: Apple emits `count/min` in every locale and the Health app offers no alternative display unit, so there is nothing to convert from."; +const UNIT_FIXED_PERCENT_FRACTION = + "Apple writes the percent unit but the VALUE rides as a 0..1 fraction; the x100 in `convertToDbUnit` already covers both spellings, so branching on the record unit would double-scale it."; +const UNIT_FIXED_EVENT_PIN = + "Categorical event: `convertToDbUnit` pins the stored value to 1 fired event regardless of the reading, so no unit attribute can move it."; +const UNIT_FIXED_AUDIO_SPL = + "Sound-pressure level: Apple emits `dBASPL` only, and a decibel is logarithmic — a sibling unit would need its own formula, never a factor."; +const UNIT_FIXED_COMPOSITE_VO2 = + "Compound rate Apple emits verbatim as `mL/(kg*min)`; the Health app exposes no other spelling and the compound has no sibling unit to convert from."; +const UNIT_FIXED_SLEEP_CATEGORY = + "Sleep samples carry a symbolic category value, not a quantity: the importer derives the duration in minutes from the sample's own dates, so the record's unit attribute (empty in Apple's archive) says nothing about the reading."; + export const APPLE_HEALTH_TYPE_MAP: Record = { // ── Body composition ──────────────────────────────────────── HKQuantityTypeIdentifierBodyMass: { @@ -203,6 +241,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierBodyFatPercentage", measurementType: "BODY_FAT", hkUnit: "%", + unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", // Apple ships 0..1 fraction; HealthLog stores 0..100. convertToDbUnit: (v) => v * 100, @@ -254,6 +293,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierHeartRate", measurementType: "PULSE", hkUnit: "count/min", + unitFixedReason: UNIT_FIXED_PER_MINUTE, dbUnit: "bpm", convertToDbUnit: (v) => v, aggregation: "latest", @@ -262,6 +302,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierRestingHeartRate", measurementType: "RESTING_HEART_RATE", hkUnit: "count/min", + unitFixedReason: UNIT_FIXED_PER_MINUTE, dbUnit: "bpm", convertToDbUnit: (v) => v, aggregation: "latest", @@ -284,6 +325,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { // duplication; see `.planning/phase-W1-v1423-research.md`). measurementType: "ACTIVITY_STEPS", hkUnit: "count", + unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT, dbUnit: "steps", convertToDbUnit: (v) => v, aggregation: "sum", @@ -300,6 +342,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierFlightsClimbed", measurementType: "FLIGHTS_CLIMBED", hkUnit: "count", + unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT, dbUnit: "flights", convertToDbUnit: (v) => v, aggregation: "sum", @@ -318,6 +361,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierVO2Max", measurementType: "VO2_MAX", hkUnit: "mL/(kg*min)", + unitFixedReason: UNIT_FIXED_COMPOSITE_VO2, dbUnit: "mL/(kg·min)", convertToDbUnit: (v) => v, aggregation: "latest", @@ -337,6 +381,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierOxygenSaturation", measurementType: "OXYGEN_SATURATION", hkUnit: "fraction", + unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", // Apple ships 0..1 fraction; HealthLog stores 0..100. convertToDbUnit: (v) => v * 100, @@ -348,6 +393,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", measurementType: "SLEEP_DURATION", hkUnit: "category", + unitFixedReason: UNIT_FIXED_SLEEP_CATEGORY, dbUnit: "minutes", // value here is duration-in-minutes (caller computes // `endDate - startDate` and converts to minutes); the per-stage @@ -367,6 +413,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierEnvironmentalAudioExposure", measurementType: "AUDIO_EXPOSURE_ENV", hkUnit: "dBASPL", + unitFixedReason: UNIT_FIXED_AUDIO_SPL, dbUnit: "dBA", convertToDbUnit: (v) => v, aggregation: "mean", @@ -379,6 +426,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierHeadphoneAudioExposure", measurementType: "AUDIO_EXPOSURE_HEADPHONE", hkUnit: "dBASPL", + unitFixedReason: UNIT_FIXED_AUDIO_SPL, dbUnit: "dBA", convertToDbUnit: (v) => v, aggregation: "mean", @@ -403,6 +451,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierAppleWalkingSteadiness", measurementType: "WALKING_STEADINESS", hkUnit: "%", + unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", // Apple ships 0..1 fraction; HealthLog stores 0..100. convertToDbUnit: (v) => v * 100, @@ -416,6 +465,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", measurementType: "AUDIO_EXPOSURE_EVENT", hkUnit: "count", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "count", convertToDbUnit: () => 1, aggregation: "sum", @@ -427,6 +477,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", measurementType: "AUDIO_EXPOSURE_EVENT", hkUnit: "count", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "count", convertToDbUnit: () => 1, aggregation: "sum", @@ -449,6 +500,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", measurementType: "IRREGULAR_RHYTHM_NOTIFICATION", hkUnit: "event", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "event", convertToDbUnit: () => 1, aggregation: "sum", @@ -462,6 +514,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierHighHeartRateEvent", measurementType: "HIGH_HEART_RATE_EVENT", hkUnit: "event", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "event", convertToDbUnit: () => 1, aggregation: "sum", @@ -474,6 +527,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierLowHeartRateEvent", measurementType: "LOW_HEART_RATE_EVENT", hkUnit: "event", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "event", convertToDbUnit: () => 1, aggregation: "sum", @@ -487,6 +541,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", measurementType: "WALKING_STEADINESS_EVENT", hkUnit: "event", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "event", convertToDbUnit: () => 1, aggregation: "sum", @@ -501,6 +556,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKCategoryTypeIdentifierSleepApneaEvent", measurementType: "BREATHING_DISTURBANCE_EVENT", hkUnit: "event", + unitFixedReason: UNIT_FIXED_EVENT_PIN, dbUnit: "event", convertToDbUnit: () => 1, aggregation: "sum", @@ -524,6 +580,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate", measurementType: "RESPIRATORY_RATE", hkUnit: "count/min", + unitFixedReason: UNIT_FIXED_PER_MINUTE, dbUnit: "breaths/min", convertToDbUnit: (v) => v, aggregation: "mean", @@ -537,6 +594,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierBodyMassIndex", measurementType: "BODY_MASS_INDEX", hkUnit: "count", + unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT, dbUnit: "kg/m²", convertToDbUnit: (v) => v, aggregation: "latest", @@ -557,6 +615,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierWalkingHeartRateAverage", measurementType: "WALKING_HEART_RATE_AVERAGE", hkUnit: "count/min", + unitFixedReason: UNIT_FIXED_PER_MINUTE, dbUnit: "bpm", convertToDbUnit: (v) => v, aggregation: "mean", @@ -570,6 +629,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", measurementType: "WALKING_ASYMMETRY", hkUnit: "%", + unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", convertToDbUnit: (v) => v * 100, aggregation: "latest", @@ -580,6 +640,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", measurementType: "WALKING_DOUBLE_SUPPORT", hkUnit: "%", + unitFixedReason: UNIT_FIXED_PERCENT_FRACTION, dbUnit: "%", convertToDbUnit: (v) => v * 100, aggregation: "latest", @@ -622,6 +683,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", measurementType: "CARDIO_RECOVERY", hkUnit: "count/min", + unitFixedReason: UNIT_FIXED_PER_MINUTE, dbUnit: "bpm", convertToDbUnit: (v) => v, aggregation: "latest", @@ -645,6 +707,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierNumberOfTimesFallen", measurementType: "FALL_COUNT", hkUnit: "count", + unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT, dbUnit: "count", convertToDbUnit: (v) => v, aggregation: "sum", @@ -685,6 +748,7 @@ export const APPLE_HEALTH_TYPE_MAP: Record = { hkIdentifier: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", measurementType: "BREATHING_DISTURBANCES", hkUnit: "count", + unitFixedReason: UNIT_FIXED_DIMENSIONLESS_COUNT, dbUnit: "count", convertToDbUnit: (v) => v, aggregation: "latest", @@ -1140,7 +1204,12 @@ 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. */ + /** + * Apple's unit string for this reading. issue #944 — the mapper converts + * the value from this unit into the table's `hkUnit` when the two differ + * and a factor is known (`km` -> `m`, `lb` -> `kg`, `degF` -> `degC`, …); + * an unknown or unplaceable unit leaves the reading untouched. + */ unit: string; /** ISO timestamp string (e.g. HealthKit `startDate`). */ startDate: string; @@ -1221,7 +1290,16 @@ export function mapAppleHealthEntry( const takenAt = new Date(input.endDate); if (Number.isNaN(takenAt.getTime())) return null; - const value = mapping.convertToDbUnit(input.value); + // issue #944 — `convertToDbUnit` is written against the table's fixed + // `hkUnit`, but Apple's `export.xml` stamps each `` with the + // ACCOUNT's own display unit: a metric archive reports walking distance + // in `km`, an imperial one in `mi`, body mass in `lb`, energy in `kJ`. + // Normalise the reading into `hkUnit` first; a unit we cannot place + // leaves the value untouched (see `convertHkValue`). + const toHkUnit = (raw: number): number => + convertHkValue(raw, input.unit, mapping.hkUnit) ?? raw; + + const value = mapping.convertToDbUnit(toHkUnit(input.value)); const out: AppleHealthEntryOutput = { type: mapping.measurementType, @@ -1235,10 +1313,10 @@ export function mapAppleHealthEntry( // hourly HR bucket). A finite check guards a malformed payload; the // caller only persists these on the HR-bucket row. if (typeof input.valueMin === "number" && Number.isFinite(input.valueMin)) { - out.valueMin = mapping.convertToDbUnit(input.valueMin); + out.valueMin = mapping.convertToDbUnit(toHkUnit(input.valueMin)); } if (typeof input.valueMax === "number" && Number.isFinite(input.valueMax)) { - out.valueMax = mapping.convertToDbUnit(input.valueMax); + out.valueMax = mapping.convertToDbUnit(toHkUnit(input.valueMax)); } if (mapping.sleepStageMap) { diff --git a/src/lib/measurements/hk-units.ts b/src/lib/measurements/hk-units.ts new file mode 100644 index 000000000..f500be22d --- /dev/null +++ b/src/lib/measurements/hk-units.ts @@ -0,0 +1,185 @@ +/** + * HealthKit unit normalisation (issue #944). + * + * Apple writes a `unit` attribute on every quantity `` of an + * `export.xml` archive, and the string it writes is the account's OWN + * display unit rather than the unit HealthLog's mapping table assumes: + * a metric account exports walking distance as `km`, an imperial one as + * `mi`, and the same split shows up on body mass (`kg` / `lb` / `st`), + * energy (`kcal` / `kJ`) and temperature (`degC` / `degF`). + * + * Before this module the export import fed the raw number straight into + * `AppleHealthMapping.convertToDbUnit`, which is written against the + * table's fixed `hkUnit` — so a 2.484 km day was stored as 2.484 m. The + * workout path had a hand-rolled km/mi→m branch of its own; both paths + * now share `convertHkValue()`. + * + * Contract, deliberately conservative: + * + * - Same unit in and out → the value is returned untouched, so an + * archive (or an iOS batch) that already speaks the table's unit is + * byte-for-byte unchanged. + * - Unknown unit, empty unit, or two units from different families → + * `null`, and every caller falls back to the raw value. A unit we + * cannot prove a factor for must never silently rescale a reading. + * - Only units Apple's `HKUnit` can actually emit are listed. A unit + * string is matched exactly (after trimming and after stripping the + * molar-mass annotation Apple writes on glucose), never lower-cased: + * `cal` (small calorie) and `Cal` (kilocalorie) are different units + * that differ only in case. + */ + +interface HkUnitSpec { + /** Units convert only within one family. */ + family: string; + /** This unit → the family's base unit. */ + toBase: (value: number) => number; + /** The family's base unit → this unit. */ + fromBase: (value: number) => number; +} + +/** A unit that is a plain multiple of its family's base unit. */ +function scaled(family: string, perBase: number): HkUnitSpec { + return { + family, + toBase: (value) => value * perBase, + fromBase: (value) => value / perBase, + }; +} + +/** + * Every unit string Apple emits that HealthLog can convert, keyed by the + * exact `HKUnit.unitString` spelling. Base units: metre, kilogram, + * kilocalorie, second, degree Celsius, mmHg, mg/dL, metres per second. + */ +const HK_UNITS: Record = { + // ── Length (base: metre) ─────────────────────────────────── + m: scaled("length", 1), + km: scaled("length", 1000), + cm: scaled("length", 0.01), + mm: scaled("length", 0.001), + in: scaled("length", 0.0254), + ft: scaled("length", 0.3048), + yd: scaled("length", 0.9144), + mi: scaled("length", 1609.344), + + // ── Mass (base: kilogram) ────────────────────────────────── + kg: scaled("mass", 1), + g: scaled("mass", 0.001), + lb: scaled("mass", 0.45359237), + oz: scaled("mass", 0.028349523125), + st: scaled("mass", 6.35029318), + + // ── Energy (base: kilocalorie) ───────────────────────────── + kcal: scaled("energy", 1), + Cal: scaled("energy", 1), // Apple's large calorie == kilocalorie + cal: scaled("energy", 0.001), // small calorie — case is load-bearing + kJ: scaled("energy", 1 / 4.184), + J: scaled("energy", 1 / 4184), + + // ── Time (base: second) ──────────────────────────────────── + s: scaled("time", 1), + ms: scaled("time", 0.001), + min: scaled("time", 60), + hr: scaled("time", 3600), + d: scaled("time", 86_400), + + // ── Pressure (base: mmHg) ────────────────────────────────── + mmHg: scaled("pressure", 1), + kPa: scaled("pressure", 7.500_616_827_041_698), + Pa: scaled("pressure", 0.007_500_616_827_041_698), + + // ── Blood glucose (base: mg/dL) ──────────────────────────── + // Apple annotates the molar unit with glucose's molar mass, which the + // normaliser strips before the lookup. + "mg/dL": scaled("glucose", 1), + "mmol/L": scaled("glucose", 18.0156), // 180.156 g/mol ÷ 10 + + // ── Speed (base: metres per second) ──────────────────────── + "m/s": scaled("speed", 1), + "km/hr": scaled("speed", 1000 / 3600), + "mi/hr": scaled("speed", 1609.344 / 3600), + + // ── Temperature (base: degree Celsius) — affine, not scaled ─ + degC: { + family: "temperature", + toBase: (value) => value, + fromBase: (value) => value, + }, + degF: { + family: "temperature", + toBase: (value) => ((value - 32) * 5) / 9, + fromBase: (value) => (value * 9) / 5 + 32, + }, + K: { + family: "temperature", + toBase: (value) => value - 273.15, + fromBase: (value) => value + 273.15, + }, +}; + +/** + * Apple writes molar units with the substance's molar mass inline — + * `mmol<180.156>/L` for blood glucose. The annotation is informational; + * strip it so the lookup sees the plain unit. + */ +const MOLAR_MASS_ANNOTATION = /<[^>]*>/g; + +function normaliseUnitString(unit: string): string { + return unit.trim().replace(MOLAR_MASS_ANNOTATION, ""); +} + +function lookup(unit: string | null | undefined): HkUnitSpec | null { + if (typeof unit !== "string") return null; + const normalised = normaliseUnitString(unit); + if (!normalised) return null; + return HK_UNITS[normalised] ?? null; +} + +/** + * `true` when this module knows a conversion factor for the unit — i.e. + * a `` that carries a sibling unit from the same family will be + * converted rather than trusted blindly. The mapping-table guard in + * `src/__tests__/apple-health-unit-audit-guard.test.ts` uses this to + * decide whether an entry's fixed `hkUnit` needs a written reason. + */ +export function isConvertibleHkUnit(unit: string | null | undefined): boolean { + return lookup(unit) !== null; +} + +/** + * Convert a HealthKit value between two `HKUnit` strings. Returns `null` + * — never a guess — when either unit is unknown or the two belong to + * different families; callers keep the raw value in that case. + */ +export function convertHkValue( + value: number, + fromUnit: string | null | undefined, + toUnit: string, +): number | null { + if (!Number.isFinite(value)) return null; + const from = lookup(fromUnit); + if (!from) return null; + const to = lookup(toUnit); + if (!to) return null; + if (from.family !== to.family) return null; + // Same unit: return the reading untouched rather than round-tripping it + // through the base and picking up a float error. + if (normaliseUnitString(String(fromUnit)) === normaliseUnitString(toUnit)) { + return value; + } + return to.fromBase(from.toBase(value)); +} + +/** + * Convert a distance a HealthKit archive reports in its own unit into + * metres, the canonical DB unit. Falls back to the raw number for a unit + * this module cannot place (including the empty attribute an archive may + * write) — the pre-#944 behaviour of the workout path, preserved. + */ +export function hkDistanceToMetres( + value: number, + unit: string | null | undefined, +): number { + return convertHkValue(value, unit, "m") ?? value; +} diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 26a1ce0bb..4a4bb9229 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -42,6 +42,7 @@ import { } from "@/lib/measurements/drain-per-sample-cumulative"; import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; import { resolveHkWorkoutSportType } from "@/lib/measurements/hk-workout-activity-type-map"; +import { hkDistanceToMetres } from "@/lib/measurements/hk-units"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; @@ -947,14 +948,12 @@ export async function streamParseExportXml( const totalDistance = Number.parseFloat(attrs.totalDistance ?? ""); const totalEnergy = Number.parseFloat(attrs.totalEnergyBurned ?? ""); const distanceUnit = attrs.totalDistanceUnit ?? ""; - // Apple ships km for HKWorkout.totalDistance by default; metres - // is the canonical DB unit. Convert when the unit is km. + // Apple ships the ACCOUNT's own length unit on HKWorkout.totalDistance + // (km for a metric account, mi for an imperial one); metres is the + // canonical DB unit. issue #944 — the same conversion the `` + // path now runs, so the two can never drift apart again. const distanceM = Number.isFinite(totalDistance) - ? distanceUnit === "km" - ? totalDistance * 1000 - : distanceUnit === "mi" - ? totalDistance * 1609.344 - : totalDistance + ? hkDistanceToMetres(totalDistance, distanceUnit) : null; const externalId = hashSampleKey(