test(timeline): pin the medication rows, the open day, the point card and the page height

The journey now writes two medications through the real routes (one with
a start date and its course, one known only by an intake) and asserts
each is drawn on exactly one row; carries an open day from /timeline to
/labs and back after folding it; hovers and focuses a value point and
checks the card names that line alone and stays inside the chart; and
measures the page at 1280, 1440 and 1920 px, with and without the docked
day: no sideways scroll, and no height past the content beyond the
shell's own padding. Expanding all of a day's values scrolls the panel
and leaves the page height as it was. The "Open <day>" link the bar no
longer has is asserted absent; a chip opens the day instead.

Label texts carry their lane under their own attribute, so a lane
selector matches the lane and not its names.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 16:41:44 +02:00
1 parent b1a0598c9d
commit b274ee51ad
2 files changed
+243 -14

No files matched your search

+241 -12
View File
@@ -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 <day>" in the selection bar does the same for the selected day.
// 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");
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<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, 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);
});
+2 -2
View File
@@ -582,8 +582,8 @@ function ChartBody({
lane.labels.map((label, i) => (
<text
key={`label-${lane.key}-${label.itemId}-${i}`}
data-lane={lane.key}
data-item={label.itemId}
data-label-lane={lane.key}
data-label-item={label.itemId}
x={label.x}
y={label.y}
fontSize={11}