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.
703 lines
16 KiB
TypeScript
703 lines
16 KiB
TypeScript
import type { Page, Route } from "@playwright/test";
|
|
|
|
/**
|
|
* Route mocks for the timeline (v1.42, #613): `GET /api/timeline`,
|
|
* `GET /api/timeline/readiness` and `/api/life-events`, answered in the
|
|
* shapes of `src/lib/day/contract.ts` from an invented record. Two records:
|
|
* `full` (years of history in every lane) and `ready` (a thin record that
|
|
* starts in May 2024, a medication without a start date, no life events, no
|
|
* vaccinations), matching the two states the design was approved in.
|
|
*
|
|
* Every value here is made up.
|
|
*/
|
|
|
|
type Item = {
|
|
id: string;
|
|
kind: string;
|
|
start: string;
|
|
end: string | null;
|
|
open: boolean;
|
|
precision: "DAY" | "MONTH" | "YEAR";
|
|
startKnown: boolean;
|
|
label: string;
|
|
sub: string | null;
|
|
href: string | null;
|
|
};
|
|
|
|
function item(over: Partial<Item> & Pick<Item, "id" | "kind" | "start">): Item {
|
|
return {
|
|
end: null,
|
|
open: false,
|
|
precision: "DAY",
|
|
startKnown: true,
|
|
label: over.id,
|
|
sub: null,
|
|
href: null,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
/** A small deterministic generator, so every run draws the same lines. */
|
|
function rng(seed: number) {
|
|
let s = seed;
|
|
return () => {
|
|
s = (s * 16807) % 2147483647;
|
|
return (s - 1) / 2147483646;
|
|
};
|
|
}
|
|
|
|
function monthly(
|
|
from: string,
|
|
to: string,
|
|
value: (i: number, monthKey: string) => number,
|
|
) {
|
|
const out: Array<{ t: string; mean: number }> = [];
|
|
let [y, m] = from.split("-").map(Number);
|
|
const [ty, tm] = to.split("-").map(Number);
|
|
let i = 0;
|
|
while (y < ty || (y === ty && m <= tm)) {
|
|
const key = `${y}-${String(m).padStart(2, "0")}-01`;
|
|
out.push({ t: key, mean: Math.round(value(i, key) * 10) / 10 });
|
|
i++;
|
|
m++;
|
|
if (m > 12) {
|
|
m = 1;
|
|
y++;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function series(dataFrom: string, today: string) {
|
|
const r = rng(11);
|
|
const months = monthly(dataFrom, today, () => 0).length;
|
|
return [
|
|
{
|
|
key: "BLOOD_PRESSURE_SYS",
|
|
unit: "mmHg",
|
|
granularity: "month",
|
|
points: monthly(
|
|
dataFrom,
|
|
today,
|
|
(i) => 147 - (18 * i) / months + (r() - 0.5) * 4,
|
|
),
|
|
},
|
|
{
|
|
key: "BLOOD_PRESSURE_DIA",
|
|
unit: "mmHg",
|
|
granularity: "month",
|
|
points: monthly(
|
|
dataFrom,
|
|
today,
|
|
(i) => 92 - (10 * i) / months + (r() - 0.5) * 3,
|
|
),
|
|
},
|
|
{
|
|
key: "WEIGHT",
|
|
unit: "kg",
|
|
granularity: "month",
|
|
points: monthly(
|
|
dataFrom,
|
|
today,
|
|
(i) => 89 - (6.5 * i) / months + (r() - 0.5) * 0.6,
|
|
),
|
|
},
|
|
{
|
|
key: "RESTING_HEART_RATE",
|
|
unit: "bpm",
|
|
granularity: "month",
|
|
points: monthly(dataFrom, today, (i, k) => {
|
|
const month = Number(k.slice(5, 7));
|
|
return (
|
|
68 -
|
|
(5 * i) / months +
|
|
Math.sin((month / 12) * 6.28) * 1.2 +
|
|
(r() - 0.5) * 1.2
|
|
);
|
|
}),
|
|
},
|
|
];
|
|
}
|
|
|
|
export function fullTimeline(today: string) {
|
|
const vaccinations = [
|
|
"2020-06-10",
|
|
"2021-05-20",
|
|
"2021-07-01",
|
|
"2021-12-10",
|
|
"2022-10-15",
|
|
"2023-10-20",
|
|
"2024-03-20",
|
|
"2024-04-25",
|
|
"2024-10-18",
|
|
"2025-10-10",
|
|
];
|
|
const visits = [
|
|
"2019-03-10",
|
|
"2020-03-05",
|
|
"2021-09-14",
|
|
"2022-09-20",
|
|
"2023-09-12",
|
|
"2024-09-17",
|
|
"2025-09-18",
|
|
"2026-01-03",
|
|
"2026-06-22",
|
|
];
|
|
const documents = [
|
|
"2019-03-12",
|
|
"2020-03-06",
|
|
"2021-09-15",
|
|
"2022-09-21",
|
|
"2022-11-09",
|
|
"2023-02-20",
|
|
"2023-09-13",
|
|
"2024-09-18",
|
|
"2025-03-14",
|
|
"2025-09-19",
|
|
"2026-01-03",
|
|
"2026-06-23",
|
|
];
|
|
return {
|
|
zoom: "all",
|
|
range: {
|
|
from: "2019-01-01",
|
|
to: `${today.slice(0, 4)}-12-31`,
|
|
dataFrom: "2019-01-01",
|
|
},
|
|
lanes: [
|
|
{
|
|
key: "life",
|
|
items: [
|
|
item({
|
|
id: "le-1",
|
|
kind: "lifeEvent",
|
|
start: "2021-05-14",
|
|
label: "Geburt Tochter",
|
|
}),
|
|
item({
|
|
id: "le-2",
|
|
kind: "lifeEvent",
|
|
start: "2023-09-01",
|
|
precision: "MONTH",
|
|
label: "Umzug",
|
|
}),
|
|
item({
|
|
id: "le-3",
|
|
kind: "lifeEvent",
|
|
start: "2024-10-01",
|
|
label: "Neue Stelle",
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
key: "illness",
|
|
items: [
|
|
item({
|
|
id: "ill-c",
|
|
kind: "chronic",
|
|
start: "2019-03-10",
|
|
open: true,
|
|
label: "Bluthochdruck",
|
|
sub: "chronisch",
|
|
href: "/illness",
|
|
}),
|
|
item({
|
|
id: "ill-1",
|
|
kind: "episode",
|
|
start: "2020-09-01",
|
|
end: "2020-10-20",
|
|
label: "Schulter",
|
|
}),
|
|
item({
|
|
id: "ill-2",
|
|
kind: "episode",
|
|
start: "2022-03-08",
|
|
end: "2022-03-24",
|
|
label: "COVID-19",
|
|
}),
|
|
item({
|
|
id: "ill-3",
|
|
kind: "episode",
|
|
start: "2022-11-02",
|
|
end: "2023-02-15",
|
|
label: "Bandscheibe L4/L5",
|
|
}),
|
|
item({
|
|
id: "ill-4",
|
|
kind: "episode",
|
|
start: "2024-08-10",
|
|
end: "2024-08-16",
|
|
label: "Magen-Darm",
|
|
}),
|
|
item({
|
|
id: "ill-5",
|
|
kind: "episode",
|
|
start: "2025-12-31",
|
|
end: "2026-01-08",
|
|
label: "Erkältung",
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
key: "allergies",
|
|
items: [
|
|
item({
|
|
id: "al-1",
|
|
kind: "allergy",
|
|
start: "2018-01-01",
|
|
open: true,
|
|
label: "Gräserpollen",
|
|
sub: "seit 2008",
|
|
}),
|
|
item({
|
|
id: "al-2",
|
|
kind: "allergy",
|
|
start: "2020-07-15",
|
|
open: true,
|
|
label: "Penicillin",
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
key: "medications",
|
|
items: [
|
|
item({
|
|
id: "med-1",
|
|
kind: "medication",
|
|
start: "2019-04-02",
|
|
open: true,
|
|
label: "Ramipril",
|
|
sub: "2,5 mg",
|
|
}),
|
|
item({
|
|
id: "dose-1",
|
|
kind: "doseChange",
|
|
start: "2020-08-12",
|
|
label: "Ramipril",
|
|
sub: "5 mg",
|
|
}),
|
|
item({
|
|
id: "med-2",
|
|
kind: "medication",
|
|
start: "2022-11-03",
|
|
end: "2022-12-10",
|
|
label: "Ibuprofen",
|
|
}),
|
|
item({
|
|
id: "c-1",
|
|
kind: "course",
|
|
start: "2023-11-01",
|
|
end: "2024-03-31",
|
|
label: "Vitamin D (Winter)",
|
|
}),
|
|
item({
|
|
id: "c-2",
|
|
kind: "course",
|
|
start: "2024-11-01",
|
|
end: "2025-03-31",
|
|
label: "Vitamin D (Winter)",
|
|
}),
|
|
item({
|
|
id: "c-3",
|
|
kind: "course",
|
|
start: "2025-11-01",
|
|
end: "2026-03-31",
|
|
label: "Vitamin D (Winter)",
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
key: "vaccinations",
|
|
items: vaccinations.map((d, i) =>
|
|
item({
|
|
id: `vac-${i}`,
|
|
kind: "vaccination",
|
|
start: d,
|
|
label: "Impfung",
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
key: "visits",
|
|
items: [
|
|
...visits.map((d, i) =>
|
|
item({
|
|
id: `v-${i}`,
|
|
kind: "visit",
|
|
start: d,
|
|
label: "Hausarztpraxis",
|
|
}),
|
|
),
|
|
item({
|
|
id: "p-1",
|
|
kind: "procedure",
|
|
start: "2022-11-08",
|
|
label: "MRT Lendenwirbelsäule",
|
|
}),
|
|
item({
|
|
id: "p-2",
|
|
kind: "procedure",
|
|
start: "2025-03-12",
|
|
label: "Darmspiegelung",
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
key: "documents",
|
|
items: documents.map((d, i) =>
|
|
item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }),
|
|
),
|
|
},
|
|
],
|
|
standing: [],
|
|
series: series("2019-01-01", today),
|
|
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
|
|
};
|
|
}
|
|
|
|
export function readyTimeline(today: string) {
|
|
return {
|
|
zoom: "all",
|
|
range: {
|
|
from: "2024-01-01",
|
|
to: `${today.slice(0, 4)}-12-31`,
|
|
dataFrom: "2024-05-01",
|
|
},
|
|
lanes: [
|
|
{
|
|
key: "illness",
|
|
items: [
|
|
item({
|
|
id: "ill-c",
|
|
kind: "chronic",
|
|
start: "2019-03-10",
|
|
open: true,
|
|
label: "Bluthochdruck",
|
|
sub: "chronisch",
|
|
}),
|
|
item({
|
|
id: "ill-4",
|
|
kind: "episode",
|
|
start: "2024-08-10",
|
|
end: "2024-08-16",
|
|
label: "Magen-Darm",
|
|
}),
|
|
item({
|
|
id: "ill-5",
|
|
kind: "episode",
|
|
start: "2025-12-31",
|
|
end: "2026-01-08",
|
|
label: "Erkältung",
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
key: "medications",
|
|
items: [
|
|
item({
|
|
id: "med-1",
|
|
kind: "medication",
|
|
start: "2024-05-01",
|
|
open: true,
|
|
startKnown: false,
|
|
label: "Ramipril",
|
|
sub: "5 mg",
|
|
}),
|
|
item({
|
|
id: "c-2",
|
|
kind: "course",
|
|
start: "2024-11-01",
|
|
end: "2025-03-31",
|
|
label: "Vitamin D (Winter)",
|
|
}),
|
|
item({
|
|
id: "c-3",
|
|
kind: "course",
|
|
start: "2025-11-01",
|
|
end: "2026-03-31",
|
|
label: "Vitamin D (Winter)",
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
key: "visits",
|
|
items: [
|
|
item({
|
|
id: "v-1",
|
|
kind: "visit",
|
|
start: "2024-09-17",
|
|
label: "Hausarztpraxis",
|
|
}),
|
|
item({
|
|
id: "p-2",
|
|
kind: "procedure",
|
|
start: "2025-03-12",
|
|
label: "Darmspiegelung",
|
|
}),
|
|
item({
|
|
id: "v-2",
|
|
kind: "visit",
|
|
start: "2025-09-18",
|
|
label: "Hausarztpraxis",
|
|
}),
|
|
item({
|
|
id: "v-3",
|
|
kind: "visit",
|
|
start: "2026-01-03",
|
|
label: "Hausarztpraxis",
|
|
}),
|
|
item({
|
|
id: "v-4",
|
|
kind: "visit",
|
|
start: "2026-06-22",
|
|
label: "Hausarztpraxis",
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
key: "documents",
|
|
items: [
|
|
"2024-09-18",
|
|
"2025-03-14",
|
|
"2025-09-19",
|
|
"2026-01-03",
|
|
"2026-06-23",
|
|
].map((d, i) =>
|
|
item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }),
|
|
),
|
|
},
|
|
],
|
|
standing: [],
|
|
series: series("2024-05-01", today).filter(
|
|
(s) => s.key !== "RESTING_HEART_RATE",
|
|
),
|
|
notable: [],
|
|
};
|
|
}
|
|
|
|
export function fullReadiness() {
|
|
return {
|
|
verdict: "carries",
|
|
since: "2019-03-01",
|
|
lanes: [
|
|
{
|
|
key: "values",
|
|
status: "carries",
|
|
count: 3,
|
|
detail: { key: "values", params: { count: 3, since: "2019-03-01" } },
|
|
gaps: [],
|
|
},
|
|
{
|
|
key: "illness",
|
|
status: "carries",
|
|
count: 6,
|
|
detail: { key: "illness", params: { count: 6, chronic: 1 } },
|
|
gaps: [],
|
|
},
|
|
{
|
|
key: "visits",
|
|
status: "carries",
|
|
count: 11,
|
|
detail: { key: "visits", params: { count: 11, procedures: 2 } },
|
|
gaps: [],
|
|
},
|
|
{
|
|
key: "documents",
|
|
status: "carries",
|
|
count: 14,
|
|
detail: { key: "documents", params: { count: 14 } },
|
|
gaps: [],
|
|
},
|
|
{
|
|
key: "labs",
|
|
status: "carries",
|
|
count: 5,
|
|
detail: { key: "labs", params: { count: 5 } },
|
|
gaps: [],
|
|
},
|
|
{
|
|
key: "medications",
|
|
status: "thin",
|
|
count: 5,
|
|
detail: { key: "medications", params: { count: 5, missingStart: 3 } },
|
|
gaps: [
|
|
{ key: "medicationsWithoutStart", count: 3, href: "/medications" },
|
|
],
|
|
},
|
|
{
|
|
key: "vaccinations",
|
|
status: "empty",
|
|
count: 0,
|
|
detail: { key: "empty", params: {} },
|
|
gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }],
|
|
},
|
|
{
|
|
key: "life",
|
|
status: "empty",
|
|
count: 0,
|
|
detail: null,
|
|
gaps: [
|
|
{ key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export function readyReadiness() {
|
|
return {
|
|
verdict: "carries",
|
|
since: "2024-05-01",
|
|
lanes: [
|
|
{
|
|
key: "values",
|
|
status: "carries",
|
|
count: 2,
|
|
detail: { key: "values", params: { count: 2, since: "2024-05-01" } },
|
|
gaps: [],
|
|
},
|
|
{
|
|
key: "illness",
|
|
status: "carries",
|
|
count: 3,
|
|
detail: { key: "illness", params: { count: 3, chronic: 1 } },
|
|
gaps: [],
|
|
},
|
|
{
|
|
key: "visits",
|
|
status: "carries",
|
|
count: 5,
|
|
detail: { key: "visits", params: { count: 5, procedures: 1 } },
|
|
gaps: [],
|
|
},
|
|
{
|
|
key: "documents",
|
|
status: "carries",
|
|
count: 5,
|
|
detail: { key: "documents", params: { count: 5 } },
|
|
gaps: [],
|
|
},
|
|
{
|
|
key: "medications",
|
|
status: "thin",
|
|
count: 3,
|
|
detail: { key: "medications", params: { count: 3, missingStart: 3 } },
|
|
gaps: [
|
|
{ key: "medicationsWithoutStart", count: 3, href: "/medications" },
|
|
],
|
|
},
|
|
{
|
|
key: "life",
|
|
status: "empty",
|
|
count: 0,
|
|
detail: null,
|
|
gaps: [
|
|
{ key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" },
|
|
],
|
|
},
|
|
{
|
|
key: "vaccinations",
|
|
status: "empty",
|
|
count: 0,
|
|
detail: { key: "empty", params: {} },
|
|
gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const LIFE_EVENTS = [
|
|
{
|
|
id: "le-1",
|
|
category: "FAMILY",
|
|
startDate: "2021-05-14",
|
|
endDate: null,
|
|
precision: "DAY",
|
|
title: "Geburt Tochter",
|
|
note: null,
|
|
},
|
|
{
|
|
id: "le-2",
|
|
category: "HOME",
|
|
startDate: "2023-09-01",
|
|
endDate: null,
|
|
precision: "MONTH",
|
|
title: "Umzug",
|
|
note: null,
|
|
},
|
|
{
|
|
id: "le-3",
|
|
category: "WORK",
|
|
startDate: "2024-10-01",
|
|
endDate: null,
|
|
precision: "DAY",
|
|
title: "Neue Stelle",
|
|
note: null,
|
|
},
|
|
].map((e) => ({
|
|
...e,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
}));
|
|
|
|
function json(route: Route, status: number, data: unknown) {
|
|
return route.fulfill({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ data, error: null }),
|
|
});
|
|
}
|
|
|
|
export interface TimelineMockLog {
|
|
timelineQueries: URLSearchParams[];
|
|
lifeEventPosts: unknown[];
|
|
}
|
|
|
|
/**
|
|
* Answer the timeline routes from the `full` or `ready` record. Returns a
|
|
* log of what the page asked for and posted, for assertions.
|
|
*/
|
|
export async function mockTimeline(
|
|
page: Page,
|
|
state: "full" | "ready",
|
|
today: string,
|
|
): Promise<TimelineMockLog> {
|
|
const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] };
|
|
await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) =>
|
|
json(route, 200, state === "full" ? fullReadiness() : readyReadiness()),
|
|
);
|
|
await page.route(/\/api\/timeline(\?|$)/, (route) => {
|
|
const url = new URL(route.request().url());
|
|
log.timelineQueries.push(url.searchParams);
|
|
const body = state === "full" ? fullTimeline(today) : readyTimeline(today);
|
|
const values = (url.searchParams.get("values") ?? "")
|
|
.split(",")
|
|
.filter(Boolean);
|
|
return json(route, 200, {
|
|
...body,
|
|
zoom: url.searchParams.get("zoom") ?? "all",
|
|
series: body.series.filter((s) => values.includes(s.key)),
|
|
});
|
|
});
|
|
await page.route(/\/api\/life-events(\/[^/?]+)?(\?|$)/, async (route) => {
|
|
const method = route.request().method();
|
|
if (method === "GET") {
|
|
return json(route, 200, { events: state === "full" ? LIFE_EVENTS : [] });
|
|
}
|
|
if (method === "POST") {
|
|
const posted = route.request().postDataJSON() as Record<string, unknown>;
|
|
log.lifeEventPosts.push(posted);
|
|
return json(route, 201, {
|
|
id: "le-new",
|
|
endDate: null,
|
|
note: null,
|
|
...posted,
|
|
createdAt: "2026-10-01T00:00:00.000Z",
|
|
updatedAt: "2026-10-01T00:00:00.000Z",
|
|
});
|
|
}
|
|
return json(route, 200, { deleted: true });
|
|
});
|
|
return log;
|
|
}
|