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([]);
});
});