Merge branch 'feat/fhir-condition-body-site' into release/v1.39.3

This commit is contained in:
Marc-André Bombeck committed 2026-09-26 17:00:45 +02:00
commit 58275aee1a
25 files changed
+859 -175

No files matched your search

+4 -3
View File
@@ -1478,9 +1478,10 @@ paths:
summary: FHIR R4 $everything (v1.11.0)
description: "Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device,
Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations,
Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one
`searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing.
Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`."
Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the
window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report
selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count`
(≤200) / `_offset`."
parameters:
- in: query
name: _count
@@ -5,7 +5,8 @@
* the Composition, the authoring Device, Patient, Coverage, every Observation
* (vitals / glucose / labs / adherence / mood / wellness AND the cycle set),
* MedicationStatements, MedicationAdministrations, Conditions, Encounters,
* AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport.
* AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present
* when the window holds a vital sign).
*
* The set is the document export's, flattened — same builder, same order, same
* `urn:uuid` entry identities, so a reference between two resources still
+88 -21
View File
@@ -9,6 +9,7 @@ import type {
FhirPatient,
FhirCoverage,
FhirDiagnosticReport,
FhirComposition,
} from "../types";
function administrationsOf(
@@ -492,11 +493,11 @@ describe("buildFhirDocumentBundle", () => {
const coding = stmt?.medicationCodeableConcept.coding;
expect(stmt?.medicationCodeableConcept.text).toBe("Mounjaro");
expect(coding).toHaveLength(2);
// ATC is primary (first), with the name as display.
// ATC is primary (first). No display: the user's name is not the ATC
// name for the code, and the validator rejects it; the name is `.text`.
expect(coding?.[0]).toEqual({
system: "http://www.whocc.no/atc",
code: "A10BX10",
display: "Mounjaro",
});
// RxNorm is secondary, no display.
expect(coding?.[1]).toEqual({
@@ -577,7 +578,7 @@ describe("buildFhirDocumentBundle", () => {
expect(a.dosage?.site?.coding?.[0]).toEqual({
system: "http://snomed.info/sct",
code: "818983003",
display: "Abdomen structure",
display: "Abdomen",
});
});
@@ -711,14 +712,14 @@ describe("buildFhirDocumentBundle", () => {
it("maps every InjectionSite enum value to its body-region SNOMED concept, preserving laterality in `.text`", () => {
const cases: Array<[string, string, string]> = [
["ABDOMEN_LEFT", "818983003", "Abdomen structure"],
["ABDOMEN_RIGHT", "818983003", "Abdomen structure"],
["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen structure"],
["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen structure"],
["ABDOMEN_LEFT", "818983003", "Abdomen"],
["ABDOMEN_RIGHT", "818983003", "Abdomen"],
["ABDOMEN_UPPER_LEFT", "818983003", "Abdomen"],
["ABDOMEN_UPPER_RIGHT", "818983003", "Abdomen"],
["THIGH_LEFT", "68367000", "Thigh structure"],
["THIGH_RIGHT", "68367000", "Thigh structure"],
["UPPER_ARM_LEFT", "40983000", "Structure of upper arm"],
["UPPER_ARM_RIGHT", "40983000", "Structure of upper arm"],
["UPPER_ARM_LEFT", "40983000", "Upper arm structure"],
["UPPER_ARM_RIGHT", "40983000", "Upper arm structure"],
];
for (const [site, code, display] of cases) {
const bundle = buildFhirDocumentBundle(
@@ -845,13 +846,12 @@ describe("buildFhirDocumentBundle", () => {
expect(stmtCoding?.[0]).toEqual({
system: "http://www.whocc.no/atc",
code: "A10BK03",
display: "Empagliflozin",
});
expect(stmtCoding?.[1]).toEqual({
system: "http://fhir.de/CodeSystem/bfarm/atc",
code: "A10BK03",
display: "Empagliflozin",
});
expect(stmt?.medicationCodeableConcept.text).toBe("Empagliflozin");
// The administration concept reflects the flag identically; RxNorm follows.
const adminCoding =
@@ -908,8 +908,12 @@ describe("buildFhirDocumentBundle", () => {
const moodObs = bundle.entry
.map((e) => e.resource)
.filter((r): r is FhirObservation => r.resourceType === "Observation")
.find((o) => o.code.coding?.some((c) => c.code === "76542-6"));
.find((o) => o.code.text === "Mood (average over period)");
expect(moodObs?.valueQuantity?.value).toBe(3.6);
// 76542-6 does not exist in LOINC: text-only, and a survey finding, not
// a vital sign (so the vital-signs panel does not claim it).
expect(moodObs?.code.coding).toBeUndefined();
expect(moodObs?.category?.[0]?.coding?.[0]?.code).toBe("survey");
});
it("emits no Observation for a type with no readings (empty domain)", () => {
@@ -952,7 +956,7 @@ describe("buildFhirDocumentBundle", () => {
expect(spo2?.valueQuantity?.code).toBe("%");
});
it("maps VO2max to 96402-2 with the iOS display", () => {
it("maps VO2max to the HealthKit identifier, not the nonexistent LOINC 96402-2", () => {
const bundle = buildFhirDocumentBundle(
makeData({
measurements: {
@@ -962,10 +966,15 @@ describe("buildFhirDocumentBundle", () => {
{ insuranceNumber: null },
FIXED_NOW,
);
const coding = observationsOf(bundle)
.flatMap((o) => o.code.coding ?? [])
.find((c) => c.code === "96402-2");
expect(coding?.display).toBe("Oxygen consumption maximum during exercise");
const codings = observationsOf(bundle).flatMap((o) => o.code.coding ?? []);
expect(codings.some((c) => c.code === "96402-2")).toBe(false);
const coding = codings.find(
(c) => c.code === "HKQuantityTypeIdentifierVO2Max",
);
expect(coding?.system).toBe(
"https://healthlog.dev/fhir/CodeSystem/healthkit",
);
expect(coding?.display).toBe("VO2 max (estimated)");
});
it("emits steps with UCUM {steps}", () => {
@@ -1007,7 +1016,7 @@ describe("buildFhirDocumentBundle", () => {
expect(sleep?.valueQuantity?.value).toBe(7.5);
});
it("maps body water + bone mass to the iOS-locked LOINC codes", () => {
it("maps body water + bone mass to the LOINC body-composition terms", () => {
const bundle = buildFhirDocumentBundle(
makeData({
measurements: {
@@ -1021,10 +1030,14 @@ describe("buildFhirDocumentBundle", () => {
FIXED_NOW,
);
const water = observationsOf(bundle).find((o) =>
o.code.coding?.some((c) => c.code === "73704-9"),
o.code.coding?.some(
(c) => c.code === "101683-1" && c.display === "Body water mass",
),
);
const bone = observationsOf(bundle).find((o) =>
o.code.coding?.some((c) => c.code === "73708-0"),
o.code.coding?.some(
(c) => c.code === "101685-6" && c.display === "Body bone mass",
),
);
expect(water?.valueQuantity?.code).toBe("kg");
expect(bone?.valueQuantity?.code).toBe("kg");
@@ -1048,8 +1061,11 @@ describe("buildFhirDocumentBundle", () => {
const energy = observationsOf(bundle).find((o) =>
o.code.coding?.some((c) => c.code === "41981-2"),
);
// Not 41957-2 (a 24-hour mean): a HealthKit walking-speed reading.
const speed = observationsOf(bundle).find((o) =>
o.code.coding?.some((c) => c.code === "41957-2"),
o.code.coding?.some(
(c) => c.code === "HKQuantityTypeIdentifierWalkingSpeed",
),
);
expect(energy?.valueQuantity?.code).toBe("kcal");
// Walking speed FHIR value stays m/s (no km/h conversion).
@@ -1172,6 +1188,57 @@ describe("buildFhirDocumentBundle", () => {
);
});
it("emits no DiagnosticReport, and no empty array anywhere, when the window holds no vital sign", () => {
const bundle = buildFhirDocumentBundle(
makeData({ measurements: {}, bmi: null }),
{ insuranceNumber: null },
FIXED_NOW,
);
const types = bundle.entry.map((e) => e.resource.resourceType);
expect(types).not.toContain("DiagnosticReport");
const composition = bundle.entry[0].resource as FhirComposition;
const refs = (composition.section ?? []).flatMap((s) => s.entry ?? []);
for (const ref of refs) {
expect(refTarget(bundle, ref.reference)).not.toMatch(
/^DiagnosticReport\//,
);
}
// R4: an array element is absent, never empty.
const emptyArrays: string[] = [];
const walk = (value: unknown, path: string): void => {
if (Array.isArray(value)) {
if (value.length === 0) emptyArrays.push(path);
value.forEach((item, i) => walk(item, `${path}[${i}]`));
} else if (value && typeof value === "object") {
for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`);
}
};
walk(bundle, "Bundle");
expect(emptyArrays).toEqual([]);
});
it("leaves the vital-signs section without `entry` when it has nothing to list", () => {
const bundle = buildFhirDocumentBundle(
makeData({
measurements: {},
bmi: null,
glucoseStats: {},
compliance: {},
medications: [],
mood: null,
labResults: [],
} as Partial<DoctorReportData>),
{ insuranceNumber: null },
FIXED_NOW,
);
const composition = bundle.entry[0].resource as FhirComposition;
const vitals = composition.section?.find((s) => s.title === "Vital signs");
expect(vitals?.text?.div).toBeTruthy();
expect(
vitals && "entry" in vitals ? vitals.entry : undefined,
).toBeUndefined();
});
it("appends a DiagnosticReport as the LAST entry routing all Observation refs", () => {
const bundle = buildFhirDocumentBundle(
makeData(),
@@ -0,0 +1,134 @@
/**
* `Condition.bodySite` from the condition journal's free-text site and side.
*
* The site rides `text`; the side rides a contained `BodyStructure` reached
* through the core `bodySite` extension, and is repeated in `text` so a
* receiver that ignores extensions still reads which side.
*/
import { describe, expect, it } from "vitest";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { conditionsFromReportData } from "@/lib/fhir/resources";
import type { FhirCondition } from "@/lib/fhir/types";
type Episode = NonNullable<DoctorReportData["illnessEpisodes"]>[number];
function conditionFor(extra: Partial<Episode>): FhirCondition {
const data = {
illnessEpisodes: [
{
label: "Sprain",
type: "INJURY",
lifecycle: "ACUTE",
onsetAt: "2026-04-01T00:00:00.000Z",
resolvedAt: null,
...extra,
},
],
} as unknown as DoctorReportData;
const [condition] = conditionsFromReportData(data).conditions;
if (!condition) throw new Error("no Condition emitted");
return condition;
}
describe("conditionsFromReportData — bodySite", () => {
it("emits no bodySite and nothing contained when no site was recorded", () => {
const c = conditionFor({});
expect(c.bodySite).toBeUndefined();
expect(c.contained).toBeUndefined();
});
it("treats a blank site as no site, even with a side", () => {
const c = conditionFor({ bodySite: " ", laterality: "LEFT" });
expect(c.bodySite).toBeUndefined();
expect(c.contained).toBeUndefined();
});
it("emits the site as text only when no side was stated", () => {
const c = conditionFor({ bodySite: "Lower back", laterality: null });
expect(c.bodySite).toEqual([{ text: "Lower back" }]);
expect(c.contained).toBeUndefined();
});
it.each([
["LEFT", "7771000", "Left", "left"],
["RIGHT", "24028007", "Right", "right"],
["BOTH", "51440002", "Right and left", "both sides"],
])(
"carries %s as SNOMED %s on a contained BodyStructure",
(laterality, code, display, sideText) => {
const c = conditionFor({ bodySite: "Knee", laterality });
expect(c.bodySite).toEqual([
{
extension: [
{
url: "http://hl7.org/fhir/StructureDefinition/bodySite",
valueReference: { reference: "#bodysite-1" },
},
],
text: `Knee (${sideText})`,
},
]);
expect(c.contained).toEqual([
{
resourceType: "BodyStructure",
id: "bodysite-1",
location: { text: "Knee" },
locationQualifier: [
{
coding: [{ system: "http://snomed.info/sct", code, display }],
text: sideText,
},
],
patient: { reference: "Patient/patient-1" },
},
]);
},
);
it("never puts the side into bodySite.coding", () => {
const c = conditionFor({ bodySite: "Knee", laterality: "RIGHT" });
expect(c.bodySite?.[0]?.coding).toBeUndefined();
});
it("writes the side in the Bundle's language and the site in the user's words", () => {
const c = conditionFor({ bodySite: "Knie", laterality: "LEFT" });
expect(c.bodySite?.[0]?.text).toBe("Knie (left)");
// The same language as the rest of the generated Condition text.
expect(c.note?.[0]?.text).toMatch(/^Self-recorded /);
expect(c.contained?.[0]?.location?.text).toBe("Knie");
});
it("falls back to the site alone for an unknown side value", () => {
const c = conditionFor({ bodySite: "Knee", laterality: "SIDEWAYS" });
expect(c.bodySite).toEqual([{ text: "Knee" }]);
expect(c.contained).toBeUndefined();
});
it("keeps each episode's site on its own Condition", () => {
const data = {
illnessEpisodes: [
{
label: "A",
type: "INJURY",
lifecycle: "ACUTE",
onsetAt: "2026-04-01T00:00:00.000Z",
resolvedAt: null,
bodySite: "Wrist",
laterality: "RIGHT",
},
{
label: "B",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-04-02T00:00:00.000Z",
resolvedAt: null,
},
],
} as unknown as DoctorReportData;
const [a, b] = conditionsFromReportData(data).conditions;
expect(a?.bodySite?.[0]?.text).toBe("Wrist (right)");
expect(b?.bodySite).toBeUndefined();
expect(b?.contained).toBeUndefined();
});
});
@@ -100,6 +100,8 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
lifecycle: "ACUTE",
onsetAt: "2026-04-01T00:00:00.000Z",
resolvedAt: "2026-04-10T00:00:00.000Z",
bodySite: "Knee",
laterality: "LEFT",
},
],
mood: { avg: 3.8, count: 20 },
@@ -173,6 +175,34 @@ function resourcesOf(bundle: FhirBundle): FhirResource[] {
return bundle.entry.map((e) => e.resource);
}
// ── Condition.bodySite — the contained BodyStructure resolves in place ───
describe("Condition.bodySite", () => {
it("points its extension at a BodyStructure contained in the same Condition, whose patient resolves", () => {
const bundle = fullBundle();
const patientUrl = bundle.entry.find(
(e) => e.resource.resourceType === "Patient",
)?.fullUrl;
const condition = resourcesOf(bundle).find(
(r) => r.resourceType === "Condition",
);
if (condition?.resourceType !== "Condition")
throw new Error("no Condition");
const ext = condition.bodySite?.[0]?.extension?.[0];
expect(ext?.url).toBe("http://hl7.org/fhir/StructureDefinition/bodySite");
const target = ext?.valueReference.reference;
const structure = condition.contained?.find((c) => `#${c.id}` === target);
expect(structure?.resourceType).toBe("BodyStructure");
expect(structure?.patient.reference).toBe(patientUrl);
// An active SNOMED CT laterality qualifier value.
expect(
["7771000", "24028007", "51440002"].includes(
structure?.locationQualifier?.[0]?.coding?.[0]?.code ?? "",
),
).toBe(true);
});
});
// ── Test 14 — document identity, entry identity, reference resolution ─────
describe("document Bundle — identity and resolvability", () => {
@@ -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", () => {
@@ -111,6 +131,55 @@ describe("clinical glucose-panel FHIR Observations", () => {
expect(tir!.valueQuantity?.value).toBeLessThanOrEqual(100);
});
it("codes a continuous-stream mean as 97507-8 in mg/dL, even for a mmol/L account", () => {
const glucoseClinical = computeGlucoseClinicalMetrics(denseReadings(), {
now: FIXED_NOW,
windowDays: 90,
});
expect(glucoseClinical.isSpotEstimate).toBe(false);
const bundle = buildFhirDocumentBundle(
makeData({ glucoseClinical, glucoseUnit: "mmol/L" }),
{ insuranceNumber: null },
FIXED_NOW,
);
const mean = observationsOf(bundle).find(
(o) => o.code.text === "Mean glucose",
);
expect(codeOf(mean!)).toBe(GLUCOSE_MEAN_LOINC);
expect(mean?.valueQuantity?.code).toBe("mg/dL");
expect(mean?.valueQuantity?.value).toBe(
Math.round(glucoseClinical.meanMgdl!),
);
});
it("sends a spot-reading mean text-only, in the account's unit", () => {
// Four readings a day: fingerstick cadence, not a continuous stream.
const sparse = Array.from({ length: 120 }, (_, i) => ({
measuredAt: new Date(FIXED_NOW.getTime() - i * 6 * 3_600_000),
mgdl: 100 + (i % 5) * 10,
}));
const glucoseClinical = computeGlucoseClinicalMetrics(sparse, {
now: FIXED_NOW,
windowDays: 90,
});
expect(glucoseClinical.isSpotEstimate).toBe(true);
expect(glucoseClinical.meanMgdl).not.toBeNull();
const bundle = buildFhirDocumentBundle(
makeData({ glucoseClinical, glucoseUnit: "mmol/L" }),
{ insuranceNumber: null },
FIXED_NOW,
);
const mean = observationsOf(bundle).find(
(o) => o.code.text === "Mean glucose",
);
expect(mean).toBeDefined();
expect(mean?.code.coding).toBeUndefined();
expect(mean?.valueQuantity?.unit).toBe("mmol/L");
expect(observationsOf(bundle).map(codeOf)).not.toContain(
GLUCOSE_MEAN_LOINC,
);
});
it("emits NO clinical glucose Observation when the panel has no readings (module off / no data)", () => {
// Default makeData() has an empty (zero-reading) panel.
const bundle = buildFhirDocumentBundle(
@@ -122,6 +191,8 @@ describe("clinical glucose-panel FHIR Observations", () => {
expect(codes).not.toContain(GLUCOSE_TIR_LOINC);
expect(codes).not.toContain(GLUCOSE_GMI_LOINC);
expect(codes).not.toContain(GLUCOSE_MEAN_LOINC);
expect(codes).not.toContain(GLUCOSE_EA1C_LOINC);
expect(
observationsOf(bundle).some((o) => o.code.text === "Estimated A1C"),
).toBe(false);
});
});
@@ -67,8 +67,8 @@ function conditionForType(type: string): FhirCondition {
describe("illness → SNOMED category coding (v1.18.8)", () => {
const cases: Array<[string, string, string]> = [
["INFECTION", "40733004", "Infectious disease"],
["ALLERGY", "106190000", "Allergy"],
["INJURY", "417163006", "Traumatic AND/OR non-traumatic injury"],
["ALLERGY", "473011001", "Allergic condition"],
["INJURY", "417163006", "Traumatic or non-traumatic injury"],
["MENTAL_HEALTH", "74732009", "Mental disorder"],
["AUTOIMMUNE", "85828009", "Autoimmune disease"],
["CHRONIC", "27624003", "Chronic disease"],
@@ -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);
});
});
+40 -28
View File
@@ -328,31 +328,39 @@ export function buildFhirDocumentBundle(
: []),
].join(" ");
const diagnosticReport: FhirDiagnosticReport = {
resourceType: "DiagnosticReport",
id: "diagnostic-report-1",
status: "final",
code: {
coding: [
{
system: LOINC_SYSTEM,
code: "85353-1",
display:
"Vital signs, weight, height, head circumference, oxygen saturation and BMI panel",
},
],
text: "Vital signs panel",
},
subject: patientRef,
effectivePeriod: { start: data.period.start, end: data.period.end },
// A vital-signs panel reports vital signs. Routing labs, adherence rates
// and survey scores through it would let a receiver read a questionnaire
// total as a vital sign because the panel code said so.
result: vitalSignsObservationRefs,
};
const diagnosticReportRef: FhirReference = {
reference: `DiagnosticReport/${diagnosticReport.id}`,
};
// A vital-signs panel with no vital signs reports nothing, and R4 forbids
// the empty `result: []` it would otherwise carry. With no vital-sign
// Observation in the window the DiagnosticReport is left out entirely,
// together with its Composition entry.
const diagnosticReport: FhirDiagnosticReport | null =
vitalSignsObservationRefs.length === 0
? null
: {
resourceType: "DiagnosticReport",
id: "diagnostic-report-1",
status: "final",
code: {
coding: [
{
system: LOINC_SYSTEM,
code: "85353-1",
display:
"Vital signs, weight, height, head circumference, oxygen saturation and BMI panel",
},
],
text: "Vital signs panel",
},
subject: patientRef,
effectivePeriod: { start: data.period.start, end: data.period.end },
// A vital-signs panel reports vital signs. Routing labs, adherence rates
// and survey scores through it would let a receiver read a questionnaire
// total as a vital sign because the panel code said so.
result: vitalSignsObservationRefs,
};
const diagnosticReportRefs: FhirReference[] = diagnosticReport
? [{ reference: `DiagnosticReport/${diagnosticReport.id}` }]
: [];
const vitalSignsEntries = [...observationRefs, ...diagnosticReportRefs];
const composition: FhirComposition = {
resourceType: "Composition",
@@ -390,7 +398,9 @@ export function buildFhirDocumentBundle(
status: "generated",
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>`,
},
entry: [...observationRefs, diagnosticReportRef],
// `entry` is omitted rather than empty; the section still carries
// its narrative, which satisfies `cmp-2`.
...(vitalSignsEntries.length > 0 ? { entry: vitalSignsEntries } : {}),
},
// Medications section carries both the active-medication
// statements and the per-dose administration records (v1.9.0).
@@ -479,11 +489,13 @@ export function buildFhirDocumentBundle(
};
// The Composition must be the FIRST entry in a document Bundle; the
// DiagnosticReport is the LAST.
// DiagnosticReport, when there is one, is the LAST.
const orderedEntries: FhirBundleEntry[] = [
{ fullUrl: `urn:uuid:${randomUUID()}`, resource: composition },
...entries,
{ fullUrl: `urn:uuid:${randomUUID()}`, resource: diagnosticReport },
...(diagnosticReport
? [{ fullUrl: `urn:uuid:${randomUUID()}`, resource: diagnosticReport }]
: []),
];
// Every entry identity is known only now, so the reference rewrite runs once
+10 -7
View File
@@ -36,8 +36,10 @@ export interface IllnessSnomedCategory {
* `IllnessType` enum value → broad SNOMED CT category concept.
*
* - INFECTION → 40733004 "Infectious disease (disorder)"
* - ALLERGY → 106190000 "Allergy (disorder)"
* - INJURY → 417163006 "Traumatic AND/OR non-traumatic injury (disorder)"
* - ALLERGY → 473011001 "Allergic condition (finding)"
* (106190000 "Allergy", used before, is inactive since
* 2013)
* - INJURY → 417163006 "Traumatic or non-traumatic injury (disorder)"
* (spans both, the honest broad class for a journal entry)
* - MENTAL_HEALTH → 74732009 "Mental disorder (disorder)"
* - AUTOIMMUNE → 85828009 "Autoimmune disease (disorder)"
@@ -45,16 +47,17 @@ export interface IllnessSnomedCategory {
* - OTHER → 64572001 "Disease (disorder)" — the generic root, the
* correct fallback for an unspecified class.
*
* Every code and display here was checked with the official HL7 FHIR
* validator (validator_cli, R4) against tx.fhir.org's SNOMED CT International
* edition: active concept, accepted display.
*
* An unknown / future enum value resolves to the generic `DISEASE_SNOMED`
* root via the builder's fallback, never a guessed specific concept.
*/
export const ILLNESS_TYPE_SNOMED: Record<string, IllnessSnomedCategory> = {
INFECTION: { code: "40733004", display: "Infectious disease" },
ALLERGY: { code: "106190000", display: "Allergy" },
INJURY: {
code: "417163006",
display: "Traumatic AND/OR non-traumatic injury",
},
ALLERGY: { code: "473011001", display: "Allergic condition" },
INJURY: { code: "417163006", display: "Traumatic or non-traumatic injury" },
MENTAL_HEALTH: { code: "74732009", display: "Mental disorder" },
AUTOIMMUNE: { code: "85828009", display: "Autoimmune disease" },
CHRONIC: { code: "27624003", display: "Chronic disease" },
+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,
};
}
+92
View File
@@ -10,6 +10,7 @@
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed";
import type {
FhirBodyStructure,
FhirCodeableConcept,
FhirCondition,
FhirEncounter,
@@ -19,6 +20,94 @@ import { SNOMED_SYSTEM, patientRef } from "@/lib/fhir/resources/common";
/** SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept. */
const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const;
/**
* The side of a body site, as active SNOMED CT laterality qualifier values:
* 7771000 Left, 24028007 Right, 51440002 Right and left. These are the codes
* of the HL7 mCODE Laterality Qualifier value set
* (https://build.fhir.org/ig/HL7/fhir-mCODE-ig/ValueSet-mcode-laterality-qualifier-vs.html).
* The R4 core `bodysite-laterality` value set lists 419161000 / 419465000 for
* left / right, but the HL7 validator reports both as inactive in current
* SNOMED CT, so they are not used. R4 binds `BodyStructure.locationQualifier`
* with example strength, so any SNOMED CT qualifier is conformant there.
* Keyed on the `Laterality` enum.
*/
const LATERALITY_SNOMED: Record<
string,
{ code: string; display: string; text: string }
> = {
LEFT: { code: "7771000", display: "Left", text: "left" },
RIGHT: { code: "24028007", display: "Right", text: "right" },
BOTH: { code: "51440002", display: "Right and left", text: "both sides" },
};
/**
* Core extension `http://hl7.org/fhir/StructureDefinition/bodySite`
* (https://hl7.org/fhir/extensions/StructureDefinition-bodySite.html): a
* Reference(BodyStructure) on `Condition.bodySite`, unchanged in R4.
*/
const BODY_SITE_EXTENSION_URL =
"http://hl7.org/fhir/StructureDefinition/bodySite";
const BODY_STRUCTURE_ID = "bodysite-1";
/**
* `Condition.bodySite` plus the contained `BodyStructure` that carries its
* side, or nothing when no site was recorded.
*
* The site is the user's free text, so it rides `text` only; no SNOMED body
* structure is guessed from it. R4 `Condition.bodySite` has no laterality
* element, and a laterality concept is not a body site, so putting "Left" in
* `bodySite.coding` would claim the condition sits on "left". The standard R4
* route is the core `bodySite` extension pointing at a `BodyStructure` whose
* `locationQualifier` holds the SNOMED CT side. The side is also written into
* `bodySite.text`, because a receiver that ignores extensions must still read
* "Knee (left)" rather than a bare "Knee".
*
* The side word is English on purpose. The builder takes no locale
* (`FhirBuildOptions` carries none, unlike the PDF path), and every other
* generated text in the Bundle is English: section titles, the Condition note,
* the narratives. The site stays in the words the user wrote, so a German
* record reads "Knie (left)", the same mix as a German label beside the
* English "Self-recorded … condition journal entry" note.
*/
function bodySiteOf(
bodySite: string | null | undefined,
laterality: string | null | undefined,
): Pick<FhirCondition, "bodySite" | "contained"> {
const site = bodySite?.trim();
if (!site) return {};
const side = laterality ? LATERALITY_SNOMED[laterality] : undefined;
if (!side) return { bodySite: [{ text: site }] };
const structure: FhirBodyStructure = {
resourceType: "BodyStructure",
id: BODY_STRUCTURE_ID,
location: { text: site },
locationQualifier: [
{
coding: [
{ system: SNOMED_SYSTEM, code: side.code, display: side.display },
],
text: side.text,
},
],
patient: patientRef,
};
return {
contained: [structure],
bodySite: [
{
extension: [
{
url: BODY_SITE_EXTENSION_URL,
valueReference: { reference: `#${BODY_STRUCTURE_ID}` },
},
],
text: `${site} (${side.text})`,
},
],
};
}
/** R4 `Condition.clinicalStatus` concept for the given resolution state. */
function conditionClinicalStatus(resolved: boolean): FhirCodeableConcept {
return {
@@ -53,9 +142,11 @@ export function conditionsFromReportData(data: DoctorReportData): {
seq += 1;
const conditionId = `condition-${seq}`;
const resolved = ep.resolvedAt !== null;
const site = bodySiteOf(ep.bodySite, ep.laterality);
conditions.push({
resourceType: "Condition",
id: conditionId,
...(site.contained ? { contained: site.contained } : {}),
clinicalStatus: conditionClinicalStatus(resolved),
verificationStatus: {
coding: [
@@ -94,6 +185,7 @@ export function conditionsFromReportData(data: DoctorReportData): {
],
text: ep.label,
},
...(site.bodySite ? { bodySite: site.bodySite } : {}),
subject: patientRef,
onsetDateTime: ep.onsetAt,
...(ep.resolvedAt ? { abatementDateTime: ep.resolvedAt } : {}),
+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"),
+41 -12
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,23 +132,47 @@ export function glucoseClinicalObservations(
if (clinical.distribution) {
push({
loinc: GLUCOSE_TIR_LOINC,
loinc: {
code: GLUCOSE_TIR_LOINC,
display:
"Glucose measurements in range out of Total glucose measurements during reporting period",
},
text: timeInRangeDisplay(glucoseUnit),
value: Math.round(clinical.distribution.tir * 1000) / 10,
unit: "%",
});
}
if (clinical.meanMgdl !== null) {
push({
loinc: GLUCOSE_MEAN_LOINC,
text: "Mean glucose",
value: convertGlucose(clinical.meanMgdl, glucoseUnit),
unit: glucoseUnit,
});
// 97507-8 is the mean of interstitial glucose in mass/volume. Only a
// continuous stream (not a spot estimate, by reading density) is
// interstitial, so only that mean carries the code, and it goes out in
// mg/dL: the canonical unit the mean is computed in, so no conversion is
// involved. A spot-reading mean stays text-only in the display unit.
if (clinical.isSpotEstimate) {
push({
text: "Mean glucose",
value: convertGlucose(clinical.meanMgdl, glucoseUnit),
unit: glucoseUnit,
});
} else {
push({
loinc: {
code: GLUCOSE_MEAN_LOINC,
display:
"Average glucose [Mass/volume] in Interstitial fluid during Reporting Period",
},
text: "Mean glucose",
value: convertGlucose(clinical.meanMgdl, "mg/dL"),
unit: "mg/dL",
});
}
}
if (clinical.gmi !== null) {
push({
loinc: GLUCOSE_GMI_LOINC,
loinc: {
code: GLUCOSE_GMI_LOINC,
display: "Glucose management indicator",
},
text: "Glucose Management Indicator (GMI)",
value: Math.round(clinical.gmi * 10) / 10,
unit: "%",
@@ -152,7 +180,8 @@ export function glucoseClinicalObservations(
}
if (clinical.estimatedA1c !== null) {
push({
loinc: GLUCOSE_EA1C_LOINC,
// Text-only: no LOINC term exists for an A1C estimated from mean
// glucose (see `loinc-map.ts`).
text: "Estimated A1C",
value: Math.round(clinical.estimatedA1c * 10) / 10,
unit: "%",
+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(
+32
View File
@@ -15,10 +15,21 @@ export interface FhirCoding {
}
export interface FhirCodeableConcept {
/**
* Element extensions. Only `Condition.bodySite` carries one today (the core
* `bodySite` extension pointing at a contained `BodyStructure`).
*/
extension?: FhirExtension[];
coding?: FhirCoding[];
text?: string;
}
/** R4 `Extension`, narrowed to the one value type HealthLog emits. */
export interface FhirExtension {
url: string;
valueReference: FhirReference;
}
export interface FhirReference {
reference: string;
display?: string;
@@ -250,10 +261,17 @@ export interface FhirComposition {
export interface FhirCondition {
resourceType: "Condition";
id: string;
/**
* v1.39.3 — the `BodyStructure` that carries the side of the body site,
* referenced from `bodySite[].extension` by a local `#`-ref.
*/
contained?: FhirBodyStructure[];
clinicalStatus?: FhirCodeableConcept;
verificationStatus?: FhirCodeableConcept;
category?: FhirCodeableConcept[];
code: FhirCodeableConcept;
/** v1.39.3 — where on the body, as the user wrote it (`text` only). */
bodySite?: FhirCodeableConcept[];
subject: FhirReference;
onsetDateTime?: string;
abatementDateTime?: string;
@@ -261,6 +279,20 @@ export interface FhirCondition {
note?: FhirAnnotation[];
}
/**
* v1.39.3 — R4 `BodyStructure`, emitted only as a resource contained in a
* `Condition`. `location` repeats the free-text site; `locationQualifier`
* carries the side as a SNOMED CT laterality concept. `patient` is `1..1` in
* R4.
*/
export interface FhirBodyStructure {
resourceType: "BodyStructure";
id: string;
location?: FhirCodeableConcept;
locationQualifier?: FhirCodeableConcept[];
patient: FhirReference;
}
/**
* v1.18.1 P4 — R4 `Encounter`. Emitted alongside a resolved episode to bound
* the period of care (onset → resolution) so a clinician sees the episode as a
+1 -1
View File
@@ -647,7 +647,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["FHIR"],
summary: "FHIR R4 $everything (v1.11.0)",
description:
"Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
"Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
requestParams: {
query: z.object({
_count: z.coerce.number().optional(),
@@ -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",
},