mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The opt-in timeline module gets its page (#613). At 768 px and up the record's lanes (life, conditions, allergies, medications, vaccinations, doctor and procedures, labs, documents) are drawn in one SVG on a shared time axis, with a year, month or week grid, open periods ending in an arrow, a dashed lead-in where a start is unknown, dose marks and pauses on the medication they belong to, a Today line and a selection line. Up to three neutral value lines run below as monthly means, chosen in a menu. Recharts stays for every value chart; this surface is plain SVG like the heatmaps. All geometry is pure (timeline-geometry.ts): window, scale, row packing and label placement through one occupancy map, so no label overlaps another label or a bar, none runs past the card edge, and labels that do not fit are left out rather than squeezed. The width comes from a ResizeObserver, so a docked day panel re-runs the layout. The bar under the chart names the selected month's entries as chips, the month's means and "Open 3 Jan.", which puts ?day= in the URL for the day layer. The chart is one tab stop: arrows move the selection by a day, a month or a year with the zoom, Home and End jump to the ends, Enter opens the day. A visually hidden table lists every entry for screen readers. The legend says once that stacked means at the same time, not cause and effect. Below 768 px the timeline is a chronicle, newest first by month or year, with an Ongoing block for what has no meaningful start, rails beside the rows an episode or course spans, and empty stretches named as such. Switching the module on in Settings opens the readiness inventory: each lane carries, is thin or is empty, with one link per gap and the verdict as a count. The same inventory sits as a dismissible card on the timeline while a gap is open, and under Layers as "Data coverage". Life events are entered in a sheet (title, kind, a date to the day, month or year, an optional end, an optional note) from the timeline, the capture picker and the dashboard's add menu; one surface line maps the capture kind to the timeline module, and it is offered in one's own record only. Deleting asks first. Server routes are built in parallel; the e2e journey answers them with the contract's shapes. Copy in all seven locales; the timeline and lifeEvents release-pending prefixes are gone.
230 lines
8.4 KiB
TypeScript
230 lines
8.4 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. 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<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("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 <day>" 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<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");
|
|
|
|
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/);
|
|
});
|