Merge pull request #629 from MBombeck/release/v1.32.26

release/v1.32.26 — apply the metric/imperial preference across every reading surface
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-25 00:48:54 +02:00
commit cfd5cf0e0c
52 files changed
+1506 -247

No files matched your search

+29
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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?",
+1 -1
View File
@@ -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?",
+1 -1
View File
@@ -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?",
+1 -1
View File
@@ -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 ?",
+1 -1
View File
@@ -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?",
+1 -1
View File
@@ -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?",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.32.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/*`
@@ -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<void> {
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<string>();
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());
@@ -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<void> {
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());
@@ -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<string> = 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);
}
});
});
@@ -22,8 +22,6 @@ export default function InsightsBodyTemperaturePage() {
i18nPrefix="insights.bodyTemperature"
explainerMetric="bodyTemperature"
color="var(--chart-3)"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
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?"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsKoerperwasserPage() {
i18nPrefix="insights.totalBodyWater"
explainerMetric="bodyWater"
color="var(--info)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Droplet className="size-6" />}
emptyStateCtaType={null}
captureType="TOTAL_BODY_WATER"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsKnochenmassePage() {
i18nPrefix="insights.boneMass"
explainerMetric="boneMass"
color="var(--warning)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Bone className="size-6" />}
emptyStateCtaType={null}
captureType="BONE_MASS"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsFettfreieMassePage() {
i18nPrefix="insights.fatFreeMass"
explainerMetric="fatFreeMass"
color="var(--chart-1)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Scale className="size-6" />}
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?"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsFettmassePage() {
i18nPrefix="insights.fatMass"
explainerMetric="fatMass"
color="var(--warning)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Droplets className="size-6" />}
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?"
-2
View File
@@ -23,8 +23,6 @@ export default function InsightsGripStrengthPage() {
chartKey="gripStrength"
i18nPrefix="insights.gripStrength"
color="var(--success)"
unit="kg"
yAxisUnit="kg"
statIcon={Dumbbell}
emptyStateIcon={<Dumbbell className="size-6" />}
emptyStateCtaType="GRIP_STRENGTH"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsMagermassePage() {
i18nPrefix="insights.leanBodyMass"
explainerMetric="leanBodyMass"
color="var(--chart-1)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Scale className="size-6" />}
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?"
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsMuskelmassePage() {
i18nPrefix="insights.muscleMass"
explainerMetric="muscleMass"
color="var(--chart-1)"
unit="kg"
yAxisUnit="kg"
emptyStateIcon={<Dumbbell className="size-6" />}
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?"
@@ -30,8 +30,6 @@ export default function InsightsHauttemperaturPage() {
i18nPrefix="insights.skinTemperature"
explainerMetric="skinTemperature"
color="var(--warning)"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
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}
-2
View File
@@ -22,8 +22,6 @@ export default function InsightsWaistPage() {
chartKey="waistCircumference"
i18nPrefix="insights.waist"
color="var(--info)"
unit="cm"
yAxisUnit="cm"
statIcon={Ruler}
emptyStateIcon={<Ruler className="size-6" />}
emptyStateCtaType="WAIST_CIRCUMFERENCE"
@@ -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={<Footprints className="size-6" />}
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?"
-3
View File
@@ -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={<Gauge className="size-6" />}
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?"
+45 -5
View File
@@ -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 (
<SubPageShell
@@ -81,14 +120,14 @@ export default function InsightsGewichtPage() {
explainerMetric="weight"
statStrip={
<MetricStatStrip
summary={weightSummary}
unit="kg"
summary={displaySummary}
unit={weightUnit}
seriesLabel={t("insights.weightSectionTitle")}
icon={Scale}
windowStats={statsByType?.WEIGHT ?? null}
/>
}
coachReadStrip={<CoachReadStrip metricType="WEIGHT" unit="kg" />}
coachReadStrip={<CoachReadStrip metricType="WEIGHT" unit={weightUnit} />}
diversityNudge={
<MeasurementDiversityNudge
measurementType="WEIGHT"
@@ -106,10 +145,11 @@ export default function InsightsGewichtPage() {
title={t("charts.weight")}
titleIcon={Scale}
colors={["var(--chart-1)"]}
unit="kg"
valueBands={weightBands}
unit={weightUnit}
valueBands={displayBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
valueScale={weightScale}
onVisibleStats={onVisibleStats}
/>
@@ -22,8 +22,6 @@ export default function InsightsWristTemperaturePage() {
i18nPrefix="insights.wristTemperature"
explainerMetric="wristTemperature"
color="var(--warning)"
unit="°C"
yAxisUnit="°C"
emptyStateIcon={<Thermometer className="size-6" />}
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?"
+96 -36
View File
@@ -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<QuickEntryDialog>(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({
<TrendCard
key="weight"
label={t("dashboard.weightShort")}
latest={w?.latest ?? null}
unit="kg"
avg7={w?.avg7 ?? null}
avg30={w?.avg30 ?? null}
latest={td("WEIGHT", w?.latest)}
unit={unitDisplay.unitFor("WEIGHT")}
avg7={td("WEIGHT", w?.avg7)}
avg30={td("WEIGHT", w?.avg30)}
avg7ColorClass={getRangeColorClass(w?.avg7, {
range: weightRange,
})}
@@ -908,23 +943,23 @@ export default function DashboardPageClient({
range: weightRange,
})}
avg7Hint={getRangeHint(
"kg",
{ range: weightRange },
unitDisplay.unitFor("WEIGHT"),
{ range: weightHintRange },
t,
fmt.number,
)}
avg30Hint={getRangeHint(
"kg",
{ range: weightRange },
unitDisplay.unitFor("WEIGHT"),
{ range: weightHintRange },
t,
fmt.number,
)}
slope30={w?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(w)}
trend7Delta={tdd("WEIGHT", summaryToTrend7Delta(w))}
icon={Activity}
directionSentiment="up-bad"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(w)}
compareDelta={tdd("WEIGHT", tileCompareDelta(w))}
staleDays={tileStaleDays("WEIGHT")}
/>
),
@@ -1276,16 +1311,22 @@ export default function DashboardPageClient({
<TrendCard
key="wristTemperature"
label={t("measurements.typeWristTemperature")}
latest={wristTempSummary?.latest ?? null}
unit="°C"
avg7={wristTempSummary?.avg7 ?? null}
avg30={wristTempSummary?.avg30 ?? null}
latest={td("WRIST_TEMPERATURE", wristTempSummary?.latest)}
unit={unitDisplay.unitFor("WRIST_TEMPERATURE")}
avg7={td("WRIST_TEMPERATURE", wristTempSummary?.avg7)}
avg30={td("WRIST_TEMPERATURE", wristTempSummary?.avg30)}
slope30={wristTempSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(wristTempSummary)}
trend7Delta={tdd(
"WRIST_TEMPERATURE",
summaryToTrend7Delta(wristTempSummary),
)}
icon={Thermometer}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(wristTempSummary)}
compareDelta={tdd(
"WRIST_TEMPERATURE",
tileCompareDelta(wristTempSummary),
)}
staleDays={tileStaleDays("WRIST_TEMPERATURE")}
/>
),
@@ -1302,16 +1343,22 @@ export default function DashboardPageClient({
<TrendCard
key="muscleMass"
label={t("measurements.typeMuscleMass")}
latest={muscleMassSummary?.latest ?? null}
unit="kg"
avg7={muscleMassSummary?.avg7 ?? null}
avg30={muscleMassSummary?.avg30 ?? null}
latest={td("MUSCLE_MASS", muscleMassSummary?.latest)}
unit={unitDisplay.unitFor("MUSCLE_MASS")}
avg7={td("MUSCLE_MASS", muscleMassSummary?.avg7)}
avg30={td("MUSCLE_MASS", muscleMassSummary?.avg30)}
slope30={muscleMassSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(muscleMassSummary)}
trend7Delta={tdd(
"MUSCLE_MASS",
summaryToTrend7Delta(muscleMassSummary),
)}
icon={Dumbbell}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(muscleMassSummary)}
compareDelta={tdd(
"MUSCLE_MASS",
tileCompareDelta(muscleMassSummary),
)}
staleDays={tileStaleDays("MUSCLE_MASS")}
/>
),
@@ -1325,16 +1372,22 @@ export default function DashboardPageClient({
<TrendCard
key="totalBodyWater"
label={t("measurements.typeTotalBodyWater")}
latest={bodyWaterSummary?.latest ?? null}
unit="kg"
avg7={bodyWaterSummary?.avg7 ?? null}
avg30={bodyWaterSummary?.avg30 ?? null}
latest={td("TOTAL_BODY_WATER", bodyWaterSummary?.latest)}
unit={unitDisplay.unitFor("TOTAL_BODY_WATER")}
avg7={td("TOTAL_BODY_WATER", bodyWaterSummary?.avg7)}
avg30={td("TOTAL_BODY_WATER", bodyWaterSummary?.avg30)}
slope30={bodyWaterSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(bodyWaterSummary)}
trend7Delta={tdd(
"TOTAL_BODY_WATER",
summaryToTrend7Delta(bodyWaterSummary),
)}
icon={Droplet}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(bodyWaterSummary)}
compareDelta={tdd(
"TOTAL_BODY_WATER",
tileCompareDelta(bodyWaterSummary),
)}
staleDays={tileStaleDays("TOTAL_BODY_WATER")}
/>
),
@@ -1348,16 +1401,22 @@ export default function DashboardPageClient({
<TrendCard
key="boneMass"
label={t("measurements.typeBoneMass")}
latest={boneMassSummary?.latest ?? null}
unit="kg"
avg7={boneMassSummary?.avg7 ?? null}
avg30={boneMassSummary?.avg30 ?? null}
latest={td("BONE_MASS", boneMassSummary?.latest)}
unit={unitDisplay.unitFor("BONE_MASS")}
avg7={td("BONE_MASS", boneMassSummary?.avg7)}
avg30={td("BONE_MASS", boneMassSummary?.avg30)}
slope30={boneMassSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(boneMassSummary)}
trend7Delta={tdd(
"BONE_MASS",
summaryToTrend7Delta(boneMassSummary),
)}
icon={Bone}
directionSentiment="neutral"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(boneMassSummary)}
compareDelta={tdd(
"BONE_MASS",
tileCompareDelta(boneMassSummary),
)}
staleDays={tileStaleDays("BONE_MASS")}
/>
),
@@ -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}
/>
),
});
+14 -4
View File
@@ -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
@@ -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";
@@ -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,
+33 -6
View File
@@ -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<Record<CyclePhaseCrosstabDisplay, string>> = {
kg: "WEIGHT",
celsius: "BODY_TEMPERATURE",
};
const HEADLINE_KEY: Record<string, string> = {
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 (
<p
@@ -108,8 +122,9 @@ export function CyclePhaseHeadline({
);
// Defensive: an unmapped display must never pass undefined to `t` (whose
// resolver does `key.split(".")`). An empty unit is the honest degrade.
const mt = DISPLAY_TO_TYPE[headline.display];
const unitKey = UNIT_KEY[headline.display];
const unit = unitKey ? t(unitKey) : "";
const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : "";
const up = headline.delta >= 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 (
<li
key={row.metricKey}
@@ -197,8 +224,8 @@ export function CyclePhaseCrosstab({
<p className="text-muted-foreground text-xs">
{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,
@@ -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,
@@ -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,
});
})();
@@ -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<string, string> = {
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<VitalsBaselineValue>(
@@ -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 (
<div data-slot="vitals-tile" data-metric={tileId} data-state="ok">
<SparklineDeltaTile
label={label}
value={v.center}
value={toShown(v.center)}
unit={unit}
icon={Icon}
series={v.series}
series={displaySeries}
framing={framing}
directionSentiment={vitalSentiment(type)}
provenance={
+24 -8
View File
@@ -3,6 +3,7 @@
import type { ComponentType } from "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 { resolveIntlLocale } from "@/lib/format-locale";
@@ -75,6 +76,16 @@ export function DeviceScoreTile({
}: DeviceScoreTileProps) {
const { t, locale } = useTranslations();
const { user } = useAuth();
// v1.32.26 — a type with a registered metric/imperial transform resolves its
// display unit + conversion from the user's preference (e.g. the Oura body-
// temperature DEVIATION renders in Δ°F for an imperial user). The affine
// `toDisplay` is correct for the readout: the deviation transform carries no
// offset (a Δ°C → Δ°F is factor-only), so no absolute shift leaks in.
const unitDisplay = useUnitDisplay();
const transform = unitDisplay.isTransformed(type)
? unitDisplay.transformFor(type)
: null;
const resolvedUnit = transform ? transform.displayUnit : unit;
const count = summary?.count ?? 0;
if (count === 0) return null;
@@ -84,10 +95,13 @@ export function DeviceScoreTile({
const isLearning = count < learningThreshold;
const fmt = (value: number) =>
value.toLocaleString(resolveIntlLocale(locale), {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
(transform ? unitDisplay.toDisplay(type, value) : value).toLocaleString(
resolveIntlLocale(locale),
{
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
},
);
return (
<Card
@@ -112,9 +126,9 @@ export function DeviceScoreTile({
className="text-foreground text-lg font-semibold tabular-nums"
>
{fmt(latest)}
{unit ? (
{resolvedUnit ? (
<span className="text-muted-foreground ml-1 text-xs font-normal">
{unit}
{resolvedUnit}
</span>
) : null}
</span>
@@ -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}
/>
</div>
@@ -147,7 +163,7 @@ export function DeviceScoreTile({
className="text-muted-foreground text-xs"
>
{t("insights.deviceScore.average", { value: fmt(mean) })}
{unit ? ` ${unit}` : ""}
{resolvedUnit ? ` ${resolvedUnit}` : ""}
</p>
) : null}
</>
+10 -4
View File
@@ -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<Glp1PlateauResponse>("/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() {
<p className="text-muted-foreground text-sm">
{t("medications.efficacy.plateau.evidence", {
delta,
unit,
window: data.windowDays,
readings: plateau.readingsCount,
days: plateau.daysOnDose,
@@ -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={
<MetricStatStrip
summary={summary}
unit={yAxisUnit ?? unit}
fractionDigits={statFractionDigits}
unit={resolvedYAxisUnit ?? resolvedUnit ?? ""}
fractionDigits={resolvedFractionDigits}
seriesLabel={title}
icon={statIcon}
windowStats={statsByType?.[effectiveType] ?? null}
@@ -364,9 +409,9 @@ export function HealthKitMetricPage({
coachReadStrip={
<CoachReadStrip
metricType={effectiveType}
unit={yAxisUnit ?? unit}
fractionDigits={statFractionDigits}
valueScale={valueScale}
unit={resolvedYAxisUnit ?? resolvedUnit ?? ""}
fractionDigits={resolvedFractionDigits}
valueScale={resolvedScale}
/>
}
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 ? (
@@ -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 (
<li
key={`${row.metricKey}:${row.factor}`}
@@ -153,8 +164,8 @@ export function MoodFactorMetricCrosstab({
{
factor: factorLabel,
metric: metricLabel,
lowAvg: fmt(row.lowAvg, row.display),
highAvg: fmt(row.highAvg, row.display),
lowAvg: fmt(conv(row.lowAvg), row.display),
highAvg: fmt(conv(row.highAvg), row.display),
unit,
lowDays: row.lowDays,
},
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.32.26 (issue #627) — the manual entry form labels its value field in the
* user's preferred unit for a type with a metric/imperial transform. A metric
* user sees "kg"; an imperial user sees "lb". The field is the entry boundary
* that converts the typed number back to canonical SI on submit, so the label
* and the stored unit must agree with the preference.
*/
let preference: "metric" | "imperial" = "metric";
vi.mock("@/hooks/use-auth", () => ({
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(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="WEIGHT" />
</I18nProvider>,
);
}
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"');
});
});
@@ -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({
<FieldGroup
htmlFor="value"
label={t("measurements.valueWithUnit", {
// For a type with a metric/imperial transform the label follows the
// user's preference (kg↔lb, cm↔in, °C↔°F); everything else keeps
// its static catalogue unit.
unit: typeInfo
? "unitKey" in typeInfo
? t(typeInfo.unitKey)
: typeInfo.unit
? unitDisplay.isTransformed(type)
? unitDisplay.unitFor(type)
: "unitKey" in typeInfo
? t(typeInfo.unitKey)
: typeInfo.unit
: "",
})}
>
@@ -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
@@ -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 && (
<span className="text-muted-foreground ml-2 text-xs font-normal">
{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}
</>
)}
</span>
@@ -1423,7 +1470,9 @@ export function MeasurementList({
<div className="space-y-2">
<Label htmlFor="edit-value">
{t("measurements.valueWithUnit", { unit: editing.unit })}
{t("measurements.valueWithUnit", {
unit: rowDisplay(editing).unit,
})}
</Label>
<Input
id="edit-value"
+71
View File
@@ -0,0 +1,71 @@
"use client";
/**
* v1.32.26 — the single client choke point for metric/imperial display.
*
* Reads the user's `unitPreference` from the cached `/api/auth/me` payload
* (`useAuth`, keyed under `queryKeys.authMe()`, invalidated by the settings
* select on change — no extra fetch) and hands back preference-bound sugar
* over `getDisplayTransform`. Every render surface that shows a measurement
* value or unit for a type in `TRANSFORMS` resolves through this hook, so the
* preference is honoured in exactly one place and no surface hardcodes a unit
* string or scale.
*
* The underlying `display-transform.ts` module stays pure and server-safe (it
* is imported by an API route); only this thin wrapper is a client hook.
*/
import { useMemo } from "react";
import { useAuth } from "@/hooks/use-auth";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getDisplayTransform,
invertDisplayTransform,
hasDisplayTransform,
type DisplayTransform,
type UnitPreference,
} from "@/lib/measurements/display-transform";
export interface UnitDisplay {
/** Resolved preference ("metric" by default / on a stale payload). */
preference: UnitPreference;
/** The full transform for a type under the current preference. */
transformFor: (type: string) => 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<UnitDisplay>(() => {
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]);
}
+7 -44
View File
@@ -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;
@@ -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", () => {
+46
View File
@@ -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",
+29 -9
View File
@@ -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 };
}
+52 -4
View File
@@ -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<McpMeasurementResult> {
// 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,
@@ -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();
}
});
});
@@ -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();
});
});
+117 -23
View File
@@ -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<UnitPreference, DisplayTransform> = {
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<UnitPreference, DisplayTransform> = {
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<MeasurementType, Record<UnitPreference, DisplayTransform>>
> = {
// ── 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<string> = 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;
}
+114
View File
@@ -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<string> = 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<string> = new Set([
"BODY_TEMPERATURE",
"SKIN_TEMPERATURE",
"WRIST_TEMPERATURE",
]);
const LB_ALIASES: ReadonlySet<string> = new Set([
"lb",
"lbs",
"pound",
"pounds",
]);
const IN_ALIASES: ReadonlySet<string> = new Set(["in", "inch", "inches", '"']);
const FAHRENHEIT_ALIASES: ReadonlySet<string> = new Set([
"f",
"°f",
"fahrenheit",
]);
const CELSIUS_ALIASES: ReadonlySet<string> = new Set(["c", "°c", "celsius"]);
const MMOL_ALIASES: ReadonlySet<string> = 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;
}