diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5c8f92b9..e93ca89f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,35 @@
## [Unreleased]
+## [1.32.26] — 2026-07-25
+
+The metric and imperial display preference is now applied everywhere your
+weight, body composition, waist, and temperature readings appear. Until now the
+setting was saved but almost nothing read it, so the numbers stayed in
+kilograms and Celsius regardless of the choice. Thanks to a self-hoster who
+reported the gap and traced how the unit was being handled.
+
+- **Weight and body composition follow the preference.** With imperial
+ selected, weight, muscle mass, body water, bone mass, fat mass, and grip
+ strength read in pounds across the dashboard tiles, the charts, the record
+ list, the insight pages, and the coach read strip. Body temperature, skin and
+ wrist temperature read in degrees Fahrenheit, and waist reads in inches.
+ Storage is unchanged: every reading is still kept in canonical SI, and a
+ metric user sees exactly the same numbers as before.
+- **Manual entry converts at the point of entry.** When you log or edit a value
+ with imperial selected, the field is labelled in your unit and the number you
+ type is converted back to canonical on save, so a pound reading is stored as
+ the correct kilogram value rather than a mislabelled one.
+- **A logging tool can no longer store a foreign unit.** The measurement logging
+ tool used by connected assistants now converts a recognised unit hint (such as
+ pounds or Fahrenheit) to canonical, and refuses anything it does not
+ recognise, so a reading is never saved under the wrong unit.
+- **Known remaining.** Height is entered in centimetres for now. Target and
+ threshold panels also still read in kilograms and Celsius, so with imperial
+ selected a weight chart reads in pounds while its target reference below it
+ reads in kilograms. The stored values are correct either way; both are
+ separate follow-ups.
+
## [1.32.25] — 2026-07-24
Mirrored medications are now marked as such, and the server limits how many
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 37001e20a..35d684eff 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.25
+ version: 1.32.26
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
diff --git a/messages/de.json b/messages/de.json
index 670f4787e..3f965de1d 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1885,7 +1885,7 @@
"levelShift": "Um {date} wurde eine Verschiebung des Niveaus erkannt.",
"plateau": {
"title": "Gewichts-Plateau auf aktueller Dosis",
- "evidence": "Gewichtsveränderung von {delta} kg in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}."
+ "evidence": "Gewichtsveränderung von {delta} {unit} in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}."
},
"retarget": {
"label": "Nicht der richtige Messwert?",
diff --git a/messages/en.json b/messages/en.json
index 6d73a0395..ef2007aae 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1875,7 +1875,7 @@
"levelShift": "A shift in the level was detected around {date}.",
"plateau": {
"title": "Weight plateau on the current dose",
- "evidence": "Weight moved by {delta} kg over the last {window} days ({readings} readings) — {days} days on {dose}."
+ "evidence": "Weight moved by {delta} {unit} over the last {window} days ({readings} readings) — {days} days on {dose}."
},
"retarget": {
"label": "Not the right measurement?",
diff --git a/messages/es.json b/messages/es.json
index 10c00c0aa..c6c9f21a2 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1887,7 +1887,7 @@
"levelShift": "Se detectó un cambio de nivel en torno a {date}.",
"plateau": {
"title": "Meseta de peso con la dosis actual",
- "evidence": "Variación de peso de {delta} kg en los últimos {window} días ({readings} mediciones) — {days} días con {dose}."
+ "evidence": "Variación de peso de {delta} {unit} en los últimos {window} días ({readings} mediciones) — {days} días con {dose}."
},
"retarget": {
"label": "¿No es la medición correcta?",
diff --git a/messages/fr.json b/messages/fr.json
index 754070013..870ed5dfd 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1889,7 +1889,7 @@
"levelShift": "Un changement de niveau a été détecté autour du {date}.",
"plateau": {
"title": "Plateau de poids à la dose actuelle",
- "evidence": "Variation de poids de {delta} kg sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}."
+ "evidence": "Variation de poids de {delta} {unit} sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}."
},
"retarget": {
"label": "Ce n'est pas la bonne mesure ?",
diff --git a/messages/it.json b/messages/it.json
index b4eeea2a1..c8eae2287 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1879,7 +1879,7 @@
"levelShift": "È stato rilevato un cambio di livello intorno al {date}.",
"plateau": {
"title": "Plateau del peso alla dose attuale",
- "evidence": "Variazione di peso di {delta} kg negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}."
+ "evidence": "Variazione di peso di {delta} {unit} negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}."
},
"retarget": {
"label": "Non è la misurazione giusta?",
diff --git a/messages/pl.json b/messages/pl.json
index ddd224985..0ea309978 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1873,7 +1873,7 @@
"levelShift": "Wykryto zmianę poziomu w okolicach {date}.",
"plateau": {
"title": "Plateau masy ciała przy obecnej dawce",
- "evidence": "Zmiana masy ciała o {delta} kg w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}."
+ "evidence": "Zmiana masy ciała o {delta} {unit} w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}."
},
"retarget": {
"label": "To nie ten pomiar?",
diff --git a/package.json b/package.json
index 42b589920..a39b086fa 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "healthlog",
- "version": "1.32.25",
+ "version": "1.32.26",
"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 c27c3ffc0..b965a2c19 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.25";
+ /* @sw-version-fallback */ "v1.32.26";
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/scripts/convert-noncanonical-measurement-units.ts b/scripts/convert-noncanonical-measurement-units.ts
new file mode 100644
index 000000000..18a75cbda
--- /dev/null
+++ b/scripts/convert-noncanonical-measurement-units.ts
@@ -0,0 +1,104 @@
+/**
+ * Convert measurement rows stored in a RECOGNISED non-canonical unit to
+ * canonical SI, in place. Companion to
+ * `diagnose-noncanonical-measurement-units.ts` — run the diagnose first.
+ *
+ * SAFETY:
+ * - Dry-run by default. Writes ONLY when called with `--execute`.
+ * - Touches ONLY rows whose stored `unit` differs from the type's canonical
+ * unit AND resolves through the shared alias resolver (lb/lbs/pound(s),
+ * in/inch(es), °F, mmol/L). A row whose unit is already canonical, or whose
+ * unit is unrecognised, is never touched.
+ * - Each row is rewritten to the canonical `{ value, unit }` the same
+ * resolver the CSV importer + MCP write path use produces, so the number is
+ * re-expressed, never re-labelled in place (which would corrupt it).
+ * - Affected users' measurement caches are busted at the end.
+ *
+ * This repairs the storage LABEL + value for the recognised-alias legacy rows
+ * (the MCP-leak class). It cannot repair a row whose unit was already canonical
+ * but whose value was a foreign unit — that class is not mechanically
+ * identifiable (see the diagnose script).
+ *
+ * Run (inside the app container):
+ * pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts # dry-run
+ * pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute # write
+ */
+import "dotenv/config";
+
+import { prisma } from "@/lib/db";
+import { getUnitForType } from "@/lib/validations/measurement";
+import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
+import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
+
+async function main(): Promise {
+ const execute = process.argv.includes("--execute");
+
+ const groups = await prisma.measurement.groupBy({
+ by: ["type", "unit"],
+ _count: { _all: true },
+ });
+ const convertibleGroups = groups.filter((g) => {
+ if (g.unit === null || g.unit === getUnitForType(g.type)) return false;
+ return resolveToCanonicalUnit(g.type, 1, g.unit) !== null;
+ });
+
+ if (convertibleGroups.length === 0) {
+ console.log(
+ "Nothing to convert — no recognised non-canonical units found.",
+ );
+ return;
+ }
+
+ const affectedUsers = new Set();
+ let converted = 0;
+
+ for (const g of convertibleGroups) {
+ const canonical = getUnitForType(g.type);
+ const rows = await prisma.measurement.findMany({
+ where: { type: g.type, unit: g.unit },
+ select: { id: true, userId: true, value: true, unit: true },
+ });
+ for (const row of rows) {
+ const resolved = resolveToCanonicalUnit(
+ g.type,
+ row.value,
+ row.unit as string,
+ );
+ if (!resolved) continue; // defensive; group already filtered
+ converted += 1;
+ affectedUsers.add(row.userId);
+ console.log(
+ ` ${execute ? "convert" : "would convert"} ${g.type} ${row.id}: ` +
+ `${row.value} ${row.unit} -> ${resolved.value} ${resolved.unit}`,
+ );
+ if (execute) {
+ await prisma.measurement.update({
+ where: { id: row.id },
+ data: { value: resolved.value, unit: canonical },
+ });
+ }
+ }
+ }
+
+ if (execute) {
+ for (const userId of affectedUsers) {
+ invalidateUserMeasurements(userId, { evict: true });
+ }
+ console.log(
+ `\nConverted ${converted} row(s) across ${affectedUsers.size} user(s). ` +
+ "Rollups recompute on the next read/backfill.",
+ );
+ } else {
+ console.log(
+ `\nDry run: ${converted} row(s) across ${affectedUsers.size} user(s) ` +
+ "would be converted. Re-run with --execute to write.",
+ );
+ }
+}
+
+main()
+ .catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+ })
+ .finally(() => prisma.$disconnect());
diff --git a/scripts/diagnose-noncanonical-measurement-units.ts b/scripts/diagnose-noncanonical-measurement-units.ts
new file mode 100644
index 000000000..9c0667bda
--- /dev/null
+++ b/scripts/diagnose-noncanonical-measurement-units.ts
@@ -0,0 +1,76 @@
+/**
+ * Read-only diagnostic for measurement rows stored in a NON-canonical unit.
+ *
+ * Canonical storage is SI: every write path stamps `getUnitForType(type)`. The
+ * one historical leak was the MCP `log_measurement` tool, which persisted a
+ * caller-supplied `unit` verbatim (closed in v1.32.26). A row whose `unit`
+ * column differs from its type's canonical unit is such a legacy row, and it
+ * mislabels every display surface that trusts the column.
+ *
+ * This groups measurements by `(type, unit)` and prints every group whose
+ * `unit` is not the canonical unit for its type, flagging whether the stored
+ * unit is a RECOGNISED alias (mechanically convertible by the companion
+ * convert script) or UNKNOWN (needs a manual look). Nothing is written.
+ *
+ * NOT auto-fixable and deliberately out of scope: a row whose stored unit IS
+ * already canonical but whose VALUE was a foreign unit (e.g. a lb reading typed
+ * into the old kg-labelled entry form). Those are indistinguishable from real
+ * canonical readings; the reporter edits them via the record list.
+ *
+ * Run (inside the app container, which has DATABASE_URL):
+ * pnpm dlx tsx scripts/diagnose-noncanonical-measurement-units.ts
+ */
+import "dotenv/config";
+
+import { prisma } from "@/lib/db";
+import { getUnitForType } from "@/lib/validations/measurement";
+import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
+
+async function main(): Promise {
+ const groups = await prisma.measurement.groupBy({
+ by: ["type", "unit"],
+ _count: { _all: true },
+ });
+
+ const mismatches = groups.filter((g) => {
+ if (g.unit === null) return false;
+ return g.unit !== getUnitForType(g.type);
+ });
+
+ if (mismatches.length === 0) {
+ console.log("No non-canonical measurement units found. Storage is clean.");
+ return;
+ }
+
+ let convertible = 0;
+ let unknown = 0;
+ console.log(
+ `Found ${mismatches.length} non-canonical (type, unit) group(s):\n`,
+ );
+ for (const g of mismatches.sort((a, b) => b._count._all - a._count._all)) {
+ const canonical = getUnitForType(g.type);
+ // Probe convertibility with a neutral value — only the recognition matters.
+ const recognised = resolveToCanonicalUnit(g.type, 1, g.unit as string);
+ const status = recognised ? "CONVERTIBLE" : "UNKNOWN — manual review";
+ if (recognised) convertible += g._count._all;
+ else unknown += g._count._all;
+ console.log(
+ ` ${g.type}: stored="${g.unit}" canonical="${canonical}" ` +
+ `rows=${g._count._all} [${status}]`,
+ );
+ }
+ console.log(
+ `\nTotals: ${convertible} row(s) convertible, ${unknown} row(s) unknown.`,
+ );
+ console.log(
+ "Convert the recognised aliases with:\n" +
+ " pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute",
+ );
+}
+
+main()
+ .catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+ })
+ .finally(() => prisma.$disconnect());
diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts
new file mode 100644
index 000000000..ff91da3c0
--- /dev/null
+++ b/src/__tests__/unit-preference-display-guard.test.ts
@@ -0,0 +1,87 @@
+/**
+ * Structural guard (a tripwire, not a proof) for the metric/imperial unit
+ * fix (issue #627). The dangerous failure mode is a PARTIAL fix: entry converts
+ * but a display surface still hardcodes the canonical unit, so an imperial user
+ * sees converted-then-mislabelled soup. This walks the dashboard + insight page
+ * sources and asserts none of them passes a transformed type's DISPLAY UNIT
+ * (kg / lb / cm / in / °C / °F / km/h / mph / km / mi) or a hardcoded value
+ * scale as a literal prop — those must resolve from the user's preference
+ * through `useUnitDisplay()` instead.
+ *
+ * Mutation check: add `unit="kg"` back to any swept page and this goes red.
+ */
+import { readFileSync } from "node:fs";
+import { globSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, it, expect } from "vitest";
+
+import {
+ getDisplayTransform,
+ TRANSFORMED_TYPES,
+} from "@/lib/measurements/display-transform";
+
+const SRC = join(process.cwd(), "src");
+
+/** The set of display-unit strings a transformed type can render. */
+const TRANSFORMED_UNITS: ReadonlySet = new Set(
+ [...TRANSFORMED_TYPES].flatMap((type) => [
+ getDisplayTransform(type, "metric").displayUnit,
+ getDisplayTransform(type, "imperial").displayUnit,
+ ]),
+);
+
+/** Swept surfaces: the dashboard client + every insights sub-page. */
+function sweptFiles(): string[] {
+ return [
+ join(SRC, "app", "page-client.tsx"),
+ ...globSync("app/insights/**/page.tsx", { cwd: SRC }).map((rel) =>
+ join(SRC, rel),
+ ),
+ ];
+}
+
+describe("unit-preference display guard", () => {
+ it("sweeps a non-empty set of surfaces", () => {
+ // Guards against a glob that silently matches nothing (a green void).
+ expect(sweptFiles().length).toBeGreaterThan(10);
+ });
+
+ it("no swept surface hardcodes a transformed type's display unit", () => {
+ const offenders: string[] = [];
+ for (const file of sweptFiles()) {
+ const src = readFileSync(file, "utf8");
+ for (const unit of TRANSFORMED_UNITS) {
+ // Match `unit="kg"` / `yAxisUnit="°C"` exactly (closing quote pinned so
+ // `unit="kg/m²"` — the excluded BMI unit — never trips it).
+ for (const prop of ["unit", "yAxisUnit"]) {
+ if (src.includes(`${prop}="${unit}"`)) {
+ offenders.push(`${file}: ${prop}="${unit}"`);
+ }
+ }
+ }
+ }
+ expect(offenders).toEqual([]);
+ });
+
+ it("no insights page hardcodes a numeric valueScale literal", () => {
+ // Transformed metrics resolve their scale from the transform; a literal
+ // `valueScale={3.6}` is the pre-fix hand-rolled scaling that double-scales.
+ const literal = /valueScale=\{\s*-?\d/;
+ const offenders = sweptFiles().filter((file) =>
+ literal.test(readFileSync(file, "utf8")),
+ );
+ 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");
+ const imperial = getDisplayTransform(type, "imperial");
+ expect(metric.displayUnit).toBeTruthy();
+ expect(imperial.displayUnit).toBeTruthy();
+ expect(Number.isFinite(metric.factor)).toBe(true);
+ expect(Number.isFinite(imperial.factor)).toBe(true);
+ }
+ });
+});
diff --git a/src/app/insights/body-temperature/page.tsx b/src/app/insights/body-temperature/page.tsx
index db572f899..7c4527b5f 100644
--- a/src/app/insights/body-temperature/page.tsx
+++ b/src/app/insights/body-temperature/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsBodyTemperaturePage() {
i18nPrefix="insights.bodyTemperature"
explainerMetric="bodyTemperature"
color="var(--chart-3)"
- unit="°C"
- yAxisUnit="°C"
emptyStateIcon={}
emptyStateCtaType="BODY_TEMPERATURE"
coachPrefill="I haven't logged any body temperature yet — what's the healthy range, and what should I do if it drifts?"
diff --git a/src/app/insights/body-water/page.tsx b/src/app/insights/body-water/page.tsx
index d9563147f..006cb963b 100644
--- a/src/app/insights/body-water/page.tsx
+++ b/src/app/insights/body-water/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsKoerperwasserPage() {
i18nPrefix="insights.totalBodyWater"
explainerMetric="bodyWater"
color="var(--info)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
captureType="TOTAL_BODY_WATER"
diff --git a/src/app/insights/bone-mass/page.tsx b/src/app/insights/bone-mass/page.tsx
index f5759fafb..d5235de3d 100644
--- a/src/app/insights/bone-mass/page.tsx
+++ b/src/app/insights/bone-mass/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsKnochenmassePage() {
i18nPrefix="insights.boneMass"
explainerMetric="boneMass"
color="var(--warning)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
captureType="BONE_MASS"
diff --git a/src/app/insights/fat-free-mass/page.tsx b/src/app/insights/fat-free-mass/page.tsx
index 54875212f..6daf8006e 100644
--- a/src/app/insights/fat-free-mass/page.tsx
+++ b/src/app/insights/fat-free-mass/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsFettfreieMassePage() {
i18nPrefix="insights.fatFreeMass"
explainerMetric="fatFreeMass"
color="var(--chart-1)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat-free mass yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/fat-mass/page.tsx b/src/app/insights/fat-mass/page.tsx
index fa27ad08e..1ae453b10 100644
--- a/src/app/insights/fat-mass/page.tsx
+++ b/src/app/insights/fat-mass/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsFettmassePage() {
i18nPrefix="insights.fatMass"
explainerMetric="fatMass"
color="var(--warning)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any fat mass yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/grip-strength/page.tsx b/src/app/insights/grip-strength/page.tsx
index ae84730cb..52750739b 100644
--- a/src/app/insights/grip-strength/page.tsx
+++ b/src/app/insights/grip-strength/page.tsx
@@ -23,8 +23,6 @@ export default function InsightsGripStrengthPage() {
chartKey="gripStrength"
i18nPrefix="insights.gripStrength"
color="var(--success)"
- unit="kg"
- yAxisUnit="kg"
statIcon={Dumbbell}
emptyStateIcon={}
emptyStateCtaType="GRIP_STRENGTH"
diff --git a/src/app/insights/lean-body-mass/page.tsx b/src/app/insights/lean-body-mass/page.tsx
index e055eeb9c..e31113b3b 100644
--- a/src/app/insights/lean-body-mass/page.tsx
+++ b/src/app/insights/lean-body-mass/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsMagermassePage() {
i18nPrefix="insights.leanBodyMass"
explainerMetric="leanBodyMass"
color="var(--chart-1)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any lean body mass yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/muscle-mass/page.tsx b/src/app/insights/muscle-mass/page.tsx
index bf6fb6500..a4dc3c786 100644
--- a/src/app/insights/muscle-mass/page.tsx
+++ b/src/app/insights/muscle-mass/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsMuskelmassePage() {
i18nPrefix="insights.muscleMass"
explainerMetric="muscleMass"
color="var(--chart-1)"
- unit="kg"
- yAxisUnit="kg"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any muscle mass yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/skin-temperature/page.tsx b/src/app/insights/skin-temperature/page.tsx
index e04a3bcad..065dc2bb8 100644
--- a/src/app/insights/skin-temperature/page.tsx
+++ b/src/app/insights/skin-temperature/page.tsx
@@ -30,8 +30,6 @@ export default function InsightsHauttemperaturPage() {
i18nPrefix="insights.skinTemperature"
explainerMetric="skinTemperature"
color="var(--warning)"
- unit="°C"
- yAxisUnit="°C"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?"
@@ -41,7 +39,6 @@ export default function InsightsHauttemperaturPage() {
title={t("measurements.typeBodyTemperatureDeviation")}
icon={ThermometerSnowflake}
color="var(--info)"
- unit="°C"
fractionDigits={2}
sectionTitle={t("insights.bodyTempDeviation.title")}
sectionIcon={ThermometerSnowflake}
diff --git a/src/app/insights/waist/page.tsx b/src/app/insights/waist/page.tsx
index 0d4106339..6c0d74bda 100644
--- a/src/app/insights/waist/page.tsx
+++ b/src/app/insights/waist/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsWaistPage() {
chartKey="waistCircumference"
i18nPrefix="insights.waist"
color="var(--info)"
- unit="cm"
- yAxisUnit="cm"
statIcon={Ruler}
emptyStateIcon={}
emptyStateCtaType="WAIST_CIRCUMFERENCE"
diff --git a/src/app/insights/walking-distance/page.tsx b/src/app/insights/walking-distance/page.tsx
index 0872c7890..26df9821e 100644
--- a/src/app/insights/walking-distance/page.tsx
+++ b/src/app/insights/walking-distance/page.tsx
@@ -22,10 +22,6 @@ export default function InsightsGehstreckePage() {
i18nPrefix="insights.walkingRunningDistance"
explainerMetric="walkingDistance"
color="var(--success)"
- unit="km"
- yAxisUnit="km"
- // Stored in metres; display daily totals in km (1 m = 0.001 km).
- valueScale={0.001}
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking and running distance yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/walking-speed/page.tsx b/src/app/insights/walking-speed/page.tsx
index 8223c557e..bdb43a22f 100644
--- a/src/app/insights/walking-speed/page.tsx
+++ b/src/app/insights/walking-speed/page.tsx
@@ -22,9 +22,6 @@ export default function InsightsGehgeschwindigkeitPage() {
i18nPrefix="insights.walkingSpeed"
explainerMetric="walkingSpeed"
color="var(--success)"
- unit="km/h"
- yAxisUnit="km/h"
- valueScale={3.6}
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any walking speed yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx
index cfa335005..4bcae9c10 100644
--- a/src/app/insights/weight/page.tsx
+++ b/src/app/insights/weight/page.tsx
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Scale } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
@@ -36,10 +37,38 @@ export default function InsightsGewichtPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
+ const unitDisplay = useUnitDisplay();
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
+ // v1.32.26 — resolve the weight display unit + scale from the user's
+ // preference (kg for metric, lb for imperial). This page inlines the shell
+ // rather than riding the scaffold, so it converts the stat strip + chart
+ // series + bands here. Weight has no affine offset, so the factor alone
+ // covers both the values and the bands.
+ const weightTransform = unitDisplay.transformFor("WEIGHT");
+ const weightUnit = weightTransform.displayUnit;
+ const weightScale = weightTransform.factor;
+ const displaySummary =
+ weightSummary && weightScale !== 1
+ ? {
+ ...weightSummary,
+ min:
+ weightSummary.min === null ? null : weightSummary.min * weightScale,
+ max:
+ weightSummary.max === null ? null : weightSummary.max * weightScale,
+ mean:
+ weightSummary.mean === null
+ ? null
+ : weightSummary.mean * weightScale,
+ median:
+ weightSummary.median === null
+ ? null
+ : weightSummary.median * weightScale,
+ }
+ : weightSummary;
+
// v1.12.8 — visible-range stats shared between the chart and the strip.
const { statsByType, onVisibleStats } = useChartDomainStats();
@@ -73,6 +102,16 @@ export default function InsightsGewichtPage() {
upperBound: 250,
})
: undefined;
+ // The bands are kg bounds → scale into the display unit so the shaded zone
+ // tracks the converted line (factor-only; weight carries no offset).
+ const displayBands =
+ weightBands && weightScale !== 1
+ ? weightBands.map((band) => ({
+ ...band,
+ min: band.min * weightScale,
+ max: band.max * weightScale,
+ }))
+ : weightBands;
return (
}
- coachReadStrip={}
+ coachReadStrip={}
diversityNudge={
diff --git a/src/app/insights/wrist-temperature/page.tsx b/src/app/insights/wrist-temperature/page.tsx
index eedb62695..9acb07bd5 100644
--- a/src/app/insights/wrist-temperature/page.tsx
+++ b/src/app/insights/wrist-temperature/page.tsx
@@ -22,8 +22,6 @@ export default function InsightsWristTemperaturePage() {
i18nPrefix="insights.wristTemperature"
explainerMetric="wristTemperature"
color="var(--warning)"
- unit="°C"
- yAxisUnit="°C"
emptyStateIcon={}
emptyStateCtaType={null}
coachPrefill="I haven't logged any wrist-temperature data yet — what does this metric tell me about my health, and how do I improve it?"
diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx
index 0b8e4af10..f4c67f5b3 100644
--- a/src/app/page-client.tsx
+++ b/src/app/page-client.tsx
@@ -3,6 +3,7 @@
import React, { Suspense, useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
@@ -225,6 +226,18 @@ export default function DashboardPageClient({
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
+ // v1.32.26 — metric/imperial display choke point for the dashboard tiles +
+ // weight chart. `td` converts an ABSOLUTE reading (affine, so a temperature
+ // picks up the °F offset); `tdd` converts a DELTA / slope (factor only, so a
+ // Δ never inherits the offset). Metric users take the identity path.
+ const unitDisplay = useUnitDisplay();
+ const td = (type: string, value: number | null | undefined): number | null =>
+ value == null ? null : unitDisplay.toDisplay(type, value);
+ const tdd = (
+ type: string,
+ value: number | null | undefined,
+ ): number | null =>
+ value == null ? null : unitDisplay.toDisplayDelta(type, value);
const [quickEntryDialog, setQuickEntryDialog] =
useState(null);
@@ -713,7 +726,29 @@ export default function DashboardPageClient({
const bands = serverBands ?? clientBands;
const bpTargets = bands.bpTargets;
const weightRange = bands.weightRange;
+ // The range hint tooltip prints its band bounds in the display unit, so scale
+ // the kg thresholds by the weight factor (offset-free). The colour-classing
+ // below stays in canonical kg — it compares a canonical avg against the
+ // canonical range, so both sides remain in the same unit.
+ const weightHintRange = weightRange
+ ? {
+ greenMin: unitDisplay.toDisplay("WEIGHT", weightRange.greenMin),
+ greenMax: unitDisplay.toDisplay("WEIGHT", weightRange.greenMax),
+ orangeMin: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMin),
+ orangeMax: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMax),
+ }
+ : weightRange;
const weightBands = bands.weightBands ?? undefined;
+ // Scale the kg chart bands into the display unit so the shaded zone tracks
+ // the converted line (factor-only; weight has no offset). Metric = identity.
+ const weightDisplayBands =
+ weightBands && unitDisplay.preference === "imperial"
+ ? weightBands.map((band) => ({
+ ...band,
+ min: unitDisplay.toDisplay("WEIGHT", band.min),
+ max: unitDisplay.toDisplay("WEIGHT", band.max),
+ }))
+ : weightBands;
const bpTargetZones = bpTargets
? [
{
@@ -897,10 +932,10 @@ export default function DashboardPageClient({
),
@@ -1276,16 +1311,22 @@ export default function DashboardPageClient({
),
@@ -1302,16 +1343,22 @@ export default function DashboardPageClient({
),
@@ -1325,16 +1372,22 @@ export default function DashboardPageClient({
),
@@ -1348,16 +1401,22 @@ export default function DashboardPageClient({
),
@@ -1554,10 +1613,11 @@ export default function DashboardPageClient({
types={["WEIGHT"]}
title={t("dashboard.weight")}
colors={["var(--chart-1)"]}
- unit="kg"
- valueBands={weightBands}
+ unit={unitDisplay.unitFor("WEIGHT")}
+ valueBands={weightDisplayBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
+ valueScale={unitDisplay.transformFor("WEIGHT").factor}
/>
),
});
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index 66e0a22b7..f7d590153 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -220,6 +220,15 @@ interface HealthChartProps {
* uniformly.
*/
valueScale?: number;
+ /**
+ * v1.32.26 — display-time additive offset folded in alongside
+ * `valueScale` at the same read boundary: `display = raw * valueScale +
+ * valueOffset`. Required for the affine °C→°F conversion (a shift, not
+ * just a scale). Defaults to `0`. Because every tooltip delta / trend
+ * slope is computed as a DIFFERENCE of two offset values, the offset
+ * cancels there automatically — a rate never inherits the +32 shift.
+ */
+ valueOffset?: number;
/**
* v1.12.8 — chart-reactive metric statistics. When supplied, the chart
* reports the per-type Min / Max / Median / Mean of the data currently
@@ -586,6 +595,7 @@ export function HealthChart({
verticalMarkers,
userTimezone = "Europe/Berlin",
valueScale = 1,
+ valueOffset = 0,
onVisibleStats,
titleIcon,
onDataReady,
@@ -832,7 +842,7 @@ export function HealthChart({
}>(`/api/measurements/series?${sleepParams}`);
const points = data?.points ?? [];
for (const point of points) {
- const value = point.value * valueScale;
+ const value = point.value * valueScale + valueOffset;
if (value == null || !Number.isFinite(value)) continue;
const dayKey = toDayKey(point.at, dayKeyFormatter);
const bucket = dailyAggregates.get(dayKey) ?? {
@@ -927,7 +937,7 @@ export function HealthChart({
// v1.15.20 — the BMI division moved into the query's `select`
// callback so the cached series stays raw and the WEIGHT and
// BMI views share one cache entry.
- const value = measurement.value * valueScale;
+ const value = measurement.value * valueScale + valueOffset;
if (!Number.isFinite(value)) {
continue;
@@ -957,8 +967,8 @@ export function HealthChart({
typeof measurement.minValue === "number" &&
typeof measurement.maxValue === "number"
) {
- const scaledMin = measurement.minValue * valueScale;
- const scaledMax = measurement.maxValue * valueScale;
+ const scaledMin = measurement.minValue * valueScale + valueOffset;
+ const scaledMax = measurement.maxValue * valueScale + valueOffset;
current.min =
current.min === null
? scaledMin
diff --git a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx
index f7a72f259..385df6d05 100644
--- a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx
+++ b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx
@@ -1,6 +1,29 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
+// The embedded crosstab reads the unit preference through `useUnitDisplay`,
+// which hangs off `useAuth` (a react-query hook). These are static SSR renders
+// with no QueryClient, so mock the hook to a real metric-preference impl.
+vi.mock("@/hooks/use-unit-display", async () => {
+ const dt = await import("@/lib/measurements/display-transform");
+ const forType = (t: string) => dt.getDisplayTransform(t, "metric");
+ return {
+ useUnitDisplay: () => ({
+ preference: "metric" as const,
+ transformFor: forType,
+ toDisplay: (t: string, v: number) =>
+ dt.applyDisplayTransform(v, forType(t)),
+ fromDisplay: (t: string, v: number) =>
+ dt.invertDisplayTransform(v, forType(t)),
+ toDisplayDelta: (t: string, v: number) =>
+ dt.applyDisplayTransformDelta(v, forType(t)),
+ unitFor: (t: string) => forType(t).displayUnit,
+ decimalsFor: (t: string) => forType(t).decimals,
+ isTransformed: (t: string) => dt.hasDisplayTransform(t),
+ }),
+ };
+});
+
import { I18nProvider } from "@/lib/i18n/context";
import type { CalendarResponse } from "../types";
import type { CycleInsightsResponse } from "../use-cycle";
diff --git a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx
index 1691a7f67..82aec2b28 100644
--- a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx
+++ b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx
@@ -1,6 +1,31 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
+// The crosstab reads the unit preference through `useUnitDisplay`, which hangs
+// off `useAuth` (a react-query hook). These are static SSR renders with no
+// QueryClient, so mock the hook to a real metric-preference implementation —
+// metric is the identity on values and yields the canonical display symbols,
+// so the assertions below (kg, °C, unchanged numbers) hold.
+vi.mock("@/hooks/use-unit-display", async () => {
+ const dt = await import("@/lib/measurements/display-transform");
+ const forType = (t: string) => dt.getDisplayTransform(t, "metric");
+ return {
+ useUnitDisplay: () => ({
+ preference: "metric" as const,
+ transformFor: forType,
+ toDisplay: (t: string, v: number) =>
+ dt.applyDisplayTransform(v, forType(t)),
+ fromDisplay: (t: string, v: number) =>
+ dt.invertDisplayTransform(v, forType(t)),
+ toDisplayDelta: (t: string, v: number) =>
+ dt.applyDisplayTransformDelta(v, forType(t)),
+ unitFor: (t: string) => forType(t).displayUnit,
+ decimalsFor: (t: string) => forType(t).decimals,
+ isTransformed: (t: string) => dt.hasDisplayTransform(t),
+ }),
+ };
+});
+
import {
CyclePhaseCrosstab,
CyclePhaseHeadline,
diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx
index 8e0f4d693..8303c18d3 100644
--- a/src/components/cycle/cycle-phase-crosstab.tsx
+++ b/src/components/cycle/cycle-phase-crosstab.tsx
@@ -1,6 +1,7 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import { cn } from "@/lib/utils";
/**
@@ -77,6 +78,18 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
}
+/**
+ * v1.32.26 — the two displays whose values follow the metric/imperial
+ * preference, mapped to the measurement type that carries the transform. Weight
+ * is offset-free (kg→lb); temperature is affine (°C→°F), so an ABSOLUTE phase
+ * average takes `toDisplay` while a phase DELTA takes `toDisplayDelta` (the
+ * offset must never leak into a difference).
+ */
+const DISPLAY_TO_TYPE: Partial> = {
+ kg: "WEIGHT",
+ celsius: "BODY_TEMPERATURE",
+};
+
const HEADLINE_KEY: Record = {
restingHeartRate: "cycle.insights.headline.restingHeartRate",
heartRateVariability: "cycle.insights.headline.heartRateVariability",
@@ -93,6 +106,7 @@ export function CyclePhaseHeadline({
headline: CyclePhaseCrosstabRow | null;
}) {
const { t } = useTranslations();
+ const unitDisplay = useUnitDisplay();
if (!headline) {
return (
= 0;
const dir = t(
up
@@ -118,7 +133,13 @@ export function CyclePhaseHeadline({
);
const params = {
metric: metricLabel,
- delta: fmt(Math.abs(headline.delta), headline.display),
+ // A phase delta → factor-only conversion (no affine offset).
+ delta: fmt(
+ Math.abs(
+ mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta,
+ ),
+ headline.display,
+ ),
unit,
dir,
};
@@ -141,6 +162,7 @@ export function CyclePhaseCrosstab({
rows: CyclePhaseCrosstabRow[];
}) {
const { t } = useTranslations();
+ const unitDisplay = useUnitDisplay();
if (rows.length === 0) return null;
return (
@@ -155,15 +177,20 @@ export function CyclePhaseCrosstab({
);
// Defensive: an unmapped display must never pass undefined to `t`
// (whose resolver does `key.split(".")`). Empty unit on a miss.
+ const mt = DISPLAY_TO_TYPE[row.display];
const unitKey = UNIT_KEY[row.display];
- const unit = unitKey ? t(unitKey) : "";
+ const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
+ const absConv = (v: number) =>
+ mt ? unitDisplay.toDisplay(mt, v) : v;
+ const deltaConv = (v: number) =>
+ mt ? unitDisplay.toDisplayDelta(mt, v) : v;
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase. The number stays NEUTRAL — higher is
// good for steps but bad for resting HR, and this board is
// observational, so colouring it would imply a verdict the data
// doesn't support. The sign prefix + `data-direction` carry it.
const up = row.delta >= 0;
- const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
+ const deltaText = `${up ? "+" : ""}${fmt(deltaConv(row.delta), row.display)} ${unit}`;
return (
{t("cycle.insights.crosstab.detail", {
metric: metricLabel,
- lutealAvg: fmt(row.lutealAvg, row.display),
- follicularAvg: fmt(row.follicularAvg, row.display),
+ lutealAvg: fmt(absConv(row.lutealAvg), row.display),
+ follicularAvg: fmt(absConv(row.follicularAvg), row.display),
unit,
lutealDays: row.lutealDays,
follicularDays: row.follicularDays,
diff --git a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx
index 479f26fb8..179104963 100644
--- a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx
+++ b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx
@@ -1,6 +1,31 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
+// The vitals tiles read the unit preference through `useUnitDisplay`, which
+// hangs off `useAuth` (a react-query hook). These are static SSR renders with
+// no QueryClient, so mock the hook to a real metric-preference implementation
+// — metric is the identity on values and yields the canonical display symbols
+// (e.g. °C), matching the pre-fix render the assertions expect.
+vi.mock("@/hooks/use-unit-display", async () => {
+ const dt = await import("@/lib/measurements/display-transform");
+ const forType = (t: string) => dt.getDisplayTransform(t, "metric");
+ return {
+ useUnitDisplay: () => ({
+ preference: "metric" as const,
+ transformFor: forType,
+ toDisplay: (t: string, v: number) =>
+ dt.applyDisplayTransform(v, forType(t)),
+ fromDisplay: (t: string, v: number) =>
+ dt.invertDisplayTransform(v, forType(t)),
+ toDisplayDelta: (t: string, v: number) =>
+ dt.applyDisplayTransformDelta(v, forType(t)),
+ unitFor: (t: string) => forType(t).displayUnit,
+ decimalsFor: (t: string) => forType(t).decimals,
+ isTransformed: (t: string) => dt.hasDisplayTransform(t),
+ }),
+ };
+});
+
import { I18nProvider } from "@/lib/i18n/context";
import type {
DerivedMetricResponse,
diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx
index 94a847fee..10c5bf57e 100644
--- a/src/components/insights/derived/coach-read-strip.tsx
+++ b/src/components/insights/derived/coach-read-strip.tsx
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -60,6 +61,15 @@ export function CoachReadStrip({
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const mounted = useMounted();
+ // v1.32.26 — a type with a registered metric/imperial transform resolves its
+ // display unit + conversion from the user's preference and IGNORES the
+ // page-passed `unit` / `valueScale` (weight labelled "kg" would otherwise
+ // never follow the toggle). The band edges + today's value are ABSOLUTE, so
+ // the affine `toDisplay` (factor + offset) is correct here. Untransformed
+ // metrics keep the props (glucose passes its own unit + reciprocal scale).
+ const unitDisplay = useUnitDisplay();
+ const transformed = unitDisplay.isTransformed(metricType);
+ const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit;
const { data } = useQuery({
queryKey: queryKeys.insightsCoachRead(metricType),
@@ -80,7 +90,11 @@ export function CoachReadStrip({
new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
- }).format(value * valueScale);
+ }).format(
+ transformed
+ ? unitDisplay.toDisplay(metricType, value)
+ : value * valueScale,
+ );
const baselineLine = ((): string | null => {
if (data.learning || !data.baseline) {
@@ -97,7 +111,7 @@ export function CoachReadStrip({
low: fmt(low),
high: fmt(high),
value: fmt(latest),
- unit,
+ unit: resolvedUnit,
});
})();
diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx
index c671b186b..a643b3c67 100644
--- a/src/components/insights/derived/vitals-dashboard.tsx
+++ b/src/components/insights/derived/vitals-dashboard.tsx
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { getUnitForType } from "@/lib/validations/measurement";
+import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
MEASUREMENT_TYPE_ICONS,
MEASUREMENT_TYPE_LABEL_KEYS,
@@ -70,23 +71,6 @@ const UP_BAD_VITALS = new Set([
/** Up-is-good for the vitals where a rise is favourable. */
const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]);
-/**
- * Storage-unit → display-symbol overrides. `getUnitForType` returns the
- * canonical *storage* string (e.g. the literal "celsius"); a tile must show
- * the symbol a user reads ("°C"). Only the units whose storage string is not
- * already a display symbol need an entry — kg / bpm / ms / % / mg/dL render
- * fine as-is.
- */
-const DISPLAY_UNIT_SYMBOL: Record = {
- celsius: "°C",
- fahrenheit: "°F",
-};
-
-function displayUnit(type: string): string {
- const storage = getUnitForType(type);
- return DISPLAY_UNIT_SYMBOL[storage] ?? storage;
-}
-
function vitalSentiment(type: string): TrendDirectionSentiment {
if (UP_BAD_VITALS.has(type)) return "up-bad";
if (UP_GOOD_VITALS.has(type)) return "up-good";
@@ -229,6 +213,7 @@ function BaselineTile({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
+ const unitDisplay = useUnitDisplay();
// `VITALS_BASELINE` is the type-generic engine and needs the type on the
// token; the dedicated bands pin their own single input, so no `type`.
const data = read(
@@ -247,7 +232,12 @@ function BaselineTile({
const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Gauge;
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type];
const label = labelKey ? t(labelKey) : type;
- const unit = displayUnit(type);
+ // v1.32.26 — resolve the display unit from the user's preference. For an
+ // absolute temperature this converts celsius→°F (affine); the reporter's
+ // proven canonical-label bug (a value labelled by the storage unit rather
+ // than the preferred one) is fixed here, retiring the local celsius shim.
+ const unit = unitDisplay.unitFor(type);
+ const toShown = (raw: number) => unitDisplay.toDisplay(type, raw);
// `VITALS_BASELINE` tiles tag the DOM by their vital type (the stable
// contract the existing surfaces + tests key on); the dedicated bands tag
// by their own metric id.
@@ -283,19 +273,23 @@ function BaselineTile({
}
const v = data.value!;
+ // Range bounds + centre + sparkline are ABSOLUTE readings → affine-convert.
const framing = t("insights.derived.vitals.typicalRange", {
- low: fmt.number(v.low, 1),
- high: fmt.number(v.high, 1),
+ low: fmt.number(toShown(v.low), 1),
+ high: fmt.number(toShown(v.high), 1),
});
+ const displaySeries = unitDisplay.isTransformed(type)
+ ? v.series.map(toShown)
+ : v.series;
return (