diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 18f396bf7..58c281091 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -1232,10 +1232,11 @@ describe("buildFhirDocumentBundle — illness episodes (v1.18.1 P4)", () => { expect(conditions).toHaveLength(1); const c = conditions[0]; if (c.resourceType !== "Condition") throw new Error("not a Condition"); - // The user's label rides code.text; the code is the generic disease root - // (never a fabricated diagnosis). + // The user's label rides code.text; the code is the BROAD SNOMED category + // for the type (INFECTION → 40733004 "Infectious disease"), never a + // fabricated specific diagnosis. expect(c.code.text).toBe("Erkältung"); - expect(c.code.coding?.[0].code).toBe("64572001"); + expect(c.code.coding?.[0].code).toBe("40733004"); expect(c.onsetDateTime).toBe("2026-04-01T00:00:00.000Z"); expect(c.abatementDateTime).toBe("2026-04-10T00:00:00.000Z"); expect(c.clinicalStatus?.coding?.[0].code).toBe("resolved"); @@ -1278,6 +1279,8 @@ describe("buildFhirDocumentBundle — illness episodes (v1.18.1 P4)", () => { const c = conditionsOf(bundle)[0]; if (c.resourceType !== "Condition") throw new Error("not a Condition"); expect(c.clinicalStatus?.coding?.[0].code).toBe("active"); + // CHRONIC → 27624003 "Chronic disease" (broad category, not a diagnosis). + expect(c.code.coding?.[0].code).toBe("27624003"); expect(c.abatementDateTime).toBeUndefined(); const enc = encountersOf(bundle)[0]; if (enc.resourceType !== "Encounter") throw new Error("not an Encounter"); diff --git a/src/lib/fhir/__tests__/illness-snomed.test.ts b/src/lib/fhir/__tests__/illness-snomed.test.ts new file mode 100644 index 000000000..5b5e40cfe --- /dev/null +++ b/src/lib/fhir/__tests__/illness-snomed.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; + +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; + +import { buildFhirDocumentBundle } from "../build-bundle"; +import { ILLNESS_TYPE_SNOMED } from "../illness-snomed"; +import type { FhirCondition } from "../types"; + +const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z"); + +function makeData(overrides?: Partial): DoctorReportData { + return { + 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: "1985-06-15T00:00:00.000Z", + gender: "MALE", + heightCm: 182, + }, + practiceName: null, + measurements: {}, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [], + mood: null, + glp1: null, + ...overrides, + }; +} + +function conditionsOf(bundle: ReturnType) { + return bundle.entry + .map((e) => e.resource) + .filter((r): r is FhirCondition => r.resourceType === "Condition"); +} + +function conditionForType(type: string): FhirCondition { + const bundle = buildFhirDocumentBundle( + makeData({ + illnessEpisodes: [ + { + label: `journal-${type}`, + type, + lifecycle: "ACUTE", + onsetAt: "2026-04-01T00:00:00.000Z", + resolvedAt: null, + }, + ], + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + return conditionsOf(bundle)[0]; +} + +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"], + ["MENTAL_HEALTH", "74732009", "Mental disorder"], + ["AUTOIMMUNE", "85828009", "Autoimmune disease"], + ["CHRONIC", "27624003", "Chronic disease"], + ["OTHER", "64572001", "Disease"], + ]; + + it.each(cases)( + "maps IllnessType %s to SNOMED %s on the Condition", + (type, code, display) => { + const c = conditionForType(type); + expect(c.code.coding?.[0].system).toBe("http://snomed.info/sct"); + expect(c.code.coding?.[0].code).toBe(code); + expect(c.code.coding?.[0].display).toBe(display); + }, + ); + + it("keeps every map entry as a real, non-empty SNOMED concept id", () => { + for (const [, { code, display }] of Object.entries(ILLNESS_TYPE_SNOMED)) { + expect(code).toMatch(/^\d{6,}$/); + expect(display.length).toBeGreaterThan(0); + } + }); + + it("falls back to the generic Disease root for an unknown / future type", () => { + const c = conditionForType("PLANTAR_FASCIITIS_NOT_A_REAL_ENUM"); + expect(c.code.coding?.[0].code).toBe("64572001"); + expect(c.code.coding?.[0].display).toBe("Disease"); + }); + + it("keeps the patient-reported guard rails intact on a coded Condition", () => { + const c = conditionForType("AUTOIMMUNE"); + // The user's own label stays the anchor — never replaced by the category. + expect(c.code.text).toBe("journal-AUTOIMMUNE"); + // The broad class is also surfaced as the category text. + expect(c.category?.[0].text).toBe("AUTOIMMUNE"); + // Never a clinician-confirmed diagnosis. + expect(c.verificationStatus?.coding?.[0].code).toBe("unconfirmed"); + expect( + c.note?.some((n) => + n.text.includes("patient-reported, not a clinical diagnosis"), + ), + ).toBe(true); + }); +}); diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index 3d03132ce..b3fed82b0 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -161,3 +161,88 @@ describe("lab-result FHIR Observations", () => { expect(labObs).toHaveLength(0); }); }); + +describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => { + function labOf(type: string, unit: string, value = 5.4) { + const bundle = buildFhirDocumentBundle( + makeData({ + labResults: [ + { + panel: null, + analyte: type, + value, + unit, + referenceLow: null, + referenceHigh: null, + takenAt: "2026-04-20T08:00:00.000Z", + count: 1, + }, + ], + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + return observationsOf(bundle).find((o) => o.code.text?.startsWith(type)); + } + + it("emits a LOINC coding alongside code.text for a mapped analyte", () => { + const o = labOf("HbA1c", "%"); + expect(o?.code.text).toBe("HbA1c"); + expect(o?.code.coding?.[0].system).toBe("http://loinc.org"); + expect(o?.code.coding?.[0].code).toBe("4548-4"); + expect(o?.code.coding?.[0].display).toContain("Hemoglobin A1c"); + }); + + 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?.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. + 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"); + } + // German alias for total cholesterol. + const chol = labOf("Gesamtcholesterin", "mg/dL", 180); + expect(chol?.code.coding?.[0].code).toBe("2093-3"); + // German alias for creatinine. + const krea = labOf("Kreatinin", "mg/dL", 0.9); + expect(krea?.code.coding?.[0].code).toBe("2160-0"); + }); + + it("normalises equivalent unit spellings to the canonical UCUM symbol", () => { + // "mg/dl" (lower-case L) normalises to canonical "mg/dL". + const o = labOf("HDL", "mg/dl", 55); + expect(o?.code.coding?.[0].code).toBe("2085-9"); + expect(o?.valueQuantity?.code).toBe("mg/dL"); + // TSH recorded in mIU/L canonicalises to UCUM m[IU]/L. + const tsh = labOf("TSH", "mIU/L", 2.1); + expect(tsh?.code.coding?.[0].code).toBe("3016-3"); + expect(tsh?.valueQuantity?.code).toBe("m[IU]/L"); + }); + + it("omits the UCUM code when the unit does not match the mapped canonical", () => { + // HbA1c mapped (LOINC present) but recorded in an mmol/mol unit we don't + // canonicalise → keep the LOINC coding, drop the UCUM code, keep display. + const o = labOf("HbA1c", "mmol/mol", 36); + expect(o?.code.coding?.[0].code).toBe("4548-4"); + expect(o?.valueQuantity?.system).toBe("http://unitsofmeasure.org"); + expect(o?.valueQuantity?.code).toBeUndefined(); + expect(o?.valueQuantity?.unit).toBe("mmol/mol"); + }); + + it("keeps an unmapped analyte text-only with no fabricated coding", () => { + const o = labOf("Selenium", "ug/L", 95); + expect(o?.code.text).toBe("Selenium"); + expect(o?.code.coding).toBeUndefined(); + // Display unit stays; no coerced UCUM code is invented. + expect(o?.valueQuantity?.unit).toBe("ug/L"); + expect(o?.valueQuantity?.code).toBeUndefined(); + expect(o?.valueQuantity?.system).toBe("http://unitsofmeasure.org"); + }); +}); diff --git a/src/lib/fhir/illness-snomed.ts b/src/lib/fhir/illness-snomed.ts new file mode 100644 index 000000000..b6493cc1e --- /dev/null +++ b/src/lib/fhir/illness-snomed.ts @@ -0,0 +1,62 @@ +/** + * v1.18.8 — HealthLog `IllnessType` → broad SNOMED CT category concept. + * + * The condition journal records PATIENT-REPORTED categories, not clinician + * diagnoses. Each `IllnessType` therefore maps to a BROAD, well-known SNOMED + * CT category concept (a disease class), NEVER a specific diagnosis. The + * user's own free-text label rides `Condition.code.text` and the broad class + * is also surfaced as `Condition.category.text`; this coded concept is the + * honest middle ground — a downstream system reads "an infectious-disease + * class condition the patient reported", not a fabricated specific diagnosis. + * + * The builder keeps `verificationStatus: unconfirmed` and the + * "patient-reported, not a clinical diagnosis" note on every Condition, so the + * category coding can never be mistaken for a confirmed finding. + * + * Concept ids are REFERENCED (not redistributed) per the SNOMED CT licence, + * matching the existing route / body-site coding convention in `resources.ts`. + * Each id below is a real SNOMED CT International concept; the category-level + * choice is deliberate (a class, not a leaf disorder). + * + * Systems: SNOMED CT — http://snomed.info/sct + * + * No ICD-10 coding is emitted: a category-level ICD-10 chapter (e.g. "A00–B99 + * Certain infectious diseases") is a chapter RANGE, not a billable leaf code, + * and asserting a single chapter code per journal category would over-state + * what the patient recorded. SNOMED-only is the honest choice here. + */ + +/** SNOMED CT category concept for a patient-reported illness class. */ +export interface IllnessSnomedCategory { + code: string; + display: string; +} + +/** + * `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)" + * (spans both, the honest broad class for a journal entry) + * - MENTAL_HEALTH → 74732009 "Mental disorder (disorder)" + * - AUTOIMMUNE → 85828009 "Autoimmune disease (disorder)" + * - CHRONIC → 27624003 "Chronic disease (disorder)" + * - OTHER → 64572001 "Disease (disorder)" — the generic root, the + * correct fallback for an unspecified class. + * + * 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", + }, + MENTAL_HEALTH: { code: "74732009", display: "Mental disorder" }, + AUTOIMMUNE: { code: "85828009", display: "Autoimmune disease" }, + CHRONIC: { code: "27624003", display: "Chronic disease" }, + OTHER: { code: "64572001", display: "Disease" }, +}; diff --git a/src/lib/fhir/lab-loinc.ts b/src/lib/fhir/lab-loinc.ts new file mode 100644 index 000000000..9874e1b82 --- /dev/null +++ b/src/lib/fhir/lab-loinc.ts @@ -0,0 +1,308 @@ +/** + * v1.18.8 — common lab biomarker → LOINC + canonical UCUM mapping. + * + * The lab-result `analyte` and `unit` are user free-text (no closed enum), so + * the FHIR exporter has historically emitted a text-only `code` with a + * display-only `unit` — honest about what it could validate. This module + * closes the gap WITHOUT over-asserting: a curated map of the COMMON + * biomarkers the app and docs surface (the demo's 9-biomarker panel + the + * usual lipid / metabolic / liver / kidney / inflammation set). An analyte + * that resolves here gets a real LOINC `coding` ALONGSIDE the user's + * `code.text`, and the canonical UCUM `code` on the value WHEN the recorded + * unit matches (or normalises to) the mapped canonical symbol. An analyte + * that does NOT resolve keeps the exact pre-v1.18.8 text-only + unit-display + * behaviour — no fabricated terminology. + * + * Conservative by design: only biomarkers whose LOINC term is well-established + * are included. When in doubt, a biomarker is left OUT (text-only stays the + * honest default) rather than guessed. + * + * Systems: + * LOINC — http://loinc.org + * UCUM — http://unitsofmeasure.org + * + * FORWARD-COMPAT (v1.19.0 Biomarker catalog): the schema has a planned + * user-scoped `Biomarker` catalog (`LabResult.biomarkerId`, NULL today). Once + * a backfill links rows to a canonical analyte + unit, the resolved canonical + * name should drive this lookup AHEAD of the raw free-text. `resolveLabCoding` + * therefore accepts an optional `canonicalName` (the catalog's resolved name) + * which takes precedence over the free-text `analyte`; the lab DTO does not + * yet carry that field, so the call site passes only `analyte` today. When the + * DTO gains a `biomarkerCanonicalName` (or similar), thread it in — no other + * change to this module is required. + */ + +import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map"; + +export { LOINC_SYSTEM, UCUM_SYSTEM }; + +/** A curated biomarker → LOINC + canonical-UCUM entry. */ +export interface LabLoincMapping { + loinc: string; + display: string; + /** Canonical UCUM symbol for this biomarker's value. */ + ucum: string; +} + +/** + * Normalise an analyte / unit string to a lookup key: lower-case, strip every + * character that is not a letter or digit. So "LDL-C", "LDL Cholesterol", + * "ldl_c" all fold to `ldlc`-ish keys; the alias table below pins the exact + * variants the app and German users actually type onto a single canonical key. + */ +export function normaliseLabKey(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +/** + * Curated map keyed by the CANONICAL normalised analyte key. Aliases (below) + * fold the many ways a user can spell a biomarker onto these keys. Each entry + * is a well-established LOINC term with the conventional canonical UCUM unit. + */ +const LAB_LOINC_BY_KEY: Record = { + hba1c: { + loinc: "4548-4", + display: "Hemoglobin A1c/Hemoglobin.total in Blood", + ucum: "%", + }, + ldlc: { + loinc: "18262-6", + display: "Cholesterol in LDL [Mass/volume] in Serum or Plasma", + ucum: "mg/dL", + }, + hdlc: { + loinc: "2085-9", + display: "Cholesterol in HDL [Mass/volume] in Serum or Plasma", + ucum: "mg/dL", + }, + cholesterol: { + loinc: "2093-3", + display: "Cholesterol [Mass/volume] in Serum or Plasma", + ucum: "mg/dL", + }, + triglycerides: { + loinc: "2571-8", + display: "Triglyceride [Mass/volume] in Serum or Plasma", + ucum: "mg/dL", + }, + ferritin: { + loinc: "2276-4", + display: "Ferritin [Mass/volume] in Serum or Plasma", + ucum: "ug/L", + }, + tsh: { + loinc: "3016-3", + display: "Thyrotropin [Units/volume] in Serum or Plasma", + ucum: "m[IU]/L", + }, + vitamind: { + loinc: "1989-3", + display: + "25-hydroxyvitamin D2+25-hydroxyvitamin D3 [Mass/volume] in Serum or Plasma", + ucum: "ng/mL", + }, + creatinine: { + loinc: "2160-0", + display: "Creatinine [Mass/volume] in Serum or Plasma", + ucum: "mg/dL", + }, + egfr: { + loinc: "33914-3", + display: + "Glomerular filtration rate/1.73 sq M.predicted by Creatinine-based formula (MDRD)", + ucum: "mL/min/{1.73_m2}", + }, + alt: { + loinc: "1742-6", + display: + "Alanine aminotransferase [Enzymatic activity/volume] in Serum or Plasma", + ucum: "U/L", + }, + ast: { + loinc: "1920-8", + display: + "Aspartate aminotransferase [Enzymatic activity/volume] in Serum or Plasma", + ucum: "U/L", + }, + crp: { + loinc: "1988-5", + display: "C reactive protein [Mass/volume] in Serum or Plasma", + ucum: "mg/L", + }, + glucosefasting: { + loinc: "1558-6", + display: "Fasting glucose [Mass/volume] in Serum or Plasma", + ucum: "mg/dL", + }, + hemoglobin: { + loinc: "718-7", + display: "Hemoglobin [Mass/volume] in Blood", + ucum: "g/dL", + }, +}; + +/** + * Alias → canonical key. The LEFT side is the normalised form of a name a user + * (EN or DE) might type; the RIGHT side is a key in `LAB_LOINC_BY_KEY`. Every + * canonical key is also implicitly its own alias (looked up directly first). + * Keep the right side honest — only fold a synonym when it unambiguously names + * the same analyte. + */ +const LAB_ALIASES: Record = { + // HbA1c + a1c: "hba1c", + glycatedhemoglobin: "hba1c", + glycohemoglobin: "hba1c", + hbalc: "hba1c", + // LDL + ldl: "ldlc", + ldlcholesterol: "ldlc", + ldlchol: "ldlc", + // HDL + hdl: "hdlc", + hdlcholesterol: "hdlc", + hdlchol: "hdlc", + // Total cholesterol + totalcholesterol: "cholesterol", + cholesterintotal: "cholesterol", + gesamtcholesterin: "cholesterol", + chol: "cholesterol", + // Triglycerides + triglyceride: "triglycerides", + trig: "triglycerides", + triglyceride2: "triglycerides", + tg: "triglycerides", + // TSH + thyrotropin: "tsh", + thyroidstimulatinghormone: "tsh", + // Vitamin D + vitd: "vitamind", + vit25ohd: "vitamind", + "25ohd": "vitamind", + "25hydroxyvitamind": "vitamind", + vitamind3: "vitamind", + // Creatinine + kreatinin: "creatinine", + creat: "creatinine", + // eGFR + gfr: "egfr", + estimatedgfr: "egfr", + // ALT + gpt: "alt", + alat: "alt", + alaninetransaminase: "alt", + // AST + got: "ast", + asat: "ast", + aspartatetransaminase: "ast", + // CRP + creactiveprotein: "crp", + // Fasting glucose + fastingglucose: "glucosefasting", + glucosefast: "glucosefasting", + nuchternglucose: "glucosefasting", + // Hemoglobin + haemoglobin: "hemoglobin", + hb: "hemoglobin", + hgb: "hemoglobin", + hamoglobin: "hemoglobin", +}; + +/** + * Canonical UCUM symbols that a recorded free-text unit may normalise to. The + * LEFT is the normalised (lower-cased, punctuation-stripped) recorded unit; + * the RIGHT is the canonical UCUM symbol. Used to decide whether the lab's + * unit MATCHES the mapped biomarker's canonical UCUM so the UCUM `code` can be + * stamped. A non-matching unit keeps the value's `unit` display only (no UCUM + * `code`) — never a coerced value. + */ +const UNIT_NORMALISATION: Record = { + // percent + "%": "%", + pct: "%", + percent: "%", + // mg/dL family + mgdl: "mg/dL", + "mg/dl": "mg/dL", + // mg/L + mgl: "mg/L", + "mg/l": "mg/L", + // ng/mL + ngml: "ng/mL", + "ng/ml": "ng/mL", + // ug/L (ferritin) + ugl: "ug/L", + "ug/l": "ug/L", + "µg/l": "ug/L", + "mcg/l": "ug/L", + ngml2: "ng/mL", + "ng/ml2": "ng/mL", + // g/dL (hemoglobin) + gdl: "g/dL", + "g/dl": "g/dL", + // U/L (enzymes) + ul: "U/L", + "u/l": "U/L", + iul: "U/L", + "iu/l": "U/L", + // mIU/L (TSH) + miul: "m[IU]/L", + "miu/l": "m[IU]/L", + uiuml: "m[IU]/L", + "µiu/ml": "m[IU]/L", + "uiu/ml": "m[IU]/L", + // eGFR rate + "mlmin173m2": "mL/min/{1.73_m2}", + "ml/min/1.73m2": "mL/min/{1.73_m2}", + "ml/min/1.73sqm": "mL/min/{1.73_m2}", +}; + +/** Normalise a recorded unit string for canonical-UCUM matching. */ +function normaliseUnit(unit: string): string { + const trimmed = unit.trim(); + // Try the verbatim-lowercase form first (covers "mg/dL" → "mg/dl"), then the + // punctuation-stripped form (covers "mg dl" / "mgdl"). Both routes resolve + // through the same table so equivalent spellings collapse. + const lower = trimmed.toLowerCase(); + if (UNIT_NORMALISATION[lower]) return UNIT_NORMALISATION[lower]; + const stripped = normaliseLabKey(trimmed); + return UNIT_NORMALISATION[stripped] ?? trimmed; +} + +/** The resolved coding for one lab result, or null when the analyte is unmapped. */ +export interface ResolvedLabCoding { + loinc: string; + display: string; + /** + * Canonical UCUM symbol to stamp on the value, ONLY when the recorded unit + * matches the mapped biomarker's canonical UCUM. Null when the unit does not + * match (display-only `unit` is kept, no coerced UCUM `code`). + */ + ucum: string | null; +} + +/** + * Resolve a lab result's LOINC coding + canonical UCUM. + * + * Precedence: the v1.19.0 catalog's resolved `canonicalName` (when the DTO + * carries it — it does not today) wins over the raw free-text `analyte`. The + * `unit` is matched against the mapped biomarker's canonical UCUM; only an + * exact (post-normalisation) match yields a UCUM `code`. + * + * Returns null when the analyte does not resolve — the caller then keeps the + * honest text-only + display-unit behaviour (no fabricated codes). + */ +export function resolveLabCoding( + analyte: string, + unit: string, + canonicalName?: string | null, +): ResolvedLabCoding | null { + const source = canonicalName && canonicalName.length > 0 ? canonicalName : analyte; + const key = normaliseLabKey(source); + const resolvedKey = LAB_LOINC_BY_KEY[key] ? key : LAB_ALIASES[key]; + if (!resolvedKey) return null; + const mapping = LAB_LOINC_BY_KEY[resolvedKey]; + if (!mapping) return null; + const ucum = normaliseUnit(unit) === mapping.ucum ? mapping.ucum : null; + return { loinc: mapping.loinc, display: mapping.display, ucum }; +} diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts index 7c42f3771..582fe6b6e 100644 --- a/src/lib/fhir/resources.ts +++ b/src/lib/fhir/resources.ts @@ -43,6 +43,8 @@ import { PERIOD_LENGTH_LOINC, type LoincMapping, } from "@/lib/fhir/loinc-map"; +import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed"; +import { resolveLabCoding } from "@/lib/fhir/lab-loinc"; import type { FhirCodeableConcept, FhirObservation, @@ -627,13 +629,15 @@ export function observationsFromReportData( } } - // --- Lab-result Observations (v1.17.1) --------------------------------- - // One `laboratory` Observation per recorded analyte. `analyte` + `unit` - // are user-recorded free text (no closed LOINC enum), so the code is a - // text-only `CodeableConcept` — honest about what we have rather than - // asserting a LOINC mapping we can't validate. The lab's reference bounds - // map onto R4 `referenceRange`. UCUM `code` is omitted (the free-text - // unit can't be asserted as a UCUM symbol); `unit` carries the display. + // --- Lab-result Observations (v1.17.1; LOINC + UCUM v1.18.8) ----------- + // One `laboratory` Observation per recorded analyte. `analyte` + `unit` are + // user-recorded free text (no closed LOINC enum). v1.18.8 — a curated + // analyte→LOINC map (`resolveLabCoding`) adds a real LOINC `coding` ALONGSIDE + // the user's `code.text` for the common biomarkers, and stamps the canonical + // UCUM `code` on the value WHEN the recorded unit matches the mapped + // canonical symbol. An analyte that does NOT resolve keeps the honest + // text-only `code` + display-only `unit` (no fabricated terminology). The + // lab's reference bounds map onto R4 `referenceRange`. for (const lab of data.labResults ?? []) { obsSeq += 1; const referenceRange = @@ -649,20 +653,33 @@ export function observationsFromReportData( }, ] : undefined; + // Forward-compat: when the v1.19.0 Biomarker catalog lands and the DTO + // carries a resolved canonical analyte name, thread it as the third arg so + // the catalog name wins over the free-text `analyte`. Absent today. + const coding = resolveLabCoding(lab.analyte, lab.unit); + const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte; push({ resourceType: "Observation", id: `obs-${obsSeq}`, status: "final", category: [categoryConcept("laboratory")], - code: { - text: lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte, - }, + code: coding + ? { + coding: [ + { system: LOINC_SYSTEM, code: coding.loinc, display: coding.display }, + ], + text, + } + : { text }, subject: patientRef, effectiveDateTime: lab.takenAt, valueQuantity: { value: lab.value, unit: lab.unit, system: UCUM_SYSTEM, + // Canonical UCUM `code` only when the recorded unit matches; otherwise + // the display `unit` stands alone (no coerced symbol). + ...(coding?.ucum ? { code: coding.ucum } : {}), }, ...(referenceRange ? { referenceRange } : {}), }); @@ -899,9 +916,12 @@ export function cycleObservationsFromReportData( * SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept. * HealthLog records a patient-kept condition journal; it is NOT a diagnosing * device and asserts no specific ICD/SNOMED diagnosis. The user's own label - * rides `code.text` and the broad class is a `category`; the coded `code` - * stays this safe root so a downstream system never reads a fabricated - * diagnosis off the export. + * rides `code.text`. v1.18.8 — the coded `code` now carries the BROAD SNOMED + * CT category concept for the episode's `IllnessType` (see + * `ILLNESS_TYPE_SNOMED`); an unknown / future type falls back to this generic + * root so a downstream system never reads a fabricated SPECIFIC diagnosis off + * the export. The `verificationStatus: unconfirmed` + "patient-reported" + * guard rails stay on every Condition. */ const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const; @@ -969,11 +989,14 @@ export function conditionsFromReportData(data: DoctorReportData): { }, ], code: { + // Broad SNOMED CT category for the journal type; the generic "Disease" + // root is the honest fallback for an unknown / future type. NEVER a + // specific diagnosis — the user's own label stays on `.text`. coding: [ { system: SNOMED_SYSTEM, - code: DISEASE_SNOMED.code, - display: DISEASE_SNOMED.display, + code: (ILLNESS_TYPE_SNOMED[ep.type] ?? DISEASE_SNOMED).code, + display: (ILLNESS_TYPE_SNOMED[ep.type] ?? DISEASE_SNOMED).display, }, ], text: ep.label,