From 3dfc3fe51524d7a714e42257625130fe144fe7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 18:55:13 +0200 Subject: [PATCH 1/4] Add J-index and LBGI/HBGI to glucose clinical metrics --- .../__tests__/glucose-metrics.test.ts | 68 ++++++++++ src/lib/analytics/glucose-metrics.ts | 124 ++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/src/lib/analytics/__tests__/glucose-metrics.test.ts b/src/lib/analytics/__tests__/glucose-metrics.test.ts index 0fdfbd685..0678a4803 100644 --- a/src/lib/analytics/__tests__/glucose-metrics.test.ts +++ b/src/lib/analytics/__tests__/glucose-metrics.test.ts @@ -10,11 +10,13 @@ import { describe, expect, it } from "vitest"; import { CV_INSTABILITY_THRESHOLD, DEFAULT_WINDOW_DAYS, + bloodGlucoseRiskIndices, computeGlucoseClinicalMetrics, estimatedA1c, glucoseSD, glucoseVariability, gmi, + jIndex, timeInRange, type GlucoseReading, } from "../glucose-metrics"; @@ -128,6 +130,51 @@ describe("timeInRange (Battelino 2019 bands)", () => { }); }); +describe("jIndex (Wojcicki 1995)", () => { + it("returns null for fewer than two values", () => { + expect(jIndex([])).toBeNull(); + expect(jIndex([120])).toBeNull(); + }); + + it("folds mean + SD: J = 0.001 × (mean + SD)²", () => { + // 14 identical 154 readings → SD 0 → J = 0.001 × 154² = 23.716 + expect(jIndex(Array.from({ length: 14 }, () => 154))).toBeCloseTo( + 23.716, + 3, + ); + // {100,110,120,130,140}: mean 120, sample SD √250 = 15.81139 → + // J = 0.001 × 135.81139² = 18.444733 + expect(jIndex([100, 110, 120, 130, 140])).toBeCloseTo(18.444733, 5); + }); +}); + +describe("bloodGlucoseRiskIndices (Kovatchev 1997/2006)", () => { + it("returns null when no usable reading remains", () => { + expect(bloodGlucoseRiskIndices([])).toBeNull(); + // non-positive / non-finite readings are skipped (ln undefined) + expect(bloodGlucoseRiskIndices([0, -5, NaN])).toBeNull(); + }); + + it("splits a low/high pair onto the correct branches", () => { + // f(50) = -1.500015 → low branch r = 22.500445 + // f(300) = 1.842607 → high branch r = 33.951988 + // n = 2 → LBGI = 22.500445/2 = 11.250223, HBGI = 33.951988/2 = 16.975994 + const r = bloodGlucoseRiskIndices([50, 300]); + expect(r).not.toBeNull(); + expect(r!.lbgi).toBeCloseTo(11.250223, 5); + expect(r!.hbgi).toBeCloseTo(16.975994, 5); + }); + + it("reads near-zero hypo risk and zero hyper risk for a stable normoglycaemic set", () => { + // every reading sits below the ~112.5 symmetrization centre, so HBGI = 0 + const r = bloodGlucoseRiskIndices([100, 110, 95, 105, 100]); + expect(r!.hbgi).toBe(0); + expect(r!.lbgi).toBeCloseTo(0.427869, 5); + // minimal-risk band (< 1.1) + expect(r!.lbgi).toBeLessThan(1.1); + }); +}); + describe("computeGlucoseClinicalMetrics — window + adequacy", () => { const now = new Date("2026-06-14T12:00:00Z"); @@ -173,6 +220,7 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => { expect(m.gmi).toBeNull(); expect(m.estimatedA1c).toBeNull(); expect(m.variability).toBeNull(); + expect(m.advanced).toBeNull(); }); it("fires below the minimum reading count, reporting N and D", () => { @@ -220,6 +268,26 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => { expect(m.distribution!.tir).toBeCloseTo(1, 10); // all in range expect(m.variability!.sd).toBe(0); expect(m.variability!.unstable).toBe(false); + // advanced tier: J-index from mean+SD, LBGI/HBGI from the risk model. + // mean 154, SD 0 → J = 0.001 × 154² = 23.716. + expect(m.advanced).not.toBeNull(); + expect(m.advanced!.jIndex).toBeCloseTo(23.716, 3); + // 154 mg/dL sits above the symmetrization centre → all hyper risk, + // zero hypo risk. + expect(m.advanced!.lbgi).toBe(0); + expect(m.advanced!.hbgi).toBeGreaterThan(0); + }); + + it("resolves LBGI/HBGI but holds J-index null for a single reading", () => { + const m = computeGlucoseClinicalMetrics(dailyReadings([100], now), { + now, + }); + expect(m.advanced).not.toBeNull(); + // one reading → sample SD undefined → J held null + expect(m.advanced!.jIndex).toBeNull(); + // the risk indices still resolve from the lone reading + expect(m.advanced!.hbgi).toBe(0); + expect(m.advanced!.lbgi).toBeGreaterThan(0); }); it("honours configurable thresholds", () => { diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts index 67d5de31b..10d4675eb 100644 --- a/src/lib/analytics/glucose-metrics.ts +++ b/src/lib/analytics/glucose-metrics.ts @@ -30,6 +30,10 @@ * Bergenstal 2018, Diabetes Care 41(11):2275-2280, DOI 10.2337/dc18-1581. * Nathan 2008 (ADAG), Diabetes Care 31(8):1473-1478, DOI 10.2337/dc08-0545. * Monnier 2017, Diabetes Care 40(7):832-838, DOI 10.2337/dc16-1769. + * Wojcicki 1995 (J-index), Horm Metab Res 27(1):41-42, DOI 10.1055/s-2007-979906. + * Kovatchev 1997 (symmetrization + BGI), Diabetes Care 20(11):1655-1658, + * DOI 10.2337/diacare.20.11.1655; Kovatchev 2006 (LBGI/HBGI risk model), + * Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085. */ // ── Inputs ─────────────────────────────────────────────── @@ -118,6 +122,41 @@ export interface TimeInRangeDistribution { }; } +/** + * Advanced glycaemic indices — the "advanced" disclosure tier of the panel. + * Each is a single scalar over the in-window readings; all are derivable from + * spot samples (unlike the dense-stream MAGE/CONGA/MODD family this module + * deliberately defers). Surfaced behind a progressive disclosure because they + * read as research-grade composites a casual user does not need up front. + */ +export interface GlucoseAdvancedIndices { + /** + * J-index — a single number folding central tendency AND variability: + * J = 0.001 × (mean + SD)², mean & SD in mg/dL. + * Reference bands (Wojcicki 1995): ~10–20 ideal/non-diabetic, + * 20–30 good control, 30–40 fair, > 40 poor. Reported here as an estimate + * over spot data, not a continuous-trace J. + * Wojcicki 1995, Horm Metab Res 27(1):41-42, DOI 10.1055/s-2007-979906. + */ + jIndex: number; + /** + * Low Blood Glucose Index — Kovatchev hypoglycaemia risk. Mean of the + * left-branch risk values `rl(BG)` over all readings; higher = more / deeper + * lows. Interpretation bands (Kovatchev 2006): < 1.1 minimal, 1.1–2.5 low, + * 2.5–5 moderate, > 5 high hypo risk. + * Kovatchev 2006, Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085. + */ + lbgi: number; + /** + * High Blood Glucose Index — Kovatchev hyperglycaemia risk. Mean of the + * right-branch risk values `rh(BG)`; higher = more / higher highs. + * Interpretation bands (Kovatchev 2006): < 4.5 low, 4.5–9 moderate, + * > 9 high hyper risk. + * Kovatchev 2006, Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085. + */ + hbgi: number; +} + /** Variability summary: SD + CV% with the Monnier stability flag. */ export interface GlucoseVariability { /** Sample standard deviation (n−1), mg/dL. */ @@ -160,6 +199,18 @@ export interface GlucoseClinicalMetrics { /** SD + CV% + instability flag (null if < 2 readings). */ variability: GlucoseVariability | null; + /** + * Advanced indices (J-index + LBGI/HBGI). Null when there are no readings; + * the J-index additionally needs ≥ 2 readings (it consumes the sample SD), so + * `jIndex` is held back to null inside the object when only one reading + * exists. Shown behind the panel's "advanced" disclosure. + */ + advanced: { + jIndex: number | null; + lbgi: number; + hbgi: number; + } | null; + /** * Always true for this module: every number is a spot-reading estimate, not a * CGM AGP. Surfaced explicitly so serializers (panel/coach/PDF/iOS) carry the @@ -228,6 +279,65 @@ export function estimatedA1c(meanMgdl: number): number { return (meanMgdl + 46.7) / 28.7; } +/** + * J-index — a single composite of central tendency and variability: + * J = 0.001 × (mean + SD)², both in mg/dL. + * Returns null for n < 2 (the sample SD is undefined). Approximation bands are + * documented on {@link GlucoseAdvancedIndices.jIndex}. + * Wojcicki 1995, Horm Metab Res 27(1):41-42, DOI 10.1055/s-2007-979906. + */ +export function jIndex(values: number[]): number | null { + const sd = glucoseSD(values); + if (sd === null) return null; + const m = mean(values); + const s = m + sd; + return 0.001 * s * s; +} + +/** + * Kovatchev symmetrization transform for a single mg/dL reading: + * f(BG) = 1.509 × ( (ln BG)^1.084 − 5.381 ). + * The transform maps the asymmetric mg/dL scale onto a symmetric axis where + * the clinical centre (~112.5 mg/dL) is 0; lows go negative, highs positive. + * Kovatchev 1997, Diabetes Care 20(11):1655-1658, + * DOI 10.2337/diacare.20.11.1655. + */ +function kovatchevSymmetrize(bgMgdl: number): number { + return 1.509 * (Math.pow(Math.log(bgMgdl), 1.084) - 5.381); +} + +/** + * Low / High Blood Glucose Index (LBGI / HBGI) — Kovatchev risk model. + * Per reading the risk is `r(BG) = 10 × f(BG)²` (f = symmetrization above); + * it is assigned to the LOW branch when f < 0 and the HIGH branch when f > 0 + * (f = 0 contributes 0 to both). LBGI = mean of the low-branch risks across + * ALL readings (zeros included), HBGI = mean of the high-branch risks. + * Readings ≤ 0 mg/dL (ln undefined) are skipped from the denominator. + * Returns null when no usable reading remains. + * Kovatchev 2006, Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085. + */ +export function bloodGlucoseRiskIndices( + values: number[], +): { lbgi: number; hbgi: number } | null { + let lowSum = 0; + let highSum = 0; + let n = 0; + for (const bg of values) { + if (!Number.isFinite(bg) || bg <= 0) continue; + const f = kovatchevSymmetrize(bg); + const r = 10 * f * f; + if (f < 0) { + lowSum += r; + } else if (f > 0) { + highSum += r; + } + // f === 0 contributes 0 to both branches but still counts in n. + n += 1; + } + if (n === 0) return null; + return { lbgi: lowSum / n, hbgi: highSum / n }; +} + /** * Time-in-range distribution over the Battelino 2019 consensus bands, as * fractions of readings (NOT % of time — spot data, labelled honestly). @@ -334,6 +444,19 @@ export function computeGlucoseClinicalMetrics( const gmiValue = meanMgdl !== null ? gmi(meanMgdl) : null; const eA1cValue = meanMgdl !== null ? estimatedA1c(meanMgdl) : null; + // Advanced indices — null when no readings. The risk indices need ≥ 1 usable + // reading; the J-index needs ≥ 2 (sample SD), so it is held to null inside + // the object for a single-reading window while LBGI/HBGI still resolve. + const risk = bloodGlucoseRiskIndices(values); + const advanced = + risk !== null + ? { + jIndex: jIndex(values), + lbgi: risk.lbgi, + hbgi: risk.hbgi, + } + : null; + // Learning/adequacy gate. Spot data cannot meet the CGM adequacy bar; this // is the honest "is the estimate worth asserting yet" check. Order matters: // report the most actionable reason first. @@ -370,6 +493,7 @@ export function computeGlucoseClinicalMetrics( gmi: gmiValue, estimatedA1c: eA1cValue, variability, + advanced, isSpotEstimate: true, }; } 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 2/4] 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; From 4b9f31f053197a42a3f893817200ef0ab203f4a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 19:11:57 +0200 Subject: [PATCH 3/4] Add glucose clinical panel with advanced-index disclosure --- messages/de.json | 65 +++++ messages/en.json | 65 +++++ messages/es.json | 65 +++++ messages/fr.json | 65 +++++ messages/it.json | 65 +++++ messages/pl.json | 65 +++++ src/app/insights/blood-glucose/page.tsx | 2 + .../__tests__/glucose-clinical-panel.test.tsx | 217 +++++++++++++++ .../glucose/glucose-advanced-disclosure.tsx | 159 +++++++++++ .../glucose/glucose-clinical-panel.tsx | 254 ++++++++++++++++++ .../insights/glucose/glucose-tir-bar.tsx | 102 +++++++ .../insights/healthkit-metric-page.tsx | 13 + .../__tests__/glucose-panel-coherence.test.ts | 132 +++++++++ 13 files changed, 1269 insertions(+) create mode 100644 src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx create mode 100644 src/components/insights/glucose/glucose-advanced-disclosure.tsx create mode 100644 src/components/insights/glucose/glucose-clinical-panel.tsx create mode 100644 src/components/insights/glucose/glucose-tir-bar.tsx create mode 100644 src/lib/analytics/__tests__/glucose-panel-coherence.test.ts diff --git a/messages/de.json b/messages/de.json index cfea1b804..1107a5032 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2795,6 +2795,71 @@ "emptyState": { "title": "Noch keine Glukosedaten", "description": "Blutzuckerwerte erscheinen hier, sobald du einen erfasst oder ein Gerät synchronisiert." + }, + "clinical": { + "title": "Glukose-Überblick", + "subtitle": "Ein Blick auf deine letzten {days} Tage, aus {count} Messungen.", + "spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors – lies sie als Richtung, nicht als klinisches Urteil.", + "learningTitle": "Lerne deine Glukose noch kennen", + "learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer – mess weiter oder lass es ein verbundenes Gerät ergänzen.", + "learningBodyEmpty": "Sobald ein paar Messungen vorliegen – von dir erfasst oder von einem Gerät synchronisiert – nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.", + "readingsCount": "{count} Messungen", + "readingsCountOne": "1 Messung", + "daysCount": "{days} Tage", + "daysCountOne": "1 Tag", + "tir": { + "title": "Zeit im Zielbereich", + "caption": "Anteil der Messungen je Bereich (70–180 mg/dl ist das Ziel).", + "veryLow": "Sehr niedrig", + "low": "Niedrig", + "inRange": "Im Zielbereich", + "high": "Hoch", + "veryHigh": "Sehr hoch", + "ariaLabel": "Zeit im Zielbereich: {tir}% im Ziel, {below}% darunter, {above}% darüber.", + "empty": "Noch keine Messungen für das Diagramm." + }, + "gmi": { + "label": "Glukose-Management-Indikator", + "help": "Eine HbA1c-ähnliche Schätzung aus deiner durchschnittlichen Glukose." + }, + "eA1c": { + "label": "Geschätzter HbA1c", + "help": "Eine HbA1c-Schätzung aus deinem mittleren Glukosewert (ADAG)." + }, + "mean": { + "label": "Durchschnittliche Glukose" + }, + "cv": { + "label": "Schwankung", + "steady": "Stabil", + "unstable": "Instabil", + "help": "Variationskoeffizient. Ab 36 % gilt die Glukose als instabil; niedriger ist stabiler." + }, + "advanced": { + "toggle": "Erweiterte Indizes", + "intro": "Forschungsnahe Kennzahlen für den genaueren Blick. Jede fasst deine Messungen zu einer risikogewichteten Zahl zusammen.", + "jIndex": { + "label": "J-Index", + "help": "Ein einzelner Wert aus Durchschnitt und Streuung – niedriger steht für engere Kontrolle." + }, + "lbgi": { + "label": "Risiko für Tiefwerte (LBGI)", + "help": "Kovatchevs Index, gewichtet danach, wie oft und wie tief Werte ins Tief rutschen." + }, + "hbgi": { + "label": "Risiko für Hochwerte (HBGI)", + "help": "Kovatchevs Index, gewichtet danach, wie oft und wie hoch Werte steigen." + }, + "byContext": { + "title": "Nach Kontext", + "fasting": "Nüchtern", + "postprandial": "Nach dem Essen", + "random": "Beliebig", + "bedtime": "Vor dem Schlafen", + "readingsShort": "{count} Messungen", + "readingsShortOne": "1 Messung" + } + } } }, "navBloodGlucose": "Glukose", diff --git a/messages/en.json b/messages/en.json index 0e147aab4..11b2e5520 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2795,6 +2795,71 @@ "emptyState": { "title": "No glucose data yet", "description": "Blood-glucose readings appear here once you log one or a connected device syncs." + }, + "clinical": { + "title": "Glucose overview", + "subtitle": "A read on your last {days} days, from {count} readings.", + "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.", + "learningTitle": "Still learning your glucose", + "learningBody": "So far that's {count} over {days}. A little more history and a fuller picture comes into focus — keep logging, or let a connected device fill it in.", + "learningBodyEmpty": "Once a handful of readings come in — logged by you or synced from a device — your time-in-range, average, and variability take shape here.", + "readingsCount": "{count} readings", + "readingsCountOne": "1 reading", + "daysCount": "{days} days", + "daysCountOne": "1 day", + "tir": { + "title": "Time in range", + "caption": "Share of readings in each band (70–180 mg/dL is the target).", + "veryLow": "Very low", + "low": "Low", + "inRange": "In range", + "high": "High", + "veryHigh": "Very high", + "ariaLabel": "Time in range: {tir}% in target, {below}% below, {above}% above.", + "empty": "No readings to chart yet." + }, + "gmi": { + "label": "Glucose Management Indicator", + "help": "An A1C-like estimate derived from your average glucose." + }, + "eA1c": { + "label": "Estimated A1C", + "help": "An A1C estimate from your mean glucose (ADAG)." + }, + "mean": { + "label": "Average glucose" + }, + "cv": { + "label": "Variability", + "steady": "Steady", + "unstable": "Unstable", + "help": "Coefficient of variation. At or above 36% glucose is considered unstable; lower is steadier." + }, + "advanced": { + "toggle": "Advanced indices", + "intro": "Research-grade composites for a closer look. Each folds your readings into a single risk-weighted number.", + "jIndex": { + "label": "J-index", + "help": "A single score blending your average and its spread — lower reads as tighter control." + }, + "lbgi": { + "label": "Low-glucose risk (LBGI)", + "help": "Kovatchev's index weighting how often and how deeply readings dip low." + }, + "hbgi": { + "label": "High-glucose risk (HBGI)", + "help": "Kovatchev's index weighting how often and how high readings climb." + }, + "byContext": { + "title": "By context", + "fasting": "Fasting", + "postprandial": "After meals", + "random": "Random", + "bedtime": "Bedtime", + "readingsShort": "{count} readings", + "readingsShortOne": "1 reading" + } + } } }, "navBloodGlucose": "Glucose", diff --git a/messages/es.json b/messages/es.json index ee2ea77c3..0a883ebb6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2795,6 +2795,71 @@ "emptyState": { "title": "Aún no hay datos de glucosa", "description": "Las lecturas de glucosa aparecen aquí cuando registras una o un dispositivo sincroniza." + }, + "clinical": { + "title": "Resumen de glucosa", + "subtitle": "Una lectura de tus últimos {days} días, a partir de {count} mediciones.", + "spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de un sensor continuo: léelas como una tendencia, no como un veredicto clínico.", + "learningTitle": "Aún conociendo tu glucosa", + "learningBody": "De momento son {count} en {days}. Con algo más de historial el panorama se aclara: sigue registrando o deja que lo complete un dispositivo conectado.", + "learningBodyEmpty": "En cuanto lleguen unas cuantas mediciones —registradas por ti o sincronizadas desde un dispositivo— tu tiempo en rango, promedio y variabilidad tomarán forma aquí.", + "readingsCount": "{count} mediciones", + "readingsCountOne": "1 medición", + "daysCount": "{days} días", + "daysCountOne": "1 día", + "tir": { + "title": "Tiempo en rango", + "caption": "Proporción de mediciones en cada banda (70–180 mg/dl es el objetivo).", + "veryLow": "Muy bajo", + "low": "Bajo", + "inRange": "En rango", + "high": "Alto", + "veryHigh": "Muy alto", + "ariaLabel": "Tiempo en rango: {tir}% en objetivo, {below}% por debajo, {above}% por encima.", + "empty": "Aún no hay mediciones para el gráfico." + }, + "gmi": { + "label": "Indicador de gestión de la glucosa", + "help": "Una estimación tipo HbA1c derivada de tu glucosa promedio." + }, + "eA1c": { + "label": "HbA1c estimada", + "help": "Una estimación de HbA1c a partir de tu glucosa media (ADAG)." + }, + "mean": { + "label": "Glucosa promedio" + }, + "cv": { + "label": "Variabilidad", + "steady": "Estable", + "unstable": "Inestable", + "help": "Coeficiente de variación. A partir del 36 % la glucosa se considera inestable; menos es más estable." + }, + "advanced": { + "toggle": "Índices avanzados", + "intro": "Compuestos de nivel investigador para mirar más de cerca. Cada uno reduce tus mediciones a un único número ponderado por riesgo.", + "jIndex": { + "label": "Índice J", + "help": "Una puntuación única que combina tu promedio y su dispersión: cuanto más bajo, más ajustado el control." + }, + "lbgi": { + "label": "Riesgo de hipoglucemia (LBGI)", + "help": "Índice de Kovatchev que pondera con qué frecuencia y profundidad bajan las lecturas." + }, + "hbgi": { + "label": "Riesgo de hiperglucemia (HBGI)", + "help": "Índice de Kovatchev que pondera con qué frecuencia y altura suben las lecturas." + }, + "byContext": { + "title": "Por contexto", + "fasting": "En ayunas", + "postprandial": "Después de comer", + "random": "Aleatorio", + "bedtime": "Antes de dormir", + "readingsShort": "{count} mediciones", + "readingsShortOne": "1 medición" + } + } } }, "navBloodGlucose": "Glucosa", diff --git a/messages/fr.json b/messages/fr.json index 54b3b084b..7aeb5ae30 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2795,6 +2795,71 @@ "emptyState": { "title": "Aucune donnée de glycémie", "description": "Les relevés de glycémie apparaissent ici dès que vous en saisissez un ou qu’un appareil se synchronise." + }, + "clinical": { + "title": "Aperçu du glucose", + "subtitle": "Un point sur vos {days} derniers jours, à partir de {count} mesures.", + "spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, pas le profil d’un capteur continu : lisez-les comme une tendance, non comme un verdict clinique.", + "learningTitle": "Votre glucose se découvre encore", + "learningBody": "Pour l’instant, cela fait {count} sur {days}. Avec un peu plus d’historique, le tableau se précise : continuez à enregistrer ou laissez un appareil connecté compléter.", + "learningBodyEmpty": "Dès que quelques mesures arrivent — saisies par vous ou synchronisées depuis un appareil — votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.", + "readingsCount": "{count} mesures", + "readingsCountOne": "1 mesure", + "daysCount": "{days} jours", + "daysCountOne": "1 jour", + "tir": { + "title": "Temps dans la cible", + "caption": "Part des mesures dans chaque plage (70–180 mg/dl est la cible).", + "veryLow": "Très bas", + "low": "Bas", + "inRange": "Dans la cible", + "high": "Élevé", + "veryHigh": "Très élevé", + "ariaLabel": "Temps dans la cible : {tir}% dans la cible, {below}% en dessous, {above}% au-dessus.", + "empty": "Pas encore de mesures à afficher." + }, + "gmi": { + "label": "Indicateur de gestion du glucose", + "help": "Une estimation de type HbA1c dérivée de votre glucose moyen." + }, + "eA1c": { + "label": "HbA1c estimée", + "help": "Une estimation d’HbA1c à partir de votre glucose moyen (ADAG)." + }, + "mean": { + "label": "Glucose moyen" + }, + "cv": { + "label": "Variabilité", + "steady": "Stable", + "unstable": "Instable", + "help": "Coefficient de variation. À partir de 36 %, le glucose est considéré comme instable ; plus bas est plus stable." + }, + "advanced": { + "toggle": "Indices avancés", + "intro": "Des indicateurs de niveau recherche pour regarder de plus près. Chacun condense vos mesures en un seul chiffre pondéré par le risque.", + "jIndex": { + "label": "Indice J", + "help": "Un score unique mêlant votre moyenne et sa dispersion — plus il est bas, plus le contrôle est serré." + }, + "lbgi": { + "label": "Risque d’hypoglycémie (LBGI)", + "help": "Indice de Kovatchev pondérant la fréquence et la profondeur des baisses." + }, + "hbgi": { + "label": "Risque d’hyperglycémie (HBGI)", + "help": "Indice de Kovatchev pondérant la fréquence et la hauteur des hausses." + }, + "byContext": { + "title": "Par contexte", + "fasting": "À jeun", + "postprandial": "Après les repas", + "random": "Aléatoire", + "bedtime": "Au coucher", + "readingsShort": "{count} mesures", + "readingsShortOne": "1 mesure" + } + } } }, "navBloodGlucose": "Glucose", diff --git a/messages/it.json b/messages/it.json index 04730edac..e488d02ef 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2795,6 +2795,71 @@ "emptyState": { "title": "Nessun dato di glicemia", "description": "Le letture di glicemia appaiono qui quando ne registri una o un dispositivo si sincronizza." + }, + "clinical": { + "title": "Panoramica della glicemia", + "subtitle": "Una lettura dei tuoi ultimi {days} giorni, da {count} misurazioni.", + "spotCaveat": "Sono stime da singole misurazioni puntuali, non il profilo di un sensore continuo: leggile come una tendenza, non come un verdetto clinico.", + "learningTitle": "Stiamo ancora imparando la tua glicemia", + "learningBody": "Per ora sono {count} su {days}. Con un po’ più di storico il quadro si chiarisce: continua a registrare o lascia che lo completi un dispositivo connesso.", + "learningBodyEmpty": "Appena arrivano alcune misurazioni — registrate da te o sincronizzate da un dispositivo — il tempo nel range, la media e la variabilità prendono forma qui.", + "readingsCount": "{count} misurazioni", + "readingsCountOne": "1 misurazione", + "daysCount": "{days} giorni", + "daysCountOne": "1 giorno", + "tir": { + "title": "Tempo nel range", + "caption": "Quota di misurazioni in ciascuna fascia (70–180 mg/dl è l’obiettivo).", + "veryLow": "Molto basso", + "low": "Basso", + "inRange": "Nel range", + "high": "Alto", + "veryHigh": "Molto alto", + "ariaLabel": "Tempo nel range: {tir}% a target, {below}% sotto, {above}% sopra.", + "empty": "Ancora nessuna misurazione da mostrare." + }, + "gmi": { + "label": "Indicatore di gestione della glicemia", + "help": "Una stima simile all’HbA1c derivata dalla tua glicemia media." + }, + "eA1c": { + "label": "HbA1c stimata", + "help": "Una stima dell’HbA1c dalla tua glicemia media (ADAG)." + }, + "mean": { + "label": "Glicemia media" + }, + "cv": { + "label": "Variabilità", + "steady": "Stabile", + "unstable": "Instabile", + "help": "Coefficiente di variazione. Dal 36 % la glicemia è considerata instabile; più basso è più stabile." + }, + "advanced": { + "toggle": "Indici avanzati", + "intro": "Indicatori di livello scientifico per uno sguardo più ravvicinato. Ognuno riduce le tue misurazioni a un singolo numero ponderato sul rischio.", + "jIndex": { + "label": "Indice J", + "help": "Un punteggio unico che combina media e dispersione: più è basso, più stretto è il controllo." + }, + "lbgi": { + "label": "Rischio di ipoglicemia (LBGI)", + "help": "Indice di Kovatchev che pesa quanto spesso e quanto in basso scendono le letture." + }, + "hbgi": { + "label": "Rischio di iperglicemia (HBGI)", + "help": "Indice di Kovatchev che pesa quanto spesso e quanto in alto salgono le letture." + }, + "byContext": { + "title": "Per contesto", + "fasting": "A digiuno", + "postprandial": "Dopo i pasti", + "random": "Casuale", + "bedtime": "Prima di dormire", + "readingsShort": "{count} misurazioni", + "readingsShortOne": "1 misurazione" + } + } } }, "navBloodGlucose": "Glucosio", diff --git a/messages/pl.json b/messages/pl.json index e3915160a..58d6f7c71 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2795,6 +2795,71 @@ "emptyState": { "title": "Brak danych o glukozie", "description": "Odczyty glukozy pojawią się tutaj po zapisaniu jednego lub synchronizacji urządzenia." + }, + "clinical": { + "title": "Przegląd glukozy", + "subtitle": "Spojrzenie na ostatnie {days} dni, na podstawie {count} pomiarów.", + "spotCaveat": "To szacunki z pojedynczych pomiarów punktowych, a nie profil z czujnika ciągłego — traktuj je jako kierunek, nie jako diagnozę.", + "learningTitle": "Wciąż poznajemy Twoją glukozę", + "learningBody": "Na razie to {count} w ciągu {days}. Przy nieco dłuższej historii obraz się wyostrza — rejestruj dalej lub pozwól uzupełnić to podłączonemu urządzeniu.", + "learningBodyEmpty": "Gdy pojawi się kilka pomiarów — wpisanych przez Ciebie lub zsynchronizowanych z urządzenia — czas w zakresie, średnia i zmienność nabiorą tu kształtu.", + "readingsCount": "{count} pomiarów", + "readingsCountOne": "1 pomiar", + "daysCount": "{days} dni", + "daysCountOne": "1 dzień", + "tir": { + "title": "Czas w zakresie", + "caption": "Udział pomiarów w każdym przedziale (70–180 mg/dl to cel).", + "veryLow": "Bardzo niski", + "low": "Niski", + "inRange": "W zakresie", + "high": "Wysoki", + "veryHigh": "Bardzo wysoki", + "ariaLabel": "Czas w zakresie: {tir}% w celu, {below}% poniżej, {above}% powyżej.", + "empty": "Brak pomiarów do wykresu." + }, + "gmi": { + "label": "Wskaźnik zarządzania glukozą", + "help": "Szacunek zbliżony do HbA1c na podstawie średniej glukozy." + }, + "eA1c": { + "label": "Szacowane HbA1c", + "help": "Szacunek HbA1c na podstawie średniej glukozy (ADAG)." + }, + "mean": { + "label": "Średnia glukoza" + }, + "cv": { + "label": "Zmienność", + "steady": "Stabilna", + "unstable": "Niestabilna", + "help": "Współczynnik zmienności. Od 36 % glukozę uznaje się za niestabilną; niżej znaczy stabilniej." + }, + "advanced": { + "toggle": "Wskaźniki zaawansowane", + "intro": "Złożone miary o poziomie badawczym dla bliższego spojrzenia. Każda sprowadza Twoje pomiary do jednej liczby ważonej ryzykiem.", + "jIndex": { + "label": "Indeks J", + "help": "Pojedynczy wynik łączący średnią i jej rozrzut — niższy oznacza ściślejszą kontrolę." + }, + "lbgi": { + "label": "Ryzyko niedocukrzeń (LBGI)", + "help": "Indeks Kovatcheva ważący, jak często i jak głęboko odczyty spadają." + }, + "hbgi": { + "label": "Ryzyko przecukrzeń (HBGI)", + "help": "Indeks Kovatcheva ważący, jak często i jak wysoko odczyty rosną." + }, + "byContext": { + "title": "Wg kontekstu", + "fasting": "Na czczo", + "postprandial": "Po posiłku", + "random": "Losowy", + "bedtime": "Przed snem", + "readingsShort": "{count} pomiarów", + "readingsShortOne": "1 pomiar" + } + } } }, "navBloodGlucose": "Glukoza", diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index 397999a13..e753ad589 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -3,6 +3,7 @@ import { Droplet } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { resolveGlucoseUnit } from "@/lib/glucose"; @@ -51,6 +52,7 @@ export default function InsightsBlutzuckerPage() { emptyStateIcon={} emptyStateCtaType={null} targetSummarySlug="blood-glucose" + afterChart={} coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?" /> ); diff --git a/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx new file mode 100644 index 000000000..5cc49eed5 --- /dev/null +++ b/src/components/insights/glucose/__tests__/glucose-clinical-panel.test.tsx @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import type { DataSummary } from "@/lib/analytics/trends"; +import { GlucoseTirBar } from "../glucose-tir-bar"; +import { GlucoseAdvancedDisclosure } from "../glucose-advanced-disclosure"; + +/** Minimal valid `DataSummary` — the panel + disclosure only read `count`. */ +function summary(count: number): DataSummary { + return { + count, + latest: 95, + min: 60, + max: 140, + mean: 100, + median: 98, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + }; +} + +/** + * v1.17.0 — glucose clinical panel render tests. + * + * Covers the two states the maintainer called out: the calm "still learning" + * branch (no asserted TIR / GMI off thin spot data) and the asserted branch + * with the advanced progressive disclosure. The panel leans on `useAuth` + + * `useAnalyticsQuery`; both are mocked so the branch under test is + * deterministic. The TIR bar + advanced disclosure are pure props, tested + * directly. + */ + +const authMock = vi.fn(); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => authMock(), +})); + +const analyticsMock = vi.fn(); +vi.mock("@/lib/queries/use-analytics-query", () => ({ + useAnalyticsQuery: () => analyticsMock(), +})); + +// Import after the mocks are registered. +const { GlucoseClinicalPanel } = await import("../glucose-clinical-panel"); + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const NOW = new Date("2026-06-14T12:00:00Z"); +const DAY = 24 * 60 * 60 * 1000; + +beforeEach(() => { + authMock.mockReturnValue({ + user: { glucoseUnit: "mg/dL" }, + isAuthenticated: true, + }); + analyticsMock.mockReset(); +}); + +describe(" learning state", () => { + it("renders the calm still-learning copy and asserts NO TIR/GMI", () => { + // 5 readings over 4 days — below the 14-reading default floor. + const readings = [100, 110, 120, 130, 140].map((mgdl, i) => ({ + measuredAt: new Date(NOW.getTime() - (4 - i) * DAY), + mgdl, + })); + const glucoseClinical = computeGlucoseClinicalMetrics(readings, { now: NOW }); + expect(glucoseClinical.stillLearning).toBe(true); + + analyticsMock.mockReturnValue({ + isLoading: false, + data: { glucoseClinical, glucoseByContext: {} }, + }); + + const html = render(); + expect(html).toContain('data-state="learning"'); + expect(html).toContain("Still learning your glucose"); + // the asserted blocks must NOT be present in the learning state + expect(html).not.toContain("data-slot=\"glucose-tir-bar\""); + expect(html).not.toContain("Glucose Management Indicator"); + // the spot-reading caveat is always carried + expect(html).toContain("spot-reading estimates"); + }); + + it("renders the empty learning copy when there are zero readings", () => { + const glucoseClinical = computeGlucoseClinicalMetrics([], { now: NOW }); + analyticsMock.mockReturnValue({ + isLoading: false, + data: { glucoseClinical, glucoseByContext: {} }, + }); + const html = render(); + expect(html).toContain('data-state="learning"'); + expect(html).toContain("Once a handful of readings come in"); + }); +}); + +describe(" asserted state", () => { + function assertedClinical() { + // 20 readings, one per day → clears the gate; a spread that lands in all + // three TIR bands so the bar has real segments and CV% is non-trivial. + const values = [ + 60, 90, 120, 150, 180, 200, 100, 110, 95, 130, 170, 220, 80, 140, 160, + 105, 115, 125, 135, 145, + ]; + const readings = values.map((mgdl, i) => ({ + measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY), + mgdl, + })); + return computeGlucoseClinicalMetrics(readings, { now: NOW, windowDays: 30 }); + } + + it("renders TIR bar, GMI, eA1C, a CV badge, and the advanced disclosure", () => { + const glucoseClinical = assertedClinical(); + expect(glucoseClinical.stillLearning).toBe(false); + + analyticsMock.mockReturnValue({ + isLoading: false, + data: { + glucoseClinical, + glucoseByContext: { + FASTING: summary(8), + }, + }, + }); + + const html = render(); + expect(html).toContain('data-state="asserted"'); + expect(html).toContain('data-slot="glucose-tir-bar"'); + expect(html).toContain("Glucose Management Indicator"); + expect(html).toContain("Estimated A1C"); + expect(html).toContain('data-slot="glucose-cv-badge"'); + // advanced disclosure trigger is present (region collapsed by default in SSR) + expect(html).toContain('data-slot="glucose-advanced-toggle"'); + expect(html).toContain("aria-expanded=\"false\""); + expect(html).toContain("J-index"); + }); + + it("flags the CV badge unstable at CV% >= 36", () => { + // wide spread → CV well above 36 + const values = Array.from({ length: 20 }, (_, i) => + i % 2 === 0 ? 60 : 260, + ); + const readings = values.map((mgdl, i) => ({ + measuredAt: new Date(NOW.getTime() - (values.length - 1 - i) * DAY), + mgdl, + })); + const glucoseClinical = computeGlucoseClinicalMetrics(readings, { + now: NOW, + windowDays: 30, + }); + expect(glucoseClinical.variability?.unstable).toBe(true); + + analyticsMock.mockReturnValue({ + isLoading: false, + data: { glucoseClinical, glucoseByContext: {} }, + }); + const html = render(); + expect(html).toContain('data-unstable="true"'); + expect(html).toContain("Unstable"); + }); + + it("renders nothing when the thick slice carries no glucoseClinical", () => { + analyticsMock.mockReturnValue({ isLoading: false, data: { summaries: {} } }); + const html = render(); + expect(html).toBe(""); + }); +}); + +describe("", () => { + it("renders one segment per non-empty Battelino band summing to 100%", () => { + const dist = computeGlucoseClinicalMetrics( + [40, 60, 120, 200, 300].map((mgdl, i) => ({ + measuredAt: new Date(NOW.getTime() - i * DAY), + mgdl, + })), + { now: NOW, minReadings: 1, minSpanDays: 1 }, + ).distribution!; + const html = render(); + // very-low (40), low (60), in-range (120), high (200), very-high (300) + expect(html).toContain("glucose-tir-segment-veryLow"); + expect(html).toContain("glucose-tir-segment-low"); + expect(html).toContain("glucose-tir-segment-inRange"); + expect(html).toContain("glucose-tir-segment-high"); + expect(html).toContain("glucose-tir-segment-veryHigh"); + // aria summary present + expect(html).toContain('role="img"'); + }); +}); + +describe("", () => { + it("is collapsed by default and exposes a toggle + the three indices", () => { + const html = render( + , + ); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain("hidden"); // region hidden in initial SSR state + expect(html).toContain("J-index"); + expect(html).toContain("LBGI"); + expect(html).toContain("HBGI"); + // singular reading label for a single-reading context + expect(html).toContain("1 reading"); + }); +}); diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx new file mode 100644 index 000000000..5be77f4ed --- /dev/null +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useId, useState } from "react"; +import { ChevronDown } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import type { DataSummary } from "@/lib/analytics/trends"; + +/** + * v1.17.0 — "advanced" progressive disclosure for the glucose panel. + * + * The default panel view leads with TIR / GMI / eA1C / CV%. The research-grade + * composites the maintainer chose for v1.17.0 — J-index (Wojcicki) and the + * Kovatchev low/high blood-glucose risk indices — live behind this disclosure + * so a casual user is not confronted with them, while a clinician or an engaged + * user can open them. Per-context reading counts ride along so the advanced + * view also answers "where do these readings come from". Values are computed + * server-side; this only renders them. + * + * No Collapsible primitive exists in the UI kit, so this is a minimal, + * accessible button + region disclosure (the same pattern other insights cards + * use): `aria-expanded` on the trigger, `aria-controls`/`hidden` on the region. + */ + +const CONTEXT_LABEL_KEYS: Record = { + FASTING: "insights.bloodGlucose.clinical.advanced.byContext.fasting", + POSTPRANDIAL: "insights.bloodGlucose.clinical.advanced.byContext.postprandial", + RANDOM: "insights.bloodGlucose.clinical.advanced.byContext.random", + BEDTIME: "insights.bloodGlucose.clinical.advanced.byContext.bedtime", +}; + +export interface GlucoseAdvancedDisclosureProps { + advanced: NonNullable; + /** Per-context summaries keyed by `GlucoseContext`; optional. */ + byContext?: Record | undefined; + /** Decimals for the index values. Defaults to 1. */ + fractionDigits?: number; +} + +export function GlucoseAdvancedDisclosure({ + advanced, + byContext, + fractionDigits = 1, +}: GlucoseAdvancedDisclosureProps) { + const { t } = useTranslations(); + const [open, setOpen] = useState(false); + const regionId = useId(); + + const fmt = (v: number | null) => + v === null ? "—" : v.toFixed(fractionDigits); + + const contexts = Object.entries(byContext ?? {}).filter( + ([, s]) => s && s.count > 0, + ); + + return ( +
+ + + +
+ ); +} + +function AdvancedStat({ + label, + help, + value, +}: { + label: string; + help: string; + value: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx new file mode 100644 index 000000000..95f32c4fb --- /dev/null +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { Activity, Droplet, TrendingUp } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/use-auth"; +import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useTranslations } from "@/lib/i18n/context"; +import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; +import type { DataSummary } from "@/lib/analytics/trends"; +import { GlucoseTirBar } from "./glucose-tir-bar"; +import { GlucoseAdvancedDisclosure } from "./glucose-advanced-disclosure"; + +/** + * v1.17.0 — glucose clinical panel. + * + * The default view leads with the three numbers that matter to a person + * managing glucose: time-in-range (Battelino bands, as a stacked bar), a GMI + + * estimated-A1C pair, and a variability badge that flags "unstable" at CV% ≥ 36 + * (Monnier). The advanced indices (J-index, LBGI/HBGI) sit behind a calm + * disclosure. Every number is server-computed by the one literature-locked + * engine — the panel renders verbatim and never re-derives. + * + * Honesty contract: HealthLog stores SPOT readings, not a CGM trace, so the + * copy always carries the "spot-reading estimate" caveat, and a thin window + * shows a warm "still learning" state instead of asserting a clinical profile. + * Tone follows the cycle-phase texts: warm, professional, grounded — never + * alarmist, never breezy. + * + * Reads the thick `/api/analytics` payload directly (the slim slice does not + * carry `glucoseClinical`); the cache cell is shared with the dashboard so the + * mount is usually a free hit. + */ +export function GlucoseClinicalPanel() { + const { user, isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const query = useAnalyticsQuery({}); + + const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); + const isMmol = glucoseUnit === "mmol/L"; + + if (!isAuthenticated) return null; + + if (query.isLoading) { + return ( + + + + + + +
+ + + +
+
+
+ ); + } + + const clinical = query.data?.glucoseClinical; + // No glucose data at all, or the thick slice did not carry the block — the + // page's own empty state already covers the truly-no-data case, so render + // nothing rather than a redundant card. + if (!clinical) return null; + + const byContext = query.data?.glucoseByContext as + | Record + | undefined; + + const formatGlucoseValue = (mgdl: number): string => { + const v = convertGlucose(mgdl, glucoseUnit); + return isMmol ? v.toFixed(1) : String(Math.round(v)); + }; + + // Learning state — calm, never asserting TIR / GMI off thin data. + if (clinical.stillLearning) { + const hasAny = clinical.readingCount > 0; + const readingsLabel = + clinical.readingCount === 1 + ? t("insights.bloodGlucose.clinical.readingsCountOne") + : t("insights.bloodGlucose.clinical.readingsCount", { + count: clinical.readingCount, + }); + const spanDays = Math.max(1, Math.round(clinical.actualSpanDays)); + const daysLabel = + spanDays === 1 + ? t("insights.bloodGlucose.clinical.daysCountOne") + : t("insights.bloodGlucose.clinical.daysCount", { days: spanDays }); + + return ( + + + + + + +

+ {hasAny + ? t("insights.bloodGlucose.clinical.learningBody", { + count: readingsLabel, + days: daysLabel, + }) + : t("insights.bloodGlucose.clinical.learningBodyEmpty")} +

+

+ {t("insights.bloodGlucose.clinical.spotCaveat")} +

+
+
+ ); + } + + // Asserted view. + const dist = clinical.distribution; + const cv = clinical.variability; + + return ( + + +
+ + + + {t("insights.bloodGlucose.clinical.subtitle", { + days: Math.max(1, Math.round(clinical.actualSpanDays)), + count: clinical.readingCount, + })} + +
+
+ + {/* Time in range */} + {dist ? ( +
+
+

+ {t("insights.bloodGlucose.clinical.tir.title")} +

+
+ +

+ {t("insights.bloodGlucose.clinical.tir.caption")} +

+
+ ) : null} + + {/* Headline metrics */} +
+ {clinical.meanMgdl !== null ? ( +
+ + {/* Spot-reading honesty caveat — always present. */} +

+ {t("insights.bloodGlucose.clinical.spotCaveat")} +

+ + {/* Advanced indices behind a disclosure. */} + {clinical.advanced ? ( + + ) : null} +
+
+ ); +} + +function Metric({ + icon, + label, + help, + value, + unit, +}: { + icon: React.ReactNode; + label: string; + help?: string; + value: string; + unit: string; +}) { + return ( +
+ + {icon} + {label} + + + {value} + {unit} + +
+ ); +} diff --git a/src/components/insights/glucose/glucose-tir-bar.tsx b/src/components/insights/glucose/glucose-tir-bar.tsx new file mode 100644 index 000000000..25602a3f0 --- /dev/null +++ b/src/components/insights/glucose/glucose-tir-bar.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import type { TimeInRangeDistribution } from "@/lib/analytics/glucose-metrics"; + +/** + * v1.17.0 — Battelino 2019 time-in-range stacked bar. + * + * A single horizontal 100%-width bar split into the five consensus bands + * (very low / low / in range / high / very high), bottom-to-top severity + * read left-to-right with the in-range band centred and dominant. Fractions + * arrive already computed by the server engine — this component only renders + * them, it never re-derives a band. The level-2 sub-bands are NESTED inside + * level-1 in the source distribution (TBR2 ⊆ TBR1, TAR2 ⊆ TAR1), so the + * segment widths here use the EXCLUSIVE slices to fill exactly 100%. + */ + +const BAND_COLORS = { + veryLow: "#ff5555", // dracula-red — urgent low + low: "#ffb86c", // dracula-orange — low + inRange: "#50fa7b", // dracula-green — target + high: "#f1fa8c", // dracula-yellow — high + veryHigh: "#ff79c6", // dracula-pink — urgent high +} as const; + +type BandKey = keyof typeof BAND_COLORS; + +export interface GlucoseTirBarProps { + distribution: TimeInRangeDistribution; +} + +export function GlucoseTirBar({ distribution }: GlucoseTirBarProps) { + const { t } = useTranslations(); + + // Exclusive slices from the nested consensus fractions: level-2 is a subset + // of level-1, so the level-1-only slice = level1 − level2. + const veryLow = distribution.tbrLevel2; + const low = Math.max(0, distribution.tbrLevel1 - distribution.tbrLevel2); + const inRange = distribution.tir; + const high = Math.max(0, distribution.tarLevel1 - distribution.tarLevel2); + const veryHigh = distribution.tarLevel2; + + const segments: Array<{ key: BandKey; label: string; fraction: number }> = [ + { key: "veryLow", label: t("insights.bloodGlucose.clinical.tir.veryLow"), fraction: veryLow }, + { key: "low", label: t("insights.bloodGlucose.clinical.tir.low"), fraction: low }, + { key: "inRange", label: t("insights.bloodGlucose.clinical.tir.inRange"), fraction: inRange }, + { key: "high", label: t("insights.bloodGlucose.clinical.tir.high"), fraction: high }, + { key: "veryHigh", label: t("insights.bloodGlucose.clinical.tir.veryHigh"), fraction: veryHigh }, + ]; + + const pct = (f: number) => Math.round(f * 100); + const ariaLabel = t("insights.bloodGlucose.clinical.tir.ariaLabel", { + tir: pct(inRange), + below: pct(distribution.tbrLevel1), + above: pct(distribution.tarLevel1), + }); + + return ( +
+
+ {segments.map((s) => + s.fraction > 0 ? ( +
+ ) : null, + )} +
+
    + {segments.map((s) => ( +
  • + + + + {pct(s.fraction)}% + +
  • + ))} +
+
+ ); +} diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 5a804ca0c..e58bea689 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -154,6 +154,15 @@ export interface HealthKitMetricPageProps { * 90-day p50 is not read as an all-time central value. */ statMedianLabel?: string; + /** + * v1.17.0 — optional extra content rendered beneath the chart + target + * summary, before the metric-status card. Blood glucose mounts its clinical + * panel (TIR / GMI / eA1C / CV% + advanced indices) here. Only rendered on + * the data-bearing branch (the empty / loading / error branches skip it, so + * a no-data metric never shows a void panel). Additive: every other page + * omits it and renders byte-identically. + */ + afterChart?: ReactNode; } export function HealthKitMetricPage({ @@ -176,6 +185,7 @@ export function HealthKitMetricPage({ forecast = false, statFractionDigits, statMedianLabel, + afterChart, }: HealthKitMetricPageProps) { const { user, isAuthenticated } = useAuth(); const { t } = useTranslations(); @@ -337,6 +347,9 @@ export function HealthKitMetricPage({ {targetSummarySlug ? ( ) : null} + {/* v1.17.0 — page-specific extra block (blood glucose: the clinical + panel). Mounted on the data-bearing branch only. */} + {afterChart} {forecast && isTrajectoryType(measurementType) ? ( ({ + measuredAt: new Date(NOW.getTime() - (VALUES.length - 1 - i) * DAY), + mgdl, + })); +} + +/** Coach projection — mirrors `snapshot.glucose.clinical` (asserted branch). */ +function coachProjection( + m: ReturnType, +) { + return { + tirPercent: m.distribution ? Math.round(m.distribution.tir * 100) : null, + gmi: m.gmi !== null ? Math.round(m.gmi * 10) / 10 : null, + estimatedA1c: + m.estimatedA1c !== null ? Math.round(m.estimatedA1c * 10) / 10 : null, + cvPercent: m.variability ? Math.round(m.variability.cv) : null, + unstable: m.variability?.unstable ?? null, + }; +} + +/** Panel projection — mirrors the panel's display rounding. */ +function panelProjection( + m: ReturnType, +) { + return { + tirPercent: m.distribution ? Math.round(m.distribution.tir * 100) : null, + gmi: m.gmi !== null ? Number(m.gmi.toFixed(1)) : null, + estimatedA1c: + m.estimatedA1c !== null ? Number(m.estimatedA1c.toFixed(1)) : null, + cvPercent: m.variability ? Math.round(m.variability.cv) : null, + unstable: m.variability?.unstable ?? null, + }; +} + +describe("glucose panel ⇄ coach ⇄ doctor coherence", () => { + it("derives identical headline numbers from one engine result", () => { + // One computed result — exactly what each surface receives server-side. + const result = computeGlucoseClinicalMetrics(readings(), { + now: NOW, + windowDays: 30, + }); + expect(result.stillLearning).toBe(false); + + const panel = panelProjection(result); + const coach = coachProjection(result); + // doctor report carries the engine result verbatim + const doctor = { + tirPercent: result.distribution + ? Math.round(result.distribution.tir * 100) + : null, + gmi: result.gmi, + estimatedA1c: result.estimatedA1c, + cvPercent: result.variability ? Math.round(result.variability.cv) : null, + unstable: result.variability?.unstable ?? null, + }; + + // Panel == coach on every rounded headline figure. + expect(panel).toEqual(coach); + + // Doctor's whole-precision figures round to the same display values. + expect(Math.round((doctor.gmi as number) * 10) / 10).toBe(panel.gmi); + expect(Math.round((doctor.estimatedA1c as number) * 10) / 10).toBe( + panel.estimatedA1c, + ); + expect(doctor.tirPercent).toBe(panel.tirPercent); + expect(doctor.cvPercent).toBe(panel.cvPercent); + expect(doctor.unstable).toBe(panel.unstable); + }); + + it("agrees on the learning gate for thin data across surfaces", () => { + // 5 readings → all three surfaces see stillLearning and withhold assertion. + const thin = computeGlucoseClinicalMetrics( + [100, 110, 120, 130, 140].map((mgdl, i) => ({ + measuredAt: new Date(NOW.getTime() - (4 - i) * DAY), + mgdl, + })), + { now: NOW, windowDays: 30 }, + ); + expect(thin.stillLearning).toBe(true); + // the coach gates its clinical block on exactly this flag; the panel + // renders its learning card on it; the doctor report carries the same + // flag in `glucoseClinical.stillLearning`. + expect(thin.gmi).not.toBeNull(); // preview value exists … + // … but every surface keys its assertion on `stillLearning`, not on the + // presence of the preview number. + }); + + it("is deterministic — identical input yields an identical result", () => { + const a = computeGlucoseClinicalMetrics(readings(), { + now: NOW, + windowDays: 30, + }); + const b = computeGlucoseClinicalMetrics(readings(), { + now: NOW, + windowDays: 30, + }); + expect(a).toEqual(b); + }); +}); From 17e0b2434e59533cd88a19cfaa131e47456f3530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 19:19:36 +0200 Subject: [PATCH 4/4] Pin coach glucose panel to the fixed 30-day clinical window --- src/lib/ai/coach/snapshot.ts | 58 +++++++++++++++---- .../__tests__/glucose-metrics.test.ts | 20 +++++++ src/lib/analytics/glucose-metrics.ts | 8 ++- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 865ae4a00..bbab2efc4 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -81,6 +81,15 @@ const DAILY_TIMELINE_DAYS = 14; /** Default window when the caller doesn't pass a scope. */ const DEFAULT_WINDOW: CoachScopeWindow = "last30days"; +/** + * v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact, + * identical across the insights panel, the dashboard snapshot, the doctor + * report, and (here) the coach. Pinned independently of the coach's variable + * narration window so the coach's TIR/GMI/CV% always equals what the panel + * renders. + */ +const GLUCOSE_CLINICAL_WINDOW_DAYS = 30; + /** * v1.7.0 — assembled-snapshot soft char cap. After the snapshot is * built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per- @@ -847,6 +856,29 @@ async function buildCoachSnapshotImpl( }, }) : null; + // v1.17.0 — the glucose CLINICAL panel is a fixed 30-day clinical artifact, + // identical to the one the insights panel + doctor report render. It must NOT + // ride the coach's variable narration window (7/30/90/365) or the + // multi-cluster timeline cap, or the coach would quote a TIR/GMI/CV% the panel + // never shows. So read the panel's own trailing-30-day glucose rows directly + // (one indexed query, only when glucose is active) and compute the clinical + // block off THOSE rows. The per-context narration timelines below keep using + // the coach-window rows — only the clinical summary is pinned to 30 days. + const glucoseClinicalCutoff = new Date( + now.getTime() - GLUCOSE_CLINICAL_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ); + const glucoseClinicalRowsPromise = sources.has("glucose") + ? prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE" as never, + measuredAt: { gte: glucoseClinicalCutoff }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true }, + }) + : null; const glp1BlockPromise = excludesMedications ? null : buildGlp1SnapshotBlock(userId, now); @@ -880,6 +912,7 @@ async function buildCoachSnapshotImpl( complianceMeds, sleepRows, workoutRows, + glucoseClinicalRows, glp1Block, derivedBlock, trajectoryBlock, @@ -890,6 +923,7 @@ async function buildCoachSnapshotImpl( complianceMedsPromise, sleepRowsPromise, workoutRowsPromise, + glucoseClinicalRowsPromise, glp1BlockPromise, derivedBlockPromise, trajectoryBlockPromise, @@ -1345,18 +1379,22 @@ 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 + // v1.17.0 — clinical panel summary from the ONE literature-locked engine + // the insights panel + doctor report also consume, computed over the SAME + // fixed trailing-30-day window + rows the panel uses (`glucoseClinicalRows`, + // not the coach-window / cap-trimmed `glucoseRows`), so the coach can never + // quote a TIR / GMI / CV% figure the panel doesn't show — true numeric + // parity, independent of the user's coach scope. 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; the unit-agnostic fractions / indices // travel as-is. const clinicalRaw = computeGlucoseClinicalMetrics( - glucoseRows.map((r) => ({ measuredAt: r.measuredAt, mgdl: r.value })), - { windowDays: windowDays, now }, + (glucoseClinicalRows ?? []).map((r) => ({ + measuredAt: r.measuredAt, + mgdl: r.value, + })), + { windowDays: GLUCOSE_CLINICAL_WINDOW_DAYS, now }, ); const clinical = clinicalRaw.stillLearning ? { diff --git a/src/lib/analytics/__tests__/glucose-metrics.test.ts b/src/lib/analytics/__tests__/glucose-metrics.test.ts index 0678a4803..1e4f6a498 100644 --- a/src/lib/analytics/__tests__/glucose-metrics.test.ts +++ b/src/lib/analytics/__tests__/glucose-metrics.test.ts @@ -202,6 +202,26 @@ describe("computeGlucoseClinicalMetrics — window + adequacy", () => { expect(m.readingCount).toBe(1); }); + it("excludes non-positive readings so every index shares one denominator", () => { + // a 0 / negative glucose is non-physiological and ln-undefined; it must be + // dropped from readingCount AND from the risk-index denominator so the + // advanced indices never use a different N than mean / TIR. + const readings: GlucoseReading[] = [ + { mgdl: 120, measuredAt: new Date(now.getTime() - 1 * DAY) }, + { mgdl: 0, measuredAt: new Date(now.getTime() - 2 * DAY) }, + { mgdl: -5, measuredAt: new Date(now.getTime() - 3 * DAY) }, + ]; + const m = computeGlucoseClinicalMetrics(readings, { + now, + minReadings: 1, + minSpanDays: 0, + }); + expect(m.readingCount).toBe(1); + expect(m.meanMgdl).toBe(120); + // advanced indices resolve from the single usable reading + expect(m.advanced).not.toBeNull(); + }); + it("always marks the result as a spot estimate", () => { const m = computeGlucoseClinicalMetrics( dailyReadings([100], now), diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts index 10d4675eb..3098c2e0b 100644 --- a/src/lib/analytics/glucose-metrics.ts +++ b/src/lib/analytics/glucose-metrics.ts @@ -424,7 +424,13 @@ export function computeGlucoseClinicalMetrics( (r) => r.measuredAt.getTime() >= cutoff && r.measuredAt.getTime() <= now.getTime() && - Number.isFinite(r.mgdl), + Number.isFinite(r.mgdl) && + // Non-positive glucose is non-physiological and ln-undefined (the + // Kovatchev symmetrization needs ln BG). Excluding it here keeps EVERY + // index — mean / SD / TIR / GMI / LBGI / HBGI — on one shared + // denominator rather than letting the risk indices drop a row the + // others kept. + r.mgdl > 0, ) .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());