From 8934a2900e05ba76499ce747749c38b765eb7820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 12:45:15 +0200 Subject: [PATCH 1/6] feat(fhir): export the body site and side of a condition Condition.bodySite carries the free-text site as text. The side rides a contained BodyStructure reached through the core bodySite extension, coded with the SNOMED CT concepts of the R4 bodysite-laterality value set, and is repeated in the text so a receiver that ignores extensions still reads it. --- .../__tests__/condition-body-site.test.ts | 126 ++++++++++++++++++ src/lib/fhir/__tests__/conformance.test.ts | 30 +++++ src/lib/fhir/resources/conditions.ts | 81 +++++++++++ src/lib/fhir/types.ts | 32 +++++ 4 files changed, 269 insertions(+) create mode 100644 src/lib/fhir/__tests__/condition-body-site.test.ts diff --git a/src/lib/fhir/__tests__/condition-body-site.test.ts b/src/lib/fhir/__tests__/condition-body-site.test.ts new file mode 100644 index 000000000..8d44b95c1 --- /dev/null +++ b/src/lib/fhir/__tests__/condition-body-site.test.ts @@ -0,0 +1,126 @@ +/** + * `Condition.bodySite` from the condition journal's free-text site and side. + * + * The site rides `text`; the side rides a contained `BodyStructure` reached + * through the core `bodySite` extension, and is repeated in `text` so a + * receiver that ignores extensions still reads which side. + */ +import { describe, expect, it } from "vitest"; + +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { conditionsFromReportData } from "@/lib/fhir/resources"; +import type { FhirCondition } from "@/lib/fhir/types"; + +type Episode = NonNullable[number]; + +function conditionFor(extra: Partial): FhirCondition { + const data = { + illnessEpisodes: [ + { + label: "Sprain", + type: "INJURY", + lifecycle: "ACUTE", + onsetAt: "2026-04-01T00:00:00.000Z", + resolvedAt: null, + ...extra, + }, + ], + } as unknown as DoctorReportData; + const [condition] = conditionsFromReportData(data).conditions; + if (!condition) throw new Error("no Condition emitted"); + return condition; +} + +describe("conditionsFromReportData — bodySite", () => { + it("emits no bodySite and nothing contained when no site was recorded", () => { + const c = conditionFor({}); + expect(c.bodySite).toBeUndefined(); + expect(c.contained).toBeUndefined(); + }); + + it("treats a blank site as no site, even with a side", () => { + const c = conditionFor({ bodySite: " ", laterality: "LEFT" }); + expect(c.bodySite).toBeUndefined(); + expect(c.contained).toBeUndefined(); + }); + + it("emits the site as text only when no side was stated", () => { + const c = conditionFor({ bodySite: "Lower back", laterality: null }); + expect(c.bodySite).toEqual([{ text: "Lower back" }]); + expect(c.contained).toBeUndefined(); + }); + + it.each([ + ["LEFT", "419161000", "Unilateral left", "left"], + ["RIGHT", "419465000", "Unilateral right", "right"], + ["BOTH", "51440002", "Bilateral", "both sides"], + ])( + "carries %s as SNOMED %s on a contained BodyStructure", + (laterality, code, display, sideText) => { + const c = conditionFor({ bodySite: "Knee", laterality }); + expect(c.bodySite).toEqual([ + { + extension: [ + { + url: "http://hl7.org/fhir/StructureDefinition/bodySite", + valueReference: { reference: "#bodysite-1" }, + }, + ], + text: `Knee (${sideText})`, + }, + ]); + expect(c.contained).toEqual([ + { + resourceType: "BodyStructure", + id: "bodysite-1", + location: { text: "Knee" }, + locationQualifier: [ + { + coding: [{ system: "http://snomed.info/sct", code, display }], + text: sideText, + }, + ], + patient: { reference: "Patient/patient-1" }, + }, + ]); + }, + ); + + it("never puts the side into bodySite.coding", () => { + const c = conditionFor({ bodySite: "Knee", laterality: "RIGHT" }); + expect(c.bodySite?.[0]?.coding).toBeUndefined(); + }); + + it("falls back to the site alone for an unknown side value", () => { + const c = conditionFor({ bodySite: "Knee", laterality: "SIDEWAYS" }); + expect(c.bodySite).toEqual([{ text: "Knee" }]); + expect(c.contained).toBeUndefined(); + }); + + it("keeps each episode's site on its own Condition", () => { + const data = { + illnessEpisodes: [ + { + label: "A", + type: "INJURY", + lifecycle: "ACUTE", + onsetAt: "2026-04-01T00:00:00.000Z", + resolvedAt: null, + bodySite: "Wrist", + laterality: "RIGHT", + }, + { + label: "B", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: "2026-04-02T00:00:00.000Z", + resolvedAt: null, + }, + ], + } as unknown as DoctorReportData; + const [a, b] = conditionsFromReportData(data).conditions; + expect(a?.bodySite?.[0]?.text).toBe("Wrist (right)"); + expect(b?.bodySite).toBeUndefined(); + expect(b?.contained).toBeUndefined(); + }); +}); diff --git a/src/lib/fhir/__tests__/conformance.test.ts b/src/lib/fhir/__tests__/conformance.test.ts index 8877f7dad..7e423e1ef 100644 --- a/src/lib/fhir/__tests__/conformance.test.ts +++ b/src/lib/fhir/__tests__/conformance.test.ts @@ -100,6 +100,8 @@ function makeData(overrides?: Partial): DoctorReportData { lifecycle: "ACUTE", onsetAt: "2026-04-01T00:00:00.000Z", resolvedAt: "2026-04-10T00:00:00.000Z", + bodySite: "Knee", + laterality: "LEFT", }, ], mood: { avg: 3.8, count: 20 }, @@ -173,6 +175,34 @@ function resourcesOf(bundle: FhirBundle): FhirResource[] { return bundle.entry.map((e) => e.resource); } +// ── Condition.bodySite — the contained BodyStructure resolves in place ─── + +describe("Condition.bodySite", () => { + it("points its extension at a BodyStructure contained in the same Condition, whose patient resolves", () => { + const bundle = fullBundle(); + const patientUrl = bundle.entry.find( + (e) => e.resource.resourceType === "Patient", + )?.fullUrl; + const condition = resourcesOf(bundle).find( + (r) => r.resourceType === "Condition", + ); + if (condition?.resourceType !== "Condition") + throw new Error("no Condition"); + const ext = condition.bodySite?.[0]?.extension?.[0]; + expect(ext?.url).toBe("http://hl7.org/fhir/StructureDefinition/bodySite"); + const target = ext?.valueReference.reference; + const structure = condition.contained?.find((c) => `#${c.id}` === target); + expect(structure?.resourceType).toBe("BodyStructure"); + expect(structure?.patient.reference).toBe(patientUrl); + // A code from the R4 core bodysite-laterality value set. + expect( + ["419161000", "419465000", "51440002"].includes( + structure?.locationQualifier?.[0]?.coding?.[0]?.code ?? "", + ), + ).toBe(true); + }); +}); + // ── Test 14 — document identity, entry identity, reference resolution ───── describe("document Bundle — identity and resolvability", () => { diff --git a/src/lib/fhir/resources/conditions.ts b/src/lib/fhir/resources/conditions.ts index 1a4984855..6728b2179 100644 --- a/src/lib/fhir/resources/conditions.ts +++ b/src/lib/fhir/resources/conditions.ts @@ -10,6 +10,7 @@ import type { DoctorReportData } from "@/lib/doctor-report-data"; import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed"; import type { + FhirBodyStructure, FhirCodeableConcept, FhirCondition, FhirEncounter, @@ -19,6 +20,83 @@ import { SNOMED_SYSTEM, patientRef } from "@/lib/fhir/resources/common"; /** SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept. */ const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const; +/** + * The side of a body site, as SNOMED CT laterality qualifiers from the R4 core + * value set `http://hl7.org/fhir/ValueSet/bodysite-laterality` + * (https://hl7.org/fhir/R4/valueset-bodysite-laterality.html); the same three + * concepts sit in `bodystructure-relative-location`, the value set R4 binds to + * `BodyStructure.locationQualifier`. Keyed on the `Laterality` enum. + */ +const LATERALITY_SNOMED: Record< + string, + { code: string; display: string; text: string } +> = { + LEFT: { code: "419161000", display: "Unilateral left", text: "left" }, + RIGHT: { code: "419465000", display: "Unilateral right", text: "right" }, + BOTH: { code: "51440002", display: "Bilateral", text: "both sides" }, +}; + +/** + * Core extension `http://hl7.org/fhir/StructureDefinition/bodySite` + * (https://hl7.org/fhir/extensions/StructureDefinition-bodySite.html): a + * Reference(BodyStructure) on `Condition.bodySite`, unchanged in R4. + */ +const BODY_SITE_EXTENSION_URL = + "http://hl7.org/fhir/StructureDefinition/bodySite"; + +const BODY_STRUCTURE_ID = "bodysite-1"; + +/** + * `Condition.bodySite` plus the contained `BodyStructure` that carries its + * side, or nothing when no site was recorded. + * + * The site is the user's free text, so it rides `text` only; no SNOMED body + * structure is guessed from it. R4 `Condition.bodySite` has no laterality + * element, and a laterality concept is not a body site, so putting "Left" in + * `bodySite.coding` would claim the condition sits on "left". The standard R4 + * route is the core `bodySite` extension pointing at a `BodyStructure` whose + * `locationQualifier` holds the SNOMED CT side. The side is also written into + * `bodySite.text`, because a receiver that ignores extensions must still read + * "Knee (left)" rather than a bare "Knee". + */ +function bodySiteOf( + bodySite: string | null | undefined, + laterality: string | null | undefined, +): Pick { + const site = bodySite?.trim(); + if (!site) return {}; + const side = laterality ? LATERALITY_SNOMED[laterality] : undefined; + if (!side) return { bodySite: [{ text: site }] }; + const structure: FhirBodyStructure = { + resourceType: "BodyStructure", + id: BODY_STRUCTURE_ID, + location: { text: site }, + locationQualifier: [ + { + coding: [ + { system: SNOMED_SYSTEM, code: side.code, display: side.display }, + ], + text: side.text, + }, + ], + patient: patientRef, + }; + return { + contained: [structure], + bodySite: [ + { + extension: [ + { + url: BODY_SITE_EXTENSION_URL, + valueReference: { reference: `#${BODY_STRUCTURE_ID}` }, + }, + ], + text: `${site} (${side.text})`, + }, + ], + }; +} + /** R4 `Condition.clinicalStatus` concept for the given resolution state. */ function conditionClinicalStatus(resolved: boolean): FhirCodeableConcept { return { @@ -53,9 +131,11 @@ export function conditionsFromReportData(data: DoctorReportData): { seq += 1; const conditionId = `condition-${seq}`; const resolved = ep.resolvedAt !== null; + const site = bodySiteOf(ep.bodySite, ep.laterality); conditions.push({ resourceType: "Condition", id: conditionId, + ...(site.contained ? { contained: site.contained } : {}), clinicalStatus: conditionClinicalStatus(resolved), verificationStatus: { coding: [ @@ -94,6 +174,7 @@ export function conditionsFromReportData(data: DoctorReportData): { ], text: ep.label, }, + ...(site.bodySite ? { bodySite: site.bodySite } : {}), subject: patientRef, onsetDateTime: ep.onsetAt, ...(ep.resolvedAt ? { abatementDateTime: ep.resolvedAt } : {}), diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 00e85cc95..649d08a32 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -15,10 +15,21 @@ export interface FhirCoding { } export interface FhirCodeableConcept { + /** + * Element extensions. Only `Condition.bodySite` carries one today (the core + * `bodySite` extension pointing at a contained `BodyStructure`). + */ + extension?: FhirExtension[]; coding?: FhirCoding[]; text?: string; } +/** R4 `Extension`, narrowed to the one value type HealthLog emits. */ +export interface FhirExtension { + url: string; + valueReference: FhirReference; +} + export interface FhirReference { reference: string; display?: string; @@ -250,10 +261,17 @@ export interface FhirComposition { export interface FhirCondition { resourceType: "Condition"; id: string; + /** + * v1.39.3 — the `BodyStructure` that carries the side of the body site, + * referenced from `bodySite[].extension` by a local `#`-ref. + */ + contained?: FhirBodyStructure[]; clinicalStatus?: FhirCodeableConcept; verificationStatus?: FhirCodeableConcept; category?: FhirCodeableConcept[]; code: FhirCodeableConcept; + /** v1.39.3 — where on the body, as the user wrote it (`text` only). */ + bodySite?: FhirCodeableConcept[]; subject: FhirReference; onsetDateTime?: string; abatementDateTime?: string; @@ -261,6 +279,20 @@ export interface FhirCondition { note?: FhirAnnotation[]; } +/** + * v1.39.3 — R4 `BodyStructure`, emitted only as a resource contained in a + * `Condition`. `location` repeats the free-text site; `locationQualifier` + * carries the side as a SNOMED CT laterality concept. `patient` is `1..1` in + * R4. + */ +export interface FhirBodyStructure { + resourceType: "BodyStructure"; + id: string; + location?: FhirCodeableConcept; + locationQualifier?: FhirCodeableConcept[]; + patient: FhirReference; +} + /** * v1.18.1 P4 — R4 `Encounter`. Emitted alongside a resolved episode to bound * the period of care (onset → resolution) so a clinician sees the episode as a From 87526e3df64ca3cfaf0c2a945bc08204c816d7c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 13:29:45 +0200 Subject: [PATCH 2/6] fix(fhir): code the body-site side with active SNOMED CT qualifiers The HL7 validator reports 419161000 and 419465000, the left and right concepts of the R4 bodysite-laterality value set, as inactive. Use the active qualifier values 7771000 Left and 24028007 Right, with 51440002 Right and left. The side word in bodySite.text stays English, the language of every other generated text in the Bundle; the builder takes no locale. --- .../__tests__/condition-body-site.test.ts | 14 +++++++--- src/lib/fhir/__tests__/conformance.test.ts | 4 +-- src/lib/fhir/resources/conditions.ts | 27 +++++++++++++------ 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/lib/fhir/__tests__/condition-body-site.test.ts b/src/lib/fhir/__tests__/condition-body-site.test.ts index 8d44b95c1..46cc15861 100644 --- a/src/lib/fhir/__tests__/condition-body-site.test.ts +++ b/src/lib/fhir/__tests__/condition-body-site.test.ts @@ -51,9 +51,9 @@ describe("conditionsFromReportData — bodySite", () => { }); it.each([ - ["LEFT", "419161000", "Unilateral left", "left"], - ["RIGHT", "419465000", "Unilateral right", "right"], - ["BOTH", "51440002", "Bilateral", "both sides"], + ["LEFT", "7771000", "Left", "left"], + ["RIGHT", "24028007", "Right", "right"], + ["BOTH", "51440002", "Right and left", "both sides"], ])( "carries %s as SNOMED %s on a contained BodyStructure", (laterality, code, display, sideText) => { @@ -91,6 +91,14 @@ describe("conditionsFromReportData — bodySite", () => { expect(c.bodySite?.[0]?.coding).toBeUndefined(); }); + it("writes the side in the Bundle's language and the site in the user's words", () => { + const c = conditionFor({ bodySite: "Knie", laterality: "LEFT" }); + expect(c.bodySite?.[0]?.text).toBe("Knie (left)"); + // The same language as the rest of the generated Condition text. + expect(c.note?.[0]?.text).toMatch(/^Self-recorded /); + expect(c.contained?.[0]?.location?.text).toBe("Knie"); + }); + it("falls back to the site alone for an unknown side value", () => { const c = conditionFor({ bodySite: "Knee", laterality: "SIDEWAYS" }); expect(c.bodySite).toEqual([{ text: "Knee" }]); diff --git a/src/lib/fhir/__tests__/conformance.test.ts b/src/lib/fhir/__tests__/conformance.test.ts index 7e423e1ef..195e5f618 100644 --- a/src/lib/fhir/__tests__/conformance.test.ts +++ b/src/lib/fhir/__tests__/conformance.test.ts @@ -194,9 +194,9 @@ describe("Condition.bodySite", () => { const structure = condition.contained?.find((c) => `#${c.id}` === target); expect(structure?.resourceType).toBe("BodyStructure"); expect(structure?.patient.reference).toBe(patientUrl); - // A code from the R4 core bodysite-laterality value set. + // An active SNOMED CT laterality qualifier value. expect( - ["419161000", "419465000", "51440002"].includes( + ["7771000", "24028007", "51440002"].includes( structure?.locationQualifier?.[0]?.coding?.[0]?.code ?? "", ), ).toBe(true); diff --git a/src/lib/fhir/resources/conditions.ts b/src/lib/fhir/resources/conditions.ts index 6728b2179..a97effba8 100644 --- a/src/lib/fhir/resources/conditions.ts +++ b/src/lib/fhir/resources/conditions.ts @@ -21,19 +21,23 @@ import { SNOMED_SYSTEM, patientRef } from "@/lib/fhir/resources/common"; const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const; /** - * The side of a body site, as SNOMED CT laterality qualifiers from the R4 core - * value set `http://hl7.org/fhir/ValueSet/bodysite-laterality` - * (https://hl7.org/fhir/R4/valueset-bodysite-laterality.html); the same three - * concepts sit in `bodystructure-relative-location`, the value set R4 binds to - * `BodyStructure.locationQualifier`. Keyed on the `Laterality` enum. + * The side of a body site, as active SNOMED CT laterality qualifier values: + * 7771000 Left, 24028007 Right, 51440002 Right and left. These are the codes + * of the HL7 mCODE Laterality Qualifier value set + * (https://build.fhir.org/ig/HL7/fhir-mCODE-ig/ValueSet-mcode-laterality-qualifier-vs.html). + * The R4 core `bodysite-laterality` value set lists 419161000 / 419465000 for + * left / right, but the HL7 validator reports both as inactive in current + * SNOMED CT, so they are not used. R4 binds `BodyStructure.locationQualifier` + * with example strength, so any SNOMED CT qualifier is conformant there. + * Keyed on the `Laterality` enum. */ const LATERALITY_SNOMED: Record< string, { code: string; display: string; text: string } > = { - LEFT: { code: "419161000", display: "Unilateral left", text: "left" }, - RIGHT: { code: "419465000", display: "Unilateral right", text: "right" }, - BOTH: { code: "51440002", display: "Bilateral", text: "both sides" }, + LEFT: { code: "7771000", display: "Left", text: "left" }, + RIGHT: { code: "24028007", display: "Right", text: "right" }, + BOTH: { code: "51440002", display: "Right and left", text: "both sides" }, }; /** @@ -58,6 +62,13 @@ const BODY_STRUCTURE_ID = "bodysite-1"; * `locationQualifier` holds the SNOMED CT side. The side is also written into * `bodySite.text`, because a receiver that ignores extensions must still read * "Knee (left)" rather than a bare "Knee". + * + * The side word is English on purpose. The builder takes no locale + * (`FhirBuildOptions` carries none, unlike the PDF path), and every other + * generated text in the Bundle is English: section titles, the Condition note, + * the narratives. The site stays in the words the user wrote, so a German + * record reads "Knie (left)", the same mix as a German label beside the + * English "Self-recorded … condition journal entry" note. */ function bodySiteOf( bodySite: string | null | undefined, From 35327b84c17591150252622b47b9606042607608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 13:31:38 +0200 Subject: [PATCH 3/6] fix(fhir): leave out the vital-signs DiagnosticReport when there are no vital signs A window without a vital sign emitted the panel with result: [], which R4 rejects. The DiagnosticReport now goes out only when it has a result, and the vital-signs section drops an empty entry list rather than sending one. --- docs/api/openapi.yaml | 7 +- src/app/api/fhir/Patient/$everything/route.ts | 3 +- src/lib/fhir/__tests__/build-bundle.test.ts | 52 ++++++++++++++ src/lib/fhir/build-bundle.ts | 68 +++++++++++-------- src/lib/openapi/routes/health-record.ts | 2 +- 5 files changed, 99 insertions(+), 33 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89ecc1799..2dc9a4a6f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1478,9 +1478,10 @@ paths: summary: FHIR R4 $everything (v1.11.0) description: "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, - Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one - `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. - Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`." + Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the + window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report + selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` + (≤200) / `_offset`." parameters: - in: query name: _count diff --git a/src/app/api/fhir/Patient/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts index df9318b9f..bf596b780 100644 --- a/src/app/api/fhir/Patient/$everything/route.ts +++ b/src/app/api/fhir/Patient/$everything/route.ts @@ -5,7 +5,8 @@ * the Composition, the authoring Device, Patient, Coverage, every Observation * (vitals / glucose / labs / adherence / mood / wellness AND the cycle set), * MedicationStatements, MedicationAdministrations, Conditions, Encounters, - * AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport. + * AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present + * when the window holds a vital sign). * * The set is the document export's, flattened — same builder, same order, same * `urn:uuid` entry identities, so a reference between two resources still diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index a0c2f5f07..a7d350c4e 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -9,6 +9,7 @@ import type { FhirPatient, FhirCoverage, FhirDiagnosticReport, + FhirComposition, } from "../types"; function administrationsOf( @@ -1172,6 +1173,57 @@ describe("buildFhirDocumentBundle", () => { ); }); + it("emits no DiagnosticReport, and no empty array anywhere, when the window holds no vital sign", () => { + const bundle = buildFhirDocumentBundle( + makeData({ measurements: {}, bmi: null }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const types = bundle.entry.map((e) => e.resource.resourceType); + expect(types).not.toContain("DiagnosticReport"); + const composition = bundle.entry[0].resource as FhirComposition; + const refs = (composition.section ?? []).flatMap((s) => s.entry ?? []); + for (const ref of refs) { + expect(refTarget(bundle, ref.reference)).not.toMatch( + /^DiagnosticReport\//, + ); + } + // R4: an array element is absent, never empty. + const emptyArrays: string[] = []; + const walk = (value: unknown, path: string): void => { + if (Array.isArray(value)) { + if (value.length === 0) emptyArrays.push(path); + value.forEach((item, i) => walk(item, `${path}[${i}]`)); + } else if (value && typeof value === "object") { + for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`); + } + }; + walk(bundle, "Bundle"); + expect(emptyArrays).toEqual([]); + }); + + it("leaves the vital-signs section without `entry` when it has nothing to list", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + measurements: {}, + bmi: null, + glucoseStats: {}, + compliance: {}, + medications: [], + mood: null, + labResults: [], + } as Partial), + { insuranceNumber: null }, + FIXED_NOW, + ); + const composition = bundle.entry[0].resource as FhirComposition; + const vitals = composition.section?.find((s) => s.title === "Vital signs"); + expect(vitals?.text?.div).toBeTruthy(); + expect( + vitals && "entry" in vitals ? vitals.entry : undefined, + ).toBeUndefined(); + }); + it("appends a DiagnosticReport as the LAST entry routing all Observation refs", () => { const bundle = buildFhirDocumentBundle( makeData(), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 8c1fc2edb..fe0309f10 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -328,31 +328,39 @@ export function buildFhirDocumentBundle( : []), ].join(" "); - const diagnosticReport: FhirDiagnosticReport = { - resourceType: "DiagnosticReport", - id: "diagnostic-report-1", - status: "final", - code: { - coding: [ - { - system: LOINC_SYSTEM, - code: "85353-1", - display: - "Vital signs, weight, height, head circumference, oxygen saturation and BMI panel", - }, - ], - text: "Vital signs panel", - }, - subject: patientRef, - effectivePeriod: { start: data.period.start, end: data.period.end }, - // A vital-signs panel reports vital signs. Routing labs, adherence rates - // and survey scores through it would let a receiver read a questionnaire - // total as a vital sign because the panel code said so. - result: vitalSignsObservationRefs, - }; - const diagnosticReportRef: FhirReference = { - reference: `DiagnosticReport/${diagnosticReport.id}`, - }; + // A vital-signs panel with no vital signs reports nothing, and R4 forbids + // the empty `result: []` it would otherwise carry. With no vital-sign + // Observation in the window the DiagnosticReport is left out entirely, + // together with its Composition entry. + const diagnosticReport: FhirDiagnosticReport | null = + vitalSignsObservationRefs.length === 0 + ? null + : { + resourceType: "DiagnosticReport", + id: "diagnostic-report-1", + status: "final", + code: { + coding: [ + { + system: LOINC_SYSTEM, + code: "85353-1", + display: + "Vital signs, weight, height, head circumference, oxygen saturation and BMI panel", + }, + ], + text: "Vital signs panel", + }, + subject: patientRef, + effectivePeriod: { start: data.period.start, end: data.period.end }, + // A vital-signs panel reports vital signs. Routing labs, adherence rates + // and survey scores through it would let a receiver read a questionnaire + // total as a vital sign because the panel code said so. + result: vitalSignsObservationRefs, + }; + const diagnosticReportRefs: FhirReference[] = diagnosticReport + ? [{ reference: `DiagnosticReport/${diagnosticReport.id}` }] + : []; + const vitalSignsEntries = [...observationRefs, ...diagnosticReportRefs]; const composition: FhirComposition = { resourceType: "Composition", @@ -390,7 +398,9 @@ export function buildFhirDocumentBundle( status: "generated", div: `
${escapeXml(narrativeText)}
`, }, - entry: [...observationRefs, diagnosticReportRef], + // `entry` is omitted rather than empty; the section still carries + // its narrative, which satisfies `cmp-2`. + ...(vitalSignsEntries.length > 0 ? { entry: vitalSignsEntries } : {}), }, // Medications section carries both the active-medication // statements and the per-dose administration records (v1.9.0). @@ -479,11 +489,13 @@ export function buildFhirDocumentBundle( }; // The Composition must be the FIRST entry in a document Bundle; the - // DiagnosticReport is the LAST. + // DiagnosticReport, when there is one, is the LAST. const orderedEntries: FhirBundleEntry[] = [ { fullUrl: `urn:uuid:${randomUUID()}`, resource: composition }, ...entries, - { fullUrl: `urn:uuid:${randomUUID()}`, resource: diagnosticReport }, + ...(diagnosticReport + ? [{ fullUrl: `urn:uuid:${randomUUID()}`, resource: diagnosticReport }] + : []), ]; // Every entry identity is known only now, so the reference rewrite runs once diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 0da6adf1f..4e8fdadb7 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -647,7 +647,7 @@ export const healthRecordPaths: NonNullable = { tags: ["FHIR"], summary: "FHIR R4 $everything (v1.11.0)", description: - "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", + "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", requestParams: { query: z.object({ _count: z.coerce.number().optional(), From 44abc6f1a009aa7829a735ae0dce6e63533c3aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 13:44:09 +0200 Subject: [PATCH 4/6] fix(fhir): code allergy and injury conditions with active SNOMED CT concepts Allergy used 106190000, inactive since 2013; it is now 473011001 Allergic condition. Injury keeps 417163006 with the display SNOMED CT publishes, Traumatic or non-traumatic injury; the old display was rejected. --- src/lib/fhir/__tests__/illness-snomed.test.ts | 4 ++-- src/lib/fhir/illness-snomed.ts | 17 ++++++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/lib/fhir/__tests__/illness-snomed.test.ts b/src/lib/fhir/__tests__/illness-snomed.test.ts index 5b5e40cfe..ce4b162c7 100644 --- a/src/lib/fhir/__tests__/illness-snomed.test.ts +++ b/src/lib/fhir/__tests__/illness-snomed.test.ts @@ -67,8 +67,8 @@ function conditionForType(type: string): FhirCondition { describe("illness → SNOMED category coding (v1.18.8)", () => { const cases: Array<[string, string, string]> = [ ["INFECTION", "40733004", "Infectious disease"], - ["ALLERGY", "106190000", "Allergy"], - ["INJURY", "417163006", "Traumatic AND/OR non-traumatic injury"], + ["ALLERGY", "473011001", "Allergic condition"], + ["INJURY", "417163006", "Traumatic or non-traumatic injury"], ["MENTAL_HEALTH", "74732009", "Mental disorder"], ["AUTOIMMUNE", "85828009", "Autoimmune disease"], ["CHRONIC", "27624003", "Chronic disease"], diff --git a/src/lib/fhir/illness-snomed.ts b/src/lib/fhir/illness-snomed.ts index b6493cc1e..a307b6947 100644 --- a/src/lib/fhir/illness-snomed.ts +++ b/src/lib/fhir/illness-snomed.ts @@ -36,8 +36,10 @@ export interface IllnessSnomedCategory { * `IllnessType` enum value → broad SNOMED CT category concept. * * - INFECTION → 40733004 "Infectious disease (disorder)" - * - ALLERGY → 106190000 "Allergy (disorder)" - * - INJURY → 417163006 "Traumatic AND/OR non-traumatic injury (disorder)" + * - ALLERGY → 473011001 "Allergic condition (finding)" + * (106190000 "Allergy", used before, is inactive since + * 2013) + * - INJURY → 417163006 "Traumatic or non-traumatic injury (disorder)" * (spans both, the honest broad class for a journal entry) * - MENTAL_HEALTH → 74732009 "Mental disorder (disorder)" * - AUTOIMMUNE → 85828009 "Autoimmune disease (disorder)" @@ -45,16 +47,17 @@ export interface IllnessSnomedCategory { * - OTHER → 64572001 "Disease (disorder)" — the generic root, the * correct fallback for an unspecified class. * + * Every code and display here was checked with the official HL7 FHIR + * validator (validator_cli, R4) against tx.fhir.org's SNOMED CT International + * edition: active concept, accepted display. + * * An unknown / future enum value resolves to the generic `DISEASE_SNOMED` * root via the builder's fallback, never a guessed specific concept. */ export const ILLNESS_TYPE_SNOMED: Record = { INFECTION: { code: "40733004", display: "Infectious disease" }, - ALLERGY: { code: "106190000", display: "Allergy" }, - INJURY: { - code: "417163006", - display: "Traumatic AND/OR non-traumatic injury", - }, + ALLERGY: { code: "473011001", display: "Allergic condition" }, + INJURY: { code: "417163006", display: "Traumatic or non-traumatic injury" }, MENTAL_HEALTH: { code: "74732009", display: "Mental disorder" }, AUTOIMMUNE: { code: "85828009", display: "Autoimmune disease" }, CHRONIC: { code: "27624003", display: "Chronic disease" }, From 0ac5377cc2f6ac104b6a7163b12301377e328bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 13:44:10 +0200 Subject: [PATCH 5/6] fix(fhir): correct the LOINC and SNOMED CT terms the export sends A run of the HL7 validator against every code the export can emit found terms that do not exist, are inactive, mean something else, or carry a display the code system does not publish. - Body water and bone mass use 101683-1 and 101685-6; the old codes are a nasogastric fluid output and a calculated body-fat mass. - Step length, walking speed, walking asymmetry and VO2 max ride their HealthKit identifiers: LOINC has no fitting term, and two of the old codes do not exist. - Mood, estimated A1C, average cycle length and average period length are text-only. The old codes did not exist or were questionnaire items. - LDL, vitamin D and eGFR use the method-less and total terms (2089-1, 62292-8, 69405-9) a recorded value can honestly claim. - Resting heart rate and pulse-oximetry SpO2 also carry the heart-rate and oxygen-saturation codes the R4 vital-signs profiles require. - Blood pressure, adherence, glucose-panel and injection-site codings carry the published displays; ATC codings carry none, since the user's medication name is not the ATC name. The name stays on the text. - Mood is a survey finding, not a vital sign, so it no longer rides the vital-signs panel. --- src/lib/fhir/__tests__/build-bundle.test.ts | 57 +++--- .../fhir/__tests__/cycle-observations.test.ts | 20 +- .../glucose-clinical-observations.test.ts | 28 ++- .../fhir/__tests__/lab-loinc-locales.test.ts | 6 +- .../fhir/__tests__/lab-observations.test.ts | 6 +- .../__tests__/terminology-displays.test.ts | 175 ++++++++++++++++++ src/lib/fhir/lab-loinc.ts | 15 +- src/lib/fhir/loinc-map.ts | 19 +- src/lib/fhir/resources/common.ts | 27 ++- src/lib/fhir/resources/cycle.ts | 36 +--- src/lib/fhir/resources/glucose.ts | 30 ++- src/lib/fhir/resources/medications.ts | 19 +- src/lib/fhir/resources/observation.ts | 29 +-- .../fixtures/expected-measurement-loinc.json | 22 +-- src/lib/signals/registry.ts | 32 ++-- 15 files changed, 393 insertions(+), 128 deletions(-) create mode 100644 src/lib/fhir/__tests__/terminology-displays.test.ts diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index a7d350c4e..b454d2002 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -493,11 +493,11 @@ describe("buildFhirDocumentBundle", () => { const coding = stmt?.medicationCodeableConcept.coding; expect(stmt?.medicationCodeableConcept.text).toBe("Mounjaro"); expect(coding).toHaveLength(2); - // ATC is primary (first), with the name as display. + // ATC is primary (first). No display: the user's name is not the ATC + // name for the code, and the validator rejects it; the name is `.text`. expect(coding?.[0]).toEqual({ system: "http://www.whocc.no/atc", code: "A10BX10", - display: "Mounjaro", }); // RxNorm is secondary, no display. expect(coding?.[1]).toEqual({ @@ -578,7 +578,7 @@ describe("buildFhirDocumentBundle", () => { expect(a.dosage?.site?.coding?.[0]).toEqual({ system: "http://snomed.info/sct", code: "818983003", - display: "Abdomen structure", + display: "Abdomen", }); }); @@ -712,14 +712,14 @@ describe("buildFhirDocumentBundle", () => { it("maps every InjectionSite enum value to its body-region SNOMED concept, preserving laterality in `.text`", () => { const cases: Array<[string, string, string]> = [ - ["ABDOMEN_LEFT", "818983003", "Abdomen structure"], - ["ABDOMEN_RIGHT", "818983003", "Abdomen structure"], - ["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen structure"], - ["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen structure"], + ["ABDOMEN_LEFT", "818983003", "Abdomen"], + ["ABDOMEN_RIGHT", "818983003", "Abdomen"], + ["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen"], + ["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen"], ["THIGH_LEFT", "68367000", "Thigh structure"], ["THIGH_RIGHT", "68367000", "Thigh structure"], - ["UPPER_ARM_LEFT", "40983000", "Structure of upper arm"], - ["UPPER_ARM_RIGHT", "40983000", "Structure of upper arm"], + ["UPPER_ARM_LEFT", "40983000", "Upper arm structure"], + ["UPPER_ARM_RIGHT", "40983000", "Upper arm structure"], ]; for (const [site, code, display] of cases) { const bundle = buildFhirDocumentBundle( @@ -846,13 +846,12 @@ describe("buildFhirDocumentBundle", () => { expect(stmtCoding?.[0]).toEqual({ system: "http://www.whocc.no/atc", code: "A10BK03", - display: "Empagliflozin", }); expect(stmtCoding?.[1]).toEqual({ system: "http://fhir.de/CodeSystem/bfarm/atc", code: "A10BK03", - display: "Empagliflozin", }); + expect(stmt?.medicationCodeableConcept.text).toBe("Empagliflozin"); // The administration concept reflects the flag identically; RxNorm follows. const adminCoding = @@ -909,8 +908,12 @@ describe("buildFhirDocumentBundle", () => { const moodObs = bundle.entry .map((e) => e.resource) .filter((r): r is FhirObservation => r.resourceType === "Observation") - .find((o) => o.code.coding?.some((c) => c.code === "76542-6")); + .find((o) => o.code.text === "Mood (average over period)"); expect(moodObs?.valueQuantity?.value).toBe(3.6); + // 76542-6 does not exist in LOINC: text-only, and a survey finding, not + // a vital sign (so the vital-signs panel does not claim it). + expect(moodObs?.code.coding).toBeUndefined(); + expect(moodObs?.category?.[0]?.coding?.[0]?.code).toBe("survey"); }); it("emits no Observation for a type with no readings (empty domain)", () => { @@ -953,7 +956,7 @@ describe("buildFhirDocumentBundle", () => { expect(spo2?.valueQuantity?.code).toBe("%"); }); - it("maps VO2max to 96402-2 with the iOS display", () => { + it("maps VO2max to the HealthKit identifier, not the nonexistent LOINC 96402-2", () => { const bundle = buildFhirDocumentBundle( makeData({ measurements: { @@ -963,10 +966,15 @@ describe("buildFhirDocumentBundle", () => { { insuranceNumber: null }, FIXED_NOW, ); - const coding = observationsOf(bundle) - .flatMap((o) => o.code.coding ?? []) - .find((c) => c.code === "96402-2"); - expect(coding?.display).toBe("Oxygen consumption maximum during exercise"); + const codings = observationsOf(bundle).flatMap((o) => o.code.coding ?? []); + expect(codings.some((c) => c.code === "96402-2")).toBe(false); + const coding = codings.find( + (c) => c.code === "HKQuantityTypeIdentifierVO2Max", + ); + expect(coding?.system).toBe( + "https://healthlog.dev/fhir/CodeSystem/healthkit", + ); + expect(coding?.display).toBe("VO2 max (estimated)"); }); it("emits steps with UCUM {steps}", () => { @@ -1008,7 +1016,7 @@ describe("buildFhirDocumentBundle", () => { expect(sleep?.valueQuantity?.value).toBe(7.5); }); - it("maps body water + bone mass to the iOS-locked LOINC codes", () => { + it("maps body water + bone mass to the LOINC body-composition terms", () => { const bundle = buildFhirDocumentBundle( makeData({ measurements: { @@ -1022,10 +1030,14 @@ describe("buildFhirDocumentBundle", () => { FIXED_NOW, ); const water = observationsOf(bundle).find((o) => - o.code.coding?.some((c) => c.code === "73704-9"), + o.code.coding?.some( + (c) => c.code === "101683-1" && c.display === "Body water mass", + ), ); const bone = observationsOf(bundle).find((o) => - o.code.coding?.some((c) => c.code === "73708-0"), + o.code.coding?.some( + (c) => c.code === "101685-6" && c.display === "Body bone mass", + ), ); expect(water?.valueQuantity?.code).toBe("kg"); expect(bone?.valueQuantity?.code).toBe("kg"); @@ -1049,8 +1061,11 @@ describe("buildFhirDocumentBundle", () => { const energy = observationsOf(bundle).find((o) => o.code.coding?.some((c) => c.code === "41981-2"), ); + // Not 41957-2 (a 24-hour mean): a HealthKit walking-speed reading. const speed = observationsOf(bundle).find((o) => - o.code.coding?.some((c) => c.code === "41957-2"), + o.code.coding?.some( + (c) => c.code === "HKQuantityTypeIdentifierWalkingSpeed", + ), ); expect(energy?.valueQuantity?.code).toBe("kcal"); // Walking speed FHIR value stays m/s (no km/h conversion). diff --git a/src/lib/fhir/__tests__/cycle-observations.test.ts b/src/lib/fhir/__tests__/cycle-observations.test.ts index bff847b86..330a83d63 100644 --- a/src/lib/fhir/__tests__/cycle-observations.test.ts +++ b/src/lib/fhir/__tests__/cycle-observations.test.ts @@ -4,11 +4,7 @@ import type { DoctorReportData } from "@/lib/doctor-report-data"; import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import { cycleObservationsFromReportData } from "@/lib/fhir/resources"; import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; -import { - LMP_LOINC, - CYCLE_LENGTH_LOINC, - PERIOD_LENGTH_LOINC, -} from "@/lib/fhir/loinc-map"; +import { LMP_LOINC } from "@/lib/fhir/loinc-map"; function baseData(overrides?: Partial): DoctorReportData { return { @@ -68,9 +64,17 @@ describe("fhir/cycle observations", () => { const codes = obs.flatMap((o) => (o.code.coding ?? []).map((c) => c.code).filter(Boolean), ); - expect(codes).toContain(LMP_LOINC); - expect(codes).toContain(CYCLE_LENGTH_LOINC); - expect(codes).toContain(PERIOD_LENGTH_LOINC); + // LMP is the only coded one. The averages are text-only: 64700-8 and + // 64698-4, used before, are PhenX questionnaire items, not these values. + expect(codes).toEqual([LMP_LOINC]); + const cycleLength = obs.find( + (o) => o.code.text === "Average menstrual cycle length", + ); + const periodLength = obs.find( + (o) => o.code.text === "Average period length", + ); + expect(cycleLength?.code.coding).toBeUndefined(); + expect(periodLength?.code.coding).toBeUndefined(); const lmp = obs.find((o) => (o.code.coding ?? []).some((c) => c.code === LMP_LOINC), diff --git a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts index 15b065a4c..1274631e9 100644 --- a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts +++ b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts @@ -6,7 +6,6 @@ import { GLUCOSE_TIR_LOINC, GLUCOSE_GMI_LOINC, GLUCOSE_MEAN_LOINC, - GLUCOSE_EA1C_LOINC, } from "@/lib/fhir/loinc-map"; import { buildFhirDocumentBundle } from "../build-bundle"; @@ -89,7 +88,28 @@ describe("clinical glucose-panel FHIR Observations", () => { expect(codes).toContain(GLUCOSE_TIR_LOINC); expect(codes).toContain(GLUCOSE_GMI_LOINC); expect(codes).toContain(GLUCOSE_MEAN_LOINC); - expect(codes).toContain(GLUCOSE_EA1C_LOINC); + + // Each coded term carries the display LOINC publishes for it. + const displayOf = (code: string) => + observationsOf(bundle) + .flatMap((o) => o.code.coding ?? []) + .find((c) => c.code === code)?.display; + expect(displayOf(GLUCOSE_TIR_LOINC)).toBe( + "Glucose measurements in range out of Total glucose measurements during reporting period", + ); + expect(displayOf(GLUCOSE_GMI_LOINC)).toBe("Glucose management indicator"); + expect(displayOf(GLUCOSE_MEAN_LOINC)).toBe( + "Average glucose [Mass/volume] in Interstitial fluid during Reporting Period", + ); + + // Estimated A1C is text-only: 41995-2, used before, is a measured + // "Hemoglobin A1c [Mass/volume] in Blood". + const ea1c = observationsOf(bundle).find( + (o) => o.code.text === "Estimated A1C", + ); + expect(ea1c).toBeDefined(); + expect(ea1c?.code.coding).toBeUndefined(); + expect(codes).not.toContain("41995-2"); }); it("emits a percent-valued, UCUM-coded TIR Observation", () => { @@ -122,6 +142,8 @@ describe("clinical glucose-panel FHIR Observations", () => { expect(codes).not.toContain(GLUCOSE_TIR_LOINC); expect(codes).not.toContain(GLUCOSE_GMI_LOINC); expect(codes).not.toContain(GLUCOSE_MEAN_LOINC); - expect(codes).not.toContain(GLUCOSE_EA1C_LOINC); + expect( + observationsOf(bundle).some((o) => o.code.text === "Estimated A1C"), + ).toBe(false); }); }); diff --git a/src/lib/fhir/__tests__/lab-loinc-locales.test.ts b/src/lib/fhir/__tests__/lab-loinc-locales.test.ts index 2e4363e45..bbe104493 100644 --- a/src/lib/fhir/__tests__/lab-loinc-locales.test.ts +++ b/src/lib/fhir/__tests__/lab-loinc-locales.test.ts @@ -35,7 +35,7 @@ describe("resolveLabCoding — the analyte name in every shipped locale", () => ["Ferrytyna", "2276-4"], ["Hémoglobine", "718-7"], ["Emoglobina", "718-7"], - ["Cholestérol LDL", "18262-6"], + ["Cholestérol LDL", "2089-1"], ["Colesterol HDL", "2085-9"], ["Triglycérides", "2571-8"], ["Trójglicerydy", "2571-8"], @@ -80,8 +80,8 @@ describe("resolveLabCoding — the analyte name in every shipped locale", () => describe("resolveLabCoding — the existing English and German behaviour", () => { it.each([ ["HbA1c", "4548-4"], - ["LDL-C", "18262-6"], - ["LDL Cholesterol", "18262-6"], + ["LDL-C", "2089-1"], + ["LDL Cholesterol", "2089-1"], ["Gesamtcholesterin", "2093-3"], ["Kreatinin", "2160-0"], ["GPT", "1742-6"], diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index 0feb2cc46..8ad312ffc 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -224,17 +224,17 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { it("stamps the canonical UCUM code when the unit matches", () => { const o = labOf("LDL", "mg/dL", 110); - expect(o?.code.coding?.[0].code).toBe("18262-6"); + expect(o?.code.coding?.[0].code).toBe("2089-1"); expect(o?.valueQuantity?.system).toBe("http://unitsofmeasure.org"); expect(o?.valueQuantity?.code).toBe("mg/dL"); expect(o?.valueQuantity?.unit).toBe("mg/dL"); }); it("resolves analyte aliases to the same canonical LOINC", () => { - // "LDL", "LDL-C", "LDL Cholesterol" all fold to 18262-6. + // "LDL", "LDL-C", "LDL Cholesterol" all fold to 2089-1. for (const name of ["LDL-C", "LDL Cholesterol", "ldl_c"]) { const o = labOf(name, "mg/dL", 110); - expect(o?.code.coding?.[0].code).toBe("18262-6"); + expect(o?.code.coding?.[0].code).toBe("2089-1"); } // German alias for total cholesterol. const chol = labOf("Gesamtcholesterin", "mg/dL", 180); diff --git a/src/lib/fhir/__tests__/terminology-displays.test.ts b/src/lib/fhir/__tests__/terminology-displays.test.ts new file mode 100644 index 000000000..f712fb2e0 --- /dev/null +++ b/src/lib/fhir/__tests__/terminology-displays.test.ts @@ -0,0 +1,175 @@ +/** + * Codes and displays the official HL7 FHIR validator (R4, tx.fhir.org) + * rejected before v1.39.3, pinned at the values it accepts. A display must be + * one LOINC or SNOMED CT publishes for the code; a code must be active and + * mean what the Observation says. + */ +import { describe, expect, it } from "vitest"; + +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; +import { resolveLabCoding } from "@/lib/fhir/lab-loinc"; +import type { FhirCoding, FhirObservation } from "@/lib/fhir/types"; + +const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z"); + +function codings(overrides: Partial): FhirCoding[] { + const data = { + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T12:00:00.000Z", + }, + patient: { username: "sample-user", dateOfBirth: null, gender: null }, + practiceName: null, + measurements: {}, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [], + mood: null, + glp1: null, + ...overrides, + } as DoctorReportData; + const bundle = buildFhirDocumentBundle( + data, + { insuranceNumber: null }, + FIXED_NOW, + ); + return bundle.entry + .map((e) => e.resource) + .filter((r): r is FhirObservation => r.resourceType === "Observation") + .flatMap((o) => [ + ...(o.code.coding ?? []), + ...(o.component ?? []).flatMap((c) => c.code.coding ?? []), + ]); +} + +describe("LOINC displays accepted by the validator", () => { + it("names the blood-pressure panel and its components as LOINC does", () => { + const all = codings({ + measurements: { + BLOOD_PRESSURE_SYS: [ + { value: 120, measuredAt: "2026-04-30T08:00:00.000Z" }, + ], + BLOOD_PRESSURE_DIA: [ + { value: 78, measuredAt: "2026-04-30T08:00:00.000Z" }, + ], + }, + }); + const display = (code: string) => all.find((c) => c.code === code)?.display; + expect(display("85354-9")).toBe( + "Blood pressure panel with all children optional", + ); + expect(display("8480-6")).toBe("Systolic blood pressure"); + expect(display("8462-4")).toBe("Diastolic blood pressure"); + }); + + it("names medication adherence as LOINC does", () => { + const all = codings({ + compliance: { + "Example Drug": { total: 10, taken: 9, skipped: 1, missed: 0 }, + }, + }); + expect(all.find((c) => c.code === "71799-1")?.display).toBe( + "Adherence to prescribed medication instructions [Reported]", + ); + }); +}); + +describe("R4 vital-signs profile codes", () => { + it("adds the heart-rate and oxygen-saturation magic codes beside the specific ones", () => { + const all = codings({ + measurements: { + RESTING_HEART_RATE: [ + { value: 54, measuredAt: "2026-04-30T08:00:00.000Z" }, + ], + OXYGEN_SATURATION: [ + { value: 97, measuredAt: "2026-04-30T08:00:00.000Z" }, + ], + }, + }); + const codes = all.map((c) => c.code); + expect(codes).toEqual( + expect.arrayContaining(["40443-4", "8867-4", "59408-5", "2708-6"]), + ); + }); +}); + +describe("medication codings", () => { + it("carries no display on an ATC coding; the user's name stays on text", () => { + const bundle = buildFhirDocumentBundle( + { + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T12:00:00.000Z", + }, + patient: { username: "sample-user", dateOfBirth: null, gender: null }, + practiceName: null, + measurements: {}, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [ + { name: "Delix 5", dose: "5mg", schedules: [], atcCode: "C09AA05" }, + ], + mood: null, + glp1: null, + } as unknown as DoctorReportData, + { insuranceNumber: null }, + FIXED_NOW, + { germanAtc: true }, + ); + const stmt = bundle.entry + .map((e) => e.resource) + .find((r) => r.resourceType === "MedicationStatement"); + if (stmt?.resourceType !== "MedicationStatement") throw new Error("none"); + expect(stmt.medicationCodeableConcept.text).toBe("Delix 5"); + expect(stmt.medicationCodeableConcept.coding).toEqual([ + { system: "http://www.whocc.no/atc", code: "C09AA05" }, + { system: "http://fhir.de/CodeSystem/bfarm/atc", code: "C09AA05" }, + ]); + }); +}); + +describe("lab LOINC terms", () => { + it.each([ + // Method-less LDL; 18262-6 is the direct-assay term. + [ + "LDL-C", + "mg/dL", + "2089-1", + "Cholesterol in LDL [Mass/volume] in Serum or Plasma", + ], + // Total 25-OH vitamin D; 1989-3 is D3 alone. + [ + "Vitamin D", + "ng/mL", + "62292-8", + "25-Hydroxyvitamin D3+25-Hydroxyvitamin D2 [Mass/volume] in Serum or Plasma", + ], + // Formula-less eGFR; 33914-3 (MDRD) is DISCOURAGED. + [ + "eGFR", + "mL/min/1.73m2", + "69405-9", + "Glomerular filtration rate [Volume Rate/Area] in Serum, Plasma or Blood by based on 1.73 sq M", + ], + ])("codes %s as %s", (analyte, unit, code, display) => { + const coding = resolveLabCoding(analyte, unit); + expect(coding?.loinc).toBe(code); + expect(coding?.display).toBe(display); + }); +}); diff --git a/src/lib/fhir/lab-loinc.ts b/src/lib/fhir/lab-loinc.ts index 1d6562b56..8b2a51158 100644 --- a/src/lib/fhir/lab-loinc.ts +++ b/src/lib/fhir/lab-loinc.ts @@ -79,8 +79,10 @@ const LAB_LOINC_BY_KEY: Record = { display: "Hemoglobin A1c/Hemoglobin.total in Blood", ucum: "%", }, + // Method-less LDL: a recorded value does not say whether it was measured + // directly (18262-6) or calculated (13457-7). ldlc: { - loinc: "18262-6", + loinc: "2089-1", display: "Cholesterol in LDL [Mass/volume] in Serum or Plasma", ucum: "mg/dL", }, @@ -109,10 +111,11 @@ const LAB_LOINC_BY_KEY: Record = { display: "Thyrotropin [Units/volume] in Serum or Plasma", ucum: "m[IU]/L", }, + // Total 25-OH vitamin D (D2+D3), the value labs report; 1989-3 is D3 alone. vitamind: { - loinc: "1989-3", + loinc: "62292-8", display: - "25-hydroxyvitamin D2+25-hydroxyvitamin D3 [Mass/volume] in Serum or Plasma", + "25-Hydroxyvitamin D3+25-Hydroxyvitamin D2 [Mass/volume] in Serum or Plasma", ucum: "ng/mL", }, creatinine: { @@ -120,10 +123,12 @@ const LAB_LOINC_BY_KEY: Record = { display: "Creatinine [Mass/volume] in Serum or Plasma", ucum: "mg/dL", }, + // Formula-less eGFR per 1.73 m2. A recorded value does not name its + // formula, and 33914-3 (MDRD) is DISCOURAGED in LOINC. egfr: { - loinc: "33914-3", + loinc: "69405-9", display: - "Glomerular filtration rate/1.73 sq M.predicted by Creatinine-based formula (MDRD)", + "Glomerular filtration rate [Volume Rate/Area] in Serum, Plasma or Blood by based on 1.73 sq M", ucum: "mL/min/{1.73_m2}", }, alt: { diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index 4920d04fa..aab8101d3 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -95,18 +95,21 @@ export const GLUCOSE_TIR_LOINC = "97510-2"; export const GLUCOSE_GMI_LOINC = "97506-0"; /** Mean glucose over the reporting period. */ export const GLUCOSE_MEAN_LOINC = "97507-8"; -/** Estimated A1C (ADAG, derived from mean glucose). */ -export const GLUCOSE_EA1C_LOINC = "41995-2"; +/* + * Estimated A1C (ADAG, derived from mean glucose) has no LOINC term of its + * own: 41995-2, used here before, is a measured "Hemoglobin A1c + * [Mass/volume] in Blood". The builder emits it text-only. + */ /** Medication-adherence Observation LOINC. */ export const MEDICATION_ADHERENCE_LOINC = "71799-1"; -/** Mood Observation LOINC (opt-in only). */ -export const MOOD_LOINC = "76542-6"; /* ── Cycle / reproductive-health LOINCs (v1.15.0, opt-in only) ──────── */ /** Last menstrual period (LMP) start date. */ export const LMP_LOINC = "8665-2"; -/** Menstrual cycle length [time]. */ -export const CYCLE_LENGTH_LOINC = "64700-8"; -/** Length of menses (period / bleeding duration). */ -export const PERIOD_LENGTH_LOINC = "64698-4"; +/* + * Average cycle length and average period length have no LOINC term for a + * value computed over many cycles. 64700-8 and 64698-4, used before, are + * PhenX questionnaire items (a remembered typical cycle; a breastfeeding + * question), so both Observations are text-only. + */ diff --git a/src/lib/fhir/resources/common.ts b/src/lib/fhir/resources/common.ts index 97fa59619..920ef0592 100644 --- a/src/lib/fhir/resources/common.ts +++ b/src/lib/fhir/resources/common.ts @@ -84,6 +84,24 @@ export function categoryConcept(category: string): FhirCodeableConcept { }; } +/** + * R4 vital-signs profiles require a "magic" LOINC code on `Observation.code` + * and allow a more specific code beside it + * (https://hl7.org/fhir/R4/observation-vitalsigns.html). A resting heart rate + * is a heart rate (8867-4) and a pulse-oximetry SpO2 is an oxygen saturation + * (2708-6); without the magic code the validator rejects both Observations. + */ +const VITAL_SIGNS_MAGIC_CODE: Record< + string, + { code: string; display: string } +> = { + "40443-4": { code: "8867-4", display: "Heart rate" }, + "59408-5": { + code: "2708-6", + display: "Oxygen saturation in Arterial blood", + }, +}; + export function codeableFromMapping(m: LoincMapping): FhirCodeableConcept { if (m.loinc) { // HealthKit placeholder codes have no published LOINC term; they must not @@ -93,8 +111,15 @@ export function codeableFromMapping(m: LoincMapping): FhirCodeableConcept { const system = m.loinc.startsWith("HKQuantityTypeIdentifier") ? HEALTHKIT_CODESYSTEM : LOINC_SYSTEM; + const magic = + system === LOINC_SYSTEM ? VITAL_SIGNS_MAGIC_CODE[m.loinc] : undefined; return { - coding: [{ system, code: m.loinc, display: m.display }], + coding: [ + { system, code: m.loinc, display: m.display }, + ...(magic + ? [{ system: LOINC_SYSTEM, code: magic.code, display: magic.display }] + : []), + ], text: m.display, }; } diff --git a/src/lib/fhir/resources/cycle.ts b/src/lib/fhir/resources/cycle.ts index a954db26d..71d48e289 100644 --- a/src/lib/fhir/resources/cycle.ts +++ b/src/lib/fhir/resources/cycle.ts @@ -1,18 +1,13 @@ /** * v1.15.0 — the cycle / reproductive-health Observations from the opt-in cycle - * summary: LMP (LOINC 8665-2, a date value), average cycle length (64700-8, - * days), average period length (64698-4, days), and the current phase + * summary: LMP (LOINC 8665-2, a date value), average cycle length and average + * period length (text-only, days; see `loinc-map.ts`), and the current phase * (text-only survey finding). Absent when the aggregator carried no cycle * summary (toggle off or no observed cycle). Ids run `obs-cycle-N` so they * never collide with the main `obs-N` sequence. */ import type { DoctorReportData } from "@/lib/doctor-report-data"; -import { - LOINC_SYSTEM, - LMP_LOINC, - CYCLE_LENGTH_LOINC, - PERIOD_LENGTH_LOINC, -} from "@/lib/fhir/loinc-map"; +import { LOINC_SYSTEM, LMP_LOINC } from "@/lib/fhir/loinc-map"; import { ucumQuantity } from "@/lib/fhir/ucum"; import type { FhirObservation } from "@/lib/fhir/types"; import { @@ -76,16 +71,9 @@ export function cycleObservationsFromReportData( id: next(), status: "final", category: [categoryConcept("survey")], - code: { - coding: [ - { - system: LOINC_SYSTEM, - code: CYCLE_LENGTH_LOINC, - display: "Menstrual cycle length", - }, - ], - text: "Average menstrual cycle length", - }, + // Text-only: no LOINC term for a cycle length averaged over the + // observed cycles (see `loinc-map.ts`). + code: { text: "Average menstrual cycle length" }, subject: patientRef, effectivePeriod, valueQuantity: ucumQuantity(cycle.averageCycleLengthDays, "d"), @@ -107,16 +95,8 @@ export function cycleObservationsFromReportData( id: next(), status: "final", category: [categoryConcept("survey")], - code: { - coding: [ - { - system: LOINC_SYSTEM, - code: PERIOD_LENGTH_LOINC, - display: "Length of menses", - }, - ], - text: "Average period length", - }, + // Text-only, like the cycle length above. + code: { text: "Average period length" }, subject: patientRef, effectivePeriod, valueQuantity: ucumQuantity(cycle.averagePeriodLengthDays, "d"), diff --git a/src/lib/fhir/resources/glucose.ts b/src/lib/fhir/resources/glucose.ts index 6bb30e618..0ec140729 100644 --- a/src/lib/fhir/resources/glucose.ts +++ b/src/lib/fhir/resources/glucose.ts @@ -15,7 +15,6 @@ import { GLUCOSE_TIR_LOINC, GLUCOSE_GMI_LOINC, GLUCOSE_MEAN_LOINC, - GLUCOSE_EA1C_LOINC, } from "@/lib/fhir/loinc-map"; import { ucumQuantity } from "@/lib/fhir/ucum"; import type { FhirObservation } from "@/lib/fhir/types"; @@ -92,7 +91,8 @@ export function glucoseClinicalObservations( const observations: FhirObservation[] = []; const push = (opts: { - loinc?: string; + /** LOINC code and its LOINC display; absent for a text-only concept. */ + loinc?: { code: string; display: string }; text: string; value: number; unit: string; @@ -106,7 +106,11 @@ export function glucoseClinicalObservations( code: opts.loinc ? { coding: [ - { system: LOINC_SYSTEM, code: opts.loinc, display: opts.text }, + { + system: LOINC_SYSTEM, + code: opts.loinc.code, + display: opts.loinc.display, + }, ], text: opts.text, } @@ -128,7 +132,11 @@ export function glucoseClinicalObservations( if (clinical.distribution) { push({ - loinc: GLUCOSE_TIR_LOINC, + loinc: { + code: GLUCOSE_TIR_LOINC, + display: + "Glucose measurements in range out of Total glucose measurements during reporting period", + }, text: timeInRangeDisplay(glucoseUnit), value: Math.round(clinical.distribution.tir * 1000) / 10, unit: "%", @@ -136,7 +144,11 @@ export function glucoseClinicalObservations( } if (clinical.meanMgdl !== null) { push({ - loinc: GLUCOSE_MEAN_LOINC, + loinc: { + code: GLUCOSE_MEAN_LOINC, + display: + "Average glucose [Mass/volume] in Interstitial fluid during Reporting Period", + }, text: "Mean glucose", value: convertGlucose(clinical.meanMgdl, glucoseUnit), unit: glucoseUnit, @@ -144,7 +156,10 @@ export function glucoseClinicalObservations( } if (clinical.gmi !== null) { push({ - loinc: GLUCOSE_GMI_LOINC, + loinc: { + code: GLUCOSE_GMI_LOINC, + display: "Glucose management indicator", + }, text: "Glucose Management Indicator (GMI)", value: Math.round(clinical.gmi * 10) / 10, unit: "%", @@ -152,7 +167,8 @@ export function glucoseClinicalObservations( } if (clinical.estimatedA1c !== null) { push({ - loinc: GLUCOSE_EA1C_LOINC, + // Text-only: no LOINC term exists for an A1C estimated from mean + // glucose (see `loinc-map.ts`). text: "Estimated A1C", value: Math.round(clinical.estimatedA1c * 10) / 10, unit: "%", diff --git a/src/lib/fhir/resources/medications.ts b/src/lib/fhir/resources/medications.ts index 233caee61..ea14d2be7 100644 --- a/src/lib/fhir/resources/medications.ts +++ b/src/lib/fhir/resources/medications.ts @@ -35,10 +35,13 @@ function medicationConcept( germanAtc: boolean, ): FhirCodeableConcept { const coding: NonNullable = []; + // No `display` on the ATC codings: a coding's display must be the code + // system's own name for the code ("ramipril"), and the user's name for the + // medication ("Delix 5") is not that. It rides `.text`. if (atcCode) { - coding.push({ system: ATC_SYSTEM, code: atcCode, display: name }); + coding.push({ system: ATC_SYSTEM, code: atcCode }); if (germanAtc) { - coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name }); + coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode }); } } if (rxNormCode) { @@ -117,14 +120,14 @@ function routeConcept( * no information is lost. */ const SITE_SNOMED: Record = { - ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" }, - ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" }, - ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" }, - ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" }, + ABDOMEN_LEFT: { code: "818983003", display: "Abdomen" }, + ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen" }, + ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen" }, + ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen" }, THIGH_LEFT: { code: "68367000", display: "Thigh structure" }, THIGH_RIGHT: { code: "68367000", display: "Thigh structure" }, - UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" }, - UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" }, + UPPER_ARM_LEFT: { code: "40983000", display: "Upper arm structure" }, + UPPER_ARM_RIGHT: { code: "40983000", display: "Upper arm structure" }, }; function siteConcept(injectionSite: string): FhirCodeableConcept { diff --git a/src/lib/fhir/resources/observation.ts b/src/lib/fhir/resources/observation.ts index 45a8bc4a8..72fc8d3af 100644 --- a/src/lib/fhir/resources/observation.ts +++ b/src/lib/fhir/resources/observation.ts @@ -22,7 +22,6 @@ import { BP_DIA_LOINC, BP_UNIT, MEDICATION_ADHERENCE_LOINC, - MOOD_LOINC, } from "@/lib/fhir/loinc-map"; import { ucumQuantity } from "@/lib/fhir/ucum"; import type { FhirObservation } from "@/lib/fhir/types"; @@ -104,7 +103,7 @@ function bloodPressureObservation( { system: LOINC_SYSTEM, code: BP_PANEL_LOINC, - display: "Blood pressure panel", + display: "Blood pressure panel with all children optional", }, ], text: "Blood pressure", @@ -116,7 +115,11 @@ function bloodPressureObservation( { code: { coding: [ - { system: LOINC_SYSTEM, code: BP_SYS_LOINC, display: "Systolic" }, + { + system: LOINC_SYSTEM, + code: BP_SYS_LOINC, + display: "Systolic blood pressure", + }, ], }, valueQuantity: ucumQuantity(sys.value, BP_UNIT), @@ -127,7 +130,7 @@ function bloodPressureObservation( { system: LOINC_SYSTEM, code: BP_DIA_LOINC, - display: "Diastolic", + display: "Diastolic blood pressure", }, ], }, @@ -194,7 +197,8 @@ function adherenceObservations( { system: LOINC_SYSTEM, code: MEDICATION_ADHERENCE_LOINC, - display: "Medication adherence", + display: + "Adherence to prescribed medication instructions [Reported]", }, ], text: `Medication adherence — ${name}`, @@ -207,7 +211,13 @@ function adherenceObservations( return observations; } -/** The opt-in mood average; absent when the toggle is off. */ +/** + * The opt-in mood average; absent when the toggle is off. + * + * Text-only, under `survey`: LOINC has no term for a self-rated mood score + * averaged over a period (76542-6, used before, does not exist in LOINC), and + * a mood score is not a vital sign, so it must not ride the vital-signs panel. + */ function moodObservation( data: DoctorReportData, nextId: () => string, @@ -218,11 +228,8 @@ function moodObservation( resourceType: "Observation", id: nextId(), status: "final", - category: [categoryConcept("vital-signs")], - code: { - coding: [{ system: LOINC_SYSTEM, code: MOOD_LOINC, display: "Mood" }], - text: "Mood (average over period)", - }, + category: [categoryConcept("survey")], + code: { text: "Mood (average over period)" }, subject: patientRef, effectivePeriod: reportingPeriod(data), valueQuantity: ucumQuantity( diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index 716887d9b..ef14ef361 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -48,8 +48,8 @@ "category": "vital-signs" }, "VO2_MAX": { - "loinc": "96402-2", - "display": "Oxygen consumption maximum during exercise", + "loinc": "HKQuantityTypeIdentifierVO2Max", + "display": "VO2 max (estimated)", "unit": "mL/min/kg", "category": "vital-signs" }, @@ -78,32 +78,32 @@ "category": "activity" }, "WALKING_SPEED": { - "loinc": "41957-2", - "display": "Gait speed [Velocity] Measured", + "loinc": "HKQuantityTypeIdentifierWalkingSpeed", + "display": "Walking speed", "unit": "m/s", "category": "vital-signs" }, "WALKING_ASYMMETRY": { - "loinc": "91557-1", + "loinc": "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", "display": "Walking asymmetry percentage", "unit": "%", "category": "vital-signs" }, "WALKING_STEP_LENGTH": { - "loinc": "41955-6", - "display": "Step length Measured", + "loinc": "HKQuantityTypeIdentifierWalkingStepLength", + "display": "Walking step length", "unit": "m", "category": "vital-signs" }, "TOTAL_BODY_WATER": { - "loinc": "73704-9", - "display": "Body water by Bioelectrical impedance analysis", + "loinc": "101683-1", + "display": "Body water mass", "unit": "kg", "category": "vital-signs" }, "BONE_MASS": { - "loinc": "73708-0", - "display": "Bone mineral content by DXA", + "loinc": "101685-6", + "display": "Body bone mass", "unit": "kg", "category": "vital-signs" }, diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index afb003c53..17ca5d025 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -574,8 +574,11 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "96402-2", - display: "Oxygen consumption maximum during exercise", + // 96402-2, used before, does not exist in LOINC. The watch estimate is + // not a measured exercise-test VO2 peak (94122-9), so the HealthKit + // identifier rides the custom system. + loinc: "HKQuantityTypeIdentifierVO2Max", + display: "VO2 max (estimated)", unit: "mL/min/kg", category: "vital-signs", }, @@ -656,8 +659,9 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "73704-9", - display: "Body water by Bioelectrical impedance analysis", + // 73704-9, used before, is a nasogastric fluid output. + loinc: "101683-1", + display: "Body water mass", unit: "kg", category: "vital-signs", }, @@ -677,8 +681,9 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "73708-0", - display: "Bone mineral content by DXA", + // 73708-0, used before, is "Body fat [Mass] Calculated". + loinc: "101685-6", + display: "Body bone mass", unit: "kg", category: "vital-signs", }, @@ -821,7 +826,8 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "91557-1", + // 91557-1, used before, does not exist in LOINC. + loinc: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", display: "Walking asymmetry percentage", unit: "%", category: "vital-signs", @@ -864,8 +870,10 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "41955-6", - display: "Step length Measured", + // 41955-6, used before, is "Distance walked in 1 week"; LOINC has no + // step-length term, so the HealthKit identifier rides the custom system. + loinc: "HKQuantityTypeIdentifierWalkingStepLength", + display: "Walking step length", unit: "m", category: "vital-signs", }, @@ -886,8 +894,10 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - loinc: "41957-2", - display: "Gait speed [Velocity] Measured", + // 41957-2, used before, is a 24-hour mean walking speed; the value + // here is a HealthKit walking-speed reading. + loinc: "HKQuantityTypeIdentifierWalkingSpeed", + display: "Walking speed", unit: "m/s", category: "vital-signs", }, From 0a58edc87a2615d3724c22a4571283de16d83151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 13:55:41 +0200 Subject: [PATCH 6/6] fix(fhir): code the mean glucose only when it is a continuous-stream mean 97507-8 is the mean of interstitial glucose in mass/volume. A spot-reading mean is neither, so it now goes out text-only in the account's unit. A continuous-stream mean keeps the code and is sent in mg/dL, the unit it is computed in, whatever unit the account displays. --- .../glucose-clinical-observations.test.ts | 49 +++++++++++++++++++ src/lib/fhir/resources/glucose.ts | 33 +++++++++---- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts index 1274631e9..8562be554 100644 --- a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts +++ b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts @@ -131,6 +131,55 @@ describe("clinical glucose-panel FHIR Observations", () => { expect(tir!.valueQuantity?.value).toBeLessThanOrEqual(100); }); + it("codes a continuous-stream mean as 97507-8 in mg/dL, even for a mmol/L account", () => { + const glucoseClinical = computeGlucoseClinicalMetrics(denseReadings(), { + now: FIXED_NOW, + windowDays: 90, + }); + expect(glucoseClinical.isSpotEstimate).toBe(false); + const bundle = buildFhirDocumentBundle( + makeData({ glucoseClinical, glucoseUnit: "mmol/L" }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const mean = observationsOf(bundle).find( + (o) => o.code.text === "Mean glucose", + ); + expect(codeOf(mean!)).toBe(GLUCOSE_MEAN_LOINC); + expect(mean?.valueQuantity?.code).toBe("mg/dL"); + expect(mean?.valueQuantity?.value).toBe( + Math.round(glucoseClinical.meanMgdl!), + ); + }); + + it("sends a spot-reading mean text-only, in the account's unit", () => { + // Four readings a day: fingerstick cadence, not a continuous stream. + const sparse = Array.from({ length: 120 }, (_, i) => ({ + measuredAt: new Date(FIXED_NOW.getTime() - i * 6 * 3_600_000), + mgdl: 100 + (i % 5) * 10, + })); + const glucoseClinical = computeGlucoseClinicalMetrics(sparse, { + now: FIXED_NOW, + windowDays: 90, + }); + expect(glucoseClinical.isSpotEstimate).toBe(true); + expect(glucoseClinical.meanMgdl).not.toBeNull(); + const bundle = buildFhirDocumentBundle( + makeData({ glucoseClinical, glucoseUnit: "mmol/L" }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const mean = observationsOf(bundle).find( + (o) => o.code.text === "Mean glucose", + ); + expect(mean).toBeDefined(); + expect(mean?.code.coding).toBeUndefined(); + expect(mean?.valueQuantity?.unit).toBe("mmol/L"); + expect(observationsOf(bundle).map(codeOf)).not.toContain( + GLUCOSE_MEAN_LOINC, + ); + }); + it("emits NO clinical glucose Observation when the panel has no readings (module off / no data)", () => { // Default makeData() has an empty (zero-reading) panel. const bundle = buildFhirDocumentBundle( diff --git a/src/lib/fhir/resources/glucose.ts b/src/lib/fhir/resources/glucose.ts index 0ec140729..5f5c3aaa2 100644 --- a/src/lib/fhir/resources/glucose.ts +++ b/src/lib/fhir/resources/glucose.ts @@ -143,16 +143,29 @@ export function glucoseClinicalObservations( }); } if (clinical.meanMgdl !== null) { - push({ - loinc: { - code: GLUCOSE_MEAN_LOINC, - display: - "Average glucose [Mass/volume] in Interstitial fluid during Reporting Period", - }, - text: "Mean glucose", - value: convertGlucose(clinical.meanMgdl, glucoseUnit), - unit: glucoseUnit, - }); + // 97507-8 is the mean of interstitial glucose in mass/volume. Only a + // continuous stream (not a spot estimate, by reading density) is + // interstitial, so only that mean carries the code, and it goes out in + // mg/dL: the canonical unit the mean is computed in, so no conversion is + // involved. A spot-reading mean stays text-only in the display unit. + if (clinical.isSpotEstimate) { + push({ + text: "Mean glucose", + value: convertGlucose(clinical.meanMgdl, glucoseUnit), + unit: glucoseUnit, + }); + } else { + push({ + loinc: { + code: GLUCOSE_MEAN_LOINC, + display: + "Average glucose [Mass/volume] in Interstitial fluid during Reporting Period", + }, + text: "Mean glucose", + value: convertGlucose(clinical.meanMgdl, "mg/dL"), + unit: "mg/dL", + }); + } } if (clinical.gmi !== null) { push({