Files
HealthLog/e2e/day-view.spec.ts
Marc-André Bombeck 59a6ee9d05 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.
2026-10-24 12:18:39 +02:00

503 lines
20 KiB
TypeScript

import type { Page, TestInfo } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
/**
* v1.42 — the day view and its doors.
*
* The record is the phone-sweep account `globalSetup` seeds: a week of blood
* pressure, two lab readings, mood and an episode. The day itself
* (`GET /api/day/{date}`, `GET /api/day/index`) is answered by a route mock in
* the contract's shape, so the journeys pin the client: which door opens which
* day, what the history does, and where no door is.
*
* - a chart point opens the day beside the chart (desktop);
* - on a touch screen the first tap shows the value and the tooltip offers
* the day, the second opens it as a bottom sheet;
* - a date in a list opens the day while the row keeps its own target;
* - `?day=` opens the layer on any page, Back and Escape close it, a date in
* the future is dropped;
* - stepping to the neighbouring days never adds history entries;
* - docked, the day folds to its strip, which runs the full height of the
* window right of the top bar, is the panel's only open and close
* control and brings the day back on any page; the search magnifier at
* the end of the top bar is never covered by it; a sheet closes for good
* from its close button;
* - the dashboard's today area offers no door; its charts below do.
*
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
* the header's `day-date-button` and its `day-date-picker` (days keyed by
* `data-date-key`, dotted by `data-entries`, and `day-date-today`),
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
* `day-next` / `day-close`, and the docked day's strip `day-strip`
* (+ `data-day`, `data-state`) with its `day-strip-toggle`.
*/
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
async function shot(page: Page, testInfo: TestInfo, name: string) {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
const dir = process.env.DAY_SHOTS_DIR;
if (dir) {
await page.screenshot({
path: `${dir}/${name}-${testInfo.project.name}.png`,
});
}
}
function isoDaysAgo(days: number): string {
const d = new Date(Date.now() - days * 86_400_000);
return d.toISOString().slice(0, 10);
}
test.describe("the day view", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page);
});
test("a chart point opens its day beside the chart; Back closes it", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "desktop click");
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/insights/blood-pressure");
const plot = page.locator(
'[data-slot="chart-plot"][data-day-links="true"]',
);
await expect(plot).toBeVisible();
// The plot's right half holds the most recent points.
const box = (await plot.boundingBox())!;
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2);
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2);
await expect(panel(page)).toBeVisible();
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
// A landmark beside the page, named by its date.
await expect(panel(page)).toHaveJSProperty("tagName", "ASIDE");
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
// The chart beside it stays usable: the panel is not modal.
await expect(page.locator('[data-slot="sheet-overlay"]')).toHaveCount(0);
await page.goBack();
await expect(panel(page)).toHaveCount(0);
await expect(page).not.toHaveURL(/[?&]day=/);
});
test("a date in a list opens the day; the row keeps its own target", async ({
page,
}) => {
await page.goto("/labs");
await page
.locator('[data-slot="lab-list-analyte"]')
.filter({ hasText: MOBILE_ROUTES_ANALYTE })
.first()
.click();
// The marker's readings, each dated.
await page.locator('a[href$="/values"]').first().click();
const link = page.locator('[data-slot="day-link"]:visible').first();
await expect(link).toBeVisible();
const day = await link.getAttribute("data-day");
const before = page.url();
await link.click();
await expect(panel(page)).toBeVisible();
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day!,
);
// Same page underneath: the click opened the day, not the reading.
expect(new URL(page.url()).pathname).toBe(new URL(before).pathname);
// The reading the person came from sits on top, with the one before it.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
test("?day= opens the layer on any page; Escape closes it", async ({
page,
}) => {
const day = isoDaysAgo(3);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toBeVisible();
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day,
);
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
});
test("the day's scores sit below its values, each opening its own page", async ({
page,
}) => {
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
await expect(panel(page)).toBeVisible();
const scores = page.locator('[data-slot="day-scores"]');
await expect(scores).toBeVisible();
const tiles = scores.locator('[data-slot="day-score"]');
await expect(tiles).toHaveCount(2);
await expect(
scores.locator('[data-slot="day-score"][data-score="healthScore"]'),
).toHaveAttribute("href", "/insights/health-score");
await expect(
scores.locator('[data-slot="day-score"][data-score="strain"]'),
).toHaveAttribute("href", "/insights/scores/strain");
// A score the day does not hold has no tile.
await expect(
scores.locator('[data-slot="day-score"][data-score="readiness"]'),
).toHaveCount(0);
// Below the values, above what happened.
const order = await page
.locator(
'[data-slot="day-values"], [data-slot="day-scores"], [data-slot="day-events"]',
)
.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-slot")),
);
expect(order).toEqual(["day-values", "day-scores", "day-events"]);
});
test("the docked day folds beside its strip and comes back on another page", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"docked from 1280 px",
);
const day = isoDaysAgo(3);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
// Docked, the header has no fold button: the strip is the toggle.
await expect(page.locator('[data-slot="day-close"]')).toHaveCount(0);
const rail = page.locator('[data-slot="day-strip"]');
const expand = page.locator('[data-slot="day-strip-toggle"]');
await expect(expand).toHaveAttribute("aria-expanded", "true");
await expand.click();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
// The strip stays, holding the day, and focus waits on it.
await expect(rail).toHaveAttribute("data-day", day);
await expect(rail).toHaveAttribute("data-state", "closed");
await expect(expand).toBeFocused();
await expect(expand).toHaveAttribute("aria-expanded", "false");
await expect(expand).toHaveAttribute("aria-controls", "day-docked-panel");
// Named by its visible date (WCAG 2.5.3); the action is the tooltip.
expect(await expand.getAttribute("aria-label")).toBeNull();
expect(await expand.getAttribute("title")).toMatch(/^Show /);
expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(40);
// Another page: the edge is still there and brings the same day back,
// from the keyboard too.
await page.goto("/labs");
await expect(rail).toHaveAttribute("data-day", day);
await expand.focus();
await page.keyboard.press("Enter");
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day,
);
await expect(page).toHaveURL(new RegExp(`/labs\\?day=${day}$`));
await expect(panel(page).locator("h2").first()).toBeFocused();
// Open, the strip stays where it was and says so.
await expect(rail).toHaveAttribute("data-state", "open");
await expect(expand).toHaveAttribute("aria-expanded", "true");
});
test("the date opens a calendar; a picked day opens, Today goes to today", async ({
page,
}, testInfo) => {
const desktop = testInfo.project.name === "chromium-desktop";
// The days of the month that hold anything carry a dot.
await mockDay(page, { index: "full" });
const day = isoDaysAgo(5);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toHaveAttribute(
"data-shell",
desktop ? "docked" : "bottom",
);
const date = panel(page).locator('[data-slot="day-date-button"]');
const picker = page.locator('[data-slot="day-date-picker"]');
// The date stays one line in the header, the arrows beside it.
await expect(date).toHaveAttribute("aria-expanded", "false");
await expect(panel(page).locator('[data-slot="day-prev"]')).toBeVisible();
await shot(page, testInfo, "day-date-closed");
await date.click();
await expect(picker).toBeVisible();
await expect(date).toHaveAttribute("aria-expanded", "true");
await expect(picker.locator(`[data-date-key="${day}"]`)).toHaveAttribute(
"data-selected-single",
"true",
);
await expect(picker.locator('[data-entries="true"]').first()).toBeVisible();
// Nothing past today can be picked.
const tomorrow = picker.locator(`[data-date-key="${isoDaysAgo(-2)}"]`);
if ((await tomorrow.count()) > 0) await expect(tomorrow).toBeDisabled();
await shot(page, testInfo, "day-date-open");
// Another day of the same month: the 1st, or the 2nd when the open day
// is the 1st itself.
const first = `${day.slice(0, 8)}01`;
const target = first === day ? isoDaysAgo(4) : first;
const start = await page.evaluate(() => window.history.length);
await picker.locator(`[data-date-key="${target}"]`).click();
await expect(picker).toHaveCount(0);
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
target,
);
await expect(page).toHaveURL(new RegExp(`[?&]day=${target}`));
// Like a step: no new history entry, and focus back on the date.
expect(await page.evaluate(() => window.history.length)).toBe(start);
await expect(date).toBeFocused();
// The keyboard: Enter opens, Escape closes the calendar only.
await page.keyboard.press("Enter");
await expect(picker).toBeVisible();
// The calendar has the keyboard before Escape is pressed.
await expect
.poll(() =>
page.evaluate(
() =>
document.activeElement?.closest(
'[role="dialog"]:not([data-slot="day-panel"])',
) != null,
),
)
.toBe(true);
// On a phone the calendar is a sheet over the day's own sheet. Focus
// arrives before Radix has handed it the Escape key: in between, the day
// underneath still holds it. The handover shows as the day's layer
// turning inert (`pointer-events: none`) once the calendar is the top
// layer; wait for that, not for a fixed time. The docked column is no
// dialog and has no such handover.
if (!desktop)
await expect
.poll(() =>
panel(page).evaluate(
(el) =>
new Promise<string>((resolve) =>
requestAnimationFrame(() =>
setTimeout(() => resolve(getComputedStyle(el).pointerEvents)),
),
),
),
)
.toBe("none");
await page.keyboard.press("Escape");
await expect(picker).toHaveCount(0);
await expect(panel(page)).toBeVisible();
await expect(date).toBeFocused();
// Today.
await page.keyboard.press("Space");
await expect(picker).toBeVisible();
await picker.locator('[data-slot="day-date-today"]').click();
await expect(picker).toHaveCount(0);
await expect(panel(page).locator('[data-slot="day-next"]')).toBeDisabled();
});
test("a sheet closes for good and leaves no strip behind", async ({
page,
}, testInfo) => {
const desktop = testInfo.project.name === "chromium-desktop";
// Below 1280 px the day is a sheet from the right; on a phone, from the
// bottom. Neither collapses.
if (desktop) await page.setViewportSize({ width: 1024, height: 800 });
const day = isoDaysAgo(3);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toHaveAttribute(
"data-shell",
desktop ? "sheet" : "bottom",
);
await expect(page.locator('[data-slot="day-close"]')).toHaveAttribute(
"aria-label",
"Close day",
);
await page.locator('[data-slot="day-close"]').click();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
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) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"docked from 1280 px",
);
const day = isoDaysAgo(3);
const strip = page.locator('[data-slot="day-strip"]');
const search = page.locator('[data-slot="command-palette-trigger"]');
const topBar = page.locator('[data-slot="top-bar"]');
for (const width of [1280, 1440, 1920]) {
await page.setViewportSize({ width, height: 900 });
for (const url of ["/mood", `/mood?day=${day}`]) {
await page.goto(url);
await expect(strip).toBeVisible();
if (url.includes("day=")) {
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
// The open panel runs the full height beside its strip.
await expect
.poll(async () => (await panel(page).boundingBox())?.width)
.toBeGreaterThan(400);
const p = (await panel(page).boundingBox())!;
expect(p.y).toBe(0);
expect(Math.round(p.y + p.height)).toBe(900);
}
const s = (await strip.boundingBox())!;
// Top edge at the very top of the window, down to the bottom.
expect(s.y).toBe(0);
expect(Math.round(s.height)).toBe(900);
expect(Math.round(s.x + s.width)).toBe(width);
// The top bar ends left of the strips, so the magnifier sits fully
// inside it, in front of them.
const bar = (await topBar.boundingBox())!;
expect(bar.x + bar.width).toBeLessThanOrEqual(s.x + 0.5);
await expect(search).toBeVisible();
const m = (await search.boundingBox())!;
expect(m.x).toBeGreaterThanOrEqual(0);
expect(m.y).toBeGreaterThanOrEqual(0);
expect(m.x + m.width).toBeLessThanOrEqual(s.x);
expect(m.y + m.height).toBeLessThanOrEqual(bar.y + bar.height);
// Nothing paints over its centre: the hit test lands on it.
const hit = await page.evaluate(
([x, y]) =>
document
.elementFromPoint(x, y)
?.closest('[data-slot="command-palette-trigger"]') != null,
[m.x + m.width / 2, m.y + m.height / 2],
);
expect(hit).toBe(true);
// No horizontal scroll anywhere on the page.
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
}
}
});
test("a future or malformed ?day= is dropped without a word", async ({
page,
}) => {
await page.goto("/mood?day=2999-01-01");
await expect(page).toHaveURL(/\/mood$/);
await expect(panel(page)).toHaveCount(0);
await page.goto("/mood?day=2026-02-30");
await expect(page).toHaveURL(/\/mood$/);
await expect(panel(page)).toHaveCount(0);
});
test("stepping between days never adds a history entry", async ({ page }) => {
await page.goto("/mood");
await page.locator('[data-slot="day-link"]:visible').first().click();
await expect(panel(page)).toBeVisible();
const start = await page.evaluate(() => window.history.length);
const first = await page
.locator('[data-slot="day-view"]')
.getAttribute("data-day");
for (let i = 0; i < 3; i += 1) {
await page.locator('[data-slot="day-prev"]').click();
}
await expect(page.locator('[data-slot="day-view"]')).not.toHaveAttribute(
"data-day",
first!,
);
expect(await page.evaluate(() => window.history.length)).toBe(start);
// One Back leaves the layer, however far it stepped.
await page.goBack();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
});
test("on a phone the first tap shows the value, the second opens the day", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "touch only");
await page.goto("/insights/blood-pressure");
const plot = page.locator(
'[data-slot="chart-plot"][data-day-links="true"]',
);
await plot.scrollIntoViewIfNeeded();
await expect(plot).toBeVisible();
const box = (await plot.boundingBox())!;
await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2);
const open = page.locator('[data-slot="chart-tooltip-open-day"]');
await expect(open).toBeVisible();
// A tap alone never opens a sheet.
await expect(panel(page)).toHaveCount(0);
await open.tap();
await expect(panel(page)).toBeVisible();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
// The tapped value heads the day.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
test("the dashboard's today area offers no day door", async ({ page }) => {
await page.goto("/");
await expect(
page.locator('[data-slot="main-content-wrapper"]'),
).toBeVisible();
// The hero is today: nothing in it opens another day. (The charts
// further down do; `day-doors.spec.ts` walks one.)
await expect(
page.locator('[data-slot^="today-hero"]').first(),
).toBeVisible();
await expect(
page.locator(
'[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]',
),
).toHaveCount(0);
});
});