mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Reference in new issue
Block a user