Merge branch 'feat/v1463-labs' into release/v1.46.3

# Conflicts:
#	src/__tests__/baselines/source-file-lines.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 20:43:40 +01:00
commit 169a0f1df4
40 files changed
+4332 -668

No files matched your search

+173 -4
View File
@@ -26131,11 +26131,17 @@ paths:
- in: query
name: values
schema:
description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts
once, so the list is bounded by the names that exist; an unknown name answers 422.
description: Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as
`lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the
names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike
names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is
left out of `labSeries` without an error, so a stored choice never breaks the page.
type: string
description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts
once, so the list is bounded by the names that exist; an unknown name answers 422.
description: Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as
`lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the
names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike
names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is left
out of `labSeries` without an error, so a stored choice never breaks the page.
responses:
"200":
description: The timeline for the requested zoom and window.
@@ -92030,6 +92036,18 @@ components:
description: "Every value series this record can show, whatever the window: the `MeasurementType` names with live
readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has
entries. Each may be named in `values`."
labSeries:
type: array
items:
$ref: "#/components/schemas/TimelineLabSeries"
description: The lab markers named in `values`, in the order named, each with its single readings in the window. A
marker named but not found in the record is left out.
availableLabs:
type: array
items:
$ref: "#/components/schemas/TimelineLabMarker"
description: Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs.
Each may be named in `values` by its `key`.
notable:
type: array
items:
@@ -92057,6 +92075,8 @@ components:
- bucket
- series
- availableSeries
- labSeries
- availableLabs
- notable
additionalProperties: false
TimelineItem:
@@ -92209,6 +92229,155 @@ components:
- unit
- points
additionalProperties: false
TimelineLabSeries:
type: object
properties:
key:
type: string
description: "`lab:<biomarkerId>`, as named in `values`."
biomarkerId:
type: string
name:
type: string
description: The marker's name, as the labs list shows it.
panel:
type:
- string
- "null"
unit:
type: string
description: The marker's own unit; every point is in it.
band:
anyOf:
- type: object
properties:
low:
type:
- number
- "null"
high:
type:
- number
- "null"
required:
- low
- high
additionalProperties: false
- type: "null"
description: "The reference range to draw behind the points: the marker's own, else the window printed with its newest
reading in the window. Null when neither is known."
points:
type: array
items:
$ref: "#/components/schemas/TimelineLabPoint"
description: "Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A
day with two readings has two points."
excluded:
type: integer
minimum: 0
maximum: 9007199254740991
description: Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points`
rather than drawn on a scale they are not in.
required:
- key
- biomarkerId
- name
- panel
- unit
- band
- points
- excluded
additionalProperties: false
TimelineLabPoint:
type: object
properties:
id:
type: string
description: The lab result's id.
date:
type: string
description: The record's local day of the draw, the day `GET /api/day/{date}` lists it under.
value:
type: number
description: In the series' `unit`.
sourceValue:
description: The figure the report printed, when `value` was converted into the series' unit; it sets the precision
`value` is shown with. Null for a reading stored as stated.
type:
- number
- "null"
rangeStatus:
type: string
enum:
- in-range
- below
- above
- unknown
description: The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known.
referenceLow:
description: "The window this reading is judged against, in the series' unit: the one its report printed, else the
marker's own."
type:
- number
- "null"
referenceHigh:
type:
- number
- "null"
report:
anyOf:
- type: object
properties:
id:
type: string
title:
description: The document's title, else its file name.
type:
- string
- "null"
href:
type: string
required:
- id
- title
- href
additionalProperties: false
- type: "null"
description: The document the reading was read from, when one is linked and the caller may see documents.
required:
- id
- date
- value
- sourceValue
- rangeStatus
- referenceLow
- referenceHigh
- report
additionalProperties: false
TimelineLabMarker:
type: object
properties:
key:
type: string
description: "`lab:<biomarkerId>`, to name in `values`."
biomarkerId:
type: string
name:
type: string
panel:
description: The marker's group, as the labs list shows it.
type:
- string
- "null"
unit:
type: string
required:
- key
- biomarkerId
- name
- panel
- unit
additionalProperties: false
TimelineReadinessEnvelope:
type: object
properties:
+99 -1
View File
@@ -15,12 +15,16 @@
* to six lines.
* 6. On a phone the chronicle replaces the chart, says its empty months
* out loud, names each quarter's means once, and a row opens its day.
* 7. A lab marker (#1299) is picked from the values menu, drawn as its
* single readings with the range behind them, read on its own, kept
* across a reload, and listed on its lab day on a phone.
*
* 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 AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -298,7 +302,9 @@ test("value lines: quarterly means, gaps kept, short ones bridged, as many as th
// The picker is one button that counts the lines; the chosen ones are
// not listed beside it.
const trigger = page.locator('[data-slot="timeline-values-trigger"]');
const trigger = page.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
);
await expect(trigger).toHaveAttribute("data-count", "3");
await expect(trigger).toHaveText(/\(3\)/);
const toolbarHeight = await trigger.evaluate(
@@ -811,6 +817,98 @@ test("a value point reads on its own, by pointer and by keyboard", async ({
await expect(tip).toHaveAttribute("data-series", series!);
});
test("a lab marker is picked, drawn as its readings, read on its own and kept", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY, { labs: true });
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/timeline?zoom=all");
await expect(page.locator('[data-slot="timeline-chart"]')).toBeVisible({
timeout: 20_000,
});
await page
.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
)
.click();
const menu = page.locator('[data-slot="timeline-values-menu"]');
await expect(
menu.locator('[data-slot="timeline-values-labs"]'),
).toBeVisible();
await expect(menu.getByText("Eisen")).toBeVisible();
await menu
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
.click();
await page.keyboard.press("Escape");
// The menu hands focus back to its trigger once its exit animation ends.
await expect(menu).toBeHidden();
await expect(
page.locator(
'[data-slot="timeline-desktop"] [data-slot="timeline-values-trigger"]',
),
).toBeFocused();
const row = page.locator('[data-lab-series="lab:bm-ferritin"]');
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4);
// Below its range: a triangle, never only a colour.
await expect(row.locator('path[data-status="below"]')).toHaveCount(1);
await expect(row.locator('[data-slot="timeline-lab-band-fill"]')).toHaveCount(
1,
);
expect(log.timelineQueries.at(-1)?.get("values")?.split(",")).toContain(
"lab:bm-ferritin",
);
// The reading below its range, by keyboard: day, value, verdict, report.
const target = page.locator(
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]',
);
await target.focus();
const tip = page.locator('[data-slot="timeline-point-tip"]');
await expect(tip).toHaveAttribute("data-t", "fe-1");
await expect(tip).toContainText("22 ng/mL");
await expect(tip).toContainText("Laborbefund März");
const axe = await new AxeBuilder({ page })
.include('[data-slot="timeline-page"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations.map((v) => `${v.id}: ${v.help}`)).toEqual([]);
// The choice is kept: a reload asks for the marker again.
await page.reload();
await expect(row.locator('[data-slot="timeline-lab-mark"]')).toHaveCount(4, {
timeout: 20_000,
});
// On a phone: the same line, listed on its lab days, no sideways scroll.
await page.setViewportSize({ width: 390, height: 844 });
const labs = page.locator('[data-slot="timeline-chronicle-labs"]');
await expect(labs.first()).toBeVisible();
await expect(labs).toHaveCount(4);
await expect(labs.last()).toContainText("22 ng/mL");
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
// Unchosen, it is gone from the request and the page.
await page
.locator(
'[data-slot="timeline-mobile"] [data-slot="timeline-values-trigger"]',
)
.click();
await page
.locator('[data-slot="timeline-values-option"][data-key="lab:bm-ferritin"]')
.click();
await page.keyboard.press("Escape");
await expect(labs).toHaveCount(0);
});
test("the timeline page ends where its content does, at every desktop width", async ({
page,
}) => {
+118 -2
View File
@@ -512,10 +512,104 @@ export function fullTimeline(today: string) {
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",
@@ -637,6 +731,8 @@ export function readyTimeline(today: string) {
(s) => s.key !== "RESTING_HEART_RATE",
),
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
labSeries: [],
availableLabs: [],
notable: [],
};
}
@@ -834,7 +930,12 @@ export async function mockTimeline(
page: Page,
state: "full" | "ready",
today: string,
options: { onlyOld?: boolean; dataFrom?: 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) =>
@@ -844,9 +945,23 @@ export async function mockTimeline(
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") ?? "")
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");
@@ -872,6 +987,7 @@ export async function mockTimeline(
: series(chosen ? from : dataFrom, chosen ? to : today, bucket);
return json(route, 200, {
...body,
...labs,
zoom,
bucket,
...(chosen
+21 -2
View File
@@ -13655,7 +13655,11 @@
"start": "Beginn",
"end": "Ende",
"seriesCaption": "Mittelwerte nach Zeitraum",
"period": "Zeitraum"
"period": "Zeitraum",
"labsCaption": "Laborwerte nach Tag",
"marker": "Marker",
"date": "Datum",
"value": "Wert"
},
"selection": {
"empty": "Keine Einträge in diesem Zeitraum",
@@ -13700,7 +13704,22 @@
"labValuesOne": "1 Laborwert",
"labValuesFew": "{count} Laborwerte",
"labValuesOther": "{count} Laborwerte"
}
},
"labs": {
"menuTitle": "Laborwerte",
"otherPanel": "Weitere",
"above": "über dem Referenzbereich",
"below": "unter dem Referenzbereich",
"reference": "Referenz {range}",
"report": "Befund: {title}",
"reportUntitled": "Befund öffnen",
"excludedOne": "{name}: {count} Wert in einer anderen Einheit ist nicht eingezeichnet",
"excludedFew": "{name}: {count} Werte in einer anderen Einheit sind nicht eingezeichnet",
"excludedOther": "{name}: {count} Werte in einer anderen Einheit sind nicht eingezeichnet"
},
"legendLabValue": "Laborwert, wie gemessen",
"legendLabOutside": "Außerhalb des Referenzbereichs",
"legendLabRange": "Referenzbereich"
},
"lifeEvents": {
"title": "Lebensereignis",
+21 -2
View File
@@ -13655,7 +13655,11 @@
"start": "Start",
"end": "End",
"seriesCaption": "Value means by period",
"period": "Period"
"period": "Period",
"labsCaption": "Lab values by day",
"marker": "Marker",
"date": "Date",
"value": "Value"
},
"selection": {
"empty": "No entries in this period",
@@ -13700,7 +13704,22 @@
"labValuesOne": "1 lab value",
"labValuesFew": "{count} lab values",
"labValuesOther": "{count} lab values"
}
},
"labs": {
"menuTitle": "Lab values",
"otherPanel": "Other",
"above": "above range",
"below": "below range",
"reference": "reference {range}",
"report": "Report: {title}",
"reportUntitled": "Open the report",
"excludedOne": "{name}: {count} value in another unit is not drawn",
"excludedFew": "{name}: {count} values in another unit are not drawn",
"excludedOther": "{name}: {count} values in another unit are not drawn"
},
"legendLabValue": "Lab value, as measured",
"legendLabOutside": "Outside the reference range",
"legendLabRange": "Reference range"
},
"lifeEvents": {
"title": "Life event",
+21 -2
View File
@@ -13655,7 +13655,11 @@
"start": "Inicio",
"end": "Fin",
"seriesCaption": "Medias por periodo",
"period": "Periodo"
"period": "Periodo",
"labsCaption": "Valores de laboratorio por día",
"marker": "Marcador",
"date": "Fecha",
"value": "Valor"
},
"selection": {
"empty": "No hay entradas en este periodo",
@@ -13700,7 +13704,22 @@
"labValuesOne": "1 valor de laboratorio",
"labValuesFew": "{count} valores de laboratorio",
"labValuesOther": "{count} valores de laboratorio"
}
},
"labs": {
"menuTitle": "Valores de laboratorio",
"otherPanel": "Otros",
"above": "por encima del intervalo",
"below": "por debajo del intervalo",
"reference": "referencia {range}",
"report": "Informe: {title}",
"reportUntitled": "Abrir el informe",
"excludedOne": "{name}: {count} valor en otra unidad no se muestra",
"excludedFew": "{name}: {count} valores en otra unidad no se muestran",
"excludedOther": "{name}: {count} valores en otra unidad no se muestran"
},
"legendLabValue": "Valor de laboratorio, tal como se midió",
"legendLabOutside": "Fuera del intervalo de referencia",
"legendLabRange": "Intervalo de referencia"
},
"lifeEvents": {
"title": "Acontecimiento vital",
+21 -2
View File
@@ -13655,7 +13655,11 @@
"start": "Début",
"end": "Fin",
"seriesCaption": "Moyennes par période",
"period": "Période"
"period": "Période",
"labsCaption": "Valeurs de laboratoire par jour",
"marker": "Marqueur",
"date": "Date",
"value": "Valeur"
},
"selection": {
"empty": "Aucune entrée sur cette période",
@@ -13700,7 +13704,22 @@
"labValuesOne": "1 valeur de laboratoire",
"labValuesFew": "{count} valeurs de laboratoire",
"labValuesOther": "{count} valeurs de laboratoire"
}
},
"labs": {
"menuTitle": "Valeurs de laboratoire",
"otherPanel": "Autres",
"above": "au-dessus de l’intervalle",
"below": "en dessous de l’intervalle",
"reference": "référence {range}",
"report": "Compte rendu : {title}",
"reportUntitled": "Ouvrir le compte rendu",
"excludedOne": "{name} : {count} valeur dans une autre unité n’est pas affichée",
"excludedFew": "{name} : {count} valeurs dans une autre unité ne sont pas affichées",
"excludedOther": "{name} : {count} valeurs dans une autre unité ne sont pas affichées"
},
"legendLabValue": "Valeur de laboratoire, telle que mesurée",
"legendLabOutside": "Hors de l’intervalle de référence",
"legendLabRange": "Intervalle de référence"
},
"lifeEvents": {
"title": "Événement de vie",
+21 -2
View File
@@ -13655,7 +13655,11 @@
"start": "Inizio",
"end": "Fine",
"seriesCaption": "Medie per periodo",
"period": "Periodo"
"period": "Periodo",
"labsCaption": "Valori di laboratorio per giorno",
"marker": "Marcatore",
"date": "Data",
"value": "Valore"
},
"selection": {
"empty": "Nessuna voce in questo periodo",
@@ -13700,7 +13704,22 @@
"labValuesOne": "1 valore di laboratorio",
"labValuesFew": "{count} valori di laboratorio",
"labValuesOther": "{count} valori di laboratorio"
}
},
"labs": {
"menuTitle": "Valori di laboratorio",
"otherPanel": "Altri",
"above": "sopra l’intervallo",
"below": "sotto l’intervallo",
"reference": "riferimento {range}",
"report": "Referto: {title}",
"reportUntitled": "Apri il referto",
"excludedOne": "{name}: {count} valore in un’altra unità non è riportato",
"excludedFew": "{name}: {count} valori in un’altra unità non sono riportati",
"excludedOther": "{name}: {count} valori in un’altra unità non sono riportati"
},
"legendLabValue": "Valore di laboratorio, così come misurato",
"legendLabOutside": "Fuori dall’intervallo di riferimento",
"legendLabRange": "Intervallo di riferimento"
},
"lifeEvents": {
"title": "Evento di vita",
+21 -2
View File
@@ -13655,7 +13655,11 @@
"start": "시작",
"end": "끝",
"seriesCaption": "기간별 평균",
"period": "기간"
"period": "기간",
"labsCaption": "날짜별 검사 수치",
"marker": "항목",
"date": "날짜",
"value": "값"
},
"selection": {
"empty": "이 기간에는 항목이 없습니다",
@@ -13700,7 +13704,22 @@
"labValuesOne": "검사 수치 1개",
"labValuesFew": "검사 수치 {count}개",
"labValuesOther": "검사 수치 {count}개"
}
},
"labs": {
"menuTitle": "검사 수치",
"otherPanel": "기타",
"above": "범위 초과",
"below": "범위 미만",
"reference": "참고 범위 {range}",
"report": "보고서: {title}",
"reportUntitled": "보고서 열기",
"excludedOne": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다",
"excludedFew": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다",
"excludedOther": "{name}: 다른 단위로 기록된 값 {count}개는 표시되지 않습니다"
},
"legendLabValue": "측정된 그대로의 검사 수치",
"legendLabOutside": "참고 범위 밖",
"legendLabRange": "참고 범위"
},
"lifeEvents": {
"title": "삶의 사건",
+21 -2
View File
@@ -13655,7 +13655,11 @@
"start": "Begin",
"end": "Einde",
"seriesCaption": "Gemiddelde waarden per periode",
"period": "Periode"
"period": "Periode",
"labsCaption": "Labwaarden per dag",
"marker": "Marker",
"date": "Datum",
"value": "Waarde"
},
"selection": {
"empty": "Geen registraties in deze periode",
@@ -13700,7 +13704,22 @@
"labValuesOne": "1 labwaarde",
"labValuesFew": "{count} labwaarden",
"labValuesOther": "{count} labwaarden"
}
},
"labs": {
"menuTitle": "Labwaarden",
"otherPanel": "Overig",
"above": "boven het bereik",
"below": "onder het bereik",
"reference": "referentie {range}",
"report": "Verslag: {title}",
"reportUntitled": "Verslag openen",
"excludedOne": "{name}: {count} waarde in een andere eenheid wordt niet getoond",
"excludedFew": "{name}: {count} waarden in een andere eenheid worden niet getoond",
"excludedOther": "{name}: {count} waarden in een andere eenheid worden niet getoond"
},
"legendLabValue": "Labwaarde, zoals gemeten",
"legendLabOutside": "Buiten het referentiebereik",
"legendLabRange": "Referentiebereik"
},
"lifeEvents": {
"title": "Levensgebeurtenis",
+21 -2
View File
@@ -13655,7 +13655,11 @@
"start": "Początek",
"end": "Koniec",
"seriesCaption": "Średnie według okresu",
"period": "Okres"
"period": "Okres",
"labsCaption": "Wyniki badań według dni",
"marker": "Parametr",
"date": "Data",
"value": "Wartość"
},
"selection": {
"empty": "Brak wpisów w tym okresie",
@@ -13700,7 +13704,22 @@
"labValuesOne": "1 wynik badania",
"labValuesFew": "{count} wyniki badań",
"labValuesOther": "{count} wyników badań"
}
},
"labs": {
"menuTitle": "Wyniki badań",
"otherPanel": "Inne",
"above": "powyżej zakresu",
"below": "poniżej zakresu",
"reference": "zakres {range}",
"report": "Dokument: {title}",
"reportUntitled": "Otwórz dokument",
"excludedOne": "{name}: {count} wartość w innej jednostce nie jest pokazana",
"excludedFew": "{name}: {count} wartości w innej jednostce nie są pokazane",
"excludedOther": "{name}: {count} wartości w innej jednostce nie jest pokazanych"
},
"legendLabValue": "Wynik badania, tak jak zmierzono",
"legendLabOutside": "Poza zakresem referencyjnym",
"legendLabRange": "Zakres referencyjny"
},
"lifeEvents": {
"title": "Wydarzenie życiowe",
+21 -2
View File
@@ -13655,7 +13655,11 @@
"start": "Início",
"end": "Fim",
"seriesCaption": "Médias de valores por período",
"period": "Período"
"period": "Período",
"labsCaption": "Valores de exames por dia",
"marker": "Marcador",
"date": "Data",
"value": "Valor"
},
"selection": {
"empty": "Nenhuma entrada neste período",
@@ -13700,7 +13704,22 @@
"labValuesOne": "1 valor laboratorial",
"labValuesFew": "{count} valores laboratoriais",
"labValuesOther": "{count} valores laboratoriais"
}
},
"labs": {
"menuTitle": "Valores de exames",
"otherPanel": "Outros",
"above": "acima da faixa",
"below": "abaixo da faixa",
"reference": "referência {range}",
"report": "Laudo: {title}",
"reportUntitled": "Abrir o laudo",
"excludedOne": "{name}: {count} valor em outra unidade não aparece no gráfico",
"excludedFew": "{name}: {count} valores em outra unidade não aparecem no gráfico",
"excludedOther": "{name}: {count} valores em outra unidade não aparecem no gráfico"
},
"legendLabValue": "Valor de exame, como foi medido",
"legendLabOutside": "Fora da faixa de referência",
"legendLabRange": "Faixa de referência"
},
"lifeEvents": {
"title": "Evento de vida",
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 94554,
"total": 94362,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -18,8 +18,8 @@
"components/medications/wizard/wizard-payload.ts": 1414,
"components/mood/mood-list.tsx": 1408,
"components/records/life-circumstances-manager.tsx": 1080,
"components/timeline/timeline-chart.tsx": 1174,
"components/timeline/timeline-geometry.ts": 1395,
"components/timeline/timeline-chart.tsx": 1093,
"components/timeline/timeline-geometry.ts": 1288,
"lib/ai/anthropic-client.ts": 804,
"lib/ai/coach/about-me.ts": 1012,
"lib/ai/coach/coach-prose-grounding.ts": 1079,
@@ -30,7 +30,7 @@
"lib/ai/coach/types.ts": 1011,
"lib/ai/codex-client.ts": 907,
"lib/ai/prompts/insight-generator.ts": 1110,
"lib/ai/provider.ts": 1632,
"lib/ai/provider.ts": 1628,
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
"lib/analytics/summaries-slice.ts": 1178,
@@ -1,14 +1,14 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 346,
"total": 112219,
"count": 345,
"total": 111940,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206,
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
"app/api/auth/me/route.ts :: apiHandler(arg)": 378,
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
@@ -19,7 +19,7 @@
"app/api/encounters/[id]/route.ts :: apiHandler(arg) #2": 269,
"app/api/environment/route.ts :: apiHandler(arg)": 201,
"app/api/export/health-record/route.ts :: apiHandler(arg)": 241,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 262,
"app/api/ingest/medication/route.ts :: apiHandler(arg)": 259,
"app/api/insights/cards/route.ts :: apiHandler(arg)": 211,
"app/api/integrations/status/route.ts :: apiHandler(arg)": 451,
"app/api/labs/[id]/route.ts :: apiHandler(arg) #2": 239,
@@ -62,7 +62,7 @@
"components/admin/login-overview-section.tsx :: LoginOverviewSection": 503,
"components/admin/pulse-day-mean-card.tsx :: PulseDayMeanCard": 305,
"components/admin/reminders-section.tsx :: RemindersSection": 309,
"components/admin/user-management-section.tsx :: UserManagementSection": 626,
"components/admin/user-management-section.tsx :: UserManagementSection": 622,
"components/admin/web-push-vapid-section.tsx :: WebPushVapidSection": 217,
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
@@ -232,10 +232,9 @@
"components/targets/range-bar.tsx :: RangeBar": 220,
"components/targets/target-edit-sheet.tsx :: TargetEditSheetBody": 474,
"components/timeline/life-event-form.tsx :: LifeEventForm": 281,
"components/timeline/series-point-tips.tsx :: SeriesPointTips": 212,
"components/timeline/timeline-chart.tsx :: ChartBody": 243,
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 274,
"components/timeline/timeline-view.tsx :: TimelineView": 501,
"components/timeline/timeline-chart.tsx :: ChartBody": 236,
"components/timeline/timeline-chronicle.tsx :: TimelineChronicle": 246,
"components/timeline/timeline-view.tsx :: TimelineView": 486,
"components/ui/responsive-sheet.tsx :: ResponsiveSheet": 239,
"components/vaccinations/catalog-picker.tsx :: CatalogPicker": 221,
"components/vaccinations/custom-vaccine-sheet.tsx :: CustomVaccineSheet": 246,
@@ -285,7 +284,7 @@
"lib/gamification/achievements-result.ts :: buildAchievementsResult": 586,
"lib/google-health/data-points.ts :: forEachDataPointPage": 288,
"lib/google-health/data-points.ts :: forEachDataPointPage > walk": 223,
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 453,
"lib/google-health/sync-core.ts :: upsertGoogleHealthMeasurements": 452,
"lib/illness/correlation.ts :: computeIllnessCorrelation": 233,
"lib/import/labs-csv.ts :: importLabsCsv": 309,
"lib/import/labs-csv.ts :: importLabsCsv > flush": 231,
@@ -348,6 +347,6 @@
"lib/withings/sync-ecg.ts :: syncUserEcg": 202,
"lib/withings/sync-sleep.ts :: syncUserSleep": 414,
"lib/withings/sync.ts :: syncUserMeasurements": 337,
"proxy.ts :: proxy": 499
"proxy.ts :: proxy": 498
}
}
+1
View File
@@ -69,6 +69,7 @@ export const GET = apiHandler(async (request: Request) => {
lanes: timeline.lanes.length,
items: timeline.lanes.reduce((n, l) => n + l.items.length, 0),
series: timeline.series.length,
labSeries: timeline.labSeries.length,
notable: timeline.notable.length,
},
});
@@ -34,6 +34,8 @@ function timeline(): TimelineResponse {
standing: [],
series: [],
availableSeries: [],
labSeries: [],
availableLabs: [],
notable: [],
} as unknown as TimelineResponse;
}
@@ -0,0 +1,417 @@
/**
* Lab markers on the timeline (#1299), as laid out and as rendered: a row
* per marker under the value lines with its readings as single points, the
* reference range behind them, a reading outside it drawn as a triangle and
* worded as outside wherever it is written out, the report it came from,
* the readings a unit left out named, and the menu's groups. All data
* invented.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { TimelineLabSeries, TimelineResponse } from "@/lib/day/contract";
import { I18nProvider } from "@/lib/i18n/context";
import {
LabExcludedNote,
LabLines,
LabSeriesTable,
labReadingsIn,
} from "../lab-lines";
import { SelectionBar } from "../selection-bar";
import type { SeriesValueFormat } from "../series-format";
import { SERIES_HEIGHT } from "../series-geometry";
import { layoutTimeline } from "../timeline-geometry";
import { TimelineChronicle } from "../timeline-chronicle";
import { TimelineLegend } from "../timeline-legend";
import { labOptionGroups } from "../timeline-menus";
import { useSeriesValueFormat } from "../use-series-value-format";
import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
const FERRITIN: TimelineLabSeries = {
key: "lab:bm-ferritin",
biomarkerId: "bm-ferritin",
name: "Ferritin",
panel: "Eisen",
unit: "ng/mL",
band: { low: 30, high: 400 },
points: [
{
id: "r1",
date: "2024-02-12",
value: 18,
sourceValue: null,
rangeStatus: "below",
referenceLow: 30,
referenceHigh: 400,
report: {
id: "d1",
title: "Befund Februar",
href: "/documents?doc=d1",
},
},
{
id: "r2",
date: "2025-06-01",
value: 85,
sourceValue: null,
rangeStatus: "in-range",
referenceLow: 30,
referenceHigh: 400,
report: null,
},
{
id: "r3",
date: "2026-03-02",
value: 420,
sourceValue: null,
rangeStatus: "above",
referenceLow: 30,
referenceHigh: 400,
report: null,
},
],
excluded: 0,
};
const HBA1C: TimelineLabSeries = {
key: "lab:bm-hba1c",
biomarkerId: "bm-hba1c",
name: "HbA1c",
panel: null,
unit: "%",
band: null,
points: [
{
id: "h1",
date: "2026-03-02",
value: 5.4,
sourceValue: null,
rangeStatus: "unknown",
referenceLow: null,
referenceHigh: null,
report: null,
},
],
excluded: 2,
};
const WINDOW = { from: "2024-01-01", to: "2026-06-30" };
function layout(labSeries: readonly TimelineLabSeries[]) {
const timeline = fullTimeline();
return layoutTimeline({
width: 900,
window: WINDOW,
lanes: timeline.lanes,
series: timeline.series,
labSeries,
bucket: "quarter",
words: wordsIn("de"),
startMissing: "?",
today: TODAY,
});
}
function render(node: React.ReactElement, locale: "de" | "en" = "de") {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
/** The text a reader sees, runs kept apart. */
function textOf(html: string): string {
let out = "";
let inTag = false;
for (const ch of html) {
if (ch === "<") inTag = true;
else if (ch === ">") {
inTag = false;
out += " ";
} else if (!inTag) out += ch;
}
return out.replace(/\s+/g, " ");
}
function WithFormat({
build,
}: {
build: (format: SeriesValueFormat) => React.ReactNode;
}) {
return <>{build(useSeriesValueFormat())}</>;
}
describe("the lab rows' geometry", () => {
it("adds a row per marker under the value lines and leaves the rest as it was", () => {
const without = layout([]);
const withLabs = layout([FERRITIN, HBA1C]);
expect(withLabs.series).toEqual(without.series);
expect(withLabs.lanes).toEqual(without.lanes);
expect(withLabs.height - without.height).toBe(2 * SERIES_HEIGHT);
const [ferritin, hba1c] = withLabs.labSeries;
expect(ferritin.top).toBe(
without.seriesTop + without.series.length * SERIES_HEIGHT + 6,
);
expect(hba1c.top - ferritin.top).toBe(SERIES_HEIGHT);
});
it("places each reading on its own day, inside the plot and inside its row", () => {
const { labSeries, scale } = layout([FERRITIN]);
const [row] = labSeries;
expect(row.points.map((p) => p.id)).toEqual(["r1", "r2", "r3"]);
for (const p of row.points) {
expect(p.x).toBeCloseTo(scale.xMid(p.date), 5);
expect(p.x).toBeGreaterThanOrEqual(scale.x0);
expect(p.x).toBeLessThanOrEqual(scale.x1);
expect(p.y).toBeGreaterThanOrEqual(row.top);
expect(p.y).toBeLessThanOrEqual(row.top + row.height);
}
// Higher values sit higher; the readings are joined in time order.
const [low, mid, high] = row.points;
expect(low.y).toBeGreaterThan(mid.y);
expect(mid.y).toBeGreaterThan(high.y);
expect(row.path.match(/[ML]/g)).toEqual(["M", "L", "L"]);
expect(row.latest?.id).toBe("r3");
});
it("holds the reference range in the scale and paints it behind the points", () => {
const [row] = layout([FERRITIN]).labSeries;
expect(row.band).not.toBeNull();
const { top, bottom } = row.band!;
expect(top).not.toBeNull();
expect(bottom).not.toBeNull();
// The in-range reading lies inside the band, the others outside it.
const [low, mid, high] = row.points;
expect(mid.y).toBeGreaterThan(top!);
expect(mid.y).toBeLessThan(bottom!);
expect(low.y).toBeGreaterThan(bottom!);
expect(high.y).toBeLessThan(top!);
});
it("draws one reading as a point with no line, and an empty window as no point", () => {
const [single] = layout([HBA1C]).labSeries;
expect(single.points).toHaveLength(1);
expect(single.path).toBe("");
expect(single.band).toBeNull();
expect(single.points[0].y).toBeCloseTo(single.top + single.height / 2, 5);
const [empty] = layout([{ ...FERRITIN, points: [] }]).labSeries;
expect(empty.points).toEqual([]);
expect(empty.latest).toBeNull();
});
});
describe("the lab rows as drawn", () => {
function drawn(rows: readonly TimelineLabSeries[]) {
const l = layout(rows);
return render(
<svg>
<LabLines
layout={l}
selectedBucket="2025-04-01"
bucket="quarter"
seriesLabel={(key) => (key === FERRITIN.key ? "Ferritin" : "HbA1c")}
seriesColor={() => "var(--chart-3)"}
valueText={(value, _source, unit) => `${value} ${unit}`}
/>
</svg>,
);
}
it("marks a reading outside its range by shape, the way it left", () => {
const html = drawn([FERRITIN]);
expect(html).toContain('data-status="below"');
expect(html).toContain('data-status="above"');
// Outside the range a triangle (a path), inside it a dot.
expect(html).toMatch(/<path d="M[^"]+Z"[^>]*data-status="below"/);
expect(html).toMatch(/<path d="M[^"]+Z"[^>]*data-status="above"/);
expect(html).toMatch(/<circle[^>]*data-status="in-range"/);
// The band and its dashed edges, in the row's own colour.
expect(html).toContain('data-slot="timeline-lab-band-fill"');
expect(html.match(/data-slot="timeline-lab-band-edge"/g)).toHaveLength(2);
expect(html).toContain('data-color="var(--chart-3)"');
// The newest reading beside the name; the selected bucket's ringed.
expect(textOf(html)).toContain("420 ng/mL");
expect(html.match(/data-slot="timeline-series-selected"/g)).toHaveLength(1);
});
it("says so when the window holds no reading", () => {
const html = drawn([{ ...FERRITIN, points: [] }]);
expect(textOf(html)).toContain("Keine Werte in diesem Zeitraum");
expect(html).not.toContain('data-slot="timeline-lab-mark"');
});
});
describe("the lab readings written out", () => {
function bar(selected: string, locale: "de" | "en" = "en") {
const timeline: TimelineResponse = {
...fullTimeline(),
bucket: "quarter",
labSeries: [FERRITIN, HBA1C],
};
return render(
<WithFormat
build={(format) => (
<SelectionBar
timeline={timeline}
selected={selected}
today={TODAY}
seriesLabel={(key) =>
key === FERRITIN.key
? "Ferritin"
: key === HBA1C.key
? "HbA1c"
: key
}
seriesColor={() => "var(--chart-3)"}
seriesFormat={format}
onOpenDay={() => undefined}
/>
)}
/>,
locale,
);
}
it("lists the bucket's readings with their day, a word outside the range and the report", () => {
const html = bar("2024-02-20");
const text = textOf(html);
expect(text).toContain("Ferritin 18 ng/mL, below range, Feb 12");
expect(html).toMatch(
/<a[^>]*href="\/documents\?doc=d1"[^>]*>Report: Befund Februar<\/a>/,
);
const both = textOf(bar("2026-03-10"));
expect(both).toContain("Ferritin 420 ng/mL, above range, Mar 2");
// In range and without a range: the value alone.
expect(both).toContain("HbA1c 5.4 %, Mar 2");
expect(both).not.toContain("HbA1c 5.4 %, above");
});
it("words the verdict in the reader's language", () => {
expect(textOf(bar("2024-02-20", "de"))).toContain(
"Ferritin 18 ng/mL, unter dem Referenzbereich",
);
});
it("finds the readings of a stretch on their own days", () => {
expect(
labReadingsIn([FERRITIN, HBA1C], "2026-03-02", "2026-03-02").map(
(r) => r.point.id,
),
).toEqual(["r3", "h1"]);
expect(labReadingsIn([FERRITIN], "2023-01-01", "2023-12-31")).toEqual([]);
});
it("rides on the phone's lab-day row, with no link inside the row's button", () => {
const timeline: TimelineResponse = {
...fullTimeline(),
bucket: "quarter",
lanes: [
{
key: "labs",
items: [
item({
id: "lab-day:2024-02-12",
kind: "labDay",
start: "2024-02-12",
label: "Ferritin",
sub: "1",
href: "/labs",
}),
],
},
],
standing: [],
labSeries: [FERRITIN],
};
const html = render(
<WithFormat
build={(format) => (
<TimelineChronicle
timeline={timeline}
today={TODAY}
grouping="month"
selected={null}
seriesColor={() => "var(--chart-3)"}
seriesFormat={format}
onOpenDay={() => undefined}
onEditLifeEvent={null}
/>
)}
/>,
"en",
);
const row = html.slice(html.indexOf('data-slot="timeline-chronicle-labs"'));
expect(textOf(row)).toContain("Ferritin 18 ng/mL, below range");
expect(html).not.toContain('data-slot="timeline-lab-report"');
});
it("names the readings a unit left out, and lists every reading for a screen reader", () => {
const note = textOf(
render(
<LabExcludedNote
labSeries={[FERRITIN, HBA1C]}
label={(key) => (key === HBA1C.key ? "HbA1c" : "Ferritin")}
/>,
"en",
),
);
expect(note).toContain("HbA1c: 2 values in another unit are not drawn");
expect(note).not.toContain("Ferritin");
const table = render(
<LabSeriesTable
labSeries={[FERRITIN]}
window={WINDOW}
label={() => "Ferritin"}
valueText={(value, _source, unit) => `${value} ${unit}`}
intl="en-GB"
/>,
"en",
);
const rows = table.match(/<tr>/g) ?? [];
expect(rows).toHaveLength(4);
expect(textOf(table)).toContain(
"18 ng/mL, below range, reference 30–400 ng/mL, Report: Befund Februar",
);
});
it("explains its marks in the legend only when a lab line is drawn", () => {
const without = render(<TimelineLegend bucket="quarter" hasSeries />);
expect(without).not.toContain("timeline-legend-lab");
const withLabs = textOf(
render(
<TimelineLegend bucket="quarter" hasSeries labSeries={[FERRITIN]} />,
"en",
),
);
expect(withLabs).toContain("Lab value, as measured");
expect(withLabs).toContain("Outside the reference range");
expect(withLabs).toContain("Reference range");
});
});
describe("the lab markers in the menu", () => {
it("groups by the labs list's group, named groups first, in the reader's order", () => {
const groups = labOptionGroups(
[
{ key: "lab:c", name: "Zink", panel: null },
{ key: "lab:b", name: "Transferrin", panel: "Eisen" },
{ key: "lab:a", name: "Ferritin", panel: "Eisen" },
{ key: "lab:d", name: "Ärzte-Wert", panel: "Blutbild" },
{ key: "lab:a", name: "Ferritin", panel: "Eisen" },
],
"de-DE",
);
expect(groups).toEqual([
{ panel: "Blutbild", keys: ["lab:d"] },
{ panel: "Eisen", keys: ["lab:a", "lab:b"] },
{ panel: null, keys: ["lab:c"] },
]);
});
});
@@ -105,6 +105,8 @@ const day = () => `2026-09-${String(nextDay++).padStart(2, "0")}`;
/** Every timeline item kind, with every code its second line can carry. */
function everyKind(): TimelineResponse {
nextDay = 1;
// Taken in its place among the days, so the other items keep theirs.
let labDay = "";
const life: TimelineItem[] = [
...LIFE_EVENT_CATEGORIES.map((category, i) =>
item({
@@ -235,7 +237,7 @@ function everyKind(): TimelineResponse {
item({
id: "lab-day",
kind: "labDay",
start: day(),
start: (labDay = day()),
label: "Kreatinin, Kalium, Natrium",
sub: "5",
}),
@@ -258,6 +260,32 @@ function everyKind(): TimelineResponse {
standing: [],
series: [],
availableSeries: [],
// A lab line (#1299) with a reading of every range verdict on the lab
// day: each is worded, none reaches the screen as its code.
labSeries: [
{
key: "lab:bm-k",
biomarkerId: "bm-k",
name: "Kalium",
panel: null,
unit: "mmol/L",
band: { low: 3.5, high: 5.1 },
points: (["below", "in-range", "above", "unknown"] as const).map(
(rangeStatus, i) => ({
id: `k${i}`,
date: labDay,
value: 3 + i,
sourceValue: null,
rangeStatus,
referenceLow: 3.5,
referenceHigh: 5.1,
report: null,
}),
),
excluded: 1,
},
],
availableLabs: [],
notable: [],
};
// The fixture itself must reach every kind, or the guard proves less.
@@ -316,6 +344,10 @@ describe.each<Locale>(["de", "en"])("the timeline in %s", (locale) => {
),
);
expect(text).toContain(locale === "de" ? "5 Laborwerte" : "5 lab values");
expect(text).toContain(
locale === "de" ? "über dem Referenzbereich" : "above range",
);
expect(text).not.toMatch(/in-range|unknown/);
expect(rawCodes(text)).toEqual([]);
expect(text).not.toContain(MIDDLE_DOT);
});
@@ -284,6 +284,8 @@ export function fullTimeline(): TimelineResponse {
},
],
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
labSeries: [],
availableLabs: [],
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
@@ -19,7 +19,6 @@ import {
itemsInPeriod,
latestDataDate,
layoutLane,
layoutSeries,
layoutTimeline,
stepSelection,
todayLabelFits,
@@ -28,6 +27,7 @@ import {
type LaneLayout,
type TimelineLayout,
} from "../timeline-geometry";
import { layoutSeries } from "../series-geometry";
import { itemLine } from "../item-words";
import { dayNumber } from "../timeline-dates";
import { TODAY, fullTimeline, item, wordsIn } from "./timeline-fixture";
@@ -0,0 +1,297 @@
/**
* Lab markers on the timeline page (#1299), in a real browser: the page as
* mounted, its reads answered by a stubbed network.
*
* - A stored choice that names a lab marker reaches the request, and the
* marker is drawn as its readings under the value lines.
* - The values menu offers the record's markers under their groups;
* choosing one adds it to the stored list and to the next request,
* unchoosing removes it from both.
* - With the labs module off, no marker is offered or requested.
* - A reading's point reads on its own by keyboard: day, value, the word
* for a reading outside its range, the range and the report.
* - At 390 px the page does not scroll sideways, the values menu is an
* icon button with a name, and the lab day's row lists the readings.
*
* All data invented.
*/
import { describe, expect, it } from "vitest";
import { page, userEvent } from "vitest/browser";
import type { TimelineResponse } from "@/lib/day/contract";
import {
stubFetch,
type FetchStub,
} from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { setSearch } from "../../../../tests/browser/url";
import { TimelineView } from "../timeline-view";
const VALUES_KEY = "healthlog.timeline.values";
function timeline(values: readonly string[]): TimelineResponse {
const ferritin = values.includes("lab:bm-ferritin");
return {
zoom: "all",
range: { from: "2024-01-01", to: "2026-06-30", dataFrom: "2024-01-01" },
bucket: "quarter",
lanes: [
{
key: "labs",
items: [
{
id: "lab-day:2024-02-12",
kind: "labDay",
start: "2024-02-12",
end: null,
open: false,
precision: "DAY",
startKnown: true,
label: "Ferritin",
sub: "1",
href: "/labs",
group: null,
},
{
id: "lab-day:2026-03-02",
kind: "labDay",
start: "2026-03-02",
end: null,
open: false,
precision: "DAY",
startKnown: true,
label: "Ferritin, HbA1c",
sub: "2",
href: "/labs",
group: null,
},
],
},
],
standing: [],
series: values.includes("WEIGHT")
? [
{
key: "WEIGHT",
unit: "kg",
points: [
{ t: "2025-01-01", mean: 82.4, count: 6 },
{ t: "2025-04-01", mean: 81.9, count: 5 },
],
},
]
: [],
availableSeries: ["WEIGHT"],
labSeries: ferritin
? [
{
key: "lab:bm-ferritin",
biomarkerId: "bm-ferritin",
name: "Ferritin",
panel: "Iron",
unit: "ng/mL",
band: { low: 30, high: 400 },
points: [
{
id: "r1",
date: "2024-02-12",
value: 18,
sourceValue: null,
rangeStatus: "below",
referenceLow: 30,
referenceHigh: 400,
report: {
id: "d1",
title: "February report",
href: "/documents?doc=d1",
},
},
{
id: "r2",
date: "2026-03-02",
value: 120,
sourceValue: null,
rangeStatus: "in-range",
referenceLow: 30,
referenceHigh: 400,
report: null,
},
],
excluded: 0,
},
]
: [],
availableLabs: [
{
key: "lab:bm-hba1c",
biomarkerId: "bm-hba1c",
name: "HbA1c",
panel: "Glucose",
unit: "%",
},
{
key: "lab:bm-ferritin",
biomarkerId: "bm-ferritin",
name: "Ferritin",
panel: "Iron",
unit: "ng/mL",
},
],
notable: [],
};
}
/** The `values` a request named, as a list. */
function valuesOf(path: string): string[] {
const raw = new URL(path, window.location.origin).searchParams.get("values");
return raw ? raw.split(",") : [];
}
function stubTimeline(): FetchStub {
return stubFetch()
.on("GET", "/api/timeline", (request) => ({
data: timeline(valuesOf(request.path)),
}))
.on("GET", "/api/timeline/readiness", {
data: { verdict: "carries", since: "2024-01-01", lanes: [] },
})
.on("GET", "/api/life-events", { data: { events: [] } });
}
async function mount(
stored: string[] | null,
modules: Record<string, boolean> = { timeline: true, labs: true },
) {
if (stored) window.localStorage.setItem(VALUES_KEY, JSON.stringify(stored));
setSearch("?zoom=all");
const net = stubTimeline();
const { screen } = await renderWithApp(<TimelineView />, {
user: { timezone: "Europe/Berlin", modules },
});
return { net, screen };
}
function lastTimelineValues(net: FetchStub): string[] {
const calls = net.calls("GET", "/api/timeline");
return valuesOf(calls[calls.length - 1]?.path ?? "");
}
describe("lab lines on the timeline page", () => {
it("asks for a stored lab marker and draws it as its readings", async () => {
await page.viewport(1280, 900);
const { net } = await mount(["WEIGHT", "lab:bm-ferritin"]);
await expect
.poll(
() =>
document.querySelectorAll(
'[data-lab-series="lab:bm-ferritin"] [data-slot="timeline-lab-mark"]',
).length,
)
.toBe(2);
expect(lastTimelineValues(net)).toEqual(["WEIGHT", "lab:bm-ferritin"]);
const below = document.querySelector(
'[data-lab-series="lab:bm-ferritin"] [data-status="below"]',
);
expect(below?.tagName.toLowerCase()).toBe("path");
// The value line keeps its own row above it.
expect(document.querySelector('[data-series="WEIGHT"]')).not.toBeNull();
// The name leads to the marker's page.
const link = document.querySelector(
'[data-slot="timeline-series-link"][data-series="lab:bm-ferritin"]',
);
expect(link?.getAttribute("href")).toBe("/labs/bm-ferritin");
});
it("offers the record's markers under their groups, and a choice persists both ways", async () => {
await page.viewport(1280, 900);
const { net, screen } = await mount(["WEIGHT"]);
const trigger = screen.getByRole("button", { name: /Values \(1\)/ });
await expect.element(trigger).toBeVisible();
await trigger.click();
await expect.element(screen.getByText("Lab values")).toBeVisible();
await expect.element(screen.getByText("Glucose")).toBeVisible();
await expect.element(screen.getByText("Iron")).toBeVisible();
const ferritin = screen.getByRole("menuitemcheckbox", { name: "Ferritin" });
await expect.element(ferritin).toHaveAttribute("aria-checked", "false");
await ferritin.click();
await expect.element(ferritin).toHaveAttribute("aria-checked", "true");
expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([
"WEIGHT",
"lab:bm-ferritin",
]);
await expect
.poll(() => lastTimelineValues(net))
.toEqual(["WEIGHT", "lab:bm-ferritin"]);
await ferritin.click();
await expect.element(ferritin).toHaveAttribute("aria-checked", "false");
expect(JSON.parse(window.localStorage.getItem(VALUES_KEY)!)).toEqual([
"WEIGHT",
]);
await expect.poll(() => lastTimelineValues(net)).toEqual(["WEIGHT"]);
});
it("neither offers nor asks for a marker with the labs module off", async () => {
await page.viewport(1280, 900);
const { net, screen } = await mount(["WEIGHT", "lab:bm-ferritin"], {
timeline: true,
labs: false,
});
const trigger = screen.getByRole("button", { name: /Values \(1\)/ });
await expect.element(trigger).toBeVisible();
expect(lastTimelineValues(net)).toEqual(["WEIGHT"]);
await trigger.click();
await expect
.element(screen.getByRole("menuitemcheckbox", { name: "Weight" }))
.toBeVisible();
expect(document.querySelector('[data-slot="timeline-values-labs"]')).toBe(
null,
);
});
it("reads a reading's point on its own by keyboard", async () => {
await page.viewport(1280, 900);
await mount(["lab:bm-ferritin"]);
const target = () =>
document.querySelector<HTMLButtonElement>(
'[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="r1"]',
);
await expect.poll(() => target() !== null).toBe(true);
expect(target()!.getAttribute("aria-label")).toBe(
"Ferritin, Feb 12, 2024: 18 ng/mL, below range, reference 30–400 ng/mL, Report: February report",
);
// One tab stop per line, on its newest reading; Left walks back.
target()!
.parentElement?.querySelector<HTMLButtonElement>('[data-t="r2"]')
?.focus();
await userEvent.keyboard("{ArrowLeft}");
expect(document.activeElement).toBe(target());
const tip = document.querySelector('[data-slot="timeline-point-tip"]');
expect(tip?.textContent).toContain("18 ng/mL");
expect(tip?.textContent).toContain("below range");
expect(tip?.textContent).toContain("February report");
});
it("fits a phone: no sideways scroll, a named values button, the readings on the lab day", async () => {
await page.viewport(390, 844);
const { screen } = await mount(["lab:bm-ferritin"]);
await expect
.element(screen.getByRole("button", { name: "Values (1)" }))
.toBeVisible();
// Newest first, each lab day with its own readings.
await expect
.poll(() =>
[
...document.querySelectorAll('[data-slot="timeline-chronicle-labs"]'),
].map((el) => el.textContent),
)
.toEqual(["Ferritin 120 ng/mL", "Ferritin 18 ng/mL, below range"]);
const root = document.documentElement;
expect(root.scrollWidth).toBeLessThanOrEqual(root.clientWidth);
const mobile = document.querySelector<HTMLElement>(
'[data-slot="timeline-mobile"]',
)!;
expect(mobile.scrollWidth).toBeLessThanOrEqual(mobile.clientWidth);
});
});
@@ -24,13 +24,10 @@ import { SERIES_PALETTE, assignSeriesColors } from "../series-colors";
import type { SeriesValueFormat } from "../series-format";
import { SeriesLines } from "../timeline-chart";
import { TimelineChronicle } from "../timeline-chronicle";
import {
SERIES_GAP,
SERIES_HEIGHT,
layoutTimeline,
} from "../timeline-geometry";
import { SERIES_GAP, layoutTimeline } from "../timeline-geometry";
import { SERIES_HEIGHT } from "../series-geometry";
import { ValueSeriesMenu } from "../timeline-menus";
import { TimelineLegend } from "../timeline-view";
import { TimelineLegend } from "../timeline-legend";
import { useSeriesValueFormat } from "../use-series-value-format";
import { item, wordsIn } from "./timeline-fixture";
@@ -150,6 +147,8 @@ function twoMedications(): TimelineResponse {
standing: [],
series: [],
availableSeries: [],
labSeries: [],
availableLabs: [],
notable: [],
};
}
+540
View File
@@ -0,0 +1,540 @@
"use client";
/**
* Lab markers on the timeline (#1299): their rows under the value lines,
* their readings in the selection bar and the phone chronicle, and their
* table for a screen reader.
*
* A lab line is its readings, one point each on the day of the draw, never a
* mean. Each row has its own scale, which holds the reference range as well
* as the points, and the range is painted behind them as the labs page
* paints it: a faint band with dashed edges, in the row's own colour. A
* reading outside its range is a triangle pointing the way it left (up above
* the range, down below it) and says so in words wherever it is written out,
* so the colour is never the only sign. The numbers read through the labs
* page's own formatter, so a reading keeps every digit it was stored with.
*/
import { useCallback } from "react";
import Link from "next/link";
import { useLabNumber } from "@/components/labs/use-lab-format";
import { InlineAction } from "@/components/ui/inline-action";
import type {
TimelineBucket,
TimelineLabPoint,
TimelineLabSeries,
} from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations } from "@/lib/i18n/context";
import type { ChronicleEntry } from "./chronicle-model";
import { TIMELINE_LAB_RANGE_KEY } from "./label-keys";
import {
bucketAfter,
dayKey,
dayNumber,
formatDayMonth,
formatDayMonthYear,
} from "./timeline-dates";
import { seriesNameLines, type TimelineLayout } from "./timeline-geometry";
/** "18 ng/mL", every digit the reading holds, in the reader's locale. */
export type LabValueText = (
value: number,
sourceValue: number | null,
unit: string,
) => string;
export function useLabValueText(): LabValueText {
const labNumber = useLabNumber();
return useCallback(
(value, sourceValue, unit) =>
`${labNumber(value, sourceValue)} ${unit}`.trim(),
[labNumber],
);
}
/** "below range" / "above range", or null for a reading in range. */
export function useLabVerdict(): (
status: TimelineLabPoint["rangeStatus"],
) => string | null {
const { t } = useTranslations();
return useCallback(
(status) =>
status === "below" || status === "above"
? t(TIMELINE_LAB_RANGE_KEY[status])
: null,
[t],
);
}
/** "Reference 30–400 ng/mL", or null without a range. */
export function useLabReference(): (
point: Pick<TimelineLabPoint, "referenceLow" | "referenceHigh">,
unit: string,
) => string | null {
const { t } = useTranslations();
const labNumber = useLabNumber();
return useCallback(
(point, unit) => {
const range = formatReferenceRange(
point.referenceLow,
point.referenceHigh,
(v) => labNumber(v),
);
return range
? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() })
: null;
},
[t, labNumber],
);
}
/** The readings of every lab line whose day lies in `[from, to]`. */
export function labReadingsIn(
labSeries: readonly TimelineLabSeries[],
from: string,
to: string,
): Array<{ key: string; unit: string; point: TimelineLabPoint }> {
return labSeries.flatMap((s) =>
s.points
.filter((p) => p.date >= from && p.date <= to)
.map((point) => ({ key: s.key, unit: s.unit, point })),
);
}
/* ─── The rows on the chart ───────────────────────────────────────────────── */
/** Where a row's name and latest value start, as on the value lines. */
const TEXT_X = 16;
/**
* A reading's mark: a dot in range (or without one), a triangle pointing up
* above it and down below it. Every mark takes the row's `currentColor`.
*/
export function LabPointGlyph({
x,
y,
status,
}: {
x: number;
y: number;
status: TimelineLabPoint["rangeStatus"];
}) {
if (status === "above" || status === "below") {
const d =
status === "above"
? `M${x} ${y - 4.5}L${x + 4.5} ${y + 3.5}L${x - 4.5} ${y + 3.5}Z`
: `M${x} ${y + 4.5}L${x + 4.5} ${y - 3.5}L${x - 4.5} ${y - 3.5}Z`;
return (
<path
d={d}
data-slot="timeline-lab-point"
data-status={status}
fill="currentColor"
stroke="var(--card)"
strokeWidth={1.2}
strokeLinejoin="round"
/>
);
}
return (
<circle
data-slot="timeline-lab-point"
data-status={status}
cx={x}
cy={y}
r={3.25}
fill="currentColor"
stroke="var(--card)"
strokeWidth={1.2}
/>
);
}
export function LabLines({
layout,
selectedBucket,
bucket,
seriesLabel,
seriesColor,
valueText,
}: {
layout: Pick<
TimelineLayout,
"labSeries" | "series" | "seriesTop" | "scale" | "width"
>;
/** The first day of the selected bucket: its readings are ringed. */
selectedBucket: string | null;
bucket: TimelineBucket;
seriesLabel: (key: string) => string;
seriesColor: (key: string) => string;
valueText: LabValueText;
}) {
const { t } = useTranslations();
const { scale, width } = layout;
const rows = layout.labSeries;
const selectedTo = selectedBucket
? dayKey(dayNumber(bucketAfter(selectedBucket, bucket)) - 1)
: null;
const nameLines = (key: string) =>
seriesNameLines(seriesLabel(key), scale.x0 - 12 - TEXT_X, 13);
return (
<>
{/* The hairline above the rows, when no value line drew it already. */}
{layout.series.length === 0 && rows.length > 0 && (
<line
x1={0}
x2={width}
y1={layout.seriesTop - 18}
y2={layout.seriesTop - 18}
stroke="var(--border)"
/>
)}
{rows.map((row) => {
const color = seriesColor(row.key);
const lines = nameLines(row.key);
return (
<g
key={row.key}
data-lab-series={row.key}
data-color={color}
color={color}
>
{row.band && (
<g data-slot="timeline-lab-band">
{row.band.top !== null && row.band.bottom !== null && (
<rect
x={scale.x0}
y={row.band.top}
width={Math.max(0, scale.x1 - scale.x0)}
height={Math.max(0, row.band.bottom - row.band.top)}
fill="currentColor"
fillOpacity={0.1}
data-slot="timeline-lab-band-fill"
/>
)}
{[row.band.top, row.band.bottom].map((edge, i) =>
edge === null ? null : (
<line
key={i}
data-slot="timeline-lab-band-edge"
x1={scale.x0}
x2={scale.x1}
y1={edge}
y2={edge}
stroke="currentColor"
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
),
)}
</g>
)}
<line
data-slot="timeline-series-swatch"
x1={1.5}
x2={10.5}
y1={row.top + 8}
y2={row.top + 8}
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
/>
<text
data-slot="timeline-series-name"
x={TEXT_X}
y={row.top + 12}
fontSize={13}
fontWeight={500}
fill="var(--foreground)"
>
{lines.map((line, i) => (
<tspan key={i} x={TEXT_X} dy={i === 0 ? 0 : 15}>
{line}
</tspan>
))}
</text>
<text
x={TEXT_X}
y={row.top + 30 + (lines.length - 1) * 15}
fontSize={12}
fill="var(--muted-foreground)"
>
{row.latest === null
? t("timeline.values.noneInWindow")
: valueText(row.latest.value, row.latest.sourceValue, row.unit)}
</text>
{row.path && (
<path
d={row.path}
data-slot="timeline-lab-line"
fill="none"
stroke="currentColor"
strokeWidth={1.2}
strokeOpacity={0.6}
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{row.points.map((point) => (
<g
key={point.id}
data-slot="timeline-lab-mark"
data-id={point.id}
data-date={point.date}
>
<LabPointGlyph
x={point.x}
y={point.y}
status={point.rangeStatus}
/>
</g>
))}
{selectedBucket !== null &&
selectedTo !== null &&
row.points
.filter(
(point) =>
point.date >= selectedBucket && point.date <= selectedTo,
)
.map((point) => (
<circle
key={`selected-${point.id}`}
data-slot="timeline-series-selected"
cx={point.x}
cy={point.y}
r={6}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
/>
))}
</g>
);
})}
</>
);
}
/* ─── Written out ─────────────────────────────────────────────────────────── */
/**
* Lab readings as a line of their own under the means: "● Ferritin 18 ng/mL,
* below range, 12 Feb." with the report it came from as a link. Each reading
* is one unit that wraps only between units, like a means line.
*/
export function LabReadingsLine({
readings,
label,
seriesColor,
valueText,
showDate,
links = true,
intl,
}: {
readings: ReturnType<typeof labReadingsIn>;
label: (key: string) => string;
seriesColor: (key: string) => string;
valueText: LabValueText;
/** The day beside each reading: where a line covers more than one day. */
showDate: boolean;
/**
* The report as a link. Off inside a row that is itself a button (a link
* cannot sit inside one); the day the row opens names the report.
*/
links?: boolean;
intl: string;
}) {
const { t } = useTranslations();
const verdict = useLabVerdict();
return readings.map(({ key, unit, point }, i) => {
const words = [
`${label(key)} ${valueText(point.value, point.sourceValue, unit)}`,
verdict(point.rangeStatus),
showDate ? formatDayMonth(point.date, intl) : null,
]
.filter(Boolean)
.join(", ");
return (
<span
key={`${key}:${point.id}`}
data-slot="timeline-lab-reading"
data-series={key}
data-status={point.rangeStatus}
className="inline-flex max-w-full min-w-0 items-center gap-1 whitespace-nowrap"
>
{i > 0 ? <span className="sr-only">, </span> : null}
<span
data-slot="timeline-series-dot"
className="size-2 shrink-0 rounded-full"
style={{ background: seriesColor(key) }}
aria-hidden="true"
/>
<span className="min-w-0 truncate">{words}</span>
{links && point.report ? (
<InlineAction asChild tone="muted" size="xs">
<Link
href={point.report.href}
data-slot="timeline-lab-report"
onClick={(event) => event.stopPropagation()}
>
{point.report.title
? t("timeline.labs.report", { title: point.report.title })
: t("timeline.labs.reportUntitled")}
</Link>
</InlineAction>
) : null}
</span>
);
});
}
/**
* A lab day's row in the phone chronicle lists the chosen lab lines'
* readings of that day under its names. The row is a button that opens the
* day, so the report is named there, not linked here.
*/
export function ChronicleLabReadings({
entry,
labSeries,
seriesColor,
className,
}: {
entry: ChronicleEntry;
labSeries: readonly TimelineLabSeries[];
seriesColor: (key: string) => string;
/** The row's means-line classes, so both read alike. */
className: string;
}) {
const { locale } = useTranslations();
const valueText = useLabValueText();
if (
entry.kind !== "item" ||
entry.item.kind !== "labDay" ||
entry.role === "end"
) {
return null;
}
const readings = labReadingsIn(labSeries, entry.date, entry.date);
if (readings.length === 0) return null;
return (
<span className={className} data-slot="timeline-chronicle-labs">
<LabReadingsLine
readings={readings}
label={(key) => labSeries.find((s) => s.key === key)?.name ?? key}
seriesColor={seriesColor}
valueText={valueText}
showDate={false}
links={false}
intl={resolveIntlLocale(locale)}
/>
</span>
);
}
/**
* A lab line with readings left out because their unit does not convert into
* the marker's says so, once, under the chart (and over the phone list):
* nothing is dropped without a word.
*/
export function LabExcludedNote({
labSeries,
label,
block = false,
}: {
labSeries: readonly TimelineLabSeries[];
label: (key: string) => string;
/** A line of its own (the phone list), not runs inside the legend. */
block?: boolean;
}) {
const { tCount } = useTranslations();
const lines = labSeries.filter((s) => s.excluded > 0);
if (lines.length === 0) return null;
const notes = lines.map((s) => (
<span
key={s.key}
data-slot="timeline-lab-excluded"
data-series={s.key}
className="inline-flex items-center gap-1.5"
>
{tCount("timeline.labs.excluded", s.excluded, { name: label(s.key) })}
</span>
));
return block ? (
<div
className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1.5 text-xs"
data-slot="timeline-mobile-lab-note"
>
{notes}
</div>
) : (
notes
);
}
/**
* The lab lines as a table, for a screen reader: one row per reading, its
* marker, day, value, verdict, range and report. Visually hidden by a
* wrapper, as the other chart tables are.
*/
export function LabSeriesTable({
labSeries,
window,
label,
valueText,
intl,
}: {
labSeries: readonly TimelineLabSeries[];
window: { from: string; to: string };
label: (key: string) => string;
valueText: LabValueText;
intl: string;
}) {
const { t } = useTranslations();
const verdict = useLabVerdict();
const reference = useLabReference();
const rows = labReadingsIn(labSeries, window.from, window.to).sort((a, b) =>
a.point.date < b.point.date ? 1 : -1,
);
if (rows.length === 0) return null;
return (
<div className="sr-only" data-slot="timeline-lab-table-frame">
<table data-slot="timeline-lab-table">
<caption>{t("timeline.table.labsCaption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.marker")}</th>
<th scope="col">{t("timeline.table.date")}</th>
<th scope="col">{t("timeline.table.value")}</th>
</tr>
</thead>
<tbody>
{rows.map(({ key, unit, point }) => (
<tr key={point.id}>
<th scope="row">{label(key)}</th>
<td>{formatDayMonthYear(point.date, intl)}</td>
<td>
{[
valueText(point.value, point.sourceValue, unit),
verdict(point.rangeStatus),
reference(point, unit),
point.report
? point.report.title
? t("timeline.labs.report", {
title: point.report.title,
})
: t("timeline.labs.reportUntitled")
: null,
]
.filter(Boolean)
.join(", ")}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+11
View File
@@ -54,6 +54,17 @@ export const TIMELINE_LEGEND_MEAN_KEY: Readonly<
day: "timeline.legendMeanDay",
};
/**
* A lab reading outside its reference range, worded beside the value
* (#1299). In range and unknown read as the value alone.
*/
export const TIMELINE_LAB_RANGE_KEY: Readonly<
Record<"below" | "above", string>
> = {
below: "timeline.labs.below",
above: "timeline.labs.above",
};
/** The two blood pressure lines read as their own names on the chart. */
export const TIMELINE_BLOOD_PRESSURE_SERIES_KEY: Readonly<
Record<"BLOOD_PRESSURE_SYS" | "BLOOD_PRESSURE_DIA", string>
+21 -2
View File
@@ -20,7 +20,10 @@
* above it, not in a box of its own. An entry's parts are joined by commas,
* a period still open reads "since 3 Mar." (it needs no "ongoing"), and a
* medication is one entry however many of its items touch the bucket
* (`medicationPeriodItem`).
* (`medicationPeriodItem`). The chosen lab lines' readings in the bucket
* follow the means, each as measured, with its day where the bucket is
* longer than one, a word where it lies outside its range, and a link to the
* report it was read from (#1299).
*/
import type { TimelineResponse } from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
@@ -38,6 +41,7 @@ import {
formatMonthYear,
} from "./timeline-dates";
import { useItemWords } from "./item-words";
import { LabReadingsLine, labReadingsIn, useLabValueText } from "./lab-lines";
import { itemsInPeriod } from "./timeline-geometry";
import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart";
import {
@@ -82,9 +86,12 @@ export function SelectionBar({
const periodTo = dayKey(dayNumber(bucketAfter(periodFrom, bucket)) - 1);
const entries = itemsInPeriod(timeline.lanes, periodFrom, periodTo, today);
const means = bucketValues(timeline.series, timeline.bucket, selected);
const labReadings = labReadingsIn(timeline.labSeries, periodFrom, periodTo);
const labValueText = useLabValueText();
// A bucket with a value but no entry is not empty: the means below say
// what it holds, so "No entries" is kept for a stretch with nothing.
const hasValue = means.values.some((v) => v.mean !== null);
const hasValue =
means.values.some((v) => v.mean !== null) || labReadings.length > 0;
// "Ø {values}": the words around the values, so the values can carry
// their colour dots.
const [meanBefore, meanAfter = ""] = t("timeline.selection.mean", {
@@ -218,6 +225,18 @@ export function SelectionBar({
{meanAfter ? <span>{meanAfter}</span> : null}
</span>
)}
{labReadings.length > 0 && (
<span className={MEAN_PARTS_ROW} data-slot="timeline-selection-labs">
<LabReadingsLine
readings={labReadings}
label={seriesLabel}
seriesColor={seriesColor}
valueText={labValueText}
showDate={bucket !== "day"}
intl={intl}
/>
</span>
)}
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
/**
* The value lines and lab lines under the timeline's lanes, as pure
* geometry (v1.42, #613; lab lines #1299). `timeline-geometry.ts` stacks
* them under the lanes; each row is laid out here on its own scale.
*/
import type {
TimelineBucket,
TimelineLabPoint,
TimelineLabSeries,
TimelineSeries,
} from "@/lib/day/contract";
import { bucketAfter, bucketIndex, dayNumber } from "./timeline-dates";
import type { Scale, TimeWindow } from "./timeline-geometry";
/** Height of one value line, label included. */
export const SERIES_HEIGHT = 60;
/* ─── Value lines ─────────────────────────────────────────────────────────── */
export interface SeriesPointLayout {
/** The bucket's first day. */
t: string;
mean: number;
/** Readings behind the mean. */
count: number;
x: number;
y: number;
/** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */
thin: boolean;
}
export interface SeriesLayout {
key: string;
unit: string | null;
top: number;
height: number;
/** SVG path data of the solid runs; empty when no two points touch. */
path: string;
/** SVG path data of the dashed bridges over short gaps; may be empty. */
bridges: string;
/** The most recent mean inside the window, or null. */
latest: number | null;
points: SeriesPointLayout[];
}
/** A bucket mean from fewer readings than this is drawn hollow. */
export const THIN_BUCKET_COUNT = 3;
/**
* The most missing buckets a dashed bridge spans. Beyond it the line stops
* and starts again, so a long silence reads as one and not as a trend.
*
* The rule is one length, about six months between the two points a bridge
* joins, never more, and never more than a third of what the zoom shows:
* one missing quarter (the points are six months apart), two missing months
* in a year (three months apart, a sixth of the window) and four missing
* weeks in three months (five weeks apart, the window's third). A longer
* bridge would draw a slope through a stretch where nothing was measured.
*/
export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
quarter: 1,
month: 2,
week: 4,
// Days come only with a chosen range under six weeks. Three missing days
// (points four days apart) bridge a weekend away or a forgotten cuff;
// a longer silence in a window that short is a break worth seeing, and
// the bridge stays well under a third of even a two-week range.
day: 3,
};
/**
* One neutral line per series, scaled to its own range inside the window.
* A point sits in the middle of its bucket, and every bucket with a reading
* gets its point, so one month with no neighbours stays visible. Missing
* buckets stay missing: a short gap is bridged by a dashed stroke
* ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none
* between them, a longer one breaks the line.
*/
export function layoutSeries(
series: readonly TimelineSeries[],
top: number,
window: TimeWindow,
scale: Scale,
bucket: TimelineBucket,
): SeriesLayout[] {
return series.map((s, index) => {
const rowTop = top + index * SERIES_HEIGHT + 6;
const height = SERIES_HEIGHT - 16;
// A bucket that starts before the window still belongs to it while any
// of its days lie inside.
const inside = s.points
.filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from)
.sort((a, b) => (a.t < b.t ? -1 : 1));
if (inside.length === 0) {
return {
key: s.key,
unit: s.unit,
top: rowTop,
height,
path: "",
bridges: "",
latest: null,
points: [],
};
}
let lo = Math.min(...inside.map((p) => p.mean));
let hi = Math.max(...inside.map((p) => p.mean));
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
lo -= pad;
hi += pad;
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
const points = inside.map((p) => {
const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2;
const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid)));
return {
t: p.t,
mean: p.mean,
count: p.count,
x,
y: y(p.mean),
thin: p.count < THIN_BUCKET_COUNT,
};
});
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
let path = "";
let bridges = "";
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const cur = points[i];
const missing =
bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1;
if (missing === 0) {
// Continue the run, or open a new one at the previous point.
path += path.endsWith(xy(prev))
? `L${xy(cur)}`
: `M${xy(prev)}L${xy(cur)}`;
} else if (missing <= MAX_BRIDGED_GAP[bucket]) {
bridges += `M${xy(prev)}L${xy(cur)}`;
}
}
return {
key: s.key,
unit: s.unit,
top: rowTop,
height,
path,
bridges,
latest: inside[inside.length - 1].mean,
points,
};
});
}
/* ─── Lab lines ───────────────────────────────────────────────────────────── */
export interface LabPointLayout extends TimelineLabPoint {
x: number;
y: number;
}
export interface LabSeriesLayout {
key: string;
biomarkerId: string;
unit: string;
top: number;
height: number;
/** SVG path data joining the points in time order; empty under two. */
path: string;
/**
* The reference range as y positions inside the row: `top` / `bottom` of
* the band, null on a side the range leaves open.
*/
band: { top: number | null; bottom: number | null } | null;
/** The newest point inside the window, or null. */
latest: LabPointLayout | null;
points: LabPointLayout[];
excluded: number;
}
/**
* One row per lab marker, below the value lines and in their rhythm, each on
* its own scale. A lab line is its readings: a point per reading on the day
* of the draw, joined in time order by a thin stroke, never a mean and never
* a value between readings (#1299). The scale holds the points and the
* reference range together, as the labs page's chart does, so the band
* behind the points is always in view and a point outside it reads as
* outside at a glance.
*/
export function layoutLabSeries(
series: readonly TimelineLabSeries[],
top: number,
window: TimeWindow,
scale: Scale,
): LabSeriesLayout[] {
return series.map((s, index) => {
const rowTop = top + index * SERIES_HEIGHT + 6;
const height = SERIES_HEIGHT - 16;
const inside = s.points.filter(
(p) => p.date >= window.from && p.date <= window.to,
);
const bounds = [s.band?.low, s.band?.high].filter(
(b): b is number => typeof b === "number",
);
const span = [...inside.map((p) => p.value), ...bounds];
let lo = span.length > 0 ? Math.min(...span) : 0;
let hi = span.length > 0 ? Math.max(...span) : 1;
// A lone value, or a range of one number, still needs a span to sit in.
const pad = hi > lo ? (hi - lo) * 0.12 : Math.abs(hi) * 0.1 || 1;
lo -= pad;
hi += pad;
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
const points = inside.map((p) => ({
...p,
x: Math.min(scale.x1, Math.max(scale.x0, scale.xMid(p.date))),
y: y(p.value),
}));
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
const xy = (p: LabPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
const path =
points.length > 1
? points.map((p, i) => `${i === 0 ? "M" : "L"}${xy(p)}`).join("")
: "";
const band =
s.band && (s.band.low !== null || s.band.high !== null)
? {
top: s.band.high === null ? null : y(s.band.high),
bottom: s.band.low === null ? null : y(s.band.low),
}
: null;
return {
key: s.key,
biomarkerId: s.biomarkerId,
unit: s.unit,
top: rowTop,
height,
path,
band,
latest: points.at(-1) ?? null,
points,
excluded: s.excluded,
};
});
}
+181 -37
View File
@@ -23,6 +23,11 @@
*
* The card is placed above the point and kept inside the chart's width
* (`tipLeft`), so it never adds a horizontal scroll of its own.
*
* A lab line's point (#1299) is one reading, not a mean: its card names the
* day of the draw, the value, whether it lies outside its reference range
* and the range itself, and the report it was read from. A tap pins the card
* with a link to that report beside "View the whole day".
*/
import {
useEffect,
@@ -30,13 +35,22 @@ import {
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import Link from "next/link";
import { RichChartTooltip } from "@/components/charts/chart-tooltip";
import {
RichChartTooltip,
type RichTooltipRow,
} from "@/components/charts/chart-tooltip";
import { TooltipDayAction } from "@/components/day/chart-day";
import { InlineAction } from "@/components/ui/inline-action";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { useLabReference, useLabValueText, useLabVerdict } from "./lab-lines";
import { formatSeriesValue, type SeriesValueFormat } from "./series-format";
import { formatDayMonthYear } from "./timeline-dates";
import type { TimelineLayout } from "./timeline-geometry";
/** Hit target across, in px: bigger than the dot, a comfortable aim. */
@@ -61,6 +75,21 @@ interface Active {
pinned: boolean;
}
/** One point's target and card, a value line's or a lab line's alike. */
interface TipPoint {
/** The point's id within its line: a bucket start, or a reading's id. */
t: string;
x: number;
y: number;
/** The day a second tap, Enter or a double click opens. */
day: string;
ariaLabel: string;
label: string;
row: RichTooltipRow;
/** Under the row: the report a reading came from. */
report: { href: string; text: string } | null;
}
export function SeriesPointTips({
layout,
today,
@@ -81,7 +110,6 @@ export function SeriesPointTips({
onSelect: (date: string) => void;
onOpenDay: (date: string) => void;
}) {
const { tCount } = useTranslations();
const [active, setActive] = useState<Active | null>(null);
// Per line, the point that holds its one tab stop.
const [stops, setStops] = useState<Record<string, string>>({});
@@ -115,21 +143,24 @@ export function SeriesPointTips({
return () => document.removeEventListener("pointerdown", away);
}, [pinned]);
// The first day of a point's stretch that has happened, the day it opens.
const dayOf = (t: string) => (t > today ? today : t);
const lines = useTipLines({
layout,
today,
seriesLabel,
seriesColor,
fmt,
bucketLabel,
});
const series = layout.series.find((s) => s.key === active?.key);
const point = series?.points.find((p) => p.t === active?.t);
const words = (key: string, t: string, mean: number, unit: string | null) =>
`${seriesLabel(key)}, ${bucketLabel(t)}: ${formatSeriesValue(key, mean, unit, fmt)}`;
const line = lines.find((l) => l.key === active?.key);
const point = line?.points.find((p) => p.t === active?.t);
function move(
event: KeyboardEvent<HTMLButtonElement>,
key: string,
index: number,
) {
const points = layout.series.find((s) => s.key === key)?.points ?? [];
const points = lines.find((l) => l.key === key)?.points ?? [];
let next: number | null = null;
if (event.key === "ArrowLeft") next = Math.max(0, index - 1);
else if (event.key === "ArrowRight")
@@ -149,7 +180,7 @@ export function SeriesPointTips({
return (
<div data-slot="timeline-point-tips">
{layout.series.map((s) =>
{lines.map((s) =>
s.points.map((p, i) => {
const id = `${s.key}:${p.t}`;
const stop = stops[s.key] ?? s.points.at(-1)?.t;
@@ -165,7 +196,7 @@ export function SeriesPointTips({
data-series={s.key}
data-t={p.t}
tabIndex={p.t === stop ? 0 : -1}
aria-label={`${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`}
aria-label={p.ariaLabel}
onPointerDown={(event) => {
pointerType.current = event.pointerType;
event.stopPropagation();
@@ -189,7 +220,7 @@ export function SeriesPointTips({
event.stopPropagation();
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenDay(dayOf(p.t));
onOpenDay(p.day);
return;
}
move(event, s.key, i);
@@ -197,7 +228,7 @@ export function SeriesPointTips({
onClick={(event) => {
event.stopPropagation();
if (pointerType.current === "mouse") {
onSelect(dayOf(p.t));
onSelect(p.day);
return;
}
// A tap shows the card; a second tap on the same point opens
@@ -207,14 +238,14 @@ export function SeriesPointTips({
active.key === s.key &&
active.t === p.t
) {
onOpenDay(dayOf(p.t));
onOpenDay(p.day);
return;
}
setActive({ key: s.key, t: p.t, pinned: true });
}}
onDoubleClick={(event) => {
event.stopPropagation();
onOpenDay(dayOf(p.t));
onOpenDay(p.day);
}}
className="focus-visible:ring-ring/50 absolute rounded-full outline-none focus-visible:ring-2"
style={{
@@ -227,11 +258,11 @@ export function SeriesPointTips({
);
}),
)}
{series && point ? (
{line && point ? (
<div
ref={cardRef}
data-slot="timeline-point-tip"
data-series={series.key}
data-series={line.key}
data-t={point.t}
className={
active?.pinned
@@ -248,28 +279,141 @@ export function SeriesPointTips({
>
<RichChartTooltip
active
label={bucketLabel(point.t)}
rows={[
{
name: seriesLabel(series.key),
value: formatSeriesValue(
series.key,
point.mean,
series.unit,
fmt,
),
color: seriesColor(series.key),
delta: tCount("timeline.values.readings", point.count),
},
]}
action={
active?.pinned ? (
<TooltipDayAction onOpen={() => onOpenDay(dayOf(point.t))} />
) : undefined
}
label={point.label}
rows={[point.row]}
action={tipAction(point, active?.pinned ?? false, () =>
onOpenDay(point.day),
)}
/>
</div>
) : null}
</div>
);
}
/**
* Under a card's row: a reading's report (a link once the card is pinned, its
* name while it only follows the pointer) and, pinned, the way into the day.
*/
function tipAction(
point: TipPoint,
pinned: boolean,
openDay: () => void,
): ReactNode {
if (!point.report)
return pinned ? <TooltipDayAction onOpen={openDay} /> : undefined;
return (
<>
<div className="mt-1.5" data-slot="timeline-point-tip-report">
{pinned ? (
<InlineAction asChild tone="muted" size="xs">
<Link
href={point.report.href}
onPointerDown={(event) => event.stopPropagation()}
>
{point.report.text}
</Link>
</InlineAction>
) : (
<span className="text-muted-foreground text-xs">
{point.report.text}
</span>
)}
</div>
{pinned ? <TooltipDayAction onOpen={openDay} /> : null}
</>
);
}
/**
* Every line's points as targets and cards, value lines first, then lab
* lines, in the order they are drawn: a value line's point names its bucket
* and the readings behind its mean, a lab line's point the day of its draw,
* its verdict, its range and its report.
*/
function useTipLines({
layout,
today,
seriesLabel,
seriesColor,
fmt,
bucketLabel,
}: {
layout: TimelineLayout;
today: string;
seriesLabel: (key: string) => string;
seriesColor: (key: string) => string;
fmt: SeriesValueFormat;
bucketLabel: (t: string) => string;
}): Array<{ key: string; points: TipPoint[] }> {
const { t: tr, tCount, locale } = useTranslations();
const intl = resolveIntlLocale(locale);
const verdict = useLabVerdict();
const labValueText = useLabValueText();
const reference = useLabReference();
// The first day of a point's stretch that has happened, the day it opens.
const dayOf = (t: string) => (t > today ? today : t);
const words = (key: string, t: string, mean: number, unit: string | null) =>
`${seriesLabel(key)}, ${bucketLabel(t)}: ${formatSeriesValue(key, mean, unit, fmt)}`;
return [
...layout.series.map((s) => ({
key: s.key,
points: s.points.map((p) => ({
t: p.t,
x: p.x,
y: p.y,
day: dayOf(p.t),
ariaLabel: `${words(s.key, p.t, p.mean, s.unit)}, ${tCount("timeline.values.readings", p.count)}`,
label: bucketLabel(p.t),
row: {
name: seriesLabel(s.key),
value: formatSeriesValue(s.key, p.mean, s.unit, fmt),
color: seriesColor(s.key),
delta: tCount("timeline.values.readings", p.count),
},
report: null,
})),
})),
...layout.labSeries.map((s) => ({
key: s.key,
points: s.points.map((p) => {
const value = labValueText(p.value, p.sourceValue, s.unit);
const detail = [verdict(p.rangeStatus), reference(p, s.unit)]
.filter(Boolean)
.join(", ");
const report = p.report
? {
href: p.report.href,
text: p.report.title
? tr("timeline.labs.report", { title: p.report.title })
: tr("timeline.labs.reportUntitled"),
}
: null;
const date = formatDayMonthYear(p.date, intl);
return {
t: p.id,
x: p.x,
y: p.y,
day: dayOf(p.date),
ariaLabel: [
`${seriesLabel(s.key)}, ${date}: ${value}`,
detail,
report?.text,
]
.filter(Boolean)
.join(", "),
label: date,
row: {
name: seriesLabel(s.key),
value,
color: seriesColor(s.key),
...(detail ? { delta: detail } : {}),
},
report,
};
}),
})),
];
}
+103 -184
View File
@@ -54,28 +54,29 @@ import type {
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { itemLine, useItemWords, type ItemWordsFn } from "./item-words";
import { medicationListItems } from "./medication-rows";
import { useItemWords, type ItemWordsFn } from "./item-words";
import {
LabLines,
LabSeriesTable,
useLabValueText,
type LabValueText,
} from "./lab-lines";
import { SeriesTable, TimelineTable, bucketText } from "./timeline-tables";
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
import {
bucketAfter,
bucketStart,
formatAtPrecision,
formatBucket,
formatMonthShort,
formatMonthYear,
dayNumber,
} from "./timeline-dates";
import {
AXIS_HEIGHT,
LANE_COLOR,
dateAtPointer,
estimateTextWidth,
SERIES_HEIGHT,
fitText,
seriesNameLines,
gridTicks,
isSpan,
todayLabelFits,
wrapToWidth,
layoutTimeline,
@@ -85,6 +86,7 @@ import {
type PlacedSpan,
type TimeWindow,
} from "./timeline-geometry";
import { SERIES_HEIGHT } from "./series-geometry";
import {
formatSeriesValue,
type MeanPart,
@@ -157,6 +159,7 @@ export function TimelineChart({
const { t, locale } = useTranslations();
const words = useItemWords();
const fmt = seriesFormat;
const labValueText = useLabValueText();
const intl = resolveIntlLocale(locale);
const { ref, width } = useElementWidth<HTMLDivElement>();
@@ -172,13 +175,24 @@ export function TimelineChart({
window,
lanes,
series: timeline.series,
labSeries: timeline.labSeries,
bucket: timeline.bucket,
words,
startMissing: t("timeline.startUnknown"),
today,
})
: null,
[width, window, lanes, timeline.series, timeline.bucket, words, t, today],
[
width,
window,
lanes,
timeline.series,
timeline.labSeries,
timeline.bucket,
words,
t,
today,
],
);
const floor = timeline.range.dataFrom ?? window.from;
@@ -266,6 +280,7 @@ export function TimelineChart({
intl={intl}
t={t}
fmt={fmt}
labValueText={labValueText}
/>
</svg>
) : (
@@ -301,6 +316,13 @@ export function TimelineChart({
fmt={fmt}
intl={intl}
/>
<LabSeriesTable
labSeries={timeline.labSeries}
window={window}
label={seriesLabel}
valueText={labValueText}
intl={intl}
/>
</div>
);
}
@@ -320,10 +342,23 @@ export function SeriesNameLinks({
layout: NonNullable<ReturnType<typeof layoutTimeline>>;
seriesLabel: (key: string) => string;
}) {
// A lab line's name leads to its marker's page on the labs list.
const rows = [
...layout.series.map((s) => ({
key: s.key,
top: s.top,
href: metricPageHref(s.key),
})),
...layout.labSeries.map((s) => ({
key: s.key,
top: s.top,
href: `/labs/${encodeURIComponent(s.biomarkerId)}`,
})),
];
return (
<>
{layout.series.map((series) => {
const href = metricPageHref(series.key);
{rows.map((series) => {
const href = series.href;
if (!href) return null;
return (
<Link
@@ -360,6 +395,7 @@ interface ChartBodyProps {
intl: string;
t: ReturnType<typeof useTranslations>["t"];
fmt: SeriesValueFormat;
labValueText: LabValueText;
}
function ChartBody({
@@ -375,6 +411,7 @@ function ChartBody({
intl,
t,
fmt,
labValueText,
}: ChartBodyProps) {
const { scale, height, width } = layout;
const todayInWindow = today >= window.from && today <= window.to;
@@ -409,30 +446,12 @@ function ChartBody({
</defs>
{noDataUntil !== null && (
<g data-slot="timeline-no-data">
<rect
x={scale.x0}
y={AXIS_HEIGHT}
width={Math.max(0, noDataUntil - scale.x0)}
height={height - AXIS_HEIGHT}
fill="var(--muted)"
opacity={0.4}
/>
{wrapToWidth(
t("timeline.noDataYet"),
noDataUntil - scale.x0 - 12,
).map((line, i, lines) => (
<text
key={line}
x={scale.x0 + 8}
y={height - 8 - (lines.length - 1 - i) * 13}
fontSize={11}
fill="var(--muted-foreground)"
>
{line}
</text>
))}
</g>
<NoDataShade
x0={scale.x0}
until={noDataUntil}
height={height}
label={t("timeline.noDataYet")}
/>
)}
{/* Grid and axis labels. */}
@@ -530,6 +549,16 @@ function ChartBody({
fmt={fmt}
/>
{/* Lab lines (#1299): single readings, under the value lines. */}
<LabLines
layout={layout}
selectedBucket={selectedBucket}
bucket={bucket}
seriesLabel={seriesLabel}
seriesColor={seriesColor}
valueText={labValueText}
/>
{/* Today. */}
{todayInWindow && (
<g data-slot="timeline-today">
@@ -606,6 +635,46 @@ function ChartBody({
);
}
/**
* The stretch before the record's first entry, shaded and named, so an empty
* start reads as "no data yet" and not as a quiet period.
*/
function NoDataShade({
x0,
until,
height,
label,
}: {
x0: number;
until: number;
height: number;
label: string;
}) {
return (
<g data-slot="timeline-no-data">
<rect
x={x0}
y={AXIS_HEIGHT}
width={Math.max(0, until - x0)}
height={height - AXIS_HEIGHT}
fill="var(--muted)"
opacity={0.4}
/>
{wrapToWidth(label, until - x0 - 12).map((line, i, lines) => (
<text
key={line}
x={x0 + 8}
y={height - 8 - (lines.length - 1 - i) * 13}
fontSize={11}
fill="var(--muted-foreground)"
>
{line}
</text>
))}
</g>
);
}
/**
* The value lines under the lanes, each in its own colour: the swatch beside
* the name, the line, the dashed bridge over a gap, the points (hollow where
@@ -964,156 +1033,6 @@ export function PointMark({
);
}
/**
* The chart as a table, for a screen reader: every entry with its lane and
* dates. Visually hidden; the phone chronicle is the visible list form.
*/
function TimelineTable({
timeline,
hiddenLanes,
intl,
}: {
timeline: TimelineResponse;
hiddenLanes: ReadonlySet<TimelineLaneKey>;
intl: string;
}) {
const { t } = useTranslations();
const words = useItemWords();
const rows = timeline.lanes
.filter((lane) => !hiddenLanes.has(lane.key))
.flatMap((lane) =>
(lane.key === "medications"
? medicationListItems(lane.items)
: lane.items
).map((item) => ({ lane: lane.key, item })),
)
.sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start));
return (
// The table is hidden by a wrapper, never by `sr-only` on the table
// itself: `overflow: hidden` does not apply to a table box, so a
// visually hidden table still laid out its rows at full height under
// the page and made `<main>` scroll past its last card into nothing.
<div className="sr-only" data-slot="timeline-table-frame">
<table data-slot="timeline-table">
<caption>{t("timeline.table.caption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.lane")}</th>
<th scope="col">{t("timeline.table.entry")}</th>
<th scope="col">{t("timeline.table.start")}</th>
<th scope="col">{t("timeline.table.end")}</th>
</tr>
</thead>
<tbody>
{rows.map(({ lane, item }) => (
<tr key={`${lane}-${item.id}`}>
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
<td>{itemLine(item, words, t("timeline.startUnknown"))}</td>
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
<td>
{!item.open && isSpan(item) && item.end
? formatAtPrecision(item.end, item.precision, intl)
: ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */
export function bucketText(
start: string,
bucket: TimelineBucket,
intl: string,
t: ReturnType<typeof useTranslations>["t"],
): string {
const label = formatBucket(start, bucket, intl);
return typeof label === "string"
? label
: t("timeline.selection.range", label);
}
/**
* The value lines as a table, for a screen reader: one row per bucket from
* the first to the last with a reading in the window, one column per
* series. A bucket without a reading says so; nothing is filled in.
*/
function SeriesTable({
timeline,
window,
seriesLabel,
fmt,
intl,
}: {
timeline: TimelineResponse;
window: { from: string; to: string };
seriesLabel: (key: string) => string;
fmt: SeriesValueFormat;
intl: string;
}) {
const { t, tCount } = useTranslations();
const { bucket, series } = timeline;
const inWindow = (start: string) =>
start <= window.to && bucketAfter(start, bucket) > window.from;
const starts = series
.flatMap((s) => s.points.map((p) => p.t))
.filter(inWindow)
.sort();
if (starts.length === 0) return null;
const rows: string[] = [];
for (
let start = bucketStart(starts[0], bucket);
start <= starts[starts.length - 1];
start = bucketAfter(start, bucket)
) {
rows.push(start);
}
const byKey = new Map(
series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]),
);
return (
// The table is hidden by a wrapper, never by `sr-only` on the table
// itself: `overflow: hidden` does not apply to a table box, so a
// visually hidden table still laid out its rows at full height under
// the page and made `<main>` scroll past its last card into nothing.
<div className="sr-only" data-slot="timeline-series-table-frame">
<table data-slot="timeline-series-table">
<caption>{t("timeline.table.seriesCaption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.period")}</th>
{series.map((s) => (
<th key={s.key} scope="col">
{seriesLabel(s.key)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.reverse().map((start) => (
<tr key={start} data-bucket={start}>
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
{series.map((s) => {
const point = byKey.get(s.key)?.get(start);
return (
<td key={s.key}>
{point
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
: t("timeline.values.noValue")}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
/**
* A means line with each value marked by its line's colour: "● 129/82 mmHg
* ● 82,6 kg". The values stand apart by space, not by a separator glyph;
+59 -41
View File
@@ -7,6 +7,9 @@
* rails beside the rows an episode or a course spans, and empty stretches
* named rather than skipped. Built from `chronicle-model.ts`.
*
* A lab day's row lists the chosen lab lines' readings of that day under its
* names, each as measured (#1299); its report is a step further, in the day.
*
* A dated row opens its day (the same `?day=` layer the chart hands off to);
* a life event opens its edit sheet instead, because the event is the
* person's own entry and that is where it is changed.
@@ -42,6 +45,7 @@ import {
type ChronicleRails,
} from "./chronicle-model";
import { useItemWords } from "./item-words";
import { ChronicleLabReadings } from "./lab-lines";
import { TIMELINE_CHRONICLE_NOTABLE_KEY } from "./label-keys";
import {
formatAtPrecision,
@@ -50,12 +54,8 @@ import {
formatMonthYear,
} from "./timeline-dates";
import { LANE_COLOR } from "./timeline-geometry";
import {
MEAN_PARTS_ROW,
MeanPartsLine,
bucketText,
laneDotStyle,
} from "./timeline-chart";
import { MEAN_PARTS_ROW, MeanPartsLine, laneDotStyle } from "./timeline-chart";
import { bucketText } from "./timeline-tables";
import {
chronicleMeans,
meanParts,
@@ -131,6 +131,52 @@ function Rails({ rails }: { rails: ChronicleRails }) {
);
}
/** A chronicle row's title and the muted line under it. */
function entryText(
entry: ChronicleEntry,
{
t,
tCount,
words,
}: {
t: ReturnType<typeof useTranslations>["t"];
tCount: ReturnType<typeof useTranslations>["tCount"];
words: ReturnType<typeof useItemWords>;
},
): {
title: string;
meta: string | null;
} {
if (entry.kind === "notable") {
return {
title: t(TIMELINE_CHRONICLE_NOTABLE_KEY[entry.notable]),
meta: t("timeline.chronicle.notableMeta"),
};
}
const { item, role } = entry;
const { label, sub, tag } = words(item);
if (role === "end") {
// A pause that ends is the medication taken up again, not ended.
return {
title:
item.kind === "pause"
? t("day.event.resumed", { label: item.label })
: t("timeline.chronicle.ended", { label }),
meta: entry.days
? tCount("timeline.chronicle.afterDays", entry.days)
: null,
};
}
// A life event's category sits on the muted line under its title.
const meta = [
sub ?? tag ?? null,
item.startKnown ? null : t("timeline.startUnknown"),
]
.filter(Boolean)
.join(", ");
return { title: label, meta: meta || null };
}
export function TimelineChronicle({
timeline,
today,
@@ -185,40 +231,6 @@ export function TimelineChronicle({
});
};
function entryText(entry: ChronicleEntry): {
title: string;
meta: string | null;
} {
if (entry.kind === "notable") {
return {
title: t(TIMELINE_CHRONICLE_NOTABLE_KEY[entry.notable]),
meta: t("timeline.chronicle.notableMeta"),
};
}
const { item, role } = entry;
const { label, sub, tag } = words(item);
if (role === "end") {
// A pause that ends is the medication taken up again, not ended.
return {
title:
item.kind === "pause"
? t("day.event.resumed", { label: item.label })
: t("timeline.chronicle.ended", { label }),
meta: entry.days
? tCount("timeline.chronicle.afterDays", entry.days)
: null,
};
}
// A life event's category sits on the muted line under its title.
const meta = [
sub ?? tag ?? null,
item.startKnown ? null : t("timeline.startUnknown"),
]
.filter(Boolean)
.join(", ");
return { title: label, meta: meta || null };
}
return (
<div className="space-y-4" data-slot="timeline-chronicle">
{chips.length > 0 && (
@@ -314,7 +326,7 @@ export function TimelineChronicle({
);
}
const { entry } = row;
const { title, meta } = entryText(entry);
const { title, meta } = entryText(entry, { t, tCount, words });
const Icon =
entry.kind === "notable"
? Sparkles
@@ -367,6 +379,12 @@ export function TimelineChronicle({
{meta}
</span>
)}
<ChronicleLabReadings
entry={entry}
labSeries={timeline.labSeries}
seriesColor={seriesColor}
className={cn(MEAN_PARTS_ROW, "mt-1")}
/>
</span>
</>
);
+37 -144
View File
@@ -20,6 +20,7 @@ import type {
TimelineBucket,
TimelineItem,
TimelineItemKind,
TimelineLabSeries,
TimelineLane,
TimelineLaneKey,
TimelineSeries,
@@ -27,6 +28,13 @@ import type {
} from "@/lib/day/contract";
import { itemLine, type ItemWordsFn } from "./item-words";
import {
SERIES_HEIGHT,
layoutLabSeries,
layoutSeries,
type LabSeriesLayout,
type SeriesLayout,
} from "./series-geometry";
import {
doseSegments,
medicationGroups,
@@ -36,8 +44,6 @@ import {
} from "./medication-rows";
import {
addMonths,
bucketAfter,
bucketIndex,
dayKey,
dayNumber,
endOfMonth,
@@ -57,8 +63,6 @@ export const ROW_HEIGHT = 22;
export const LANE_PAD_BOTTOM = 18;
/** Gap between the last lane and the first value line. */
export const SERIES_GAP = 18;
/** Height of one value line, label included. */
export const SERIES_HEIGHT = 60;
/** Label font size in the lanes, px. */
export const LABEL_FONT_PX = 11;
/** Estimated advance of one label character at 11 px, on the wide side. */
@@ -1051,143 +1055,6 @@ function placeLabels(
return labels;
}
/* ─── Value lines ─────────────────────────────────────────────────────────── */
export interface SeriesPointLayout {
/** The bucket's first day. */
t: string;
mean: number;
/** Readings behind the mean. */
count: number;
x: number;
y: number;
/** Fewer than {@link THIN_BUCKET_COUNT} readings: drawn hollow. */
thin: boolean;
}
export interface SeriesLayout {
key: string;
unit: string | null;
top: number;
height: number;
/** SVG path data of the solid runs; empty when no two points touch. */
path: string;
/** SVG path data of the dashed bridges over short gaps; may be empty. */
bridges: string;
/** The most recent mean inside the window, or null. */
latest: number | null;
points: SeriesPointLayout[];
}
/** A bucket mean from fewer readings than this is drawn hollow. */
export const THIN_BUCKET_COUNT = 3;
/**
* The most missing buckets a dashed bridge spans. Beyond it the line stops
* and starts again, so a long silence reads as one and not as a trend.
*
* The rule is one length, about six months between the two points a bridge
* joins, never more, and never more than a third of what the zoom shows:
* one missing quarter (the points are six months apart), two missing months
* in a year (three months apart, a sixth of the window) and four missing
* weeks in three months (five weeks apart, the window's third). A longer
* bridge would draw a slope through a stretch where nothing was measured.
*/
export const MAX_BRIDGED_GAP: Readonly<Record<TimelineBucket, number>> = {
quarter: 1,
month: 2,
week: 4,
// Days come only with a chosen range under six weeks. Three missing days
// (points four days apart) bridge a weekend away or a forgotten cuff;
// a longer silence in a window that short is a break worth seeing, and
// the bridge stays well under a third of even a two-week range.
day: 3,
};
/**
* One neutral line per series, scaled to its own range inside the window.
* A point sits in the middle of its bucket, and every bucket with a reading
* gets its point, so one month with no neighbours stays visible. Missing
* buckets stay missing: a short gap is bridged by a dashed stroke
* ({@link MAX_BRIDGED_GAP}) that joins the two real points and invents none
* between them, a longer one breaks the line.
*/
export function layoutSeries(
series: readonly TimelineSeries[],
top: number,
window: TimeWindow,
scale: Scale,
bucket: TimelineBucket,
): SeriesLayout[] {
return series.map((s, index) => {
const rowTop = top + index * SERIES_HEIGHT + 6;
const height = SERIES_HEIGHT - 16;
// A bucket that starts before the window still belongs to it while any
// of its days lie inside.
const inside = s.points
.filter((p) => p.t <= window.to && bucketAfter(p.t, bucket) > window.from)
.sort((a, b) => (a.t < b.t ? -1 : 1));
if (inside.length === 0) {
return {
key: s.key,
unit: s.unit,
top: rowTop,
height,
path: "",
bridges: "",
latest: null,
points: [],
};
}
let lo = Math.min(...inside.map((p) => p.mean));
let hi = Math.max(...inside.map((p) => p.mean));
const pad = Math.max((hi - lo) * 0.12, Math.abs(hi) * 0.01, 0.5);
lo -= pad;
hi += pad;
const y = (v: number) => rowTop + height * (1 - (v - lo) / (hi - lo));
const points = inside.map((p) => {
const mid = (dayNumber(p.t) + dayNumber(bucketAfter(p.t, bucket))) / 2;
const x = Math.min(scale.x1, Math.max(scale.x0, scale.x(mid)));
return {
t: p.t,
mean: p.mean,
count: p.count,
x,
y: y(p.mean),
thin: p.count < THIN_BUCKET_COUNT,
};
});
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, read by the renderer, not a person
const xy = (p: SeriesPointLayout) => `${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
let path = "";
let bridges = "";
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const cur = points[i];
const missing =
bucketIndex(cur.t, bucket) - bucketIndex(prev.t, bucket) - 1;
if (missing === 0) {
// Continue the run, or open a new one at the previous point.
path += path.endsWith(xy(prev))
? `L${xy(cur)}`
: `M${xy(prev)}L${xy(cur)}`;
} else if (missing <= MAX_BRIDGED_GAP[bucket]) {
bridges += `M${xy(prev)}L${xy(cur)}`;
}
}
return {
key: s.key,
unit: s.unit,
top: rowTop,
height,
path,
bridges,
latest: inside[inside.length - 1].mean,
points,
};
});
}
/* ─── Whole chart ─────────────────────────────────────────────────────────── */
export interface TimelineLayout {
@@ -1197,6 +1064,8 @@ export interface TimelineLayout {
lanes: LaneLayout[];
seriesTop: number;
series: SeriesLayout[];
/** The lab lines, below the value lines (#1299). */
labSeries: LabSeriesLayout[];
}
/** Width of the lane-name column at a given chart width. */
@@ -1209,6 +1078,8 @@ export function layoutTimeline(input: {
window: TimeWindow;
lanes: readonly TimelineLane[];
series: readonly TimelineSeries[];
/** Lab lines, drawn under the value lines; none by default. */
labSeries?: readonly TimelineLabSeries[];
bucket: TimelineBucket;
words: ItemWordsFn;
startMissing: string;
@@ -1231,7 +1102,9 @@ export function layoutTimeline(input: {
lanes.push(layout);
top += layout.height;
}
const seriesTop = top + (input.series.length > 0 ? SERIES_GAP : 0);
const labInput = input.labSeries ?? [];
const seriesTop =
top + (input.series.length + labInput.length > 0 ? SERIES_GAP : 0);
const series = layoutSeries(
input.series,
seriesTop,
@@ -1239,8 +1112,23 @@ export function layoutTimeline(input: {
scale,
input.bucket,
);
const height = seriesTop + series.length * SERIES_HEIGHT + 4;
return { width: input.width, height, scale, lanes, seriesTop, series };
const labSeries = layoutLabSeries(
labInput,
seriesTop + series.length * SERIES_HEIGHT,
input.window,
scale,
);
const height =
seriesTop + (series.length + labSeries.length) * SERIES_HEIGHT + 4;
return {
width: input.width,
height,
scale,
lanes,
seriesTop,
series,
labSeries,
};
}
/* ─── Interaction ─────────────────────────────────────────────────────────── */
@@ -1364,6 +1252,7 @@ export function latestDataDate(
timeline: {
lanes: readonly TimelineLane[];
series: readonly TimelineSeries[];
labSeries?: readonly TimelineLabSeries[];
},
today: string,
window: TimeWindow,
@@ -1391,5 +1280,9 @@ export function latestDataDate(
for (const series of timeline.series) {
for (const point of series.points) consider(point.t);
}
// A lab reading counts on its own day.
for (const series of timeline.labSeries ?? []) {
for (const point of series.points) consider(point.date);
}
return best;
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
/**
* The timeline's legend (v1.42, #613; lab marks #1299).
*/
import type { TimelineBucket, TimelineLabSeries } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
import { TIMELINE_LEGEND_MEAN_KEY } from "./label-keys";
import { LabExcludedNote } from "./lab-lines";
/**
* What the marks mean. Under the selection bar and set off from it by a
* hairline. With value lines, the two marks a line can carry besides its
* points are explained by an example of each: the dashed stroke over a short
* gap without measurements, and the hollow point of a mean resting on fewer
* than three.
*/
export function TimelineLegend({
bucket,
hasSeries,
labSeries = [],
label = (key) => key,
}: {
bucket: TimelineBucket;
hasSeries: boolean;
/**
* The lab lines drawn: their three marks are explained, and what a line
* left out is said here, where the marks are (#1299).
*/
labSeries?: readonly TimelineLabSeries[];
label?: (key: string) => string;
}) {
const { t } = useTranslations();
const hasLabs = labSeries.length > 0;
return (
<div
className="text-muted-foreground border-border flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t pt-3 text-xs"
data-slot="timeline-legend"
>
<span className="inline-flex items-center gap-1.5">
<span
className="bg-muted-foreground h-1.5 w-4 shrink-0 rounded-full"
aria-hidden="true"
/>
{t("timeline.legendSpan")}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="bg-muted-foreground size-2 shrink-0 rounded-full"
aria-hidden="true"
/>
{t("timeline.legendEvent")}
</span>
{hasSeries && (
<>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-mean"
data-bucket={bucket}
>
<span
className="bg-foreground h-0.5 w-4 shrink-0"
aria-hidden="true"
/>
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
</span>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-gap"
>
<svg
width="16"
height="8"
viewBox="0 0 16 8"
className="text-foreground"
aria-hidden="true"
>
<line
x1="1"
x2="15"
y1="4"
y2="4"
stroke="currentColor"
strokeWidth="1.2"
strokeDasharray="3 3"
strokeLinecap="round"
/>
</svg>
{t("timeline.legendGap")}
</span>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-thin"
>
<svg
width="8"
height="8"
viewBox="0 0 8 8"
className="text-foreground"
aria-hidden="true"
>
<circle
cx="4"
cy="4"
r="2.75"
fill="var(--card)"
stroke="currentColor"
strokeWidth="1.4"
/>
</svg>
{t("timeline.legendThin")}
</span>
</>
)}
{hasLabs && (
<>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-lab-value"
>
<svg
width="8"
height="8"
viewBox="0 0 8 8"
className="text-foreground"
aria-hidden="true"
>
<circle cx="4" cy="4" r="3.25" fill="currentColor" />
</svg>
{t("timeline.legendLabValue")}
</span>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-lab-outside"
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
className="text-foreground"
aria-hidden="true"
>
<path d="M5 0.5L9.5 8.5L0.5 8.5Z" fill="currentColor" />
</svg>
{t("timeline.legendLabOutside")}
</span>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-lab-range"
>
<svg
width="16"
height="8"
viewBox="0 0 16 8"
className="text-foreground"
aria-hidden="true"
>
<rect
x="0"
y="0.5"
width="16"
height="7"
fill="currentColor"
fillOpacity="0.1"
/>
<line
x1="0"
x2="16"
y1="0.5"
y2="0.5"
stroke="currentColor"
strokeOpacity="0.4"
strokeDasharray="3 3"
/>
<line
x1="0"
x2="16"
y1="7.5"
y2="7.5"
stroke="currentColor"
strokeOpacity="0.4"
strokeDasharray="3 3"
/>
</svg>
{t("timeline.legendLabRange")}
</span>
<LabExcludedNote labSeries={labSeries} label={label} />
</>
)}
</div>
);
}
+179 -33
View File
@@ -7,7 +7,9 @@
*
* The value menu is one compact button, "Values (3)". The chosen lines are
* not listed again beside it: each one is a named row under the lanes, and
* the menu marks it with its colour.
* the menu marks it with its colour. Below the measurements it offers the
* lab markers the record has values for (#1299), under the groups the labs
* list sorts them into; a lab line is chosen and stored like any other.
*/
import { ChartLine, ChevronDown, Layers } from "lucide-react";
@@ -16,44 +18,94 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { TimelineLaneKey } from "@/lib/day/contract";
import {
timelineLabMarkerId,
type TimelineLaneKey,
type TimelineResponse,
} from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
/** Lab markers under one group of the labs list (null: no group). */
export interface LabOptionGroup {
panel: string | null;
keys: readonly string[];
}
export function ValueSeriesMenu({
options,
labGroups = [],
selected,
label,
color,
onChange,
iconOnly = false,
}: {
options: readonly string[];
/** The record's lab markers, grouped as the labs list groups them. */
labGroups?: readonly LabOptionGroup[];
selected: readonly string[];
label: (key: string) => string;
/** A chosen line's colour, shown as a dot beside its name. */
color: (key: string) => string;
onChange: (next: string[]) => void;
/** The phone toolbar's square button, named by its label. */
iconOnly?: boolean;
}) {
const { t } = useTranslations();
const option = (key: string) => {
const checked = selected.includes(key);
return (
<DropdownMenuCheckboxItem
key={key}
checked={checked}
data-slot="timeline-values-option"
data-key={key}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(next) =>
onChange(
next ? [...selected, key] : selected.filter((k) => k !== key),
)
}
>
{/* The dot is the line's colour on the chart; an unchosen
value has no line yet, so its place stays empty. */}
<span
data-slot="timeline-values-dot"
className="size-2 shrink-0 rounded-full"
style={checked ? { background: color(key) } : undefined}
aria-hidden="true"
/>
{label(key)}
</DropdownMenuCheckboxItem>
);
};
const trigger = t("timeline.values.trigger", { count: selected.length });
// A group is named once there is more than one, or when it has a name.
const namedGroups =
labGroups.length > 1 || labGroups.some((g) => g.panel !== null);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
size={iconOnly ? "icon" : "sm"}
className={iconOnly ? "size-11" : "min-h-11 sm:min-h-9"}
aria-label={iconOnly ? trigger : undefined}
data-slot="timeline-values-trigger"
data-count={selected.length}
>
<ChartLine className="size-4" aria-hidden="true" />
{t("timeline.values.trigger", { count: selected.length })}
<ChevronDown className="size-4" aria-hidden="true" />
{!iconOnly && trigger}
{!iconOnly && <ChevronDown className="size-4" aria-hidden="true" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -62,33 +114,28 @@ export function ValueSeriesMenu({
data-slot="timeline-values-menu"
>
<DropdownMenuLabel>{t("timeline.values.menuTitle")}</DropdownMenuLabel>
{options.map((key) => {
const checked = selected.includes(key);
return (
<DropdownMenuCheckboxItem
key={key}
checked={checked}
data-slot="timeline-values-option"
data-key={key}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(next) =>
onChange(
next ? [...selected, key] : selected.filter((k) => k !== key),
)
}
>
{/* The dot is the line's colour on the chart; an unchosen
value has no line yet, so its place stays empty. */}
<span
data-slot="timeline-values-dot"
className="size-2 shrink-0 rounded-full"
style={checked ? { background: color(key) } : undefined}
aria-hidden="true"
/>
{label(key)}
</DropdownMenuCheckboxItem>
);
})}
{options.map(option)}
{labGroups.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel data-slot="timeline-values-labs">
{t("timeline.labs.menuTitle")}
</DropdownMenuLabel>
{labGroups.map((group) => (
<DropdownMenuGroup
key={group.panel ?? ""}
data-slot="timeline-values-lab-group"
>
{namedGroups && (
<DropdownMenuLabel className="text-muted-foreground pt-2 pb-1 text-xs font-medium tracking-wide uppercase">
{group.panel ?? t("timeline.labs.otherPanel")}
</DropdownMenuLabel>
)}
{group.keys.map(option)}
</DropdownMenuGroup>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -147,3 +194,102 @@ export function LayersMenu({
</DropdownMenu>
);
}
/**
* The lab markers of the menu, grouped as the labs list groups them: by the
* marker's group, named groups first, each in the reader's alphabetical
* order, markers inside a group likewise.
*/
export function labOptionGroups(
markers: ReadonlyArray<{ key: string; name: string; panel: string | null }>,
intl: string,
): LabOptionGroup[] {
const collator = new Intl.Collator(intl, { sensitivity: "base" });
const groups = new Map<string | null, typeof markers>();
const seen = new Set<string>();
for (const marker of markers) {
if (seen.has(marker.key)) continue;
seen.add(marker.key);
const panel = marker.panel?.trim() || null;
groups.set(panel, [...(groups.get(panel) ?? []), marker]);
}
return [...groups]
.sort(([a], [b]) =>
a === null ? 1 : b === null ? -1 : collator.compare(a, b),
)
.map(([panel, list]) => ({
panel,
keys: [...list]
.sort((a, b) => collator.compare(a.name, b.name))
.map((m) => m.key),
}));
}
/** Each lab marker's name by its key, from what the server sent. */
export function labMarkerNames(
timeline: Pick<TimelineResponse, "availableLabs" | "labSeries"> | undefined,
): Map<string, string> {
return new Map(
[...(timeline?.availableLabs ?? []), ...(timeline?.labSeries ?? [])].map(
(m) => [m.key, m.name],
),
);
}
/**
* The values menu as the page holds it: every series the record has, the
* chosen ones kept even when they have nothing yet, in `rank`'s order and
* then the record's; then the record's lab markers by group. It counts and
* toggles the lines it can name, so a stored marker the record no longer
* has drops out with the next change.
*/
export function TimelineValuesMenu({
timeline,
values,
allowed,
rank,
labsOn,
label,
color,
onChange,
iconOnly,
}: {
timeline: TimelineResponse | undefined;
values: readonly string[];
allowed: (key: string) => boolean;
rank: readonly string[];
labsOn: boolean;
label: (key: string) => string;
color: (key: string) => string;
onChange: (next: string[]) => void;
iconOnly: boolean;
}) {
const { locale } = useTranslations();
const options = [
...new Set([
...(timeline?.availableSeries ?? []).filter(allowed),
...values.filter((key) => timelineLabMarkerId(key) === null),
]),
].sort((a, b) => rank.indexOf(a) - rank.indexOf(b));
const markers = new Map(
[...(timeline?.availableLabs ?? []), ...(timeline?.labSeries ?? [])].map(
(m) => [m.key, m],
),
);
const labGroups = labsOn
? labOptionGroups([...markers.values()], resolveIntlLocale(locale))
: [];
return (
<ValueSeriesMenu
options={options}
labGroups={labGroups}
selected={values.filter(
(key) => timelineLabMarkerId(key) === null || markers.has(key),
)}
label={label}
color={color}
onChange={onChange}
iconOnly={iconOnly}
/>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
/**
* The timeline's chart as tables, for a screen reader (v1.42, #613): the
* lane entries with their dates, and the value lines' means per bucket.
* Both are visually hidden; the phone chronicle is the visible list form.
* The lab lines' table sits with the lab lines (`lab-lines.tsx`).
*/
import type {
TimelineBucket,
TimelineLaneKey,
TimelineResponse,
} from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
import { itemLine, useItemWords } from "./item-words";
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
import { medicationListItems } from "./medication-rows";
import { formatSeriesValue, type SeriesValueFormat } from "./series-format";
import {
bucketAfter,
bucketStart,
dayNumber,
formatAtPrecision,
formatBucket,
} from "./timeline-dates";
import { isSpan } from "./timeline-geometry";
/**
* The chart as a table, for a screen reader: every entry with its lane and
* dates. Visually hidden; the phone chronicle is the visible list form.
*/
export function TimelineTable({
timeline,
hiddenLanes,
intl,
}: {
timeline: TimelineResponse;
hiddenLanes: ReadonlySet<TimelineLaneKey>;
intl: string;
}) {
const { t } = useTranslations();
const words = useItemWords();
const rows = timeline.lanes
.filter((lane) => !hiddenLanes.has(lane.key))
.flatMap((lane) =>
(lane.key === "medications"
? medicationListItems(lane.items)
: lane.items
).map((item) => ({ lane: lane.key, item })),
)
.sort((a, b) => dayNumber(b.item.start) - dayNumber(a.item.start));
return (
// The table is hidden by a wrapper, never by `sr-only` on the table
// itself: `overflow: hidden` does not apply to a table box, so a
// visually hidden table still laid out its rows at full height under
// the page and made `<main>` scroll past its last card into nothing.
<div className="sr-only" data-slot="timeline-table-frame">
<table data-slot="timeline-table">
<caption>{t("timeline.table.caption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.lane")}</th>
<th scope="col">{t("timeline.table.entry")}</th>
<th scope="col">{t("timeline.table.start")}</th>
<th scope="col">{t("timeline.table.end")}</th>
</tr>
</thead>
<tbody>
{rows.map(({ lane, item }) => (
<tr key={`${lane}-${item.id}`}>
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
<td>{itemLine(item, words, t("timeline.startUnknown"))}</td>
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
<td>
{!item.open && isSpan(item) && item.end
? formatAtPrecision(item.end, item.precision, intl)
: ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/** "Januar 2026", "Jan bis März 2026", "5. Jan. bis 11. Jan. 2026". */
export function bucketText(
start: string,
bucket: TimelineBucket,
intl: string,
t: ReturnType<typeof useTranslations>["t"],
): string {
const label = formatBucket(start, bucket, intl);
return typeof label === "string"
? label
: t("timeline.selection.range", label);
}
/**
* The value lines as a table, for a screen reader: one row per bucket from
* the first to the last with a reading in the window, one column per
* series. A bucket without a reading says so; nothing is filled in.
*/
export function SeriesTable({
timeline,
window,
seriesLabel,
fmt,
intl,
}: {
timeline: TimelineResponse;
window: { from: string; to: string };
seriesLabel: (key: string) => string;
fmt: SeriesValueFormat;
intl: string;
}) {
const { t, tCount } = useTranslations();
const { bucket, series } = timeline;
const inWindow = (start: string) =>
start <= window.to && bucketAfter(start, bucket) > window.from;
const starts = series
.flatMap((s) => s.points.map((p) => p.t))
.filter(inWindow)
.sort();
if (starts.length === 0) return null;
const rows: string[] = [];
for (
let start = bucketStart(starts[0], bucket);
start <= starts[starts.length - 1];
start = bucketAfter(start, bucket)
) {
rows.push(start);
}
const byKey = new Map(
series.map((s) => [s.key, new Map(s.points.map((p) => [p.t, p]))]),
);
return (
// The table is hidden by a wrapper, never by `sr-only` on the table
// itself: `overflow: hidden` does not apply to a table box, so a
// visually hidden table still laid out its rows at full height under
// the page and made `<main>` scroll past its last card into nothing.
<div className="sr-only" data-slot="timeline-series-table-frame">
<table data-slot="timeline-series-table">
<caption>{t("timeline.table.seriesCaption")}</caption>
<thead>
<tr>
<th scope="col">{t("timeline.table.period")}</th>
{series.map((s) => (
<th key={s.key} scope="col">
{seriesLabel(s.key)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.reverse().map((start) => (
<tr key={start} data-bucket={start}>
<th scope="row">{bucketText(start, bucket, intl, t)}</th>
{series.map((s) => {
const point = byKey.get(s.key)?.get(start);
return (
<td key={s.key}>
{point
? `${formatSeriesValue(s.key, point.mean, s.unit, fmt)}, ${tCount("timeline.values.readings", point.count)}`
: t("timeline.values.noValue")}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
+85 -147
View File
@@ -14,6 +14,10 @@
* and Back closes it. An incoming `?day=` selects that day and zooms to
* three months, so a day's "In the timeline" lands on itself.
*
* Lab markers the record has values for are offered beside the measurements
* (#1299): a chosen marker is stored and requested like any other line, as
* `lab:<biomarkerId>`, and drawn as its single readings, never as means.
*
* At 768 px and up the chart; below it the chronicle, which is also the
* list form of the chart. The readiness card sits on top while a lane is
* thin and the person has not closed it; empty lanes are never drawn.
@@ -35,8 +39,8 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
DAY_QUERY_PARAM,
TIMELINE_ZOOMS,
timelineLabMarkerId,
type LifeEventDTO,
type TimelineBucket,
type TimelineLaneKey,
type TimelineResponse,
type TimelineZoom,
@@ -49,9 +53,10 @@ import type { ModuleKey } from "@/lib/modules/registry";
import type { ChronicleGrouping } from "./chronicle-model";
import {
TIMELINE_BLOOD_PRESSURE_SERIES_KEY,
TIMELINE_LEGEND_MEAN_KEY,
TIMELINE_ZOOM_LABEL_KEY,
} from "./label-keys";
import { LabExcludedNote } from "./lab-lines";
import { TimelineLegend } from "./timeline-legend";
import { LifeEventSheet } from "./life-event-sheet";
import { ReadinessCard } from "./readiness-card";
import {
@@ -78,7 +83,11 @@ import {
type TimelineRange,
} from "./timeline-url";
import { LANE_ORDER, latestDataDate, windowFor } from "./timeline-geometry";
import { LayersMenu, ValueSeriesMenu } from "./timeline-menus";
import {
LayersMenu,
TimelineValuesMenu,
labMarkerNames,
} from "./timeline-menus";
import {
isBoolean,
isStringArray,
@@ -140,13 +149,56 @@ function timelineIsEmpty(data: TimelineResponse): boolean {
return (
data.lanes.every((l) => l.items.length === 0) &&
data.standing.length === 0 &&
data.series.every((s) => s.points.length === 0)
data.series.every((s) => s.points.length === 0) &&
data.labSeries.every((s) => s.points.length === 0)
);
}
const VALUES_KEY = "healthlog.timeline.values";
const HIDDEN_LANES_KEY = "healthlog.timeline.hiddenLanes";
/**
* The value lines asked for: the stored choice, minus anything unknown and
* minus a line whose module is off, and one colour per chosen line.
*/
function useValueLines(
modules: Partial<Record<ModuleKey, boolean>> | undefined,
) {
const [storedValues, setStoredValues] = useDevicePreference(
VALUES_KEY,
DEFAULT_VALUES,
isStringArray,
);
// What is asked for: the stored choice, minus anything unknown and minus
// a line whose module is off. The server narrows it further to what the
// caller may see.
const allowed = useCallback(
(key: string) => {
// A lab line belongs to the labs module; whether the record has the
// marker is the server's to say.
if (timelineLabMarkerId(key) !== null) return modules?.labs !== false;
if (!KNOWN_SERIES.has(key)) return false;
const owner: ModuleKey | null | undefined =
VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE];
return !owner || modules?.[owner] !== false;
},
[modules],
);
const values = useMemo(
() => storedValues.filter(allowed),
[storedValues, allowed],
);
// One colour per chosen line, shared by the chart, the selection bar, the
// menu and the phone chronicle; contested colours go by the menu's order.
const seriesColors = useMemo(
() => assignSeriesColors(values, SERIES_RANK),
[values],
);
const seriesColor = (key: string) =>
seriesColors.get(key) ?? SERIES_PALETTE[0];
return { values, allowed, setStoredValues, seriesColor };
}
export function TimelineView() {
const { t } = useTranslations();
const { user } = useAuth();
@@ -183,11 +235,6 @@ export function TimelineView() {
const [lifeEventOpen, setLifeEventOpen] = useState(false);
const [editing, setEditing] = useState<LifeEventDTO | null>(null);
const [storedValues, setStoredValues] = useDevicePreference(
VALUES_KEY,
DEFAULT_VALUES,
isStringArray,
);
const [hiddenList, setHiddenList] = useDevicePreference<string[]>(
HIDDEN_LANES_KEY,
[],
@@ -200,31 +247,9 @@ export function TimelineView() {
);
const modules = user?.modules;
// What is asked for: the stored choice, minus anything unknown and minus
// a line whose module is off. The server narrows it further to what the
// caller may see.
const allowed = useCallback(
(key: string) => {
if (!KNOWN_SERIES.has(key)) return false;
const owner: ModuleKey | null | undefined =
VALUE_OPTION_MODULE[key as keyof typeof VALUE_OPTION_MODULE];
return !owner || modules?.[owner] !== false;
},
[modules],
);
const values = useMemo(
() => storedValues.filter(allowed),
[storedValues, allowed],
);
// One colour per chosen line, shared by the chart, the selection bar, the
// menu and the phone chronicle; contested colours go by the menu's order.
const seriesColors = useMemo(
() => assignSeriesColors(values, SERIES_RANK),
[values],
);
const { values, allowed, setStoredValues, seriesColor } =
useValueLines(modules);
const seriesFormat = useSeriesValueFormat();
const seriesColor = (key: string) =>
seriesColors.get(key) ?? SERIES_PALETTE[0];
const hidden = useMemo(
() => new Set(hiddenList as TimelineLaneKey[]),
[hiddenList],
@@ -334,14 +359,17 @@ export function TimelineView() {
setLifeEventOpen(true);
}
const labNames = labMarkerNames(data);
// Blood pressure reads as its own name here; the list labels ("Sys",
// "Dia") lean on a column header the chart does not have.
// "Dia") lean on a column header the chart does not have. A lab marker
// reads by its own name, as on the labs list.
const seriesLabel = (key: string) =>
key === "BLOOD_PRESSURE_SYS" || key === "BLOOD_PRESSURE_DIA"
? t(TIMELINE_BLOOD_PRESSURE_SERIES_KEY[key])
: key === "MOOD"
? t("timeline.readiness.lanes.mood")
: t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key);
: (labNames.get(key) ??
t(MEASUREMENT_TYPE_LABEL_KEYS[key as MeasurementType] ?? key));
const shownWindow = data
? windowFor(
@@ -367,12 +395,19 @@ export function TimelineView() {
today,
data?.range.dataFrom ?? null,
);
// The selector offers every series the record has, the chosen ones kept
// even when they have nothing yet, in the selector's order and then the
// record's.
const valueOptions = [
...new Set([...(data?.availableSeries ?? []).filter(allowed), ...values]),
].sort((a, b) => SERIES_RANK.indexOf(a) - SERIES_RANK.indexOf(b));
const valuesMenu = (iconOnly: boolean) => (
<TimelineValuesMenu
timeline={data}
values={values}
allowed={allowed}
rank={SERIES_RANK}
labsOn={modules?.labs !== false}
label={seriesLabel}
color={seriesColor}
onChange={setStoredValues}
iconOnly={iconOnly}
/>
);
const presentLanes = (data?.lanes ?? [])
.filter((l) => l.items.length > 0)
.map((l) => l.key)
@@ -532,13 +567,7 @@ export function TimelineView() {
onApply={(next) => navigate("range", next)}
/>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<ValueSeriesMenu
options={valueOptions}
selected={values}
label={seriesLabel}
color={seriesColor}
onChange={setStoredValues}
/>
{valuesMenu(false)}
{layers(false)}
</div>
</div>
@@ -571,6 +600,8 @@ export function TimelineView() {
<TimelineLegend
bucket={data.bucket}
hasSeries={data.series.length > 0}
labSeries={data.labSeries}
label={seriesLabel}
/>
</CardContent>
</Card>
@@ -593,6 +624,7 @@ export function TimelineView() {
slot="timeline-grouping"
/>
<div className="flex items-center gap-2">
{valuesMenu(true)}
<Button
variant={zoom === "range" ? "secondary" : "outline"}
size="icon"
@@ -616,6 +648,11 @@ export function TimelineView() {
onApply={(next) => navigate("range", next)}
onClear={zoom === "range" ? () => setZoom("all") : null}
/>
<LabExcludedNote
labSeries={data.labSeries}
label={seriesLabel}
block
/>
<TimelineChronicle
timeline={{
...data,
@@ -648,102 +685,3 @@ export function TimelineView() {
</div>
);
}
/**
* What the marks mean. Under the selection bar and set off from it by a
* hairline. With value lines, the two marks a line can carry besides its
* points are explained by an example of each: the dashed stroke over a short
* gap without measurements, and the hollow point of a mean resting on fewer
* than three.
*/
export function TimelineLegend({
bucket,
hasSeries,
}: {
bucket: TimelineBucket;
hasSeries: boolean;
}) {
const { t } = useTranslations();
return (
<div
className="text-muted-foreground border-border flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t pt-3 text-xs"
data-slot="timeline-legend"
>
<span className="inline-flex items-center gap-1.5">
<span
className="bg-muted-foreground h-1.5 w-4 shrink-0 rounded-full"
aria-hidden="true"
/>
{t("timeline.legendSpan")}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="bg-muted-foreground size-2 shrink-0 rounded-full"
aria-hidden="true"
/>
{t("timeline.legendEvent")}
</span>
{hasSeries && (
<>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-mean"
data-bucket={bucket}
>
<span
className="bg-foreground h-0.5 w-4 shrink-0"
aria-hidden="true"
/>
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
</span>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-gap"
>
<svg
width="16"
height="8"
viewBox="0 0 16 8"
className="text-foreground"
aria-hidden="true"
>
<line
x1="1"
x2="15"
y1="4"
y2="4"
stroke="currentColor"
strokeWidth="1.2"
strokeDasharray="3 3"
strokeLinecap="round"
/>
</svg>
{t("timeline.legendGap")}
</span>
<span
className="inline-flex items-center gap-1.5"
data-slot="timeline-legend-thin"
>
<svg
width="8"
height="8"
viewBox="0 0 8 8"
className="text-foreground"
aria-hidden="true"
>
<circle
cx="4"
cy="4"
r="2.75"
fill="var(--card)"
stroke="currentColor"
strokeWidth="1.4"
/>
</svg>
{t("timeline.legendThin")}
</span>
</>
)}
</div>
);
}
+28
View File
@@ -425,6 +425,34 @@ export type TimelineResponse = z.infer<typeof timelineResponseSchema>;
export type TimelineLane = TimelineResponse["lanes"][number];
export type TimelineItem = TimelineLane["items"][number];
export type TimelineSeries = TimelineResponse["series"][number];
export type TimelineLabSeries = TimelineResponse["labSeries"][number];
export type TimelineLabPoint = TimelineLabSeries["points"][number];
export type TimelineLabMarker = TimelineResponse["availableLabs"][number];
/**
* A lab marker's line is named in `values` as `lab:<biomarkerId>` (#1299),
* beside the measurement types and `MOOD`, so one choice, one request and
* one stored list carry every line under the lanes.
*/
export const TIMELINE_LAB_KEY_PREFIX = "lab:";
/**
* The most lab markers one request names. Measurement names are a closed
* set and bound themselves; marker ids are not.
*/
export const TIMELINE_LAB_SERIES_MAX = 24;
/** The marker id a `values` entry names, or null when it names no marker. */
export function timelineLabMarkerId(key: string): string | null {
if (!key.startsWith(TIMELINE_LAB_KEY_PREFIX)) return null;
const id = key.slice(TIMELINE_LAB_KEY_PREFIX.length);
return /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
}
/** The `values` entry for a lab marker. */
export function timelineLabKey(biomarkerId: string): string {
return `${TIMELINE_LAB_KEY_PREFIX}${biomarkerId}`;
}
/** `GET /api/timeline/readiness` → `data`. */
export type TimelineReadinessResponse = z.infer<
+95 -1
View File
@@ -370,7 +370,7 @@ export const timelineQuerySchema = z
.string()
.optional()
.describe(
"Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts once, so the list is bounded by the names that exist; an unknown name answers 422.",
"Comma-separated value series to include, as `MeasurementType` names, `MOOD`, or a lab marker as `lab:<biomarkerId>` (see `availableLabs`). A name given twice counts once, so the list is bounded by the names that exist; an unknown measurement name answers 422, and so do more than 24 lab markers (ids, unlike names, are not a closed set). A lab marker that is not in the record (deleted, or another record's) is left out of `labSeries` without an error, so a stored choice never breaks the page.",
),
})
.refine((q) => q.zoom !== "range" || (q.from && q.to), {
@@ -466,6 +466,90 @@ const timelineSeries = z
})
.meta({ id: "TimelineSeries" });
const labRangeStatus = z
.enum(["in-range", "below", "above", "unknown"])
.describe(
"The reading against `referenceLow` / `referenceHigh`, bounds inclusive; `unknown` when neither is known.",
);
const timelineLabPoint = z
.object({
id: z.string().describe("The lab result's id."),
date: dateKeySchema.describe(
"The record's local day of the draw, the day `GET /api/day/{date}` lists it under.",
),
value: z.number().describe("In the series' `unit`."),
sourceValue: z
.number()
.nullable()
.describe(
"The figure the report printed, when `value` was converted into the series' unit; it sets the precision `value` is shown with. Null for a reading stored as stated.",
),
rangeStatus: labRangeStatus,
referenceLow: z
.number()
.nullable()
.describe(
"The window this reading is judged against, in the series' unit: the one its report printed, else the marker's own.",
),
referenceHigh: z.number().nullable(),
report: z
.object({
id: z.string(),
title: z
.string()
.nullable()
.describe("The document's title, else its file name."),
href: z.string(),
})
.nullable()
.describe(
"The document the reading was read from, when one is linked and the caller may see documents.",
),
})
.meta({ id: "TimelineLabPoint" });
const timelineLabSeries = z
.object({
key: z.string().describe("`lab:<biomarkerId>`, as named in `values`."),
biomarkerId: z.string(),
name: z.string().describe("The marker's name, as the labs list shows it."),
panel: z.string().nullable(),
unit: z.string().describe("The marker's own unit; every point is in it."),
band: z
.object({ low: z.number().nullable(), high: z.number().nullable() })
.nullable()
.describe(
"The reference range to draw behind the points: the marker's own, else the window printed with its newest reading in the window. Null when neither is known.",
),
points: z
.array(timelineLabPoint)
.describe(
"Every numeric reading in the window, oldest first, one point each: never averaged, never interpolated. A day with two readings has two points.",
),
excluded: z
.number()
.int()
.min(0)
.describe(
"Readings in the window stored in a unit that does not convert into `unit`. They are left out of `points` rather than drawn on a scale they are not in.",
),
})
.meta({ id: "TimelineLabSeries" });
const timelineLabMarker = z
.object({
key: z.string().describe("`lab:<biomarkerId>`, to name in `values`."),
biomarkerId: z.string(),
name: z.string(),
panel: z
.string()
.nullable()
.describe("The marker's group, as the labs list shows it."),
unit: z.string(),
})
.meta({ id: "TimelineLabMarker" });
export const timelineResponseSchema = z
.object({
zoom: z.enum(TIMELINE_ZOOMS),
@@ -496,6 +580,16 @@ export const timelineResponseSchema = z
.describe(
"Every value series this record can show, whatever the window: the `MeasurementType` names with live readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has entries. Each may be named in `values`.",
),
labSeries: z
.array(timelineLabSeries)
.describe(
"The lab markers named in `values`, in the order named, each with its single readings in the window. A marker named but not found in the record is left out.",
),
availableLabs: z
.array(timelineLabMarker)
.describe(
"Every lab marker this record has a numeric reading for, whatever the window, when the caller may see labs. Each may be named in `values` by its `key`.",
),
notable: z.array(
z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }),
),
@@ -0,0 +1,283 @@
/**
* Lab markers as lines on the timeline (#1299): the shape of one marker's
* line, and the `values` entries that name one.
*
* A lab line is its readings, one point each, never a mean; a reading in a
* unit that converts is drawn converted, one that does not is counted and
* left out; each point is judged against its own window and the band behind
* the line is the marker's own range. All data invented.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
import {
TIMELINE_LAB_SERIES_MAX,
timelineLabMarkerId,
} from "@/lib/day/contract";
import { parseSeriesKeys } from "../load-timeline";
import {
shapeLabSeries,
type LabLineMarker,
type LabLineRow,
} from "../lab-series";
const FERRITIN: LabLineMarker = {
id: "bm1",
name: "Ferritin",
unit: "ng/mL",
lowerBound: 30,
upperBound: 400,
panel: "Iron",
analyteKey: "ferritin",
};
const GLUCOSE: LabLineMarker = {
id: "bm2",
name: "Fasting glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 99,
panel: null,
analyteKey: "fasting-glucose",
};
function row(over: Partial<LabLineRow> & Pick<LabLineRow, "id">): LabLineRow {
return {
analyte: "Ferritin",
panel: null,
value: 100,
unit: "ng/mL",
sourceValue: null,
sourceUnit: null,
referenceLow: null,
referenceHigh: null,
sourceReferenceLow: null,
sourceReferenceHigh: null,
sourceReferenceText: null,
biomarkerId: "bm1",
takenAt: new Date("2025-03-10T07:30:00.000Z"),
report: null,
...over,
};
}
const WINDOW = { from: "2025-01-01", to: "2025-12-31", tz: "Europe/Berlin" };
describe("a lab marker's line", () => {
it("is its readings, one point each, oldest first, never a mean", () => {
const line = shapeLabSeries(
FERRITIN,
[
row({ id: "r3", value: 52, takenAt: new Date("2025-11-02T08:00:00Z") }),
row({ id: "r1", value: 41, takenAt: new Date("2025-02-03T08:00:00Z") }),
// Two draws on one day stay two points.
row({
id: "r2a",
value: 44,
takenAt: new Date("2025-02-20T07:00:00Z"),
}),
row({
id: "r2b",
value: 46,
takenAt: new Date("2025-02-20T15:00:00Z"),
}),
],
WINDOW,
);
expect(line.key).toBe("lab:bm1");
expect(line.unit).toBe("ng/mL");
expect(line.points.map((p) => [p.id, p.date, p.value])).toEqual([
["r1", "2025-02-03", 41],
["r2a", "2025-02-20", 44],
["r2b", "2025-02-20", 46],
["r3", "2025-11-02", 52],
]);
expect(line.excluded).toBe(0);
});
it("dates a reading by the record's local day, as the day view lists it", () => {
// 23:30 UTC on 31 Dec is already 1 Jan in Berlin.
const line = shapeLabSeries(
FERRITIN,
[
row({ id: "late", takenAt: new Date("2024-12-31T23:30:00Z") }),
row({ id: "out", takenAt: new Date("2025-12-31T23:30:00Z") }),
],
WINDOW,
);
expect(line.points.map((p) => [p.id, p.date])).toEqual([
["late", "2025-01-01"],
]);
});
it("judges each reading against its own printed window, else the marker's", () => {
const line = shapeLabSeries(
FERRITIN,
[
row({ id: "low", value: 12 }),
row({
id: "printed",
value: 420,
takenAt: new Date("2025-04-01T08:00:00Z"),
sourceReferenceLow: 20,
sourceReferenceHigh: 500,
}),
row({
id: "high",
value: 450,
takenAt: new Date("2025-05-01T08:00:00Z"),
}),
],
WINDOW,
);
expect(
line.points.map((p) => [
p.id,
p.rangeStatus,
p.referenceLow,
p.referenceHigh,
]),
).toEqual([
["low", "below", 30, 400],
// In range against the window its own report printed.
["printed", "in-range", 20, 500],
["high", "above", 30, 400],
]);
// The band behind the line is the marker's own range.
expect(line.band).toEqual({ low: 30, high: 400 });
});
it("falls back to the newest reading's window when the marker has no range", () => {
const line = shapeLabSeries(
{ ...FERRITIN, lowerBound: null, upperBound: null },
[
row({ id: "a", sourceReferenceLow: 10, sourceReferenceHigh: 300 }),
row({
id: "b",
takenAt: new Date("2025-06-01T08:00:00Z"),
sourceReferenceLow: 15,
sourceReferenceHigh: 150,
}),
],
WINDOW,
);
expect(line.band).toEqual({ low: 15, high: 150 });
const bare = shapeLabSeries(
{ ...FERRITIN, lowerBound: null, upperBound: null },
[row({ id: "a" })],
WINDOW,
);
expect(bare.band).toBeNull();
expect(bare.points[0].rangeStatus).toBe("unknown");
});
it("converts a reading in another unit that converts, window and all", () => {
const line = shapeLabSeries(
GLUCOSE,
[
row({
id: "mmol",
analyte: "Fasting glucose",
biomarkerId: "bm2",
value: 5.5,
unit: "mmol/L",
sourceReferenceLow: 3.9,
sourceReferenceHigh: 5.5,
}),
// The same unit spelled another way is the same unit.
row({
id: "spelled",
analyte: "Fasting glucose",
biomarkerId: "bm2",
value: 92,
unit: "mg/dl",
takenAt: new Date("2025-06-01T08:00:00Z"),
}),
],
WINDOW,
);
const [mmol, spelled] = line.points;
expect(mmol.value).toBeCloseTo(99.09, 1);
// The figure as stored sets the precision the converted one shows with.
expect(mmol.sourceValue).toBe(5.5);
expect(mmol.referenceLow).toBeCloseTo(70.26, 1);
expect(mmol.referenceHigh).toBeCloseTo(99.09, 1);
expect(mmol.rangeStatus).toBe("in-range");
expect(spelled).toMatchObject({ value: 92, sourceValue: null });
expect(line.excluded).toBe(0);
});
it("leaves out and counts a reading whose unit does not convert", () => {
// Ferritin has no molar conversion: pmol/L is never drawn on a ng/mL scale.
const line = shapeLabSeries(
FERRITIN,
[
row({ id: "ok", value: 80 }),
row({
id: "molar",
value: 180,
unit: "pmol/L",
takenAt: new Date("2025-06-01T08:00:00Z"),
}),
],
WINDOW,
);
expect(line.points.map((p) => p.id)).toEqual(["ok"]);
expect(line.excluded).toBe(1);
});
it("links the report a reading was read from", () => {
const line = shapeLabSeries(
FERRITIN,
[
row({ id: "doc", report: { id: "d1", title: "Lab report March" } }),
row({ id: "none", takenAt: new Date("2025-04-01T08:00:00Z") }),
],
WINDOW,
);
expect(line.points[0].report).toEqual({
id: "d1",
title: "Lab report March",
href: "/documents?doc=d1",
});
expect(line.points[1].report).toBeNull();
});
it("has no points, and says so, when the window holds no reading", () => {
const line = shapeLabSeries(FERRITIN, [], WINDOW);
expect(line.points).toEqual([]);
expect(line.excluded).toBe(0);
expect(line.band).toEqual({ low: 30, high: 400 });
});
});
describe("a lab marker in `values`", () => {
it("is named `lab:<biomarkerId>`, nothing else", () => {
expect(timelineLabMarkerId("lab:ckx1abc")).toBe("ckx1abc");
expect(timelineLabMarkerId("WEIGHT")).toBeNull();
expect(timelineLabMarkerId("lab:")).toBeNull();
expect(timelineLabMarkerId("lab:a,b")).toBeNull();
expect(timelineLabMarkerId("lab:../x")).toBeNull();
});
it("rides beside measurement names, and an unknown name still refuses", () => {
expect(parseSeriesKeys("WEIGHT,lab:bm1,lab:bm1,MOOD")).toEqual([
"WEIGHT",
"lab:bm1",
"MOOD",
]);
expect(parseSeriesKeys("lab:bm1,NOPE")).toBeNull();
expect(parseSeriesKeys("lab:../etc")).toBeNull();
});
it("is capped, because marker ids are not a closed set", () => {
const many = (n: number) =>
Array.from({ length: n }, (_, i) => `lab:m${i}`).join(",");
expect(parseSeriesKeys(many(TIMELINE_LAB_SERIES_MAX))).toHaveLength(
TIMELINE_LAB_SERIES_MAX,
);
expect(parseSeriesKeys(many(TIMELINE_LAB_SERIES_MAX + 1))).toBeNull();
});
});
+316
View File
@@ -0,0 +1,316 @@
/**
* Lab markers as lines on the timeline (#1299).
*
* A measurement line is a mean per bucket; a lab marker is a handful of
* readings a year, and a mean of them would invent a value nobody measured.
* So a lab line is its readings, one point each, on the day of the draw. The
* day is the record's local day of `takenAt`, the same cut the labs lane and
* the day view make, so a point opens the day that lists it.
*
* Only markers linked to a `Biomarker` are offered: every write links one,
* the backfill links the rest, and the labs list itself shows an unlinked
* group as not yet linked. A marker is read through `resolveLabFields`, the
* resolver every lab surface shares, so its name, unit, panel and the window
* each reading is judged against are the labs page's own.
*
* Units. A reading stored since v1.40 is in its marker's unit by
* construction. Older ones were linked by the backfill under the unit of the
* group's newest reading, and a marker's unit can be edited after its
* readings were written, so a row's own `unit` can still differ from the
* marker's. Such a row is converted through the unit-conversion registry
* when the pair is known (its printed window with it) and otherwise left out
* and counted in `excluded`: it is never drawn on a scale it is not in.
*/
import {
classifyAgainstEffectiveRange,
resolveEffectiveReferenceRange,
} from "@/lib/labs/reference-range";
import { resolveLabFields, type LabFieldRow } from "@/lib/labs/serialise";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import {
convertLabRange,
convertLabValue,
} from "@/lib/labs/unit-conversion/convert";
import {
timelineLabKey,
type TimelineLabMarker,
type TimelineLabPoint,
type TimelineLabSeries,
} from "@/lib/day/contract";
import { prisma } from "@/lib/db";
import { listTargetsBySource } from "@/lib/links/link-service";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { laneVisible, type TimelineAccess } from "./lanes";
/** The marker columns a lab line needs. */
export interface LabLineMarker {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
analyteKey: string | null;
}
/** One numeric reading of a marker, with the report it came from. */
export interface LabLineRow extends LabFieldRow {
id: string;
value: number;
sourceValue: number | null;
takenAt: Date;
report: { id: string; title: string | null } | null;
}
/** A reading in the marker's unit, or null when its unit does not convert. */
function inMarkerUnit(
row: LabLineRow,
marker: LabLineMarker,
): Omit<TimelineLabPoint, "id" | "date" | "report"> | null {
if (sameLabUnit(row.unit, marker.unit)) {
const fields = resolveLabFields(row, marker);
return {
value: row.value,
sourceValue: row.sourceValue,
rangeStatus: classifyAgainstEffectiveRange(
row.value,
fields.effectiveRange,
),
referenceLow: fields.referenceLow,
referenceHigh: fields.referenceHigh,
};
}
const converted = convertLabValue({
analyteKey: marker.analyteKey,
value: row.value,
from: row.unit,
to: marker.unit,
});
if (!converted.ok) return null;
// The printed window is in the row's unit; it moves with the value or is
// dropped (the marker's own band then judges the reading).
const printed = convertLabRange({
analyteKey: marker.analyteKey,
low: row.sourceReferenceLow,
high: row.sourceReferenceHigh,
from: row.unit,
to: marker.unit,
});
const effective = resolveEffectiveReferenceRange(
marker.lowerBound,
marker.upperBound,
{
sourceReferenceLow: printed.ok ? printed.low : null,
sourceReferenceHigh: printed.ok ? printed.high : null,
sourceReferenceText: null,
sourceUnit: row.unit,
},
);
return {
value: converted.value,
// The figure as stored sets the precision the converted one is shown with.
sourceValue: row.value,
rangeStatus: classifyAgainstEffectiveRange(converted.value, effective),
referenceLow: effective.low,
referenceHigh: effective.high,
};
}
/**
* One marker's line over `[from, to]` from its numeric readings (any order):
* a point per reading inside the window, oldest first, the readings whose
* unit does not convert counted, and the band to draw behind them.
*/
export function shapeLabSeries(
marker: LabLineMarker,
rows: readonly LabLineRow[],
window: { from: string; to: string; tz: string },
): TimelineLabSeries {
const drawn: Array<{ point: TimelineLabPoint; at: number }> = [];
let excluded = 0;
for (const row of rows) {
const date = userDayKey(row.takenAt, window.tz);
if (date < window.from || date > window.to) continue;
const reading = inMarkerUnit(row, marker);
if (!reading) {
excluded += 1;
continue;
}
drawn.push({
point: {
id: row.id,
date,
...reading,
report: row.report
? {
id: row.report.id,
title: row.report.title,
href: `/documents?doc=${row.report.id}`,
}
: null,
},
at: row.takenAt.getTime(),
});
}
const points = drawn
.sort((a, b) => a.at - b.at || (a.point.id < b.point.id ? -1 : 1))
.map((d) => d.point);
const newest = points.at(-1);
const band =
marker.lowerBound !== null || marker.upperBound !== null
? { low: marker.lowerBound, high: marker.upperBound }
: newest &&
(newest.referenceLow !== null || newest.referenceHigh !== null)
? { low: newest.referenceLow, high: newest.referenceHigh }
: null;
return {
key: timelineLabKey(marker.id),
biomarkerId: marker.id,
name: marker.name,
panel: marker.panel,
unit: marker.unit,
band,
points,
excluded,
};
}
const MARKER_SELECT = {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
analyteKey: true,
} as const;
/**
* The lines of the markers `ids` names, in that order, over `[from, to]`.
* Every read is narrowed to the record: an id of another record's marker
* finds nothing and is left out, as is a marker deleted since it was chosen.
* The report behind a reading is read only when the caller may see
* documents.
*/
export async function loadLabSeries(args: {
recordId: string;
ids: readonly string[];
from: string;
to: string;
tz: string;
access: TimelineAccess;
}): Promise<TimelineLabSeries[]> {
const { recordId, ids, from, to, tz, access } = args;
if (ids.length === 0 || !laneVisible("labs", access)) return [];
const markers = await prisma.biomarker.findMany({
where: { userId: recordId, id: { in: [...ids] } },
select: MARKER_SELECT,
});
if (markers.length === 0) return [];
const rows = await prisma.labResult.findMany({
where: {
userId: recordId,
biomarkerId: { in: markers.map((m) => m.id) },
deletedAt: null,
value: { not: null },
takenAt: {
gte: startOfLocalDayKey(from, tz),
lt: startOfLocalDayKey(shiftDateKey(to, 1), tz),
},
},
select: {
id: true,
biomarkerId: true,
analyte: true,
panel: true,
value: true,
unit: true,
sourceValue: true,
sourceUnit: true,
referenceLow: true,
referenceHigh: true,
sourceReferenceLow: true,
sourceReferenceHigh: true,
sourceReferenceText: true,
takenAt: true,
},
orderBy: [{ takenAt: "asc" }, { id: "asc" }],
});
// A reading read from two documents names the first one linked. The link
// service drops a deleted document and narrows every read to the record.
const reportOf = new Map<string, { id: string; title: string | null }>();
if (rows.length > 0 && laneVisible("documents", access)) {
const linked = await listTargetsBySource(prisma, {
userId: recordId,
sourceKind: "labResult",
sourceIds: rows.map((r) => r.id),
targetKind: "document",
});
for (const [labResultId, [first]] of linked) {
if (!first) continue;
// A document with neither title nor file name is labelled by its id.
reportOf.set(labResultId, {
id: first.id,
title: first.label === first.id ? null : first.label,
});
}
}
const byMarker = new Map<string, LabLineRow[]>();
for (const row of rows) {
if (row.biomarkerId === null || row.value === null) continue;
const list = byMarker.get(row.biomarkerId) ?? [];
list.push({
...row,
value: row.value,
report: reportOf.get(row.id) ?? null,
});
byMarker.set(row.biomarkerId, list);
}
const found = new Map(markers.map((m) => [m.id, m]));
return ids.flatMap((id) => {
const marker = found.get(id);
return marker
? [shapeLabSeries(marker, byMarker.get(id) ?? [], { from, to, tz })]
: [];
});
}
/**
* Every marker the record has a numeric reading for, whatever the window,
* by name. A marker with only qualitative readings ("negative") has nothing
* to draw and is not offered.
*/
export async function loadAvailableLabMarkers(args: {
recordId: string;
access: TimelineAccess;
}): Promise<TimelineLabMarker[]> {
const { recordId, access } = args;
if (!laneVisible("labs", access)) return [];
const groups = await prisma.labResult.groupBy({
by: ["biomarkerId"],
where: {
userId: recordId,
deletedAt: null,
value: { not: null },
biomarkerId: { not: null },
},
});
const ids = groups
.map((g) => g.biomarkerId)
.filter((id): id is string => id !== null);
if (ids.length === 0) return [];
const markers = await prisma.biomarker.findMany({
where: { userId: recordId, id: { in: ids } },
select: { id: true, name: true, panel: true, unit: true },
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return markers.map((m) => ({
key: timelineLabKey(m.id),
biomarkerId: m.id,
name: m.name,
panel: m.panel,
unit: m.unit,
}));
}
+56 -31
View File
@@ -18,7 +18,9 @@ import type { MeasurementType } from "@/generated/prisma/enums";
import {
DAY_NOTABLE_MAX_SPAN_DAYS,
TIMELINE_LAB_SERIES_MAX,
TIMELINE_LANE_KEYS,
timelineLabMarkerId,
type TimelineBucket,
type TimelineItem,
type TimelineQuery,
@@ -36,6 +38,10 @@ import {
type StandingItem,
type TimelineAccess,
} from "@/lib/timeline/lanes";
import {
loadAvailableLabMarkers,
loadLabSeries,
} from "@/lib/timeline/lab-series";
import { loadTimelineSeries, MOOD_SERIES_KEY } from "@/lib/timeline/series";
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
@@ -107,8 +113,12 @@ const MEASUREMENT_TYPE_SET: ReadonlySet<string> = new Set(
/**
* The series keys a `values` parameter names, or null when one is not a
* series this API knows (the route answers 422). There is no cap on the
* count: a name given twice counts once, so the list never grows past the
* names that exist, and each series is its own row under the lanes.
* measurement names: a name given twice counts once, so the list never
* grows past the names that exist, and each series is its own row under the
* lanes. A lab marker (`lab:<biomarkerId>`, #1299) is checked for its shape
* only: whether the record has it is the loader's question, and the answer
* to a stale one is no line rather than an error. Marker ids are not a
* closed set, so their count is capped.
*/
export function parseSeriesKeys(raw: string | undefined): string[] | null {
if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES];
@@ -120,10 +130,15 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null {
.filter((k) => k.length > 0),
),
];
let labs = 0;
for (const key of keys) {
if (timelineLabMarkerId(key) !== null) {
labs += 1;
continue;
}
if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null;
}
return keys;
return labs > TIMELINE_LAB_SERIES_MAX ? null : keys;
}
/**
@@ -246,11 +261,16 @@ export async function loadTimeline(args: {
standing.push(...reads[i].standing);
});
const labIds = args.seriesKeys
.map(timelineLabMarkerId)
.filter((id): id is string => id !== null);
const seriesKeys = args.seriesKeys.filter((key) =>
key === MOOD_SERIES_KEY
? access.modules.mood !== false && access.domainVisible("mind")
: measurementsVisible &&
measurementTypeVisible(key as MeasurementType, access.modules),
timelineLabMarkerId(key) !== null
? false
: key === MOOD_SERIES_KEY
? access.modules.mood !== false && access.domainVisible("mind")
: measurementsVisible &&
measurementTypeVisible(key as MeasurementType, access.modules),
);
const priorityJson = measurementsVisible
? await loadUserSourcePriority(recordId)
@@ -260,30 +280,33 @@ export async function loadTimeline(args: {
? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1))
: from;
const bucket = timelineBucket(query.zoom, from, to);
const [series, notable, availableSeries] = await Promise.all([
loadTimelineSeries({
userId: recordId,
keys: seriesKeys,
from,
to,
tz,
bucket,
priorityJson,
now,
}),
measurementsVisible
? loadNotableRange({
userId: recordId,
from: notableFrom,
to,
tz,
priorityJson,
typeVisible: (t) => measurementTypeVisible(t, access.modules),
gaps: false,
})
: Promise.resolve([]),
loadAvailableSeries({ recordId, access }),
]);
const [series, notable, availableSeries, labSeries, availableLabs] =
await Promise.all([
loadTimelineSeries({
userId: recordId,
keys: seriesKeys,
from,
to,
tz,
bucket,
priorityJson,
now,
}),
measurementsVisible
? loadNotableRange({
userId: recordId,
from: notableFrom,
to,
tz,
priorityJson,
typeVisible: (t) => measurementTypeVisible(t, access.modules),
gaps: false,
})
: Promise.resolve([]),
loadAvailableSeries({ recordId, access }),
loadLabSeries({ recordId, ids: labIds, from, to, tz, access }),
loadAvailableLabMarkers({ recordId, access }),
]);
return {
zoom: query.zoom,
@@ -293,6 +316,8 @@ export async function loadTimeline(args: {
bucket,
series,
availableSeries,
labSeries,
availableLabs,
notable: notable.map(({ date, kind }) => ({ date, kind })),
};
}
@@ -0,0 +1,372 @@
/**
* Lab markers as lines on the timeline (#1299), driven through
* `GET /api/timeline` against real Postgres.
*
* What the unit tests cannot show: that the record's own markers are offered
* and nobody else's, that a marker id from another record finds nothing,
* that a reading is dated by the record's local day and linked to its
* report, that a switched-off labs or documents module reads neither, that a
* grant scoped to some sections is refused, and that the route admits a cookie session and
* refuses a narrow Bearer token (the fail-closed default: the timeline names
* no scope). All data invented.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { hashToken } from "@/lib/auth/hmac";
import type { TimelineResponse } from "@/lib/day/contract";
import { shiftDateKey } from "@/lib/tz/format";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
let counter = 0;
const DAY_MS = 86_400_000;
/**
* The calendar day `n` days before today, so every reading stays inside
* the windows below whenever the suite runs.
*/
function daysAgo(n: number): string {
return new Date(Date.now() - n * DAY_MS).toISOString().slice(0, 10);
}
/** The window every reading lies in. */
const WINDOW = () => `zoom=range&from=${daysAgo(1000)}&to=${daysAgo(1)}`;
/** The reading days the record's local zone puts them on. */
const DAYS = {
low: () => daysAgo(900),
lateNight: () => shiftDateKey(daysAgo(500), 1),
recent: () => daysAgo(200),
};
const MODULES = { timeline: true, labs: true, inboundDocuments: true };
async function makeUser(label: string) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `tl-lab-${suffix}`,
email: `tl-lab-${suffix}@example.test`,
displayName: `TL ${suffix}`,
role: "USER",
timezone: "Europe/Berlin",
locale: "en",
modulePreferencesJson: MODULES,
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
async function mintToken(userId: string, permissions: string[]) {
const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0");
await getPrismaClient().apiToken.create({
data: {
userId,
name: "timeline-lab-test",
tokenHash: hashToken(raw),
permissions,
},
});
return raw;
}
async function shareWith(
ownerId: string,
delegateId: string,
scope: Array<"labs" | "measurements" | "documents"> | null,
) {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: ownerId,
granteeId: delegateId,
access: "READ",
scope,
});
await acceptGrant({ grantId: invited.id, granteeId: delegateId });
const session = await signIn(delegateId);
await switchSessionTo(session.id, ownerId);
}
async function getTimeline(query: string): Promise<Response> {
const { GET } = await import("@/app/api/timeline/route");
return (GET as Handler)(
new NextRequest(`http://localhost/api/timeline?${query}`, {
method: "GET",
}),
{ params: Promise.resolve({}) },
);
}
async function data(response: Response): Promise<TimelineResponse> {
expect(response.status).toBe(200);
return ((await response.json()) as { data: TimelineResponse }).data;
}
/** A record with ferritin over three years, one reading linked to its report. */
async function seedFerritin(userId: string) {
const db = getPrismaClient();
const marker = await db.biomarker.create({
data: {
userId,
name: "Ferritin",
unit: "ng/mL",
lowerBound: 30,
upperBound: 400,
panel: "Iron",
analyteKey: "ferritin",
},
});
const at = (iso: string, value: number, unit = "ng/mL") =>
db.labResult.create({
data: {
userId,
biomarkerId: marker.id,
analyte: "Ferritin",
value,
unit,
takenAt: new Date(iso),
},
});
const first = await at(`${daysAgo(900)}T12:00:00.000Z`, 18);
// 23:30 UTC is already the next day in Berlin, summer or winter.
await at(`${daysAgo(500)}T23:30:00.000Z`, 85);
await at(`${daysAgo(200)}T12:00:00.000Z`, 120);
// A reading stored in a unit that does not convert into ng/mL.
await at(`${daysAgo(199)}T12:00:00.000Z`, 260, "pmol/L");
// A qualitative reading has nothing to draw.
await db.labResult.create({
data: {
userId,
biomarkerId: marker.id,
analyte: "Ferritin",
valueText: "haemolysed",
unit: "",
takenAt: new Date(`${daysAgo(198)}T12:00:00.000Z`),
},
});
const document = await db.inboundDocument.create({
data: {
userId,
kind: "LAB_RESULT",
title: "Lab report February",
filename: "labs.pdf",
mimeType: "application/pdf",
byteSize: 1,
contentEncrypted: new Uint8Array([1]),
contentCodec: "binary2",
},
});
await db.labResultDocumentLink.create({
data: { userId, labResultId: first.id, documentId: document.id },
});
// A marker with only a qualitative reading is not offered.
const urine = await db.biomarker.create({
data: { userId, name: "Urine protein", unit: "" },
});
await db.labResult.create({
data: {
userId,
biomarkerId: urine.id,
analyte: "Urine protein",
valueText: "negative",
unit: "",
takenAt: new Date(`${daysAgo(200)}T12:00:00.000Z`),
},
});
return { marker, document };
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("lab lines on the timeline", () => {
it("offers the record's markers and draws a chosen one as its readings", async () => {
const owner = await makeUser("owner");
const { marker, document } = await seedFerritin(owner.id);
await signIn(owner.id);
const timeline = await data(
await getTimeline(`${WINDOW()}&values=WEIGHT,lab:${marker.id}`),
);
expect(timeline.availableLabs).toEqual([
{
key: `lab:${marker.id}`,
biomarkerId: marker.id,
name: "Ferritin",
panel: "Iron",
unit: "ng/mL",
},
]);
expect(timeline.labSeries).toHaveLength(1);
const [line] = timeline.labSeries;
expect(line).toMatchObject({
key: `lab:${marker.id}`,
name: "Ferritin",
unit: "ng/mL",
band: { low: 30, high: 400 },
excluded: 1,
});
expect(
line.points.map((p) => [p.date, p.value, p.rangeStatus, p.report]),
).toEqual([
[
DAYS.low(),
18,
"below",
{
id: document.id,
title: "Lab report February",
href: `/documents?doc=${document.id}`,
},
],
[DAYS.lateNight(), 85, "in-range", null],
[DAYS.recent(), 120, "in-range", null],
]);
// The measurement lines are not touched by a lab entry beside them.
expect(timeline.series).toEqual([]);
// The window decides which readings are sent; the marker stays a line.
const quarter = await data(
await getTimeline(
`zoom=range&from=${daysAgo(530)}&to=${daysAgo(470)}&values=lab:${marker.id}`,
),
);
expect(quarter.labSeries[0].points.map((p) => p.date)).toEqual([
DAYS.lateNight(),
]);
const empty = await data(
await getTimeline(
`zoom=range&from=${daysAgo(1300)}&to=${daysAgo(1200)}&values=lab:${marker.id}`,
),
);
expect(empty.labSeries[0]).toMatchObject({ points: [], excluded: 0 });
});
it("finds nothing under another record's marker id, and does not refuse", async () => {
const owner = await makeUser("owner");
const other = await makeUser("other");
const { marker: theirs } = await seedFerritin(other.id);
await signIn(owner.id);
const timeline = await data(
await getTimeline(`zoom=all&values=lab:${theirs.id},lab:gone123`),
);
expect(timeline.labSeries).toEqual([]);
expect(timeline.availableLabs).toEqual([]);
// A list of ids is capped, and a malformed one is refused.
const many = Array.from({ length: 25 }, (_, i) => `lab:m${i}`).join(",");
expect((await getTimeline(`values=${many}`)).status).toBe(422);
expect((await getTimeline(`values=lab:..%2Fx`)).status).toBe(422);
});
it("refuses a grant scoped to some sections, and shares the lines on a whole-record grant", async () => {
const owner = await makeUser("owner");
const { marker } = await seedFerritin(owner.id);
const noLabs = await makeUser("no-labs");
await shareWith(owner.id, noLabs.id, ["measurements"]);
// The timeline is admitted on a whole-record grant only.
expect((await getTimeline(`zoom=all&values=lab:${marker.id}`)).status).toBe(
403,
);
const whole = await makeUser("whole");
await shareWith(owner.id, whole.id, null);
const shared = await data(
await getTimeline(`${WINDOW()}&values=lab:${marker.id}`),
);
expect(shared.labSeries[0].points).toHaveLength(3);
expect(shared.availableLabs.map((m) => m.name)).toEqual(["Ferritin"]);
});
it("names no report with documents off, and draws no lab line with labs off", async () => {
const owner = await makeUser("owner");
const { marker } = await seedFerritin(owner.id);
await getPrismaClient().user.update({
where: { id: owner.id },
data: { modulePreferencesJson: { ...MODULES, inboundDocuments: false } },
});
await signIn(owner.id);
const noDocs = await data(
await getTimeline(`${WINDOW()}&values=lab:${marker.id}`),
);
expect(noDocs.labSeries[0].points.map((p) => p.report)).toEqual([
null,
null,
null,
]);
await getPrismaClient().user.update({
where: { id: owner.id },
data: { modulePreferencesJson: { ...MODULES, labs: false } },
});
const timeline = await data(
await getTimeline(`zoom=all&values=lab:${marker.id}`),
);
expect(timeline.labSeries).toEqual([]);
expect(timeline.availableLabs).toEqual([]);
});
it("admits a cookie session and a full token, refuses a narrow token and nobody", async () => {
const owner = await makeUser("owner");
const { marker } = await seedFerritin(owner.id);
const query = `zoom=all&values=lab:${marker.id}`;
expect((await getTimeline(query)).status).toBe(401);
const narrow = await mintToken(owner.id, ["fhir:read"]);
headerJar.set("authorization", `Bearer ${narrow}`);
expect((await getTimeline(query)).status).toBe(403);
const full = await mintToken(owner.id, ["*"]);
headerJar.set("authorization", `Bearer ${full}`);
const timeline = await data(await getTimeline(query));
expect(timeline.labSeries[0].points.length).toBeGreaterThan(0);
});
});