/** * 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. On a phone the chronicle replaces the chart, says its empty months * out loud, 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 { 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("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"); 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"]'), ).toBeVisible(); // At most three value lines are asked for. const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? []; expect(asked.length).toBeGreaterThan(0); expect(asked.length).toBeLessThanOrEqual(3); // 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); 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. 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-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).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); }); 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"); 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(); await page .locator( '[data-slot="timeline-chronicle-row"][data-date="2026-01-03"] button', ) .first() .click(); await expect(page).toHaveURL(/[?&]day=2026-01-03/); });