fix(fhir): correct the LOINC and SNOMED CT terms the export sends

A run of the HL7 validator against every code the export can emit found
terms that do not exist, are inactive, mean something else, or carry a
display the code system does not publish.

- Body water and bone mass use 101683-1 and 101685-6; the old codes are a
  nasogastric fluid output and a calculated body-fat mass.
- Step length, walking speed, walking asymmetry and VO2 max ride their
  HealthKit identifiers: LOINC has no fitting term, and two of the old
  codes do not exist.
- Mood, estimated A1C, average cycle length and average period length are
  text-only. The old codes did not exist or were questionnaire items.
- LDL, vitamin D and eGFR use the method-less and total terms (2089-1,
  62292-8, 69405-9) a recorded value can honestly claim.
- Resting heart rate and pulse-oximetry SpO2 also carry the heart-rate and
  oxygen-saturation codes the R4 vital-signs profiles require.
- Blood pressure, adherence, glucose-panel and injection-site codings carry
  the published displays; ATC codings carry none, since the user's
  medication name is not the ATC name. The name stays on the text.
- Mood is a survey finding, not a vital sign, so it no longer rides the
  vital-signs panel.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 13:44:10 +02:00
1 parent 44abc6f1a0
commit 0ac5377cc2
15 files changed
+393 -128

No files matched your search

