fix(targets): edit glucose thresholds in the reader's glucose unit

The settings threshold editor took the identity path for the glucose
thresholds, so an account on mmol/L was shown a mg/dL default and asked
for mg/dL figures against 40-400 bounds, while the insights sheet for
the same threshold already spoke mmol/L. The shared target adapter now
owns the glucose unit as it owns pounds: fields, inward-rounded bounds,
the default hint and the canonical write all go through it, and the
insights panel and its sheet use the same branch instead of their own
conversion.

Refs #1067
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 08:58:24 +02:00
1 parent 1058859730
commit d0a9086538
6 files changed
+270 -76

No files matched your search

@@ -8,7 +8,6 @@ import { useAuth } from "@/hooks/use-auth";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { RangeBar } from "@/components/targets/range-bar";
@@ -18,6 +17,7 @@ import { TileHeader } from "@/components/insights/tile-header";
import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
import { getTargetSourceLink } from "@/lib/targets/source-link";
import { apiGet } from "@/lib/api/api-fetch";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
/**
* v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each
@@ -152,28 +152,17 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
// mg/dL values to the user's display unit. The route's per-context
// labels are i18n keys, so resolve them.
if (isGlucose) {
const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
const convert = (v: number | null) =>
v == null ? null : convertGlucose(v, displayUnit);
// The payload stays canonical mg/dL here; each panel converts through
// the shared target adapter, which owns the glucose unit as it owns
// pounds, so the panel and its edit sheet cannot convert twice.
const glucoseUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
const panels = GLUCOSE_TARGET_TYPES.map((type) =>
data.targets.find((entry) => entry.type === type),
)
.filter(
(entry): entry is TargetItem => entry != null && entry.range != null,
)
.map((entry) => ({
...entry,
label: t(entry.label),
unit: displayUnit,
current: convert(entry.current),
average30: convert(entry.average30),
range: entry.range
? {
min: convertGlucose(entry.range.min, displayUnit),
max: convertGlucose(entry.range.max, displayUnit),
}
: null,
}));
.map((entry) => ({ ...entry, label: t(entry.label) }));
if (panels.length === 0) return null;
@@ -188,6 +177,7 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
key={panel.type}
target={panel}
heading={panel.label}
glucoseUnit={glucoseUnit}
/>
))}
</div>
@@ -212,6 +202,12 @@ interface TargetReferencePanelProps {
bpDiastolic?: TargetsResponse["bpDiastolic"];
/** Optional sub-heading shown above the panel (used for glucose contexts). */
heading?: string;
/**
* The record's glucose unit as the targets payload resolved it. The
* glucose panels pass it so the band reads in the unit the server
* resolved for this record; every other panel leaves it to the hook.
*/
glucoseUnit?: GlucoseUnit;
}
/**
@@ -232,21 +228,26 @@ function TargetReferencePanel({
target,
bpDiastolic,
heading,
glucoseUnit: payloadGlucoseUnit,
}: TargetReferencePanelProps) {
const { t } = useTranslations();
const adjust = useTargetAdjust();
const { preference } = useUnitDisplay();
const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay();
const glucoseUnit = payloadGlucoseUnit ?? accountGlucoseUnit;
// v1.32.27 — the panel renders in the user's preferred unit. The
// `/api/insights/targets` payload is canonical (kg for weight); the
// adapter converts it once, here, and everything downstream — the
// range bar, the status pill, the 30-day average, and the edit
// sheet's seed — consumes the converted pair. Glucose contexts are
// pre-converted by the caller above and carry no display transform,
// so they take the adapter's identity path and are never scaled
// twice. For a metric account the adapter is the exact identity, so
// this panel is byte-identical to the previous release.
const units = resolveTargetUnitAdapter(target.type, target.unit, preference);
// sheet's seed — consumes the converted pair. The glucose contexts
// arrive canonical too and convert here to the reader's glucose unit.
// On the default preferences the adapter is the exact identity.
const units = resolveTargetUnitAdapter(
target.type,
target.unit,
preference,
glucoseUnit,
);
const unit = units.unit;
const range = target.range
? {
@@ -0,0 +1,76 @@
/**
* The settings threshold editor reads glucose in the reader's unit.
*
* It used to pass every glucose threshold through the identity path, so an
* mmol/L account was shown "Default: 70–99 mg/dL" and mg/dL input fields
* bounded at 40–400, while the insights sheet for the same threshold already
* spoke mmol/L. The stored value stays canonical mg/dL either way.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const authUser = vi.hoisted(() => ({
current: { glucoseUnit: "mmol/L", unitPreference: "metric" } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: authUser.current, isAuthenticated: true }),
useAccountOnceMounted: () => authUser.current,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
isLoading: false,
data: {
effective: {
BLOOD_GLUCOSE_FASTING: {
range: { greenMin: 72, greenMax: 108 },
isOverride: true,
default: { greenMin: 70, greenMax: 99 },
bounds: { min: 40, max: 400, unit: "mg/dL" },
},
},
overrides: { BLOOD_GLUCOSE_FASTING: { min: 72, max: 108 } },
},
}),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ThresholdsEditorSection } from "../thresholds-editor-section";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ThresholdsEditorSection id="thresholds" />
</I18nProvider>,
);
}
describe("<ThresholdsEditorSection> glucose unit", () => {
it("states the default, the fields and the bounds in mmol/L", () => {
authUser.current = { glucoseUnit: "mmol/L", unitPreference: "metric" };
const html = render();
// 70–99 mg/dL is 3.9–5.5 mmol/L.
expect(html).toMatch(/3\.9–5\.5 mmol\/L/);
expect(html).not.toMatch(/70(\.0)?–99(\.0)? mg\/dL/);
// The stored 72–108 mg/dL override seeds 4–6 mmol/L.
expect(html).toContain('value="4"');
expect(html).toContain('value="6"');
// Inward-rounded guardrail of 40–400 mg/dL.
expect(html).toContain('min="2.3"');
expect(html).toContain('max="22.1"');
});
it("keeps mg/dL for an mg/dL reader", () => {
authUser.current = { glucoseUnit: "mg/dL", unitPreference: "metric" };
const html = render();
expect(html).toMatch(/70(\.0)?–99(\.0)? mg\/dL/);
expect(html).toContain('value="72"');
expect(html).toContain('min="40"');
});
});
@@ -237,7 +237,7 @@ function MetricRow({
}: MetricRowProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { preference } = useUnitDisplay();
const { preference, glucoseUnit } = useUnitDisplay();
const canonicalBounds = METRIC_BOUNDS[metric];
// v1.32.27 — the row reads and writes in the user's preferred unit
@@ -246,12 +246,14 @@ function MetricRow({
// canonical, so it converts on the way into the fields and inverts on
// the way back out in `onSave`. Guardrails round inward so a value
// typed at the displayed limit still passes the server's canonical
// check. Metrics without a transform — and every metric for a metric
// user — take the adapter's identity path untouched.
// check. The glucose thresholds read in the reader's glucose unit the
// same way. Metrics without a transform — and every metric on the default
// preferences — take the adapter's identity path untouched.
const units = resolveTargetUnitAdapter(
metric,
canonicalBounds.unit,
preference,
glucoseUnit,
);
const bounds = { ...units.bounds(canonicalBounds), unit: units.unit };
+26 -39
View File
@@ -17,8 +17,11 @@ import {
type ThresholdMetric,
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import {
isGlucoseTargetMetric,
resolveTargetUnitAdapter,
} from "@/lib/targets/target-unit-display";
import { resolveGlucoseUnit } from "@/lib/glucose";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
@@ -129,49 +132,33 @@ function TargetEditSheetBody({
const metric = TARGET_TYPE_TO_METRIC[targetType] ?? null;
const isDerivedMetric = !isBp && metric == null;
// Glucose is the one metric whose display unit can differ from the
// canonical storage unit: HealthLog stores mg/dL, but a user on the
// mmol/L preference sees — and types — mmol/L. The parent already
// hands us `unit="mmol/L"` and a `initialRange` pre-converted to that
// unit. We must therefore (a) seed the mg/dL persisted override into
// the display unit, (b) validate the typed value against bounds
// expressed in the display unit, and (c) convert the typed value back
// to canonical mg/dL before the PUT — otherwise a `5.5 mmol/L` target
// is rejected by the 40–400 mg/dL bounds or stored verbatim as 5.5.
const isGlucoseMmol =
metric != null && metric.startsWith("BLOOD_GLUCOSE") && unit === "mmol/L";
// v1.32.27 — the same coupled treatment now covers the metric/imperial
// preference. Weight-class thresholds are stored in kg; an imperial
// user seeds, validates, and types in lb, and the adapter inverts the
// typed number back to kg before the PUT. Glucose keeps its own
// mg/dL ↔ mmol/L dialect (it carries no display transform, so the two
// never compose); every other metric takes the adapter's identity
// path and is numerically untouched.
const { preference } = useUnitDisplay();
// One adapter owns every direction of the unit conversion: an imperial
// user seeds, validates and types weight-class thresholds in pounds, an
// mmol/L user types glucose thresholds in mmol/L, and the typed number is
// inverted back to the canonical kg / mg/dL before the PUT. The parent
// hands `unit` and `initialRange` already in the display unit; the
// persisted override and `METRIC_BOUNDS` are canonical and go through the
// adapter. Every other metric takes the identity path.
// A glucose sheet speaks the unit its panel registered, so the sheet and
// the band it edits can never disagree.
const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay();
const adapterKey = isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? "");
const units = resolveTargetUnitAdapter(
isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""),
adapterKey,
unit,
preference,
isGlucoseTargetMetric(adapterKey)
? resolveGlucoseUnit(unit)
: accountGlucoseUnit,
);
const toDisplay = (canonical: number) =>
isGlucoseMmol
? convertGlucose(canonical, "mmol/L")
: units.toDisplay(canonical);
const toCanonical = (displayValue: number) =>
isGlucoseMmol
? toCanonicalMgdl(displayValue, "mmol/L")
: units.toCanonical(displayValue);
// Guardrails the typed value is checked against, expressed in the
// display unit. Glucose projects its mg/dL window through the same
// conversion the fields use; every other metric rounds the window
// INWARD so a value typed at the display bound never inverts to a
// canonical value the server rejects.
const toDisplay = (canonical: number) => units.toDisplay(canonical);
const toCanonical = (displayValue: number) => units.toCanonical(displayValue);
// Guardrails the typed value is checked against, expressed in the display
// unit and rounded INWARD so a value typed at the display bound never
// inverts to a canonical value the server rejects.
const toDisplayBounds = (canonical: { min: number; max: number }) =>
isGlucoseMmol
? { min: toDisplay(canonical.min), max: toDisplay(canonical.max) }
: units.bounds(canonical);
units.bounds(canonical);
// Lazy-load the thresholds payload so the dialog also catches any
// already-persisted override even when the seeded `initialRange`
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
import { resolveTargetUnitAdapter } from "../target-unit-display";
import { toCanonicalMgdl } from "@/lib/glucose";
/**
* v1.32.27 — the target/threshold unit adapter.
@@ -18,7 +19,7 @@ const KG_PER_LB = 0.45359237;
describe("resolveTargetUnitAdapter — metric account (identity)", () => {
it("leaves a weight threshold bit-for-bit untouched", () => {
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric");
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric", "mg/dL");
expect(units.rescales).toBe(false);
expect(units.unit).toBe("kg");
// Not "close to" — exactly the same double, in both directions.
@@ -37,6 +38,7 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => {
"BLOOD_PRESSURE_SYS",
"mmHg",
preference,
"mg/dL",
);
expect(units.rescales).toBe(false);
expect(units.unit).toBe("mmHg");
@@ -51,11 +53,14 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => {
it("is inert for the keys the edit sheet passes on its non-metric paths", () => {
// A derived card (BMI, mood, medication compliance) has no editable
// threshold and the sheet resolves the adapter with an empty key;
// glucose keeps its own mg/dL ↔ mmol/L dialect and must never pick
// up a second conversion on top.
for (const key of ["", "BMI", "BLOOD_GLUCOSE_FASTING"]) {
const units = resolveTargetUnitAdapter(key, "mmol/L", "imperial");
// threshold and the sheet resolves the adapter with an empty key.
for (const key of ["", "BMI"]) {
const units = resolveTargetUnitAdapter(
key,
"mmol/L",
"imperial",
"mg/dL",
);
expect(units.rescales).toBe(false);
expect(units.unit).toBe("mmol/L");
expect(units.toDisplay(5.5)).toBe(5.5);
@@ -66,14 +71,15 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => {
it("keeps the whole-hundred step for a step-count target", () => {
for (const preference of ["metric", "imperial"] as const) {
expect(
resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference).step,
resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference, "mg/dL")
.step,
).toBe(100);
}
});
});
describe("resolveTargetUnitAdapter — imperial weight class", () => {
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial");
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial", "mg/dL");
it("announces pounds and a one-decimal step", () => {
expect(units.rescales).toBe(true);
@@ -145,7 +151,12 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => {
it("carries the same treatment across the whole weight class", () => {
for (const metric of ["TOTAL_BODY_WATER", "BONE_MASS"] as const) {
const adapter = resolveTargetUnitAdapter(metric, "kg", "imperial");
const adapter = resolveTargetUnitAdapter(
metric,
"kg",
"imperial",
"mg/dL",
);
expect(adapter.unit).toBe("lb");
const bounds = adapter.bounds(METRIC_BOUNDS[metric]);
expect(adapter.toCanonical(bounds.min)).toBeGreaterThanOrEqual(
@@ -169,6 +180,7 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => {
"BODY_TEMPERATURE",
"celsius",
"imperial",
"mg/dL",
);
it("shifts absolute readings but never a difference", () => {
@@ -208,6 +220,7 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => {
"BODY_TEMPERATURE",
"celsius",
"metric",
"mg/dL",
);
expect(metricUnits.rescales).toBe(false);
expect(metricUnits.toDisplay(37)).toBe(37);
@@ -224,3 +237,73 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => {
expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(45);
});
});
describe("resolveTargetUnitAdapter — glucose thresholds follow the glucose unit", () => {
const contexts = [
"BLOOD_GLUCOSE_FASTING",
"BLOOD_GLUCOSE_POSTPRANDIAL",
"BLOOD_GLUCOSE_RANDOM",
"BLOOD_GLUCOSE_BEDTIME",
] as const;
it("reads mmol/L for an mmol/L reader, whatever the metric/imperial branch", () => {
for (const metric of contexts) {
for (const preference of ["metric", "imperial"] as const) {
const units = resolveTargetUnitAdapter(
metric,
"mg/dL",
preference,
"mmol/L",
);
expect(units.unit).toBe("mmol/L");
expect(units.rescales).toBe(true);
expect(units.step).toBe(0.1);
// The 70–99 mg/dL fasting default reads 3.9–5.5.
expect(units.toDisplay(70)).toBe(3.9);
expect(units.toDisplay(99)).toBe(5.5);
}
}
});
it("round-trips a typed mmol/L value to whole mg/dL and back", () => {
const units = resolveTargetUnitAdapter(
"BLOOD_GLUCOSE_FASTING",
"mg/dL",
"metric",
"mmol/L",
);
expect(units.toCanonical(5.5)).toBe(99);
expect(units.toDisplay(units.toCanonical(5.5))).toBe(5.5);
expect(units.toCanonical(3.9)).toBe(toCanonicalMgdl(3.9, "mmol/L"));
});
it("rounds the canonical 40–400 mg/dL guardrail inward", () => {
const units = resolveTargetUnitAdapter(
"BLOOD_GLUCOSE_FASTING",
"mg/dL",
"metric",
"mmol/L",
);
const bounds = units.bounds(METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING);
expect(bounds).toEqual({ min: 2.3, max: 22.1 });
expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual(
METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING.min,
);
expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(
METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING.max,
);
});
it("stays the exact identity for an mg/dL reader", () => {
const units = resolveTargetUnitAdapter(
"BLOOD_GLUCOSE_FASTING",
"mg/dL",
"imperial",
"mg/dL",
);
expect(units.unit).toBe("mg/dL");
expect(units.rescales).toBe(false);
expect(units.toDisplay(99)).toBe(99);
expect(units.toCanonical(99)).toBe(99);
});
});
+46 -1
View File
@@ -48,6 +48,12 @@ import {
transformRescales,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import {
MGDL_PER_MMOL,
convertGlucose,
toCanonicalMgdl,
type GlucoseUnit,
} from "@/lib/glucose";
/** Canonical decimals a converted threshold value persists with. */
const CANONICAL_DECIMALS = 2;
@@ -119,6 +125,41 @@ function identityAdapter(metric: string, unit: string): TargetUnitAdapter {
};
}
/**
* The glucose thresholds (`BLOOD_GLUCOSE_FASTING` and its siblings) are
* stored in mg/dL and read in the reader's glucose unit. The settings editor
* used to take the identity path for them, so an mmol/L account was asked
* for mg/dL figures under a label that said nothing, against 40–400 bounds
* and a "default: 70–99" hint — while the insights sheet for the same
* threshold already spoke mmol/L.
*
* mmol/L reads at one decimal; the canonical write rounds to whole mg/dL
* (`toCanonicalMgdl`), the precision every glucose path stores. Guardrails
* round inward exactly like the imperial branch: 40 mg/dL is 2.22 mmol/L,
* shown as 2.3 so that the typed bound inverts to 41 mg/dL and passes.
*/
function glucoseAdapter(glucoseUnit: GlucoseUnit): TargetUnitAdapter {
if (glucoseUnit === "mg/dL") return identityAdapter("BLOOD_GLUCOSE", "mg/dL");
const toMmol = (mgdl: number) => mgdl / MGDL_PER_MMOL;
return {
unit: "mmol/L",
step: 0.1,
rescales: true,
toDisplay: (canonical) => convertGlucose(canonical, "mmol/L"),
toCanonical: (display) => toCanonicalMgdl(display, "mmol/L"),
toDisplayDelta: (canonicalDelta) => roundTo(toMmol(canonicalDelta), 1),
bounds: (canonical) => ({
min: ceilTo(toMmol(canonical.min), 1),
max: floorTo(toMmol(canonical.max), 1),
}),
};
}
/** True for a glucose threshold metric or target-card type. */
export function isGlucoseTargetMetric(metric: string): boolean {
return metric === "BLOOD_GLUCOSE" || metric.startsWith("BLOOD_GLUCOSE_");
}
/**
* Resolve the unit adapter for one threshold metric / target-card type.
*
@@ -128,13 +169,17 @@ function identityAdapter(metric: string, unit: string): TargetUnitAdapter {
* on, so a metric participates exactly when it has a registered
* transform. `canonicalUnit` is the unit the surface announced before
* this release (`METRIC_BOUNDS[metric].unit` or the targets payload's
* `unit`) and is what an untransformed metric keeps.
* `unit`) and is what an untransformed metric keeps. `glucoseUnit` is
* the reader's glucose preference; it decides the glucose thresholds and
* nothing else.
*/
export function resolveTargetUnitAdapter(
metric: string,
canonicalUnit: string,
preference: UnitPreference,
glucoseUnit: GlucoseUnit,
): TargetUnitAdapter {
if (isGlucoseTargetMetric(metric)) return glucoseAdapter(glucoseUnit);
if (!hasDisplayTransform(metric))
return identityAdapter(metric, canonicalUnit);