diff --git a/CHANGELOG.md b/CHANGELOG.md index e93ca89f7..f7e8b3a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 35d684eff..68f1a5e20 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index a39b086fa..3acabb0c1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index b965a2c19..f7112ce6e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index ff91da3c0..0a7c638ec 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -31,13 +31,32 @@ const TRANSFORMED_UNITS: ReadonlySet = 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"); diff --git a/src/components/insights/__tests__/metric-target-summary-unit-preference.test.tsx b/src/components/insights/__tests__/metric-target-summary-unit-preference.test.tsx new file mode 100644 index 000000000..91d0a0c75 --- /dev/null +++ b/src/components/insights/__tests__/metric-target-summary-unit-preference.test.tsx @@ -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( + + + , + ); +} + +describe(" 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")); + }); +}); 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({