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 (
    - value.toLocaleString(resolveIntlLocale(locale), { - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - }); + (transform ? unitDisplay.toDisplay(type, value) : value).toLocaleString( + resolveIntlLocale(locale), + { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }, + ); return ( {fmt(latest)} - {unit ? ( + {resolvedUnit ? ( - {unit} + {resolvedUnit} ) : null} @@ -136,8 +150,10 @@ export function DeviceScoreTile({ types={[type]} title={title} colors={[color]} - unit={unit ?? ""} + unit={resolvedUnit ?? ""} mini + valueScale={transform ? transform.factor : undefined} + valueOffset={transform ? (transform.offset ?? 0) : undefined} userTimezone={user?.timezone} />
    @@ -147,7 +163,7 @@ export function DeviceScoreTile({ className="text-muted-foreground text-xs" > {t("insights.deviceScore.average", { value: fmt(mean) })} - {unit ? ` ${unit}` : ""} + {resolvedUnit ? ` ${resolvedUnit}` : ""}

    ) : null} diff --git a/src/components/insights/glp1-plateau-note.tsx b/src/components/insights/glp1-plateau-note.tsx index dd8d9f01e..28bc1ee29 100644 --- a/src/components/insights/glp1-plateau-note.tsx +++ b/src/components/insights/glp1-plateau-note.tsx @@ -27,6 +27,7 @@ import { TileHeader } from "@/components/insights/tile-header"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import type { Glp1PlateauContext } from "@/lib/insights/glp1-plateau"; interface Glp1PlateauResponse { @@ -36,6 +37,7 @@ interface Glp1PlateauResponse { export function Glp1PlateauNote() { const { t } = useTranslations(); + const unitDisplay = useUnitDisplay(); const { data } = useQuery({ queryKey: queryKeys.insightsGlp1Plateau(), queryFn: () => apiGet("/api/insights/glp1-plateau"), @@ -45,10 +47,13 @@ export function Glp1PlateauNote() { if (!data?.plateau) return null; const plateau = data.plateau; - const delta = - plateau.weightDeltaKg > 0 - ? `+${plateau.weightDeltaKg}` - : `${plateau.weightDeltaKg}`; + // The weight change is a DELTA → factor-only conversion (no affine offset). + const deltaValue = + Math.round( + unitDisplay.toDisplayDelta("WEIGHT", plateau.weightDeltaKg) * 10, + ) / 10; + const delta = deltaValue > 0 ? `+${deltaValue}` : `${deltaValue}`; + const unit = unitDisplay.unitFor("WEIGHT"); const dose = `${plateau.drug} ${plateau.doseValue} ${plateau.doseUnit}`; return ( @@ -64,6 +69,7 @@ export function Glp1PlateauNote() {

    {t("medications.efficacy.plateau.evidence", { delta, + unit, window: data.windowDays, readings: plateau.readingsCount, days: plateau.daysOnDose, diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index d033aaed5..e551109f1 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -6,6 +6,7 @@ import type { ComponentProps, ComponentType, ReactNode } from "react"; import { RefreshCw, Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { useChartDomainStats } from "@/hooks/use-chart-domain-stats"; @@ -88,8 +89,14 @@ export interface HealthKitMetricPageProps { * token retune (see UI standards, colour token rules). */ color: `var(--${string})`; - /** Unit suffix the chart renders next to the value. */ - unit: string; + /** + * Unit suffix the chart renders next to the value. Optional: for a type + * with a registered metric/imperial transform (weight-class, temperature, + * waist) the scaffold resolves the unit + scale from the user's preference + * and IGNORES any page-passed `unit` / `yAxisUnit` / `valueScale`. Pages for + * transformed types omit these props; everything else still passes `unit`. + */ + unit?: string; /** Optional y-axis label shown above the unit. */ yAxisUnit?: string; /** Optional value bands (Apple-Health-style target zone shading). */ @@ -246,22 +253,60 @@ export function HealthKitMetricPage({ ? (fallbackMeasurementType as string) : measurementType; + // v1.32.26 — resolve the display unit + scale from the user's preference for + // a type with a registered transform (kg↔lb, cm↔in, °C↔°F). When resolved, + // the scaffold OWNS the unit / y-axis unit / value scale / offset / bands + + // stat precision and IGNORES the page-passed values, so no page can double- + // scale or drift its unit label from the line. Untransformed types keep the + // page-passed props exactly (glucose, pulse, %, scores, …). + const unitDisplay = useUnitDisplay(); + const transform = unitDisplay.isTransformed(effectiveType) + ? unitDisplay.transformFor(effectiveType) + : null; + const resolvedUnit = transform ? transform.displayUnit : unit; + const resolvedYAxisUnit = transform ? transform.displayUnit : yAxisUnit; + const resolvedScale = transform ? transform.factor : (valueScale ?? 1); + const resolvedOffset = transform ? (transform.offset ?? 0) : 0; + const resolvedFractionDigits = transform + ? transform.decimals + : statFractionDigits; + // Value bands are absolute bounds → affine-convert (factor + offset) so the + // shaded zone tracks the converted line. Metric users get the identity. + const resolvedBands = + transform && valueBands + ? valueBands.map((band) => ({ + ...band, + min: unitDisplay.toDisplay(effectiveType, band.min), + max: unitDisplay.toDisplay(effectiveType, band.max), + })) + : valueBands; + const rawSummary = analytics?.summaries?.[effectiveType] ?? null; // The stat strip renders display-unit values. The summary holds stored - // values, so when the page renders a scaled unit (e.g. WALKING_SPEED - // stores m/s but displays km/h via `valueScale`), fold the same scale - // into the strip's min / max / median / mean so the numbers and the - // unit agree. `valueScale` defaults to 1 (identity) → byte-identical - // for every non-scaled metric. - const scale = valueScale ?? 1; + // values, so fold the resolved scale + offset (affine) into the strip's + // min / max / median / mean so the numbers and the unit agree. All four are + // ABSOLUTE values, so they take the offset; an untransformed metric keeps + // scale 1 / offset 0 → byte-identical. const summary = - rawSummary && scale !== 1 + rawSummary && (resolvedScale !== 1 || resolvedOffset !== 0) ? { ...rawSummary, - min: rawSummary.min === null ? null : rawSummary.min * scale, - max: rawSummary.max === null ? null : rawSummary.max * scale, - mean: rawSummary.mean === null ? null : rawSummary.mean * scale, - median: rawSummary.median === null ? null : rawSummary.median * scale, + min: + rawSummary.min === null + ? null + : rawSummary.min * resolvedScale + resolvedOffset, + max: + rawSummary.max === null + ? null + : rawSummary.max * resolvedScale + resolvedOffset, + mean: + rawSummary.mean === null + ? null + : rawSummary.mean * resolvedScale + resolvedOffset, + median: + rawSummary.median === null + ? null + : rawSummary.median * resolvedScale + resolvedOffset, } : rawSummary; @@ -353,8 +398,8 @@ export function HealthKitMetricPage({ statStrip={ } diversityNudge={ @@ -393,12 +438,13 @@ export function HealthKitMetricPage({ } titleIcon={statIcon} colors={[color]} - unit={unit} - yAxisUnit={yAxisUnit} - valueBands={valueBands} + unit={resolvedUnit} + yAxisUnit={resolvedYAxisUnit} + valueBands={resolvedBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} - valueScale={valueScale} + valueScale={resolvedScale} + valueOffset={resolvedOffset} onVisibleStats={onVisibleStats} /> {targetSummarySlug ? ( diff --git a/src/components/insights/mood/mood-factor-metric-crosstab.tsx b/src/components/insights/mood/mood-factor-metric-crosstab.tsx index c4c1460e7..8393d0bbf 100644 --- a/src/components/insights/mood/mood-factor-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-factor-metric-crosstab.tsx @@ -1,6 +1,7 @@ "use client"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { moodTagIcon } from "@/components/mood/mood-tag-icons"; import { cn } from "@/lib/utils"; import type { MoodInfluenceConfidence } from "./mood-tag-influence"; @@ -85,6 +86,7 @@ export function MoodFactorMetricCrosstab({ rows: MoodFactorMetricCrosstabRow[]; }) { const { t } = useTranslations(); + const unitDisplay = useUnitDisplay(); if (rows.length === 0) return null; return ( @@ -99,7 +101,16 @@ export function MoodFactorMetricCrosstab({ const metricLabel = t( METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey, ); - const unit = t(UNIT_KEY[row.display]); + // v1.32.26 — the weight ("kg") row follows the metric/imperial + // preference: the unit symbol + the values (averages + delta, all in + // kg, offset-free) convert together. Every other display is unitless + // to the preference and keeps its i18n label + raw value. + const isWeightRow = row.display === "kg"; + const unit = isWeightRow + ? unitDisplay.unitFor("WEIGHT") + : t(UNIT_KEY[row.display]); + const conv = (v: number) => + isWeightRow ? unitDisplay.toDisplay("WEIGHT", v) : v; // `delta` = lowAvg − highAvg: positive = the vital runs higher on the // low-factor (worse) days. The number stays NEUTRAL — a higher value // is good for steps but bad for resting HR, and this board is @@ -107,7 +118,7 @@ export function MoodFactorMetricCrosstab({ // green/red would imply a health verdict the data doesn't support. // The sign prefix + `data-direction` carry the direction. const up = row.delta >= 0; - const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`; + const deltaText = `${up ? "+" : ""}${fmt(conv(row.delta), row.display)} ${unit}`; return (

  • ({ + useAuth: () => ({ + user: { + id: "u1", + username: "testuser", + role: "USER", + unitPreference: preference, + }, + isAuthenticated: true, + isLoading: false, + }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + refetchQueries: vi.fn(), + }), +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiPost: vi.fn().mockResolvedValue({}), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { MeasurementForm } from "../measurement-form"; + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("MeasurementForm — preferred-unit value label", () => { + it("labels the weight field in kg for a metric user", () => { + preference = "metric"; + const html = render(); + expect(html).toContain("kg"); + expect(html).not.toContain("lb"); + // The metric placeholder (kg) is shown, not the imperial one. + expect(html).toContain('placeholder="75.5"'); + }); + + it("labels the weight field in lb for an imperial user", () => { + preference = "imperial"; + const html = render(); + expect(html).toContain("lb"); + // The imperial placeholder (lb) is shown. + expect(html).toContain('placeholder="165"'); + }); +}); diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 978a8e274..631044d53 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -24,6 +24,7 @@ import { import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { invalidateKeys, measurementDependentKeys, @@ -50,6 +51,7 @@ const MEASUREMENT_TYPES = [ labelKey: "measurements.typeWeight", unit: "kg", placeholder: "75.5", + placeholderImperial: "165", }, { value: "PULSE", @@ -86,12 +88,14 @@ const MEASUREMENT_TYPES = [ labelKey: "measurements.typeTotalBodyWater", unit: "kg", placeholder: "42", + placeholderImperial: "93", }, { value: "BONE_MASS", labelKey: "measurements.typeBoneMass", unit: "kg", placeholder: "3.2", + placeholderImperial: "7", }, { value: "OXYGEN_SATURATION", @@ -104,6 +108,7 @@ const MEASUREMENT_TYPES = [ labelKey: "measurements.typeBodyTemperature", unit: "°C", placeholder: "36.6", + placeholderImperial: "97.9", }, // v1.25 — physical / clinical signals. Manual web capture so a web-only // self-hoster can log them (the iOS client + measurements API already do). @@ -112,6 +117,7 @@ const MEASUREMENT_TYPES = [ labelKey: "measurements.typeWaistCircumference", unit: "cm", placeholder: "84", + placeholderImperial: "33", }, { value: "WAIST_TO_HEIGHT", @@ -124,6 +130,7 @@ const MEASUREMENT_TYPES = [ labelKey: "measurements.typeGripStrength", unit: "kg", placeholder: "38", + placeholderImperial: "84", }, { value: "PAIN_NRS", @@ -232,6 +239,7 @@ export function MeasurementForm({ }: MeasurementFormProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); + const unitDisplay = useUnitDisplay(); // Normalize legacy BP types to combined mode const normalizedDefault = @@ -327,10 +335,24 @@ export function MeasurementForm({ await apiPost("/api/measurements", batch); } else { - // Single measurement + // Single measurement. Canonical storage stays SI: an imperial user + // types in lb / in / °F and we invert to the canonical unit at the + // entry boundary. A metric user (or an untransformed type) takes the + // identity path — `fromDisplay` returns the typed number unchanged, so + // the payload is byte-identical to before. Round only the converted + // imperial value so 210 lb stores as 95.25 kg, not 95.25439770…. + const typed = parseFloat(value); + let canonicalValue = typed; + if (unitDisplay.isTransformed(type)) { + const inverted = unitDisplay.fromDisplay(type, typed); + canonicalValue = + unitDisplay.preference === "imperial" + ? Math.round(inverted * 100) / 100 + : inverted; + } await apiPost("/api/measurements", { type, - value: parseFloat(value), + value: canonicalValue, measuredAt: timestamp, notes: notes || undefined, ...(isGlucoseMode ? { glucoseContext } : {}), @@ -483,10 +505,15 @@ export function MeasurementForm({ @@ -498,8 +525,13 @@ export function MeasurementForm({ value={value} onChange={(e) => setValue(e.target.value)} placeholder={ - typeInfo && "placeholder" in typeInfo - ? typeInfo.placeholder + typeInfo + ? unitDisplay.preference === "imperial" && + "placeholderImperial" in typeInfo + ? typeInfo.placeholderImperial + : "placeholder" in typeInfo + ? typeInfo.placeholder + : undefined : undefined } required diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index a3ebc38c0..1a4f4f22d 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -66,6 +66,8 @@ import { formatDateOrRelative, formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"; import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform"; +import { getUnitForType } from "@/lib/validations/measurement"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { invalidateKeys, measurementDependentKeys, @@ -282,6 +284,24 @@ export function MeasurementList({ const { t } = useTranslations(); const fmt = useFormatters(); const { isAuthenticated } = useAuth(); + const unitDisplay = useUnitDisplay(); + // v1.32.26 — a single-reading row renders its value + unit in the user's + // preferred unit (kg↔lb, cm↔in, °C↔°F). A canonical row converts through the + // transform; a legacy non-canonical row (an old MCP write whose `unit` ≠ the + // type's canonical unit) is shown verbatim so its stored unit is never + // silently relabelled. Metric users take the identity path — unchanged. + const rowDisplay = useCallback( + (m: { type: string; value: number; unit: string }) => { + if (m.unit !== getUnitForType(m.type)) { + return { value: m.value, unit: m.unit }; + } + return { + value: unitDisplay.toDisplay(m.type, m.value), + unit: unitDisplay.unitFor(m.type), + }; + }, + [unitDisplay], + ); // v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH // render (only CSS `display` hid one), so a dense cumulative (up to ~5000 // rows) or sleep page built every row subtree twice — double the DOM, double @@ -710,8 +730,20 @@ export function MeasurementList({ return; } + // Seed the input in the user's preferred unit so an imperial user edits in + // lb / in / °F (converted back to canonical on save). Metric users and + // legacy non-canonical rows take the identity path — the seed is the raw + // stored value, unchanged. Round the converted imperial seed so the input + // shows "210" rather than a long float. + const rd = rowDisplay(measurement); + const seedValue = + unitDisplay.preference === "imperial" && + unitDisplay.isTransformed(measurement.type) && + measurement.unit === getUnitForType(measurement.type) + ? Math.round(rd.value * 100) / 100 + : measurement.value; const seed = { - value: String(measurement.value), + value: String(seedValue), measuredAt: toDateTimeLocalValue(measurement.measuredAt), notes: (measurement.notes ?? "").slice(0, MAX_COMMENT_LENGTH), }; @@ -761,10 +793,25 @@ export function MeasurementList({ return; } + // The input holds a display-unit value; invert it to canonical SI before + // the PATCH so an imperial edit persists in the stored unit. Metric users + // and legacy non-canonical rows are the identity path — value unchanged. + let canonicalValue = parsedValue; + if ( + unitDisplay.isTransformed(editing.type) && + editing.unit === getUnitForType(editing.type) + ) { + const inverted = unitDisplay.fromDisplay(editing.type, parsedValue); + canonicalValue = + unitDisplay.preference === "imperial" + ? Math.round(inverted * 100) / 100 + : inverted; + } + setEditError(null); updateMutation.mutate({ id: editing.id, - value: parsedValue, + value: canonicalValue, measuredAt: measuredDate.toISOString(), notes: editNotes.trim() ? editNotes.trim() : null, }); @@ -1050,10 +1097,10 @@ export function MeasurementList({ {isGrouped ? fmt.integer(m.value) : fmt.number( - m.value, + rowDisplay(m).value, rawDisplayFractionDigits(m.type), )}{" "} - {m.unit} + {isGrouped ? m.unit : rowDisplay(m).unit} {isGrouped && ( {t("measurements.dailyTotalCaption", { @@ -1253,10 +1300,10 @@ export function MeasurementList({ {isGrouped ? fmt.integer(m.value) : fmt.number( - m.value, + rowDisplay(m).value, rawDisplayFractionDigits(m.type), )}{" "} - {m.unit} + {isGrouped ? m.unit : rowDisplay(m).unit} )} @@ -1423,7 +1470,9 @@ export function MeasurementList({
    DisplayTransform; + /** Convert a raw canonical ABSOLUTE value to the display value. */ + toDisplay: (type: string, rawValue: number) => number; + /** Convert a display ABSOLUTE value back to canonical (entry boundary). */ + fromDisplay: (type: string, displayValue: number) => number; + /** Convert a DELTA / slope / SD — factor only, never the affine offset. */ + toDisplayDelta: (type: string, deltaValue: number) => number; + /** The display unit symbol for a type under the current preference. */ + unitFor: (type: string) => string; + /** Display decimal places for a type under the current preference. */ + decimalsFor: (type: string) => number; + /** True when a type actually rescales under preference (vs identity). */ + isTransformed: (type: string) => boolean; +} + +export function useUnitDisplay(): UnitDisplay { + const { user } = useAuth(); + const preference: UnitPreference = + user?.unitPreference === "imperial" ? "imperial" : "metric"; + + return useMemo(() => { + const transformFor = (type: string) => + getDisplayTransform(type, preference); + return { + preference, + transformFor, + toDisplay: (type, rawValue) => + applyDisplayTransform(rawValue, transformFor(type)), + fromDisplay: (type, displayValue) => + invertDisplayTransform(displayValue, transformFor(type)), + toDisplayDelta: (type, deltaValue) => + applyDisplayTransformDelta(deltaValue, transformFor(type)), + unitFor: (type) => transformFor(type).displayUnit, + decimalsFor: (type) => transformFor(type).decimals, + isTransformed: (type) => hasDisplayTransform(type), + }; + }, [preference]); +} diff --git a/src/lib/import/csv-measurements.ts b/src/lib/import/csv-measurements.ts index e5fc82601..3b07b902a 100644 --- a/src/lib/import/csv-measurements.ts +++ b/src/lib/import/csv-measurements.ts @@ -18,11 +18,11 @@ * * - `type` — a `measurementTypeEnum` value (e.g. WEIGHT). Unknown → skipped. * - `value` — a number in the row's `unit`. - * - `unit` — the canonical unit for the type, OR a recognised alias: - * glucose mmol/L → mg/dL (× 18.016), - * weight lb → kg (× 0.453592). - * Any other non-canonical unit → skipped (never silently - * mis-stored). + * - `unit` — the canonical unit for the type, OR a recognised alias + * (glucose mmol/L → mg/dL, mass lb → kg, waist in → cm, + * temperature °F → °C) via the shared + * `resolveToCanonicalUnit` resolver. Any other + * non-canonical unit → skipped (never silently mis-stored). * - `measuredAt` — ISO-8601 WITH an explicit offset (`Z` or ±HH:MM). A row * without an offset is skipped — the importer never guesses * a timezone. The instant is bounded by `validateEntryInstant` @@ -36,15 +36,11 @@ import { measurementTypeEnum, glucoseContextEnum, - getUnitForType, validateMeasurementRange, MEASUREMENT_NOTES_MAX_LENGTH, } from "@/lib/validations/measurement"; import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; - -/** Canonical conversion factors keyed by `${type}:${loweredAlias}`. */ -const GLUCOSE_MMOL_TO_MGDL = 18.016; -const LB_TO_KG = 0.453592; +import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases"; /** A normalised, write-ready row in the `/api/import` measurement shape. */ export interface NormalisedMeasurementRow { @@ -165,39 +161,6 @@ function hasExplicitOffset(value: string): boolean { return /([zZ]|[+-]\d{2}:?\d{2})$/.test(value.trim()); } -/** - * Resolve the row's unit to the canonical unit + converted value, or return - * `null` when the unit is neither canonical nor a recognised alias. - */ -function convertToCanonicalUnit( - type: string, - value: number, - rawUnit: string, -): { value: number; unit: string } | null { - const canonical = getUnitForType(type); - const unit = rawUnit.trim(); - // Case-insensitive match against the canonical unit (mg/dL vs MG/DL). - if (unit.toLowerCase() === canonical.toLowerCase()) { - return { value, unit: canonical }; - } - const lower = unit.toLowerCase(); - // Glucose: mmol/L → mg/dL. - if (type === "BLOOD_GLUCOSE" && (lower === "mmol/l" || lower === "mmol")) { - return { value: value * GLUCOSE_MMOL_TO_MGDL, unit: canonical }; - } - // Weight: lb → kg. - if ( - type === "WEIGHT" && - (lower === "lb" || - lower === "lbs" || - lower === "pound" || - lower === "pounds") - ) { - return { value: value * LB_TO_KG, unit: canonical }; - } - return null; -} - const KNOWN_TYPES = new Set(measurementTypeEnum.options as readonly string[]); const KNOWN_GLUCOSE_CONTEXTS = new Set( glucoseContextEnum.options as readonly string[], @@ -284,7 +247,7 @@ export function parseCsvMeasurements( continue; } - const converted = convertToCanonicalUnit(type, value, rawUnit); + const converted = resolveToCanonicalUnit(type, value, rawUnit); if (converted === null) { skip("unknown_unit"); continue; diff --git a/src/lib/mcp/__tests__/write-tools.test.ts b/src/lib/mcp/__tests__/write-tools.test.ts index d677f3ba6..d8eb0b8cd 100644 --- a/src/lib/mcp/__tests__/write-tools.test.ts +++ b/src/lib/mcp/__tests__/write-tools.test.ts @@ -5,12 +5,15 @@ const logMcpMood = vi.fn(); const logMcpBloodPressure = vi.fn(); const checkMcpMeasurement = vi.fn(); const checkMcpBloodPressure = vi.fn(); +const resolveMcpMeasurementUnit = vi.fn(); vi.mock("../writes", () => ({ logMcpMeasurement: (...a: unknown[]) => logMcpMeasurement(...a), logMcpMood: (...a: unknown[]) => logMcpMood(...a), logMcpBloodPressure: (...a: unknown[]) => logMcpBloodPressure(...a), checkMcpMeasurement: (...a: unknown[]) => checkMcpMeasurement(...a), checkMcpBloodPressure: (...a: unknown[]) => checkMcpBloodPressure(...a), + resolveMcpMeasurementUnit: (...a: unknown[]) => + resolveMcpMeasurementUnit(...a), })); const checkMcpWriteRateLimit = vi.fn(); @@ -49,6 +52,10 @@ beforeEach(() => { // would-be record. Individual tests override to assert wouldFail. checkMcpMeasurement.mockReturnValue({ ok: true }); checkMcpBloodPressure.mockReturnValue({ ok: true }); + // Default: the unit hint resolves to canonical, echoing the value through. + resolveMcpMeasurementUnit.mockImplementation( + (_type: string, value: number) => ({ ok: true, value, unit: "kg" }), + ); }); describe("write-tool surface", () => { diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts index 32776ebf2..22c1eea9f 100644 --- a/src/lib/mcp/__tests__/writes.test.ts +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -89,6 +89,52 @@ describe("logMcpMeasurement", () => { ); }); + it("converts a lb unit hint to canonical kg before writing", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 210, + unit: "lb", + idempotencyKey: "key-lb", + }); + + expect(result.status).toBe("written"); + const data = measurement.create.mock.calls[0][0].data; + expect(data.unit).toBe("kg"); + // 210 lb → 95.25 kg (stored canonical, never the caller's lb verbatim). + expect(data.value).toBeCloseTo(210 * 0.45359237, 5); + if (result.status === "written") { + expect(result.measurement.unit).toBe("kg"); + } + }); + + it("refuses an unsupported unit hint without writing", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 12, + unit: "stone", + idempotencyKey: "key-stone", + }); + + expect(result.status).toBe("unsupported_unit"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("stamps canonical kg when the unit hint is omitted", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "key-none", + }); + + expect(result.status).toBe("written"); + const data = measurement.create.mock.calls[0][0].data; + expect(data.unit).toBe("kg"); + expect(data.value).toBe(80); + }); + it("refuses a non-capturable / clinical-only type without writing", async () => { const result = await logMcpMeasurement({ userId: "u-1", diff --git a/src/lib/mcp/write-tools.ts b/src/lib/mcp/write-tools.ts index cbf644600..76e1e49c0 100644 --- a/src/lib/mcp/write-tools.ts +++ b/src/lib/mcp/write-tools.ts @@ -58,6 +58,7 @@ import { logMcpBloodPressure, checkMcpMeasurement, checkMcpBloodPressure, + resolveMcpMeasurementUnit, } from "./writes"; /** @@ -184,7 +185,7 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ .max(20) .optional() .describe( - "Optional unit hint; the server uses the type's canonical unit when omitted.", + "Optional unit hint. Omit to use the type's canonical unit. A recognised alias (lb/lbs/pound(s), in/inch(es), °F, mmol/L) is converted to canonical; any other unit is refused with unsupported_unit — the value is never stored in a non-canonical unit.", ), measuredAt: z.iso .datetime({ offset: true }) @@ -214,27 +215,43 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ } if (!confirm) { - // Run the SAME validation the commit core runs so the preview cannot - // show a value that would then be refused on commit. - const check = checkMcpMeasurement(type, value, measuredAt); + // Run the SAME normalisation + validation the commit core runs so the + // preview cannot show a value that would then be refused on commit. The + // unit hint resolves to canonical (converting the value) or refuses; + // the preview echoes the canonical value the commit would store. + const unitResolution = resolveMcpMeasurementUnit(type, value, unit); + const previewValue = unitResolution.ok ? unitResolution.value : value; + const previewUnit = unitResolution.ok + ? unitResolution.unit + : (unit ?? null); + const check = unitResolution.ok + ? checkMcpMeasurement(type, previewValue, measuredAt) + : null; annotate({ action: { name: "mcp.tool.write" }, meta: { tool: "log_measurement", status: "preview" }, }); + const wouldFail = !unitResolution.ok + ? { wouldFail: true as const, error: "unsupported_unit" as const } + : check && !check.ok + ? { + wouldFail: true as const, + error: check.error, + reason: check.reason, + } + : {}; return { requiresConfirmation: true, written: false, preview: { type, - value, - unit: unit ?? null, + value: previewValue, + unit: previewUnit, measuredAt: measuredAt ? measuredAt.toISOString() : "now", source: "MCP", }, instruction: CONFIRM_INSTRUCTION, - ...(check.ok - ? {} - : { wouldFail: true, error: check.error, reason: check.reason }), + ...wouldFail, }; } @@ -254,6 +271,9 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ if (result.status === "unsupported_type") { return { written: false, error: "unsupported_type" }; } + if (result.status === "unsupported_unit") { + return { written: false, error: "unsupported_unit" }; + } if (result.status === "out_of_range") { return { written: false, error: "out_of_range", reason: result.reason }; } diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index daa79b28e..0db69f8e2 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -26,6 +26,7 @@ import { getUnitForType, validateMeasurementRange, } from "@/lib/validations/measurement"; +import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases"; import { isPlausibleEntryInstant, ENTRY_INSTANT_CLOCK_SKEW_MS, @@ -71,6 +72,34 @@ export type McpWriteCheck = | { ok: true } | { ok: false; error: "unsupported_type" | "out_of_range"; reason?: string }; +/** + * Resolve a caller-supplied `unit` hint to the canonical unit + converted + * value, or refuse it. Canonical storage is an invariant: an omitted hint + * stamps the type's canonical unit; a recognised alias (lb/lbs/pound(s), in, + * °F, mmol/L) converts the value and stamps canonical; anything else is + * refused so a non-canonical unit is never persisted verbatim (the pre-fix + * leak — a lb value tagged "kg" corrupts every display surface that trusts + * the row's `unit` column). Shared by the confirm-gate preview and the commit + * core so both reach one verdict for the same input. + */ +export type McpUnitResolution = + { ok: true; value: number; unit: string } | { ok: false }; + +export function resolveMcpMeasurementUnit( + type: MeasurementType, + value: number, + unit: string | undefined, +): McpUnitResolution { + if (unit === undefined) { + return { ok: true, value, unit: getUnitForType(type) }; + } + const resolved = resolveToCanonicalUnit(type, value, unit); + if (resolved === null) { + return { ok: false }; + } + return { ok: true, value: resolved.value, unit: resolved.unit }; +} + /** * Bound a client-supplied `measuredAt` exactly like the manual measurement * route (`validateEntryInstant`): reject a future instant beyond the 5-min @@ -156,6 +185,7 @@ export interface McpMeasurementRecord { export type McpMeasurementResult = | { status: "unsupported_type" } + | { status: "unsupported_unit" } | { status: "out_of_range"; reason: string } | { status: "written"; measurement: McpMeasurementRecord } | { status: "already_logged"; measurement: McpMeasurementRecord }; @@ -176,9 +206,28 @@ export async function logMcpMeasurement(input: { measuredAt?: Date; idempotencyKey: string; }): Promise { + // Normalise the caller's unit hint to canonical (converting the value) or + // refuse it — a non-canonical unit must never be persisted verbatim. Runs + // BEFORE the range check so the plausibility gate sees the canonical value + // (e.g. 210 lb → 95.25 kg passes WEIGHT {1..500}). + const unitResolution = resolveMcpMeasurementUnit( + input.type, + input.value, + input.unit, + ); + if (!unitResolution.ok) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "unsupported_unit" }, + }); + return { status: "unsupported_unit" }; + } + const value = unitResolution.value; + const unit = unitResolution.unit; + // Same validation the confirm-gate preview runs (type allowlist, range, and // the measuredAt instant bound) — preview and commit reach one verdict. - const check = checkMcpMeasurement(input.type, input.value, input.measuredAt); + const check = checkMcpMeasurement(input.type, value, input.measuredAt); if (!check.ok) { annotate({ action: { name: "mcp.tool.write" }, @@ -193,13 +242,12 @@ export async function logMcpMeasurement(input: { }; } - const unit = input.unit ?? getUnitForType(input.type); const measuredAt = input.measuredAt ?? new Date(); const externalId = mcpExternalId("measure", input.idempotencyKey); const record: McpMeasurementRecord = { type: input.type, - value: input.value, + value, unit, measuredAt: measuredAt.toISOString(), source: MCP_SOURCE, @@ -238,7 +286,7 @@ export async function logMcpMeasurement(input: { data: { userId: input.userId, type: input.type, - value: input.value, + value, unit, source: MCP_SOURCE, measuredAt, diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index 48165c42a..e6203f227 100644 --- a/src/lib/measurements/__tests__/display-transform.test.ts +++ b/src/lib/measurements/__tests__/display-transform.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest"; import { applyDisplayTransform, + applyDisplayTransformDelta, + invertDisplayTransform, getDisplayTransform, + hasDisplayTransform, + TRANSFORMED_TYPES, DEFAULT_UNIT_PREFERENCE, } from "../display-transform"; @@ -65,4 +69,116 @@ describe("display-transform", () => { getDisplayTransform("WALKING_SPEED", "metric"), ); }); + + // ── v1.32.26 — weight-class / length / temperature registry ── + + it("converts WEIGHT kg → lb on the imperial branch", () => { + const metric = getDisplayTransform("WEIGHT", "metric"); + expect(metric.factor).toBe(1); + expect(metric.displayUnit).toBe("kg"); + // Metric is the identity on the number — byte-identical to storage. + expect(applyDisplayTransform(95.25, metric)).toBe(95.25); + + const imperial = getDisplayTransform("WEIGHT", "imperial"); + expect(imperial.displayUnit).toBe("lb"); + // 95.25 kg → 210.0 lb. + expect(applyDisplayTransform(95.25, imperial)).toBeCloseTo(210.0, 1); + }); + + it("shares the mass branch across every body-mass metric", () => { + for (const type of [ + "TOTAL_BODY_WATER", + "BONE_MASS", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "GRIP_STRENGTH", + ]) { + expect(getDisplayTransform(type, "imperial").displayUnit).toBe("lb"); + expect(getDisplayTransform(type, "metric").displayUnit).toBe("kg"); + // 10 kg → 22.05 lb. + expect( + applyDisplayTransform(10, getDisplayTransform(type, "imperial")), + ).toBeCloseTo(22.0462, 3); + } + }); + + it("converts WAIST_CIRCUMFERENCE cm → in", () => { + const imperial = getDisplayTransform("WAIST_CIRCUMFERENCE", "imperial"); + expect(imperial.displayUnit).toBe("in"); + // 84 cm → 33.07 in. + expect(applyDisplayTransform(84, imperial)).toBeCloseTo(33.07, 2); + }); + + it("converts an ABSOLUTE temperature °C → °F affinely (factor + offset)", () => { + const metric = getDisplayTransform("BODY_TEMPERATURE", "metric"); + expect(metric.displayUnit).toBe("°C"); + expect(metric.offset ?? 0).toBe(0); + + const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial"); + expect(imperial.displayUnit).toBe("°F"); + expect(imperial.factor).toBe(1.8); + expect(imperial.offset).toBe(32); + // 36.6 °C → 97.88 °F (the +32 shift MUST apply to an absolute reading). + expect(applyDisplayTransform(36.6, imperial)).toBeCloseTo(97.88, 2); + // 0 °C → 32 °F (not 0). + expect(applyDisplayTransform(0, imperial)).toBe(32); + }); + + it("skin + wrist temperature share the affine temperature branch", () => { + for (const type of ["SKIN_TEMPERATURE", "WRIST_TEMPERATURE"]) { + const imperial = getDisplayTransform(type, "imperial"); + expect(imperial.displayUnit).toBe("°F"); + expect(imperial.offset).toBe(32); + } + }); + + it("BODY_TEMPERATURE_DEVIATION scales °C→°F WITHOUT the offset (it is a Δ)", () => { + const imperial = getDisplayTransform( + "BODY_TEMPERATURE_DEVIATION", + "imperial", + ); + expect(imperial.displayUnit).toBe("°F"); + expect(imperial.factor).toBe(1.8); + // A signed deviation is a delta: 1 Δ°C is a 1.8 Δ°F, NEVER 33.8 °F. + expect(imperial.offset ?? 0).toBe(0); + expect(applyDisplayTransform(1, imperial)).toBeCloseTo(1.8, 5); + }); + + it("the delta helper NEVER applies the affine offset", () => { + const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial"); + // Same absolute transform (offset 32), but a delta must be factor-only. + expect(applyDisplayTransform(1, imperial)).toBe(33.8); + expect(applyDisplayTransformDelta(1, imperial)).toBeCloseTo(1.8, 5); + // A 2 °C rise reads as a 3.6 °F rise, not a 35.6 °F reading. + expect(applyDisplayTransformDelta(2, imperial)).toBeCloseTo(3.6, 5); + }); + + it("inverse round-trips every registered branch (entry ⇄ display)", () => { + for (const type of TRANSFORMED_TYPES) { + for (const pref of ["metric", "imperial"] as const) { + const transform = getDisplayTransform(type, pref); + for (const raw of [0, 1, 36.6, 84, 95.25, 210]) { + const shown = applyDisplayTransform(raw, transform); + expect(invertDisplayTransform(shown, transform)).toBeCloseTo(raw, 6); + } + } + } + }); + + it("hasDisplayTransform + TRANSFORMED_TYPES agree, and exclude BMI/glucose", () => { + expect(hasDisplayTransform("WEIGHT")).toBe(true); + expect(hasDisplayTransform("BODY_TEMPERATURE")).toBe(true); + expect(hasDisplayTransform("PULSE")).toBe(false); + // Deliberately excluded (own preference / universal unit). + expect(TRANSFORMED_TYPES.has("BLOOD_GLUCOSE")).toBe(false); + expect(TRANSFORMED_TYPES.has("BODY_MASS_INDEX")).toBe(false); + // Every registered type resolves a non-identity or symbol-only metric + // branch, and both branches exist. + for (const type of TRANSFORMED_TYPES) { + expect(getDisplayTransform(type, "metric").displayUnit).toBeTruthy(); + expect(getDisplayTransform(type, "imperial").displayUnit).toBeTruthy(); + } + }); }); diff --git a/src/lib/measurements/__tests__/unit-aliases.test.ts b/src/lib/measurements/__tests__/unit-aliases.test.ts new file mode 100644 index 000000000..a12f4d860 --- /dev/null +++ b/src/lib/measurements/__tests__/unit-aliases.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; + +import { resolveToCanonicalUnit } from "../unit-aliases"; + +describe("resolveToCanonicalUnit", () => { + it("passes the canonical unit through case-insensitively", () => { + expect(resolveToCanonicalUnit("WEIGHT", 80, "kg")).toEqual({ + value: 80, + unit: "kg", + }); + expect(resolveToCanonicalUnit("WEIGHT", 80, "KG")).toEqual({ + value: 80, + unit: "kg", + }); + expect(resolveToCanonicalUnit("BLOOD_GLUCOSE", 95, "MG/DL")).toEqual({ + value: 95, + unit: "mg/dL", + }); + }); + + it("converts a lb weight to canonical kg (exact factor)", () => { + const out = resolveToCanonicalUnit("WEIGHT", 210, "lb"); + expect(out?.unit).toBe("kg"); + expect(out?.value).toBeCloseTo(210 * 0.45359237, 6); + // aliases + for (const alias of ["lbs", "pound", "pounds", "LB"]) { + expect(resolveToCanonicalUnit("WEIGHT", 1, alias)?.unit).toBe("kg"); + } + }); + + it("converts lb for every body-mass metric, not just WEIGHT", () => { + for (const type of ["MUSCLE_MASS", "FAT_MASS", "BONE_MASS"]) { + const out = resolveToCanonicalUnit(type, 22.0462, "lb"); + expect(out?.unit).toBe("kg"); + expect(out?.value).toBeCloseTo(10, 3); + } + }); + + it("converts waist inches to canonical cm", () => { + const out = resolveToCanonicalUnit("WAIST_CIRCUMFERENCE", 33, "in"); + expect(out?.unit).toBe("cm"); + expect(out?.value).toBeCloseTo(83.82, 2); + }); + + it("converts an absolute °F reading to canonical celsius", () => { + const out = resolveToCanonicalUnit("BODY_TEMPERATURE", 98.6, "°F"); + expect(out?.unit).toBe("celsius"); + expect(out?.value).toBeCloseTo(37, 5); + // an explicit celsius spelling also normalises to the canonical string + expect(resolveToCanonicalUnit("BODY_TEMPERATURE", 37, "°C")).toEqual({ + value: 37, + unit: "celsius", + }); + }); + + it("converts glucose mmol/L to canonical mg/dL", () => { + const out = resolveToCanonicalUnit("BLOOD_GLUCOSE", 5.3, "mmol/L"); + expect(out?.unit).toBe("mg/dL"); + expect(out?.value).toBeCloseTo(5.3 * 18.016, 3); + }); + + it("refuses an unrecognised or type-mismatched unit (never mis-stores)", () => { + expect(resolveToCanonicalUnit("WEIGHT", 12, "stone")).toBeNull(); + expect(resolveToCanonicalUnit("WEIGHT", 12, "°F")).toBeNull(); + // lb is not a waist unit + expect(resolveToCanonicalUnit("WAIST_CIRCUMFERENCE", 12, "lb")).toBeNull(); + // mmol/L is not a weight unit + expect(resolveToCanonicalUnit("WEIGHT", 12, "mmol/L")).toBeNull(); + }); +}); diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 1bc059952..98cd5326a 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -3,8 +3,12 @@ * * Canonical storage stays SI (the iOS wire contract + the Withings * passthrough both lock raw SI — see the convention block in - * `apple-health-mapping.ts`). A handful of metrics read better in a - * derived unit at the display boundary only: + * `apple-health-mapping.ts`). Storage never changes; the metric/imperial + * user preference (`unitPreference`) only selects which branch a + * transform exposes at the render boundary. + * + * A handful of metrics also read better in a derived unit regardless of + * preference: * * - WALKING_SPEED is stored in m/s but a casual gait of 1.3 m/s * reads as "4.7 km/h" to a human. @@ -12,16 +16,20 @@ * reads as "5000 m"; daily totals make more sense in km. * * The transform is applied at the render layer only (chart `valueScale` - * + the measurement-list cell + tooltip). Raw rows, plausibility - * ranges, rollup math, and the AI snapshot all keep canonical SI. A - * `factor` of 1 is the identity transform and is what every other - * type resolves to, so callers that pass an un-transformed value see - * no change (charts stay visually identical). + * + the measurement-list cell + tooltip + entry form). Raw rows, + * plausibility ranges, rollup math, FHIR/CSV export, the doctor report, + * and the AI snapshot all keep canonical SI. For a metric user every + * transform below is the identity on the number (factor 1, offset 0), so + * the metric render is byte-identical to before — charts stay visually + * identical. The imperial branch rescales; that is the intended + * behaviour, not a regression. * - * The metric/imperial user preference (`unitPreference`) selects which - * branch a transform exposes. The default is metric; the imperial - * branch is additive and only wired for the two distance/speed metrics - * so far — every other type ignores the preference entirely. + * v1.32.26 — the registry now covers the full weight-class / length / + * temperature set the display surfaces render, and the interface gained + * an affine `offset` so °C→°F (a shift, not just a scale) can be + * expressed. The offset applies to ABSOLUTE values only; deltas, slopes, + * standard deviations, and band widths take the factor alone — use + * `applyDisplayTransformDelta` for those. */ import type { MeasurementType } from "@/generated/prisma/client"; @@ -34,6 +42,13 @@ export const DEFAULT_UNIT_PREFERENCE: UnitPreference = "metric"; export interface DisplayTransform { /** Multiplier applied to the raw canonical value for display. */ factor: number; + /** + * Additive shift applied AFTER the factor for an absolute display + * value (`display = raw * factor + offset`). Defaults to 0. Present + * only for affine conversions (°C→°F). Deltas/slopes/SD must NOT use + * it — see `applyDisplayTransformDelta`. + */ + offset?: number; /** Unit string shown next to the displayed value. */ displayUnit: string; /** Decimal places for the displayed value. */ @@ -47,20 +62,60 @@ const IDENTITY: DisplayTransform = { decimals: 1, }; +// kg → lb (exact: 1 lb = 0.45359237 kg → 1 kg = 2.20462262185 lb). +const KG_TO_LB = 2.20462262185; +// cm → in (exact: 1 in = 2.54 cm → 1 cm = 0.393700787402 in). +const CM_TO_IN = 0.393700787402; + +/** Shared metric/imperial branch pair for a body-mass metric (kg ↔ lb). */ +const MASS_TRANSFORM: Record = { + metric: { factor: 1, displayUnit: "kg", decimals: 1 }, + imperial: { factor: KG_TO_LB, displayUnit: "lb", decimals: 1 }, +}; + +/** Shared branch pair for an ABSOLUTE temperature (°C ↔ °F, affine). */ +const TEMPERATURE_TRANSFORM: Record = { + metric: { factor: 1, displayUnit: "°C", decimals: 1 }, + imperial: { factor: 1.8, offset: 32, displayUnit: "°F", decimals: 1 }, +}; + /** - * Per-type, per-preference transforms. Only types that genuinely read - * better in a derived unit appear here; everything else resolves to - * the identity transform with the canonical unit from - * `getUnitForType()`. + * Per-type, per-preference transforms. Types absent here resolve to the + * identity transform with the canonical unit from `getUnitForType()`. * - * Metric branch ships in v1.7.0. The imperial branch is additive: it - * covers the same two metrics in mph / miles so the global - * metric/imperial toggle has somewhere to land without a follow-up - * migration. + * The metric branch of every entry below is the identity on the number + * (factor 1, no offset), differing from `getUnitForType` only in the + * DISPLAY symbol it surfaces (e.g. "°C" for the stored "celsius"). The + * imperial branch carries the real conversion. */ const TRANSFORMS: Partial< Record> > = { + // ── Body-mass metrics (kg canonical → lb imperial) ── + WEIGHT: MASS_TRANSFORM, + TOTAL_BODY_WATER: MASS_TRANSFORM, + BONE_MASS: MASS_TRANSFORM, + FAT_MASS: MASS_TRANSFORM, + FAT_FREE_MASS: MASS_TRANSFORM, + MUSCLE_MASS: MASS_TRANSFORM, + LEAN_BODY_MASS: MASS_TRANSFORM, + GRIP_STRENGTH: MASS_TRANSFORM, + // ── Circumference (cm canonical → in imperial) ── + WAIST_CIRCUMFERENCE: { + metric: { factor: 1, displayUnit: "cm", decimals: 1 }, + imperial: { factor: CM_TO_IN, displayUnit: "in", decimals: 1 }, + }, + // ── Absolute temperatures (°C canonical → °F imperial, affine) ── + BODY_TEMPERATURE: TEMPERATURE_TRANSFORM, + SKIN_TEMPERATURE: TEMPERATURE_TRANSFORM, + WRIST_TEMPERATURE: TEMPERATURE_TRANSFORM, + // A signed baseline DEVIATION (Δ°C) — the °F imperial branch scales by + // 1.8 but carries NO +32 offset. A one-degree-Celsius deviation is a + // 1.8-degree-Fahrenheit deviation, not a 33.8 °F reading. + BODY_TEMPERATURE_DEVIATION: { + metric: { factor: 1, displayUnit: "°C", decimals: 1 }, + imperial: { factor: 1.8, displayUnit: "°F", decimals: 1 }, + }, // 1 m/s = 3.6 km/h = 2.236936 mph. WALKING_SPEED: { metric: { factor: 3.6, displayUnit: "km/h", decimals: 1 }, @@ -73,6 +128,20 @@ const TRANSFORMS: Partial< }, }; +/** + * Every type with a registered transform. The structural guard test uses + * this set to assert no display surface hardcodes a unit string / scale + * for a type that should route through the transform instead. + */ +export const TRANSFORMED_TYPES: ReadonlySet = new Set( + Object.keys(TRANSFORMS), +); + +/** True when `type` has a per-preference transform (vs the identity path). */ +export function hasDisplayTransform(type: string): boolean { + return TRANSFORMED_TYPES.has(type); +} + /** * Resolve the display transform for a `(type, preference)` pair. * Returns the identity transform (with the canonical unit) for every @@ -117,13 +186,38 @@ export function rawDisplayFractionDigits(type: string): number | undefined { } /** - * Apply a transform's factor to a raw canonical value. Pure helper — - * keeps the multiply in one place so the chart, list cell, and tooltip - * all scale identically. + * Apply a transform to a raw canonical ABSOLUTE value: + * `display = raw * factor + offset`. Pure helper — keeps the arithmetic + * in one place so the chart, list cell, tooltip, and entry form all + * scale identically. */ export function applyDisplayTransform( rawValue: number, transform: DisplayTransform, ): number { - return rawValue * transform.factor; + return rawValue * transform.factor + (transform.offset ?? 0); +} + +/** + * Apply a transform to a DELTA / slope / standard-deviation / band-width + * — factor only, NEVER the offset. A 1 °C change is a 1.8 °F change; it + * must not pick up the +32 absolute-scale shift. + */ +export function applyDisplayTransformDelta( + deltaValue: number, + transform: DisplayTransform, +): number { + return deltaValue * transform.factor; +} + +/** + * Invert an absolute display value back to the canonical stored value: + * `raw = (display - offset) / factor`. Used at the entry boundary so an + * imperial user's typed number persists in canonical SI. + */ +export function invertDisplayTransform( + displayValue: number, + transform: DisplayTransform, +): number { + return (displayValue - (transform.offset ?? 0)) / transform.factor; } diff --git a/src/lib/measurements/unit-aliases.ts b/src/lib/measurements/unit-aliases.ts new file mode 100644 index 000000000..bb43693ed --- /dev/null +++ b/src/lib/measurements/unit-aliases.ts @@ -0,0 +1,114 @@ +/** + * v1.32.26 — shared inbound unit-alias resolver. + * + * Canonical storage is SI (`getUnitForType`). A few write paths accept a + * caller-supplied unit string and must normalise it to the canonical unit + * (converting the value) or refuse it outright — they must NEVER persist a + * non-canonical unit verbatim, or the row's `unit` column stops being an + * invariant and every display surface that trusts it silently mislabels. + * + * This is the single resolver behind both inbound paths that take a unit + * string: the CSV importer (`csv-measurements.ts`) and the MCP + * `log_measurement` tool (`mcp/writes.ts`). It recognises the canonical unit + * (case-insensitively) and a closed set of common aliases (lb/lbs/pound(s), + * in/inch(es), °F, mmol/L); anything else returns `null` so the caller can + * skip / refuse rather than mis-store. + * + * Pure + side-effect-free; safe to import from an API route or a script. + */ +import { getUnitForType } from "@/lib/validations/measurement"; + +/** Glucose mmol/L → mg/dL. */ +const GLUCOSE_MMOL_TO_MGDL = 18.016; +/** Exact pound → kilogram (1 lb = 0.45359237 kg). */ +const LB_TO_KG = 0.45359237; +/** Exact inch → centimetre (1 in = 2.54 cm). */ +const IN_TO_CM = 2.54; + +/** Body-mass metrics stored in kg — a lb value converts, all others skip. */ +const MASS_TYPES: ReadonlySet = new Set([ + "WEIGHT", + "TOTAL_BODY_WATER", + "BONE_MASS", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "GRIP_STRENGTH", +]); + +/** Absolute-temperature metrics stored in °C (canonical string "celsius"). */ +const TEMPERATURE_TYPES: ReadonlySet = new Set([ + "BODY_TEMPERATURE", + "SKIN_TEMPERATURE", + "WRIST_TEMPERATURE", +]); + +const LB_ALIASES: ReadonlySet = new Set([ + "lb", + "lbs", + "pound", + "pounds", +]); +const IN_ALIASES: ReadonlySet = new Set(["in", "inch", "inches", '"']); +const FAHRENHEIT_ALIASES: ReadonlySet = new Set([ + "f", + "°f", + "fahrenheit", +]); +const CELSIUS_ALIASES: ReadonlySet = new Set(["c", "°c", "celsius"]); +const MMOL_ALIASES: ReadonlySet = new Set(["mmol/l", "mmol"]); + +export interface CanonicalUnitResult { + /** The value expressed in the canonical unit. */ + value: number; + /** The canonical unit string (`getUnitForType(type)`). */ + unit: string; +} + +/** + * Resolve `(type, value, rawUnit)` to `{ value, unit }` in the canonical + * unit, or `null` when `rawUnit` is neither the canonical unit nor a + * recognised alias. Never mis-stores: an unknown unit is a hard `null`. + */ +export function resolveToCanonicalUnit( + type: string, + value: number, + rawUnit: string, +): CanonicalUnitResult | null { + const canonical = getUnitForType(type); + const trimmed = rawUnit.trim(); + const lower = trimmed.toLowerCase(); + + // Canonical match (case-insensitive: "mg/dL" vs "MG/DL", "kg" vs "KG"). + if (lower === canonical.toLowerCase()) { + return { value, unit: canonical }; + } + + // Glucose: mmol/L → mg/dL. + if (type === "BLOOD_GLUCOSE" && MMOL_ALIASES.has(lower)) { + return { value: value * GLUCOSE_MMOL_TO_MGDL, unit: canonical }; + } + + // Body mass: lb → kg. + if (MASS_TYPES.has(type) && LB_ALIASES.has(lower)) { + return { value: value * LB_TO_KG, unit: canonical }; + } + + // Waist circumference: in → cm. + if (type === "WAIST_CIRCUMFERENCE" && IN_ALIASES.has(lower)) { + return { value: value * IN_TO_CM, unit: canonical }; + } + + // Absolute temperature: °F → °C (affine), or an explicit °C spelling. + if (TEMPERATURE_TYPES.has(type)) { + if (FAHRENHEIT_ALIASES.has(lower)) { + return { value: (value - 32) / 1.8, unit: canonical }; + } + if (CELSIUS_ALIASES.has(lower)) { + return { value, unit: canonical }; + } + } + + return null; +}