Merge pull request #630 from MBombeck/release/v1.32.27

release/v1.32.27 — target and threshold panels follow the unit preference
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-25 03:43:29 +02:00
commit 8af5c6a0eb
12 files changed
+679 -33

No files matched your search

+21
View File
@@ -2,6 +2,27 @@
## [Unreleased]
## [1.32.27] — 2026-07-25
Target and threshold panels now follow the metric and imperial preference. The
previous release listed this as the one place still reading in kilograms and
Celsius: with imperial selected a weight chart read in pounds while the target
reference right below it read in kilograms.
- **Target ranges read in your unit.** On the weight insight page the target
band, the range bar, the status pill, and the 30 day average all read in
pounds when imperial is selected. Body water and bone mass follow the same
rule in the threshold settings.
- **Editing a target works in the unit you see.** The target editor and the
threshold settings seed their fields from the stored value converted to your
unit, check what you type against limits expressed in that same unit, and
convert back before saving. A value typed at the displayed limit is accepted
instead of being rejected against a kilogram bound you never saw. Saving and
reopening shows the number you typed.
- **Storage is unchanged.** Targets are still kept in canonical SI, and a metric
account's saved values are untouched by this release.
- **Known remaining.** Height is still entered in centimetres.
## [1.32.26] — 2026-07-25
The metric and imperial display preference is now applied everywhere your
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.32.26
version: 1.32.27
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.32.26",
"version": "1.32.27",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.32.26";
/* @sw-version-fallback */ "v1.32.27";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -31,13 +31,32 @@ const TRANSFORMED_UNITS: ReadonlySet<string> = new Set(
]),
);
/** Swept surfaces: the dashboard client + every insights sub-page. */
/**
* v1.32.27 — the target/threshold subsystem. These three files are the
* coupled display+edit unit: the reference panel that RENDERS a target
* band, the sheet that EDITS it, and the settings editor that edits the
* same thresholds from the other end. A partial wire here is worse than
* none — a converted display over a canonical save silently rewrites
* the user's stored target — so they are swept for hardcoded units AND
* asserted to route through the preference hook.
*/
const TARGET_SURFACES = [
join(SRC, "components", "insights", "metric-target-summary.tsx"),
join(SRC, "components", "targets", "target-edit-sheet.tsx"),
join(SRC, "components", "settings", "thresholds-editor-section.tsx"),
];
/**
* Swept surfaces: the dashboard client, every insights sub-page, and the
* target/threshold surfaces.
*/
function sweptFiles(): string[] {
return [
join(SRC, "app", "page-client.tsx"),
...globSync("app/insights/**/page.tsx", { cwd: SRC }).map((rel) =>
join(SRC, rel),
),
...TARGET_SURFACES,
];
}
@@ -74,6 +93,37 @@ describe("unit-preference display guard", () => {
expect(offenders).toEqual([]);
});
it("every target/threshold surface resolves its unit from the preference", () => {
// The tripwire for a silent un-wire: delete the hook import from any
// of the three and the surface falls back to canonical kilograms
// while its sibling still shows pounds.
// The call site, not the import — swapping the hook out for a
// hardcoded "metric" literal while leaving the import behind is
// exactly the un-wire this has to catch.
const offenders = TARGET_SURFACES.filter(
(file) => !readFileSync(file, "utf8").includes("useUnitDisplay()"),
);
expect(offenders).toEqual([]);
});
it("every target/threshold surface converts through the shared adapter", () => {
// One adapter owns seed, guardrail, save, and unit label for all
// three surfaces. A surface that hand-rolls its own conversion (or
// announces `METRIC_BOUNDS[metric].unit`, the canonical symbol)
// is how the display and the edit halves drift apart.
const offenders: string[] = [];
for (const file of TARGET_SURFACES) {
const src = readFileSync(file, "utf8");
if (!src.includes("resolveTargetUnitAdapter")) {
offenders.push(`${file}: no adapter`);
}
if (/METRIC_BOUNDS\[[^\]]+\]\.unit/.test(src)) {
offenders.push(`${file}: canonical bound unit rendered`);
}
}
expect(offenders).toEqual([]);
});
it("every transformed type carries both a metric and an imperial branch", () => {
for (const type of TRANSFORMED_TYPES) {
const metric = getDisplayTransform(type, "metric");
@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.32.27 — the target reference panel follows the metric/imperial
* preference.
*
* `/api/insights/targets` stays canonical (kg for weight); the panel
* converts once and hands the converted pair to the range bar, the
* status pill, and the edit sheet's seed. These assertions read the
* range bar's axis labels out of the SSR markup — the one place the
* band endpoints and their unit are rendered as plain text rather than
* inside a portalled tooltip.
*/
const authUser = vi.hoisted(() => ({
value: null as { unitPreference?: string } | null,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: vi.fn(() => ({ isAuthenticated: true, user: authUser.value })),
}));
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
const { MetricTargetSummary } = await import("../metric-target-summary");
/** A weight target as the route emits it: canonical kilograms. */
const weightPayload = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 72.5,
average30: 73.1,
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "green" },
source: "WHO",
daysInRange7d: 5,
daysLogged7d: 7,
daysInRange30d: 20,
daysLogged30d: 30,
insufficientData: false,
consistency7d: ["in", "in", "out", "in", "in", "near", "in"] as const,
},
],
bpDiastolic: { current: null, average30: null, range: null },
};
function renderWeightPanel(unitPreference: string | null): string {
authUser.value = unitPreference ? { unitPreference } : null;
useQueryMock.mockReturnValue({ data: weightPayload });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MetricTargetSummary slug="weight" />
</I18nProvider>,
);
}
describe("<MetricTargetSummary> under a unit preference", () => {
it("renders the weight band in pounds for an imperial account", () => {
const html = renderWeightPanel("imperial");
// 60 kg → 132.3 lb, 80 kg → 176.4 lb.
expect(html).toContain("132.3 lb");
expect(html).toContain("176.4 lb");
expect(html).not.toContain("60 kg");
expect(html).not.toContain("80 kg");
});
it("leaves the weight band in kilograms for a metric account", () => {
const html = renderWeightPanel("metric");
expect(html).toContain("60 kg");
expect(html).toContain("80 kg");
expect(html).not.toContain("lb");
});
it("treats a payload with no preference as metric", () => {
// A stale `/api/auth/me` payload must never silently rescale a band.
expect(renderWeightPanel(null)).toBe(renderWeightPanel("metric"));
});
});
@@ -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"
@@ -116,6 +116,29 @@ describe("<TargetEditSheet>", () => {
).not.toThrow();
});
it("mounts a weight target seeded in pounds without throwing", () => {
// v1.32.27 — an imperial account gets `unit="lb"` and a
// pound-converted seed from the panel; the sheet projects the
// kilogram guardrails into the same unit and inverts on save. The
// arithmetic itself is pinned in `target-unit-display.test.ts`
// (Radix portals the body, so SSR cannot read the fields back);
// this covers the mount path composing cleanly.
expect(() =>
renderToStaticMarkup(
withProviders(
<TargetEditSheet
targetType="WEIGHT"
targetLabel="Weight"
unit="lb"
initialRange={{ min: 132.3, max: 176.4 }}
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.
+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}
@@ -0,0 +1,226 @@
import { describe, it, expect } from "vitest";
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
import { resolveTargetUnitAdapter } from "../target-unit-display";
/**
* v1.32.27 — the target/threshold unit adapter.
*
* The dangerous failure mode this subsystem exists to avoid is a HALF
* wire: display converted, edit seed or save left canonical, so the
* user's typed number silently changes meaning between save and reopen.
* These assertions pin the whole loop — seed, guardrail, save, reopen —
* for the weight class (a pure scale) and for temperature (the affine
* one, where an offset leaking into a delta is the classic bug).
*/
const KG_PER_LB = 0.45359237;
describe("resolveTargetUnitAdapter — metric account (identity)", () => {
it("leaves a weight threshold bit-for-bit untouched", () => {
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric");
expect(units.rescales).toBe(false);
expect(units.unit).toBe("kg");
// Not "close to" — exactly the same double, in both directions.
for (const value of [30, 68.04, 72.5, 300, 88.123456789]) {
expect(units.toDisplay(value)).toBe(value);
expect(units.toCanonical(value)).toBe(value);
expect(units.toDisplayDelta(value)).toBe(value);
}
expect(units.bounds(METRIC_BOUNDS.WEIGHT)).toEqual({ min: 30, max: 300 });
expect(units.step).toBe(0.1);
});
it("leaves an untransformed metric alone under either preference", () => {
for (const preference of ["metric", "imperial"] as const) {
const units = resolveTargetUnitAdapter(
"BLOOD_PRESSURE_SYS",
"mmHg",
preference,
);
expect(units.rescales).toBe(false);
expect(units.unit).toBe("mmHg");
expect(units.toDisplay(118)).toBe(118);
expect(units.toCanonical(118)).toBe(118);
expect(units.bounds(METRIC_BOUNDS.BLOOD_PRESSURE_SYS)).toEqual({
min: 80,
max: 220,
});
}
});
it("is inert for the keys the edit sheet passes on its non-metric paths", () => {
// A derived card (BMI, mood, medication compliance) has no editable
// threshold and the sheet resolves the adapter with an empty key;
// glucose keeps its own mg/dL ↔ mmol/L dialect and must never pick
// up a second conversion on top.
for (const key of ["", "BMI", "BLOOD_GLUCOSE_FASTING"]) {
const units = resolveTargetUnitAdapter(key, "mmol/L", "imperial");
expect(units.rescales).toBe(false);
expect(units.unit).toBe("mmol/L");
expect(units.toDisplay(5.5)).toBe(5.5);
expect(units.toCanonical(5.5)).toBe(5.5);
}
});
it("keeps the whole-hundred step for a step-count target", () => {
for (const preference of ["metric", "imperial"] as const) {
expect(
resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference).step,
).toBe(100);
}
});
});
describe("resolveTargetUnitAdapter — imperial weight class", () => {
const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial");
it("announces pounds and a one-decimal step", () => {
expect(units.rescales).toBe(true);
expect(units.unit).toBe("lb");
expect(units.step).toBe(0.1);
});
it("seeds a stored kilogram threshold into pounds", () => {
// 68.04 kg is what 150 lb persists as; it must read back as 150.
expect(units.toDisplay(68.04)).toBe(150);
expect(units.toDisplay(80)).toBe(176.4);
});
it("saves a typed pound value back as canonical kilograms", () => {
expect(units.toCanonical(150)).toBe(68.04);
// 80 kg renders as 176.4 lb; typing 176.4 back lands on 80.01 kg,
// one hundredth off — the unavoidable cost of a one-decimal pound
// field. What must NOT drift is the number the user typed, which
// the round-trip sweep below pins.
expect(units.toCanonical(176.4)).toBe(80.01);
});
it("round-trips every typed value it can render (save → reopen)", () => {
// The property that matters: type a number, save, reopen, see the
// same number. Swept across the whole editable window at the
// input's own step granularity.
for (let typed = 70; typed <= 660; typed += 0.1) {
const shown = Math.round(typed * 10) / 10;
const canonical = units.toCanonical(shown);
expect(units.toDisplay(canonical)).toBe(shown);
}
});
it("rounds guardrails inward so a value typed at the limit still saves", () => {
const bounds = units.bounds(METRIC_BOUNDS.WEIGHT);
// A naive round of the 30 kg floor gives 66.1 lb, which inverts to
// 29.98 kg and the server rejects it. The ceiling keeps it legal.
expect(bounds.min).toBe(66.2);
expect(bounds.max).toBe(661.3);
expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual(
METRIC_BOUNDS.WEIGHT.min,
);
expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(
METRIC_BOUNDS.WEIGHT.max,
);
});
it("accepts an imperial-valid value a kilogram bound would have rejected", () => {
const bounds = units.bounds(METRIC_BOUNDS.WEIGHT);
// 150 lb sails past the raw canonical 30–300 check as a NUMBER only
// because the window is now expressed in pounds. Against the
// unconverted kilogram window a 400 lb target would have been read
// as "400 > 300, rejected" while it is a legitimate 181.44 kg.
for (const typed of [150, 400, 660]) {
expect(typed).toBeGreaterThanOrEqual(bounds.min);
expect(typed).toBeLessThanOrEqual(bounds.max);
const canonical = units.toCanonical(typed);
expect(canonical).toBeGreaterThanOrEqual(METRIC_BOUNDS.WEIGHT.min);
expect(canonical).toBeLessThanOrEqual(METRIC_BOUNDS.WEIGHT.max);
}
});
it("still rejects a genuinely out-of-range value", () => {
const bounds = units.bounds(METRIC_BOUNDS.WEIGHT);
for (const typed of [12, 66.1, 661.4, 900]) {
expect(typed < bounds.min || typed > bounds.max).toBe(true);
}
});
it("carries the same treatment across the whole weight class", () => {
for (const metric of ["TOTAL_BODY_WATER", "BONE_MASS"] as const) {
const adapter = resolveTargetUnitAdapter(metric, "kg", "imperial");
expect(adapter.unit).toBe("lb");
const bounds = adapter.bounds(METRIC_BOUNDS[metric]);
expect(adapter.toCanonical(bounds.min)).toBeGreaterThanOrEqual(
METRIC_BOUNDS[metric].min,
);
expect(adapter.toCanonical(bounds.max)).toBeLessThanOrEqual(
METRIC_BOUNDS[metric].max,
);
}
});
it("matches the exact pound definition, not an approximation", () => {
expect(units.toCanonical(100)).toBe(
Math.round(100 * KG_PER_LB * 100) / 100,
);
});
});
describe("resolveTargetUnitAdapter — temperature (the affine one)", () => {
const units = resolveTargetUnitAdapter(
"BODY_TEMPERATURE",
"celsius",
"imperial",
);
it("shifts absolute readings but never a difference", () => {
expect(units.unit).toBe("°F");
expect(units.toDisplay(37)).toBe(98.6);
expect(units.toDisplay(38)).toBe(100.4);
// A one-degree-Celsius spread is a 1.8-degree-Fahrenheit spread. If
// the delta ever picked up the +32 absolute shift it would read
// 33.8 — the exact bug `applyDisplayTransformDelta` exists to stop.
expect(units.toDisplayDelta(1)).toBe(1.8);
expect(units.toDisplayDelta(1)).not.toBe(33.8);
});
it("keeps a rendered difference offset-free by construction", () => {
// The range bar computes its "x above target" sentence from two
// already-converted absolutes. That subtraction must agree with the
// factor-only delta conversion, or the two would disagree by 32.
for (const [a, b] of [
[38, 37],
[37.4, 36.6],
[41, 35],
]) {
const difference = units.toDisplay(a) - units.toDisplay(b);
expect(difference).toBeCloseTo(units.toDisplayDelta(a - b), 6);
}
});
it("round-trips a typed Fahrenheit target back to itself", () => {
for (let typed = 95; typed <= 106; typed += 0.1) {
const shown = Math.round(typed * 10) / 10;
expect(units.toDisplay(units.toCanonical(shown))).toBe(shown);
}
});
it("is the exact identity for a metric account", () => {
const metricUnits = resolveTargetUnitAdapter(
"BODY_TEMPERATURE",
"celsius",
"metric",
);
expect(metricUnits.rescales).toBe(false);
expect(metricUnits.toDisplay(37)).toBe(37);
expect(metricUnits.toCanonical(37)).toBe(37);
expect(metricUnits.toDisplayDelta(1)).toBe(1);
});
it("rounds an affine guardrail inward on both ends", () => {
const bounds = units.bounds({ min: 30, max: 45 });
// 30 °C = 86 °F and 45 °C = 113 °F exactly; inward rounding must not
// drift a clean bound off its own value.
expect(bounds).toEqual({ min: 86, max: 113 });
expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual(30);
expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(45);
});
});
+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),
}),
};
}