From 554f36aeabd8a952cb8018c8a7e7937d1b198c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 16:21:12 +0200 Subject: [PATCH] Show whole-number metrics as whole numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard tiles printed every value at one decimal: '121,0 mmHg', '60,0 bpm', '9.240,0' steps. TrendCard takes the metric's precision, and blood pressure, pulse, HRV, steps and mg/dL glucose pass zero. The measurement list names count-shaped units the way the insights pages do ('13 Etagen', '12,5 Atemzüge/min') instead of the source's 'count'. --- src/app/page-client.tsx | 12 ++++- .../trend-card-fraction-digits.test.tsx | 52 +++++++++++++++++++ src/components/charts/trend-card.tsx | 15 ++++-- .../__tests__/measurement-list-meta.test.ts | 16 ++++++ .../measurements/measurement-list.tsx | 13 ++++- src/lib/glucose.ts | 7 ++- 6 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 src/components/charts/__tests__/trend-card-fraction-digits.test.tsx diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index dbc186d1a..7134a5754 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -28,7 +28,11 @@ import { TrendingUp, Wind, } from "lucide-react"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; +import { + convertGlucose, + glucoseFractionDigits, + resolveGlucoseUnit, +} from "@/lib/glucose"; import { cn } from "@/lib/utils"; import { resolveDashboardLayout, @@ -1039,6 +1043,7 @@ export default function DashboardPageClient({ node: ( {node}, + ); +} + +const base = { + label: "Schritte", + latest: 9240, + unit: "", + avg7: 9535.14, + avg30: 9100, + slope30: null, + icon: Activity, +}; + +describe(" fractionDigits", () => { + it("keeps one decimal by default", () => { + const html = render(); + expect(html).toContain("9.240,0"); + }); + + it("prints whole numbers, averages and deltas at 0", () => { + const html = render( + , + ); + expect(html).toContain("9.240"); + expect(html).not.toContain("9.240,0"); + expect(html).toContain("9.535"); + expect(html).not.toContain("9.535,1"); + expect(html).toContain("−1.383"); + expect(html).not.toContain("−1.383,2"); + }); + + it("reads a sub-step delta as zero at 0 decimals", () => { + const html = render( + , + ); + expect(html).toContain("±0"); + }); +}); diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 63d4bbfcf..d12e72b9c 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -101,6 +101,13 @@ interface TrendCardProps { * before (no reserved space, no layout shift). */ valueAdornment?: React.ReactNode; + /** + * Decimal places for the headline, the averages and the deltas. Defaults + * to 1. A whole-number metric (steps, mmHg, bpm, mg/dL) passes 0 so the + * tile reads "121 mmHg", not "121,0 mmHg" — the precision its own + * insights page uses. + */ + fractionDigits?: number; } export function TrendCard({ @@ -123,6 +130,7 @@ export function TrendCard({ staleDays = null, emptyHint = null, valueAdornment = null, + fractionDigits = 1, }: TrendCardProps) { const { t, tCount } = useTranslations(); const fmt = useFormatters(); @@ -152,7 +160,7 @@ export function TrendCard({ const arrowSentiment = getTrendSentiment(primarySignal, directionSentiment); const trendColor = sentimentColorClass(arrowSentiment); - const formatValue = (value: number) => fmt.number(value, 1); + const formatValue = (value: number) => fmt.number(value, fractionDigits); // v1.4.15 Fix 4 — the 7-day delta now shares the arrow's colour so // both elements communicate the same sentiment for the same signal. @@ -171,9 +179,10 @@ export function TrendCard({ const comparisonDeltaColor = sentimentColorClass(comparisonSentiment); const formatDelta = (value: number): string => { - if (Math.abs(value) < 0.05) return `±0`; + // Below half a step of the shown precision the delta prints as zero. + if (Math.abs(value) < 0.5 * 10 ** -fractionDigits) return `±0`; const sign = value > 0 ? "+" : "−"; - return `${sign}${fmt.number(Math.abs(value), 1)}`; + return `${sign}${fmt.number(Math.abs(value), fractionDigits)}`; }; // The label flips from "7d" / "7T" (mean) to "7d trend" / "7T-Trend" diff --git a/src/components/measurements/__tests__/measurement-list-meta.test.ts b/src/components/measurements/__tests__/measurement-list-meta.test.ts index d5e19e2c3..f64070e04 100644 --- a/src/components/measurements/__tests__/measurement-list-meta.test.ts +++ b/src/components/measurements/__tests__/measurement-list-meta.test.ts @@ -4,6 +4,7 @@ import { MEASUREMENT_TYPE_LABEL_KEYS, MEASUREMENT_TYPE_ICONS, MEASUREMENT_TYPE_COLORS, + measurementUnitLabel, } from "../measurement-list-meta"; import enMessages from "../../../../messages/en.json"; import deMessages from "../../../../messages/de.json"; @@ -41,3 +42,18 @@ describe("measurement-list-meta", () => { } }); }); + +describe("measurementUnitLabel", () => { + const t = (key: string) => `t:${key}`; + it("shows the localised word for count-shaped source units", () => { + expect(measurementUnitLabel("FLIGHTS_CLIMBED", "count", t)).toBe( + "t:insights.units.flights", + ); + expect(measurementUnitLabel("RESPIRATORY_RATE", "count/min", t)).toBe( + "t:insights.units.respiratoryRate", + ); + }); + it("passes every other unit through", () => { + expect(measurementUnitLabel("WEIGHT", "kg", t)).toBe("kg"); + }); +}); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 7196e29aa..f0f859f59 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -104,6 +104,7 @@ import { MEASUREMENT_TYPE_LABEL_KEYS as TYPE_LABEL_KEYS, MEASUREMENT_TYPE_ICONS as TYPE_ICONS, MEASUREMENT_TYPE_COLORS as TYPE_COLORS, + measurementUnitLabel, } from "./measurement-list-meta"; import { measurementListFiltersToSearch, @@ -1262,7 +1263,11 @@ export function MeasurementList({ rowDisplay(m).value, rawDisplayFractionDigits(m.type), )}{" "} - {isGrouped ? m.unit : rowDisplay(m).unit} + {measurementUnitLabel( + m.type, + isGrouped ? m.unit : rowDisplay(m).unit, + t, + )} {isGrouped && ( {t("measurements.dailyTotalCaption", { @@ -1486,7 +1491,11 @@ export function MeasurementList({ rowDisplay(m).value, rawDisplayFractionDigits(m.type), )}{" "} - {isGrouped ? m.unit : rowDisplay(m).unit} + {measurementUnitLabel( + m.type, + isGrouped ? m.unit : rowDisplay(m).unit, + t, + )} )} diff --git a/src/lib/glucose.ts b/src/lib/glucose.ts index f03203b8c..246ba8ed2 100644 --- a/src/lib/glucose.ts +++ b/src/lib/glucose.ts @@ -27,6 +27,11 @@ export function mmolToMgdl(mmol: number): number { return Math.round(mmol * MGDL_PER_MMOL); } +/** Decimal places a glucose value is shown at: whole mg/dL, tenths of mmol/L. */ +export function glucoseFractionDigits(unit: GlucoseUnit): number { + return unit === "mmol/L" ? 1 : 0; +} + export function convertGlucose(value: number, to: GlucoseUnit): number { return to === "mmol/L" ? mgdlToMmol(value) : Math.round(value); } @@ -80,7 +85,7 @@ export function glucoseBandParams( unit: GlucoseUnit, formatNumber: (value: number, fractionDigits: number) => string, ): Record { - const digits = unit === "mmol/L" ? 1 : 0; + const digits = glucoseFractionDigits(unit); const v = (point: GlucoseCutPoint) => formatNumber(unit === "mmol/L" ? point.mmol : point.mgdl, digits); return {