mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
A custom symptom could feed pattern discovery and the Coach but could not be looked at over time. The timeline now offers every symptom the record has logged, hidden ones included, beside the measurements and lab markers, and draws a chosen one as a value line. `values` takes `symptom:<definitionId>`, checked for its shape and capped like a lab marker; an id the record does not hold draws nothing. A day's value is its highest intensity on the record's local day; a point is the mean of the bucket's day values over the days logged, with the number of those days, and a day without an entry is not a zero. The line sits on a fixed 0 to 10 scale. Symptoms are read under the illness lane's gate, the module and sharing domain the symptom routes use. An `all` window reaches back to a chosen symptom's first entry. The response carries `symptomSeries` and `availableSymptoms`; the OpenAPI contract is regenerated. The page words a point's count as days and the value out of ten. The date-key schema moves to its own module so the symptom schemas can share it.
1092 lines
27 KiB
TypeScript
1092 lines
27 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: [],
|
|
symptomSeries: [],
|
|
availableSymptoms: [],
|
|
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",
|
|
}),
|
|
),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Two of the person's own symptoms (#981): a headache logged over several
|
|
* quarters, one quarter resting on two days, and a nausea hidden from entry
|
|
* since, logged once. Their points are bucket means of day peaks on the
|
|
* fixed 0 to 10 scale; only quarters are drawn here, the bucket an `all`
|
|
* view of the full record uses.
|
|
*/
|
|
export const SYMPTOM_OPTIONS = [
|
|
{
|
|
key: "symptom:sd-headache",
|
|
definitionId: "sd-headache",
|
|
label: "Kopfschmerz",
|
|
icon: "brain",
|
|
isActive: true,
|
|
},
|
|
{
|
|
key: "symptom:sd-nausea",
|
|
definitionId: "sd-nausea",
|
|
label: "Übelkeit",
|
|
icon: null,
|
|
isActive: false,
|
|
},
|
|
];
|
|
|
|
const SYMPTOM_QUARTERS: Record<
|
|
string,
|
|
Array<{ t: string; mean: number; count: number }>
|
|
> = {
|
|
"symptom:sd-headache": [
|
|
{ t: "2024-01-01", mean: 6.5, count: 9 },
|
|
{ t: "2024-04-01", mean: 4, count: 2 },
|
|
{ t: "2025-01-01", mean: 3.25, count: 5 },
|
|
],
|
|
"symptom:sd-nausea": [{ t: "2023-07-01", mean: 2, count: 1 }],
|
|
};
|
|
|
|
function symptomLines(keys: readonly string[], bucket: Bucket) {
|
|
return SYMPTOM_OPTIONS.filter((o) => keys.includes(o.key)).map((o) => ({
|
|
...o,
|
|
scale: { min: 0, max: 10 },
|
|
points: bucket === "quarter" ? SYMPTOM_QUARTERS[o.key] : [],
|
|
}));
|
|
}
|
|
|
|
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: [],
|
|
symptomSeries: [],
|
|
availableSymptoms: [],
|
|
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;
|
|
/** The record has logged symptoms to offer and draw (#981). */
|
|
symptoms?: 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:"));
|
|
// A symptom is a line of its own too (`symptomSeries`), on 0 to 10.
|
|
const symptomKeys = named.filter((key) => key.startsWith("symptom:"));
|
|
const values = named.filter(
|
|
(key) => !key.startsWith("lab:") && !key.startsWith("symptom:"),
|
|
);
|
|
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 symptoms =
|
|
state === "full" && options.symptoms
|
|
? {
|
|
symptomSeries: symptomLines(symptomKeys, bucket),
|
|
availableSymptoms: SYMPTOM_OPTIONS,
|
|
}
|
|
: {};
|
|
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,
|
|
...symptoms,
|
|
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;
|
|
}
|