diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts new file mode 100644 index 000000000..ce02fa091 --- /dev/null +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -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([]); + }); +}); diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 4bcae9c10..92d023935 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -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() { + 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; diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index e6203f227..ccb7c5d79 100644 --- a/src/lib/measurements/__tests__/display-transform.test.ts +++ b/src/lib/measurements/__tests__/display-transform.test.ts @@ -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); + }); +}); diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 98cd5326a..ec61fe1f2 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -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, diff --git a/src/lib/targets/target-unit-display.ts b/src/lib/targets/target-unit-display.ts index ae3b2e4e5..a92218e4e 100644 --- a/src/lib/targets/target-unit-display.ts +++ b/src/lib/targets/target-unit-display.ts @@ -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, + ), }), }; }