Files
HealthLog/src/lib/measurements/display-transform.ts
T
Marc-André Bombeck 84d78d2926 fix(units): round a converted value where the conversion happens
Every entry in the display-transform registry declares the decimals its
unit is read at. That number was carried to the render layer and read by
exactly one screen out of all the ones that show a converted value, so
everywhere else the raw product of a kilogram and 2.20462262185 reached
the surface. Pounds came out with a dozen digits it never needed.

Move the rounding into `applyDisplayTransform` and
`applyDisplayTransformDelta`, so a surface gets a correctly-scaled number
whether or not it remembers to ask. Patching the weight cell would have
left the same trap behind inches, Fahrenheit, miles per hour and miles.

Two boundaries stay exempt and are documented as such. A transform that
does not rescale is the exact identity and is never rounded, so a stored
78.45 kg keeps rendering as 78.45 on the metric branch and every
untransformed metric is byte-identical. `invertDisplayTransform` — the
entry boundary — keeps full precision, because a value rounded on the way
out and inverted on the way back would drift the stored canonical number
a little further on every save; the entry surfaces quantise to their own
two-decimal canonical dialect instead.

`applyDisplayTransformUnrounded` is the one documented escape hatch, for
arithmetic whose result is not rendered. The target adapter needs it: its
guardrails round inward, and rounding the 30 kg floor to the nearest
tenth of a pound first gives an edge that inverts to 29.98 kg and the
server rejects it.

The weight page and the HealthKit scaffold were folding the factor into
their stat strips by hand. Both now convert through the helper, so the
raw multiplier is only ever handed to the chart, which owns its own
series and formatting.

Formatting is unchanged. Precision is fixed at the conversion and the
surfaces keep the formatter they already used, so switching preference
changes the unit and nothing else about how the number looks.

Refs #627
2026-07-26 00:46:03 +02:00

314 lines
13 KiB
TypeScript

