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: "",