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..b454d2002 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( @@ -492,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({ @@ -577,7 +578,7 @@ describe("buildFhirDocumentBundle", () => { expect(a.dosage?.site?.coding?.[0]).toEqual({ system: "http://snomed.info/sct", code: "818983003", - display: "Abdomen structure", + display: "Abdomen", }); }); @@ -711,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( @@ -845,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 = @@ -908,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)", () => { @@ -952,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: { @@ -962,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}", () => { @@ -1007,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: { @@ -1021,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"); @@ -1048,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). @@ -1172,6 +1188,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/__tests__/condition-body-site.test.ts b/src/lib/fhir/__tests__/condition-body-site.test.ts new file mode 100644 index 000000000..46cc15861 --- /dev/null +++ b/src/lib/fhir/__tests__/condition-body-site.test.ts @@ -0,0 +1,134 @@ +/** + * `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", "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) => { + 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("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" }]); + 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..195e5f618 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); + // An active SNOMED CT laterality qualifier value. + expect( + ["7771000", "24028007", "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/__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..8562be554 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", () => { @@ -111,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( @@ -122,6 +191,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__/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/__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/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/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" }, 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/conditions.ts b/src/lib/fhir/resources/conditions.ts index 1a4984855..a97effba8 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,94 @@ 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 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: "7771000", display: "Left", text: "left" }, + RIGHT: { code: "24028007", display: "Right", text: "right" }, + BOTH: { code: "51440002", display: "Right and left", 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". + * + * 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, + 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 +142,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 +185,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/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..5f5c3aaa2 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,23 +132,47 @@ 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: "%", }); } if (clinical.meanMgdl !== null) { - push({ - loinc: GLUCOSE_MEAN_LOINC, - 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({ - 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 +180,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/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 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(), 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", },