From dd7213db5ce84e1bbfb6a3d773e20db18f8e61e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 23:58:45 +0200 Subject: [PATCH 1/7] fix(measurements): honor the unit preference at entry and in the record list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the affine display-transform registry (kg↔lb, cm↔in, °C↔°F with the temperature offset carried as an absolute-only shift), a single client hook that reads the preference, and a shared inbound unit-alias resolver. Wire the manual entry form to label, prefill, and convert at the boundary so an imperial reading persists in canonical SI, and route the record list value cells and edit dialog through the same choke point. Close the one write path that could persist a non-canonical unit: the MCP log_measurement tool now resolves a unit hint to canonical or refuses it, reusing the resolver the CSV importer already relied on. --- .../measurements/measurement-form.tsx | 46 +++++- .../measurements/measurement-list.tsx | 63 +++++++- src/hooks/use-unit-display.ts | 71 +++++++++ src/lib/import/csv-measurements.ts | 51 +------ src/lib/mcp/write-tools.ts | 38 +++-- src/lib/mcp/writes.ts | 56 ++++++- src/lib/measurements/display-transform.ts | 140 +++++++++++++++--- src/lib/measurements/unit-aliases.ts | 114 ++++++++++++++ 8 files changed, 485 insertions(+), 94 deletions(-) create mode 100644 src/hooks/use-unit-display.ts create mode 100644 src/lib/measurements/unit-aliases.ts diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 978a8e274..631044d53 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -24,6 +24,7 @@ import { import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { invalidateKeys, measurementDependentKeys, @@ -50,6 +51,7 @@ const MEASUREMENT_TYPES = [ labelKey: "measurements.typeWeight", unit: "kg", placeholder: "75.5", + placeholderImperial: "165", }, { value: "PULSE", @@ -86,12 +88,14 @@ const MEASUREMENT_TYPES = [ labelKey: "measurements.typeTotalBodyWater", unit: "kg", placeholder: "42", + placeholderImperial: "93", }, { value: "BONE_MASS", labelKey: "measurements.typeBoneMass", unit: "kg", placeholder: "3.2", + placeholderImperial: "7", }, { value: "OXYGEN_SATURATION", @@ -104,6 +108,7 @@ const MEASUREMENT_TYPES = [ labelKey: "measurements.typeBodyTemperature", unit: "°C", placeholder: "36.6", + placeholderImperial: "97.9", }, // v1.25 — physical / clinical signals. Manual web capture so a web-only // self-hoster can log them (the iOS client + measurements API already do). @@ -112,6 +117,7 @@ const MEASUREMENT_TYPES = [ labelKey: "measurements.typeWaistCircumference", unit: "cm", placeholder: "84", + placeholderImperial: "33", }, { value: "WAIST_TO_HEIGHT", @@ -124,6 +130,7 @@ const MEASUREMENT_TYPES = [ labelKey: "measurements.typeGripStrength", unit: "kg", placeholder: "38", + placeholderImperial: "84", }, { value: "PAIN_NRS", @@ -232,6 +239,7 @@ export function MeasurementForm({ }: MeasurementFormProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); + const unitDisplay = useUnitDisplay(); // Normalize legacy BP types to combined mode const normalizedDefault = @@ -327,10 +335,24 @@ export function MeasurementForm({ await apiPost("/api/measurements", batch); } else { - // Single measurement + // Single measurement. Canonical storage stays SI: an imperial user + // types in lb / in / °F and we invert to the canonical unit at the + // entry boundary. A metric user (or an untransformed type) takes the + // identity path — `fromDisplay` returns the typed number unchanged, so + // the payload is byte-identical to before. Round only the converted + // imperial value so 210 lb stores as 95.25 kg, not 95.25439770…. + const typed = parseFloat(value); + let canonicalValue = typed; + if (unitDisplay.isTransformed(type)) { + const inverted = unitDisplay.fromDisplay(type, typed); + canonicalValue = + unitDisplay.preference === "imperial" + ? Math.round(inverted * 100) / 100 + : inverted; + } await apiPost("/api/measurements", { type, - value: parseFloat(value), + value: canonicalValue, measuredAt: timestamp, notes: notes || undefined, ...(isGlucoseMode ? { glucoseContext } : {}), @@ -483,10 +505,15 @@ export function MeasurementForm({ @@ -498,8 +525,13 @@ export function MeasurementForm({ value={value} onChange={(e) => setValue(e.target.value)} placeholder={ - typeInfo && "placeholder" in typeInfo - ? typeInfo.placeholder + typeInfo + ? unitDisplay.preference === "imperial" && + "placeholderImperial" in typeInfo + ? typeInfo.placeholderImperial + : "placeholder" in typeInfo + ? typeInfo.placeholder + : undefined : undefined } required diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index a3ebc38c0..1a4f4f22d 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -66,6 +66,8 @@ import { formatDateOrRelative, formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"; import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform"; +import { getUnitForType } from "@/lib/validations/measurement"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { invalidateKeys, measurementDependentKeys, @@ -282,6 +284,24 @@ export function MeasurementList({ const { t } = useTranslations(); const fmt = useFormatters(); const { isAuthenticated } = useAuth(); + const unitDisplay = useUnitDisplay(); + // v1.32.26 — a single-reading row renders its value + unit in the user's + // preferred unit (kg↔lb, cm↔in, °C↔°F). A canonical row converts through the + // transform; a legacy non-canonical row (an old MCP write whose `unit` ≠ the + // type's canonical unit) is shown verbatim so its stored unit is never + // silently relabelled. Metric users take the identity path — unchanged. + const rowDisplay = useCallback( + (m: { type: string; value: number; unit: string }) => { + if (m.unit !== getUnitForType(m.type)) { + return { value: m.value, unit: m.unit }; + } + return { + value: unitDisplay.toDisplay(m.type, m.value), + unit: unitDisplay.unitFor(m.type), + }; + }, + [unitDisplay], + ); // v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH // render (only CSS `display` hid one), so a dense cumulative (up to ~5000 // rows) or sleep page built every row subtree twice — double the DOM, double @@ -710,8 +730,20 @@ export function MeasurementList({ return; } + // Seed the input in the user's preferred unit so an imperial user edits in + // lb / in / °F (converted back to canonical on save). Metric users and + // legacy non-canonical rows take the identity path — the seed is the raw + // stored value, unchanged. Round the converted imperial seed so the input + // shows "210" rather than a long float. + const rd = rowDisplay(measurement); + const seedValue = + unitDisplay.preference === "imperial" && + unitDisplay.isTransformed(measurement.type) && + measurement.unit === getUnitForType(measurement.type) + ? Math.round(rd.value * 100) / 100 + : measurement.value; const seed = { - value: String(measurement.value), + value: String(seedValue), measuredAt: toDateTimeLocalValue(measurement.measuredAt), notes: (measurement.notes ?? "").slice(0, MAX_COMMENT_LENGTH), }; @@ -761,10 +793,25 @@ export function MeasurementList({ return; } + // The input holds a display-unit value; invert it to canonical SI before + // the PATCH so an imperial edit persists in the stored unit. Metric users + // and legacy non-canonical rows are the identity path — value unchanged. + let canonicalValue = parsedValue; + if ( + unitDisplay.isTransformed(editing.type) && + editing.unit === getUnitForType(editing.type) + ) { + const inverted = unitDisplay.fromDisplay(editing.type, parsedValue); + canonicalValue = + unitDisplay.preference === "imperial" + ? Math.round(inverted * 100) / 100 + : inverted; + } + setEditError(null); updateMutation.mutate({ id: editing.id, - value: parsedValue, + value: canonicalValue, measuredAt: measuredDate.toISOString(), notes: editNotes.trim() ? editNotes.trim() : null, }); @@ -1050,10 +1097,10 @@ export function MeasurementList({ {isGrouped ? fmt.integer(m.value) : fmt.number( - m.value, + rowDisplay(m).value, rawDisplayFractionDigits(m.type), )}{" "} - {m.unit} + {isGrouped ? m.unit : rowDisplay(m).unit} {isGrouped && ( {t("measurements.dailyTotalCaption", { @@ -1253,10 +1300,10 @@ export function MeasurementList({ {isGrouped ? fmt.integer(m.value) : fmt.number( - m.value, + rowDisplay(m).value, rawDisplayFractionDigits(m.type), )}{" "} - {m.unit} + {isGrouped ? m.unit : rowDisplay(m).unit} )} @@ -1423,7 +1470,9 @@ export function MeasurementList({
DisplayTransform; + /** Convert a raw canonical ABSOLUTE value to the display value. */ + toDisplay: (type: string, rawValue: number) => number; + /** Convert a display ABSOLUTE value back to canonical (entry boundary). */ + fromDisplay: (type: string, displayValue: number) => number; + /** Convert a DELTA / slope / SD — factor only, never the affine offset. */ + toDisplayDelta: (type: string, deltaValue: number) => number; + /** The display unit symbol for a type under the current preference. */ + unitFor: (type: string) => string; + /** Display decimal places for a type under the current preference. */ + decimalsFor: (type: string) => number; + /** True when a type actually rescales under preference (vs identity). */ + isTransformed: (type: string) => boolean; +} + +export function useUnitDisplay(): UnitDisplay { + const { user } = useAuth(); + const preference: UnitPreference = + user?.unitPreference === "imperial" ? "imperial" : "metric"; + + return useMemo(() => { + const transformFor = (type: string) => + getDisplayTransform(type, preference); + return { + preference, + transformFor, + toDisplay: (type, rawValue) => + applyDisplayTransform(rawValue, transformFor(type)), + fromDisplay: (type, displayValue) => + invertDisplayTransform(displayValue, transformFor(type)), + toDisplayDelta: (type, deltaValue) => + applyDisplayTransformDelta(deltaValue, transformFor(type)), + unitFor: (type) => transformFor(type).displayUnit, + decimalsFor: (type) => transformFor(type).decimals, + isTransformed: (type) => hasDisplayTransform(type), + }; + }, [preference]); +} diff --git a/src/lib/import/csv-measurements.ts b/src/lib/import/csv-measurements.ts index e5fc82601..3b07b902a 100644 --- a/src/lib/import/csv-measurements.ts +++ b/src/lib/import/csv-measurements.ts @@ -18,11 +18,11 @@ * * - `type` — a `measurementTypeEnum` value (e.g. WEIGHT). Unknown → skipped. * - `value` — a number in the row's `unit`. - * - `unit` — the canonical unit for the type, OR a recognised alias: - * glucose mmol/L → mg/dL (× 18.016), - * weight lb → kg (× 0.453592). - * Any other non-canonical unit → skipped (never silently - * mis-stored). + * - `unit` — the canonical unit for the type, OR a recognised alias + * (glucose mmol/L → mg/dL, mass lb → kg, waist in → cm, + * temperature °F → °C) via the shared + * `resolveToCanonicalUnit` resolver. Any other + * non-canonical unit → skipped (never silently mis-stored). * - `measuredAt` — ISO-8601 WITH an explicit offset (`Z` or ±HH:MM). A row * without an offset is skipped — the importer never guesses * a timezone. The instant is bounded by `validateEntryInstant` @@ -36,15 +36,11 @@ import { measurementTypeEnum, glucoseContextEnum, - getUnitForType, validateMeasurementRange, MEASUREMENT_NOTES_MAX_LENGTH, } from "@/lib/validations/measurement"; import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; - -/** Canonical conversion factors keyed by `${type}:${loweredAlias}`. */ -const GLUCOSE_MMOL_TO_MGDL = 18.016; -const LB_TO_KG = 0.453592; +import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases"; /** A normalised, write-ready row in the `/api/import` measurement shape. */ export interface NormalisedMeasurementRow { @@ -165,39 +161,6 @@ function hasExplicitOffset(value: string): boolean { return /([zZ]|[+-]\d{2}:?\d{2})$/.test(value.trim()); } -/** - * Resolve the row's unit to the canonical unit + converted value, or return - * `null` when the unit is neither canonical nor a recognised alias. - */ -function convertToCanonicalUnit( - type: string, - value: number, - rawUnit: string, -): { value: number; unit: string } | null { - const canonical = getUnitForType(type); - const unit = rawUnit.trim(); - // Case-insensitive match against the canonical unit (mg/dL vs MG/DL). - if (unit.toLowerCase() === canonical.toLowerCase()) { - return { value, unit: canonical }; - } - const lower = unit.toLowerCase(); - // Glucose: mmol/L → mg/dL. - if (type === "BLOOD_GLUCOSE" && (lower === "mmol/l" || lower === "mmol")) { - return { value: value * GLUCOSE_MMOL_TO_MGDL, unit: canonical }; - } - // Weight: lb → kg. - if ( - type === "WEIGHT" && - (lower === "lb" || - lower === "lbs" || - lower === "pound" || - lower === "pounds") - ) { - return { value: value * LB_TO_KG, unit: canonical }; - } - return null; -} - const KNOWN_TYPES = new Set(measurementTypeEnum.options as readonly string[]); const KNOWN_GLUCOSE_CONTEXTS = new Set( glucoseContextEnum.options as readonly string[], @@ -284,7 +247,7 @@ export function parseCsvMeasurements( continue; } - const converted = convertToCanonicalUnit(type, value, rawUnit); + const converted = resolveToCanonicalUnit(type, value, rawUnit); if (converted === null) { skip("unknown_unit"); continue; diff --git a/src/lib/mcp/write-tools.ts b/src/lib/mcp/write-tools.ts index cbf644600..76e1e49c0 100644 --- a/src/lib/mcp/write-tools.ts +++ b/src/lib/mcp/write-tools.ts @@ -58,6 +58,7 @@ import { logMcpBloodPressure, checkMcpMeasurement, checkMcpBloodPressure, + resolveMcpMeasurementUnit, } from "./writes"; /** @@ -184,7 +185,7 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ .max(20) .optional() .describe( - "Optional unit hint; the server uses the type's canonical unit when omitted.", + "Optional unit hint. Omit to use the type's canonical unit. A recognised alias (lb/lbs/pound(s), in/inch(es), °F, mmol/L) is converted to canonical; any other unit is refused with unsupported_unit — the value is never stored in a non-canonical unit.", ), measuredAt: z.iso .datetime({ offset: true }) @@ -214,27 +215,43 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ } if (!confirm) { - // Run the SAME validation the commit core runs so the preview cannot - // show a value that would then be refused on commit. - const check = checkMcpMeasurement(type, value, measuredAt); + // Run the SAME normalisation + validation the commit core runs so the + // preview cannot show a value that would then be refused on commit. The + // unit hint resolves to canonical (converting the value) or refuses; + // the preview echoes the canonical value the commit would store. + const unitResolution = resolveMcpMeasurementUnit(type, value, unit); + const previewValue = unitResolution.ok ? unitResolution.value : value; + const previewUnit = unitResolution.ok + ? unitResolution.unit + : (unit ?? null); + const check = unitResolution.ok + ? checkMcpMeasurement(type, previewValue, measuredAt) + : null; annotate({ action: { name: "mcp.tool.write" }, meta: { tool: "log_measurement", status: "preview" }, }); + const wouldFail = !unitResolution.ok + ? { wouldFail: true as const, error: "unsupported_unit" as const } + : check && !check.ok + ? { + wouldFail: true as const, + error: check.error, + reason: check.reason, + } + : {}; return { requiresConfirmation: true, written: false, preview: { type, - value, - unit: unit ?? null, + value: previewValue, + unit: previewUnit, measuredAt: measuredAt ? measuredAt.toISOString() : "now", source: "MCP", }, instruction: CONFIRM_INSTRUCTION, - ...(check.ok - ? {} - : { wouldFail: true, error: check.error, reason: check.reason }), + ...wouldFail, }; } @@ -254,6 +271,9 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ if (result.status === "unsupported_type") { return { written: false, error: "unsupported_type" }; } + if (result.status === "unsupported_unit") { + return { written: false, error: "unsupported_unit" }; + } if (result.status === "out_of_range") { return { written: false, error: "out_of_range", reason: result.reason }; } diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index daa79b28e..0db69f8e2 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -26,6 +26,7 @@ import { getUnitForType, validateMeasurementRange, } from "@/lib/validations/measurement"; +import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases"; import { isPlausibleEntryInstant, ENTRY_INSTANT_CLOCK_SKEW_MS, @@ -71,6 +72,34 @@ export type McpWriteCheck = | { ok: true } | { ok: false; error: "unsupported_type" | "out_of_range"; reason?: string }; +/** + * Resolve a caller-supplied `unit` hint to the canonical unit + converted + * value, or refuse it. Canonical storage is an invariant: an omitted hint + * stamps the type's canonical unit; a recognised alias (lb/lbs/pound(s), in, + * °F, mmol/L) converts the value and stamps canonical; anything else is + * refused so a non-canonical unit is never persisted verbatim (the pre-fix + * leak — a lb value tagged "kg" corrupts every display surface that trusts + * the row's `unit` column). Shared by the confirm-gate preview and the commit + * core so both reach one verdict for the same input. + */ +export type McpUnitResolution = + { ok: true; value: number; unit: string } | { ok: false }; + +export function resolveMcpMeasurementUnit( + type: MeasurementType, + value: number, + unit: string | undefined, +): McpUnitResolution { + if (unit === undefined) { + return { ok: true, value, unit: getUnitForType(type) }; + } + const resolved = resolveToCanonicalUnit(type, value, unit); + if (resolved === null) { + return { ok: false }; + } + return { ok: true, value: resolved.value, unit: resolved.unit }; +} + /** * Bound a client-supplied `measuredAt` exactly like the manual measurement * route (`validateEntryInstant`): reject a future instant beyond the 5-min @@ -156,6 +185,7 @@ export interface McpMeasurementRecord { export type McpMeasurementResult = | { status: "unsupported_type" } + | { status: "unsupported_unit" } | { status: "out_of_range"; reason: string } | { status: "written"; measurement: McpMeasurementRecord } | { status: "already_logged"; measurement: McpMeasurementRecord }; @@ -176,9 +206,28 @@ export async function logMcpMeasurement(input: { measuredAt?: Date; idempotencyKey: string; }): Promise { + // Normalise the caller's unit hint to canonical (converting the value) or + // refuse it — a non-canonical unit must never be persisted verbatim. Runs + // BEFORE the range check so the plausibility gate sees the canonical value + // (e.g. 210 lb → 95.25 kg passes WEIGHT {1..500}). + const unitResolution = resolveMcpMeasurementUnit( + input.type, + input.value, + input.unit, + ); + if (!unitResolution.ok) { + annotate({ + action: { name: "mcp.tool.write" }, + meta: { tool: "log_measurement", status: "unsupported_unit" }, + }); + return { status: "unsupported_unit" }; + } + const value = unitResolution.value; + const unit = unitResolution.unit; + // Same validation the confirm-gate preview runs (type allowlist, range, and // the measuredAt instant bound) — preview and commit reach one verdict. - const check = checkMcpMeasurement(input.type, input.value, input.measuredAt); + const check = checkMcpMeasurement(input.type, value, input.measuredAt); if (!check.ok) { annotate({ action: { name: "mcp.tool.write" }, @@ -193,13 +242,12 @@ export async function logMcpMeasurement(input: { }; } - const unit = input.unit ?? getUnitForType(input.type); const measuredAt = input.measuredAt ?? new Date(); const externalId = mcpExternalId("measure", input.idempotencyKey); const record: McpMeasurementRecord = { type: input.type, - value: input.value, + value, unit, measuredAt: measuredAt.toISOString(), source: MCP_SOURCE, @@ -238,7 +286,7 @@ export async function logMcpMeasurement(input: { data: { userId: input.userId, type: input.type, - value: input.value, + value, unit, source: MCP_SOURCE, measuredAt, diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 1bc059952..98cd5326a 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -3,8 +3,12 @@ * * Canonical storage stays SI (the iOS wire contract + the Withings * passthrough both lock raw SI — see the convention block in - * `apple-health-mapping.ts`). A handful of metrics read better in a - * derived unit at the display boundary only: + * `apple-health-mapping.ts`). Storage never changes; the metric/imperial + * user preference (`unitPreference`) only selects which branch a + * transform exposes at the render boundary. + * + * A handful of metrics also read better in a derived unit regardless of + * preference: * * - WALKING_SPEED is stored in m/s but a casual gait of 1.3 m/s * reads as "4.7 km/h" to a human. @@ -12,16 +16,20 @@ * reads as "5000 m"; daily totals make more sense in km. * * The transform is applied at the render layer only (chart `valueScale` - * + the measurement-list cell + tooltip). Raw rows, plausibility - * ranges, rollup math, and the AI snapshot all keep canonical SI. A - * `factor` of 1 is the identity transform and is what every other - * type resolves to, so callers that pass an un-transformed value see - * no change (charts stay visually identical). + * + the measurement-list cell + tooltip + entry form). Raw rows, + * plausibility ranges, rollup math, FHIR/CSV export, the doctor report, + * and the AI snapshot all keep canonical SI. For a metric user every + * transform below is the identity on the number (factor 1, offset 0), so + * the metric render is byte-identical to before — charts stay visually + * identical. The imperial branch rescales; that is the intended + * behaviour, not a regression. * - * The metric/imperial user preference (`unitPreference`) selects which - * branch a transform exposes. The default is metric; the imperial - * branch is additive and only wired for the two distance/speed metrics - * so far — every other type ignores the preference entirely. + * v1.32.26 — the registry now covers the full weight-class / length / + * temperature set the display surfaces render, and the interface gained + * an affine `offset` so °C→°F (a shift, not just a scale) can be + * expressed. The offset applies to ABSOLUTE values only; deltas, slopes, + * standard deviations, and band widths take the factor alone — use + * `applyDisplayTransformDelta` for those. */ import type { MeasurementType } from "@/generated/prisma/client"; @@ -34,6 +42,13 @@ export const DEFAULT_UNIT_PREFERENCE: UnitPreference = "metric"; export interface DisplayTransform { /** Multiplier applied to the raw canonical value for display. */ factor: number; + /** + * Additive shift applied AFTER the factor for an absolute display + * value (`display = raw * factor + offset`). Defaults to 0. Present + * only for affine conversions (°C→°F). Deltas/slopes/SD must NOT use + * it — see `applyDisplayTransformDelta`. + */ + offset?: number; /** Unit string shown next to the displayed value. */ displayUnit: string; /** Decimal places for the displayed value. */ @@ -47,20 +62,60 @@ const IDENTITY: DisplayTransform = { decimals: 1, }; +// kg → lb (exact: 1 lb = 0.45359237 kg → 1 kg = 2.20462262185 lb). +const KG_TO_LB = 2.20462262185; +// cm → in (exact: 1 in = 2.54 cm → 1 cm = 0.393700787402 in). +const CM_TO_IN = 0.393700787402; + +/** Shared metric/imperial branch pair for a body-mass metric (kg ↔ lb). */ +const MASS_TRANSFORM: Record = { + metric: { factor: 1, displayUnit: "kg", decimals: 1 }, + imperial: { factor: KG_TO_LB, displayUnit: "lb", decimals: 1 }, +}; + +/** Shared branch pair for an ABSOLUTE temperature (°C ↔ °F, affine). */ +const TEMPERATURE_TRANSFORM: Record = { + metric: { factor: 1, displayUnit: "°C", decimals: 1 }, + imperial: { factor: 1.8, offset: 32, displayUnit: "°F", decimals: 1 }, +}; + /** - * Per-type, per-preference transforms. Only types that genuinely read - * better in a derived unit appear here; everything else resolves to - * the identity transform with the canonical unit from - * `getUnitForType()`. + * Per-type, per-preference transforms. Types absent here resolve to the + * identity transform with the canonical unit from `getUnitForType()`. * - * Metric branch ships in v1.7.0. The imperial branch is additive: it - * covers the same two metrics in mph / miles so the global - * metric/imperial toggle has somewhere to land without a follow-up - * migration. + * The metric branch of every entry below is the identity on the number + * (factor 1, no offset), differing from `getUnitForType` only in the + * DISPLAY symbol it surfaces (e.g. "°C" for the stored "celsius"). The + * imperial branch carries the real conversion. */ const TRANSFORMS: Partial< Record> > = { + // ── Body-mass metrics (kg canonical → lb imperial) ── + WEIGHT: MASS_TRANSFORM, + TOTAL_BODY_WATER: MASS_TRANSFORM, + BONE_MASS: MASS_TRANSFORM, + FAT_MASS: MASS_TRANSFORM, + FAT_FREE_MASS: MASS_TRANSFORM, + MUSCLE_MASS: MASS_TRANSFORM, + LEAN_BODY_MASS: MASS_TRANSFORM, + GRIP_STRENGTH: MASS_TRANSFORM, + // ── Circumference (cm canonical → in imperial) ── + WAIST_CIRCUMFERENCE: { + metric: { factor: 1, displayUnit: "cm", decimals: 1 }, + imperial: { factor: CM_TO_IN, displayUnit: "in", decimals: 1 }, + }, + // ── Absolute temperatures (°C canonical → °F imperial, affine) ── + BODY_TEMPERATURE: TEMPERATURE_TRANSFORM, + SKIN_TEMPERATURE: TEMPERATURE_TRANSFORM, + WRIST_TEMPERATURE: TEMPERATURE_TRANSFORM, + // A signed baseline DEVIATION (Δ°C) — the °F imperial branch scales by + // 1.8 but carries NO +32 offset. A one-degree-Celsius deviation is a + // 1.8-degree-Fahrenheit deviation, not a 33.8 °F reading. + BODY_TEMPERATURE_DEVIATION: { + metric: { factor: 1, displayUnit: "°C", decimals: 1 }, + imperial: { factor: 1.8, displayUnit: "°F", decimals: 1 }, + }, // 1 m/s = 3.6 km/h = 2.236936 mph. WALKING_SPEED: { metric: { factor: 3.6, displayUnit: "km/h", decimals: 1 }, @@ -73,6 +128,20 @@ const TRANSFORMS: Partial< }, }; +/** + * Every type with a registered transform. The structural guard test uses + * this set to assert no display surface hardcodes a unit string / scale + * for a type that should route through the transform instead. + */ +export const TRANSFORMED_TYPES: ReadonlySet = new Set( + Object.keys(TRANSFORMS), +); + +/** True when `type` has a per-preference transform (vs the identity path). */ +export function hasDisplayTransform(type: string): boolean { + return TRANSFORMED_TYPES.has(type); +} + /** * Resolve the display transform for a `(type, preference)` pair. * Returns the identity transform (with the canonical unit) for every @@ -117,13 +186,38 @@ export function rawDisplayFractionDigits(type: string): number | undefined { } /** - * Apply a transform's factor to a raw canonical value. Pure helper — - * keeps the multiply in one place so the chart, list cell, and tooltip - * all scale identically. + * Apply a transform to a raw canonical ABSOLUTE value: + * `display = raw * factor + offset`. Pure helper — keeps the arithmetic + * in one place so the chart, list cell, tooltip, and entry form all + * scale identically. */ export function applyDisplayTransform( rawValue: number, transform: DisplayTransform, ): number { - return rawValue * transform.factor; + return rawValue * transform.factor + (transform.offset ?? 0); +} + +/** + * Apply a transform to a DELTA / slope / standard-deviation / band-width + * — factor only, NEVER the offset. A 1 °C change is a 1.8 °F change; it + * must not pick up the +32 absolute-scale shift. + */ +export function applyDisplayTransformDelta( + deltaValue: number, + transform: DisplayTransform, +): number { + return deltaValue * transform.factor; +} + +/** + * Invert an absolute display value back to the canonical stored value: + * `raw = (display - offset) / factor`. Used at the entry boundary so an + * imperial user's typed number persists in canonical SI. + */ +export function invertDisplayTransform( + displayValue: number, + transform: DisplayTransform, +): number { + return (displayValue - (transform.offset ?? 0)) / transform.factor; } diff --git a/src/lib/measurements/unit-aliases.ts b/src/lib/measurements/unit-aliases.ts new file mode 100644 index 000000000..bb43693ed --- /dev/null +++ b/src/lib/measurements/unit-aliases.ts @@ -0,0 +1,114 @@ +/** + * v1.32.26 — shared inbound unit-alias resolver. + * + * Canonical storage is SI (`getUnitForType`). A few write paths accept a + * caller-supplied unit string and must normalise it to the canonical unit + * (converting the value) or refuse it outright — they must NEVER persist a + * non-canonical unit verbatim, or the row's `unit` column stops being an + * invariant and every display surface that trusts it silently mislabels. + * + * This is the single resolver behind both inbound paths that take a unit + * string: the CSV importer (`csv-measurements.ts`) and the MCP + * `log_measurement` tool (`mcp/writes.ts`). It recognises the canonical unit + * (case-insensitively) and a closed set of common aliases (lb/lbs/pound(s), + * in/inch(es), °F, mmol/L); anything else returns `null` so the caller can + * skip / refuse rather than mis-store. + * + * Pure + side-effect-free; safe to import from an API route or a script. + */ +import { getUnitForType } from "@/lib/validations/measurement"; + +/** Glucose mmol/L → mg/dL. */ +const GLUCOSE_MMOL_TO_MGDL = 18.016; +/** Exact pound → kilogram (1 lb = 0.45359237 kg). */ +const LB_TO_KG = 0.45359237; +/** Exact inch → centimetre (1 in = 2.54 cm). */ +const IN_TO_CM = 2.54; + +/** Body-mass metrics stored in kg — a lb value converts, all others skip. */ +const MASS_TYPES: ReadonlySet = new Set([ + "WEIGHT", + "TOTAL_BODY_WATER", + "BONE_MASS", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "GRIP_STRENGTH", +]); + +/** Absolute-temperature metrics stored in °C (canonical string "celsius"). */ +const TEMPERATURE_TYPES: ReadonlySet = new Set([ + "BODY_TEMPERATURE", + "SKIN_TEMPERATURE", + "WRIST_TEMPERATURE", +]); + +const LB_ALIASES: ReadonlySet = new Set([ + "lb", + "lbs", + "pound", + "pounds", +]); +const IN_ALIASES: ReadonlySet = new Set(["in", "inch", "inches", '"']); +const FAHRENHEIT_ALIASES: ReadonlySet = new Set([ + "f", + "°f", + "fahrenheit", +]); +const CELSIUS_ALIASES: ReadonlySet = new Set(["c", "°c", "celsius"]); +const MMOL_ALIASES: ReadonlySet = new Set(["mmol/l", "mmol"]); + +export interface CanonicalUnitResult { + /** The value expressed in the canonical unit. */ + value: number; + /** The canonical unit string (`getUnitForType(type)`). */ + unit: string; +} + +/** + * Resolve `(type, value, rawUnit)` to `{ value, unit }` in the canonical + * unit, or `null` when `rawUnit` is neither the canonical unit nor a + * recognised alias. Never mis-stores: an unknown unit is a hard `null`. + */ +export function resolveToCanonicalUnit( + type: string, + value: number, + rawUnit: string, +): CanonicalUnitResult | null { + const canonical = getUnitForType(type); + const trimmed = rawUnit.trim(); + const lower = trimmed.toLowerCase(); + + // Canonical match (case-insensitive: "mg/dL" vs "MG/DL", "kg" vs "KG"). + if (lower === canonical.toLowerCase()) { + return { value, unit: canonical }; + } + + // Glucose: mmol/L → mg/dL. + if (type === "BLOOD_GLUCOSE" && MMOL_ALIASES.has(lower)) { + return { value: value * GLUCOSE_MMOL_TO_MGDL, unit: canonical }; + } + + // Body mass: lb → kg. + if (MASS_TYPES.has(type) && LB_ALIASES.has(lower)) { + return { value: value * LB_TO_KG, unit: canonical }; + } + + // Waist circumference: in → cm. + if (type === "WAIST_CIRCUMFERENCE" && IN_ALIASES.has(lower)) { + return { value: value * IN_TO_CM, unit: canonical }; + } + + // Absolute temperature: °F → °C (affine), or an explicit °C spelling. + if (TEMPERATURE_TYPES.has(type)) { + if (FAHRENHEIT_ALIASES.has(lower)) { + return { value: (value - 32) / 1.8, unit: canonical }; + } + if (CELSIUS_ALIASES.has(lower)) { + return { value, unit: canonical }; + } + } + + return null; +} From e1ca523e21914b0280b8d468d845ecc55eca4e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 00:10:53 +0200 Subject: [PATCH 2/7] fix(insights): route every weight-class and temperature surface through the unit transform Wire the display transform into the chart scaffold, the chart itself (with an affine offset for the temperature conversion), the dashboard trend tiles and weight chart, the vitals grid, the coach read strip, and the device-score tile. Each surface resolves its unit and scale from the user's preference at a single choke point and stops hardcoding the canonical unit, so an imperial user sees a consistent value, axis, band, tooltip, and range hint while a metric user's render stays byte-identical. Retire the local celsius display shim now that the transform owns the symbol. --- src/app/insights/body-temperature/page.tsx | 2 - src/app/insights/body-water/page.tsx | 2 - src/app/insights/bone-mass/page.tsx | 2 - src/app/insights/fat-free-mass/page.tsx | 2 - src/app/insights/fat-mass/page.tsx | 2 - src/app/insights/grip-strength/page.tsx | 2 - src/app/insights/lean-body-mass/page.tsx | 2 - src/app/insights/muscle-mass/page.tsx | 2 - src/app/insights/skin-temperature/page.tsx | 3 - src/app/insights/waist/page.tsx | 2 - src/app/insights/walking-distance/page.tsx | 4 - src/app/insights/walking-speed/page.tsx | 3 - src/app/insights/weight/page.tsx | 50 ++++++- src/app/insights/wrist-temperature/page.tsx | 2 - src/app/page-client.tsx | 132 +++++++++++++----- src/components/charts/health-chart.tsx | 18 ++- .../insights/derived/coach-read-strip.tsx | 18 ++- .../insights/derived/vitals-dashboard.tsx | 38 +++-- src/components/insights/device-score-tile.tsx | 32 +++-- .../insights/healthkit-metric-page.tsx | 90 +++++++++--- 20 files changed, 279 insertions(+), 129 deletions(-) diff --git a/src/app/insights/body-temperature/page.tsx b/src/app/insights/body-temperature/page.tsx index db572f899..7c4527b5f 100644 --- a/src/app/insights/body-temperature/page.tsx +++ b/src/app/insights/body-temperature/page.tsx @@ -22,8 +22,6 @@ export default function InsightsBodyTemperaturePage() { i18nPrefix="insights.bodyTemperature" explainerMetric="bodyTemperature" color="var(--chart-3)" - unit="°C" - yAxisUnit="°C" emptyStateIcon={} emptyStateCtaType="BODY_TEMPERATURE" coachPrefill="I haven't logged any body temperature yet — what's the healthy range, and what should I do if it drifts?" diff --git a/src/app/insights/body-water/page.tsx b/src/app/insights/body-water/page.tsx index d9563147f..006cb963b 100644 --- a/src/app/insights/body-water/page.tsx +++ b/src/app/insights/body-water/page.tsx @@ -22,8 +22,6 @@ export default function InsightsKoerperwasserPage() { i18nPrefix="insights.totalBodyWater" explainerMetric="bodyWater" color="var(--info)" - unit="kg" - yAxisUnit="kg" emptyStateIcon={} emptyStateCtaType={null} captureType="TOTAL_BODY_WATER" diff --git a/src/app/insights/bone-mass/page.tsx b/src/app/insights/bone-mass/page.tsx index f5759fafb..d5235de3d 100644 --- a/src/app/insights/bone-mass/page.tsx +++ b/src/app/insights/bone-mass/page.tsx @@ -22,8 +22,6 @@ export default function InsightsKnochenmassePage() { i18nPrefix="insights.boneMass" explainerMetric="boneMass" color="var(--warning)" - unit="kg" - yAxisUnit="kg" emptyStateIcon={} emptyStateCtaType={null} captureType="BONE_MASS" diff --git a/src/app/insights/fat-free-mass/page.tsx b/src/app/insights/fat-free-mass/page.tsx index 54875212f..6daf8006e 100644 --- a/src/app/insights/fat-free-mass/page.tsx +++ b/src/app/insights/fat-free-mass/page.tsx @@ -22,8 +22,6 @@ export default function InsightsFettfreieMassePage() { i18nPrefix="insights.fatFreeMass" explainerMetric="fatFreeMass" color="var(--chart-1)" - unit="kg" - yAxisUnit="kg" emptyStateIcon={} emptyStateCtaType={null} coachPrefill="I haven't logged any fat-free mass yet — what does this metric tell me about my health, and how do I improve it?" diff --git a/src/app/insights/fat-mass/page.tsx b/src/app/insights/fat-mass/page.tsx index fa27ad08e..1ae453b10 100644 --- a/src/app/insights/fat-mass/page.tsx +++ b/src/app/insights/fat-mass/page.tsx @@ -22,8 +22,6 @@ export default function InsightsFettmassePage() { i18nPrefix="insights.fatMass" explainerMetric="fatMass" color="var(--warning)" - unit="kg" - yAxisUnit="kg" emptyStateIcon={} emptyStateCtaType={null} coachPrefill="I haven't logged any fat mass yet — what does this metric tell me about my health, and how do I improve it?" diff --git a/src/app/insights/grip-strength/page.tsx b/src/app/insights/grip-strength/page.tsx index ae84730cb..52750739b 100644 --- a/src/app/insights/grip-strength/page.tsx +++ b/src/app/insights/grip-strength/page.tsx @@ -23,8 +23,6 @@ export default function InsightsGripStrengthPage() { chartKey="gripStrength" i18nPrefix="insights.gripStrength" color="var(--success)" - unit="kg" - yAxisUnit="kg" statIcon={Dumbbell} emptyStateIcon={} emptyStateCtaType="GRIP_STRENGTH" diff --git a/src/app/insights/lean-body-mass/page.tsx b/src/app/insights/lean-body-mass/page.tsx index e055eeb9c..e31113b3b 100644 --- a/src/app/insights/lean-body-mass/page.tsx +++ b/src/app/insights/lean-body-mass/page.tsx @@ -22,8 +22,6 @@ export default function InsightsMagermassePage() { i18nPrefix="insights.leanBodyMass" explainerMetric="leanBodyMass" color="var(--chart-1)" - unit="kg" - yAxisUnit="kg" emptyStateIcon={} emptyStateCtaType={null} coachPrefill="I haven't logged any lean body mass yet — what does this metric tell me about my health, and how do I improve it?" diff --git a/src/app/insights/muscle-mass/page.tsx b/src/app/insights/muscle-mass/page.tsx index bf6fb6500..a4dc3c786 100644 --- a/src/app/insights/muscle-mass/page.tsx +++ b/src/app/insights/muscle-mass/page.tsx @@ -22,8 +22,6 @@ export default function InsightsMuskelmassePage() { i18nPrefix="insights.muscleMass" explainerMetric="muscleMass" color="var(--chart-1)" - unit="kg" - yAxisUnit="kg" emptyStateIcon={} emptyStateCtaType={null} coachPrefill="I haven't logged any muscle mass yet — what does this metric tell me about my health, and how do I improve it?" diff --git a/src/app/insights/skin-temperature/page.tsx b/src/app/insights/skin-temperature/page.tsx index e04a3bcad..065dc2bb8 100644 --- a/src/app/insights/skin-temperature/page.tsx +++ b/src/app/insights/skin-temperature/page.tsx @@ -30,8 +30,6 @@ export default function InsightsHauttemperaturPage() { i18nPrefix="insights.skinTemperature" explainerMetric="skinTemperature" color="var(--warning)" - unit="°C" - yAxisUnit="°C" emptyStateIcon={} emptyStateCtaType={null} coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?" @@ -41,7 +39,6 @@ export default function InsightsHauttemperaturPage() { title={t("measurements.typeBodyTemperatureDeviation")} icon={ThermometerSnowflake} color="var(--info)" - unit="°C" fractionDigits={2} sectionTitle={t("insights.bodyTempDeviation.title")} sectionIcon={ThermometerSnowflake} diff --git a/src/app/insights/waist/page.tsx b/src/app/insights/waist/page.tsx index 0d4106339..6c0d74bda 100644 --- a/src/app/insights/waist/page.tsx +++ b/src/app/insights/waist/page.tsx @@ -22,8 +22,6 @@ export default function InsightsWaistPage() { chartKey="waistCircumference" i18nPrefix="insights.waist" color="var(--info)" - unit="cm" - yAxisUnit="cm" statIcon={Ruler} emptyStateIcon={} emptyStateCtaType="WAIST_CIRCUMFERENCE" diff --git a/src/app/insights/walking-distance/page.tsx b/src/app/insights/walking-distance/page.tsx index 0872c7890..26df9821e 100644 --- a/src/app/insights/walking-distance/page.tsx +++ b/src/app/insights/walking-distance/page.tsx @@ -22,10 +22,6 @@ export default function InsightsGehstreckePage() { i18nPrefix="insights.walkingRunningDistance" explainerMetric="walkingDistance" color="var(--success)" - unit="km" - yAxisUnit="km" - // Stored in metres; display daily totals in km (1 m = 0.001 km). - valueScale={0.001} emptyStateIcon={} emptyStateCtaType={null} coachPrefill="I haven't logged any walking and running distance yet — what does this metric tell me about my health, and how do I improve it?" diff --git a/src/app/insights/walking-speed/page.tsx b/src/app/insights/walking-speed/page.tsx index 8223c557e..bdb43a22f 100644 --- a/src/app/insights/walking-speed/page.tsx +++ b/src/app/insights/walking-speed/page.tsx @@ -22,9 +22,6 @@ export default function InsightsGehgeschwindigkeitPage() { i18nPrefix="insights.walkingSpeed" explainerMetric="walkingSpeed" color="var(--success)" - unit="km/h" - yAxisUnit="km/h" - valueScale={3.6} emptyStateIcon={} emptyStateCtaType={null} coachPrefill="I haven't logged any walking speed yet — what does this metric tell me about my health, and how do I improve it?" diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index cfa335005..4bcae9c10 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { Scale } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; @@ -36,10 +37,38 @@ export default function InsightsGewichtPage() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); + const unitDisplay = useUnitDisplay(); const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT"); const weightSummary = analytics?.summaries?.WEIGHT ?? null; + // v1.32.26 — resolve the weight display unit + scale from the user's + // preference (kg for metric, lb for imperial). This page inlines the shell + // rather than riding the scaffold, so it converts the stat strip + chart + // series + bands here. Weight has no affine offset, so the factor alone + // covers both the values and the bands. + const weightTransform = unitDisplay.transformFor("WEIGHT"); + const weightUnit = weightTransform.displayUnit; + const weightScale = weightTransform.factor; + const displaySummary = + weightSummary && weightScale !== 1 + ? { + ...weightSummary, + min: + weightSummary.min === null ? null : weightSummary.min * weightScale, + max: + weightSummary.max === null ? null : weightSummary.max * weightScale, + mean: + weightSummary.mean === null + ? null + : weightSummary.mean * weightScale, + median: + weightSummary.median === null + ? null + : weightSummary.median * weightScale, + } + : weightSummary; + // v1.12.8 — visible-range stats shared between the chart and the strip. const { statsByType, onVisibleStats } = useChartDomainStats(); @@ -73,6 +102,16 @@ export default function InsightsGewichtPage() { upperBound: 250, }) : undefined; + // The bands are kg bounds → scale into the display unit so the shaded zone + // tracks the converted line (factor-only; weight carries no offset). + const displayBands = + weightBands && weightScale !== 1 + ? weightBands.map((band) => ({ + ...band, + min: band.min * weightScale, + max: band.max * weightScale, + })) + : weightBands; return ( } - coachReadStrip={} + coachReadStrip={} diversityNudge={ diff --git a/src/app/insights/wrist-temperature/page.tsx b/src/app/insights/wrist-temperature/page.tsx index eedb62695..9acb07bd5 100644 --- a/src/app/insights/wrist-temperature/page.tsx +++ b/src/app/insights/wrist-temperature/page.tsx @@ -22,8 +22,6 @@ export default function InsightsWristTemperaturePage() { i18nPrefix="insights.wristTemperature" explainerMetric="wristTemperature" color="var(--warning)" - unit="°C" - yAxisUnit="°C" emptyStateIcon={} emptyStateCtaType={null} coachPrefill="I haven't logged any wrist-temperature data yet — what does this metric tell me about my health, and how do I improve it?" diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 0b8e4af10..f4c67f5b3 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -3,6 +3,7 @@ import React, { Suspense, useCallback, useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { useMounted } from "@/hooks/use-mounted"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; @@ -225,6 +226,18 @@ export default function DashboardPageClient({ const { t } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); + // v1.32.26 — metric/imperial display choke point for the dashboard tiles + + // weight chart. `td` converts an ABSOLUTE reading (affine, so a temperature + // picks up the °F offset); `tdd` converts a DELTA / slope (factor only, so a + // Δ never inherits the offset). Metric users take the identity path. + const unitDisplay = useUnitDisplay(); + const td = (type: string, value: number | null | undefined): number | null => + value == null ? null : unitDisplay.toDisplay(type, value); + const tdd = ( + type: string, + value: number | null | undefined, + ): number | null => + value == null ? null : unitDisplay.toDisplayDelta(type, value); const [quickEntryDialog, setQuickEntryDialog] = useState(null); @@ -713,7 +726,29 @@ export default function DashboardPageClient({ const bands = serverBands ?? clientBands; const bpTargets = bands.bpTargets; const weightRange = bands.weightRange; + // The range hint tooltip prints its band bounds in the display unit, so scale + // the kg thresholds by the weight factor (offset-free). The colour-classing + // below stays in canonical kg — it compares a canonical avg against the + // canonical range, so both sides remain in the same unit. + const weightHintRange = weightRange + ? { + greenMin: unitDisplay.toDisplay("WEIGHT", weightRange.greenMin), + greenMax: unitDisplay.toDisplay("WEIGHT", weightRange.greenMax), + orangeMin: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMin), + orangeMax: unitDisplay.toDisplay("WEIGHT", weightRange.orangeMax), + } + : weightRange; const weightBands = bands.weightBands ?? undefined; + // Scale the kg chart bands into the display unit so the shaded zone tracks + // the converted line (factor-only; weight has no offset). Metric = identity. + const weightDisplayBands = + weightBands && unitDisplay.preference === "imperial" + ? weightBands.map((band) => ({ + ...band, + min: unitDisplay.toDisplay("WEIGHT", band.min), + max: unitDisplay.toDisplay("WEIGHT", band.max), + })) + : weightBands; const bpTargetZones = bpTargets ? [ { @@ -897,10 +932,10 @@ export default function DashboardPageClient({ ), @@ -1276,16 +1311,22 @@ export default function DashboardPageClient({ ), @@ -1302,16 +1343,22 @@ export default function DashboardPageClient({ ), @@ -1325,16 +1372,22 @@ export default function DashboardPageClient({ ), @@ -1348,16 +1401,22 @@ export default function DashboardPageClient({ ), @@ -1554,10 +1613,11 @@ export default function DashboardPageClient({ types={["WEIGHT"]} title={t("dashboard.weight")} colors={["var(--chart-1)"]} - unit="kg" - valueBands={weightBands} + unit={unitDisplay.unitFor("WEIGHT")} + valueBands={weightDisplayBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} + valueScale={unitDisplay.transformFor("WEIGHT").factor} /> ), }); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 66e0a22b7..f7d590153 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -220,6 +220,15 @@ interface HealthChartProps { * uniformly. */ valueScale?: number; + /** + * v1.32.26 — display-time additive offset folded in alongside + * `valueScale` at the same read boundary: `display = raw * valueScale + + * valueOffset`. Required for the affine °C→°F conversion (a shift, not + * just a scale). Defaults to `0`. Because every tooltip delta / trend + * slope is computed as a DIFFERENCE of two offset values, the offset + * cancels there automatically — a rate never inherits the +32 shift. + */ + valueOffset?: number; /** * v1.12.8 — chart-reactive metric statistics. When supplied, the chart * reports the per-type Min / Max / Median / Mean of the data currently @@ -586,6 +595,7 @@ export function HealthChart({ verticalMarkers, userTimezone = "Europe/Berlin", valueScale = 1, + valueOffset = 0, onVisibleStats, titleIcon, onDataReady, @@ -832,7 +842,7 @@ export function HealthChart({ }>(`/api/measurements/series?${sleepParams}`); const points = data?.points ?? []; for (const point of points) { - const value = point.value * valueScale; + const value = point.value * valueScale + valueOffset; if (value == null || !Number.isFinite(value)) continue; const dayKey = toDayKey(point.at, dayKeyFormatter); const bucket = dailyAggregates.get(dayKey) ?? { @@ -927,7 +937,7 @@ export function HealthChart({ // v1.15.20 — the BMI division moved into the query's `select` // callback so the cached series stays raw and the WEIGHT and // BMI views share one cache entry. - const value = measurement.value * valueScale; + const value = measurement.value * valueScale + valueOffset; if (!Number.isFinite(value)) { continue; @@ -957,8 +967,8 @@ export function HealthChart({ typeof measurement.minValue === "number" && typeof measurement.maxValue === "number" ) { - const scaledMin = measurement.minValue * valueScale; - const scaledMax = measurement.maxValue * valueScale; + const scaledMin = measurement.minValue * valueScale + valueOffset; + const scaledMax = measurement.maxValue * valueScale + valueOffset; current.min = current.min === null ? scaledMin diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 94a847fee..10c5bf57e 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -60,6 +61,15 @@ export function CoachReadStrip({ const { isAuthenticated } = useAuth(); const { t, locale } = useTranslations(); const mounted = useMounted(); + // v1.32.26 — a type with a registered metric/imperial transform resolves its + // display unit + conversion from the user's preference and IGNORES the + // page-passed `unit` / `valueScale` (weight labelled "kg" would otherwise + // never follow the toggle). The band edges + today's value are ABSOLUTE, so + // the affine `toDisplay` (factor + offset) is correct here. Untransformed + // metrics keep the props (glucose passes its own unit + reciprocal scale). + const unitDisplay = useUnitDisplay(); + const transformed = unitDisplay.isTransformed(metricType); + const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit; const { data } = useQuery({ queryKey: queryKeys.insightsCoachRead(metricType), @@ -80,7 +90,11 @@ export function CoachReadStrip({ new Intl.NumberFormat(locale, { minimumFractionDigits: 0, maximumFractionDigits: fractionDigits, - }).format(value * valueScale); + }).format( + transformed + ? unitDisplay.toDisplay(metricType, value) + : value * valueScale, + ); const baselineLine = ((): string | null => { if (data.learning || !data.baseline) { @@ -97,7 +111,7 @@ export function CoachReadStrip({ low: fmt(low), high: fmt(high), value: fmt(latest), - unit, + unit: resolvedUnit, }); })(); diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index c671b186b..a643b3c67 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { getUnitForType } from "@/lib/validations/measurement"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { MEASUREMENT_TYPE_ICONS, MEASUREMENT_TYPE_LABEL_KEYS, @@ -70,23 +71,6 @@ const UP_BAD_VITALS = new Set([ /** Up-is-good for the vitals where a rise is favourable. */ const UP_GOOD_VITALS = new Set(["HEART_RATE_VARIABILITY", "OXYGEN_SATURATION"]); -/** - * Storage-unit → display-symbol overrides. `getUnitForType` returns the - * canonical *storage* string (e.g. the literal "celsius"); a tile must show - * the symbol a user reads ("°C"). Only the units whose storage string is not - * already a display symbol need an entry — kg / bpm / ms / % / mg/dL render - * fine as-is. - */ -const DISPLAY_UNIT_SYMBOL: Record = { - celsius: "°C", - fahrenheit: "°F", -}; - -function displayUnit(type: string): string { - const storage = getUnitForType(type); - return DISPLAY_UNIT_SYMBOL[storage] ?? storage; -} - function vitalSentiment(type: string): TrendDirectionSentiment { if (UP_BAD_VITALS.has(type)) return "up-bad"; if (UP_GOOD_VITALS.has(type)) return "up-good"; @@ -229,6 +213,7 @@ function BaselineTile({ }) { const { t } = useTranslations(); const fmt = useFormatters(); + const unitDisplay = useUnitDisplay(); // `VITALS_BASELINE` is the type-generic engine and needs the type on the // token; the dedicated bands pin their own single input, so no `type`. const data = read( @@ -247,7 +232,12 @@ function BaselineTile({ const Icon = MEASUREMENT_TYPE_ICONS[type] ?? Gauge; const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[type]; const label = labelKey ? t(labelKey) : type; - const unit = displayUnit(type); + // v1.32.26 — resolve the display unit from the user's preference. For an + // absolute temperature this converts celsius→°F (affine); the reporter's + // proven canonical-label bug (a value labelled by the storage unit rather + // than the preferred one) is fixed here, retiring the local celsius shim. + const unit = unitDisplay.unitFor(type); + const toShown = (raw: number) => unitDisplay.toDisplay(type, raw); // `VITALS_BASELINE` tiles tag the DOM by their vital type (the stable // contract the existing surfaces + tests key on); the dedicated bands tag // by their own metric id. @@ -283,19 +273,23 @@ function BaselineTile({ } const v = data.value!; + // Range bounds + centre + sparkline are ABSOLUTE readings → affine-convert. const framing = t("insights.derived.vitals.typicalRange", { - low: fmt.number(v.low, 1), - high: fmt.number(v.high, 1), + low: fmt.number(toShown(v.low), 1), + high: fmt.number(toShown(v.high), 1), }); + const displaySeries = unitDisplay.isTransformed(type) + ? v.series.map(toShown) + : v.series; return (
- value.toLocaleString(resolveIntlLocale(locale), { - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - }); + (transform ? unitDisplay.toDisplay(type, value) : value).toLocaleString( + resolveIntlLocale(locale), + { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }, + ); return ( {fmt(latest)} - {unit ? ( + {resolvedUnit ? ( - {unit} + {resolvedUnit} ) : null} @@ -136,8 +150,10 @@ export function DeviceScoreTile({ types={[type]} title={title} colors={[color]} - unit={unit ?? ""} + unit={resolvedUnit ?? ""} mini + valueScale={transform ? transform.factor : undefined} + valueOffset={transform ? (transform.offset ?? 0) : undefined} userTimezone={user?.timezone} />
@@ -147,7 +163,7 @@ export function DeviceScoreTile({ className="text-muted-foreground text-xs" > {t("insights.deviceScore.average", { value: fmt(mean) })} - {unit ? ` ${unit}` : ""} + {resolvedUnit ? ` ${resolvedUnit}` : ""}

) : null} diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index d033aaed5..e551109f1 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -6,6 +6,7 @@ import type { ComponentProps, ComponentType, ReactNode } from "react"; import { RefreshCw, Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { useChartDomainStats } from "@/hooks/use-chart-domain-stats"; @@ -88,8 +89,14 @@ export interface HealthKitMetricPageProps { * token retune (see UI standards, colour token rules). */ color: `var(--${string})`; - /** Unit suffix the chart renders next to the value. */ - unit: string; + /** + * Unit suffix the chart renders next to the value. Optional: for a type + * with a registered metric/imperial transform (weight-class, temperature, + * waist) the scaffold resolves the unit + scale from the user's preference + * and IGNORES any page-passed `unit` / `yAxisUnit` / `valueScale`. Pages for + * transformed types omit these props; everything else still passes `unit`. + */ + unit?: string; /** Optional y-axis label shown above the unit. */ yAxisUnit?: string; /** Optional value bands (Apple-Health-style target zone shading). */ @@ -246,22 +253,60 @@ export function HealthKitMetricPage({ ? (fallbackMeasurementType as string) : measurementType; + // v1.32.26 — resolve the display unit + scale from the user's preference for + // a type with a registered transform (kg↔lb, cm↔in, °C↔°F). When resolved, + // the scaffold OWNS the unit / y-axis unit / value scale / offset / bands + + // stat precision and IGNORES the page-passed values, so no page can double- + // scale or drift its unit label from the line. Untransformed types keep the + // page-passed props exactly (glucose, pulse, %, scores, …). + const unitDisplay = useUnitDisplay(); + const transform = unitDisplay.isTransformed(effectiveType) + ? unitDisplay.transformFor(effectiveType) + : null; + const resolvedUnit = transform ? transform.displayUnit : unit; + const resolvedYAxisUnit = transform ? transform.displayUnit : yAxisUnit; + const resolvedScale = transform ? transform.factor : (valueScale ?? 1); + const resolvedOffset = transform ? (transform.offset ?? 0) : 0; + const resolvedFractionDigits = transform + ? transform.decimals + : statFractionDigits; + // Value bands are absolute bounds → affine-convert (factor + offset) so the + // shaded zone tracks the converted line. Metric users get the identity. + const resolvedBands = + transform && valueBands + ? valueBands.map((band) => ({ + ...band, + min: unitDisplay.toDisplay(effectiveType, band.min), + max: unitDisplay.toDisplay(effectiveType, band.max), + })) + : valueBands; + const rawSummary = analytics?.summaries?.[effectiveType] ?? null; // The stat strip renders display-unit values. The summary holds stored - // values, so when the page renders a scaled unit (e.g. WALKING_SPEED - // stores m/s but displays km/h via `valueScale`), fold the same scale - // into the strip's min / max / median / mean so the numbers and the - // unit agree. `valueScale` defaults to 1 (identity) → byte-identical - // for every non-scaled metric. - const scale = valueScale ?? 1; + // values, so fold the resolved scale + offset (affine) into the strip's + // min / max / median / mean so the numbers and the unit agree. All four are + // ABSOLUTE values, so they take the offset; an untransformed metric keeps + // scale 1 / offset 0 → byte-identical. const summary = - rawSummary && scale !== 1 + rawSummary && (resolvedScale !== 1 || resolvedOffset !== 0) ? { ...rawSummary, - min: rawSummary.min === null ? null : rawSummary.min * scale, - max: rawSummary.max === null ? null : rawSummary.max * scale, - mean: rawSummary.mean === null ? null : rawSummary.mean * scale, - median: rawSummary.median === null ? null : rawSummary.median * scale, + min: + rawSummary.min === null + ? null + : rawSummary.min * resolvedScale + resolvedOffset, + max: + rawSummary.max === null + ? null + : rawSummary.max * resolvedScale + resolvedOffset, + mean: + rawSummary.mean === null + ? null + : rawSummary.mean * resolvedScale + resolvedOffset, + median: + rawSummary.median === null + ? null + : rawSummary.median * resolvedScale + resolvedOffset, } : rawSummary; @@ -353,8 +398,8 @@ export function HealthKitMetricPage({ statStrip={ } diversityNudge={ @@ -393,12 +438,13 @@ export function HealthKitMetricPage({ } titleIcon={statIcon} colors={[color]} - unit={unit} - yAxisUnit={yAxisUnit} - valueBands={valueBands} + unit={resolvedUnit} + yAxisUnit={resolvedYAxisUnit} + valueBands={resolvedBands} compareBaseline={compareBaseline} userTimezone={user?.timezone} - valueScale={valueScale} + valueScale={resolvedScale} + valueOffset={resolvedOffset} onVisibleStats={onVisibleStats} /> {targetSummarySlug ? ( From 13b88e47f522593c28775317816ceb9ca22578c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 00:16:41 +0200 Subject: [PATCH 3/7] fix(insights): convert weight and temperature in the mood/cycle crosstabs and the plateau note Route the weight and temperature displays in the mood-factor and cycle-phase crosstabs and the GLP-1 plateau note through the unit transform: absolute phase averages take the affine conversion, deltas stay factor-only, and the unit symbol follows the preference. Thread a unit token into the plateau evidence copy across the six locales so the sentence reads in the user's unit. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/components/cycle/cycle-phase-crosstab.tsx | 39 ++++++++++++++++--- src/components/insights/glp1-plateau-note.tsx | 14 +++++-- .../mood/mood-factor-metric-crosstab.tsx | 19 +++++++-- 9 files changed, 64 insertions(+), 20 deletions(-) diff --git a/messages/de.json b/messages/de.json index 670f4787e..3f965de1d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1885,7 +1885,7 @@ "levelShift": "Um {date} wurde eine Verschiebung des Niveaus erkannt.", "plateau": { "title": "Gewichts-Plateau auf aktueller Dosis", - "evidence": "Gewichtsveränderung von {delta} kg in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}." + "evidence": "Gewichtsveränderung von {delta} {unit} in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}." }, "retarget": { "label": "Nicht der richtige Messwert?", diff --git a/messages/en.json b/messages/en.json index 6d73a0395..ef2007aae 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1875,7 +1875,7 @@ "levelShift": "A shift in the level was detected around {date}.", "plateau": { "title": "Weight plateau on the current dose", - "evidence": "Weight moved by {delta} kg over the last {window} days ({readings} readings) — {days} days on {dose}." + "evidence": "Weight moved by {delta} {unit} over the last {window} days ({readings} readings) — {days} days on {dose}." }, "retarget": { "label": "Not the right measurement?", diff --git a/messages/es.json b/messages/es.json index 10c00c0aa..c6c9f21a2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1887,7 +1887,7 @@ "levelShift": "Se detectó un cambio de nivel en torno a {date}.", "plateau": { "title": "Meseta de peso con la dosis actual", - "evidence": "Variación de peso de {delta} kg en los últimos {window} días ({readings} mediciones) — {days} días con {dose}." + "evidence": "Variación de peso de {delta} {unit} en los últimos {window} días ({readings} mediciones) — {days} días con {dose}." }, "retarget": { "label": "¿No es la medición correcta?", diff --git a/messages/fr.json b/messages/fr.json index 754070013..870ed5dfd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1889,7 +1889,7 @@ "levelShift": "Un changement de niveau a été détecté autour du {date}.", "plateau": { "title": "Plateau de poids à la dose actuelle", - "evidence": "Variation de poids de {delta} kg sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}." + "evidence": "Variation de poids de {delta} {unit} sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}." }, "retarget": { "label": "Ce n'est pas la bonne mesure ?", diff --git a/messages/it.json b/messages/it.json index b4eeea2a1..c8eae2287 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1879,7 +1879,7 @@ "levelShift": "È stato rilevato un cambio di livello intorno al {date}.", "plateau": { "title": "Plateau del peso alla dose attuale", - "evidence": "Variazione di peso di {delta} kg negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}." + "evidence": "Variazione di peso di {delta} {unit} negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}." }, "retarget": { "label": "Non è la misurazione giusta?", diff --git a/messages/pl.json b/messages/pl.json index ddd224985..0ea309978 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1873,7 +1873,7 @@ "levelShift": "Wykryto zmianę poziomu w okolicach {date}.", "plateau": { "title": "Plateau masy ciała przy obecnej dawce", - "evidence": "Zmiana masy ciała o {delta} kg w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}." + "evidence": "Zmiana masy ciała o {delta} {unit} w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}." }, "retarget": { "label": "To nie ten pomiar?", diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index 8e0f4d693..8303c18d3 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -1,6 +1,7 @@ "use client"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { cn } from "@/lib/utils"; /** @@ -77,6 +78,18 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string { return display === "steps" ? Math.round(value).toString() : value.toFixed(1); } +/** + * v1.32.26 — the two displays whose values follow the metric/imperial + * preference, mapped to the measurement type that carries the transform. Weight + * is offset-free (kg→lb); temperature is affine (°C→°F), so an ABSOLUTE phase + * average takes `toDisplay` while a phase DELTA takes `toDisplayDelta` (the + * offset must never leak into a difference). + */ +const DISPLAY_TO_TYPE: Partial> = { + kg: "WEIGHT", + celsius: "BODY_TEMPERATURE", +}; + const HEADLINE_KEY: Record = { restingHeartRate: "cycle.insights.headline.restingHeartRate", heartRateVariability: "cycle.insights.headline.heartRateVariability", @@ -93,6 +106,7 @@ export function CyclePhaseHeadline({ headline: CyclePhaseCrosstabRow | null; }) { const { t } = useTranslations(); + const unitDisplay = useUnitDisplay(); if (!headline) { return (

= 0; const dir = t( up @@ -118,7 +133,13 @@ export function CyclePhaseHeadline({ ); const params = { metric: metricLabel, - delta: fmt(Math.abs(headline.delta), headline.display), + // A phase delta → factor-only conversion (no affine offset). + delta: fmt( + Math.abs( + mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta, + ), + headline.display, + ), unit, dir, }; @@ -141,6 +162,7 @@ export function CyclePhaseCrosstab({ rows: CyclePhaseCrosstabRow[]; }) { const { t } = useTranslations(); + const unitDisplay = useUnitDisplay(); if (rows.length === 0) return null; return ( @@ -155,15 +177,20 @@ export function CyclePhaseCrosstab({ ); // Defensive: an unmapped display must never pass undefined to `t` // (whose resolver does `key.split(".")`). Empty unit on a miss. + const mt = DISPLAY_TO_TYPE[row.display]; const unitKey = UNIT_KEY[row.display]; - const unit = unitKey ? t(unitKey) : ""; + const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : ""; + const absConv = (v: number) => + mt ? unitDisplay.toDisplay(mt, v) : v; + const deltaConv = (v: number) => + mt ? unitDisplay.toDisplayDelta(mt, v) : v; // `delta` = lutealAvg − follicularAvg: positive = the vital runs // higher in the luteal phase. The number stays NEUTRAL — higher is // good for steps but bad for resting HR, and this board is // observational, so colouring it would imply a verdict the data // doesn't support. The sign prefix + `data-direction` carry it. const up = row.delta >= 0; - const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`; + const deltaText = `${up ? "+" : ""}${fmt(deltaConv(row.delta), row.display)} ${unit}`; return (

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

    {t("medications.efficacy.plateau.evidence", { delta, + unit, window: data.windowDays, readings: plateau.readingsCount, days: plateau.daysOnDose, diff --git a/src/components/insights/mood/mood-factor-metric-crosstab.tsx b/src/components/insights/mood/mood-factor-metric-crosstab.tsx index c4c1460e7..8393d0bbf 100644 --- a/src/components/insights/mood/mood-factor-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-factor-metric-crosstab.tsx @@ -1,6 +1,7 @@ "use client"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { moodTagIcon } from "@/components/mood/mood-tag-icons"; import { cn } from "@/lib/utils"; import type { MoodInfluenceConfidence } from "./mood-tag-influence"; @@ -85,6 +86,7 @@ export function MoodFactorMetricCrosstab({ rows: MoodFactorMetricCrosstabRow[]; }) { const { t } = useTranslations(); + const unitDisplay = useUnitDisplay(); if (rows.length === 0) return null; return ( @@ -99,7 +101,16 @@ export function MoodFactorMetricCrosstab({ const metricLabel = t( METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey, ); - const unit = t(UNIT_KEY[row.display]); + // v1.32.26 — the weight ("kg") row follows the metric/imperial + // preference: the unit symbol + the values (averages + delta, all in + // kg, offset-free) convert together. Every other display is unitless + // to the preference and keeps its i18n label + raw value. + const isWeightRow = row.display === "kg"; + const unit = isWeightRow + ? unitDisplay.unitFor("WEIGHT") + : t(UNIT_KEY[row.display]); + const conv = (v: number) => + isWeightRow ? unitDisplay.toDisplay("WEIGHT", v) : v; // `delta` = lowAvg − highAvg: positive = the vital runs higher on the // low-factor (worse) days. The number stays NEUTRAL — a higher value // is good for steps but bad for resting HR, and this board is @@ -107,7 +118,7 @@ export function MoodFactorMetricCrosstab({ // green/red would imply a health verdict the data doesn't support. // The sign prefix + `data-direction` carry the direction. const up = row.delta >= 0; - const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`; + const deltaText = `${up ? "+" : ""}${fmt(conv(row.delta), row.display)} ${unit}`; return (

  • Date: Sat, 25 Jul 2026 00:21:36 +0200 Subject: [PATCH 4/7] test(measurements): cover the unit transform, MCP unit hardening, and a display guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add affine-correctness tests for the transform registry (°C→°F absolute vs a delta that must stay offset-free, lb/in factors, inverse round-trips), a resolver test for the inbound unit aliases, a preferred-unit entry-form label test, and MCP cases for converting a lb hint to canonical kg and refusing an unknown unit. Add the structural guard that trips if any dashboard or insight surface reintroduces a hardcoded transformed-type unit or value scale. Ship the diagnose/convert script pair for legacy non-canonical measurement rows. --- .../convert-noncanonical-measurement-units.ts | 104 ++++++++++++++++ ...diagnose-noncanonical-measurement-units.ts | 76 ++++++++++++ .../unit-preference-display-guard.test.ts | 87 +++++++++++++ .../measurement-form-unit-label.test.tsx | 66 ++++++++++ src/lib/mcp/__tests__/writes.test.ts | 46 +++++++ .../__tests__/display-transform.test.ts | 116 ++++++++++++++++++ .../__tests__/unit-aliases.test.ts | 70 +++++++++++ 7 files changed, 565 insertions(+) create mode 100644 scripts/convert-noncanonical-measurement-units.ts create mode 100644 scripts/diagnose-noncanonical-measurement-units.ts create mode 100644 src/__tests__/unit-preference-display-guard.test.ts create mode 100644 src/components/measurements/__tests__/measurement-form-unit-label.test.tsx create mode 100644 src/lib/measurements/__tests__/unit-aliases.test.ts diff --git a/scripts/convert-noncanonical-measurement-units.ts b/scripts/convert-noncanonical-measurement-units.ts new file mode 100644 index 000000000..18a75cbda --- /dev/null +++ b/scripts/convert-noncanonical-measurement-units.ts @@ -0,0 +1,104 @@ +/** + * Convert measurement rows stored in a RECOGNISED non-canonical unit to + * canonical SI, in place. Companion to + * `diagnose-noncanonical-measurement-units.ts` — run the diagnose first. + * + * SAFETY: + * - Dry-run by default. Writes ONLY when called with `--execute`. + * - Touches ONLY rows whose stored `unit` differs from the type's canonical + * unit AND resolves through the shared alias resolver (lb/lbs/pound(s), + * in/inch(es), °F, mmol/L). A row whose unit is already canonical, or whose + * unit is unrecognised, is never touched. + * - Each row is rewritten to the canonical `{ value, unit }` the same + * resolver the CSV importer + MCP write path use produces, so the number is + * re-expressed, never re-labelled in place (which would corrupt it). + * - Affected users' measurement caches are busted at the end. + * + * This repairs the storage LABEL + value for the recognised-alias legacy rows + * (the MCP-leak class). It cannot repair a row whose unit was already canonical + * but whose value was a foreign unit — that class is not mechanically + * identifiable (see the diagnose script). + * + * Run (inside the app container): + * pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts # dry-run + * pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute # write + */ +import "dotenv/config"; + +import { prisma } from "@/lib/db"; +import { getUnitForType } from "@/lib/validations/measurement"; +import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +async function main(): Promise { + const execute = process.argv.includes("--execute"); + + const groups = await prisma.measurement.groupBy({ + by: ["type", "unit"], + _count: { _all: true }, + }); + const convertibleGroups = groups.filter((g) => { + if (g.unit === null || g.unit === getUnitForType(g.type)) return false; + return resolveToCanonicalUnit(g.type, 1, g.unit) !== null; + }); + + if (convertibleGroups.length === 0) { + console.log( + "Nothing to convert — no recognised non-canonical units found.", + ); + return; + } + + const affectedUsers = new Set(); + let converted = 0; + + for (const g of convertibleGroups) { + const canonical = getUnitForType(g.type); + const rows = await prisma.measurement.findMany({ + where: { type: g.type, unit: g.unit }, + select: { id: true, userId: true, value: true, unit: true }, + }); + for (const row of rows) { + const resolved = resolveToCanonicalUnit( + g.type, + row.value, + row.unit as string, + ); + if (!resolved) continue; // defensive; group already filtered + converted += 1; + affectedUsers.add(row.userId); + console.log( + ` ${execute ? "convert" : "would convert"} ${g.type} ${row.id}: ` + + `${row.value} ${row.unit} -> ${resolved.value} ${resolved.unit}`, + ); + if (execute) { + await prisma.measurement.update({ + where: { id: row.id }, + data: { value: resolved.value, unit: canonical }, + }); + } + } + } + + if (execute) { + for (const userId of affectedUsers) { + invalidateUserMeasurements(userId, { evict: true }); + } + console.log( + `\nConverted ${converted} row(s) across ${affectedUsers.size} user(s). ` + + "Rollups recompute on the next read/backfill.", + ); + } else { + console.log( + `\nDry run: ${converted} row(s) across ${affectedUsers.size} user(s) ` + + "would be converted. Re-run with --execute to write.", + ); + } +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/diagnose-noncanonical-measurement-units.ts b/scripts/diagnose-noncanonical-measurement-units.ts new file mode 100644 index 000000000..9c0667bda --- /dev/null +++ b/scripts/diagnose-noncanonical-measurement-units.ts @@ -0,0 +1,76 @@ +/** + * Read-only diagnostic for measurement rows stored in a NON-canonical unit. + * + * Canonical storage is SI: every write path stamps `getUnitForType(type)`. The + * one historical leak was the MCP `log_measurement` tool, which persisted a + * caller-supplied `unit` verbatim (closed in v1.32.26). A row whose `unit` + * column differs from its type's canonical unit is such a legacy row, and it + * mislabels every display surface that trusts the column. + * + * This groups measurements by `(type, unit)` and prints every group whose + * `unit` is not the canonical unit for its type, flagging whether the stored + * unit is a RECOGNISED alias (mechanically convertible by the companion + * convert script) or UNKNOWN (needs a manual look). Nothing is written. + * + * NOT auto-fixable and deliberately out of scope: a row whose stored unit IS + * already canonical but whose VALUE was a foreign unit (e.g. a lb reading typed + * into the old kg-labelled entry form). Those are indistinguishable from real + * canonical readings; the reporter edits them via the record list. + * + * Run (inside the app container, which has DATABASE_URL): + * pnpm dlx tsx scripts/diagnose-noncanonical-measurement-units.ts + */ +import "dotenv/config"; + +import { prisma } from "@/lib/db"; +import { getUnitForType } from "@/lib/validations/measurement"; +import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases"; + +async function main(): Promise { + const groups = await prisma.measurement.groupBy({ + by: ["type", "unit"], + _count: { _all: true }, + }); + + const mismatches = groups.filter((g) => { + if (g.unit === null) return false; + return g.unit !== getUnitForType(g.type); + }); + + if (mismatches.length === 0) { + console.log("No non-canonical measurement units found. Storage is clean."); + return; + } + + let convertible = 0; + let unknown = 0; + console.log( + `Found ${mismatches.length} non-canonical (type, unit) group(s):\n`, + ); + for (const g of mismatches.sort((a, b) => b._count._all - a._count._all)) { + const canonical = getUnitForType(g.type); + // Probe convertibility with a neutral value — only the recognition matters. + const recognised = resolveToCanonicalUnit(g.type, 1, g.unit as string); + const status = recognised ? "CONVERTIBLE" : "UNKNOWN — manual review"; + if (recognised) convertible += g._count._all; + else unknown += g._count._all; + console.log( + ` ${g.type}: stored="${g.unit}" canonical="${canonical}" ` + + `rows=${g._count._all} [${status}]`, + ); + } + console.log( + `\nTotals: ${convertible} row(s) convertible, ${unknown} row(s) unknown.`, + ); + console.log( + "Convert the recognised aliases with:\n" + + " pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute", + ); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts new file mode 100644 index 000000000..ff91da3c0 --- /dev/null +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -0,0 +1,87 @@ +/** + * Structural guard (a tripwire, not a proof) for the metric/imperial unit + * fix (issue #627). The dangerous failure mode is a PARTIAL fix: entry converts + * but a display surface still hardcodes the canonical unit, so an imperial user + * sees converted-then-mislabelled soup. This walks the dashboard + insight page + * sources and asserts none of them passes a transformed type's DISPLAY UNIT + * (kg / lb / cm / in / °C / °F / km/h / mph / km / mi) or a hardcoded value + * scale as a literal prop — those must resolve from the user's preference + * through `useUnitDisplay()` instead. + * + * Mutation check: add `unit="kg"` back to any swept page and this goes red. + */ +import { readFileSync } from "node:fs"; +import { globSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, it, expect } from "vitest"; + +import { + getDisplayTransform, + TRANSFORMED_TYPES, +} from "@/lib/measurements/display-transform"; + +const SRC = join(process.cwd(), "src"); + +/** The set of display-unit strings a transformed type can render. */ +const TRANSFORMED_UNITS: ReadonlySet = new Set( + [...TRANSFORMED_TYPES].flatMap((type) => [ + getDisplayTransform(type, "metric").displayUnit, + getDisplayTransform(type, "imperial").displayUnit, + ]), +); + +/** Swept surfaces: the dashboard client + every insights sub-page. */ +function sweptFiles(): string[] { + return [ + join(SRC, "app", "page-client.tsx"), + ...globSync("app/insights/**/page.tsx", { cwd: SRC }).map((rel) => + join(SRC, rel), + ), + ]; +} + +describe("unit-preference display guard", () => { + it("sweeps a non-empty set of surfaces", () => { + // Guards against a glob that silently matches nothing (a green void). + expect(sweptFiles().length).toBeGreaterThan(10); + }); + + it("no swept surface hardcodes a transformed type's display unit", () => { + const offenders: string[] = []; + for (const file of sweptFiles()) { + const src = readFileSync(file, "utf8"); + for (const unit of TRANSFORMED_UNITS) { + // Match `unit="kg"` / `yAxisUnit="°C"` exactly (closing quote pinned so + // `unit="kg/m²"` — the excluded BMI unit — never trips it). + for (const prop of ["unit", "yAxisUnit"]) { + if (src.includes(`${prop}="${unit}"`)) { + offenders.push(`${file}: ${prop}="${unit}"`); + } + } + } + } + expect(offenders).toEqual([]); + }); + + it("no insights page hardcodes a numeric valueScale literal", () => { + // Transformed metrics resolve their scale from the transform; a literal + // `valueScale={3.6}` is the pre-fix hand-rolled scaling that double-scales. + const literal = /valueScale=\{\s*-?\d/; + const offenders = sweptFiles().filter((file) => + literal.test(readFileSync(file, "utf8")), + ); + expect(offenders).toEqual([]); + }); + + it("every transformed type carries both a metric and an imperial branch", () => { + for (const type of TRANSFORMED_TYPES) { + const metric = getDisplayTransform(type, "metric"); + const imperial = getDisplayTransform(type, "imperial"); + expect(metric.displayUnit).toBeTruthy(); + expect(imperial.displayUnit).toBeTruthy(); + expect(Number.isFinite(metric.factor)).toBe(true); + expect(Number.isFinite(imperial.factor)).toBe(true); + } + }); +}); diff --git a/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx b/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx new file mode 100644 index 000000000..b53d0d50a --- /dev/null +++ b/src/components/measurements/__tests__/measurement-form-unit-label.test.tsx @@ -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( + + + , + ); +} + +describe("MeasurementForm — preferred-unit value label", () => { + it("labels the weight field in kg for a metric user", () => { + preference = "metric"; + const html = render(); + expect(html).toContain("kg"); + expect(html).not.toContain("lb"); + // The metric placeholder (kg) is shown, not the imperial one. + expect(html).toContain('placeholder="75.5"'); + }); + + it("labels the weight field in lb for an imperial user", () => { + preference = "imperial"; + const html = render(); + expect(html).toContain("lb"); + // The imperial placeholder (lb) is shown. + expect(html).toContain('placeholder="165"'); + }); +}); diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts index 32776ebf2..22c1eea9f 100644 --- a/src/lib/mcp/__tests__/writes.test.ts +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -89,6 +89,52 @@ describe("logMcpMeasurement", () => { ); }); + it("converts a lb unit hint to canonical kg before writing", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 210, + unit: "lb", + idempotencyKey: "key-lb", + }); + + expect(result.status).toBe("written"); + const data = measurement.create.mock.calls[0][0].data; + expect(data.unit).toBe("kg"); + // 210 lb → 95.25 kg (stored canonical, never the caller's lb verbatim). + expect(data.value).toBeCloseTo(210 * 0.45359237, 5); + if (result.status === "written") { + expect(result.measurement.unit).toBe("kg"); + } + }); + + it("refuses an unsupported unit hint without writing", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 12, + unit: "stone", + idempotencyKey: "key-stone", + }); + + expect(result.status).toBe("unsupported_unit"); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("stamps canonical kg when the unit hint is omitted", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "key-none", + }); + + expect(result.status).toBe("written"); + const data = measurement.create.mock.calls[0][0].data; + expect(data.unit).toBe("kg"); + expect(data.value).toBe(80); + }); + it("refuses a non-capturable / clinical-only type without writing", async () => { const result = await logMcpMeasurement({ userId: "u-1", diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index 48165c42a..e6203f227 100644 --- a/src/lib/measurements/__tests__/display-transform.test.ts +++ b/src/lib/measurements/__tests__/display-transform.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect } from "vitest"; import { applyDisplayTransform, + applyDisplayTransformDelta, + invertDisplayTransform, getDisplayTransform, + hasDisplayTransform, + TRANSFORMED_TYPES, DEFAULT_UNIT_PREFERENCE, } from "../display-transform"; @@ -65,4 +69,116 @@ describe("display-transform", () => { getDisplayTransform("WALKING_SPEED", "metric"), ); }); + + // ── v1.32.26 — weight-class / length / temperature registry ── + + it("converts WEIGHT kg → lb on the imperial branch", () => { + const metric = getDisplayTransform("WEIGHT", "metric"); + expect(metric.factor).toBe(1); + expect(metric.displayUnit).toBe("kg"); + // Metric is the identity on the number — byte-identical to storage. + expect(applyDisplayTransform(95.25, metric)).toBe(95.25); + + const imperial = getDisplayTransform("WEIGHT", "imperial"); + expect(imperial.displayUnit).toBe("lb"); + // 95.25 kg → 210.0 lb. + expect(applyDisplayTransform(95.25, imperial)).toBeCloseTo(210.0, 1); + }); + + it("shares the mass branch across every body-mass metric", () => { + for (const type of [ + "TOTAL_BODY_WATER", + "BONE_MASS", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "GRIP_STRENGTH", + ]) { + expect(getDisplayTransform(type, "imperial").displayUnit).toBe("lb"); + expect(getDisplayTransform(type, "metric").displayUnit).toBe("kg"); + // 10 kg → 22.05 lb. + expect( + applyDisplayTransform(10, getDisplayTransform(type, "imperial")), + ).toBeCloseTo(22.0462, 3); + } + }); + + it("converts WAIST_CIRCUMFERENCE cm → in", () => { + const imperial = getDisplayTransform("WAIST_CIRCUMFERENCE", "imperial"); + expect(imperial.displayUnit).toBe("in"); + // 84 cm → 33.07 in. + expect(applyDisplayTransform(84, imperial)).toBeCloseTo(33.07, 2); + }); + + it("converts an ABSOLUTE temperature °C → °F affinely (factor + offset)", () => { + const metric = getDisplayTransform("BODY_TEMPERATURE", "metric"); + expect(metric.displayUnit).toBe("°C"); + expect(metric.offset ?? 0).toBe(0); + + const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial"); + expect(imperial.displayUnit).toBe("°F"); + expect(imperial.factor).toBe(1.8); + expect(imperial.offset).toBe(32); + // 36.6 °C → 97.88 °F (the +32 shift MUST apply to an absolute reading). + expect(applyDisplayTransform(36.6, imperial)).toBeCloseTo(97.88, 2); + // 0 °C → 32 °F (not 0). + expect(applyDisplayTransform(0, imperial)).toBe(32); + }); + + it("skin + wrist temperature share the affine temperature branch", () => { + for (const type of ["SKIN_TEMPERATURE", "WRIST_TEMPERATURE"]) { + const imperial = getDisplayTransform(type, "imperial"); + expect(imperial.displayUnit).toBe("°F"); + expect(imperial.offset).toBe(32); + } + }); + + it("BODY_TEMPERATURE_DEVIATION scales °C→°F WITHOUT the offset (it is a Δ)", () => { + const imperial = getDisplayTransform( + "BODY_TEMPERATURE_DEVIATION", + "imperial", + ); + expect(imperial.displayUnit).toBe("°F"); + expect(imperial.factor).toBe(1.8); + // A signed deviation is a delta: 1 Δ°C is a 1.8 Δ°F, NEVER 33.8 °F. + expect(imperial.offset ?? 0).toBe(0); + expect(applyDisplayTransform(1, imperial)).toBeCloseTo(1.8, 5); + }); + + it("the delta helper NEVER applies the affine offset", () => { + const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial"); + // Same absolute transform (offset 32), but a delta must be factor-only. + expect(applyDisplayTransform(1, imperial)).toBe(33.8); + expect(applyDisplayTransformDelta(1, imperial)).toBeCloseTo(1.8, 5); + // A 2 °C rise reads as a 3.6 °F rise, not a 35.6 °F reading. + expect(applyDisplayTransformDelta(2, imperial)).toBeCloseTo(3.6, 5); + }); + + it("inverse round-trips every registered branch (entry ⇄ display)", () => { + for (const type of TRANSFORMED_TYPES) { + for (const pref of ["metric", "imperial"] as const) { + const transform = getDisplayTransform(type, pref); + for (const raw of [0, 1, 36.6, 84, 95.25, 210]) { + const shown = applyDisplayTransform(raw, transform); + expect(invertDisplayTransform(shown, transform)).toBeCloseTo(raw, 6); + } + } + } + }); + + it("hasDisplayTransform + TRANSFORMED_TYPES agree, and exclude BMI/glucose", () => { + expect(hasDisplayTransform("WEIGHT")).toBe(true); + expect(hasDisplayTransform("BODY_TEMPERATURE")).toBe(true); + expect(hasDisplayTransform("PULSE")).toBe(false); + // Deliberately excluded (own preference / universal unit). + expect(TRANSFORMED_TYPES.has("BLOOD_GLUCOSE")).toBe(false); + expect(TRANSFORMED_TYPES.has("BODY_MASS_INDEX")).toBe(false); + // Every registered type resolves a non-identity or symbol-only metric + // branch, and both branches exist. + for (const type of TRANSFORMED_TYPES) { + expect(getDisplayTransform(type, "metric").displayUnit).toBeTruthy(); + expect(getDisplayTransform(type, "imperial").displayUnit).toBeTruthy(); + } + }); }); diff --git a/src/lib/measurements/__tests__/unit-aliases.test.ts b/src/lib/measurements/__tests__/unit-aliases.test.ts new file mode 100644 index 000000000..a12f4d860 --- /dev/null +++ b/src/lib/measurements/__tests__/unit-aliases.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; + +import { resolveToCanonicalUnit } from "../unit-aliases"; + +describe("resolveToCanonicalUnit", () => { + it("passes the canonical unit through case-insensitively", () => { + expect(resolveToCanonicalUnit("WEIGHT", 80, "kg")).toEqual({ + value: 80, + unit: "kg", + }); + expect(resolveToCanonicalUnit("WEIGHT", 80, "KG")).toEqual({ + value: 80, + unit: "kg", + }); + expect(resolveToCanonicalUnit("BLOOD_GLUCOSE", 95, "MG/DL")).toEqual({ + value: 95, + unit: "mg/dL", + }); + }); + + it("converts a lb weight to canonical kg (exact factor)", () => { + const out = resolveToCanonicalUnit("WEIGHT", 210, "lb"); + expect(out?.unit).toBe("kg"); + expect(out?.value).toBeCloseTo(210 * 0.45359237, 6); + // aliases + for (const alias of ["lbs", "pound", "pounds", "LB"]) { + expect(resolveToCanonicalUnit("WEIGHT", 1, alias)?.unit).toBe("kg"); + } + }); + + it("converts lb for every body-mass metric, not just WEIGHT", () => { + for (const type of ["MUSCLE_MASS", "FAT_MASS", "BONE_MASS"]) { + const out = resolveToCanonicalUnit(type, 22.0462, "lb"); + expect(out?.unit).toBe("kg"); + expect(out?.value).toBeCloseTo(10, 3); + } + }); + + it("converts waist inches to canonical cm", () => { + const out = resolveToCanonicalUnit("WAIST_CIRCUMFERENCE", 33, "in"); + expect(out?.unit).toBe("cm"); + expect(out?.value).toBeCloseTo(83.82, 2); + }); + + it("converts an absolute °F reading to canonical celsius", () => { + const out = resolveToCanonicalUnit("BODY_TEMPERATURE", 98.6, "°F"); + expect(out?.unit).toBe("celsius"); + expect(out?.value).toBeCloseTo(37, 5); + // an explicit celsius spelling also normalises to the canonical string + expect(resolveToCanonicalUnit("BODY_TEMPERATURE", 37, "°C")).toEqual({ + value: 37, + unit: "celsius", + }); + }); + + it("converts glucose mmol/L to canonical mg/dL", () => { + const out = resolveToCanonicalUnit("BLOOD_GLUCOSE", 5.3, "mmol/L"); + expect(out?.unit).toBe("mg/dL"); + expect(out?.value).toBeCloseTo(5.3 * 18.016, 3); + }); + + it("refuses an unrecognised or type-mismatched unit (never mis-stores)", () => { + expect(resolveToCanonicalUnit("WEIGHT", 12, "stone")).toBeNull(); + expect(resolveToCanonicalUnit("WEIGHT", 12, "°F")).toBeNull(); + // lb is not a waist unit + expect(resolveToCanonicalUnit("WAIST_CIRCUMFERENCE", 12, "lb")).toBeNull(); + // mmol/L is not a weight unit + expect(resolveToCanonicalUnit("WEIGHT", 12, "mmol/L")).toBeNull(); + }); +}); From 6cf56ce456ab4ca307dc6b76e268e168d264bb74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 00:34:51 +0200 Subject: [PATCH 5/7] test: mock the unit-display hook in the crosstab, vitals, and MCP suites The crosstab, vitals, and cycle-summary renders are static server renders with no QueryClient; the new preferred-unit hook reads through the auth query, so mock it to a real metric-preference implementation (identity values, canonical symbols) that matches the render those suites assert. Register the new unit resolver on the MCP write-tools module mock so the preview path resolves. --- .../cycle-insight-summary-card.test.tsx | 23 ++++++++++++++++ .../__tests__/cycle-phase-crosstab.test.tsx | 27 ++++++++++++++++++- .../__tests__/vitals-dashboard.test.tsx | 25 +++++++++++++++++ src/lib/mcp/__tests__/write-tools.test.ts | 7 +++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx index f7a72f259..385df6d05 100644 --- a/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx +++ b/src/components/cycle/__tests__/cycle-insight-summary-card.test.tsx @@ -1,6 +1,29 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +// The embedded crosstab reads the unit preference through `useUnitDisplay`, +// which hangs off `useAuth` (a react-query hook). These are static SSR renders +// with no QueryClient, so mock the hook to a real metric-preference impl. +vi.mock("@/hooks/use-unit-display", async () => { + const dt = await import("@/lib/measurements/display-transform"); + const forType = (t: string) => dt.getDisplayTransform(t, "metric"); + return { + useUnitDisplay: () => ({ + preference: "metric" as const, + transformFor: forType, + toDisplay: (t: string, v: number) => + dt.applyDisplayTransform(v, forType(t)), + fromDisplay: (t: string, v: number) => + dt.invertDisplayTransform(v, forType(t)), + toDisplayDelta: (t: string, v: number) => + dt.applyDisplayTransformDelta(v, forType(t)), + unitFor: (t: string) => forType(t).displayUnit, + decimalsFor: (t: string) => forType(t).decimals, + isTransformed: (t: string) => dt.hasDisplayTransform(t), + }), + }; +}); + import { I18nProvider } from "@/lib/i18n/context"; import type { CalendarResponse } from "../types"; import type { CycleInsightsResponse } from "../use-cycle"; diff --git a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx index 1691a7f67..82aec2b28 100644 --- a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx +++ b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx @@ -1,6 +1,31 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +// The crosstab reads the unit preference through `useUnitDisplay`, which hangs +// off `useAuth` (a react-query hook). These are static SSR renders with no +// QueryClient, so mock the hook to a real metric-preference implementation — +// metric is the identity on values and yields the canonical display symbols, +// so the assertions below (kg, °C, unchanged numbers) hold. +vi.mock("@/hooks/use-unit-display", async () => { + const dt = await import("@/lib/measurements/display-transform"); + const forType = (t: string) => dt.getDisplayTransform(t, "metric"); + return { + useUnitDisplay: () => ({ + preference: "metric" as const, + transformFor: forType, + toDisplay: (t: string, v: number) => + dt.applyDisplayTransform(v, forType(t)), + fromDisplay: (t: string, v: number) => + dt.invertDisplayTransform(v, forType(t)), + toDisplayDelta: (t: string, v: number) => + dt.applyDisplayTransformDelta(v, forType(t)), + unitFor: (t: string) => forType(t).displayUnit, + decimalsFor: (t: string) => forType(t).decimals, + isTransformed: (t: string) => dt.hasDisplayTransform(t), + }), + }; +}); + import { CyclePhaseCrosstab, CyclePhaseHeadline, diff --git a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx index 479f26fb8..179104963 100644 --- a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx +++ b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx @@ -1,6 +1,31 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +// The vitals tiles read the unit preference through `useUnitDisplay`, which +// hangs off `useAuth` (a react-query hook). These are static SSR renders with +// no QueryClient, so mock the hook to a real metric-preference implementation +// — metric is the identity on values and yields the canonical display symbols +// (e.g. °C), matching the pre-fix render the assertions expect. +vi.mock("@/hooks/use-unit-display", async () => { + const dt = await import("@/lib/measurements/display-transform"); + const forType = (t: string) => dt.getDisplayTransform(t, "metric"); + return { + useUnitDisplay: () => ({ + preference: "metric" as const, + transformFor: forType, + toDisplay: (t: string, v: number) => + dt.applyDisplayTransform(v, forType(t)), + fromDisplay: (t: string, v: number) => + dt.invertDisplayTransform(v, forType(t)), + toDisplayDelta: (t: string, v: number) => + dt.applyDisplayTransformDelta(v, forType(t)), + unitFor: (t: string) => forType(t).displayUnit, + decimalsFor: (t: string) => forType(t).decimals, + isTransformed: (t: string) => dt.hasDisplayTransform(t), + }), + }; +}); + import { I18nProvider } from "@/lib/i18n/context"; import type { DerivedMetricResponse, diff --git a/src/lib/mcp/__tests__/write-tools.test.ts b/src/lib/mcp/__tests__/write-tools.test.ts index d677f3ba6..d8eb0b8cd 100644 --- a/src/lib/mcp/__tests__/write-tools.test.ts +++ b/src/lib/mcp/__tests__/write-tools.test.ts @@ -5,12 +5,15 @@ const logMcpMood = vi.fn(); const logMcpBloodPressure = vi.fn(); const checkMcpMeasurement = vi.fn(); const checkMcpBloodPressure = vi.fn(); +const resolveMcpMeasurementUnit = vi.fn(); vi.mock("../writes", () => ({ logMcpMeasurement: (...a: unknown[]) => logMcpMeasurement(...a), logMcpMood: (...a: unknown[]) => logMcpMood(...a), logMcpBloodPressure: (...a: unknown[]) => logMcpBloodPressure(...a), checkMcpMeasurement: (...a: unknown[]) => checkMcpMeasurement(...a), checkMcpBloodPressure: (...a: unknown[]) => checkMcpBloodPressure(...a), + resolveMcpMeasurementUnit: (...a: unknown[]) => + resolveMcpMeasurementUnit(...a), })); const checkMcpWriteRateLimit = vi.fn(); @@ -49,6 +52,10 @@ beforeEach(() => { // would-be record. Individual tests override to assert wouldFail. checkMcpMeasurement.mockReturnValue({ ok: true }); checkMcpBloodPressure.mockReturnValue({ ok: true }); + // Default: the unit hint resolves to canonical, echoing the value through. + resolveMcpMeasurementUnit.mockImplementation( + (_type: string, value: number) => ({ ok: true, value, unit: "kg" }), + ); }); describe("write-tool surface", () => { From a9b2ab2d2b8bc4aa63c490f4d357386c536378bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 00:35:07 +0200 Subject: [PATCH 6/7] =?UTF-8?q?chore(release):=20v1.32.26=20=E2=80=94=20ap?= =?UTF-8?q?ply=20the=20metric/imperial=20preference=20across=20every=20rea?= =?UTF-8?q?ding=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface a self-hoster's report that the unit preference was stored but read by almost no display. Weight, body composition, waist, and temperature now follow metric or imperial at entry and everywhere they render, storage stays canonical SI, and the MCP logging tool converts a known unit hint or refuses an unknown one. OpenAPI diff is version-only; the wire stays canonical. --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c8f92b9..563cd824e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ ## [Unreleased] +## [1.32.26] — 2026-07-24 + +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; an imperial + height entry is a separate follow-up. + ## [1.32.25] — 2026-07-24 Mirrored medications are now marked as such, and the server limits how many diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 37001e20a..35d684eff 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.25 + version: 1.32.26 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 42b589920..a39b086fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.25", + "version": "1.32.26", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index c27c3ffc0..b965a2c19 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.25"; + /* @sw-version-fallback */ "v1.32.26"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From 2e5841c3375dfc6ee473fcaaf0ef469d7c9ceaf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 00:37:28 +0200 Subject: [PATCH 7/7] docs(changelog): date v1.32.26 and note the target-panel unit gap --- CHANGELOG.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 563cd824e..e93ca89f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -## [1.32.26] — 2026-07-24 +## [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 @@ -25,8 +25,11 @@ reported the gap and traced how the unit was being handled. 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; an imperial - height entry is a separate follow-up. +- **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