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] 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());