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

@@ -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 ? (