fix(insights): keep a stat value and its unit on one line

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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 12:18:39 +02:00
1 parent 9cc77c79fc
commit 59a6ee9d05
4 files changed
+178 -13

No files matched your search

+34
View File
@@ -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) => {
@@ -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/);
});
});
@@ -96,7 +96,7 @@ describe("<MetricStatStrip>", () => {
/>,
);
// 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("<MetricStatStrip> 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("<MetricStatStrip> 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("<MetricStatStrip> 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(
<MetricStatStrip
groupLabel="Blood pressure"
series={[
{
dataKey: "sys",
summary: sys,
unit: "mmHg",
fractionDigits: 0,
seriesLabel: "Systolic",
},
{
dataKey: "dia",
summary: dia,
unit: "mmHg",
fractionDigits: 0,
seriesLabel: "Diastolic",
},
]}
/>,
);
}
/** The text of every value cell, in order. */
function values(html: string): string[] {
return [
...html.matchAll(
/data-stat="[a-z]+"[^>]*><p[^>]*>[^<]*<\/p><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(<MetricStatStrip summary={populated} unit="" />);
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(<MetricStatStrip summary={populated} unit="bpm" />);
expect(html).toContain('data-layout="full"');
expect(html).toContain("@xl/stat-series:grid-cols-4");
expect(html).not.toMatch(/(^|[\s"])sm:grid-cols-4/);
});
});
+43 -7
View File
@@ -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<MetricStatSeries> & {
/**
* 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 ? (
<TileHeader
@@ -227,7 +258,7 @@ function SeriesBlock({
{description ? (
<p className="text-foreground text-sm leading-relaxed">{description}</p>
) : null}
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4 [&>div]:min-h-[44px]">
<div className={`${CELL_GRID[layout]} [&>div]:min-h-[44px]`}>
{cells.map((cell) => (
<div
key={cell.key}
@@ -291,7 +322,11 @@ export function MetricStatStrip({
<CardContent>
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
{series.map((s, i) => (
<SeriesBlock key={s.dataKey ?? s.seriesLabel ?? i} {...s} />
<SeriesBlock
key={s.dataKey ?? s.seriesLabel ?? i}
{...s}
layout="column"
/>
))}
</div>
</CardContent>
@@ -334,6 +369,7 @@ export function MetricStatStrip({
description={description}
titleAs={titleAs}
windowPending={windowPending}
layout="full"
/>
</CardContent>
</Card>
@@ -367,10 +403,10 @@ export function MetricStatStripSkeleton({
}
>
{Array.from({ length: Math.max(1, seriesCount) }).map((_, s) => (
<div key={s} className="space-y-1.5">
<div key={s} className="@container/stat-series space-y-1.5">
{/* h-6: the TileHeader row is one 24 px line. */}
<Skeleton className="h-6 w-32" />
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4">
<div className={CELL_GRID[seriesCount > 1 ? "column" : "full"]}>
{Array.from({ length: 4 }).map((__, i) => (
<div key={i} className="min-h-[44px] space-y-1">
<Skeleton className="h-3 w-12" />