mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
9cc77c79fc
commit
59a6ee9d05
4 files changed
+178
-13
No files matched your search
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user