/** * The timeline journey (v1.42, #613), on its own account (`E2E_TIMELINE`). * * 1. Off by default: no navigation entry, and a deep link to /timeline * lands on the module notice, not the page. * 2. Switching it on in Settings opens the readiness inventory; "Open * timeline" goes there and the navigation entry appears. * 3. The timeline hands off to the day: "Open 3 Jan." and Enter on the * chart both put `?day=` in the URL; an incoming `?day=` selects that * day. * 4. A life event is entered through the sheet and posted in the * contract's shape. * 5. The value lines: quarterly means over the years, a missing quarter * left out, a short gap bridged dashed, a thin quarter hollow, and up * to six lines. * 6. On a phone the chronicle replaces the chart, says its empty months * out loud, names each quarter's means once, and a row opens its day. * * The timeline's server routes are mocked with the contract's shapes * (`utils/mock-timeline.ts`); the module switch is the real one. Every * assertion is addressed to a stable `data-*` attribute. The journey moves * its account's module switch, so it runs serially and in one project. */ import type { Page } from "@playwright/test"; import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup"; import { expect, test } from "./setup/test"; import { SYS_MISSING, SYS_THIN, mockTimeline } from "./utils/mock-timeline"; test.use({ storageState: TIMELINE_STORAGE_STATE_PATH }); test.describe.configure({ mode: "serial" }); /** Today in the zone the browser and the account run in. */ const TODAY = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit", }).format(new Date()); async function setTimelineModule(page: Page, on: boolean): Promise { const status = await page.evaluate(async (on) => { const res = await fetch("/api/auth/me/modules", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ timeline: on }), }); return res.status; }, on); expect(status).toBe(200); } const navEntry = '[data-tour-id="nav-timeline"]'; test("is off by default: no navigation entry, a deep link meets the notice", async ({ page, }) => { await page.goto("/settings/modules"); await setTimelineModule(page, false); await page.goto("/timeline"); await expect( page.locator( '[data-slot="module-disabled-notice"][data-module="timeline"]', ), ).toBeVisible({ timeout: 20_000 }); await expect(page.locator('[data-slot="timeline-page"]')).toHaveCount(0); await expect(page.locator(navEntry)).toHaveCount(0); }); test("opens on three months when the URL names no zoom", async ({ page }) => { await mockTimeline(page, "ready", TODAY); await page.goto("/settings/modules"); await setTimelineModule(page, true); await page.goto("/timeline"); await expect( page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'), ).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); // The next test starts from the module switched off. await setTimelineModule(page, false); }); test("only old data opens on everything, and an empty window offers it", async ({ page, }) => { await setTimelineModuleOn(page); const log = await mockTimeline(page, "full", TODAY, { onlyOld: true }); await page.goto("/timeline"); // The default three months hold nothing: the page opens on everything, // without writing it into the URL. await expect( page.locator('[data-slot="timeline-zoom"] [data-value="all"]'), ).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible(); await expect(page).toHaveURL(/\/timeline$/); expect(log.timelineQueries.map((q) => q.get("zoom"))).toEqual( expect.arrayContaining(["quarter", "all"]), ); // Three months asked for by name stay three months, and offer the rest. await page.goto("/timeline?zoom=quarter"); const empty = page.locator('[data-slot="timeline-window-empty"]'); await expect(empty).toBeVisible({ timeout: 20_000 }); await page.locator('[data-slot="timeline-show-everything"]').click(); await expect(page).toHaveURL(/zoom=all/); await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible(); // The next test starts from the module switched off. await setTimelineModule(page, false); }); test("a young record opens on three months starting at its first entry", async ({ page, }) => { await setTimelineModuleOn(page); // The record starts ten days ago: the window spans the four-week minimum. const day = (offset: number) => { const [y, m, d] = TODAY.split("-").map(Number); return new Date(Date.UTC(y, m - 1, d + offset)).toISOString().slice(0, 10); }; await mockTimeline(page, "full", TODAY, { dataFrom: day(-10) }); await page.goto("/timeline"); await expect( page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'), ).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); await expect(page.locator('[data-slot="timeline-chart"]')).toHaveAttribute( "data-window-from", day(-27), ); // The next test starts from the module switched off. await setTimelineModule(page, false); }); test("switching it on opens the readiness inventory, which leads to the timeline", async ({ page, }) => { await mockTimeline(page, "ready", TODAY); await page.goto("/settings/modules"); const toggle = page.locator("#module-toggle-timeline"); await expect(toggle).toHaveAttribute("aria-checked", "false", { timeout: 20_000, }); await toggle.click(); const sheet = page.locator('[data-slot="timeline-readiness-sheet"]'); await expect(sheet).toBeVisible(); await expect( page.locator( '[data-slot="timeline-readiness-verdict"][data-verdict="carries"]', ), ).toBeVisible(); await expect( page.locator('[data-slot="timeline-readiness-lane"][data-status="thin"]'), ).toHaveCount(1); await expect( page.locator( '[data-slot="timeline-readiness-gap"][data-gap="lifeEventsEmpty"]', ), ).toHaveAttribute("href", "/timeline?add=lifeEvent"); await page.locator('[data-slot="timeline-readiness-open"]').click(); await expect(page).toHaveURL(/\/timeline$/); await expect(page.locator('[data-slot="timeline-page"]')).toBeVisible(); await expect(page.locator(navEntry).first()).toBeAttached(); // The card carries the open gaps onto the page until it is closed. await expect( page.locator('[data-slot="timeline-readiness-card"]'), ).toBeVisible(); await page.locator('[data-slot="timeline-readiness-card-dismiss"]').click(); await expect( page.locator('[data-slot="timeline-readiness-card"]'), ).toHaveCount(0); }); test("the timeline hands the selected day to the day view through ?day=", async ({ page, }) => { await setTimelineModuleOn(page); const log = await mockTimeline(page, "full", TODAY); await page.goto("/timeline?zoom=all"); const chart = page.locator('[data-slot="timeline-chart"]'); await expect(chart).toBeVisible({ timeout: 20_000 }); // Empty lanes are not drawn: the record has no labs. await expect(page.locator('svg [data-lane="labs"]')).toHaveCount(0); await expect(page.locator('svg [data-lane="illness"]')).toHaveCount(1); await expect( page.locator('[data-slot="timeline-legend-causality"]'), ).toHaveCount(0); const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? []; expect(asked.length).toBeGreaterThan(0); // Before anyone picks a day the bar opens on the newest bucket it has // something for, says so in its head, and says how to pick another. const bar = page.locator('[data-slot="timeline-selection-bar"]'); // It sits on the card's own surface under a hairline, not in a grey box, // and the legend under it is set off by a hairline of its own. const surface = await bar.evaluate((el) => { const style = getComputedStyle(el); return { background: style.backgroundColor, borderTop: style.borderTopWidth, }; }); expect(surface.background).toBe("rgba(0, 0, 0, 0)"); expect(surface.borderTop).toBe("1px"); await expect(page.locator('[data-slot="timeline-legend"]')).toHaveCSS( "border-top-width", "1px", ); await expect(bar).toHaveAttribute("data-bucket", "quarter"); await expect(bar).toHaveAttribute( "data-period-from", /^\d{4}-(01|04|07|10)-01$/, ); await expect( page.locator('[data-slot="timeline-selection-empty"]'), ).toHaveCount(0); await expect( page.locator('[data-slot="timeline-selection-hint"]'), ).toBeVisible(); // The keyboard: one step back and Enter opens that day. const before = await page .locator('[data-slot="timeline-selection"]') .getAttribute("data-date"); await chart.focus(); await page.keyboard.press("ArrowLeft"); const after = await page .locator('[data-slot="timeline-selection"]') .getAttribute("data-date"); expect(after).not.toBe(before); // A day is picked now: the hint has done its job. await expect( page.locator('[data-slot="timeline-selection-hint"]'), ).toHaveCount(0); await page.keyboard.press("Enter"); await expect(page).toHaveURL(new RegExp(`[?&]day=${after}`)); // 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"); 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-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}/); }); test("value lines: quarterly means, gaps kept, short ones bridged, as many as the record has", async ({ page, }) => { await setTimelineModuleOn(page); const log = await mockTimeline(page, "full", TODAY); await page.goto("/timeline?zoom=all"); const sys = page.locator('svg [data-series="BLOOD_PRESSURE_SYS"]'); await expect(sys).toBeVisible({ timeout: 20_000 }); await expect( page.locator('[data-slot="timeline-legend-mean"]'), ).toHaveAttribute("data-bucket", "quarter"); const point = (t: string) => sys.locator(`[data-slot="timeline-series-point"][data-t="${t}"]`); // A missing quarter stays missing: no point is drawn for it. for (const t of SYS_MISSING) await expect(point(t)).toHaveCount(0); // The quarters around Q2 2023 are joined by a dashed bridge; the three // missing quarters of 2021 are not (one bridge in the whole line). await expect(sys.locator('[data-slot="timeline-series-bridge"]')).toHaveCount( 1, ); await expect(point("2023-01-01")).toHaveCount(1); await expect(point("2023-07-01")).toHaveCount(1); // Two readings: drawn hollow; four and more: filled. await expect(point(SYS_THIN)).toHaveAttribute("data-thin", "true"); await expect(point("2023-01-01")).toHaveAttribute("data-thin", "false"); // The value table keeps the missing quarter as a row of its own. await expect( page.locator( '[data-slot="timeline-series-table"] [data-bucket="2023-04-01"]', ), ).toBeAttached(); // The legend explains the dashed stroke and the hollow point. await expect( page.locator('[data-slot="timeline-legend-gap"] svg'), ).toBeVisible(); await expect( page.locator('[data-slot="timeline-legend-thin"] svg'), ).toBeVisible(); // The picker is one button that counts the lines; the chosen ones are // not listed beside it. const trigger = page.locator('[data-slot="timeline-values-trigger"]'); await expect(trigger).toHaveAttribute("data-count", "3"); await expect(trigger).toHaveText(/\(3\)/); const toolbarHeight = await trigger.evaluate( (el) => el.parentElement!.getBoundingClientRect().height, ); // No cap: every series the record has can be chosen, fifteen here. await trigger.click(); const options = page.locator('[data-slot="timeline-values-option"]'); await expect(options.first()).toBeVisible(); const count = await options.count(); expect(count).toBe(15); await expect( page.locator('[data-slot="timeline-values-option"][data-disabled]'), ).toHaveCount(0); for (let i = 0; i < count; i++) { const option = options.nth(i); if ((await option.getAttribute("aria-checked")) === "true") continue; await option.click(); } await expect( page.locator('[data-slot="timeline-values-option"][aria-checked="true"]'), ).toHaveCount(15); // The menu marks each chosen line with its colour. const dots = await page .locator( '[data-slot="timeline-values-option"][aria-checked="true"] [data-slot="timeline-values-dot"]', ) .evaluateAll((els) => els.map((el) => getComputedStyle(el).backgroundColor), ); expect(dots).toHaveLength(15); await page.keyboard.press("Escape"); // The menu closes on an exit animation and only then hands focus back to // its trigger, which scrolls the page to the toolbar. Measured before // that, the scroll to the last row below was undone a moment later. await expect(options).toHaveCount(0); await expect(trigger).toBeFocused(); await expect .poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length) .toBe(15); await expect(page.locator("svg [data-series]")).toHaveCount(15); await expect(trigger).toHaveAttribute("data-count", "15"); // The toolbar did not grow with the choice. expect( await trigger.evaluate( (el) => el.parentElement!.getBoundingClientRect().height, ), ).toBe(toolbarHeight); // Each line in one colour, every mark of a line in that colour, the five // data colours repeating past the fifth line. const colours = await page .locator("svg [data-series]") .evaluateAll((groups) => groups.map((g) => ({ colour: getComputedStyle(g).color, marks: [ ...g.querySelectorAll( '[data-slot="timeline-series-line"], [data-slot="timeline-series-swatch"]', ), ].map((m) => getComputedStyle(m).stroke), })), ); expect(new Set(colours.map((c) => c.colour)).size).toBe(5); for (const { colour, marks } of colours) { expect(marks.length).toBeGreaterThan(0); for (const stroke of marks) expect(stroke).toBe(colour); } expect(new Set(dots)).toEqual(new Set(colours.map((c) => c.colour))); // The rows run on down the page, which scrolls; the last one is reached. const last = page.locator("svg [data-series]").last(); await last.scrollIntoViewIfNeeded(); await expect(last).toBeInViewport(); }); test("each mean's dot sits on the middle of its text and never wraps apart from it", async ({ page, }, testInfo) => { await setTimelineModuleOn(page); await mockTimeline(page, "full", TODAY); const check = async (slot: string) => { const rows = page.locator(`[data-slot="${slot}"]`); await expect(rows.first()).toBeVisible({ timeout: 20_000 }); const units = await rows.evaluateAll((els) => els.flatMap((row) => Array.from( row.querySelectorAll('[data-slot="timeline-mean-part"]'), ).map((unit) => { const dot = unit .querySelector('[data-slot="timeline-series-dot"]')! .getBoundingClientRect(); const text = unit.querySelector(".truncate")!.getBoundingClientRect(); return { dotMid: dot.top + dot.height / 2, textMid: text.top + text.height / 2, dotRight: dot.right, textLeft: text.left, textHeight: text.height, }; }), ), ); expect(units.length).toBeGreaterThan(0); for (const unit of units) { // Centred on the text line, to the pixel. expect(Math.abs(unit.dotMid - unit.textMid)).toBeLessThanOrEqual(1); // On the same line, the value right after its dot: one line high. expect(unit.textLeft).toBeGreaterThan(unit.dotRight); expect(unit.textLeft - unit.dotRight).toBeLessThanOrEqual(8); expect(unit.textHeight).toBeLessThanOrEqual(20); } }; // A narrow chart: the selection bar's means wrap between units only. await page.setViewportSize({ width: 800, height: 900 }); await page.goto("/timeline?zoom=all"); await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({ timeout: 20_000, }); await check("timeline-selection-means"); await testInfo.attach("selection-means-800", { body: await page .locator('[data-slot="timeline-selection-bar"]') .screenshot(), contentType: "image/png", }); // The phone chronicle's means follow the same rule. await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/timeline?zoom=all"); await check("timeline-chronicle-means"); await testInfo.attach("chronicle-means-390", { body: await page.screenshot(), contentType: "image/png", }); }); test("one medication is one row: its doses cut the bar, its pause is a gap in it", async ({ page, }) => { await setTimelineModuleOn(page); await mockTimeline(page, "full", TODAY); await page.goto("/timeline?zoom=all"); const lane = page.locator('svg [data-lane="medications"]'); await expect(lane).toBeVisible({ timeout: 20_000 }); // The medication's own span is not drawn beside its course. await expect(lane.locator('[data-item="mj"]')).toHaveCount(0); const pieces = lane.locator('[data-item="mj-course"]'); await expect(pieces).toHaveCount(3); expect( await pieces.evaluateAll((els) => els.map((el) => el.dataset.dose)), ).toEqual(["2,5 mg", "5 mg", "7,5 mg"]); const rowOf = (selector: string) => lane .locator(selector) .evaluateAll((els) => [...new Set(els.map((el) => el.dataset.row))]); const mounjaroRow = await rowOf('[data-item="mj-course"]'); expect(mounjaroRow).toHaveLength(1); expect(await rowOf('[data-kind="pause"][data-item="mj-pause"]')).toEqual( mounjaroRow, ); // Three winter courses of one medication share one row of their own. const vitamin = await rowOf('[data-item^="c-"]'); expect(vitamin).toHaveLength(1); expect(vitamin).not.toEqual(mounjaroRow); // Four medications, four rows. const rows = await lane .locator("[data-row]") .evaluateAll((els) => new Set(els.map((el) => el.dataset.row)).size); expect(rows).toBe(4); // A zoom change redraws the pieces: none is left behind from the last // window (each piece has a key of its own). const zoom = (value: string) => page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`); await zoom("year").click(); await expect(page).toHaveURL(/zoom=year/); await zoom("all").click(); await expect(page).toHaveURL(/zoom=all/); await expect(pieces).toHaveCount(3); }); /** `days` calendar days before `key`. */ function daysBefore(key: string, days: number): string { const [y, m, d] = key.split("-").map(Number); return new Date(Date.UTC(y, m - 1, d - days)).toISOString().slice(0, 10); } test("a chosen range lives in the URL: reload keeps it, Back returns to it", async ({ page, }) => { await setTimelineModuleOn(page); const log = await mockTimeline(page, "full", TODAY); await page.goto("/timeline?zoom=all"); const chart = page.locator('[data-slot="timeline-chart"]'); await expect(chart).toBeVisible({ timeout: 20_000 }); const desktop = page.locator('[data-slot="timeline-desktop"]'); const zoom = (value: string) => page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`); // Choosing "Range" opens its fields over the page, anchored to the // segment: the chart does not move, and the URL waits for "Apply". const before = await chart.boundingBox(); await zoom("range").click(); const popover = page.locator('[data-slot="timeline-range-popover"]'); await expect(popover).toBeVisible(); const fields = popover.locator('[data-slot="timeline-range"]'); await expect(fields).toBeVisible(); expect(await chart.boundingBox()).toEqual(before); await expect(desktop.locator('[data-slot="timeline-range"]')).toHaveCount(0); expect(page.url()).not.toContain("zoom=range"); // The popover slides and scales in (`slide-in-from-top-2 zoom-in-95`); // measured mid-animation it sat up to 8 px higher than where it comes to // rest. Read its place once the entry animation has finished. await popover.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)), ); const anchor = await zoom("range").boundingBox(); const box = await popover.boundingBox(); expect(box!.y).toBeGreaterThanOrEqual(anchor!.y + anchor!.height); expect(Math.abs(box!.x - anchor!.x)).toBeLessThan(24); // Starts from what is on screen; "Apply" writes it down. await popover.locator('[data-slot="timeline-range-apply"]').click(); await expect(popover).toHaveCount(0); await expect(page).toHaveURL( /[?&]zoom=range&from=\d{4}-\d{2}-\d{2}&to=\d{4}-\d{2}-\d{2}/, ); // Three weeks: daily means, the bar headed by one day. const from = daysBefore(TODAY, 20); await page.goto(`/timeline?zoom=range&from=${from}&to=${TODAY}`); await expect(desktop).toHaveAttribute("data-from", from, { timeout: 20_000, }); await expect(desktop).toHaveAttribute("data-to", TODAY); await expect(zoom("range")).toHaveAttribute("aria-checked", "true"); await expect( page.locator('[data-slot="timeline-legend-mean"]'), ).toHaveAttribute("data-bucket", "day"); await expect( page.locator('[data-slot="timeline-selection-bar"]'), ).toHaveAttribute("data-bucket", "day"); const asked = log.timelineQueries.at(-1)!; expect(asked.get("zoom")).toBe("range"); expect(asked.get("from")).toBe(from); expect(asked.get("to")).toBe(TODAY); // A reload keeps it. await page.reload(); await expect(desktop).toHaveAttribute("data-from", from, { timeout: 20_000, }); // A fixed zoom replaces it; Back brings it back. await zoom("year").click(); await expect(page).toHaveURL(/[?&]zoom=year$/); await expect(desktop).not.toHaveAttribute("data-from", from); await page.goBack(); await expect(page).toHaveURL(new RegExp(`zoom=range&from=${from}`)); await expect(desktop).toHaveAttribute("data-from", from); // Typing a start in the popover moves the range once it is applied. const later = daysBefore(TODAY, 10); await zoom("range").click(); const start = popover.locator('[data-testid="timeline-range-from"]'); await start.fill(later); await start.press("Enter"); expect(page.url()).toContain(`from=${from}`); await popover.locator('[data-slot="timeline-range-apply"]').click(); await expect(page).toHaveURL(new RegExp(`from=${later}&to=${TODAY}`)); // A parameter it cannot read opens the same default as no zoom: this // record has entries in the last three months, so three months. await page.goto(`/timeline?zoom=range&from=nonsense&to=${TODAY}`); await expect(zoom("quarter")).toHaveAttribute("aria-checked", "true", { timeout: 20_000, }); await expect(desktop).toHaveAttribute("data-zoom", "quarter"); }); async function setTimelineModuleOn(page: Page) { await page.goto("/settings/modules"); await setTimelineModule(page, true); } test("a life event is entered through the sheet, in the contract's shape", async ({ page, }) => { await setTimelineModuleOn(page); const log = await mockTimeline(page, "full", TODAY); await page.goto("/timeline?add=lifeEvent"); const form = page.locator('[data-slot="life-event-form"]'); await expect(form).toBeVisible({ timeout: 20_000 }); // Saving an empty form says what is missing and sends nothing. await page.locator('[data-slot="life-event-save"]').click(); await expect(form.locator('[aria-invalid="true"]').first()).toBeVisible(); expect(log.lifeEventPosts).toHaveLength(0); await page .locator('[data-slot="life-event-title"]') .fill("Umzug nach Bochum"); await page .locator('[data-slot="life-event-category"] [data-value="HOME"]') .click(); await page .locator('[data-slot="life-event-precision"] [data-value="MONTH"]') .click(); await expect(page.locator('[data-slot="life-event-privacy"]')).toBeVisible(); await page.locator('[data-slot="life-event-save"]').click(); await expect(form).toHaveCount(0); expect(log.lifeEventPosts).toHaveLength(1); const posted = log.lifeEventPosts[0] as Record; expect(posted).toMatchObject({ title: "Umzug nach Bochum", category: "HOME", precision: "MONTH", endDate: null, note: null, }); expect(posted.startDate).toMatch(/^\d{4}-\d{2}-01$/); // The parameter left the URL with the sheet. await expect(page).toHaveURL(/\/timeline$/); }); test("on a phone the chronicle replaces the chart and opens a day per row", async ({ page, }) => { await setTimelineModuleOn(page); await mockTimeline(page, "full", TODAY); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/timeline?zoom=all"); const chronicle = page.locator('[data-slot="timeline-chronicle"]'); await expect(chronicle).toBeVisible({ timeout: 20_000 }); await expect(page.locator('[data-slot="timeline-chart"]')).toBeHidden(); await expect(page.locator('[data-slot="timeline-standing"]')).toBeVisible(); await expect( page.locator('[data-slot="timeline-chronicle-gap"]').first(), ).toBeVisible(); // Quarterly means, each quarter named once, at its newest listed month. const means = page.locator('[data-slot="timeline-chronicle-means"]'); await expect(means.first()).toBeVisible(); const buckets = await means.evaluateAll((els) => els.map((el) => el.getAttribute("data-bucket") ?? ""), ); expect(buckets.length).toBeGreaterThan(0); expect(new Set(buckets).size).toBe(buckets.length); for (const b of buckets) expect(b).toMatch(/^\d{4}-(01|04|07|10)-01$/); // Each mean carries its line's colour dot. await expect( means.first().locator('[data-slot="timeline-series-dot"]').first(), ).toBeVisible(); // One medication, one start: its course and its own span are one row, // with the dose taken then. const started = chronicle.locator( '[data-slot="timeline-chronicle-row"][data-date="2025-01-06"]', ); await expect(started).toHaveCount(1); await expect(started).toContainText("Mounjaro"); await expect(started).toContainText("2,5 mg"); await expect(chronicle).not.toContainText("ยท"); // A range is chosen in a sheet; the list underneath does not move. const listTop = (await chronicle.boundingBox())!.y; await page.locator('[data-slot="timeline-range-toggle"]').click(); const sheet = page.locator('[data-slot="responsive-sheet-content"]'); await expect(sheet.locator('[data-slot="timeline-range"]')).toBeVisible(); expect((await chronicle.boundingBox())!.y).toBe(listTop); await sheet.locator('[data-slot="timeline-range-apply"]').click(); await expect(page).toHaveURL(/[?&]zoom=range&from=\d{4}-\d{2}-\d{2}/); await page.goto("/timeline?zoom=all"); await expect(chronicle).toBeVisible({ timeout: 20_000 }); await page .locator( '[data-slot="timeline-chronicle-row"][data-date="2026-01-03"] button', ) .first() .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 (the strip is the docked day's only fold control), // it stays folded on the next page. await expect(page.locator('[data-slot="day-close"]')).toHaveCount(0); await page.locator('[data-slot="day-strip-toggle"]').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-strip"]')).toHaveAttribute( "data-state", "closed", ); 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?zoom=all"); 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 (the desktop shell's // pb-24 and the top padding), nothing more. expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 120 + 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); }); test("the day's strip stays at the right edge, opens and closes the day, and never moves", async ({ page, }, testInfo) => { await setTimelineModuleOn(page); await mockTimeline(page, "full", TODAY); const strip = page.locator('[data-slot="day-strip"]'); const toggle = page.locator('[data-slot="day-strip-toggle"]'); const panel = page.locator('[data-slot="day-panel"]'); const where = async () => { const box = (await strip.boundingBox())!; return [box.x, box.y, box.width, box.height].map(Math.round); }; const measure = () => page.evaluate(() => { const main = document.getElementById("main-content")!; const doc = document.scrollingElement!; return { docSw: doc.scrollWidth - doc.clientWidth, docSh: doc.scrollHeight - doc.clientHeight, mainSw: main.scrollWidth - main.clientWidth, }; }); for (const width of [1280, 1440, 1920]) { await page.setViewportSize({ width, height: 900 }); await page.goto("/timeline?zoom=all"); await expect( page.locator('[data-slot="timeline-chart"] > svg'), ).toBeVisible({ timeout: 20_000 }); // Closed: the strip is there, at the right edge, the full height of the // window from its top edge, holding today. await expect(strip).toHaveAttribute("data-state", "closed"); await expect(toggle).toHaveAttribute("aria-expanded", "false"); const closed = await where(); expect(closed[0] + closed[2], `${width}px right edge`).toBe(width); const before = await measure(); expect(closed[1], `${width}px from the top edge`).toBe(0); expect(closed[3], `${width}px full height`).toBe(900); expect( Math.round((await toggle.boundingBox())!.y), `${width}px toggle from the top edge`, ).toBe(0); expect(before.docSw).toBeLessThanOrEqual(0); expect(before.mainSw).toBeLessThanOrEqual(0); // A click opens the day left of the strip; the strip stays put. await toggle.click(); await expect(panel).toHaveAttribute("data-shell", "docked"); await expect(strip).toHaveAttribute("data-state", "open"); await expect(page.locator("#day-docked-panel")).toHaveAttribute( "data-state", "open", ); await expect .poll(async () => { const box = (await panel.boundingBox())!; return Math.round(box.x + box.width); }) .toBe(closed[0]); expect(await where()).toEqual(closed); await expect(panel.locator("h2").first()).toBeFocused(); const open = await measure(); expect(open.docSw, `${width}px open, sideways`).toBeLessThanOrEqual(0); expect(open.mainSw, `${width}px open, sideways`).toBeLessThanOrEqual(0); expect(open.docSh, `${width}px open, page height`).toBeLessThanOrEqual( before.docSh, ); await testInfo.attach(`day-strip-${width}-open`, { body: await page.screenshot(), contentType: "image/png", }); // A second click closes it; focus stays on the strip, which stays put. await toggle.click(); await expect(panel).toHaveCount(0); await expect(strip).toHaveAttribute("data-state", "closed"); await expect(toggle).toBeFocused(); expect(await where()).toEqual(closed); // Closing the day takes `?day=` out and leaves the zoom it opened with. await expect(page).toHaveURL(/\/timeline\?zoom=all$/); } // Both themes, open at 1440. await page.setViewportSize({ width: 1440, height: 900 }); for (const colorScheme of ["light", "dark"] as const) { await page.emulateMedia({ colorScheme }); await page.goto("/timeline?zoom=all"); await toggle.click(); await expect(panel).toHaveAttribute("data-shell", "docked"); await testInfo.attach(`day-strip-1440-${colorScheme}`, { body: await page.screenshot(), contentType: "image/png", }); await toggle.click(); await expect(panel).toHaveCount(0); } });