mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(fhir): code illness conditions and lab observations
Close the three FHIR R4 coding gaps in the health-record export. Both are export-layer only — no schema column, no migration, no contract change. Illness Conditions now carry a broad SNOMED CT category concept per IllnessType (infectious disease, allergy, injury, mental disorder, autoimmune disease, chronic disease, with the generic Disease root as the fallback for an unknown type) instead of the single generic placeholder. The user's own label stays on code.text, the class on category.text, and the unconfirmed + patient-reported guard rails are unchanged — the export still asserts no specific diagnosis. Lab Observations resolve a curated analyte to a real LOINC coding alongside the user's code.text for the common biomarkers (HbA1c, lipid panel, ferritin, TSH, vitamin D, creatinine, eGFR, ALT, AST, CRP, fasting glucose, hemoglobin), with analyte-key and EN/DE alias normalisation. The canonical UCUM code is stamped on the value only when the recorded unit matches the mapped canonical symbol; an unmapped analyte or a non-matching unit keeps the honest text-only code and display-only unit, never a fabricated code. The resolver takes an optional canonical-name argument so a future Biomarker-catalog link can take precedence over the free-text analyte without a further change here.
This commit is contained in:
1 parent
69d03c45c4
commit
9a29de4b02
6 files changed
+614
-18
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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>): 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<typeof buildFhirDocumentBundle>) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, IllnessSnomedCategory> = {
|
||||
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" },
|
||||
};
|
||||
@@ -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<string, LabLoincMapping> = {
|
||||
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<string, string> = {
|
||||
// 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<string, string> = {
|
||||
// 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 };
|
||||
}
|
||||
+38
-15
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user