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; +}