mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(glucose): add the clinical glucose panel
Surface a clinical panel beneath the blood-glucose chart: time-in-range on the consensus bands, the glucose management indicator and estimated A1C, and the coefficient of variation with its instability flag by default, and the J-index and the low and high blood-glucose risk indices with a per-context breakdown behind an advanced disclosure. Below the calibration threshold it shows a calm learning state, and it always carries the spot-reading caveat. The analytics read, the dashboard snapshot, the coach and the doctor report all consume one computed result over a fixed window, so the panel, the assistant and the printed report cannot disagree.
This commit is contained in:
commit
e561f8d893
26 files changed
+1868
-2
No files matched your search
@@ -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<string> {
|
||||
@@ -67,6 +68,7 @@ function makeData(overrides?: Partial<DoctorReportData>): 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: {
|
||||
|
||||
@@ -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,
|
||||
@@ -80,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-
|
||||
@@ -846,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);
|
||||
@@ -879,6 +912,7 @@ async function buildCoachSnapshotImpl(
|
||||
complianceMeds,
|
||||
sleepRows,
|
||||
workoutRows,
|
||||
glucoseClinicalRows,
|
||||
glp1Block,
|
||||
derivedBlock,
|
||||
trajectoryBlock,
|
||||
@@ -889,6 +923,7 @@ async function buildCoachSnapshotImpl(
|
||||
complianceMedsPromise,
|
||||
sleepRowsPromise,
|
||||
workoutRowsPromise,
|
||||
glucoseClinicalRowsPromise,
|
||||
glp1BlockPromise,
|
||||
derivedBlockPromise,
|
||||
trajectoryBlockPromise,
|
||||
@@ -1344,9 +1379,70 @@ 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, 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(
|
||||
(glucoseClinicalRows ?? []).map((r) => ({
|
||||
measuredAt: r.measuredAt,
|
||||
mgdl: r.value,
|
||||
})),
|
||||
{ windowDays: GLUCOSE_CLINICAL_WINDOW_DAYS, 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
|
||||
// "<value> <unit>" 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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -155,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),
|
||||
@@ -173,6 +240,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 +288,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", () => {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* v1.17.0 — glucose panel coherence.
|
||||
*
|
||||
* The server-authoritative contract: the insights panel, the AI coach, and the
|
||||
* doctor-report PDF all read ONE computed `computeGlucoseClinicalMetrics`
|
||||
* result for the same readings — none re-derives a clinical number. This test
|
||||
* pins that invariant by deriving each surface's headline numbers from the SAME
|
||||
* engine output and asserting they agree:
|
||||
*
|
||||
* - panel → renders `gmi` / `estimatedA1c` / `distribution.tir` / `cv`
|
||||
* verbatim (rounded for display only).
|
||||
* - coach → `snapshot.glucose.clinical` rounds `gmi` (1dp), `tirPercent`
|
||||
* (whole %), `cvPercent` (whole %) from the same result.
|
||||
* - doctor → `glucoseClinical` IS the engine result, carried whole.
|
||||
*
|
||||
* Because the three consumers all call the one pure function, identical
|
||||
* readings MUST produce identical headline figures. The projections below
|
||||
* mirror the rounding each surface applies; the assertions prove no surface can
|
||||
* drift onto a different denominator or formula.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeGlucoseClinicalMetrics } from "../glucose-metrics";
|
||||
|
||||
const NOW = new Date("2026-06-14T12:00:00Z");
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** A spread that clears the learning gate and lands in all three TIR bands. */
|
||||
const VALUES = [
|
||||
60, 90, 120, 150, 180, 200, 100, 110, 95, 130, 170, 220, 80, 140, 160, 105,
|
||||
115, 125, 135, 145,
|
||||
];
|
||||
|
||||
function readings() {
|
||||
return VALUES.map((mgdl, i) => ({
|
||||
measuredAt: new Date(NOW.getTime() - (VALUES.length - 1 - i) * DAY),
|
||||
mgdl,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Coach projection — mirrors `snapshot.glucose.clinical` (asserted branch). */
|
||||
function coachProjection(
|
||||
m: ReturnType<typeof computeGlucoseClinicalMetrics>,
|
||||
) {
|
||||
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<typeof computeGlucoseClinicalMetrics>,
|
||||
) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
@@ -314,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());
|
||||
|
||||
@@ -334,6 +450,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 +499,7 @@ export function computeGlucoseClinicalMetrics(
|
||||
gmi: gmiValue,
|
||||
estimatedA1c: eA1cValue,
|
||||
variability,
|
||||
advanced,
|
||||
isSpotEstimate: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, DataSummary>;
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
@@ -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<string, DoctorReportStats>;
|
||||
glucoseRanges: Record<string, { min: number; max: number }>;
|
||||
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<string, DoctorReportCompliance>;
|
||||
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,
|
||||
|
||||
@@ -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>): 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: {
|
||||
|
||||
@@ -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>): DoctorReportData {
|
||||
stats: {},
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
glucoseClinical: computeGlucoseClinicalMetrics([], {
|
||||
now: new Date("2026-05-03T12:00:00.000Z"),
|
||||
}),
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: null,
|
||||
compliance: {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
Reference in new issue
Block a user