diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index ed0f4e542..0a5280be6 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -173,22 +173,21 @@ test("the timeline hands the selected day to the day view through ?day=", async await page.keyboard.press("Enter"); await expect(page).toHaveURL(new RegExp(`[?&]day=${after}`)); - // "Open " in the selection bar does the same for the selected day. + // An incoming ?day= selects that day; the selection bar has no separate + // "Open " link, a chip opens the day it touches. await page.goto("/timeline?day=2026-01-03"); - const open = page.locator('[data-slot="timeline-open-day"]'); - await expect(open).toHaveAttribute("data-date", "2026-01-03", { - timeout: 20_000, - }); + await expect( + page.locator('[data-slot="timeline-selection"]'), + ).toHaveAttribute("data-date", "2026-01-03", { timeout: 20_000 }); await expect( page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'), ).toHaveAttribute("aria-checked", "true"); - await expect( - page.locator('[data-slot="timeline-selection-chip"][data-item="ill-5"]'), - ).toBeVisible(); - await page.goto("/timeline"); - await expect(chart).toBeVisible({ timeout: 20_000 }); - await page.locator('[data-slot="timeline-zoom"] [data-value="year"]').click(); - await open.click(); + await expect(page.locator('[data-slot="timeline-open-day"]')).toHaveCount(0); + const chip = page.locator( + '[data-slot="timeline-selection-chip"][data-item="ill-5"]', + ); + await expect(chip).toBeVisible(); + await chip.click(); await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); }); @@ -546,3 +545,233 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn .click(); await expect(page).toHaveURL(/[?&]day=2026-01-03/); }); + +/** A calendar date `days` before today, in the account's zone. */ +function daysAgo(days: number): string { + const at = new Date(`${TODAY}T12:00:00Z`); + at.setUTCDate(at.getUTCDate() - days); + return at.toISOString().slice(0, 10); +} + +test("every medication still taken is drawn, from the account's real record", async ({ + page, +}) => { + await setTimelineModuleOn(page); + // Written through the app's own routes, once: a medication with a start + // date (it opens a course), one without (its first intake is its start). + const list = await page.request.get("/api/medications"); + const held = JSON.stringify(await list.json()); + const create = async (body: Record) => { + const res = await page.request.post("/api/medications", { data: body }); + expect(res.status()).toBeLessThan(300); + return ((await res.json()) as { data: { id: string } }).data.id; + }; + if (!held.includes("E2E Ramipril")) { + await create({ + name: "E2E Ramipril", + dose: "5 mg", + startsOn: daysAgo(40), + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }); + } + if (!held.includes("E2E Vitamin D3")) { + const id = await create({ + name: "E2E Vitamin D3", + dose: "2000 IU", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }); + const intake = await page.request.post(`/api/medications/${id}/intake`, { + data: { takenAt: new Date(Date.now() - 20 * 86_400_000).toISOString() }, + }); + expect(intake.status()).toBeLessThan(300); + } + + await page.goto("/timeline?zoom=quarter"); + const lane = page.locator('svg [data-lane="medications"]'); + await expect(lane).toHaveCount(1, { timeout: 20_000 }); + // One row each, and every one of them reaches today. + for (const name of ["E2E Ramipril", "E2E Vitamin D3"]) { + const bars = lane.locator("g[data-row]", { + has: page.locator(`title:text-matches("^${name}")`), + }); + await expect(bars.first()).toBeAttached(); + const rows = await bars.evaluateAll((els) => + els.map((el) => el.getAttribute("data-row")), + ); + expect(new Set(rows).size).toBe(1); + } +}); + +test("an open day stays open from the timeline to another page", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + await page.setViewportSize({ width: 1440, height: 900 }); + const day = daysAgo(3); + await page.goto(`/timeline?day=${day}`); + const panel = page.locator('[data-slot="day-panel"]'); + await expect(panel).toBeVisible({ timeout: 20_000 }); + + await page.locator('a[href="/labs"]').first().click(); + await expect(page).toHaveURL(new RegExp(`/labs\\?(.*&)?day=${day}`)); + await expect(panel).toBeVisible(); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day, + ); + + // Folded by the person, it stays folded on the next page. + await page.locator('[data-slot="day-close"]').click(); + await expect(panel).toHaveCount(0); + await page.locator('a[href="/timeline"]').first().click(); + await expect(page).toHaveURL(/\/timeline$/); + await expect(page.locator('[data-slot="day-rail"]')).toBeVisible(); + await expect(panel).toHaveCount(0); +}); + +test("a value point reads on its own, by pointer and by keyboard", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/timeline"); + const targets = page.locator('[data-slot="timeline-point-target"]'); + await expect(targets.first()).toBeAttached({ timeout: 20_000 }); + + const target = targets.last(); + const series = await target.getAttribute("data-series"); + const t = await target.getAttribute("data-t"); + await target.hover(); + const tip = page.locator('[data-slot="timeline-point-tip"]'); + await expect(tip).toBeVisible(); + await expect(tip).toHaveAttribute("data-series", series!); + await expect(tip).toHaveAttribute("data-t", t!); + // One line only, never the whole bucket. + await expect(tip.locator('[data-slot="rich-chart-tooltip-row"]')).toHaveCount( + 1, + ); + // Inside the chart, and the page does not scroll sideways for it. + const chart = (await page + .locator('[data-slot="timeline-chart"]') + .boundingBox())!; + const box = (await tip.boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(chart.x - 1); + expect(box.x + box.width).toBeLessThanOrEqual(chart.x + chart.width + 1); + + // The keyboard: focus shows the card, the arrows walk the line. + await page.mouse.move(0, 0); + await target.focus(); + await expect(tip).toHaveAttribute("data-t", t!); + await page.keyboard.press("ArrowLeft"); + await expect(tip).not.toHaveAttribute("data-t", t!); + await expect(tip).toHaveAttribute("data-series", series!); +}); + +test("the timeline page ends where its content does, at every desktop width", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + for (const width of [1280, 1440, 1920]) { + for (const day of [null, daysAgo(3)]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto(day ? `/timeline?day=${day}` : "/timeline"); + await expect( + page.locator('[data-slot="timeline-chart"] > svg'), + ).toBeVisible({ timeout: 20_000 }); + const m = await page.evaluate(() => { + const main = document.getElementById("main-content")!; + const page = document.querySelector('[data-slot="timeline-page"]')!; + const doc = document.scrollingElement!; + return { + mainSw: main.scrollWidth, + mainCw: main.clientWidth, + docSw: doc.scrollWidth, + docCw: doc.clientWidth, + docSh: doc.scrollHeight, + docCh: doc.clientHeight, + mainSh: main.scrollHeight, + contentBottom: + page.getBoundingClientRect().bottom - + main.getBoundingClientRect().top + + main.scrollTop, + }; + }); + const where = `${width}px ${day ? "with" : "without"} the day`; + expect(m.mainSw, where).toBeLessThanOrEqual(m.mainCw + 1); + expect(m.docSw, where).toBeLessThanOrEqual(m.docCw + 1); + expect(m.docSh, where).toBeLessThanOrEqual(m.docCh + 1); + // The shell's own buffer below the last card (pb-20 and the top + // padding), nothing more. + expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 104 + 2); + } + } +}); + +test("listing all of a day's values scrolls the panel, never the page", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + const day = daysAgo(3); + const types = [ + "WEIGHT", + "BODY_FAT", + "PULSE", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "MUSCLE_MASS", + "BONE_MASS", + "FAT_MASS", + "VISCERAL_FAT", + "BLOOD_GLUCOSE", + "ACTIVITY_STEPS", + ]; + await page.route(`**/api/day/${day}`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + date: day, + tz: "Europe/Berlin", + counts: { values: types.length, entries: 0 }, + running: [], + values: types.map((type, i) => ({ + type, + value: 10 + i, + unit: "u", + at: `${day}T0${i % 10}:00:00.000Z`, + source: "MANUAL", + band: null, + })), + events: [], + notable: [], + scores: [], + sections: {}, + }, + error: null, + }), + }), + ); + await page.setViewportSize({ width: 1440, height: 800 }); + await page.goto(`/timeline?day=${day}`); + const all = page.locator('[data-slot="day-values-all"]'); + await expect(all).toBeVisible({ timeout: 20_000 }); + const heights = () => + page.evaluate(() => ({ + doc: document.scrollingElement!.scrollHeight, + main: document.getElementById("main-content")!.scrollHeight, + })); + const before = await heights(); + await all.click(); + await expect(all).toHaveAttribute("aria-expanded", "true"); + const after = await heights(); + expect(Math.abs(after.doc - before.doc)).toBeLessThanOrEqual(2); + expect(Math.abs(after.main - before.main)).toBeLessThanOrEqual(2); +}); diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 4f821749a..d8cbc2fdb 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -582,8 +582,8 @@ function ChartBody({ lane.labels.map((label, i) => (