mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge(reconcile): t1-drcohere audit findings
This commit is contained in:
commit
98d1e7ae31
13 files changed
+566
-9
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",
|
||||
|
||||
Binary file not shown.
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
import type {
|
||||
GlucoseContext,
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||||
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
@@ -288,6 +292,70 @@ interface RecoveryMeasurementRow {
|
||||
source: MeasurementSource;
|
||||
}
|
||||
|
||||
/** Minimal Measurement shape the canonical-source collapse consults. */
|
||||
export interface CanonicalCollapseRow {
|
||||
type: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
source: MeasurementSource;
|
||||
deviceType?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a window's raw Measurement rows to one canonical source per metric
|
||||
* before the doctor report computes per-type avg/min/max. Without this the PDF
|
||||
* + FHIR stats blend every source (a WHOOP + Apple-Watch resting-heart-rate
|
||||
* day would average two readings), while the dashboard / insights aggregator
|
||||
* already collapse to the ladder-canonical source per day. This routes the rows
|
||||
* through the SAME picker (`pickCanonicalSourceRows`, keyed by
|
||||
* `metricKeyForType` + the user's day key + source-priority ladder), so the two
|
||||
* surfaces report identical numbers.
|
||||
*
|
||||
* SLEEP_DURATION is passed through untouched: it carries per-stage rows that
|
||||
* `reconstructSleepNights` resolves with its own source de-dup downstream, so a
|
||||
* pre-collapse here would drop stages the night reconstruction needs. A type
|
||||
* with no ladder, or a day with a single source, passes through unchanged via
|
||||
* the picker's documented fallback. Exported for unit tests.
|
||||
*/
|
||||
export function collapseMeasurementsToCanonical<T extends CanonicalCollapseRow>(
|
||||
measurements: readonly T[],
|
||||
timezone: string,
|
||||
sourcePriorityJson: unknown,
|
||||
): T[] {
|
||||
const dayKey = (d: Date) => userDayKey(d, timezone);
|
||||
const byType = new Map<string, T[]>();
|
||||
for (const m of measurements) {
|
||||
const slot = byType.get(m.type);
|
||||
if (slot) slot.push(m);
|
||||
else byType.set(m.type, [m]);
|
||||
}
|
||||
const out: T[] = [];
|
||||
for (const [type, rows] of byType) {
|
||||
const metricKey =
|
||||
type === "SLEEP_DURATION"
|
||||
? null
|
||||
: metricKeyForType(type as MeasurementType);
|
||||
if (!metricKey) {
|
||||
out.push(...rows);
|
||||
continue;
|
||||
}
|
||||
const { canonicalRows } = pickCanonicalSourceRows(
|
||||
rows.map((m) => ({
|
||||
...m,
|
||||
type: m.type as MeasurementType,
|
||||
})),
|
||||
metricKey,
|
||||
sourcePriorityJson,
|
||||
dayKey,
|
||||
);
|
||||
// `pickCanonicalSourceRows` returns the spread copies; re-key back to the
|
||||
// original rows by reference identity is unnecessary because the spread
|
||||
// preserves every field the caller reads (value / measuredAt / type).
|
||||
out.push(...(canonicalRows as unknown as T[]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise RECOVERY_SCORE over the window from the CANONICAL row per day:
|
||||
* a WHOOP-native row wins over the COMPUTED proxy for the same day, so the
|
||||
@@ -739,18 +807,35 @@ export async function collectDoctorReportData(
|
||||
}),
|
||||
]);
|
||||
|
||||
// Group measurements by type.
|
||||
const reportTz = userProfile?.timezone ?? "Europe/Berlin";
|
||||
|
||||
// v1.18.0 — collapse each multi-source metric to its CANONICAL source before
|
||||
// grouping, so the report's per-type avg/min/max match the dashboard rather
|
||||
// than blending overlapping sources (e.g. WHOOP + Apple Watch resting-heart-
|
||||
// rate summed into one inflated mean). See `collapseMeasurementsToCanonical`.
|
||||
const canonicalMeasurements = collapseMeasurementsToCanonical(
|
||||
measurements,
|
||||
reportTz,
|
||||
userProfile?.sourcePriorityJson ?? null,
|
||||
);
|
||||
|
||||
// Group measurements by type (canonical-source-collapsed).
|
||||
const byType: Record<
|
||||
string,
|
||||
Array<{ value: number; measuredAt: string }>
|
||||
> = {};
|
||||
for (const m of measurements) {
|
||||
for (const m of canonicalMeasurements) {
|
||||
if (!byType[m.type]) byType[m.type] = [];
|
||||
byType[m.type].push({
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt.toISOString(),
|
||||
});
|
||||
}
|
||||
// measuredAt-ascending within each type so `latest` (last element) is the
|
||||
// most recent reading after the canonical collapse reorders by bucket.
|
||||
for (const entries of Object.values(byType)) {
|
||||
entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt));
|
||||
}
|
||||
|
||||
// v1.17.1 parity — SLEEP_DURATION enters `byType` as RAW per-stage rows (a
|
||||
// 40-min DEEP block, a 12-min AWAKE block, …). Every other sleep surface
|
||||
@@ -759,7 +844,6 @@ export async function collectDoctorReportData(
|
||||
// and the v1.17.1 stamping fixes rewrite exactly those raw stage rows. Route
|
||||
// the report's sleep value through the same engine — one number, one surface
|
||||
// — exactly as RECOVERY_SCORE routes through `summariseCanonicalRecovery`.
|
||||
const reportTz = userProfile?.timezone ?? "Europe/Berlin";
|
||||
const sleepRows = measurements.filter(
|
||||
(m) => m.type === "SLEEP_DURATION",
|
||||
) as unknown as SleepStageRow[];
|
||||
|
||||
@@ -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