Show whole-number metrics as whole numbers

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'.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 16:21:12 +02:00
1 parent b80dc14142
commit 554f36aeab
6 files changed
+108 -7

No files matched your search

+11 -1
View File
@@ -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: (
<TrendCard
key="bp-sys"
fractionDigits={0}
label={t("dashboard.bloodPressureSysShort")}
latest={sys?.latest ?? null}
unit="mmHg"
@@ -1080,6 +1085,7 @@ export default function DashboardPageClient({
node: (
<TrendCard
key="bp-dia"
fractionDigits={0}
label={t("dashboard.bloodPressureDiaShort")}
latest={dia?.latest ?? null}
unit="mmHg"
@@ -1121,6 +1127,7 @@ export default function DashboardPageClient({
node: (
<TrendCard
key="pulse"
fractionDigits={0}
label={t("dashboard.pulseShort")}
latest={pulseTileSummary?.latest ?? null}
unit="bpm"
@@ -1243,6 +1250,7 @@ export default function DashboardPageClient({
node: (
<TrendCard
key="steps"
fractionDigits={0}
label={t("dashboard.stepsShort") ?? "Steps"}
latest={stepsSummary?.latest ?? null}
unit=""
@@ -1303,6 +1311,7 @@ export default function DashboardPageClient({
node: (
<TrendCard
key="hrv"
fractionDigits={0}
// Name the measure when the RMSSD series is the one on show.
// Without it a ring / strap reading (typically 20-60 ms) sits
// under the same label as an SDNN one and reads as a collapse
@@ -1631,6 +1640,7 @@ export default function DashboardPageClient({
: null
}
unit={displayGlucoseUnit}
fractionDigits={glucoseFractionDigits(displayGlucoseUnit)}
avg7={
s.avg7 != null
? convertGlucose(s.avg7, displayGlucoseUnit)
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Activity } from "lucide-react";
import { TrendCard } from "../trend-card";
import { I18nProvider } from "@/lib/i18n/context";
/**
* A whole-number metric reads as a whole number on the tile: "121 mmHg",
* not "121,0 mmHg", and "9.240" steps rather than "9.240,0".
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="de">{node}</I18nProvider>,
);
}
const base = {
label: "Schritte",
latest: 9240,
unit: "",
avg7: 9535.14,
avg30: 9100,
slope30: null,
icon: Activity,
};
describe("<TrendCard> fractionDigits", () => {
it("keeps one decimal by default", () => {
const html = render(<TrendCard {...base} />);
expect(html).toContain("9.240,0");
});
it("prints whole numbers, averages and deltas at 0", () => {
const html = render(
<TrendCard {...base} fractionDigits={0} trend7Delta={-1383.2} />,
);
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(
<TrendCard {...base} fractionDigits={0} trend7Delta={0.3} />,
);
expect(html).toContain("±0");
});
});
+12 -3
View File
@@ -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"
@@ -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");
});
});
@@ -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 && (
<span className="text-muted-foreground ml-2 text-xs font-normal">
{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,
)}
</>
)}
</span>
+6 -1
View File
@@ -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<string, string> {
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 {