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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 03:24:10 +02:00
1 parent cfd5cf0e0c
commit bf73755899
4 files changed
+266 -29

No files matched your search

@@ -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 `<MetricPrimaryTile>` (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({
</CardHeader>
<CardContent className="space-y-3">
{/* Row 2: range bar — where today's value sits inside the band. */}
{target.current != null ? (
{currentValue != null ? (
<RangeBar
value={target.current}
value={currentValue}
min={range.min}
max={range.max}
unit={unit}
@@ -19,6 +19,8 @@ import {
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import { SettingsCard } from "@/components/settings/settings-card";
interface ThresholdsApiResponse {
@@ -215,14 +217,35 @@ function MetricRow({
}: MetricRowProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const bounds = METRIC_BOUNDS[metric];
const { preference } = useUnitDisplay();
const canonicalBounds = METRIC_BOUNDS[metric];
// v1.32.27 — the row reads and writes in the user's preferred unit
// while `User.thresholdsJson` stays canonical SI. Everything the row
// receives (`override`, `effective.default`, `METRIC_BOUNDS`) is
// canonical, so it converts on the way into the fields and inverts on
// the way back out in `onSave`. Guardrails round inward so a value
// typed at the displayed limit still passes the server's canonical
// check. Metrics without a transform — and every metric for a metric
// user — take the adapter's identity path untouched.
const units = resolveTargetUnitAdapter(
metric,
canonicalBounds.unit,
preference,
);
const bounds = { ...units.bounds(canonicalBounds), unit: units.unit };
/** Canonical seed → the string the field shows, falling back to the bound. */
const seedString = (canonical: number | undefined, fallback: number) =>
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({
<p className="text-sm font-medium">{t(METRIC_LABEL_KEYS[metric])}</p>
<p className="text-muted-foreground text-xs">
{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")}
</p>
</div>
@@ -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({
</div>
<div className="flex items-end gap-2">
<Button
onClick={() => valid && onSave({ min: minNum, max: maxNum })}
onClick={() =>
valid &&
onSave({
min: units.toCanonical(minNum),
max: units.toCanonical(maxNum),
})
}
disabled={busy || !valid}
size="sm"
className="min-h-11 sm:min-h-9"
+37 -14
View File
@@ -17,6 +17,8 @@ import {
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose";
import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
/**
@@ -133,10 +135,38 @@ function TargetEditSheetBody({
// 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;
// v1.32.27 — the same coupled treatment now covers the metric/imperial
// preference. Weight-class thresholds are stored in kg; an imperial
// user seeds, validates, and types in lb, and the adapter inverts the
// typed number back to kg before the PUT. Glucose keeps its own
// mg/dL ↔ mmol/L dialect (it carries no display transform, so the two
// never compose); every other metric takes the adapter's identity
// path and is numerically untouched.
const { preference } = useUnitDisplay();
const units = resolveTargetUnitAdapter(
isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""),
unit,
preference,
);
const toDisplay = (canonical: number) =>
isGlucoseMmol
? convertGlucose(canonical, "mmol/L")
: units.toDisplay(canonical);
const toCanonical = (displayValue: number) =>
isGlucoseMmol ? toCanonicalMgdl(displayValue, "mmol/L") : displayValue;
isGlucoseMmol
? toCanonicalMgdl(displayValue, "mmol/L")
: units.toCanonical(displayValue);
// Guardrails the typed value is checked against, expressed in the
// display unit. Glucose projects its mg/dL window through the same
// conversion the fields use; every other metric rounds the window
// INWARD so a value typed at the display bound never inverts to a
// canonical value the server rejects.
const toDisplayBounds = (canonical: { min: number; max: number }) =>
isGlucoseMmol
? { min: toDisplay(canonical.min), max: toDisplay(canonical.max) }
: units.bounds(canonical);
// Lazy-load the thresholds payload so the dialog also catches any
// already-persisted override even when the seeded `initialRange`
@@ -166,10 +196,7 @@ function TargetEditSheetBody({
return { min: toDisplay(override.min), max: toDisplay(override.max) };
}
if (fallback) return fallback;
if (m) {
const bounds = METRIC_BOUNDS[m];
return { min: toDisplay(bounds.min), max: toDisplay(bounds.max) };
}
if (m) return toDisplayBounds(METRIC_BOUNDS[m]);
return { min: 0, max: 100 };
};
@@ -281,11 +308,7 @@ function TargetEditSheetBody({
// 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,
}
? { ...toDisplayBounds(canonicalPrimaryBounds), unit }
: null;
const primaryValid =
@@ -466,7 +489,7 @@ function TargetEditSheetBody({
id="target-edit-min"
ref={firstInputRef}
type="number"
step={targetType === "ACTIVITY_STEPS" ? 100 : 0.1}
step={units.step}
min={primaryBounds.min}
max={primaryBounds.max}
value={displayMin}
@@ -485,7 +508,7 @@ function TargetEditSheetBody({
<Input
id="target-edit-max"
type="number"
step={targetType === "ACTIVITY_STEPS" ? 100 : 0.1}
step={units.step}
min={primaryBounds.min}
max={primaryBounds.max}
value={displayMax}
+162
View File
@@ -0,0 +1,162 @@
/**
* v1.32.27 — the target/threshold subsystem's unit adapter.
*
* v1.32.26 wired the metric/imperial preference across every READING
* surface but deliberately left target ranges canonical: a target panel
* is a coupled display+edit unit, and converting only the display half
* would corrupt the edit seed and the save round-trip. This module is
* the missing half — one adapter that owns every direction of the
* conversion so the three target surfaces (the Insights reference
* panel, the per-metric edit sheet, the settings threshold editor)
* cannot drift apart.
*
* Storage never changes. `User.thresholdsJson`, `METRIC_BOUNDS`, the
* `/api/user/thresholds` wire contract, the effective-range resolver,
* the doctor report, and the AI snapshot all stay canonical SI. The
* adapter converts at the render boundary and inverts at the entry
* boundary, exactly like `measurement-form.tsx` does for a raw reading.
*
* The four rules that make the round-trip safe:
*
* 1. `toDisplay` rounds to the transform's `decimals`, so the field
* shows "150" and not "150.00000000000003".
* 2. `toCanonical` rounds the inverted value to 2 decimals — the same
* dialect the measurement entry form uses — so 150 lb persists as
* 68.04 kg, not 68.03885550000001.
* 3. Rules 1 + 2 compose back to the typed number: 150 lb → 68.04 kg
* → 150 lb. Save-then-reopen is stable.
* 4. Guardrails round INWARD (min up, max down). A naive round of the
* 30 kg floor gives 66.1 lb, which inverts to 29.98 kg and the
* server rejects it. Ceiling it to 66.2 lb inverts to 30.03 kg and
* passes.
*
* For a metric user every transformed type resolves to factor 1 /
* offset 0, `rescales` is false, and all four helpers return their
* argument untouched — no rounding, no re-derivation. A metric
* account's stored thresholds are therefore bit-for-bit unchanged by
* this release.
*/
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getDisplayTransform,
hasDisplayTransform,
invertDisplayTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
/** Canonical decimals a converted threshold value persists with. */
const CANONICAL_DECIMALS = 2;
/** Numeric `step` for a step-counting target — whole hundreds, not 0.1. */
const STEP_COUNT_INPUT_STEP = 100;
/** Fallback input step for a metric with no registered transform. */
const DEFAULT_INPUT_STEP = 0.1;
export interface TargetUnitAdapter {
/** Unit symbol every label, hint, and tooltip on the surface announces. */
unit: string;
/** `step` for a display-unit numeric input on this metric. */
step: number;
/**
* True when the adapter actually rescales the number (the imperial
* branch of a transformed type). False for a metric user and for
* every untransformed metric, in which case every helper below is the
* exact identity.
*/
rescales: boolean;
/** Canonical stored value → the number the surface renders. */
toDisplay(canonical: number): number;
/** Typed display number → the canonical value that gets persisted. */
toCanonical(display: number): number;
/**
* Canonical DELTA / band width / spread → display. Factor only, never
* the affine offset: a 1 °C spread is a 1.8 °F spread, not 33.8 °F.
*/
toDisplayDelta(canonicalDelta: number): number;
/**
* Canonical guardrail pair → the display window a typed value is
* validated against, rounded inward so the inverted value never falls
* outside the canonical guardrail the server enforces.
*/
bounds(canonical: { min: number; max: number }): {
min: number;
max: number;
};
}
function roundTo(value: number, decimals: number): number {
const scale = 10 ** decimals;
return Math.round(value * scale) / scale;
}
function ceilTo(value: number, decimals: number): number {
const scale = 10 ** decimals;
return Math.ceil(value * scale) / scale;
}
function floorTo(value: number, decimals: number): number {
const scale = 10 ** decimals;
return Math.floor(value * scale) / scale;
}
/** The pass-through adapter: canonical unit, no arithmetic at all. */
function identityAdapter(metric: string, unit: string): TargetUnitAdapter {
return {
unit,
step:
metric === "ACTIVITY_STEPS" ? STEP_COUNT_INPUT_STEP : DEFAULT_INPUT_STEP,
rescales: false,
toDisplay: (canonical) => canonical,
toCanonical: (display) => display,
toDisplayDelta: (canonicalDelta) => canonicalDelta,
bounds: (canonical) => ({ min: canonical.min, max: canonical.max }),
};
}
/**
* Resolve the unit adapter for one threshold metric / target-card type.
*
* `metric` is the threshold-metric literal (`"WEIGHT"`,
* `"BLOOD_PRESSURE_SYS"`, …); for the transformed set those literals are
* the measurement-type literals the display-transform registry is keyed
* on, so a metric participates exactly when it has a registered
* transform. `canonicalUnit` is the unit the surface announced before
* this release (`METRIC_BOUNDS[metric].unit` or the targets payload's
* `unit`) and is what an untransformed metric keeps.
*/
export function resolveTargetUnitAdapter(
metric: string,
canonicalUnit: string,
preference: UnitPreference,
): TargetUnitAdapter {
if (!hasDisplayTransform(metric))
return identityAdapter(metric, canonicalUnit);
const transform = getDisplayTransform(metric, preference);
const rescales = transform.factor !== 1 || (transform.offset ?? 0) !== 0;
// A transformed type on the metric branch keeps the transform's
// DISPLAY symbol (which is the canonical symbol for every threshold
// metric in the registry today) but no arithmetic — the identity
// adapter guarantees the stored value is untouched.
if (!rescales) return identityAdapter(metric, transform.displayUnit);
const { decimals } = transform;
return {
unit: transform.displayUnit,
step: metric === "ACTIVITY_STEPS" ? STEP_COUNT_INPUT_STEP : 10 ** -decimals,
rescales: true,
toDisplay: (canonical) =>
roundTo(applyDisplayTransform(canonical, transform), decimals),
toCanonical: (display) =>
roundTo(invertDisplayTransform(display, transform), CANONICAL_DECIMALS),
toDisplayDelta: (canonicalDelta) =>
roundTo(applyDisplayTransformDelta(canonicalDelta, transform), decimals),
bounds: (canonical) => ({
min: ceilTo(applyDisplayTransform(canonical.min, transform), decimals),
max: floorTo(applyDisplayTransform(canonical.max, transform), decimals),
}),
};
}