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

+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 {