From 9a282ed698ebd7661e079b8ec3094b4a901c2c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:17:44 +0200 Subject: [PATCH] fix(units): keep converted values on the rounding helpers The narrative, the workout distance and the elevation chart converted through the unrounded escape hatch, and the trend caption scaled its noise floor off the raw factor. They now take the rounded helpers, so a narrated pound or mmol/L figure reads at the grain every screen shows it, and the caption's floors are declared canonically (the distance floor in metres). The trends row joins the chart-feeding surfaces that may hand a raw scale to a chart. Refs #1067 --- .../display-transform-rounding-guard.test.ts | 5 ++++- src/components/charts/workout-elevation-chart.tsx | 6 +++--- src/components/insights/trend-annotation.tsx | 14 ++++++++------ src/components/insights/workout-detail/format.ts | 3 +-- .../narrative/__tests__/period-narrative.test.ts | 14 +++++++------- src/lib/insights/narrative/period-narrative.ts | 9 ++++++--- src/lib/insights/trend-descriptor.ts | 6 ++++-- 7 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index 7db775374..73b928296 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -52,7 +52,7 @@ const REGISTRY = "lib/measurements/display-transform.ts"; const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"]; /** - * The surfaces allowed to read a transform's raw `factor` / `offset`. All four + * The surfaces allowed to read a transform's raw `factor` / `offset`. All five * 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 @@ -61,6 +61,9 @@ const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"]; */ const RAW_SCALE_SURFACES = [ "app/page-client.tsx", + // The insights trends row: a single transformed series hands its scale + // and offset to the mini chart, which formats every value itself. + "components/insights/trends-row.tsx", "app/insights/weight/page.tsx", "components/insights/healthkit-metric-page.tsx", "components/insights/device-score-tile.tsx", diff --git a/src/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx index 3fd0cfa93..dbf0382fc 100644 --- a/src/components/charts/workout-elevation-chart.tsx +++ b/src/components/charts/workout-elevation-chart.tsx @@ -13,7 +13,7 @@ import { import { useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { - applyDisplayTransformUnrounded, + applyDisplayTransform, getQuantityTransform, } from "@/lib/measurements/display-transform"; @@ -41,8 +41,8 @@ export function WorkoutElevationChart({ const distance = getQuantityTransform("distance", preference); const elevation = getQuantityTransform("elevation", preference); const data = points.map((p) => ({ - dist: applyDisplayTransformUnrounded(p.distanceM, distance), - alt: applyDisplayTransformUnrounded(p.altitude, elevation), + dist: applyDisplayTransform(p.distanceM, distance), + alt: applyDisplayTransform(p.altitude, elevation), })); return ( diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx index 5b72e9ba9..7551a88cc 100644 --- a/src/components/insights/trend-annotation.tsx +++ b/src/components/insights/trend-annotation.tsx @@ -265,7 +265,7 @@ export function TrendDescriptorCaption({ }) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); - const { toDisplay, transformFor, unitFor } = useUnitDisplay(); + const { toDisplay, toDisplayDelta, unitFor } = useUnitDisplay(); const isMood = kind === "mood"; const primaryType = types[0] ?? ""; @@ -334,19 +334,21 @@ export function TrendDescriptorCaption({ const descriptor = useMemo(() => { const meta = TREND_SLOT_DESCRIPTOR_META[metric]; - // A slot that converts its series converts its noise floor with it (a - // floor is a difference: factor only). + // A slot that converts its series converts its noise floor with it: the + // floor is declared canonically and is a difference (factor only). const config = isMood ? MOOD_DESCRIPTOR_CONFIG : meta?.unitType ? { ...meta.config, - absoluteFloor: - meta.config.absoluteFloor * transformFor(meta.unitType).factor, + absoluteFloor: toDisplayDelta( + meta.unitType, + meta.config.absoluteFloor, + ), } : meta?.config; return computeTrendDescriptor(points, config); - }, [points, isMood, metric, transformFor]); + }, [points, isMood, metric, toDisplayDelta]); // Tier 4 — genuinely too few points (also covers the in-flight window // before the small series lands). Surface the real empty hint so the diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index 03e559ea1..9dfb6eadd 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -1,7 +1,6 @@ import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { applyDisplayTransform, - applyDisplayTransformUnrounded, getQuantityTransform, paceSecondsPerDistanceUnit, type UnitPreference, @@ -46,7 +45,7 @@ export function formatDistance( preference: UnitPreference, ): string { const transform = getQuantityTransform("distance", preference); - const value = applyDisplayTransformUnrounded(meters, transform); + const value = applyDisplayTransform(meters, transform); const formatted = getNumberFormat(locale, { maximumFractionDigits: 2, minimumFractionDigits: value < 10 ? 2 : 1, diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts index 99e3c7e68..83abb0ed5 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts @@ -156,15 +156,15 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => { ); const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!; expect(g.unit).toBe("mmol/L"); - expect(g.current).toBe(5.99); - expect(g.prior).toBe(4.99); + expect(g.current).toBe(6); + expect(g.prior).toBe(5); expect(g.delta).toBe(1); // Percent is unit-free. expect(g.deltaPercent).toBe(20); const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!; expect(w.unit).toBe("lb"); - expect(w.current).toBe(180.78); - expect(w.delta).toBe(4.41); + expect(w.current).toBe(180.8); + expect(w.delta).toBe(4.4); const prose = buildDeterministicNarrative(ctx, "en"); expect(prose).toContain("mmol/L"); @@ -172,7 +172,7 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => { expect(prose).not.toContain("mg/dL"); expect(prose).not.toContain("kg"); const prompt = buildNarrativeUserPrompt(ctx, "en"); - expect(prompt).toContain("BLOOD_GLUCOSE: 5.99 mmol/L"); + expect(prompt).toContain("BLOOD_GLUCOSE: 6 mmol/L"); expect(prompt).not.toContain("mg/dL"); }); @@ -231,10 +231,10 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => { ); const t = ctx.bandTransitions.find((b) => b.type === "BLOOD_GLUCOSE")!; expect(t.unit).toBe("mmol/L"); - expect(t.center).toBe(7.77); + expect(t.center).toBe(7.8); expect(t.movedOut).toBe(true); expect(buildNarrativeUserPrompt(ctx, "en")).toContain( - "BLOOD_GLUCOSE: 7.77 mmol/L", + "BLOOD_GLUCOSE: 7.8 mmol/L", ); }); }); diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index c03deab01..807ade7f6 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -57,7 +57,8 @@ import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; import { resolveModuleMap } from "@/lib/modules/gate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { - applyDisplayTransformUnrounded, + applyDisplayTransform, + applyDisplayTransformDelta, getReadingTransform, isUnitSensitiveType, resolveUnitPreferences, @@ -135,8 +136,10 @@ function narratedUnit( const transform = getReadingTransform(type, units); return { unit: transform.displayUnit, - abs: (v) => round2(applyDisplayTransformUnrounded(v, transform)), - delta: (v) => round2(v * transform.factor), + // Rounded to the unit's own grain (one decimal for lb and mmol/L), the + // grain every screen shows the same reading at. + abs: (v) => round2(applyDisplayTransform(v, transform)), + delta: (v) => round2(applyDisplayTransformDelta(v, transform)), }; } if (type === "SLEEP_DURATION") { diff --git a/src/lib/insights/trend-descriptor.ts b/src/lib/insights/trend-descriptor.ts index f42dd31aa..fea9ec7a7 100644 --- a/src/lib/insights/trend-descriptor.ts +++ b/src/lib/insights/trend-descriptor.ts @@ -148,7 +148,8 @@ export interface TrendSlotDescriptorMeta { /** * For a metric whose unit follows the reader's preference: the * measurement type the caller resolves the unit of (and converts the - * series and the noise floor through) before describing it. + * series and the noise floor through) before describing it. Such a slot + * declares its `absoluteFloor` in the CANONICAL unit (kg, m, °C). */ unitType?: string; config: TrendDescriptorConfig; @@ -196,7 +197,8 @@ export const TREND_SLOT_DESCRIPTOR_META: Record< }, distance: { unitType: "WALKING_RUNNING_DISTANCE", - config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 }, + // 300 m — canonical metres, converted to the reader's km or mi. + config: { absoluteFloor: 300, relativeFloor: 0.05, decimals: 1 }, }, vo2_max: { unit: "",