mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'feat/v1463-labs' into release/v1.46.3
# Conflicts: # src/__tests__/baselines/source-file-lines.json
This commit is contained in:
commit
169a0f1df4
40 files changed
+4332
-668
No files matched your search
+173
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user