mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The journey picks a lab marker from the values menu, sees its readings drawn with the range behind them and the one below it as a triangle, reads that reading's card by keyboard with its report, checks the page with axe, reloads to find the choice kept, and on a phone finds the readings on their lab days without a sideways scroll before unchoosing the marker again. The mocks answer lab markers only when a test asks for them, so the other value-line steps keep counting what they counted, and the two values triggers (the toolbar's and the phone's) are addressed by their surface. Refs #1299
1030 lines
25 KiB
TypeScript
1030 lines
25 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,
|
|
labSeries: [],
|
|
availableLabs: [],
|
|
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Two lab markers of the full record (#1299): ferritin read a few times over
|
|
* the years against a 30 to 400 range, one reading below it and one read
|
|
* from a report, and HbA1c read once. Only what `values` names is sent as a
|
|
* line; the menu offers both.
|
|
*/
|
|
export const LAB_MARKERS = [
|
|
{
|
|
key: "lab:bm-ferritin",
|
|
biomarkerId: "bm-ferritin",
|
|
name: "Ferritin",
|
|
panel: "Eisen",
|
|
unit: "ng/mL",
|
|
},
|
|
{
|
|
key: "lab:bm-hba1c",
|
|
biomarkerId: "bm-hba1c",
|
|
name: "HbA1c",
|
|
panel: "Zucker",
|
|
unit: "%",
|
|
},
|
|
];
|
|
|
|
function labPoint(
|
|
id: string,
|
|
date: string,
|
|
value: number,
|
|
rangeStatus: "below" | "in-range" | "above" | "unknown",
|
|
report: { id: string; title: string } | null = null,
|
|
) {
|
|
const ranged = rangeStatus !== "unknown";
|
|
return {
|
|
id,
|
|
date,
|
|
value,
|
|
sourceValue: null,
|
|
rangeStatus,
|
|
referenceLow: ranged ? 30 : null,
|
|
referenceHigh: ranged ? 400 : null,
|
|
report: report ? { ...report, href: `/documents?doc=${report.id}` } : null,
|
|
};
|
|
}
|
|
|
|
export const LAB_LINES = [
|
|
{
|
|
...LAB_MARKERS[0],
|
|
band: { low: 30, high: 400 },
|
|
points: [
|
|
labPoint("fe-1", "2021-03-15", 22, "below", {
|
|
id: "doc-lab-1",
|
|
title: "Laborbefund März",
|
|
}),
|
|
labPoint("fe-2", "2022-09-20", 64, "in-range"),
|
|
labPoint("fe-3", "2024-02-12", 118, "in-range"),
|
|
labPoint("fe-4", "2025-11-04", 96, "in-range"),
|
|
],
|
|
excluded: 0,
|
|
},
|
|
{
|
|
...LAB_MARKERS[1],
|
|
band: null,
|
|
points: [labPoint("a1c-1", "2025-11-04", 5.4, "unknown")],
|
|
excluded: 0,
|
|
},
|
|
];
|
|
|
|
/** The lab days behind the lab lines, as the labs lane names them. */
|
|
function labsLane(keys: readonly string[]) {
|
|
const days = new Map<string, string[]>();
|
|
for (const line of LAB_LINES) {
|
|
if (!keys.includes(line.key)) continue;
|
|
for (const p of line.points) {
|
|
days.set(p.date, [...(days.get(p.date) ?? []), line.name]);
|
|
}
|
|
}
|
|
return {
|
|
key: "labs",
|
|
items: [...days]
|
|
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
.map(([date, names]) =>
|
|
item({
|
|
id: `lab-day:${date}`,
|
|
kind: "labDay",
|
|
start: date,
|
|
label: names.join(", "),
|
|
sub: String(names.length),
|
|
href: "/labs",
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
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"],
|
|
labSeries: [],
|
|
availableLabs: [],
|
|
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.
|
|
*
|
|
* `onlyOld`: the record's entries all lie before the last year, so a
|
|
* quarter or year window answers empty (with the record's `dataFrom`) and
|
|
* only everything, or a chosen range, carries them.
|
|
*
|
|
* `dataFrom`: the record's first entry as the answers name it, to stand in
|
|
* for a young record.
|
|
*/
|
|
export async function mockTimeline(
|
|
page: Page,
|
|
state: "full" | "ready",
|
|
today: string,
|
|
options: {
|
|
onlyOld?: boolean;
|
|
dataFrom?: string;
|
|
/** The record has lab markers to offer and draw (#1299). */
|
|
labs?: boolean;
|
|
} = {},
|
|
): 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 named = (url.searchParams.get("values") ?? "")
|
|
.split(",")
|
|
.filter(Boolean);
|
|
// A lab marker is a line of its own (`labSeries`), never a mean.
|
|
const labKeys = named.filter((key) => key.startsWith("lab:"));
|
|
const values = named.filter((key) => !key.startsWith("lab:"));
|
|
const labs =
|
|
state === "full" && options.labs
|
|
? {
|
|
labSeries: LAB_LINES.filter((l) => labKeys.includes(l.key)),
|
|
availableLabs: LAB_MARKERS,
|
|
lanes:
|
|
labKeys.length > 0
|
|
? [...body.lanes, labsLane(labKeys)]
|
|
: body.lanes,
|
|
}
|
|
: {};
|
|
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 =
|
|
options.dataFrom ?? (state === "full" ? "2019-01-01" : "2024-05-01");
|
|
if (options.onlyOld && (zoom === "quarter" || zoom === "year")) {
|
|
return json(route, 200, {
|
|
...body,
|
|
zoom,
|
|
bucket,
|
|
range: { from, to, dataFrom },
|
|
lanes: body.lanes.map((lane) => ({ ...lane, items: [] })),
|
|
standing: [],
|
|
series: values.map((key) => ({ key, unit: null, points: [] })),
|
|
});
|
|
}
|
|
// 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,
|
|
...labs,
|
|
zoom,
|
|
bucket,
|
|
...(chosen
|
|
? { range: { from, to, dataFrom } }
|
|
: options.dataFrom
|
|
? { range: { ...body.range, 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;
|
|
}
|