mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
44abc6f1a0
commit
0ac5377cc2
15 files changed
+393
-128
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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: "%",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user