mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Pin coach glucose panel to the fixed 30-day clinical window
This commit is contained in:
1 parent
4b9f31f053
commit
17e0b2434e
3 files changed
+75
-11
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in new issue
Block a user