diff --git a/messages/de.json b/messages/de.json index 9abff5ab1..857f62f14 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 9efa8c1b5..c706b06a8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index b44f90227..ac3d35f3a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 7510ec4ef..cf509b224 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 8082b599e..cb6c1446e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 9aa72c2a0..7fbf98025 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 9c3bfc1f1..85b194085 100644 Binary files a/src/lib/__tests__/doctor-report-data.test.ts and b/src/lib/__tests__/doctor-report-data.test.ts differ diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 5b0dcd089..68cc6494f 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -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)"); + }); +}); diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 0f61620b9..f4ac832aa 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -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( + measurements: readonly T[], + timezone: string, + sourcePriorityJson: unknown, +): T[] { + const dayKey = (d: Date) => userDayKey(d, timezone); + const byType = new Map(); + 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[]; diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 11fd211b5..fce890833 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -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`. diff --git a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts new file mode 100644 index 000000000..7686c700b --- /dev/null +++ b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts @@ -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 { + 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) { + 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); + }); +}); diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index cd65a48af..d360a8e12 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -304,6 +304,21 @@ export const GLUCOSE_LOINC: Record = 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). */ diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts index 41c6e55dd..93de33db6 100644 --- a/src/lib/fhir/resources.ts +++ b/src/lib/fhir/resources.ts @@ -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