diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7edb69d..88a560799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## [1.18.8] — 2026-06-20 — coded condition and lab terminology in the health-record export + +An additive export-only release: the FHIR R4 health record now carries standard terminology codes where it can assert them honestly. No migration, no API contract change. + +### Changed + +- Condition resources for journal entries carry a SNOMED CT category coding per illness type (infection, allergy, injury, mental health, autoimmune, chronic, other) alongside the user's free-text label. These stay broad categories, not diagnoses — the patient-reported, unconfirmed guard rails are unchanged. +- Lab Observations now emit a LOINC code and a canonical UCUM unit for the common biomarkers (HbA1c, lipid panel, ferritin, TSH, vitamin D, creatinine, eGFR, liver enzymes, CRP, fasting glucose, hemoglobin), resolved through an analyte alias table. An unrecognised analyte, or a unit that does not match the canonical UCUM symbol, keeps the honest text-only behaviour rather than asserting a code it cannot validate. + ## [1.18.7] — 2026-06-19 — dashboard, Coach and insights polish A broad UI, insights, and AI-efficiency release. It ships database migrations (see the migration note below) and two client-facing contract changes: Coach budget responses now arrive as a stream error rather than a 429, and the measurement-reminder push carries the reminder id. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 10a6ed223..12e8aed5c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.18.7 + version: 1.18.8 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index abe773b83..43357b31a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.18.7", + "version": "1.18.8", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 4ceb3436e..dff137972 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.18.6.1"; + /* @sw-version-fallback */ "v1.18.8"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` 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..7493cb545 --- /dev/null +++ b/src/lib/fhir/lab-loinc.ts @@ -0,0 +1,309 @@ +/** + * 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..68cbda343 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,37 @@ 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 +920,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 +993,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,