mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(doctor-report): render the clinical glucose panel in the PDF and FHIR
The server already computes the clinical glucose panel (TIR / GMI / estimated A1C / mean / CV%) by the one literature-locked engine the insights panel and coach consume, but nothing surfaced it in the export. Render it as a dedicated PDF table after the vitals section and emit it as FHIR Observations (LOINC-coded where a term exists; CV% under a survey text-only concept). Both gate on the panel carrying readings, which the aggregator already zeroes when the glucose module is off — so a disabled module emits no glucose panel. The spot-reading caveat rides along when the readings are sparse. Adds clinical glucose-panel keys across all locales and PDF + FHIR tests.
This commit is contained in:
1 parent
b9a3f577ca
commit
5f9670d222
11 files changed
+479
-6
No files matched your search
+16
-1
@@ -189,7 +189,22 @@
|
||||
"labsColValue": "Wert",
|
||||
"labsColReference": "Referenz",
|
||||
"labsColStatus": "Status",
|
||||
"labsColDate": "Datum"
|
||||
"labsColDate": "Datum",
|
||||
"glucoseClinical": {
|
||||
"title": "Glukose-Übersicht (klinisch)",
|
||||
"colMetric": "Kennzahl",
|
||||
"colValue": "Wert",
|
||||
"tirInRange": "Zeit im Zielbereich (70–180 mg/dl)",
|
||||
"tirLow": "Niedrig (< 70 mg/dl)",
|
||||
"tirVeryLow": "Sehr niedrig (< 54 mg/dl)",
|
||||
"tirHigh": "Hoch (> 180 mg/dl)",
|
||||
"tirVeryHigh": "Sehr hoch (> 250 mg/dl)",
|
||||
"mean": "Durchschnittliche Glukose",
|
||||
"gmi": "Glukose-Management-Indikator (GMI)",
|
||||
"eA1c": "Geschätzter HbA1c",
|
||||
"cv": "Variabilität (VK%)",
|
||||
"spotCaveat": "Dies sind Schätzwerte aus Einzelmessungen, kein Profil eines kontinuierlichen Messgeräts — als Tendenz und nicht als klinisches Urteil zu lesen."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
|
||||
+16
-1
@@ -189,7 +189,22 @@
|
||||
"labsColValue": "Value",
|
||||
"labsColReference": "Reference",
|
||||
"labsColStatus": "Status",
|
||||
"labsColDate": "Date"
|
||||
"labsColDate": "Date",
|
||||
"glucoseClinical": {
|
||||
"title": "Glucose overview (clinical)",
|
||||
"colMetric": "Metric",
|
||||
"colValue": "Value",
|
||||
"tirInRange": "Time in range (70–180 mg/dL)",
|
||||
"tirLow": "Low (< 70 mg/dL)",
|
||||
"tirVeryLow": "Very low (< 54 mg/dL)",
|
||||
"tirHigh": "High (> 180 mg/dL)",
|
||||
"tirVeryHigh": "Very high (> 250 mg/dL)",
|
||||
"mean": "Average glucose",
|
||||
"gmi": "Glucose Management Indicator (GMI)",
|
||||
"eA1c": "Estimated A1C",
|
||||
"cv": "Variability (CV%)",
|
||||
"spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
|
||||
+16
-1
@@ -189,7 +189,22 @@
|
||||
"labsColValue": "Valor",
|
||||
"labsColReference": "Referencia",
|
||||
"labsColStatus": "Estado",
|
||||
"labsColDate": "Fecha"
|
||||
"labsColDate": "Fecha",
|
||||
"glucoseClinical": {
|
||||
"title": "Resumen de glucosa (clínico)",
|
||||
"colMetric": "Métrica",
|
||||
"colValue": "Valor",
|
||||
"tirInRange": "Tiempo en rango (70–180 mg/dl)",
|
||||
"tirLow": "Bajo (< 70 mg/dl)",
|
||||
"tirVeryLow": "Muy bajo (< 54 mg/dl)",
|
||||
"tirHigh": "Alto (> 180 mg/dl)",
|
||||
"tirVeryHigh": "Muy alto (> 250 mg/dl)",
|
||||
"mean": "Glucosa media",
|
||||
"gmi": "Indicador de gestión de la glucosa (GMI)",
|
||||
"eA1c": "A1C estimada",
|
||||
"cv": "Variabilidad (CV%)",
|
||||
"spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de monitor continuo — léelas como una tendencia, no como un veredicto clínico."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Algo ha salido mal",
|
||||
|
||||
+16
-1
@@ -189,7 +189,22 @@
|
||||
"labsColValue": "Valeur",
|
||||
"labsColReference": "Référence",
|
||||
"labsColStatus": "Statut",
|
||||
"labsColDate": "Date"
|
||||
"labsColDate": "Date",
|
||||
"glucoseClinical": {
|
||||
"title": "Aperçu glycémique (clinique)",
|
||||
"colMetric": "Indicateur",
|
||||
"colValue": "Valeur",
|
||||
"tirInRange": "Temps dans la cible (70–180 mg/dl)",
|
||||
"tirLow": "Bas (< 70 mg/dl)",
|
||||
"tirVeryLow": "Très bas (< 54 mg/dl)",
|
||||
"tirHigh": "Élevé (> 180 mg/dl)",
|
||||
"tirVeryHigh": "Très élevé (> 250 mg/dl)",
|
||||
"mean": "Glycémie moyenne",
|
||||
"gmi": "Indicateur de gestion du glucose (GMI)",
|
||||
"eA1c": "HbA1c estimée",
|
||||
"cv": "Variabilité (CV%)",
|
||||
"spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, et non un profil de capteur continu — à lire comme une tendance, pas comme un verdict clinique."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Une erreur est survenue",
|
||||
|
||||
+16
-1
@@ -189,7 +189,22 @@
|
||||
"labsColValue": "Valore",
|
||||
"labsColReference": "Riferimento",
|
||||
"labsColStatus": "Stato",
|
||||
"labsColDate": "Data"
|
||||
"labsColDate": "Data",
|
||||
"glucoseClinical": {
|
||||
"title": "Panoramica glicemia (clinica)",
|
||||
"colMetric": "Metrica",
|
||||
"colValue": "Valore",
|
||||
"tirInRange": "Tempo nell'intervallo (70–180 mg/dl)",
|
||||
"tirLow": "Basso (< 70 mg/dl)",
|
||||
"tirVeryLow": "Molto basso (< 54 mg/dl)",
|
||||
"tirHigh": "Alto (> 180 mg/dl)",
|
||||
"tirVeryHigh": "Molto alto (> 250 mg/dl)",
|
||||
"mean": "Glicemia media",
|
||||
"gmi": "Indicatore di gestione del glucosio (GMI)",
|
||||
"eA1c": "A1C stimata",
|
||||
"cv": "Variabilità (CV%)",
|
||||
"spotCaveat": "Sono stime da misurazioni occasionali, non un profilo da monitor continuo — vanno lette come una tendenza, non come un verdetto clinico."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Qualcosa è andato storto",
|
||||
|
||||
+16
-1
@@ -189,7 +189,22 @@
|
||||
"labsColValue": "Wartość",
|
||||
"labsColReference": "Norma",
|
||||
"labsColStatus": "Status",
|
||||
"labsColDate": "Data"
|
||||
"labsColDate": "Data",
|
||||
"glucoseClinical": {
|
||||
"title": "Przegląd glukozy (kliniczny)",
|
||||
"colMetric": "Wskaźnik",
|
||||
"colValue": "Wartość",
|
||||
"tirInRange": "Czas w zakresie (70–180 mg/dl)",
|
||||
"tirLow": "Niski (< 70 mg/dl)",
|
||||
"tirVeryLow": "Bardzo niski (< 54 mg/dl)",
|
||||
"tirHigh": "Wysoki (> 180 mg/dl)",
|
||||
"tirVeryHigh": "Bardzo wysoki (> 250 mg/dl)",
|
||||
"mean": "Średnia glukoza",
|
||||
"gmi": "Wskaźnik zarządzania glukozą (GMI)",
|
||||
"eA1c": "Szacowane HbA1c",
|
||||
"cv": "Zmienność (CV%)",
|
||||
"spotCaveat": "To szacunki z pojedynczych pomiarów, nie profil z monitora ciągłego — czytaj je jako kierunek zmian, a nie kliniczny werdykt."
|
||||
}
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Coś poszło nie tak",
|
||||
|
||||
@@ -439,3 +439,52 @@ describe("doctor-report-pdf-core type-map coverage", () => {
|
||||
expect(text).toContain("Oxygen saturation");
|
||||
});
|
||||
});
|
||||
|
||||
// ── clinical glucose panel ── the server-computed TIR / GMI / eA1C panel must
|
||||
// render in the PDF whenever glucose readings exist, and stay absent otherwise.
|
||||
describe("doctor-report clinical glucose panel", () => {
|
||||
function denseReadings(): { measuredAt: Date; mgdl: number }[] {
|
||||
const out: { measuredAt: Date; mgdl: number }[] = [];
|
||||
for (let d = 0; d < 60; d += 1) {
|
||||
for (let h = 0; h < 24; h += 1) {
|
||||
out.push({
|
||||
measuredAt: new Date(
|
||||
FIXED_NOW.getTime() - d * 86_400_000 - h * 3_600_000,
|
||||
),
|
||||
mgdl: 110 + ((d + h) % 7) * 8,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("renders the clinical panel title + TIR/GMI rows when readings exist", async () => {
|
||||
const data = makeData({
|
||||
glucoseClinical: computeGlucoseClinicalMetrics(denseReadings(), {
|
||||
now: FIXED_NOW,
|
||||
windowDays: 90,
|
||||
}),
|
||||
});
|
||||
expect(data.glucoseClinical.readingCount).toBeGreaterThan(0);
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Glucose overview (clinical)");
|
||||
expect(text).toContain("Time in range");
|
||||
expect(text).toContain("Glucose Management Indicator");
|
||||
});
|
||||
|
||||
it("omits the clinical panel when there are no glucose readings", async () => {
|
||||
const data = makeData(); // empty (zero-reading) glucose panel
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
t: getServerTranslator("en").t,
|
||||
locale: "en",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
expect(text).not.toContain("Glucose overview (clinical)");
|
||||
});
|
||||
});
|
||||
@@ -746,6 +746,108 @@ export function buildDoctorReportPdfDocument(
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// v1.18.0 — clinical glucose panel (TIR / GMI / eA1C / mean / CV%). The
|
||||
// server already computes this (`data.glucoseClinical`) by the one
|
||||
// literature-locked engine the insights panel + coach consume, and zeroes it
|
||||
// when the glucose module is off (readingCount === 0). Render it whenever the
|
||||
// window carries glucose readings so the PDF surfaces the same numbers the
|
||||
// app shows. A `pct` helper turns the engine's 0–1 fractions into percent.
|
||||
const clinical = data.glucoseClinical;
|
||||
if (clinical && clinical.readingCount > 0 && clinical.distribution) {
|
||||
const pct = (fraction: number) => `${num(fraction * 100)} %`;
|
||||
const clinicalRows: string[][] = [];
|
||||
const dist = clinical.distribution;
|
||||
clinicalRows.push([t("doctorReport.glucoseClinical.tirInRange"), pct(dist.tir)]);
|
||||
clinicalRows.push([t("doctorReport.glucoseClinical.tirLow"), pct(dist.tbrLevel1)]);
|
||||
clinicalRows.push([t("doctorReport.glucoseClinical.tirVeryLow"), pct(dist.tbrLevel2)]);
|
||||
clinicalRows.push([t("doctorReport.glucoseClinical.tirHigh"), pct(dist.tarLevel1)]);
|
||||
clinicalRows.push([
|
||||
t("doctorReport.glucoseClinical.tirVeryHigh"),
|
||||
pct(dist.tarLevel2),
|
||||
]);
|
||||
if (clinical.meanMgdl !== null) {
|
||||
clinicalRows.push([
|
||||
t("doctorReport.glucoseClinical.mean"),
|
||||
`${num(convertGlucose(clinical.meanMgdl, glucoseUnit))} ${glucoseUnit}`.trim(),
|
||||
]);
|
||||
}
|
||||
if (clinical.gmi !== null) {
|
||||
clinicalRows.push([t("doctorReport.glucoseClinical.gmi"), `${num(clinical.gmi)} %`]);
|
||||
}
|
||||
if (clinical.estimatedA1c !== null) {
|
||||
clinicalRows.push([
|
||||
t("doctorReport.glucoseClinical.eA1c"),
|
||||
`${num(clinical.estimatedA1c)} %`,
|
||||
]);
|
||||
}
|
||||
if (clinical.variability !== null) {
|
||||
clinicalRows.push([
|
||||
t("doctorReport.glucoseClinical.cv"),
|
||||
`${num(clinical.variability.cv)} %`,
|
||||
]);
|
||||
}
|
||||
|
||||
// Keep the heading with the start of its table.
|
||||
y = ensureSpace(y, 6 + 14);
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.glucoseClinical.title"), margin, y);
|
||||
y += 6;
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.glucoseClinical.colMetric"),
|
||||
t("doctorReport.glucoseClinical.colValue"),
|
||||
],
|
||||
],
|
||||
body: clinicalRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 4;
|
||||
|
||||
// Spot-reading caveat when the engine flags the readings as sparse, so the
|
||||
// clinician reads the panel as a direction-of-travel, not a CGM AGP.
|
||||
if (clinical.isSpotEstimate) {
|
||||
doc.setFontSize(8);
|
||||
doc.setFont("helvetica", "italic");
|
||||
doc.setTextColor(110, 110, 110);
|
||||
const caveat = doc.splitTextToSize(
|
||||
t("doctorReport.glucoseClinical.spotCaveat"),
|
||||
pageWidth - 2 * margin,
|
||||
);
|
||||
for (const line of caveat) {
|
||||
y = ensureSpace(y, 4);
|
||||
doc.text(line, margin, y);
|
||||
y += 4;
|
||||
}
|
||||
}
|
||||
y += 4;
|
||||
}
|
||||
|
||||
// v1.7.0 — jsPDF-native trend sparklines per primary vital. Vector
|
||||
// polylines drawn with `doc.lines()` — zero new dependency, no native
|
||||
// canvas module, isomorphic. Selection-gated via `includeCharts`.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import {
|
||||
GLUCOSE_TIR_LOINC,
|
||||
GLUCOSE_GMI_LOINC,
|
||||
GLUCOSE_MEAN_LOINC,
|
||||
GLUCOSE_EA1C_LOINC,
|
||||
} from "@/lib/fhir/loinc-map";
|
||||
|
||||
import { buildFhirDocumentBundle } from "../build-bundle";
|
||||
import type { FhirObservation } from "../types";
|
||||
|
||||
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
|
||||
|
||||
function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
return {
|
||||
period: {
|
||||
days: 90,
|
||||
since: "2026-02-02T00:00:00.000Z",
|
||||
start: "2026-02-02T00:00:00.000Z",
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
},
|
||||
practiceName: null,
|
||||
measurements: {},
|
||||
stats: {},
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }),
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: null,
|
||||
compliance: {},
|
||||
medications: [],
|
||||
mood: null,
|
||||
glp1: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function observationsOf(bundle: ReturnType<typeof buildFhirDocumentBundle>) {
|
||||
return bundle.entry
|
||||
.map((e) => e.resource)
|
||||
.filter((r): r is FhirObservation => r.resourceType === "Observation");
|
||||
}
|
||||
|
||||
function codeOf(obs: FhirObservation): string | undefined {
|
||||
return obs.code.coding?.[0]?.code;
|
||||
}
|
||||
|
||||
/** Dense daily readings over the window so the panel populates fully. */
|
||||
function denseReadings(): { measuredAt: Date; mgdl: number }[] {
|
||||
const out: { measuredAt: Date; mgdl: number }[] = [];
|
||||
for (let d = 0; d < 60; d += 1) {
|
||||
for (let h = 0; h < 24; h += 1) {
|
||||
out.push({
|
||||
measuredAt: new Date(
|
||||
FIXED_NOW.getTime() - d * 86_400_000 - h * 3_600_000,
|
||||
),
|
||||
// Mostly in-range with a wobble so CV% / TIR are real.
|
||||
mgdl: 110 + ((d + h) % 7) * 8,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("clinical glucose-panel FHIR Observations", () => {
|
||||
it("emits TIR / GMI / mean / eA1C Observations when glucose readings exist", () => {
|
||||
const glucoseClinical = computeGlucoseClinicalMetrics(denseReadings(), {
|
||||
now: FIXED_NOW,
|
||||
windowDays: 90,
|
||||
});
|
||||
expect(glucoseClinical.readingCount).toBeGreaterThan(0);
|
||||
|
||||
const bundle = buildFhirDocumentBundle(makeData({ glucoseClinical }), { insuranceNumber: "A123456780" }, FIXED_NOW);
|
||||
const codes = observationsOf(bundle).map(codeOf);
|
||||
|
||||
expect(codes).toContain(GLUCOSE_TIR_LOINC);
|
||||
expect(codes).toContain(GLUCOSE_GMI_LOINC);
|
||||
expect(codes).toContain(GLUCOSE_MEAN_LOINC);
|
||||
expect(codes).toContain(GLUCOSE_EA1C_LOINC);
|
||||
});
|
||||
|
||||
it("emits a percent-valued, UCUM-coded TIR Observation", () => {
|
||||
const glucoseClinical = computeGlucoseClinicalMetrics(denseReadings(), {
|
||||
now: FIXED_NOW,
|
||||
windowDays: 90,
|
||||
});
|
||||
const bundle = buildFhirDocumentBundle(makeData({ glucoseClinical }), { insuranceNumber: "A123456780" }, FIXED_NOW);
|
||||
const tir = observationsOf(bundle).find((o) => codeOf(o) === GLUCOSE_TIR_LOINC);
|
||||
expect(tir).toBeDefined();
|
||||
expect(tir!.valueQuantity?.code).toBe("%");
|
||||
expect(tir!.valueQuantity?.value).toBeGreaterThan(0);
|
||||
expect(tir!.valueQuantity?.value).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
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(makeData(), { insuranceNumber: "A123456780" }, FIXED_NOW);
|
||||
const codes = observationsOf(bundle).map(codeOf);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -304,6 +304,21 @@ export const GLUCOSE_LOINC: Record<string, { loinc: string; display: string }> =
|
||||
BEDTIME: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" },
|
||||
};
|
||||
|
||||
/* ── Clinical glucose-panel LOINCs (v1.18.0) ───────────────────────────
|
||||
* The CGM/spot-reading composite metrics the doctor report computes by the
|
||||
* one literature-locked engine. Each carries the published LOINC term where
|
||||
* one exists; the variability CV% has no published LOINC and is emitted as a
|
||||
* survey-category text-only concept by the builder (honest about what we have).
|
||||
*/
|
||||
/** Time in range 70–180 mg/dL [Battelino 2019]. */
|
||||
export const GLUCOSE_TIR_LOINC = "97510-2";
|
||||
/** Glucose Management Indicator (GMI) [Bergenstal 2018]. */
|
||||
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";
|
||||
|
||||
/** Medication-adherence Observation LOINC. */
|
||||
export const MEDICATION_ADHERENCE_LOINC = "71799-1";
|
||||
/** Mood Observation LOINC (opt-in only). */
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
BP_DIA_LOINC,
|
||||
BP_UNIT,
|
||||
GLUCOSE_LOINC,
|
||||
GLUCOSE_TIR_LOINC,
|
||||
GLUCOSE_GMI_LOINC,
|
||||
GLUCOSE_MEAN_LOINC,
|
||||
GLUCOSE_EA1C_LOINC,
|
||||
MEDICATION_ADHERENCE_LOINC,
|
||||
MOOD_LOINC,
|
||||
LMP_LOINC,
|
||||
@@ -511,6 +515,106 @@ export function observationsFromReportData(
|
||||
});
|
||||
}
|
||||
|
||||
// --- Clinical glucose-panel Observations (v1.18.0) ---------------------
|
||||
// The server-computed TIR / GMI / estimated-A1C / mean / CV% over the report
|
||||
// period, by the one literature-locked engine the insights panel + coach +
|
||||
// PDF consume. Emitted only when readings exist (the aggregator zeroes the
|
||||
// panel when the glucose module is off, so an absent panel = module off OR no
|
||||
// data). Each carries the published LOINC where one exists; CV% has none and
|
||||
// rides a `survey` text-only concept. Percent metrics use UCUM `%`.
|
||||
const clinical = data.glucoseClinical;
|
||||
if (clinical && clinical.readingCount > 0) {
|
||||
const pushClinical = (
|
||||
opts: {
|
||||
loinc?: string;
|
||||
text: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
ucum: string;
|
||||
survey?: boolean;
|
||||
},
|
||||
) => {
|
||||
obsSeq += 1;
|
||||
push({
|
||||
resourceType: "Observation",
|
||||
id: `obs-${obsSeq}`,
|
||||
status: "final",
|
||||
category: [categoryConcept(opts.survey ? "survey" : "laboratory")],
|
||||
code: opts.loinc
|
||||
? {
|
||||
coding: [
|
||||
{ system: LOINC_SYSTEM, code: opts.loinc, display: opts.text },
|
||||
],
|
||||
text: opts.text,
|
||||
}
|
||||
: { text: opts.text },
|
||||
subject: patientRef,
|
||||
effectiveDateTime: data.period.end,
|
||||
valueQuantity: {
|
||||
value: opts.value,
|
||||
unit: opts.unit,
|
||||
system: UCUM_SYSTEM,
|
||||
code: opts.ucum,
|
||||
},
|
||||
...(clinical.isSpotEstimate
|
||||
? {
|
||||
note: [
|
||||
{
|
||||
text: "Spot-reading estimate from individual measurements, not a continuous-monitor profile.",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
if (clinical.distribution) {
|
||||
pushClinical({
|
||||
loinc: GLUCOSE_TIR_LOINC,
|
||||
text: "Glucose time in range (70–180 mg/dL)",
|
||||
value: Math.round(clinical.distribution.tir * 1000) / 10,
|
||||
unit: "%",
|
||||
ucum: "%",
|
||||
});
|
||||
}
|
||||
if (clinical.meanMgdl !== null) {
|
||||
pushClinical({
|
||||
loinc: GLUCOSE_MEAN_LOINC,
|
||||
text: "Mean glucose",
|
||||
value: convertGlucose(clinical.meanMgdl, glucoseUnit),
|
||||
unit: glucoseUnit,
|
||||
ucum: glucoseUnit === "mmol/L" ? "mmol/L" : "mg/dL",
|
||||
});
|
||||
}
|
||||
if (clinical.gmi !== null) {
|
||||
pushClinical({
|
||||
loinc: GLUCOSE_GMI_LOINC,
|
||||
text: "Glucose Management Indicator (GMI)",
|
||||
value: Math.round(clinical.gmi * 10) / 10,
|
||||
unit: "%",
|
||||
ucum: "%",
|
||||
});
|
||||
}
|
||||
if (clinical.estimatedA1c !== null) {
|
||||
pushClinical({
|
||||
loinc: GLUCOSE_EA1C_LOINC,
|
||||
text: "Estimated A1C",
|
||||
value: Math.round(clinical.estimatedA1c * 10) / 10,
|
||||
unit: "%",
|
||||
ucum: "%",
|
||||
});
|
||||
}
|
||||
if (clinical.variability !== null) {
|
||||
pushClinical({
|
||||
text: "Glucose variability (coefficient of variation)",
|
||||
value: Math.round(clinical.variability.cv * 10) / 10,
|
||||
unit: "%",
|
||||
ucum: "%",
|
||||
survey: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lab-result Observations (v1.17.1) ---------------------------------
|
||||
// One `laboratory` Observation per recorded analyte. `analyte` + `unit`
|
||||
// are user-recorded free text (no closed LOINC enum), so the code is a
|
||||
|
||||
Reference in new issue
Block a user