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:
Marc-André Bombeck committed 2026-10-01 22:07:07 +02:00
1 parent fc47006c88
commit 75361d0666
19 files changed
+357 -70

No files matched your search

@@ -10,7 +10,7 @@
*
* Mutation check: add `unit="kg"` back to any swept page and this goes red.
*/
import { readFileSync } from "node:fs";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { stripComments, walkSourceFiles } from "./helpers/source-files";
@@ -515,3 +515,74 @@ describe("unit-preference display guard — AI and Coach producers", () => {
expect(offenders).toEqual([]);
});
});
/**
* Locale strings that may name a glucose unit literally, each with the reason
* it is not a reading shown in the reader's unit. Keys are dotted paths and
* apply to every locale bundle.
*/
const GLUCOSE_UNIT_LITERAL_ALLOWLIST: Record<string, string> = {
"dashboard.metric.unit.glucose":
"the unit token the dashboard summary wire hands the native client; the web resolves the glucose unit from the account instead",
"labs.biomarker.form.unitPlaceholder":
"an example of a free-text lab unit in an input placeholder, not a reading",
};
const GLUCOSE_UNIT_LITERAL = /mg\/dl|mmol\/l\b/i;
function flattenStrings(
node: unknown,
path: string,
out: Array<[string, string]>,
): void {
if (typeof node === "string") {
out.push([path, node]);
} else if (node && typeof node === "object") {
for (const [key, value] of Object.entries(node)) {
flattenStrings(value, path ? `${path}.${key}` : key, out);
}
}
}
describe("unit-preference display guard — locale strings", () => {
const MESSAGES = join(process.cwd(), "messages");
const bundles = readdirSync(MESSAGES).filter((f) => f.endsWith(".json"));
it("reads every locale bundle", () => {
expect(bundles.length).toBeGreaterThanOrEqual(7);
});
it("no locale string fixes a glucose unit outside the allowlist", () => {
// A sentence that prints "70–180 mg/dL" shows mg/dL to a reader who
// chose mmol/L. Band and unit go in as parameters (`glucoseBandParams`).
const offenders: string[] = [];
for (const file of bundles) {
const entries: Array<[string, string]> = [];
flattenStrings(
JSON.parse(readFileSync(join(MESSAGES, file), "utf8")),
"",
entries,
);
for (const [key, value] of entries) {
if (key in GLUCOSE_UNIT_LITERAL_ALLOWLIST) continue;
if (GLUCOSE_UNIT_LITERAL.test(value)) offenders.push(`${file}: ${key}`);
}
}
expect(offenders).toEqual([]);
});
it("every allowlisted key still exists and still names a unit", () => {
// An entry whose string was reworded or removed is a stale exemption.
const en: Array<[string, string]> = [];
flattenStrings(
JSON.parse(readFileSync(join(MESSAGES, "en.json"), "utf8")),
"",
en,
);
const byKey = new Map(en);
const stale = Object.keys(GLUCOSE_UNIT_LITERAL_ALLOWLIST).filter(
(key) => !GLUCOSE_UNIT_LITERAL.test(byKey.get(key) ?? ""),
);
expect(stale).toEqual([]);
});
});
+10 -2
View File
@@ -5,8 +5,12 @@ import { Droplet } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
glucoseBandParams,
MGDL_PER_MMOL,
resolveGlucoseUnit,
} from "@/lib/glucose";
/**
* v1.7.0 — `/insights/blood-glucose`.
@@ -27,6 +31,7 @@ import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose";
export default function InsightsBlutzuckerPage() {
const { user } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
const isMmol = glucoseUnit === "mmol/L";
@@ -38,6 +43,9 @@ export default function InsightsBlutzuckerPage() {
chartKey="bloodGlucose"
i18nPrefix="insights.bloodGlucose"
explainerMetric="bloodGlucose"
// The definition names the fasting cut-points; they follow the
// reader's glucose unit like the chart does.
explainerParams={glucoseBandParams(glucoseUnit, fmt.number)}
color="var(--chart-3)"
unit={glucoseUnit}
yAxisUnit={glucoseUnit}
@@ -2,6 +2,8 @@ import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { glucoseBandParams } from "@/lib/glucose";
import { makeFormatters } from "@/lib/format-locale";
import { SubPageShell } from "../sub-page-shell";
vi.mock("next/navigation", () => ({
@@ -68,6 +70,27 @@ describe("<SubPageShell>", () => {
);
});
it("fills the explainer's figures from the explainer parameters", () => {
// The glucose definition names the fasting cut-points; a reader on
// mmol/L reads them in mmol/L, never a fixed "100 mg/dL".
const html = render(
<SubPageShell
title="Blood glucose"
explainerMetric="bloodGlucose"
explainerParams={glucoseBandParams(
"mmol/L",
makeFormatters("en", "UTC", "AUTO", "AUTO").number,
)}
>
<span />
</SubPageShell>,
);
expect(html).toContain(
"a fasting reading under 5.6 mmol/L is normal, 5.6–6.9 is prediabetes, and 7.0 or above",
);
expect(html).not.toContain("{");
});
it("no longer paints the round `?` explainer trigger next to the heading", () => {
const html = render(
<SubPageShell title="Blood pressure" explainerMetric="bloodPressure">
@@ -148,6 +148,30 @@ describe("<GlucoseClinicalPanel> asserted state", () => {
expect(html).toContain("J-index");
});
it("states the target band in the reader's unit and number format", () => {
// The caption used to print "70–180 mg/dL" to every reader, beside
// values the panel had already converted to mmol/L.
authMock.mockReturnValue({
user: { glucoseUnit: "mmol/L" },
isAuthenticated: true,
});
analyticsMock.mockReturnValue({
isLoading: false,
data: { glucoseClinical: assertedClinical(), glucoseByContext: {} },
});
const de = render(<GlucoseClinicalPanel />, "de");
expect(de).toContain("(3,9–10,0 mmol/L ist das Ziel)");
expect(de).not.toMatch(/mg\/dl/i);
authMock.mockReturnValue({
user: { glucoseUnit: "mg/dL" },
isAuthenticated: true,
});
expect(render(<GlucoseClinicalPanel />)).toContain(
"(70–180 mg/dL is the target)",
);
});
it("flags the CV badge unstable at CV% >= 36", () => {
// wide spread → CV well above 36
const values = Array.from({ length: 20 }, (_, i) =>
@@ -12,8 +12,12 @@ import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useTranslations } from "@/lib/i18n/context";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
convertGlucose,
glucoseBandParams,
resolveGlucoseUnit,
} from "@/lib/glucose";
import type { DataSummary } from "@/lib/analytics/trends";
import { GlucoseTirBar } from "./glucose-tir-bar";
import { GlucoseAdvancedDisclosure } from "./glucose-advanced-disclosure";
@@ -41,6 +45,7 @@ import { GlucoseAdvancedDisclosure } from "./glucose-advanced-disclosure";
export function GlucoseClinicalPanel() {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
const query = useAnalyticsQuery({});
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
@@ -158,7 +163,10 @@ export function GlucoseClinicalPanel() {
</div>
<GlucoseTirBar distribution={dist} />
<p className="text-muted-foreground text-xs">
{t("insights.bloodGlucose.clinical.tir.caption")}
{t(
"insights.bloodGlucose.clinical.tir.caption",
glucoseBandParams(glucoseUnit, fmt.number),
)}
</p>
</section>
) : null}
@@ -153,6 +153,8 @@ export interface HealthKitMetricPageProps {
* Resolves `insights.subPage.explainer.<explainerMetric>{Title,Body}`.
*/
explainerMetric?: string;
/** Interpolation values for the explainer sentence (`SubPageShell`). */
explainerParams?: Record<string, string>;
/**
* v1.8.5 W5 — when set, renders `<MetricTargetSummary slug=…>` beneath
* the chart. Used by blood glucose, whose per-context ADA / DDG bands
@@ -227,6 +229,7 @@ export function HealthKitMetricPage({
coachPrefill,
valueScale,
explainerMetric,
explainerParams,
targetSummarySlug,
statusMetric,
statIcon,
@@ -334,7 +337,10 @@ export function HealthKitMetricPage({
: rawSummary;
const title = t(`${i18nPrefix}.title`);
const description = t(`${i18nPrefix}.description`);
// A description that names its unit ("in mg/dL") reads the resolved one.
const description = t(`${i18nPrefix}.description`, {
unit: resolvedUnit ?? "",
});
// v1.12.7 — in-flight skeleton. The page consumed only `{data, isEmpty}`
// before, so the ~30 HealthKit sub-pages painted nothing until the
@@ -347,6 +353,7 @@ export function HealthKitMetricPage({
title={title}
description={description}
explainerMetric={explainerMetric}
explainerParams={explainerParams}
statStrip={<StatStripSkeleton />}
>
<ChartSkeleton />
@@ -364,6 +371,7 @@ export function HealthKitMetricPage({
title={title}
description={description}
explainerMetric={explainerMetric}
explainerParams={explainerParams}
>
<QueryErrorRow
slot="healthkit-metric-error"
@@ -389,6 +397,7 @@ export function HealthKitMetricPage({
title={title}
description={description}
explainerMetric={explainerMetric}
explainerParams={explainerParams}
>
<MetricEmptyState
icon={emptyStateIcon}
@@ -406,6 +415,7 @@ export function HealthKitMetricPage({
title={title}
description={description}
explainerMetric={explainerMetric}
explainerParams={explainerParams}
statStrip={
<MetricStatStrip
summary={summary}
+10 -1
View File
@@ -70,6 +70,11 @@ export interface SubPageShellProps {
* one- to two-sentence definition, no tap required.
*/
explainerMetric?: string;
/**
* Interpolation values for the explainer sentence, for a definition that
* names figures in the reader's own unit (the glucose cut-points).
*/
explainerParams?: Record<string, string>;
/**
* v1.4.27 MB7 / CF-35 — opt-in programmatic focus on mount.
*
@@ -155,6 +160,7 @@ export function SubPageShell({
badge,
description,
explainerMetric,
explainerParams,
focusOnMount = false,
statStrip,
coachReadStrip,
@@ -385,7 +391,10 @@ export function SubPageShell({
data-slot="metric-explainer-inline"
className="text-foreground text-sm leading-relaxed"
>
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
{t(
`insights.subPage.explainer.${explainerMetric}Body`,
explainerParams,
)}
</p>
) : null}
{description && !explainerMetric ? (
@@ -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, {
+46
View File
@@ -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);
}
});
});
+9 -5
View File
@@ -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) {
+53
View File
@@ -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 {