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
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 00:46:03 +02:00
1 parent 708846f72b
commit 84d78d2926
6 files changed
+496 -49

No files matched your search

@@ -0,0 +1,207 @@
/**
* Guard for the converted-value precision class (issue #627, second half).
*
* Every entry in the display-transform registry declares the `decimals` its
* unit is read at. Before v1.32.39 that number was carried all the way 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 unrounded. The fix moved the rounding into
* the conversion helpers, which means a surface gets a correctly-scaled number
* whether or not it remembers to ask.
*
* This file guards the two ways that could be undone:
*
* 1. A PROPERTY over the whole registry — every branch, swept over a spread
* of raw values, must come out already at its declared grain, and a
* non-rescaling branch must come out byte-identical. A property test is
* the honest shape here: the thing being guaranteed is arithmetic, and a
* structural sweep for "did the surface remember to round" is exactly the
* per-call-site discipline this change exists to abolish.
* 2. A STRUCTURAL freeze on the two ways to get an unrounded converted value
* anyway — importing the documented escape hatch, or hand-rolling
* `value * transform.factor + transform.offset` at a call site. Both sets
* are pinned to a named allowlist, so a new converted-value surface
* cannot quietly join them.
*/
import { readFileSync, globSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getDisplayTransform,
transformRescales,
TRANSFORMED_TYPES,
} from "@/lib/measurements/display-transform";
const SRC = join(process.cwd(), "src");
/** The registry itself owns the arithmetic; it is not a call site. */
const REGISTRY = "lib/measurements/display-transform.ts";
/**
* The one module allowed to import the unrounded escape hatch. The target
* adapter needs the unrounded pound edge of a canonical guardrail so it can
* round it INWARD — rounding to the nearest tenth first gives 66.1 lb for the
* 30 kg floor, which inverts to 29.98 kg and the server rejects it.
*/
const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
/**
* The surfaces allowed to read a transform's raw `factor` / `offset`. All four
* do it for the same reason: the chart takes the multiplier as a prop, folds
* it into its own series at the single read boundary, and formats the result
* itself. Everything those files RENDER directly goes through the rounding
* helpers. Adding a file here means arguing that its number never reaches a
* screen unrounded.
*/
const RAW_SCALE_SURFACES = [
"app/page-client.tsx",
"app/insights/weight/page.tsx",
"components/insights/healthkit-metric-page.tsx",
"components/insights/device-score-tile.tsx",
];
/** Matches a `factor` / `offset` read off a display transform. */
const RAW_SCALE_READ =
/\b\w*[Tt]ransform(?:For\([^)]*\))?\??\.(factor|offset)\b/;
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC }).filter(
(rel) =>
// Never walk the Prisma client, and skip the suites that legitimately
// exercise the raw arithmetic they are asserting about.
!rel.startsWith("generated/") && !rel.includes("__tests__/"),
);
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
describe("display-transform rounding guard — the registry property", () => {
const RAW_VALUES = [
0, 1, 1.3, 10, 36.6, 36.66666, 37, 78.45, 83.99981845627636, 84, 95.25, 210,
1609.344, 8046.72, -0.5, -12.3456789,
];
it("sweeps a non-empty registry", () => {
// Guards against a set that silently empties out (a green void).
expect(TRANSFORMED_TYPES.size).toBeGreaterThan(5);
});
it("declares a usable decimal count on every branch", () => {
for (const type of TRANSFORMED_TYPES) {
for (const preference of ["metric", "imperial"] as const) {
const { decimals } = getDisplayTransform(type, preference);
expect(Number.isInteger(decimals)).toBe(true);
expect(decimals).toBeGreaterThanOrEqual(0);
}
}
});
it("never returns a converted value finer than its declared decimals", () => {
const offenders: string[] = [];
for (const type of TRANSFORMED_TYPES) {
for (const preference of ["metric", "imperial"] as const) {
const transform = getDisplayTransform(type, preference);
if (!transformRescales(transform)) continue;
const scale = 10 ** transform.decimals;
for (const raw of RAW_VALUES) {
for (const [label, shown] of [
["absolute", applyDisplayTransform(raw, transform)],
["delta", applyDisplayTransformDelta(raw, transform)],
] as const) {
// Re-rounding at the declared grain must be a no-op: the value
// carries no more precision than the transform says it should.
if (Math.round(shown * scale) / scale !== shown) {
offenders.push(
`${type}/${preference} ${label} ${raw} → ${shown}`,
);
}
}
}
}
}
expect(offenders).toEqual([]);
});
it("leaves a non-rescaling branch byte-identical", () => {
// The metric branch only relabels the symbol; the number it shows IS the
// stored number, and a stored 78.45 kg is not conversion noise.
const offenders: string[] = [];
for (const type of TRANSFORMED_TYPES) {
for (const preference of ["metric", "imperial"] as const) {
const transform = getDisplayTransform(type, preference);
if (transformRescales(transform)) continue;
for (const raw of RAW_VALUES) {
if (applyDisplayTransform(raw, transform) !== raw) {
offenders.push(`${type}/${preference} absolute ${raw}`);
}
if (applyDisplayTransformDelta(raw, transform) !== raw) {
offenders.push(`${type}/${preference} delta ${raw}`);
}
}
}
}
expect(offenders).toEqual([]);
});
it("leaves an untransformed type byte-identical", () => {
for (const type of ["PULSE", "BLOOD_GLUCOSE", "BODY_MASS_INDEX"]) {
const transform = getDisplayTransform(type, "imperial");
for (const raw of RAW_VALUES) {
expect(applyDisplayTransform(raw, transform)).toBe(raw);
expect(applyDisplayTransformDelta(raw, transform)).toBe(raw);
}
}
});
});
describe("display-transform rounding guard — the bypass freeze", () => {
it("sweeps a non-empty source tree", () => {
expect(sourceFiles().length).toBeGreaterThan(500);
});
it("confines the unrounded escape hatch to its documented importers", () => {
const importers = sourceFiles().filter(
(rel) =>
rel !== REGISTRY &&
read(rel).includes("applyDisplayTransformUnrounded"),
);
expect(importers.sort()).toEqual([...UNROUNDED_IMPORTERS].sort());
});
it("confines hand-rolled transform scaling to the chart-feeding surfaces", () => {
const offenders = sourceFiles().filter(
(rel) =>
rel !== REGISTRY &&
!UNROUNDED_IMPORTERS.includes(rel) &&
RAW_SCALE_READ.test(read(rel)),
);
expect(offenders.sort()).toEqual([...RAW_SCALE_SURFACES].sort());
});
it("every allowlisted surface reads the raw scale to feed a chart", () => {
// The justification for holding the raw multiplier at all: the chart
// takes it as a prop. A file that stops passing it has no reason to be
// reading `.factor` any more and belongs back behind the helpers.
// The prop as it is actually passed — a bare substring would still
// match a renamed identifier that no longer reaches any chart.
const offenders = RAW_SCALE_SURFACES.filter(
(rel) => !/\bvalueScale=/.test(read(rel)),
);
expect(offenders).toEqual([]);
});
it("every allowlisted surface still converts its rendered values", () => {
// The chart formats its own series; anything these files render outside
// the chart must come back through the preference hook.
const offenders = RAW_SCALE_SURFACES.filter(
(rel) => !read(rel).includes("useUnitDisplay()"),
);
expect(offenders).toEqual([]);
});
});
+17 -6
View File
@@ -50,22 +50,32 @@ export default function InsightsGewichtPage() {
const weightTransform = unitDisplay.transformFor("WEIGHT");
const weightUnit = weightTransform.displayUnit;
const weightScale = weightTransform.factor;
// v1.32.39 — the strip + band values convert through `toDisplay`, which
// rounds to the transform's own decimals. `weightScale` stays for the chart
// alone: it folds the factor into its series and formats at its own
// precision, so it needs the raw multiplier rather than a rounded value.
const toDisplayWeight = (value: number) =>
unitDisplay.toDisplay("WEIGHT", value);
const displaySummary =
weightSummary && weightScale !== 1
? {
...weightSummary,
min:
weightSummary.min === null ? null : weightSummary.min * weightScale,
weightSummary.min === null
? null
: toDisplayWeight(weightSummary.min),
max:
weightSummary.max === null ? null : weightSummary.max * weightScale,
weightSummary.max === null
? null
: toDisplayWeight(weightSummary.max),
mean:
weightSummary.mean === null
? null
: weightSummary.mean * weightScale,
: toDisplayWeight(weightSummary.mean),
median:
weightSummary.median === null
? null
: weightSummary.median * weightScale,
: toDisplayWeight(weightSummary.median),
}
: weightSummary;
@@ -108,8 +118,8 @@ export default function InsightsGewichtPage() {
weightBands && weightScale !== 1
? weightBands.map((band) => ({
...band,
min: band.min * weightScale,
max: band.max * weightScale,
min: toDisplayWeight(band.min),
max: toDisplayWeight(band.max),
}))
: weightBands;
@@ -122,6 +132,7 @@ export default function InsightsGewichtPage() {
<MetricStatStrip
summary={displaySummary}
unit={weightUnit}
fractionDigits={weightTransform.decimals}
seriesLabel={t("insights.weightSectionTitle")}
icon={Scale}
windowStats={statsByType?.WEIGHT ?? null}
@@ -288,26 +288,24 @@ export function HealthKitMetricPage({
// 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.
// v1.32.39 — a transformed type converts through `toDisplay`, which rounds
// to the transform's own decimals. Hand-rolling `value * scale + offset`
// here is what let a converted stat reach the strip unrounded; the raw
// scale below is now only ever handed to the chart, which folds it into its
// own series and formats at its own precision.
const toStripValue = (value: number) =>
transform
? unitDisplay.toDisplay(effectiveType, value)
: value * resolvedScale + resolvedOffset;
const summary =
rawSummary && (resolvedScale !== 1 || resolvedOffset !== 0)
? {
...rawSummary,
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,
min: rawSummary.min === null ? null : toStripValue(rawSummary.min),
max: rawSummary.max === null ? null : toStripValue(rawSummary.max),
mean: rawSummary.mean === null ? null : toStripValue(rawSummary.mean),
median:
rawSummary.median === null
? null
: rawSummary.median * resolvedScale + resolvedOffset,
rawSummary.median === null ? null : toStripValue(rawSummary.median),
}
: rawSummary;
@@ -3,9 +3,11 @@ import { describe, it, expect } from "vitest";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
applyDisplayTransformUnrounded,
invertDisplayTransform,
getDisplayTransform,
hasDisplayTransform,
transformRescales,
TRANSFORMED_TYPES,
DEFAULT_UNIT_PREFERENCE,
} from "../display-transform";
@@ -16,10 +18,9 @@ describe("display-transform", () => {
expect(t.factor).toBe(3.6);
expect(t.displayUnit).toBe("km/h");
expect(t.decimals).toBe(1);
// 1.3 m/s → 4.68 km/h (1 dp → 4.7).
const scaled = applyDisplayTransform(1.3, t);
expect(scaled).toBeCloseTo(4.68, 5);
expect(Number(scaled.toFixed(t.decimals))).toBe(4.7);
// 1.3 m/s → 4.68 km/h, rendered at the declared single decimal.
expect(applyDisplayTransform(1.3, t)).toBe(4.7);
expect(applyDisplayTransformUnrounded(1.3, t)).toBeCloseTo(4.68, 5);
});
it("converts WALKING_RUNNING_DISTANCE m → km (factor 0.001)", () => {
@@ -34,13 +35,14 @@ describe("display-transform", () => {
it("exposes imperial branches for speed + distance", () => {
const speed = getDisplayTransform("WALKING_SPEED", "imperial");
expect(speed.displayUnit).toBe("mph");
// 1.34 m/s ≈ 3.0 mph.
expect(applyDisplayTransform(1.34, speed)).toBeCloseTo(2.9975, 3);
// 1.34 m/s ≈ 2.9975 mph → 3 mph at the declared single decimal.
expect(applyDisplayTransform(1.34, speed)).toBe(3);
expect(applyDisplayTransformUnrounded(1.34, speed)).toBeCloseTo(2.9975, 3);
const dist = getDisplayTransform("WALKING_RUNNING_DISTANCE", "imperial");
expect(dist.displayUnit).toBe("mi");
// 1609.34 m ≈ 1 mile.
expect(applyDisplayTransform(1609.344, dist)).toBeCloseTo(1, 3);
// 1609.34 m ≈ 1 mile, at the two decimals a distance is read at.
expect(applyDisplayTransform(1609.344, dist)).toBe(1);
});
it("returns identity (factor 1) for untransformed types", () => {
@@ -97,18 +99,18 @@ describe("display-transform", () => {
]) {
expect(getDisplayTransform(type, "imperial").displayUnit).toBe("lb");
expect(getDisplayTransform(type, "metric").displayUnit).toBe("kg");
// 10 kg → 22.05 lb.
// 10 kg → 22.0462… lb → 22 lb at the pound's single decimal.
expect(
applyDisplayTransform(10, getDisplayTransform(type, "imperial")),
).toBeCloseTo(22.0462, 3);
).toBe(22);
}
});
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);
// 84 cm → 33.0708… in → 33.1 in at the inch's single decimal.
expect(applyDisplayTransform(84, imperial)).toBe(33.1);
});
it("converts an ABSOLUTE temperature °C → °F affinely (factor + offset)", () => {
@@ -120,8 +122,13 @@ describe("display-transform", () => {
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);
// 36.6 °C → 97.88 °F (the +32 shift MUST apply to an absolute reading),
// rendered at the declared single decimal.
expect(applyDisplayTransform(36.6, imperial)).toBe(97.9);
expect(applyDisplayTransformUnrounded(36.6, imperial)).toBeCloseTo(
97.88,
2,
);
// 0 °C → 32 °F (not 0).
expect(applyDisplayTransform(0, imperial)).toBe(32);
});
@@ -156,11 +163,15 @@ describe("display-transform", () => {
});
it("inverse round-trips every registered branch (entry ⇄ display)", () => {
// The entry boundary works on the UNROUNDED conversion: a display
// value the surface produced must invert back to the exact canonical
// number it came from. `applyDisplayTransform` rounds for the screen,
// so the round-trip property is stated against the raw arithmetic.
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);
const shown = applyDisplayTransformUnrounded(raw, transform);
expect(invertDisplayTransform(shown, transform)).toBeCloseTo(raw, 6);
}
}
@@ -182,3 +193,118 @@ describe("display-transform", () => {
}
});
});
/**
* v1.32.39 — issue #627 follow-up. A conversion declares the decimals its
* unit is read at; before this the number was carried to the display layer
* and read by one screen out of all the ones that render a converted value,
* so everywhere else the raw product of a kilogram and 2.20462262185 went
* straight to the surface.
*/
describe("display-transform — converted values carry the declared precision", () => {
it("renders the reported kilogram reading at one decimal, not seventeen", () => {
// The reporter's case: a stored weight whose pound conversion is a long
// float. 83.99981845627636 kg * 2.20462262185 is 185.1879000… lb.
const imperial = getDisplayTransform("WEIGHT", "imperial");
const raw = 83.99981845627636;
expect(String(applyDisplayTransformUnrounded(raw, imperial))).toMatch(
/^185\.1879/,
);
expect(applyDisplayTransform(raw, imperial)).toBe(185.2);
expect(String(applyDisplayTransform(raw, imperial))).toBe("185.2");
});
it("rounds every converted unit family to its own declared decimals", () => {
const cases: Array<[string, number, number]> = [
// mass: kg → lb, one decimal
["WEIGHT", 83.99981845627636, 185.2],
// length: cm → in, one decimal
["WAIST_CIRCUMFERENCE", 84, 33.1],
// temperature: °C → °F, affine (the offset must survive rounding)
["BODY_TEMPERATURE", 36.66666, 98],
// speed: m/s → mph, one decimal
["WALKING_SPEED", 1.3333333, 2.98],
// distance: m → mi, two decimals
["WALKING_RUNNING_DISTANCE", 8046.72, 5],
];
for (const [type, raw, expected] of cases) {
const transform = getDisplayTransform(type, "imperial");
const shown = applyDisplayTransform(raw, transform);
// Re-rounding at the declared grain is a no-op: the value carries no
// more precision than the transform says it should.
const scale = 10 ** transform.decimals;
expect(Math.round(shown * scale) / scale).toBe(shown);
expect(shown).toBeCloseTo(expected, transform.decimals);
}
});
it("keeps the affine offset exact through the rounding", () => {
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
// 0 °C is 32 °F on the nose; rounding must not shift a clean value.
expect(applyDisplayTransform(0, imperial)).toBe(32);
expect(applyDisplayTransform(37, imperial)).toBe(98.6);
// 36.66666 °C = 97.999988 °F → 98.0, not 97.999988.
expect(applyDisplayTransform(36.66666, imperial)).toBe(98);
});
it("rounds a converted DELTA to the same grain, offset-free", () => {
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
// A 0.37 °C rise is a 0.666 °F rise → 0.7, and never picks up the +32.
expect(applyDisplayTransformDelta(0.37, imperial)).toBe(0.7);
const mass = getDisplayTransform("WEIGHT", "imperial");
expect(applyDisplayTransformDelta(-0.5, mass)).toBe(-1.1);
});
it("leaves a metric-preference value completely untouched", () => {
// The metric branch of every registered type is the identity on the
// number — it only relabels the symbol. A stored 78.45 kg is not
// conversion noise and must not be rounded away to 78.5.
for (const type of TRANSFORMED_TYPES) {
const metric = getDisplayTransform(type, "metric");
if (transformRescales(metric)) continue;
for (const raw of [78.45, 0.123456789, 95.25, 36.66666]) {
expect(applyDisplayTransform(raw, metric)).toBe(raw);
expect(applyDisplayTransformDelta(raw, metric)).toBe(raw);
}
}
// Same for a type with no transform at all.
const identity = getDisplayTransform("PULSE");
expect(applyDisplayTransform(58.123456789, identity)).toBe(58.123456789);
expect(applyDisplayTransformDelta(58.123456789, identity)).toBe(
58.123456789,
);
});
it("keeps the entry path free of drift on a typed imperial value", () => {
// What the entry surfaces actually do: invert the typed display number
// (full precision, no rounding), then quantise to the two-decimal
// canonical dialect. Reopening must show the number that was typed.
const imperial = getDisplayTransform("WEIGHT", "imperial");
for (let typed = 70; typed <= 660; typed += 0.1) {
const shown = Math.round(typed * 10) / 10;
const inverted = invertDisplayTransform(shown, imperial);
const canonical = Math.round(inverted * 100) / 100;
expect(applyDisplayTransform(canonical, imperial)).toBe(shown);
}
});
it("keeps the entry path free of drift on a typed Fahrenheit value", () => {
const imperial = getDisplayTransform("BODY_TEMPERATURE", "imperial");
for (let typed = 95; typed <= 106; typed += 0.1) {
const shown = Math.round(typed * 10) / 10;
const canonical =
Math.round(invertDisplayTransform(shown, imperial) * 100) / 100;
expect(applyDisplayTransform(canonical, imperial)).toBe(shown);
}
});
it("never rounds the inverse — the write path keeps full precision", () => {
const imperial = getDisplayTransform("WEIGHT", "imperial");
// 185.2 lb is 84.00825… kg. Quantising here instead of at the entry
// surface would push the stored value a little further on every save.
expect(invertDisplayTransform(185.2, imperial)).toBe(
185.2 / imperial.factor,
);
expect(invertDisplayTransform(185.2, imperial)).not.toBe(84);
});
});
+96 -6
View File
@@ -30,6 +30,34 @@
* 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";
@@ -186,34 +214,96 @@ export function rawDisplayFractionDigits(type: string): number | undefined {
}
/**
* 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.
* 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 applyDisplayTransform(
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 {
return deltaValue * transform.factor;
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,
+22 -7
View File
@@ -19,7 +19,9 @@
* The four rules that make the round-trip safe:
*
* 1. `toDisplay` rounds to the transform's `decimals`, so the field
* shows "150" and not "150.00000000000003".
* shows "150" and not "150.00000000000003". Since v1.32.39 that
* rounding lives in `applyDisplayTransform` itself, so this adapter
* inherits it rather than re-applying it.
* 2. `toCanonical` rounds the inverted value to 2 decimals — the same
* dialect the measurement entry form uses — so 150 lb persists as
* 68.04 kg, not 68.03885550000001.
@@ -39,9 +41,11 @@
import {
applyDisplayTransform,
applyDisplayTransformDelta,
applyDisplayTransformUnrounded,
getDisplayTransform,
hasDisplayTransform,
invertDisplayTransform,
transformRescales,
type UnitPreference,
} from "@/lib/measurements/display-transform";
@@ -135,7 +139,7 @@ export function resolveTargetUnitAdapter(
return identityAdapter(metric, canonicalUnit);
const transform = getDisplayTransform(metric, preference);
const rescales = transform.factor !== 1 || (transform.offset ?? 0) !== 0;
const rescales = transformRescales(transform);
// A transformed type on the metric branch keeps the transform's
// DISPLAY symbol (which is the canonical symbol for every threshold
@@ -148,15 +152,26 @@ export function resolveTargetUnitAdapter(
unit: transform.displayUnit,
step: metric === "ACTIVITY_STEPS" ? STEP_COUNT_INPUT_STEP : 10 ** -decimals,
rescales: true,
toDisplay: (canonical) =>
roundTo(applyDisplayTransform(canonical, transform), decimals),
// Both converters round to `decimals` inside the shared helper.
toDisplay: (canonical) => applyDisplayTransform(canonical, transform),
toCanonical: (display) =>
roundTo(invertDisplayTransform(display, transform), CANONICAL_DECIMALS),
toDisplayDelta: (canonicalDelta) =>
roundTo(applyDisplayTransformDelta(canonicalDelta, transform), decimals),
applyDisplayTransformDelta(canonicalDelta, transform),
// A guardrail is not a rendered value — it is the window a typed
// number is checked against, and it has to round INWARD (rule 4).
// That needs the UNROUNDED edge: the shared helper would round 30 kg
// to 66.1 lb first and the ceiling below would then be a no-op on an
// edge that already inverts to 29.98 kg.
bounds: (canonical) => ({
min: ceilTo(applyDisplayTransform(canonical.min, transform), decimals),
max: floorTo(applyDisplayTransform(canonical.max, transform), decimals),
min: ceilTo(
applyDisplayTransformUnrounded(canonical.min, transform),
decimals,
),
max: floorTo(
applyDisplayTransformUnrounded(canonical.max, transform),
decimals,
),
}),
};
}