mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
12 files changed
+679
-33
No files matched your search
@@ -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,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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user