/**
* v1.7.0 — display-time unit transforms.
*
* Canonical storage stays SI (the iOS wire contract + the Withings
* passthrough both lock raw SI — see the convention block in
* `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.
* - WALKING_RUNNING_DISTANCE is stored in metres but a 5 km walk
* 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 + 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.
*
* 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.
*
* v1.32.39 — the conversion helpers ROUND to the transform's own
* `decimals`. Every transform has always declared how many decimals its
* unit is read at, but the number was handed to the render layer and
* only one screen ever asked for it, so everywhere else the raw product
* of a kilogram and 2.20462262185 went straight to the surface
* ("185.18790000000001 lb"). Rounding at the conversion means a surface
* gets a correctly-scaled number whether or not it remembers to ask, and
* a new converted-value surface cannot reintroduce the class.
*
* Two boundaries are deliberately exempt:
*
* - A transform that does NOT rescale (`factor === 1`, no offset) is
* the exact identity, never rounded. That covers every untransformed
* type and the whole metric branch of the mass / length /
* temperature registry, whose numbers are the stored values
* themselves — a stored 78.45 kg is not conversion noise and must
* keep rendering as 78.45. `transformRescales()` is the predicate.
* - `invertDisplayTransform` — the ENTRY boundary — stays at full
* precision. A value rounded on the way out and inverted on the way
* back would drift the stored canonical number; the entry surfaces
* quantise the inverted value to their own canonical dialect.
*
* `applyDisplayTransformUnrounded` is the single documented escape
* hatch, for arithmetic whose result is NOT rendered (the target
* adapter's inward-rounded guardrails, which need the unrounded edge to
* round the right way). Its call sites are frozen by
* `src/__tests__/display-transform-rounding-guard.test.ts`.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { getUnitForType } from "@/lib/validations/measurement";
export type UnitPreference = "metric" | "imperial";
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. */
decimals: number;
}
/** Identity transform — raw canonical value, canonical unit. */
const IDENTITY: DisplayTransform = {
factor: 1,
displayUnit: "",
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. Types absent here resolve to the
* identity transform with the canonical unit from `getUnitForType()`.
*
* 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 },
imperial: { factor: 2.2369362920544, displayUnit: "mph", decimals: 1 },
},
// Daily totals — 1 m = 0.001 km = 0.000621371192 mi.
WALKING_RUNNING_DISTANCE: {
metric: { factor: 0.001, displayUnit: "km", decimals: 2 },
imperial: { factor: 0.000621371192237, displayUnit: "mi", decimals: 2 },
},
};
/**
* 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
* type that has no registered transform, so call sites can apply the
* result unconditionally.
*/
export function getDisplayTransform(
type: string,
preference: UnitPreference = DEFAULT_UNIT_PREFERENCE,
): DisplayTransform {
const perType = TRANSFORMS[type as MeasurementType];
if (!perType) {
return { ...IDENTITY, displayUnit: getUnitForType(type) };
}
return perType[preference] ?? perType.metric;
}
/**
* v1.16.4 — count metrics are integers by definition; any fractional
* part on a stored sample is source noise (HK chunk splitting, device
* estimation), so the raw-value surfaces render them with 0 decimals
* ("9.689 steps", never "9.688,595 steps"). Every other type keeps the
* formatter's default precision (≤3 digits, trailing zeros dropped).
*/
const INTEGER_DISPLAY_TYPES: ReadonlySet<string> = new Set([
"ACTIVITY_STEPS",
"FLIGHTS_CLIMBED",
"FALL_COUNT",
// kcal + daylight minutes — whole units are the meaningful display
// grain for these day-cumulative counts.
"ACTIVE_ENERGY_BURNED",
"TIME_IN_DAYLIGHT",
]);
/**
* Display fraction digits for a RAW (untransformed) value of `type`.
* `0` for integer count metrics, `undefined` (= formatter default)
* otherwise — pass the result straight into `Formatters.number`.
*/
export function rawDisplayFractionDigits(type: string): number | undefined {
return INTEGER_DISPLAY_TYPES.has(type) ? 0 : undefined;
}
/**
* True when a transform actually rescales the number it is applied to
* (as opposed to only relabelling the unit symbol). False for the
* identity transform of an untransformed type and for the metric branch
* of every mass / length / temperature entry, where the displayed number
* IS the stored number.
*/
export function transformRescales(transform: DisplayTransform): boolean {
return transform.factor !== 1 || (transform.offset ?? 0) !== 0;
}
/** Round to `decimals` fraction digits. Non-finite input passes through. */
function roundToDecimals(value: number, decimals: number): number {
if (!Number.isFinite(value)) return value;
const scale = 10 ** decimals;
return Math.round(value * scale) / scale;
}
/**
* The raw affine arithmetic — `raw * factor + offset`, NO rounding.
*
* Escape hatch, not the default. Use it only where the result is a
* computation input rather than something a surface renders: the target
* adapter needs the unrounded pound edge of the 30 kg guardrail to round
* it INWARD (rounding it to the nearest tenth first gives 66.1 lb, which
* inverts to 29.98 kg and the server rejects it). Anything that reaches
* a screen takes `applyDisplayTransform`. The call sites of this export
* are frozen by the rounding guard test.
*/
export function applyDisplayTransformUnrounded(
rawValue: number,
transform: DisplayTransform,
): number {
return rawValue * transform.factor + (transform.offset ?? 0);
}
/**
* Apply a transform to a raw canonical ABSOLUTE value:
* `display = round(raw * factor + offset, decimals)`. Pure helper —
* keeps the arithmetic in one place so the chart, list cell, tooltip,
* and entry form all scale identically, and rounds to the transform's
* declared `decimals` so no surface can render a converted value at
* more precision than its unit is read at.
*
* A transform that does not rescale returns the raw value untouched —
* the metric branch stays byte-identical to storage.
*/
export function applyDisplayTransform(
rawValue: number,
transform: DisplayTransform,
): number {
if (!transformRescales(transform)) return rawValue;
return roundToDecimals(
applyDisplayTransformUnrounded(rawValue, transform),
transform.decimals,
);
}
/**
* 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.
*
* Rounded to the same `decimals` as an absolute value: every consumer of
* a converted delta in the tree renders it (the dashboard 7-day trend,
* the cycle and mood crosstab rows, the target range bar), and a delta
* shown at a finer grain than the readings it was computed from is the
* same defect in a different place. Comparisons in the tree are made on
* canonical values, never on a converted delta; a future one that needs
* the unrounded difference takes `applyDisplayTransformUnrounded`.
*
* A factor of 1 returns the delta untouched (the offset is irrelevant to
* a difference, so the metric branch is the exact identity here too).
*/
export function applyDisplayTransformDelta(
deltaValue: number,
transform: DisplayTransform,
): number {
if (transform.factor === 1) return deltaValue;
return roundToDecimals(deltaValue * transform.factor, transform.decimals);
}
/**
* 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.
*
* Deliberately NOT rounded — this is the write path. Rounding here would
* quantise the stored canonical value to the display unit's grain and
* drift it a little further on every save. The entry surfaces round the
* result to their own canonical dialect (two decimals) instead.
*/
export function invertDisplayTransform(
displayValue: number,
transform: DisplayTransform,
): number {
return (displayValue - (transform.offset ?? 0)) / transform.factor;
}