+36 -21
View File
@@ -493,11 +493,11 @@ describe("buildFhirDocumentBundle", () => {
const coding = stmt?.medicationCodeableConcept.coding;
expect(stmt?.medicationCodeableConcept.text).toBe("Mounjaro");
expect(coding).toHaveLength(2);
// ATC is primary (first), with the name as display.
// ATC is primary (first). No display: the user's name is not the ATC
// name for the code, and the validator rejects it; the name is `.text`.
expect(coding?.[0]).toEqual({
system: "http://www.whocc.no/atc",
code: "A10BX10",
display: "Mounjaro",
});
// RxNorm is secondary, no display.
expect(coding?.[1]).toEqual({
@@ -578,7 +578,7 @@ describe("buildFhirDocumentBundle", () => {
expect(a.dosage?.site?.coding?.[0]).toEqual({
system: "http://snomed.info/sct",
code: "818983003",
display: "Abdomen structure",
display: "Abdomen",
});
});
@@ -712,14 +712,14 @@ describe("buildFhirDocumentBundle", () => {
it("maps every InjectionSite enum value to its body-region SNOMED concept, preserving laterality in `.text`", () => {
const cases: Array<[string, string, string]> = [
["ABDOMEN_LEFT", "818983003", "Abdomen structure"],
["ABDOMEN_RIGHT", "818983003", "Abdomen structure"],
["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen structure"],
["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen structure"],
["ABDOMEN_LEFT", "818983003", "Abdomen"],
["ABDOMEN_RIGHT", "818983003", "Abdomen"],
["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen"],
["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen"],
["THIGH_LEFT", "68367000", "Thigh structure"],
["THIGH_RIGHT", "68367000", "Thigh structure"],
["UPPER_ARM_LEFT", "40983000", "Structure of upper arm"],
["UPPER_ARM_RIGHT", "40983000", "Structure of upper arm"],
["UPPER_ARM_LEFT", "40983000", "Upper arm structure"],
["UPPER_ARM_RIGHT", "40983000", "Upper arm structure"],
];
for (const [site, code, display] of cases) {
const bundle = buildFhirDocumentBundle(
@@ -846,13 +846,12 @@ describe("buildFhirDocumentBundle", () => {
expect(stmtCoding?.[0]).toEqual({
system: "http://www.whocc.no/atc",
code: "A10BK03",
display: "Empagliflozin",
});
expect(stmtCoding?.[1]).toEqual({
system: "http://fhir.de/CodeSystem/bfarm/atc",
code: "A10BK03",
display: "Empagliflozin",
});
expect(stmt?.medicationCodeableConcept.text).toBe("Empagliflozin");
// The administration concept reflects the flag identically; RxNorm follows.
const adminCoding =
@@ -909,8 +908,12 @@ describe("buildFhirDocumentBundle", () => {
const moodObs = bundle.entry
.map((e) => e.resource)
.filter((r): r is FhirObservation => r.resourceType === "Observation")
.find((o) => o.code.coding?.some((c) => c.code === "76542-6"));
.find((o) => o.code.text === "Mood (average over period)");
expect(moodObs?.valueQuantity?.value).toBe(3.6);
// 76542-6 does not exist in LOINC: text-only, and a survey finding, not
// a vital sign (so the vital-signs panel does not claim it).
expect(moodObs?.code.coding).toBeUndefined();
expect(moodObs?.category?.[0]?.coding?.[0]?.code).toBe("survey");
});
it("emits no Observation for a type with no readings (empty domain)", () => {
@@ -953,7 +956,7 @@ describe("buildFhirDocumentBundle", () => {
expect(spo2?.valueQuantity?.code).toBe("%");
});
it("maps VO2max to 96402-2 with the iOS display", () => {
it("maps VO2max to the HealthKit identifier, not the nonexistent LOINC 96402-2", () => {
const bundle = buildFhirDocumentBundle(
makeData({
measurements: {
@@ -963,10 +966,15 @@ describe("buildFhirDocumentBundle", () => {
{ insuranceNumber: null },
FIXED_NOW,
);
const coding = observationsOf(bundle)
.flatMap((o) => o.code.coding ?? [])
.find((c) => c.code === "96402-2");
expect(coding?.display).toBe("Oxygen consumption maximum during exercise");
const codings = observationsOf(bundle).flatMap((o) => o.code.coding ?? []);
expect(codings.some((c) => c.code === "96402-2")).toBe(false);
const coding = codings.find(
(c) => c.code === "HKQuantityTypeIdentifierVO2Max",
);
expect(coding?.system).toBe(
"https://healthlog.dev/fhir/CodeSystem/healthkit",
);
expect(coding?.display).toBe("VO2 max (estimated)");
});
it("emits steps with UCUM {steps}", () => {
@@ -1008,7 +1016,7 @@ describe("buildFhirDocumentBundle", () => {
expect(sleep?.valueQuantity?.value).toBe(7.5);
});
it("maps body water + bone mass to the iOS-locked LOINC codes", () => {
it("maps body water + bone mass to the LOINC body-composition terms", () => {
const bundle = buildFhirDocumentBundle(
makeData({
measurements: {
@@ -1022,10 +1030,14 @@ describe("buildFhirDocumentBundle", () => {
FIXED_NOW,
);
const water = observationsOf(bundle).find((o) =>
o.code.coding?.some((c) => c.code === "73704-9"),
o.code.coding?.some(
(c) => c.code === "101683-1" && c.display === "Body water mass",
),
);
const bone = observationsOf(bundle).find((o) =>
o.code.coding?.some((c) => c.code === "73708-0"),
o.code.coding?.some(
(c) => c.code === "101685-6" && c.display === "Body bone mass",
),
);
expect(water?.valueQuantity?.code).toBe("kg");
expect(bone?.valueQuantity?.code).toBe("kg");
@@ -1049,8 +1061,11 @@ describe("buildFhirDocumentBundle", () => {
const energy = observationsOf(bundle).find((o) =>
o.code.coding?.some((c) => c.code === "41981-2"),
);
// Not 41957-2 (a 24-hour mean): a HealthKit walking-speed reading.
const speed = observationsOf(bundle).find((o) =>
o.code.coding?.some((c) => c.code === "41957-2"),
o.code.coding?.some(
(c) => c.code === "HKQuantityTypeIdentifierWalkingSpeed",
),
);
expect(energy?.valueQuantity?.code).toBe("kcal");
// Walking speed FHIR value stays m/s (no km/h conversion).
@@ -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>): 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),
@@ -6,7 +6,6 @@ import {
GLUCOSE_TIR_LOINC,
GLUCOSE_GMI_LOINC,
GLUCOSE_MEAN_LOINC,
GLUCOSE_EA1C_LOINC,
} from "@/lib/fhir/loinc-map";
import { buildFhirDocumentBundle } from "../build-bundle";
@@ -89,7 +88,28 @@ describe("clinical glucose-panel FHIR Observations", () => {
expect(codes).toContain(GLUCOSE_TIR_LOINC);
expect(codes).toContain(GLUCOSE_GMI_LOINC);
expect(codes).toContain(GLUCOSE_MEAN_LOINC);
expect(codes).toContain(GLUCOSE_EA1C_LOINC);
// Each coded term carries the display LOINC publishes for it.
const displayOf = (code: string) =>
observationsOf(bundle)
.flatMap((o) => o.code.coding ?? [])
.find((c) => c.code === code)?.display;
expect(displayOf(GLUCOSE_TIR_LOINC)).toBe(
"Glucose measurements in range out of Total glucose measurements during reporting period",
);
expect(displayOf(GLUCOSE_GMI_LOINC)).toBe("Glucose management indicator");
expect(displayOf(GLUCOSE_MEAN_LOINC)).toBe(
"Average glucose [Mass/volume] in Interstitial fluid during Reporting Period",
);
// Estimated A1C is text-only: 41995-2, used before, is a measured
// "Hemoglobin A1c [Mass/volume] in Blood".
const ea1c = observationsOf(bundle).find(
(o) => o.code.text === "Estimated A1C",
);
expect(ea1c).toBeDefined();
expect(ea1c?.code.coding).toBeUndefined();
expect(codes).not.toContain("41995-2");
});
it("emits a percent-valued, UCUM-coded TIR Observation", () => {
@@ -122,6 +142,8 @@ describe("clinical glucose-panel FHIR Observations", () => {
expect(codes).not.toContain(GLUCOSE_TIR_LOINC);
expect(codes).not.toContain(GLUCOSE_GMI_LOINC);
expect(codes).not.toContain(GLUCOSE_MEAN_LOINC);
expect(codes).not.toContain(GLUCOSE_EA1C_LOINC);
expect(
observationsOf(bundle).some((o) => o.code.text === "Estimated A1C"),
).toBe(false);
});
});
@@ -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"],
@@ -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);
@@ -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<DoctorReportData>): 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);
});
});
+10 -5
View File
@@ -79,8 +79,10 @@ const LAB_LOINC_BY_KEY: Record<string, LabLoincMapping> = {
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<string, LabLoincMapping> = {
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<string, LabLoincMapping> = {
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: {
+11 -8
View File
@@ -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.
*/
+26 -1
View File
@@ -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,
};
}
+8 -28
View File
@@ -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"),
+23 -7
View File
@@ -15,7 +15,6 @@ import {
GLUCOSE_TIR_LOINC,
GLUCOSE_GMI_LOINC,
GLUCOSE_MEAN_LOINC,
GLUCOSE_EA1C_LOINC,
} from "@/lib/fhir/loinc-map";
import { ucumQuantity } from "@/lib/fhir/ucum";
import type { FhirObservation } from "@/lib/fhir/types";
@@ -92,7 +91,8 @@ export function glucoseClinicalObservations(
const observations: FhirObservation[] = [];
const push = (opts: {
loinc?: string;
/** LOINC code and its LOINC display; absent for a text-only concept. */
loinc?: { code: string; display: string };
text: string;
value: number;
unit: string;
@@ -106,7 +106,11 @@ export function glucoseClinicalObservations(
code: opts.loinc
? {
coding: [
{ system: LOINC_SYSTEM, code: opts.loinc, display: opts.text },
{
system: LOINC_SYSTEM,
code: opts.loinc.code,
display: opts.loinc.display,
},
],
text: opts.text,
}
@@ -128,7 +132,11 @@ export function glucoseClinicalObservations(
if (clinical.distribution) {
push({
loinc: GLUCOSE_TIR_LOINC,
loinc: {
code: GLUCOSE_TIR_LOINC,
display:
"Glucose measurements in range out of Total glucose measurements during reporting period",
},
text: timeInRangeDisplay(glucoseUnit),
value: Math.round(clinical.distribution.tir * 1000) / 10,
unit: "%",
@@ -136,7 +144,11 @@ export function glucoseClinicalObservations(
}
if (clinical.meanMgdl !== null) {
push({
loinc: GLUCOSE_MEAN_LOINC,
loinc: {
code: GLUCOSE_MEAN_LOINC,
display:
"Average glucose [Mass/volume] in Interstitial fluid during Reporting Period",
},
text: "Mean glucose",
value: convertGlucose(clinical.meanMgdl, glucoseUnit),
unit: glucoseUnit,
@@ -144,7 +156,10 @@ export function glucoseClinicalObservations(
}
if (clinical.gmi !== null) {
push({
loinc: GLUCOSE_GMI_LOINC,
loinc: {
code: GLUCOSE_GMI_LOINC,
display: "Glucose management indicator",
},
text: "Glucose Management Indicator (GMI)",
value: Math.round(clinical.gmi * 10) / 10,
unit: "%",
@@ -152,7 +167,8 @@ export function glucoseClinicalObservations(
}
if (clinical.estimatedA1c !== null) {
push({
loinc: GLUCOSE_EA1C_LOINC,
// Text-only: no LOINC term exists for an A1C estimated from mean
// glucose (see `loinc-map.ts`).
text: "Estimated A1C",
value: Math.round(clinical.estimatedA1c * 10) / 10,
unit: "%",
+11 -8
View File
@@ -35,10 +35,13 @@ function medicationConcept(
germanAtc: boolean,
): FhirCodeableConcept {
const coding: NonNullable<FhirCodeableConcept["coding"]> = [];
// 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<string, { code: string; display: string }> = {
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 {
+18 -11
View File
@@ -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(
@@ -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"
},
+21 -11
View File
@@ -574,8 +574,11 @@ export const SIGNALS: Record<string, SignalDefinition> = {
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<string, SignalDefinition> = {
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<string, SignalDefinition> = {
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<string, SignalDefinition> = {
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<string, SignalDefinition> = {
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<string, SignalDefinition> = {
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",
},