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((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); }); });