mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(glucose): state band edges in the reader's glucose unit
The time-in-range labels in the doctor report, the target caption on the glucose panel, the glucose page description and the fasting explainer printed fixed mg/dL figures, so a reader on mmol/L saw "Low (< 70 mg/dL)" beside values in mmol/L. The cut-points now live in one place (`GLUCOSE_TIR_CUT_POINTS`, `GLUCOSE_FASTING_CUT_POINTS`) with the SI figure each guideline prints, the analytics engine counts its bands from the same constants, and every sentence takes the band edges and unit as parameters through `glucoseBandParams`, formatted with the reader's number format. The unit-preference guard now also reads the locale bundles: a string that names mg/dL or mmol/L fails unless it is allowlisted with a reason. Refs #1067
This commit is contained in:
1 parent
fc47006c88
commit
75361d0666
19 files changed
+357
-70
No files matched your search
@@ -594,6 +594,30 @@ describe("doctor-report clinical glucose panel", () => {
|
||||
expect(text).toContain("Glucose Management Indicator");
|
||||
});
|
||||
|
||||
it("labels the time-in-range bands in the owner's glucose unit", async () => {
|
||||
// The band labels used to read "Low (< 70 mg/dL)" in a report whose
|
||||
// values were all in mmol/L.
|
||||
const data = makeData({
|
||||
glucoseUnit: "mmol/L",
|
||||
glucoseClinical: computeGlucoseClinicalMetrics(denseReadings(), {
|
||||
now: FIXED_NOW,
|
||||
windowDays: 90,
|
||||
}),
|
||||
});
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
timeFormat: "AUTO",
|
||||
dateFormat: "AUTO",
|
||||
t: getServerTranslator("de").t,
|
||||
locale: "de",
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Zeit im Zielbereich (3,9–10,0 mmol/L)");
|
||||
expect(text).toContain("Sehr niedrig (< 3,0 mmol/L)");
|
||||
expect(text).toContain("Sehr hoch (> 13,9 mmol/L)");
|
||||
expect(text).not.toMatch(/mg\/dl/i);
|
||||
});
|
||||
|
||||
it("omits the clinical panel when there are no glucose readings", async () => {
|
||||
const data = makeData(); // empty (zero-reading) glucose panel
|
||||
const bytes = renderDoctorReportPdfBytes(data, {
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
glucoseContextBucket,
|
||||
groupByGlucoseContext,
|
||||
GLUCOSE_CONTEXT_UNSPECIFIED,
|
||||
GLUCOSE_FASTING_CUT_POINTS,
|
||||
GLUCOSE_TIR_CUT_POINTS,
|
||||
glucoseBandParams,
|
||||
} from "../glucose";
|
||||
import { makeFormatters } from "../format-locale";
|
||||
|
||||
describe("glucose conversion", () => {
|
||||
it("converts mg/dL to mmol/L with 1 decimal", () => {
|
||||
@@ -157,3 +161,45 @@ describe("glucose context buckets (#943)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("glucose band parameters", () => {
|
||||
const de = makeFormatters("de", "UTC", "AUTO", "AUTO").number;
|
||||
const en = makeFormatters("en", "UTC", "AUTO", "AUTO").number;
|
||||
|
||||
it("states the consensus bands in mg/dL as whole numbers", () => {
|
||||
expect(glucoseBandParams("mg/dL", en)).toMatchObject({
|
||||
unit: "mg/dL",
|
||||
veryLow: "54",
|
||||
low: "70",
|
||||
high: "180",
|
||||
veryHigh: "250",
|
||||
prediabetesFrom: "100",
|
||||
prediabetesTo: "125",
|
||||
diabetesFrom: "126",
|
||||
});
|
||||
});
|
||||
|
||||
it("states them in mmol/L with the locale's decimal separator", () => {
|
||||
expect(glucoseBandParams("mmol/L", de)).toMatchObject({
|
||||
unit: "mmol/L",
|
||||
veryLow: "3,0",
|
||||
low: "3,9",
|
||||
high: "10,0",
|
||||
veryHigh: "13,9",
|
||||
prediabetesFrom: "5,6",
|
||||
prediabetesTo: "6,9",
|
||||
diabetesFrom: "7,0",
|
||||
});
|
||||
});
|
||||
|
||||
it("carries SI figures that agree with the conversion", () => {
|
||||
// The consensus bands convert exactly; the ADA cut-points are within
|
||||
// one rounding step of it (100 mg/dL is printed as 5.6, converts to 5.5).
|
||||
for (const point of Object.values(GLUCOSE_TIR_CUT_POINTS)) {
|
||||
expect(point.mmol).toBe(mgdlToMmol(point.mgdl));
|
||||
}
|
||||
for (const point of Object.values(GLUCOSE_FASTING_CUT_POINTS)) {
|
||||
expect(Math.abs(point.mmol - point.mgdl / 18.0182)).toBeLessThan(0.1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,8 @@
|
||||
* Diabetes Care 29(11):2433-2438, DOI 10.2337/dc06-1085.
|
||||
*/
|
||||
|
||||
import { GLUCOSE_TIR_CUT_POINTS } from "@/lib/glucose";
|
||||
|
||||
// ── Inputs ───────────────────────────────────────────────
|
||||
|
||||
/** A single spot blood-glucose reading. Value is canonical mg/dL. */
|
||||
@@ -79,11 +81,13 @@ export interface GlucoseMetricsOptions {
|
||||
}
|
||||
|
||||
// ── Consensus thresholds (Battelino 2019) ────────────────
|
||||
// Bands in mg/dL. These are consensus FACTS, cited above.
|
||||
const TBR_LEVEL2_MAX = 54; // very low: G < 54
|
||||
const TBR_LEVEL1_MAX = 70; // low: G < 70 (level-1 sub-band is 54–69)
|
||||
const TAR_LEVEL1_MIN = 180; // high: G > 180 (level-1 sub-band is 181–250)
|
||||
const TAR_LEVEL2_MIN = 250; // very high: G > 250
|
||||
// Bands in mg/dL. These are consensus FACTS, cited above. The values live in
|
||||
// `GLUCOSE_TIR_CUT_POINTS` so the band labels a reader sees and the bands
|
||||
// this engine counts are the same numbers.
|
||||
const TBR_LEVEL2_MAX = GLUCOSE_TIR_CUT_POINTS.veryLow.mgdl; // very low: G < 54
|
||||
const TBR_LEVEL1_MAX = GLUCOSE_TIR_CUT_POINTS.low.mgdl; // low: G < 70 (level-1 sub-band is 54–69)
|
||||
const TAR_LEVEL1_MIN = GLUCOSE_TIR_CUT_POINTS.high.mgdl; // high: G > 180 (level-1 sub-band is 181–250)
|
||||
const TAR_LEVEL2_MIN = GLUCOSE_TIR_CUT_POINTS.veryHigh.mgdl; // very high: G > 250
|
||||
// Time-in-range target band is [70, 180] inclusive.
|
||||
/** The time-in-range band, mg/dL, inclusive at both ends. */
|
||||
export const TIME_IN_RANGE_MGDL = {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { jsPDF } from "jspdf";
|
||||
import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "../glucose";
|
||||
import {
|
||||
convertGlucose,
|
||||
glucoseBandParams,
|
||||
resolveGlucoseUnit,
|
||||
} from "../glucose";
|
||||
import { reportStatValue } from "../doctor-report/stat-display";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys";
|
||||
@@ -303,26 +307,29 @@ export function buildMeasurementsChartsSection(
|
||||
const clinical = data.glucoseClinical;
|
||||
if (clinical && clinical.readingCount > 0 && clinical.distribution) {
|
||||
const pct = (fraction: number) => `${num(fraction * 100)} %`;
|
||||
// The band edges follow the reader's glucose unit, like every value in
|
||||
// the table beside them.
|
||||
const bands = glucoseBandParams(glucoseUnit, num);
|
||||
const clinicalRows: string[][] = [];
|
||||
const dist = clinical.distribution;
|
||||
clinicalRows.push([
|
||||
t("doctorReport.glucoseClinical.tirInRange"),
|
||||
t("doctorReport.glucoseClinical.tirInRange", bands),
|
||||
pct(dist.tir),
|
||||
]);
|
||||
clinicalRows.push([
|
||||
t("doctorReport.glucoseClinical.tirLow"),
|
||||
t("doctorReport.glucoseClinical.tirLow", bands),
|
||||
pct(dist.tbrLevel1),
|
||||
]);
|
||||
clinicalRows.push([
|
||||
t("doctorReport.glucoseClinical.tirVeryLow"),
|
||||
t("doctorReport.glucoseClinical.tirVeryLow", bands),
|
||||
pct(dist.tbrLevel2),
|
||||
]);
|
||||
clinicalRows.push([
|
||||
t("doctorReport.glucoseClinical.tirHigh"),
|
||||
t("doctorReport.glucoseClinical.tirHigh", bands),
|
||||
pct(dist.tarLevel1),
|
||||
]);
|
||||
clinicalRows.push([
|
||||
t("doctorReport.glucoseClinical.tirVeryHigh"),
|
||||
t("doctorReport.glucoseClinical.tirVeryHigh", bands),
|
||||
pct(dist.tarLevel2),
|
||||
]);
|
||||
if (clinical.meanMgdl !== null) {
|
||||
|
||||
@@ -42,6 +42,59 @@ export function toCanonicalMgdl(value: number, from: GlucoseUnit): number {
|
||||
return from === "mmol/L" ? mmolToMgdl(value) : Math.round(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A published glucose cut-point, in both units as its source states it.
|
||||
*
|
||||
* The SI figure is carried rather than converted because a guideline prints
|
||||
* its own rounding: ADA's fasting cut-point is 100 mg/dL and 5.6 mmol/L, and
|
||||
* `mgdlToMmol(100)` gives 5.5. A reader on mmol/L must see the number their
|
||||
* own guideline prints, not a re-rounded neighbour of it.
|
||||
*/
|
||||
export interface GlucoseCutPoint {
|
||||
mgdl: number;
|
||||
mmol: number;
|
||||
}
|
||||
|
||||
/** Consensus time-in-range band edges (Battelino 2019). */
|
||||
export const GLUCOSE_TIR_CUT_POINTS = {
|
||||
veryLow: { mgdl: 54, mmol: 3.0 },
|
||||
low: { mgdl: 70, mmol: 3.9 },
|
||||
high: { mgdl: 180, mmol: 10.0 },
|
||||
veryHigh: { mgdl: 250, mmol: 13.9 },
|
||||
} as const satisfies Record<string, GlucoseCutPoint>;
|
||||
|
||||
/** Fasting-glucose diagnostic cut-points for adults (ADA 2024). */
|
||||
export const GLUCOSE_FASTING_CUT_POINTS = {
|
||||
prediabetesFrom: { mgdl: 100, mmol: 5.6 },
|
||||
prediabetesTo: { mgdl: 125, mmol: 6.9 },
|
||||
diabetesFrom: { mgdl: 126, mmol: 7.0 },
|
||||
} as const satisfies Record<string, GlucoseCutPoint>;
|
||||
|
||||
/**
|
||||
* The interpolation parameters every glucose band sentence takes: each
|
||||
* cut-point in the reader's unit, formatted by the caller's locale-aware
|
||||
* number formatter, plus the unit symbol. A sentence that names a band
|
||||
* reads its numbers from here, so no locale string carries a fixed unit.
|
||||
*/
|
||||
export function glucoseBandParams(
|
||||
unit: GlucoseUnit,
|
||||
formatNumber: (value: number, fractionDigits: number) => string,
|
||||
): Record<string, string> {
|
||||
const digits = unit === "mmol/L" ? 1 : 0;
|
||||
const v = (point: GlucoseCutPoint) =>
|
||||
formatNumber(unit === "mmol/L" ? point.mmol : point.mgdl, digits);
|
||||
return {
|
||||
unit,
|
||||
veryLow: v(GLUCOSE_TIR_CUT_POINTS.veryLow),
|
||||
low: v(GLUCOSE_TIR_CUT_POINTS.low),
|
||||
high: v(GLUCOSE_TIR_CUT_POINTS.high),
|
||||
veryHigh: v(GLUCOSE_TIR_CUT_POINTS.veryHigh),
|
||||
prediabetesFrom: v(GLUCOSE_FASTING_CUT_POINTS.prediabetesFrom),
|
||||
prediabetesTo: v(GLUCOSE_FASTING_CUT_POINTS.prediabetesTo),
|
||||
diabetesFrom: v(GLUCOSE_FASTING_CUT_POINTS.diabetesFrom),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGlucoseUnit(
|
||||
userPreference: string | null | undefined,
|
||||
): GlucoseUnit {
|
||||
|
||||
Reference in new issue
Block a user