From 3be7c3788b3779da768f267f9a5d4089ff596867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 19:03:34 +0200 Subject: [PATCH] Serve glucose clinical panel from one engine to all surfaces --- docs/api/openapi.yaml | 136 ++++++++++++++++++ src/app/api/analytics/route.ts | 18 +++ .../__tests__/clinician-view.test.tsx | 4 + .../__tests__/doctor-report-pdf-core.test.ts | 2 + src/lib/ai/coach/snapshot.ts | 60 +++++++- src/lib/dashboard/snapshot.ts | 24 ++++ src/lib/doctor-report-data.ts | 27 ++++ src/lib/fhir/__tests__/build-bundle.test.ts | 4 + .../fhir/__tests__/cycle-observations.test.ts | 4 + src/lib/openapi/routes/insights.ts | 55 +++++++ src/lib/queries/use-analytics-query.ts | 9 ++ 11 files changed, 342 insertions(+), 1 deletion(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5a859419a..69e86a399 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13688,6 +13688,8 @@ components: propertyNames: type: string additionalProperties: {} + glucoseClinical: + $ref: "#/components/schemas/GlucoseClinicalMetrics" required: - bpInTargetPct - bpInTargetPct7d @@ -13696,6 +13698,7 @@ components: - bpInTargetPctPriorMonth - bpInTargetPctPriorYear - glucoseByContext + - glucoseClinical additionalProperties: false - type: "null" medsToday: @@ -13817,6 +13820,139 @@ components: `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip." + GlucoseClinicalMetrics: + type: object + properties: + stillLearning: + type: boolean + stillLearningReason: + anyOf: + - type: string + - type: "null" + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + actualSpanDays: + type: number + readingCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + meanMgdl: + anyOf: + - type: number + - type: "null" + distribution: + anyOf: + - type: object + properties: + tir: + type: number + tbrLevel1: + type: number + tbrLevel2: + type: number + tarLevel1: + type: number + tarLevel2: + type: number + minutesEquivalent: + type: object + properties: + tir: + type: number + tbrLevel1: + type: number + tbrLevel2: + type: number + tarLevel1: + type: number + tarLevel2: + type: number + required: + - tir + - tbrLevel1 + - tbrLevel2 + - tarLevel1 + - tarLevel2 + additionalProperties: false + required: + - tir + - tbrLevel1 + - tbrLevel2 + - tarLevel1 + - tarLevel2 + - minutesEquivalent + additionalProperties: false + - type: "null" + gmi: + anyOf: + - type: number + - type: "null" + estimatedA1c: + anyOf: + - type: number + - type: "null" + variability: + anyOf: + - type: object + properties: + sd: + type: number + cv: + type: number + unstable: + type: boolean + required: + - sd + - cv + - unstable + additionalProperties: false + - type: "null" + advanced: + anyOf: + - type: object + properties: + jIndex: + anyOf: + - type: number + - type: "null" + lbgi: + type: number + hbgi: + type: number + required: + - jIndex + - lbgi + - hbgi + additionalProperties: false + - type: "null" + isSpotEstimate: + type: boolean + const: true + required: + - stillLearning + - stillLearningReason + - windowDays + - actualSpanDays + - readingCount + - meanMgdl + - distribution + - gmi + - estimatedA1c + - variability + - advanced + - isSpotEstimate + additionalProperties: false + description: Server-authoritative glucose clinical panel over the trailing 30-day window. Every figure is a SPOT-READING + ESTIMATE (a % of readings), not a CGM time-in-range AGP — `isSpotEstimate` is always true and `stillLearning` + gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino + 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal + 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 + ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev + hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading + window. InsightsComprehensiveResponseEnvelope: type: object properties: diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index bd82bec73..39643f8a1 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -20,6 +20,10 @@ import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fas import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; +import { + computeGlucoseClinicalMetrics, + type GlucoseClinicalMetrics, +} from "@/lib/analytics/glucose-metrics"; export const dynamic = "force-dynamic"; @@ -365,6 +369,19 @@ async function buildAnalyticsResponse(user: AuthedUser) { } } + // v1.17.0 — server-authoritative glucose clinical panel. The TIR / GMI / + // eA1C / CV% headline and the advanced J-index + LBGI/HBGI tier are computed + // ONCE here over the declared 30-day window (matching `glucoseSince`) so the + // insights panel, the coach snapshot, and the doctor report all render the + // same numbers instead of each re-deriving. The learning gate keeps a thin + // spot-data window from being asserted as a clinical AGP. Always present in + // the payload (even with zero readings) so the client renders the calm + // "still learning" state from a populated object rather than a missing key. + const glucoseClinical: GlucoseClinicalMetrics = computeGlucoseClinicalMetrics( + glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })), + { windowDays: 30, now: new Date() }, + ); + // v1.4.20 phase B3 — three pre-defined correlation hypotheses. // v1.4.37 W2 — probe-gated helper. The 28-day scan window (down // from 30 to keep the cold critical path tight while still @@ -420,6 +437,7 @@ async function buildAnalyticsResponse(user: AuthedUser) { bpInTargetCount90, bpInTargetSpanDays90, glucoseByContext, + glucoseClinical, correlations, healthScore, sleepStages, diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index 34bbe9b2a..74dd4606e 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -12,6 +12,7 @@ import { ClinicianView } from "../clinician-view"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; function makeReport( overrides: Partial = {}, @@ -36,6 +37,9 @@ function makeReport( }, glucoseStats: {}, glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { + now: new Date("2026-01-31T00:00:00.000Z"), + }), glucoseUnit: "mg/dL", bmi: 24.5, compliance: {}, diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index cf93d39f0..5b0dcd089 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -10,6 +10,7 @@ import { } from "../doctor-report-pdf-core"; import { measurementTypeEnum } from "../validations/measurement"; import type { DoctorReportData } from "../doctor-report-data"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import { getServerTranslator } from "../i18n/server-translator"; async function extractText(bytes: Uint8Array): Promise { @@ -67,6 +68,7 @@ function makeData(overrides?: Partial): DoctorReportData { FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 }, }, glucoseRanges: { FASTING: { min: 70, max: 99 } }, + glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }), glucoseUnit: "mg/dL", bmi: 24.1, compliance: { diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index c5a052a99..865ae4a00 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -24,6 +24,7 @@ import { } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import { reconstructSleepNights, type SleepStageRow, @@ -1344,9 +1345,66 @@ async function buildCoachSnapshotImpl( ); contexts[ctx] = { recent, weekly }; } + // v1.17.0 — clinical panel summary from the ONE literature-locked + // engine the insights panel + doctor report also consume, so the coach + // never quotes a TIR / GMI / CV% figure the panel doesn't show. Computed + // on canonical mg/dL over the resolved coach window; gated by + // `stillLearning` so a thin spot-data window is offered as a calm + // "still learning" note rather than asserted as a clinical AGP. The + // headline mean is converted ONCE to the user's display unit for parity + // with the per-context lines above; the unit-agnostic fractions / indices + // travel as-is. + const clinicalRaw = computeGlucoseClinicalMetrics( + glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })), + { windowDays: windowDays, now }, + ); + const clinical = clinicalRaw.stillLearning + ? { + stillLearning: true as const, + reason: clinicalRaw.stillLearningReason, + readingCount: clinicalRaw.readingCount, + spanDays: Math.round(clinicalRaw.actualSpanDays), + } + : { + stillLearning: false as const, + windowDays: clinicalRaw.windowDays, + spanDays: Math.round(clinicalRaw.actualSpanDays), + readingCount: clinicalRaw.readingCount, + meanInRange: + clinicalRaw.meanMgdl !== null + ? Math.round( + convertGlucose(clinicalRaw.meanMgdl, glucoseUnit) * + (glucoseUnit === "mmol/L" ? 10 : 1), + ) / (glucoseUnit === "mmol/L" ? 10 : 1) + : null, + tirPercent: clinicalRaw.distribution + ? Math.round(clinicalRaw.distribution.tir * 100) + : null, + timeBelowPercent: clinicalRaw.distribution + ? Math.round(clinicalRaw.distribution.tbrLevel1 * 100) + : null, + timeAbovePercent: clinicalRaw.distribution + ? Math.round(clinicalRaw.distribution.tarLevel1 * 100) + : null, + gmi: + clinicalRaw.gmi !== null + ? Math.round(clinicalRaw.gmi * 10) / 10 + : null, + estimatedA1c: + clinicalRaw.estimatedA1c !== null + ? Math.round(clinicalRaw.estimatedA1c * 10) / 10 + : null, + cvPercent: clinicalRaw.variability + ? Math.round(clinicalRaw.variability.cv) + : null, + unstable: clinicalRaw.variability?.unstable ?? null, + // Spot-reading estimate, never a CGM AGP — pinned so the model + // never narrates these as continuous-trace clinical figures. + isSpotEstimate: true as const, + }; // The display unit travels with the block so the prompt renders // " " and the EVIDENCE BLOCK tags glucose lines correctly. - snapshot.glucose = { unit: glucoseUnit, byContext: contexts }; + snapshot.glucose = { unit: glucoseUnit, byContext: contexts, clinical }; metrics.add("glucose"); counts.glucose = glucoseRows.length; registerBlock("glucose", "glucose"); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index b0ecb259e..0500d1cd6 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -46,6 +46,10 @@ import { type DataSummary, } from "@/lib/analytics/trends"; import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { + computeGlucoseClinicalMetrics, + type GlucoseClinicalMetrics, +} from "@/lib/analytics/glucose-metrics"; import { computeBpInTargetFastPath, type BpInTargetEnvelope, @@ -206,6 +210,17 @@ export interface DashboardSnapshotExtras { bpInTargetCount90: number | null; bpInTargetSpanDays90: number | null; glucoseByContext: Record; + /** + * v1.17.0 — server-authoritative glucose clinical panel over the trailing + * 30-day window: the learning gate (+ reason), window/span/count, mean, + * Battelino TIR distribution, GMI, estimated A1C, SD/CV%/instability, and the + * advanced J-index + LBGI/HBGI tier. Computed ONCE by + * `computeGlucoseClinicalMetrics` so the iOS client renders the same numbers + * the web panel, the coach, and the doctor report do — iOS never re-derives. + * Always present (even with zero readings) so the native client paints the + * calm "still learning" state from a populated object. + */ + glucoseClinical: GlucoseClinicalMetrics; } /** @@ -565,6 +580,14 @@ async function buildExtras( } } + // v1.17.0 — clinical panel from the SAME 30-day glucose rows already read + // above (no extra DB hop), computed by the one literature-locked engine so + // the iOS cold-launch seed matches the web panel / coach / doctor report. + const glucoseClinical = computeGlucoseClinicalMetrics( + glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })), + { windowDays: 30, now }, + ); + return { extras: { bpInTargetPct, @@ -576,6 +599,7 @@ async function buildExtras( bpInTargetCount90, bpInTargetSpanDays90, glucoseByContext, + glucoseClinical, }, healthScore, }; diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 9e33e35b1..0aa635833 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -18,6 +18,10 @@ import { thresholdMetricForContext, type GlucoseUnit, } from "@/lib/glucose"; +import { + computeGlucoseClinicalMetrics, + type GlucoseClinicalMetrics, +} from "@/lib/analytics/glucose-metrics"; import type { GlucoseContext, MeasurementSource, @@ -96,6 +100,16 @@ export interface DoctorReportData { glucoseStats: Record; glucoseRanges: Record; glucoseUnit: GlucoseUnit; + /** + * v1.17.0 — server-authoritative glucose clinical panel over the report + * period (TIR / GMI / estimated A1C / CV% + the advanced J-index + LBGI/HBGI + * tier), computed by `computeGlucoseClinicalMetrics` so the PDF/FHIR export + * carries the SAME numbers the insights panel and the coach show. Values are + * canonical mg/dL; `glucoseUnit` drives display conversion at render. Carried + * as a spot-reading estimate (`isSpotEstimate: true`), gated by + * `stillLearning` when the period is too thin to assert clinically. + */ + glucoseClinical: GlucoseClinicalMetrics; bmi: number | null; compliance: Record; medications: Array<{ @@ -869,6 +883,18 @@ export async function collectDoctorReportData( } } + // v1.17.0 — clinical panel over the WHOLE report period (all contexts + // pooled), computed by the one literature-locked engine the insights panel + // and the coach also consume. `windowDays` is the report's own period so the + // TIR / GMI / eA1C / CV% reflect exactly the readings the rest of the report + // tabulates; `now: end` anchors the window to the report's upper bound. The + // learning gate keeps a thin period from asserting a clinical AGP off spot + // data. Values stay canonical mg/dL; the renderer converts with `glucoseUnit`. + const glucoseClinical = computeGlucoseClinicalMetrics( + glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })), + { windowDays: days, now: end }, + ); + // `practiceName` is sanitised to a single-line string with a hard length // cap. The PDF cover prints it verbatim — never let unbounded input land in // a layout-sensitive header. @@ -1056,6 +1082,7 @@ export async function collectDoctorReportData( stats: filteredStats, glucoseStats, glucoseRanges, + glucoseClinical, glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null), bmi: filteredBmi, compliance: filteredCompliance, diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index f1312e1d7..efa480f9e 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { buildFhirDocumentBundle } from "../build-bundle"; import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; import type { FhirObservation, FhirMedicationStatement, @@ -77,6 +78,9 @@ function makeData(overrides?: Partial): DoctorReportData { FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 }, }, glucoseRanges: { FASTING: { min: 70, max: 99 } }, + glucoseClinical: computeGlucoseClinicalMetrics([], { + now: new Date("2026-05-03T12:00:00.000Z"), + }), glucoseUnit: "mg/dL", bmi: 24.1, compliance: { diff --git a/src/lib/fhir/__tests__/cycle-observations.test.ts b/src/lib/fhir/__tests__/cycle-observations.test.ts index b9f61f25d..02021f389 100644 --- a/src/lib/fhir/__tests__/cycle-observations.test.ts +++ b/src/lib/fhir/__tests__/cycle-observations.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; 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 { @@ -28,6 +29,9 @@ function baseData(overrides?: Partial): DoctorReportData { stats: {}, glucoseStats: {}, glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { + now: new Date("2026-05-03T12:00:00.000Z"), + }), glucoseUnit: "mg/dL", bmi: null, compliance: {}, diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts index 6a97d605b..5db110d2b 100644 --- a/src/lib/openapi/routes/insights.ts +++ b/src/lib/openapi/routes/insights.ts @@ -541,6 +541,60 @@ const insightsPregenerateResponse = z // modules and the iOS client does not consume this web-only route. const dataSummaryRecord = z.record(z.string(), z.unknown()); +// v1.17.0 — server-authoritative glucose clinical panel. Mirrors +// `GlucoseClinicalMetrics` from `@/lib/analytics/glucose-metrics`: the +// trailing-30-day TIR / GMI / eA1C / CV% headline plus the advanced +// J-index + LBGI/HBGI tier, gated by a `stillLearning` flag so a thin +// spot-data window is never asserted as a clinical AGP. The iOS client +// renders these numbers verbatim and never re-derives them. +const glucoseClinicalSchema = z + .object({ + stillLearning: z.boolean(), + stillLearningReason: z.string().nullable(), + windowDays: z.number().int(), + actualSpanDays: z.number(), + readingCount: z.number().int(), + meanMgdl: z.number().nullable(), + distribution: z + .object({ + tir: z.number(), + tbrLevel1: z.number(), + tbrLevel2: z.number(), + tarLevel1: z.number(), + tarLevel2: z.number(), + minutesEquivalent: z.object({ + tir: z.number(), + tbrLevel1: z.number(), + tbrLevel2: z.number(), + tarLevel1: z.number(), + tarLevel2: z.number(), + }), + }) + .nullable(), + gmi: z.number().nullable(), + estimatedA1c: z.number().nullable(), + variability: z + .object({ + sd: z.number(), + cv: z.number(), + unstable: z.boolean(), + }) + .nullable(), + advanced: z + .object({ + jIndex: z.number().nullable(), + lbgi: z.number(), + hbgi: z.number(), + }) + .nullable(), + isSpotEstimate: z.literal(true), + }) + .meta({ + id: "GlucoseClinicalMetrics", + description: + "Server-authoritative glucose clinical panel over the trailing 30-day window. Every figure is a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP — `isSpotEstimate` is always true and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.", + }); + const dashboardSnapshotResponse = z .object({ user: z.object({ @@ -608,6 +662,7 @@ const dashboardSnapshotResponse = z bpInTargetPctPriorMonth: z.number().nullable(), bpInTargetPctPriorYear: z.number().nullable(), glucoseByContext: dataSummaryRecord, + glucoseClinical: glucoseClinicalSchema, }) .nullable(), // Dashboard hero — today's medication block (fast phase, always diff --git a/src/lib/queries/use-analytics-query.ts b/src/lib/queries/use-analytics-query.ts index 066c9e655..2fb964a2b 100644 --- a/src/lib/queries/use-analytics-query.ts +++ b/src/lib/queries/use-analytics-query.ts @@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; import type { DataSummary } from "@/lib/analytics/trends"; +import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; /** * v1.4.33 IW2 — single TanStack-Query wrapper for `/api/analytics`. @@ -71,6 +72,14 @@ export interface AnalyticsRawPayload { bpInTargetPctPriorMonth?: number | null; bpInTargetPctPriorYear?: number | null; glucoseByContext?: Record; + /** + * v1.17.0 — server-authoritative glucose clinical panel (TIR / GMI / eA1C / + * CV% headline + the advanced J-index + LBGI/HBGI tier), computed once over + * the trailing-30-day window. Present on the thick default slice; absent on + * the slim `?slice=summaries` branch. The panel renders this verbatim and + * never re-derives a clinical number client-side. + */ + glucoseClinical?: GlucoseClinicalMetrics; correlations?: unknown; healthScore?: unknown; sleepStages?: unknown;