From 59a6ee9d05dfed069d08d6103a2e66e777bf57bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 12:18:39 +0200 Subject: [PATCH] fix(insights): keep a stat value and its unit on one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the day panel docked beside the blood-pressure page (1280 px and up), each half of the stat card is a narrow column, and its four cells still went side by side because the step followed the window's width. Every cell came out narrower than "112 mmHg": the systolic values broke onto two lines, the two-digit diastolic ones next to them did not. The stat strip now joins a value to its unit with a no-break space, and each series is a container whose cells go four-up only when the series itself has the room: from 28rem for a half-card column, from 36rem for a full-width strip (where the 640 px window step put it, so the longest unit written, mL/(kg·min), keeps a cell it fits in). The loading shell follows the same steps. Every page with a stat strip (pulse, weight, the HealthKit, lab and custom-metric pages) shares the component. A component test pins the no-break pair and the container steps; the day-view spec measures every blood-pressure value beside the docked day at 1280 px and fails on the old markup. --- e2e/day-view.spec.ts | 34 ++++++ .../integer-metric-precision-guard.test.tsx | 3 +- .../__tests__/metric-stat-strip.test.tsx | 104 +++++++++++++++++- src/components/insights/metric-stat-strip.tsx | 50 +++++++-- 4 files changed, 178 insertions(+), 13 deletions(-) diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index c4e5f5fd4..f767c73f7 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -332,6 +332,40 @@ test.describe("the day view", () => { await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0); }); + test("beside the docked day, every blood-pressure value keeps its unit on its line", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "docked from 1280 px", + ); + // The narrowest docked width: each half of the stat card is narrowest + // here. Four cells a row left "1xx mmHg" no room, so the systolic values + // broke onto two lines while the two-digit diastolic ones fitted. + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(`/insights/blood-pressure?day=${isoDaysAgo(3)}`); + await expect(panel(page)).toHaveAttribute("data-shell", "docked"); + const values = page.locator( + '[data-slot="metric-stat-series"] [data-slot="metric-stat"] > p:nth-child(2)', + ); + await expect(values).toHaveCount(8); + const cells = await values.evaluateAll((els) => + els.map((el) => { + const lineHeight = parseFloat(getComputedStyle(el).lineHeight); + return { + text: el.textContent ?? "", + lines: Math.round(el.getBoundingClientRect().height / lineHeight), + overflow: el.scrollWidth - el.clientWidth, + }; + }), + ); + for (const cell of cells) { + expect(cell.text, cell.text).toMatch(/mmHg$/); + expect(cell.lines, cell.text).toBe(1); + expect(cell.overflow, cell.text).toBeLessThanOrEqual(0); + } + }); + test("the strip runs the full height; the search magnifier stays clear of it", async ({ page, }, testInfo) => { diff --git a/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx b/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx index a7e4ad6c6..62ac9a72b 100644 --- a/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx +++ b/src/components/insights/__tests__/integer-metric-precision-guard.test.tsx @@ -182,7 +182,8 @@ describe("integer-metric precision guard", () => { // …and the unit reads in German, not as the English word. (The route // slug `/insights/steps` legitimately appears in the header links, so // match the unit in its rendered position: right after a number.) - expect(html).toContain("104 Schritte"); + // The stat strip joins a value to its unit with a no-break space. + expect(html).toContain("104\u00A0Schritte"); expect(html).not.toMatch(/\d\s*steps\b/); }); }); diff --git a/src/components/insights/__tests__/metric-stat-strip.test.tsx b/src/components/insights/__tests__/metric-stat-strip.test.tsx index 4268af7e9..ae755e382 100644 --- a/src/components/insights/__tests__/metric-stat-strip.test.tsx +++ b/src/components/insights/__tests__/metric-stat-strip.test.tsx @@ -96,7 +96,7 @@ describe("", () => { />, ); // No "100.0" — integer precision. - expect(html).toContain("100 mg/dL"); + expect(html).toContain("100\u00A0mg/dL"); expect(html).not.toContain("100.0"); }); @@ -209,8 +209,8 @@ describe(" waits for the chart's window", () => { ); expect(html).toContain('data-stat="min"'); expect(html).toContain('data-window-pending="true"'); - expect(html).not.toContain("58.0 bpm"); - expect(html).not.toContain("88.0 bpm"); + expect(html).not.toContain("58.0\u00A0bpm"); + expect(html).not.toContain("88.0\u00A0bpm"); }); it("paints the window's numbers once the chart reported", () => { @@ -221,7 +221,101 @@ describe(" waits for the chart's window", () => { windowStats={{ count: 5, min: 60, max: 80, median: 70, mean: 70 }} />, ); - expect(html).toContain("60.0 bpm"); - expect(html).not.toContain("58.0 bpm"); + expect(html).toContain("60.0\u00A0bpm"); + expect(html).not.toContain("58.0\u00A0bpm"); + }); +}); + +/** + * With the day panel docked beside the blood-pressure page (1280 px and up) + * each half of the card is a narrow column. A four-up row there left every + * cell narrower than "112 mmHg": the systolic values broke onto two lines, + * the two-digit diastolic ones did not. The pair is now unbreakable and the + * cell grid steps by the series' own width, not the window's. + */ +describe(" keeps a value and its unit on one line", () => { + const sys: DataSummary = { + ...populated, + min: 104, + max: 131, + median: 112, + mean: 112.4, + }; + const dia: DataSummary = { ...populated, min: 66, max: 84, median: 74 }; + + function bloodPressure(): string { + return render( + , + ); + } + + /** The text of every value cell, in order. */ + function values(html: string): string[] { + return [ + ...html.matchAll( + /data-stat="[a-z]+"[^>]*>]*>[^<]*<\/p>]*>([^<]*)<\/p>/g, + ), + ].map((m) => m[1]!); + } + + it("joins every value to its unit with a no-break space", () => { + const cells = values(bloodPressure()); + expect(cells).toEqual([ + "104\u00A0mmHg", + "131\u00A0mmHg", + "112\u00A0mmHg", + "112\u00A0mmHg", + "66\u00A0mmHg", + "84\u00A0mmHg", + "74\u00A0mmHg", + "70\u00A0mmHg", + ]); + // No ordinary space is left for the line to break at. + for (const cell of cells) expect(cell).not.toMatch(/ /); + }); + + it("writes a value without a unit as the number alone", () => { + const html = render(); + for (const cell of values(html)) expect(cell).not.toMatch(/\s$/); + }); + + it("steps the cells of a half-card column by the column's width, not the window's", () => { + const html = bloodPressure(); + const series = [ + ...html.matchAll(/data-slot="metric-stat-series"[^>]*>/g), + ].map((m) => m[0]); + expect(series).toHaveLength(2); + for (const tag of series) { + expect(tag).toContain('data-layout="column"'); + expect(tag).toContain("@container/stat-series"); + } + // Two by two in a narrow column, four up only from 28rem of its own. + expect(html.match(/@md\/stat-series:grid-cols-4/g)).toHaveLength(2); + expect(html).not.toMatch(/(^|[\s"])sm:grid-cols-4/); + }); + + it("steps a full-width strip from 36rem, the width its old window step gave it", () => { + const html = render(); + expect(html).toContain('data-layout="full"'); + expect(html).toContain("@xl/stat-series:grid-cols-4"); + expect(html).not.toMatch(/(^|[\s"])sm:grid-cols-4/); }); }); diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 34d3142b3..5fee548eb 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -41,8 +41,31 @@ import type { MetricWindowStats } from "@/lib/charts/window-stats"; * to a denser rhythm so the numbers-first header gives away less vertical * space on every subpage without dropping below a readable value size or * the AA-contrast micro-labels. + * + * A value and its unit never part: they are joined by a no-break space, and + * the four cells go side by side only when the series has the room for them. + * That step follows the width of the series itself (a container query), not + * the window's: the blood-pressure halves are half a card wide, and with the + * day panel docked beside the content a four-up row left each cell narrower + * than "112 mmHg", so the systolic values broke onto two lines while the + * two-digit diastolic ones still fitted. A half-card column goes four-up from + * 28rem, a full-width strip from 36rem (where the window step it replaces, + * 640 px, put it), which leaves the longest unit the app writes + * ("mL/(kg·min)") a cell it fits in. */ +/** No-break space: the line may break around the pair, never inside it. */ +const NBSP = "\u00A0"; + +/** + * The cell grid of one series. `column` is a series sharing the card with + * another (half its width); `full` is the only series of its strip. + */ +const CELL_GRID = { + column: "grid grid-cols-2 gap-x-3 gap-y-2 @md/stat-series:grid-cols-4", + full: "grid grid-cols-2 gap-x-3 gap-y-2 @xl/stat-series:grid-cols-4", +} as const; + /** * One series rendered inside the strip — its header (label + icon), the * full-range `summary`, and the optional brushed-window stats. Single-series @@ -127,6 +150,8 @@ export interface MetricStatSeries { windowPending?: boolean; } +type SeriesLayout = keyof typeof CELL_GRID; + type MetricStatStripProps = Partial & { /** * v1.12.7 — multi-series mode. When supplied, the strip renders ONE Card @@ -173,7 +198,8 @@ function SeriesBlock({ description, titleAs, windowPending = false, -}: MetricStatSeries) { + layout, +}: MetricStatSeries & { layout: SeriesLayout }) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -192,7 +218,11 @@ function SeriesBlock({ const holding = windowPending && !windowed; const format = (value: number | null): string => - value === null ? "—" : `${fmt.number(value, fractionDigits)} ${unit}`; + value === null + ? "—" + : unit + ? `${fmt.number(value, fractionDigits)}${NBSP}${unit}` + : fmt.number(value, fractionDigits); const cells: Array<{ key: string; label: string; value: number | null }> = [ { key: "min", label: t("insights.subPage.stats.min"), value: source.min }, @@ -215,7 +245,8 @@ function SeriesBlock({ data-series={dataKey} data-windowed={windowed ? "true" : undefined} data-window-pending={holding ? "true" : undefined} - className="space-y-1.5" + data-layout={layout} + className="@container/stat-series space-y-1.5" > {seriesLabel ? ( {description}

) : null} -
+
div]:min-h-[44px]`}> {cells.map((cell) => (
{series.map((s, i) => ( - + ))}
@@ -334,6 +369,7 @@ export function MetricStatStrip({ description={description} titleAs={titleAs} windowPending={windowPending} + layout="full" /> @@ -367,10 +403,10 @@ export function MetricStatStripSkeleton({ } > {Array.from({ length: Math.max(1, seriesCount) }).map((_, s) => ( -
+
{/* h-6: the TileHeader row is one 24 px line. */} -
+
1 ? "column" : "full"]}> {Array.from({ length: 4 }).map((__, i) => (