From 170862fdf83717d73e3fdb1fc9c652fa2f3a5ebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:59:38 +0200 Subject: [PATCH] fix(targets): convert glucose targets back to mg/dL on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) (cherry picked from commit 0cc0361058a85f32d7135e5f2fd425c98f62d885) --- .../__tests__/target-edit-sheet.test.tsx | 20 ++++++++ src/components/targets/target-edit-sheet.tsx | 47 +++++++++++++++++-- src/lib/__tests__/glucose.test.ts | 23 +++++++++ src/lib/glucose.ts | 15 ++++++ 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/src/components/targets/__tests__/target-edit-sheet.test.tsx b/src/components/targets/__tests__/target-edit-sheet.test.tsx index d64379473..115c8ba2c 100644 --- a/src/components/targets/__tests__/target-edit-sheet.test.tsx +++ b/src/components/targets/__tests__/target-edit-sheet.test.tsx @@ -96,6 +96,26 @@ describe("", () => { ).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( + , + ), + ), + ).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. diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index 948c5a6c9..baa25561f 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -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 diff --git a/src/lib/__tests__/glucose.test.ts b/src/lib/__tests__/glucose.test.ts index 40040b3c7..9ea3148dd 100644 --- a/src/lib/__tests__/glucose.test.ts +++ b/src/lib/__tests__/glucose.test.ts @@ -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); + }); }); diff --git a/src/lib/glucose.ts b/src/lib/glucose.ts index a794376a6..96cb33ab7 100644 --- a/src/lib/glucose.ts +++ b/src/lib/glucose.ts @@ -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 {