diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 98378b987..985b7a263 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -398,6 +398,44 @@ describe("buildFhirDocumentBundle", () => { expect(flights?.valueQuantity?.code).toBe("{flights}"); }); + it("routes HealthKit placeholder codes onto the custom CodeSystem, never LOINC", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + measurements: { + WEIGHT: [{ value: 80, measuredAt: "2026-04-30T08:00:00.000Z" }], + FLIGHTS_CLIMBED: [ + { value: 12, measuredAt: "2026-04-30T08:00:00.000Z" }, + ], + }, + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const codings = observationsOf(bundle).flatMap((o) => o.code.coding ?? []); + + // A real LOINC metric keeps the LOINC namespace. + const weight = codings.find((c) => c.code === "29463-7"); + expect(weight?.system).toBe("http://loinc.org"); + + // The HealthKit placeholder moves to the shared custom CodeSystem with the + // raw HK identifier as the code (byte-aligned with the iOS exporter). + const flightsCoding = codings.find( + (c) => c.code === "HKQuantityTypeIdentifierFlightsClimbed", + ); + expect(flightsCoding?.system).toBe( + "https://healthlog.dev/fhir/CodeSystem/healthkit", + ); + + // Conformance invariant: no HealthKit identifier is ever emitted under the + // LOINC namespace anywhere in the bundle. + const hkUnderLoinc = codings.filter( + (c) => + c.system === "http://loinc.org" && + c.code?.startsWith("HKQuantityTypeIdentifier"), + ); + expect(hkUnderLoinc).toHaveLength(0); + }); + it("discriminates glucose LOINC by context (random/fasting/afterMeal)", () => { const bundle = buildFhirDocumentBundle( makeData({ diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 229e5d827..f816843de 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -21,6 +21,7 @@ import type { DoctorReportData } from "@/lib/doctor-report-data"; import { resolveGlucoseUnit, convertGlucose } from "@/lib/glucose"; import { LOINC_SYSTEM, + HEALTHKIT_CODESYSTEM, UCUM_SYSTEM, MEASUREMENT_LOINC, BP_PANEL_LOINC, @@ -66,8 +67,15 @@ function escapeXml(value: string): string { function codeableFromMapping(m: LoincMapping): FhirCodeableConcept { if (m.loinc) { + // HealthKit placeholder codes have no published LOINC term; they must not + // sit under the LOINC namespace (a non-LOINC code there is a conformance + // violation). Route them onto the shared custom CodeSystem instead — + // byte-aligned with the iOS exporter. + const system = m.loinc.startsWith("HKQuantityTypeIdentifier") + ? HEALTHKIT_CODESYSTEM + : LOINC_SYSTEM; return { - coding: [{ system: LOINC_SYSTEM, code: m.loinc, display: m.display }], + coding: [{ system, code: m.loinc, display: m.display }], text: m.display, }; } diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index 2cbae62df..5e662a244 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -12,6 +12,15 @@ export const LOINC_SYSTEM = "http://loinc.org"; export const UCUM_SYSTEM = "http://unitsofmeasure.org"; +/** + * Shared custom CodeSystem for HealthKit placeholder metrics that have no + * published LOINC term. A non-LOINC code under `http://loinc.org` is a FHIR + * conformance violation, so these route here instead. Byte-aligned with the + * iOS exporter (confirmed 2026-06-01) — both clients emit the identical + * `system` + raw `HKQuantityTypeIdentifier…` `code`. + */ +export const HEALTHKIT_CODESYSTEM = + "https://healthlog.dev/fhir/CodeSystem/healthkit"; export type FhirObservationCategory = | "vital-signs" @@ -145,10 +154,12 @@ export const MEASUREMENT_LOINC: Record = { unit: "kg", category: "vital-signs", }, - // HK-placeholder codes — no published LOINC term. iOS emits the HealthKit - // identifier STRING as the `code` (in the LOINC system slot); the server - // must emit the identical placeholder, not invent a LOINC. The `loinc` - // field below carries that placeholder string verbatim. + // HK-placeholder codes — no published LOINC term. Both iOS and the server + // emit the HealthKit identifier STRING as the `code` under the shared + // `HEALTHKIT_CODESYSTEM` (NOT the LOINC namespace — a non-LOINC code under + // http://loinc.org fails FHIR conformance). The `loinc` field below carries + // the placeholder string verbatim; the builder routes any + // `HKQuantityTypeIdentifier…` code onto the custom system. WALKING_DOUBLE_SUPPORT: { loinc: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", display: "Walking double support percentage",