fix(targets): convert glucose targets back to mg/dL on save

The per-metric target editor receives a glucose range already projected
into the user's display unit (mmol/L) along with `unit="mmol/L"`, but it
seeded, validated and saved that value verbatim. HealthLog stores glucose
canonically in mg/dL, so a user on the mmol/L preference either had a
valid target (e.g. 3.9–5.5 mmol/L) rejected by the 40–400 mg/dL bounds,
or — when an override already existed — saw a mg/dL number rendered as if
it were mmol/L and wrote it straight back.

Add `mmolToMgdl` / `toCanonicalMgdl` (the inverse of `convertGlucose`) and
teach the edit sheet to treat the display unit at its boundary: seed the
persisted mg/dL override into the display unit, validate against bounds
expressed in the display unit, and convert the typed value back to
canonical mg/dL before the PUT. A no-op for every non-glucose metric and
for users on the mg/dL preference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 0cc0361058a85f32d7135e5f2fd425c98f62d885)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 17:01:14 +02:00
1 parent 09af60303d
commit 170862fdf8
4 files changed
+101 -4

No files matched your search

@@ -96,6 +96,26 @@ describe("<TargetEditSheet>", () => {
).not.toThrow();
});
it("mounts a glucose target seeded in mmol/L without throwing", () => {
// The parent hands a mmol/L-converted range + unit="mmol/L"; the
// sheet now seeds/validates in mmol/L and converts back to mg/dL on
// save. Smoke-check the new conversion path mounts cleanly.
expect(() =>
renderToStaticMarkup(
withProviders(
<TargetEditSheet
targetType="BLOOD_GLUCOSE_FASTING"
targetLabel="Fasting glucose"
unit="mmol/L"
initialRange={{ min: 3.9, max: 5.5 }}
open={true}
onOpenChange={vi.fn()}
/>,
),
),
).not.toThrow();
});
it("mounts the body without throwing for a derived metric (BMI)", () => {
// BMI is derived from weight + height — the body shows the
// explanatory hint instead of the editable inputs.
+43 -4
View File
@@ -16,6 +16,7 @@ import {
type ThresholdMetric,
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose";
/**
* Per-metric target editor, mounted inline on the Insights metric
@@ -120,6 +121,24 @@ 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";
const toDisplay = (mgdl: number) =>
isGlucoseMmol ? convertGlucose(mgdl, "mmol/L") : mgdl;
const toCanonical = (displayValue: number) =>
isGlucoseMmol ? toCanonicalMgdl(displayValue, "mmol/L") : displayValue;
// Lazy-load the thresholds payload so the dialog also catches any
// already-persisted override even when the seeded `initialRange`
// came from the targets API (which can paint a different in-band
@@ -142,13 +161,18 @@ function TargetEditSheetBody({
m: ThresholdMetric | null,
fallback: CurrentRange | null,
) => {
// The persisted override is canonical (mg/dL for glucose); the
// parent-provided `fallback` (initialRange) is already in the
// display unit. Convert the override into the display unit so the
// seeded inputs always read in the unit the label announces.
if (m && thresholdsData?.overrides?.[m]) {
return thresholdsData.overrides[m]!;
const override = thresholdsData.overrides[m]!;
return { min: toDisplay(override.min), max: toDisplay(override.max) };
}
if (fallback) return fallback;
if (m) {
const bounds = METRIC_BOUNDS[m];
return { min: bounds.min, max: bounds.max };
return { min: toDisplay(bounds.min), max: toDisplay(bounds.max) };
}
return { min: 0, max: 100 };
};
@@ -260,13 +284,24 @@ function TargetEditSheetBody({
const diaMinNum = parseFloat(displayDiaMin);
const diaMaxNum = parseFloat(displayDiaMax);
const primaryBounds = isBp
const canonicalPrimaryBounds = isBp
? METRIC_BOUNDS.BLOOD_PRESSURE_SYS
: metric
? METRIC_BOUNDS[metric]
: null;
const secondaryBounds = isBp ? METRIC_BOUNDS.BLOOD_PRESSURE_DIA : null;
// Bounds the user is validated/clamped against — expressed in the
// display unit so a mmol/L glucose entry is checked against the
// mmol/L-projected 40–400 mg/dL window, not the raw mg/dL numbers.
const primaryBounds = canonicalPrimaryBounds
? {
min: toDisplay(canonicalPrimaryBounds.min),
max: toDisplay(canonicalPrimaryBounds.max),
unit,
}
: null;
const primaryValid =
primaryBounds != null &&
Number.isFinite(minNum) &&
@@ -296,7 +331,11 @@ function TargetEditSheetBody({
return;
}
if (!metric) return;
updateMutation.mutate({ [metric]: { min: minNum, max: maxNum } });
// Convert the typed display-unit value back to canonical mg/dL for
// glucose on the mmol/L preference; a no-op for every other metric.
updateMutation.mutate({
[metric]: { min: toCanonical(minNum), max: toCanonical(maxNum) },
});
};
const hasOverride = isBp
+23
View File
@@ -1,7 +1,9 @@
import { describe, it, expect } from "vitest";
import {
mgdlToMmol,
mmolToMgdl,
convertGlucose,
toCanonicalMgdl,
resolveGlucoseUnit,
thresholdMetricForContext,
} from "../glucose";
@@ -50,4 +52,25 @@ describe("glucose conversion", () => {
expect(convertGlucose(fastingMax, "mmol/L")).toBe(5.5);
expect(convertGlucose(ada126, "mmol/L")).toBe(7);
});
// The target-editor write path: a value the user typed in their
// display unit must persist as canonical mg/dL. Before the fix the
// editor saved the mmol/L number verbatim (a 5.5 mmol/L target stored
// as 5.5 mg/dL, or rejected outright by the 40–400 mg/dL bounds).
it("converts a display-unit value back to canonical mg/dL", () => {
expect(mmolToMgdl(5.5)).toBe(99);
expect(mmolToMgdl(7.0)).toBe(126); // ADA diabetes threshold
expect(mmolToMgdl(3.9)).toBe(70); // hypoglycemia threshold
});
it("toCanonicalMgdl is the inverse of convertGlucose at the editor boundary", () => {
// mg/dL display unit is already canonical — only rounds.
expect(toCanonicalMgdl(126, "mg/dL")).toBe(126);
expect(toCanonicalMgdl(99.4, "mg/dL")).toBe(99);
// mmol/L display unit converts back; the round-trip is stable to the
// 1-decimal mmol/L the UI shows (≤1 mg/dL drift is unavoidable).
expect(toCanonicalMgdl(7.0, "mmol/L")).toBe(126);
expect(toCanonicalMgdl(mgdlToMmol(126), "mmol/L")).toBeCloseTo(126, -0.5);
expect(toCanonicalMgdl(mgdlToMmol(70), "mmol/L")).toBe(70);
});
});
+15
View File
@@ -15,10 +15,25 @@ export function mgdlToMmol(mgdl: number): number {
return Math.round((mgdl / MGDL_PER_MMOL) * 10) / 10;
}
export function mmolToMgdl(mmol: number): number {
return Math.round(mmol * MGDL_PER_MMOL);
}
export function convertGlucose(value: number, to: GlucoseUnit): number {
return to === "mmol/L" ? mgdlToMmol(value) : Math.round(value);
}
/**
* Convert a value the user typed in their display unit back to the
* canonical mg/dL HealthLog stores. The inverse of {@link convertGlucose}
* for the editor write path: a `5.5 mmol/L` target the user enters must
* persist as `99 mg/dL`, not as the literal `5.5`. A `mg/dL` display unit
* is already canonical, so it only rounds.
*/
export function toCanonicalMgdl(value: number, from: GlucoseUnit): number {
return from === "mmol/L" ? mmolToMgdl(value) : Math.round(value);
}
export function resolveGlucoseUnit(
userPreference: string | null | undefined,
): GlucoseUnit {