fix(measurements): honor the unit preference at entry and in the record list

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-24 23:58:45 +02:00
1 parent 14dcab786e
commit dd7213db5c
8 files changed
+485 -94

No files matched your search

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