Files
HealthLog/e2e/timeline.spec.ts
Marc-André Bombeck 74e691cecd feat(timeline): draw person-defined symptoms as value lines
A custom symptom could feed pattern discovery and the Coach but could not
be looked at over time. The timeline now offers every symptom the record
has logged, hidden ones included, beside the measurements and lab markers,
and draws a chosen one as a value line.

`values` takes `symptom:<definitionId>`, checked for its shape and capped
like a lab marker; an id the record does not hold draws nothing. A day's
value is its highest intensity on the record's local day; a point is the
mean of the bucket's day values over the days logged, with the number of
those days, and a day without an entry is not a zero. The line sits on a
fixed 0 to 10 scale. Symptoms are read under the illness lane's gate, the
module and sharing domain the symptom routes use. An `all` window reaches
back to a chosen symptom's first entry.

The response carries `symptomSeries` and `availableSymptoms`; the OpenAPI
contract is regenerated. The page words a point's count as days and the
value out of ten. The date-key schema moves to its own module so the
symptom schemas can share it.
2026-11-07 00:38:53 +01:00

1210 lines
46 KiB
TypeScript

/**
* 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.
* 7. A lab marker (#1299) is picked from the values menu, drawn as its
* single readings with the range behind them, read on its own, kept
* across a reload, and listed on its lab day on a phone.
* 8. A symptom (#981) is picked from the values menu, drawn as a value
* line on the fixed 0 to 10 scale, read with the days behind a point,
* kept across a reload, and named in the phone chronicle.
*
* 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 AxeBuilder from "@axe-core/playwright";
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<void> {
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 <day>" 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-desktop"] [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<string, string | null>;
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<string, unknown>) => {
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("a lab marker is picked, drawn as its readings, read on its own and kept", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY, { labs: true });
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/timeline?zoom=all");
await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({
timeout: 20_000,
});
await page
.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
)
.click();
const menu = page.locator('[data-slot="timeline-values-menu"]');
await expect(
menu.locator('[data-slot="timeline-values-labs"]'),
).toBeVisible();
await expect(menu.getByText("Eisen")).toBeVisible();
await menu
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
.click();
await page.keyboard.press("Escape");
// The menu hands focus back to its trigger once its exit animation ends.
await expect(menu).toBeHidden();
await expect(
page.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
),
).toBeFocused();
const row = page.locator('[data-lab-series="lab:bm-ferritin"]');
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4);
// Below its range: a triangle, never only a colour.
await expect(row.locator('path[data-status="below"]')).toHaveCount(1);
await expect(row.locator('[data-slot="timeline-lab-band-fill"]')).toHaveCount(
1,
);
expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain(
"lab:bm-ferritin",
);
// The reading below its range, by keyboard: day, value, verdict, report.
const target = page.locator(
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]',
);
const tip = page.locator('[data-slot="timeline-point-tip"]');
// A pointer resting on another line's point, as the menu click can leave
// it: the focus takes the card, and that point losing the pointer (the
// focus scrolls the chart from under it) must not close the card.
await page
.locator('[data-slot="timeline-point-target"][data-series="WEIGHT"]')
.last()
.hover();
await expect(tip).toHaveAttribute("data-series", "WEIGHT");
await target.focus();
await expect(tip).toHaveAttribute("data-t", "fe-1");
await page.mouse.move(0, 0);
await expect(tip).toHaveAttribute("data-t", "fe-1");
await expect(tip).toContainText("22 ng/mL");
await expect(tip).toContainText("Laborbefund März");
const axe = await new AxeBuilder({ page })
.include('[data-slot="timeline-page"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
// The choice is kept: a reload asks for the marker again.
await page.reload();
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4, {
timeout: 20_000,
});
// On a phone: the same line, listed on its lab days, no sideways scroll.
await page.setViewportSize({ width: 390, height: 844 });
const labs = page.locator('[data-slot="timeline-chronicle-labs"]');
await expect(labs.first()).toBeVisible();
await expect(labs).toHaveCount(4);
await expect(labs.last()).toContainText("22 ng/mL");
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
// Unchosen, it is gone from the request and the page.
await page
.locator(
'[data-slot="timeline-mobile"] [data-slot="timeline-values-trigger"]',
)
.click();
await page
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
.click();
await page.keyboard.press("Escape");
await expect(labs).toHaveCount(0);
});
test("a symptom is picked, drawn as a 0 to 10 line, read with its days and kept", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY, { symptoms: true });
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/timeline?zoom=all");
await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({
timeout: 20_000,
});
await page
.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
)
.click();
const menu = page.locator('[data-slot="timeline-values-menu"]');
await expect(
menu.locator('[data-slot="timeline-values-symptoms"]'),
).toBeVisible();
// A symptom hidden from entry is still offered, and says so.
await expect(
menu.locator(
'[data-slot="timeline-values-option"][data-key="symptom:sd-nausea"]',
),
).toBeVisible();
await menu
.locator(
'[data-slot="timeline-values-option"][data-key="symptom:sd-headache"]',
)
.click();
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
const row = page.locator('g[data-series="symptom:sd-headache"]');
const points = row.locator('[data-slot="timeline-series-point"]');
await expect(points).toHaveCount(3);
// The quarter resting on two days is hollow.
await expect(points.nth(1)).toHaveAttribute("data-thin", "true");
await expect(
page.locator('[data-slot="timeline-legend-symptom"]'),
).toBeVisible();
expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain(
"symptom:sd-headache",
);
const target = page.locator(
'[data-slot="timeline-point-target"][data-series="symptom:sd-headache"][data-t="2024-01-01"]',
);
const tip = page.locator('[data-slot="timeline-point-tip"]');
await target.focus();
await expect(tip).toHaveAttribute("data-t", "2024-01-01");
// Out of 10 in the account's own decimal mark, with the days behind it.
await expect(tip).toContainText(/6[.,]5\/10/);
await expect(tip).toContainText(/\b9\b/);
const axe = await new AxeBuilder({ page })
.include('[data-slot="timeline-page"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
// The choice is kept: a reload asks for the symptom again.
await page.reload();
await expect(points).toHaveCount(3, { timeout: 20_000 });
// On a phone: its means in the chronicle, no sideways scroll.
await page.setViewportSize({ width: 390, height: 844 });
const means = page.locator(
'[data-slot="timeline-mobile"] [data-slot="timeline-mean-part"][data-series="symptom:sd-headache"]',
);
await expect(means.first()).toBeVisible();
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
});
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);
}
});