mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
A medication arrived as up to four kinds of item (its own span, its courses, its dose changes, its pauses) and was drawn item by item, so a medication with a course read as two bars on two rows. Every item of one medication now carries the medication's id as `group`, and the lane draws one row per group: the courses are the stretches it was taken, each dose change cuts them into pieces labelled with their dose, and a pause is a gap in the same bar. The selection bar and the phone chronicle list one entry per medication with the dose in the text. The value series cap of six is gone. The response carries `availableSeries`, every series the record can show, and the picker is one compact "Values (n)" button whose menu lists them. Past the fifth line the five data colours repeat; every line is named at its left. Choosing a range opens a popover anchored to the "Range" segment (a sheet on a phone) with "Apply", so the chart no longer moves; the URL keeps the range as before. The selection bar sits on the card under a hairline instead of a grey box, the legend explains the dashed stroke and the hollow point, and no timeline text joins its parts with a middle dot or calls an open span "ongoing".
890 lines
22 KiB
TypeScript
890 lines
22 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;
|
|
/** The medication an item belongs to; one row per group. */
|
|
group: 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,
|
|
group: 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;
|
|
};
|
|
}
|
|
|
|
type Bucket = "quarter" | "month" | "week" | "day";
|
|
|
|
/** The bucket the server picks for a zoom (`timelineBucket`). */
|
|
export function bucketFor(
|
|
zoom: string,
|
|
from?: string | null,
|
|
to?: string | null,
|
|
): Bucket {
|
|
if (zoom === "range" && from && to) {
|
|
// `rangeBucket`: by the range's length in days.
|
|
const days = dayNum(to) - dayNum(from) + 1;
|
|
return days >= 730
|
|
? "quarter"
|
|
: days >= 120
|
|
? "month"
|
|
: days >= 42
|
|
? "week"
|
|
: "day";
|
|
}
|
|
return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter";
|
|
}
|
|
|
|
function dayNum(key: string): number {
|
|
const [y, m, d] = key.split("-").map(Number);
|
|
return Math.round(Date.UTC(y, m - 1, d) / 86_400_000);
|
|
}
|
|
|
|
function dayStr(n: number): string {
|
|
return new Date(n * 86_400_000).toISOString().slice(0, 10);
|
|
}
|
|
|
|
/** Bucket starts from the one holding `from` to the one holding `to`. */
|
|
function bucketStarts(from: string, to: string, bucket: Bucket): string[] {
|
|
const out: string[] = [];
|
|
if (bucket === "day") {
|
|
for (let d = dayNum(from); d <= dayNum(to); d++) out.push(dayStr(d));
|
|
return out;
|
|
}
|
|
if (bucket === "week") {
|
|
const n = dayNum(from);
|
|
// 1970-01-01 was a Thursday: day 4 is the first Monday.
|
|
for (let d = n - ((((n - 4) % 7) + 7) % 7); d <= dayNum(to); d += 7) {
|
|
out.push(dayStr(d));
|
|
}
|
|
return out;
|
|
}
|
|
const step = bucket === "quarter" ? 3 : 1;
|
|
let [y, m] = from.split("-").map(Number);
|
|
m -= (m - 1) % step;
|
|
const [ty, tm] = to.split("-").map(Number);
|
|
while (y < ty || (y === ty && m <= tm)) {
|
|
out.push(`${y}-${String(m).padStart(2, "0")}-01`);
|
|
m += step;
|
|
if (m > 12) {
|
|
m -= 12;
|
|
y++;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Systolic pressure leaves gaps the chart has to show honestly: in the
|
|
* quarterly view one missing quarter (Q2 2023, bridged dashed) and three
|
|
* (2021, the line breaks). Q1 2022 rests on two readings in every series.
|
|
*/
|
|
export const SYS_MISSING = new Set([
|
|
"2021-01-01",
|
|
"2021-04-01",
|
|
"2021-07-01",
|
|
"2023-04-01",
|
|
]);
|
|
export const SYS_THIN = "2022-01-01";
|
|
|
|
function points(
|
|
from: string,
|
|
to: string,
|
|
bucket: Bucket,
|
|
value: (i: number, key: string) => number,
|
|
skip: (key: string) => boolean = () => false,
|
|
) {
|
|
const starts = bucketStarts(from, to, bucket);
|
|
return starts
|
|
.map((key, i) => ({
|
|
t: key,
|
|
mean: Math.round(value(i / starts.length, key) * 10) / 10,
|
|
count: key === SYS_THIN ? 2 : 4 + (i % 9),
|
|
}))
|
|
.filter((p) => !skip(p.t));
|
|
}
|
|
|
|
function series(dataFrom: string, today: string, bucket: Bucket) {
|
|
const r = rng(11);
|
|
return [
|
|
{
|
|
key: "BLOOD_PRESSURE_SYS",
|
|
unit: "mmHg",
|
|
points: points(
|
|
dataFrom,
|
|
today,
|
|
bucket,
|
|
(f) => 147 - 18 * f + (r() - 0.5) * 4,
|
|
(t) => bucket === "quarter" && SYS_MISSING.has(t),
|
|
),
|
|
},
|
|
{
|
|
key: "BLOOD_PRESSURE_DIA",
|
|
unit: "mmHg",
|
|
points: points(
|
|
dataFrom,
|
|
today,
|
|
bucket,
|
|
(f) => 92 - 10 * f + (r() - 0.5) * 3,
|
|
),
|
|
},
|
|
{
|
|
key: "WEIGHT",
|
|
unit: "kg",
|
|
points: points(
|
|
dataFrom,
|
|
today,
|
|
bucket,
|
|
(f) => 89 - 6.5 * f + (r() - 0.5) * 0.6,
|
|
),
|
|
},
|
|
{
|
|
key: "RESTING_HEART_RATE",
|
|
unit: "bpm",
|
|
points: points(dataFrom, today, bucket, (f, k) => {
|
|
const month = Number(k.slice(5, 7));
|
|
return (
|
|
68 - 5 * f + Math.sin((month / 12) * 6.28) * 1.2 + (r() - 0.5) * 1.2
|
|
);
|
|
}),
|
|
},
|
|
{
|
|
key: "PULSE",
|
|
unit: "bpm",
|
|
points: points(dataFrom, today, bucket, (f) => 74 - 4 * f + r()),
|
|
},
|
|
{
|
|
key: "BODY_FAT",
|
|
unit: "%",
|
|
points: points(dataFrom, today, bucket, (f) => 24 - 3 * f + r() * 0.4),
|
|
},
|
|
{
|
|
key: "ACTIVITY_STEPS",
|
|
unit: "steps",
|
|
points: points(
|
|
dataFrom,
|
|
today,
|
|
bucket,
|
|
(f) => 7200 + 900 * f + r() * 400,
|
|
),
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Every series the `full` record can show: fifteen, past the five colours
|
|
* and past the six lines the chart used to stop at.
|
|
*/
|
|
export const FULL_AVAILABLE = [
|
|
"WEIGHT",
|
|
"BLOOD_PRESSURE_SYS",
|
|
"BLOOD_PRESSURE_DIA",
|
|
"PULSE",
|
|
"BODY_FAT",
|
|
"SLEEP_DURATION",
|
|
"ACTIVITY_STEPS",
|
|
"BLOOD_GLUCOSE",
|
|
"OXYGEN_SATURATION",
|
|
"HEART_RATE_VARIABILITY",
|
|
"RESTING_HEART_RATE",
|
|
"VO2_MAX",
|
|
"BODY_TEMPERATURE",
|
|
"RESPIRATORY_RATE",
|
|
"MOOD",
|
|
];
|
|
|
|
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",
|
|
group: "med-1",
|
|
kind: "medication",
|
|
start: "2019-04-02",
|
|
open: true,
|
|
label: "Ramipril",
|
|
sub: "2,5 mg",
|
|
}),
|
|
item({
|
|
id: "dose-1",
|
|
group: "med-1",
|
|
kind: "doseChange",
|
|
start: "2020-08-12",
|
|
label: "Ramipril",
|
|
sub: "5 mg",
|
|
}),
|
|
item({
|
|
id: "med-2",
|
|
group: "med-2",
|
|
kind: "medication",
|
|
start: "2022-11-03",
|
|
end: "2022-12-10",
|
|
label: "Ibuprofen",
|
|
}),
|
|
// One medication sent as its own span, a course, three dose
|
|
// changes and a pause: the case the beta drew as two medications.
|
|
item({
|
|
id: "mj",
|
|
group: "mj",
|
|
kind: "medication",
|
|
start: "2025-01-06",
|
|
open: true,
|
|
label: "Mounjaro",
|
|
sub: "7,5 mg",
|
|
}),
|
|
item({
|
|
id: "mj-course",
|
|
group: "mj",
|
|
kind: "course",
|
|
start: "2025-01-06",
|
|
open: true,
|
|
label: "Mounjaro",
|
|
}),
|
|
...(
|
|
[
|
|
["mj-25", "2025-01-06", "2,5 mg"],
|
|
["mj-5", "2025-03-03", "5 mg"],
|
|
["mj-75", "2025-06-02", "7,5 mg"],
|
|
] as const
|
|
).map(([id, start, sub]) =>
|
|
item({
|
|
id,
|
|
group: "mj",
|
|
kind: "doseChange",
|
|
start,
|
|
label: "Mounjaro",
|
|
sub,
|
|
}),
|
|
),
|
|
item({
|
|
id: "mj-pause",
|
|
group: "mj",
|
|
kind: "pause",
|
|
start: "2025-08-01",
|
|
end: "2025-08-20",
|
|
label: "Mounjaro",
|
|
}),
|
|
item({
|
|
id: "c-1",
|
|
group: "vit-d",
|
|
kind: "course",
|
|
start: "2023-11-01",
|
|
end: "2024-03-31",
|
|
label: "Vitamin D (Winter)",
|
|
}),
|
|
item({
|
|
id: "c-2",
|
|
group: "vit-d",
|
|
kind: "course",
|
|
start: "2024-11-01",
|
|
end: "2025-03-31",
|
|
label: "Vitamin D (Winter)",
|
|
}),
|
|
item({
|
|
id: "c-3",
|
|
group: "vit-d",
|
|
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: [],
|
|
bucket: "quarter" as Bucket,
|
|
series: series("2019-01-01", today, "quarter"),
|
|
availableSeries: FULL_AVAILABLE,
|
|
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",
|
|
group: "med-1",
|
|
kind: "medication",
|
|
start: "2024-05-01",
|
|
open: true,
|
|
startKnown: false,
|
|
label: "Ramipril",
|
|
sub: "5 mg",
|
|
}),
|
|
item({
|
|
id: "c-2",
|
|
group: "vit-d",
|
|
kind: "course",
|
|
start: "2024-11-01",
|
|
end: "2025-03-31",
|
|
label: "Vitamin D (Winter)",
|
|
}),
|
|
item({
|
|
id: "c-3",
|
|
group: "vit-d",
|
|
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: [],
|
|
bucket: "quarter" as Bucket,
|
|
series: series("2024-05-01", today, "quarter").filter(
|
|
(s) => s.key !== "RESTING_HEART_RATE",
|
|
),
|
|
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
|
|
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);
|
|
const zoom = url.searchParams.get("zoom") ?? "all";
|
|
const from = url.searchParams.get("from");
|
|
const to = url.searchParams.get("to");
|
|
const bucket = bucketFor(zoom, from, to);
|
|
const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01";
|
|
// A chosen range answers its own window, as the server does.
|
|
const chosen = zoom === "range" && from && to;
|
|
const all =
|
|
!chosen && bucket === body.bucket
|
|
? body.series
|
|
: series(chosen ? from : dataFrom, chosen ? to : today, bucket);
|
|
return json(route, 200, {
|
|
...body,
|
|
zoom,
|
|
bucket,
|
|
...(chosen ? { range: { from, to, dataFrom } } : {}),
|
|
// Any other value the menu offers answers a flat line of its own.
|
|
series: values.map(
|
|
(key) =>
|
|
all.find((s) => s.key === key) ?? {
|
|
key,
|
|
unit: null,
|
|
points: points(dataFrom, today, bucket, (f) => 50 + 10 * f),
|
|
},
|
|
),
|
|
});
|
|
});
|
|
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;
|
|
}
|