From bf7375589905f9e4a3f397c9f60ae4b1206b4210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 03:24:10 +0200 Subject: [PATCH 1/3] fix(targets): follow the unit preference in target and threshold panels Target ranges stayed canonical while every reading surface moved to the metric/imperial preference in the previous release, so an imperial account saw a weight chart in pounds above a target reference in kilograms. The panel is a coupled display and edit unit: converting only its display would have corrupted the edit seed and the save round-trip, which is why it was held back. Add one adapter that owns every direction of the conversion and route all three surfaces through it. The reference panel converts the canonical payload once and hands the converted pair to the range bar, the status pill, and the editor seed. The editor seeds from the stored value converted to the display unit, validates against guardrails projected into that same unit, and inverts the typed number back to canonical before the PUT. Guardrails round inward, so the 30 kg floor projects to 66.2 lb rather than 66.1 lb, which would have inverted below the floor the server enforces. Absolute values carry the affine offset, differences take the factor alone. Input step comes from the transform's own precision. Storage is untouched: thresholds stay canonical SI, and for a metric account the adapter is the exact identity in both directions, so a stored value is unchanged bit for bit. --- .../insights/metric-target-summary.tsx | 35 +++- .../settings/thresholds-editor-section.tsx | 47 ++++- src/components/targets/target-edit-sheet.tsx | 51 ++++-- src/lib/targets/target-unit-display.ts | 162 ++++++++++++++++++ 4 files changed, 266 insertions(+), 29 deletions(-) create mode 100644 src/lib/targets/target-unit-display.ts diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index aec8c9079..2718c3b1b 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -5,9 +5,11 @@ import { useQuery } from "@tanstack/react-query"; import { ExternalLink, Target } from "lucide-react"; 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"; import { ConsistencyStrip } from "@/components/targets/consistency-strip"; @@ -229,8 +231,29 @@ function TargetReferencePanel({ }: TargetReferencePanelProps) { const { t } = useTranslations(); const adjust = useTargetAdjust(); + const { preference } = useUnitDisplay(); - const { range, unit } = target; + // 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); + const unit = units.unit; + const range = target.range + ? { + min: units.toDisplay(target.range.min), + max: units.toDisplay(target.range.max), + } + : null; + const currentValue = + target.current == null ? null : units.toDisplay(target.current); + const average30 = + target.average30 == null ? null : units.toDisplay(target.average30); // Register this metric as the header gear's edit target. The effect // re-runs when the seeded range / unit / label changes (e.g. a glucose @@ -282,11 +305,11 @@ function TargetReferencePanel({ // the exception: it has no `` (this richer panel IS // its primary tile), so it keeps the stitched S/D 30-day average inline. let averageLabel: string | null = null; - if (isBp && target.average30 != null) { + if (isBp && average30 != null) { const avgValue = bpDiastolic?.average30 != null - ? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}` - : String(Math.round(target.average30 * 10) / 10); + ? `${Math.round(average30)}/${Math.round(bpDiastolic.average30)}` + : String(Math.round(average30 * 10) / 10); averageLabel = `${t("targets.average30d")} ${avgValue} ${unit}`; } @@ -338,9 +361,9 @@ function TargetReferencePanel({ {/* Row 2: range bar — where today's value sits inside the band. */} - {target.current != null ? ( + {currentValue != null ? ( + String(canonical == null ? fallback : units.toDisplay(canonical)); + const hasOverride = override !== null; const [overrideMode, setOverrideMode] = useState(hasOverride); const [minStr, setMinStr] = useState( - String(override?.min ?? effective?.default?.greenMin ?? bounds.min), + seedString(override?.min ?? effective?.default?.greenMin, bounds.min), ); const [maxStr, setMaxStr] = useState( - String(override?.max ?? effective?.default?.greenMax ?? bounds.max), + seedString(override?.max ?? effective?.default?.greenMax, bounds.max), ); // v1.30.1 M9 — the row is keyed on the stable `metric`, so a per-row @@ -244,10 +267,10 @@ function MetricRow({ setSyncedIdentity(overrideIdentity); setOverrideMode(hasOverride); setMinStr( - String(override?.min ?? effective?.default?.greenMin ?? bounds.min), + seedString(override?.min ?? effective?.default?.greenMin, bounds.min), ); setMaxStr( - String(override?.max ?? effective?.default?.greenMax ?? bounds.max), + seedString(override?.max ?? effective?.default?.greenMax, bounds.max), ); } @@ -269,7 +292,7 @@ function MetricRow({

{t(METRIC_LABEL_KEYS[metric])}

{defaultRange - ? `${t("thresholds.defaultLabel")}: ${fmt.number(defaultRange.greenMin, 1)}–${fmt.number(defaultRange.greenMax, 1)} ${bounds.unit}` + ? `${t("thresholds.defaultLabel")}: ${fmt.number(units.toDisplay(defaultRange.greenMin), 1)}–${fmt.number(units.toDisplay(defaultRange.greenMax), 1)} ${bounds.unit}` : t("thresholds.unsetExplanation")}

@@ -316,7 +339,7 @@ function MetricRow({ type="number" inputMode={metric === "ACTIVITY_STEPS" ? "numeric" : "decimal"} enterKeyHint="next" - step={metric === "ACTIVITY_STEPS" ? 100 : 0.1} + step={units.step} min={bounds.min} max={bounds.max} value={minStr} @@ -334,7 +357,7 @@ function MetricRow({ type="number" inputMode={metric === "ACTIVITY_STEPS" ? "numeric" : "decimal"} enterKeyHint="done" - step={metric === "ACTIVITY_STEPS" ? 100 : 0.1} + step={units.step} min={bounds.min} max={bounds.max} value={maxStr} @@ -344,7 +367,13 @@ function MetricRow({