Pin coach glucose panel to the fixed 30-day clinical window

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:19:36 +02:00
1 parent 4b9f31f053
commit 17e0b2434e
3 files changed
+75 -11

No files matched your search

+48 -10
View File
@@ -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
? {
@@ -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),
+7 -1
View File
@@ -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());