Merge the timeline rework into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 15:02:46 +02:00
commit 4ca2334805
37 files changed
+2507 -390

No files matched your search

+19 -4
View File
@@ -23472,11 +23472,11 @@ paths:
- in: query
name: values
schema:
description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an
unknown name, answers 422.
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.
type: string
description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an
unknown name, answers 422.
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.
responses:
"200":
description: The timeline for the requested zoom and window.
@@ -81664,6 +81664,13 @@ components:
type: array
items:
$ref: "#/components/schemas/TimelineSeries"
availableSeries:
type: array
items:
type: string
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`."
notable:
type: array
items:
@@ -81690,6 +81697,7 @@ components:
- standing
- bucket
- series
- availableSeries
- notable
additionalProperties: false
TimelineItem:
@@ -81750,6 +81758,12 @@ components:
anyOf:
- type: string
- type: "null"
group:
anyOf:
- type: string
- type: "null"
description: "Items that belong to one thing share a group and are drawn on one row: a medication's own span, its
courses, its dose changes and its pauses all carry the medication's id. Null for an item that stands alone."
required:
- id
- kind
@@ -81761,6 +81775,7 @@ components:
- label
- sub
- href
- group
additionalProperties: false
TimelineStandingItem:
type: object
+162 -47
View File
@@ -125,11 +125,25 @@ test("the timeline hands the selected day to the day view through ?day=", async
).toHaveCount(0);
const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? [];
expect(asked.length).toBeGreaterThan(0);
expect(asked.length).toBeLessThanOrEqual(6);
// Before anyone picks a day the bar opens on the newest bucket it has
// something for, says so in its head, and says how to pick another.
const bar = page.locator('[data-slot="timeline-selection-bar"]');
// It sits on the card's own surface under a hairline, not in a grey box,
// and the legend under it is set off by a hairline of its own.
const surface = await bar.evaluate((el) => {
const style = getComputedStyle(el);
return {
background: style.backgroundColor,
borderTop: style.borderTopWidth,
};
});
expect(surface.background).toBe("rgba(0, 0, 0, 0)");
expect(surface.borderTop).toBe("1px");
await expect(page.locator('[data-slot="timeline-legend"]')).toHaveCSS(
"border-top-width",
"1px",
);
await expect(bar).toHaveAttribute("data-bucket", "quarter");
await expect(bar).toHaveAttribute(
"data-period-from",
@@ -178,7 +192,7 @@ test("the timeline hands the selected day to the day view through ?day=", async
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
});
test("value lines: quarterly means, gaps kept, short ones bridged, up to six lines", async ({
test("value lines: quarterly means, gaps kept, short ones bridged, as many as the record has", async ({
page,
}) => {
await setTimelineModuleOn(page);
@@ -212,34 +226,63 @@ test("value lines: quarterly means, gaps kept, short ones bridged, up to six lin
),
).toBeAttached();
// Six value lines can be chosen; a seventh cannot.
await page.locator('[data-slot="timeline-values-trigger"]').click();
// The legend explains the dashed stroke and the hollow point.
await expect(
page.locator('[data-slot="timeline-legend-gap"] svg'),
).toBeVisible();
await expect(
page.locator('[data-slot="timeline-legend-thin"] svg'),
).toBeVisible();
// 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"]');
await expect(trigger).toHaveAttribute("data-count", "3");
await expect(trigger).toHaveText(/\(3\)/);
const toolbarHeight = await trigger.evaluate(
(el) => el.parentElement!.getBoundingClientRect().height,
);
// No cap: every series the record has can be chosen, fifteen here.
await trigger.click();
const options = page.locator('[data-slot="timeline-values-option"]');
await expect(options.first()).toBeVisible();
const count = await options.count();
expect(count).toBe(15);
await expect(
page.locator('[data-slot="timeline-values-option"][data-disabled]'),
).toHaveCount(0);
for (let i = 0; i < count; i++) {
const checked = page.locator(
'[data-slot="timeline-values-option"][aria-checked="true"]',
);
if ((await checked.count()) >= 6) break;
const option = options.nth(i);
if ((await option.getAttribute("aria-checked")) === "true") continue;
await option.click();
}
await expect(
page.locator('[data-slot="timeline-values-option"][aria-checked="true"]'),
).toHaveCount(6);
await expect(
page.locator(
'[data-slot="timeline-values-option"][aria-checked="false"][data-disabled]',
),
).toHaveCount(count - 6);
).toHaveCount(15);
// The menu marks each chosen line with its colour.
const dots = await page
.locator(
'[data-slot="timeline-values-option"][aria-checked="true"] [data-slot="timeline-values-dot"]',
)
.evaluateAll((els) =>
els.map((el) => getComputedStyle(el).backgroundColor),
);
expect(dots).toHaveLength(15);
await page.keyboard.press("Escape");
await expect
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
.toBe(6);
await expect(page.locator("svg [data-series]")).toHaveCount(6);
// Each line in its own colour, and every mark of a line in that colour.
.toBe(15);
await expect(page.locator("svg [data-series]")).toHaveCount(15);
await expect(trigger).toHaveAttribute("data-count", "15");
// The toolbar did not grow with the choice.
expect(
await trigger.evaluate(
(el) => el.parentElement!.getBoundingClientRect().height,
),
).toBe(toolbarHeight);
// Each line in one colour, every mark of a line in that colour, the five
// data colours repeating past the fifth line.
const colours = await page
.locator("svg [data-series]")
.evaluateAll((groups) =>
@@ -252,30 +295,60 @@ test("value lines: quarterly means, gaps kept, short ones bridged, up to six lin
].map((m) => getComputedStyle(m).stroke),
})),
);
expect(new Set(colours.map((c) => c.colour)).size).toBe(6);
expect(new Set(colours.map((c) => c.colour)).size).toBe(5);
for (const { colour, marks } of colours) {
expect(marks.length).toBeGreaterThan(0);
for (const stroke of marks) expect(stroke).toBe(colour);
}
// The menu marks each chosen line with the same colour.
const trigger = page.locator('[data-slot="timeline-values-trigger"]');
await expect(async () => {
if ((await trigger.getAttribute("data-state")) !== "open") {
await trigger.click();
}
await expect(trigger).toHaveAttribute("data-state", "open", {
timeout: 1_000,
});
}).toPass();
const chosenDots = page.locator(
'[data-slot="timeline-values-option"][aria-checked="true"] [data-slot="timeline-values-dot"]',
);
await expect(chosenDots).toHaveCount(6);
const dots = await chosenDots.evaluateAll((els) =>
els.map((el) => getComputedStyle(el).backgroundColor),
);
expect(new Set(dots)).toEqual(new Set(colours.map((c) => c.colour)));
await page.keyboard.press("Escape");
// The rows run on down the page, which scrolls; the last one is reached.
const last = page.locator("svg [data-series]").last();
await last.scrollIntoViewIfNeeded();
await expect(last).toBeInViewport();
});
test("one medication is one row: its doses cut the bar, its pause is a gap in it", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const lane = page.locator('svg [data-lane="medications"]');
await expect(lane).toBeVisible({ timeout: 20_000 });
// The medication's own span is not drawn beside its course.
await expect(lane.locator('[data-item="mj"]')).toHaveCount(0);
const pieces = lane.locator('[data-item="mj-course"]');
await expect(pieces).toHaveCount(3);
expect(
await pieces.evaluateAll((els) => els.map((el) => el.dataset.dose)),
).toEqual(["2,5 mg", "5 mg", "7,5 mg"]);
const rowOf = (selector: string) =>
lane
.locator(selector)
.evaluateAll((els) => [...new Set(els.map((el) => el.dataset.row))]);
const mounjaroRow = await rowOf('[data-item="mj-course"]');
expect(mounjaroRow).toHaveLength(1);
expect(await rowOf('[data-kind="pause"][data-item="mj-pause"]')).toEqual(
mounjaroRow,
);
// Three winter courses of one medication share one row of their own.
const vitamin = await rowOf('[data-item^="c-"]');
expect(vitamin).toHaveLength(1);
expect(vitamin).not.toEqual(mounjaroRow);
// Four medications, four rows.
const rows = await lane
.locator("[data-row]")
.evaluateAll((els) => new Set(els.map((el) => el.dataset.row)).size);
expect(rows).toBe(4);
// A zoom change redraws the pieces: none is left behind from the last
// window (each piece has a key of its own).
const zoom = (value: string) =>
page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`);
await zoom("year").click();
await expect(page).toHaveURL(/zoom=year/);
await zoom("all").click();
await expect(page).toHaveURL(/zoom=all/);
await expect(pieces).toHaveCount(3);
});
/** `days` calendar days before `key`. */
@@ -296,20 +369,35 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy
const zoom = (value: string) =>
page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`);
// Choosing "Range" starts from what is on screen and writes it down.
// Choosing "Range" opens its fields over the page, anchored to the
// segment: the chart does not move, and the URL waits for "Apply".
const before = await chart.boundingBox();
await zoom("range").click();
const popover = page.locator('[data-slot="timeline-range-popover"]');
await expect(popover).toBeVisible();
const fields = popover.locator('[data-slot="timeline-range"]');
await expect(fields).toBeVisible();
expect(await chart.boundingBox()).toEqual(before);
await expect(desktop.locator('[data-slot="timeline-range"]')).toHaveCount(0);
expect(page.url()).not.toContain("zoom=range");
const anchor = await zoom("range").boundingBox();
const box = await popover.boundingBox();
expect(box!.y).toBeGreaterThanOrEqual(anchor!.y + anchor!.height);
expect(Math.abs(box!.x - anchor!.x)).toBeLessThan(24);
// Starts from what is on screen; "Apply" writes it down.
await popover.locator('[data-slot="timeline-range-apply"]').click();
await expect(popover).toHaveCount(0);
await expect(page).toHaveURL(
/[?&]zoom=range&from=\d{4}-\d{2}-\d{2}&to=\d{4}-\d{2}-\d{2}/,
);
const fields = desktop.locator('[data-slot="timeline-range"]');
await expect(fields).toBeVisible();
// Three weeks: daily means, the bar headed by one day.
const from = daysBefore(TODAY, 20);
const start = desktop.locator('[data-testid="timeline-range-from"]');
await page.goto(`/timeline?zoom=range&from=${from}&to=${TODAY}`);
await expect(fields).toHaveAttribute("data-from", from, { timeout: 20_000 });
await expect(fields).toHaveAttribute("data-to", TODAY);
await expect(desktop).toHaveAttribute("data-from", from, {
timeout: 20_000,
});
await expect(desktop).toHaveAttribute("data-to", TODAY);
await expect(zoom("range")).toHaveAttribute("aria-checked", "true");
await expect(
page.locator('[data-slot="timeline-legend-mean"]'),
@@ -324,20 +412,26 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy
// A reload keeps it.
await page.reload();
await expect(fields).toHaveAttribute("data-from", from, { timeout: 20_000 });
await expect(desktop).toHaveAttribute("data-from", from, {
timeout: 20_000,
});
// A fixed zoom replaces it; Back brings it back.
await zoom("year").click();
await expect(page).toHaveURL(/[?&]zoom=year$/);
await expect(fields).toHaveCount(0);
await expect(desktop).not.toHaveAttribute("data-from", from);
await page.goBack();
await expect(page).toHaveURL(new RegExp(`zoom=range&from=${from}`));
await expect(fields).toHaveAttribute("data-from", from);
await expect(desktop).toHaveAttribute("data-from", from);
// Typing a start moves the range and the URL with it.
// Typing a start in the popover moves the range once it is applied.
const later = daysBefore(TODAY, 10);
await zoom("range").click();
const start = popover.locator('[data-testid="timeline-range-from"]');
await start.fill(later);
await start.press("Enter");
expect(page.url()).toContain(`from=${from}`);
await popover.locator('[data-slot="timeline-range-apply"]').click();
await expect(page).toHaveURL(new RegExp(`from=${later}&to=${TODAY}`));
// A parameter it cannot read opens the whole record.
@@ -345,7 +439,7 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy
await expect(zoom("all")).toHaveAttribute("aria-checked", "true", {
timeout: 20_000,
});
await expect(fields).toHaveCount(0);
await expect(desktop).toHaveAttribute("data-zoom", "all");
});
async function setTimelineModuleOn(page: Page) {
@@ -423,6 +517,27 @@ test("on a phone the chronicle replaces the chart and opens a day per row", asyn
means.first().locator('[data-slot="timeline-series-dot"]').first(),
).toBeVisible();
// One medication, one start: its course and its own span are one row,
// with the dose taken then.
const started = chronicle.locator(
'[data-slot="timeline-chronicle-row"][data-date="2025-01-06"]',
);
await expect(started).toHaveCount(1);
await expect(started).toContainText("Mounjaro");
await expect(started).toContainText("2,5 mg");
await expect(chronicle).not.toContainText("·");
// A range is chosen in a sheet; the list underneath does not move.
const listTop = (await chronicle.boundingBox())!.y;
await page.locator('[data-slot="timeline-range-toggle"]').click();
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet.locator('[data-slot="timeline-range"]')).toBeVisible();
expect((await chronicle.boundingBox())!.y).toBe(listTop);
await sheet.locator('[data-slot="timeline-range-apply"]').click();
await expect(page).toHaveURL(/[?&]zoom=range&from=\d{4}-\d{2}-\d{2}/);
await page.goto("/timeline");
await expect(chronicle).toBeVisible({ timeout: 20_000 });
await page
.locator(
'[data-slot="timeline-chronicle-row"][data-date="2026-01-03"] button',
+79
View File
@@ -22,6 +22,8 @@ type Item = {
label: string;
sub: string | null;
href: string | null;
/** The medication an item belongs to; one row per group. */
group: string | null;
};
function item(over: Partial<Item> & Pick<Item, "id" | "kind" | "start">): Item {
@@ -33,6 +35,7 @@ function item(over: Partial<Item> & Pick<Item, "id" | "kind" | "start">): Item {
label: over.id,
sub: null,
href: null,
group: null,
...over,
};
}
@@ -204,6 +207,28 @@ function series(dataFrom: string, today: string, bucket: Bucket) {
];
}
/**
* Every series the `full` record can show: fifteen, past the five colours
* and past the six lines the chart used to stop at.
*/
export const FULL_AVAILABLE = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
"OXYGEN_SATURATION",
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"VO2_MAX",
"BODY_TEMPERATURE",
"RESPIRATORY_RATE",
"MOOD",
];
export function fullTimeline(today: string) {
const vaccinations = [
"2020-06-10",
@@ -348,6 +373,7 @@ export function fullTimeline(today: string) {
items: [
item({
id: "med-1",
group: "med-1",
kind: "medication",
start: "2019-04-02",
open: true,
@@ -356,6 +382,7 @@ export function fullTimeline(today: string) {
}),
item({
id: "dose-1",
group: "med-1",
kind: "doseChange",
start: "2020-08-12",
label: "Ramipril",
@@ -363,13 +390,58 @@ export function fullTimeline(today: string) {
}),
item({
id: "med-2",
group: "med-2",
kind: "medication",
start: "2022-11-03",
end: "2022-12-10",
label: "Ibuprofen",
}),
// One medication sent as its own span, a course, three dose
// changes and a pause: the case the beta drew as two medications.
item({
id: "mj",
group: "mj",
kind: "medication",
start: "2025-01-06",
open: true,
label: "Mounjaro",
sub: "7,5 mg",
}),
item({
id: "mj-course",
group: "mj",
kind: "course",
start: "2025-01-06",
open: true,
label: "Mounjaro",
}),
...(
[
["mj-25", "2025-01-06", "2,5 mg"],
["mj-5", "2025-03-03", "5 mg"],
["mj-75", "2025-06-02", "7,5 mg"],
] as const
).map(([id, start, sub]) =>
item({
id,
group: "mj",
kind: "doseChange",
start,
label: "Mounjaro",
sub,
}),
),
item({
id: "mj-pause",
group: "mj",
kind: "pause",
start: "2025-08-01",
end: "2025-08-20",
label: "Mounjaro",
}),
item({
id: "c-1",
group: "vit-d",
kind: "course",
start: "2023-11-01",
end: "2024-03-31",
@@ -377,6 +449,7 @@ export function fullTimeline(today: string) {
}),
item({
id: "c-2",
group: "vit-d",
kind: "course",
start: "2024-11-01",
end: "2025-03-31",
@@ -384,6 +457,7 @@ export function fullTimeline(today: string) {
}),
item({
id: "c-3",
group: "vit-d",
kind: "course",
start: "2025-11-01",
end: "2026-03-31",
@@ -437,6 +511,7 @@ export function fullTimeline(today: string) {
standing: [],
bucket: "quarter" as Bucket,
series: series("2019-01-01", today, "quarter"),
availableSeries: FULL_AVAILABLE,
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
@@ -482,6 +557,7 @@ export function readyTimeline(today: string) {
items: [
item({
id: "med-1",
group: "med-1",
kind: "medication",
start: "2024-05-01",
open: true,
@@ -491,6 +567,7 @@ export function readyTimeline(today: string) {
}),
item({
id: "c-2",
group: "vit-d",
kind: "course",
start: "2024-11-01",
end: "2025-03-31",
@@ -498,6 +575,7 @@ export function readyTimeline(today: string) {
}),
item({
id: "c-3",
group: "vit-d",
kind: "course",
start: "2025-11-01",
end: "2026-03-31",
@@ -558,6 +636,7 @@ export function readyTimeline(today: string) {
series: series("2024-05-01", today, "quarter").filter(
(s) => s.key !== "RESTING_HEART_RATE",
),
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
notable: [],
};
}
+9 -7
View File
@@ -12308,7 +12308,7 @@
"title": "Zeitleiste",
"subtitle": "Was wann war, neben deinen Werten.",
"standing": "Dauerhaft",
"startMissing": "Startdatum fehlt",
"startUnknown": "Beginn unbekannt",
"openDay": "{date} öffnen",
"zoom": {
"all": "Alles",
@@ -12433,6 +12433,7 @@
"legendMeanMonth": "Monatsmittel",
"legendMeanWeek": "Wochenmittel",
"legendThin": "Weniger als 3 Messungen",
"legendGap": "Kurze Lücke ohne Messungen",
"today": "Heute",
"noDataYet": "Noch keine Daten",
"layers": "Ebenen",
@@ -12446,8 +12447,8 @@
"description": "Sobald es Einträge mit Datum gibt, erscheinen sie hier."
},
"values": {
"menuLabel": "Bis zu sechs Werte",
"none": "Keine Werte",
"menuTitle": "Werte unter den Spuren",
"trigger": "Werte ({count})",
"noneInWindow": "Keine Werte in diesem Zeitraum",
"series": {
"BLOOD_PRESSURE_SYS": "Blutdruck sys.",
@@ -12470,7 +12471,7 @@
},
"selection": {
"empty": "Keine Einträge in diesem Zeitraum",
"ongoing": "laufend",
"since": "seit {date}",
"range": "{from} bis {to}",
"mean": "Ø {values}",
"week": "Woche vom {date}",
@@ -12481,8 +12482,8 @@
"months": "Monate",
"years": "Jahre",
"groupingLabel": "Gruppierung",
"gap": "{from} bis {to} · keine Einträge",
"gapSingle": "{period} · keine Einträge",
"empty": "Keine Einträge von {from} bis {to}",
"emptySingle": "{period}: keine Einträge",
"ended": "{label} vorbei",
"afterDaysOne": "nach einem Tag",
"afterDaysFew": "nach {count} Tagen",
@@ -12504,7 +12505,8 @@
"to": "Bis",
"label": "Gewählter Zeitraum",
"open": "Zeitraum wählen",
"clear": "Alles zeigen"
"clear": "Alles zeigen",
"apply": "Übernehmen"
},
"item": {
"labValuesOne": "1 Laborwert",
+9 -7
View File
@@ -12308,7 +12308,7 @@
"title": "Timeline",
"subtitle": "What happened when, beside your values.",
"standing": "Ongoing",
"startMissing": "Start date missing",
"startUnknown": "start unknown",
"openDay": "Open {date}",
"zoom": {
"all": "All",
@@ -12433,6 +12433,7 @@
"legendMeanMonth": "Monthly mean",
"legendMeanWeek": "Weekly mean",
"legendThin": "Fewer than 3 readings",
"legendGap": "Short gap without measurements",
"today": "Today",
"noDataYet": "No data yet",
"layers": "Layers",
@@ -12446,8 +12447,8 @@
"description": "Dated entries appear here as soon as there are some."
},
"values": {
"menuLabel": "Up to six values",
"none": "No values",
"menuTitle": "Values under the lanes",
"trigger": "Values ({count})",
"noneInWindow": "No values in this range",
"series": {
"BLOOD_PRESSURE_SYS": "Blood pressure sys.",
@@ -12470,7 +12471,7 @@
},
"selection": {
"empty": "No entries in this period",
"ongoing": "ongoing",
"since": "since {date}",
"range": "{from} to {to}",
"mean": "Ø {values}",
"week": "Week of {date}",
@@ -12481,8 +12482,8 @@
"months": "Months",
"years": "Years",
"groupingLabel": "Grouping",
"gap": "{from} to {to} · no entries",
"gapSingle": "{period} · no entries",
"empty": "No entries from {from} to {to}",
"emptySingle": "No entries in {period}",
"ended": "{label} ended",
"afterDaysOne": "after 1 day",
"afterDaysFew": "after {count} days",
@@ -12504,7 +12505,8 @@
"to": "To",
"label": "Chosen range",
"open": "Choose a range",
"clear": "Show everything"
"clear": "Show everything",
"apply": "Apply"
},
"item": {
"labValuesOne": "1 lab value",
+9 -7
View File
@@ -12308,7 +12308,7 @@
"title": "Cronología",
"subtitle": "Qué pasó y cuándo, junto a tus valores.",
"standing": "Permanente",
"startMissing": "Falta la fecha de inicio",
"startUnknown": "inicio desconocido",
"openDay": "Abrir {date}",
"zoom": {
"all": "Todo",
@@ -12433,6 +12433,7 @@
"legendMeanMonth": "Media mensual",
"legendMeanWeek": "Media semanal",
"legendThin": "Menos de 3 mediciones",
"legendGap": "Hueco corto sin mediciones",
"today": "Hoy",
"noDataYet": "Aún sin datos",
"layers": "Capas",
@@ -12446,8 +12447,8 @@
"description": "Las entradas con fecha aparecerán aquí en cuanto haya alguna."
},
"values": {
"menuLabel": "Hasta seis valores",
"none": "Sin valores",
"menuTitle": "Valores bajo los carriles",
"trigger": "Valores ({count})",
"noneInWindow": "Sin valores en este periodo",
"series": {
"BLOOD_PRESSURE_SYS": "Presión sistólica",
@@ -12470,7 +12471,7 @@
},
"selection": {
"empty": "No hay entradas en este periodo",
"ongoing": "en curso",
"since": "desde {date}",
"range": "{from} a {to}",
"mean": "Ø {values}",
"week": "Semana del {date}",
@@ -12481,8 +12482,8 @@
"months": "Meses",
"years": "Años",
"groupingLabel": "Agrupación",
"gap": "{from} a {to} · sin entradas",
"gapSingle": "{period} · sin entradas",
"empty": "Sin entradas de {from} a {to}",
"emptySingle": "Sin entradas en {period}",
"ended": "Fin: {label}",
"afterDaysOne": "después de 1 día",
"afterDaysFew": "después de {count} días",
@@ -12504,7 +12505,8 @@
"to": "Hasta",
"label": "Periodo elegido",
"open": "Elegir un periodo",
"clear": "Mostrar todo"
"clear": "Mostrar todo",
"apply": "Aplicar"
},
"item": {
"labValuesOne": "1 valor de laboratorio",
+9 -7
View File
@@ -12308,7 +12308,7 @@
"title": "Chronologie",
"subtitle": "Ce qui s’est passé et quand, à côté de vos valeurs.",
"standing": "Permanent",
"startMissing": "Date de début manquante",
"startUnknown": "début inconnu",
"openDay": "Ouvrir le {date}",
"zoom": {
"all": "Tout",
@@ -12433,6 +12433,7 @@
"legendMeanMonth": "Moyenne mensuelle",
"legendMeanWeek": "Moyenne hebdomadaire",
"legendThin": "Moins de 3 mesures",
"legendGap": "Courte période sans mesures",
"today": "Aujourd’hui",
"noDataYet": "Pas encore de données",
"layers": "Calques",
@@ -12446,8 +12447,8 @@
"description": "Les entrées datées apparaîtront ici dès qu’il y en aura."
},
"values": {
"menuLabel": "Jusqu’à six valeurs",
"none": "Aucune valeur",
"menuTitle": "Valeurs sous les lignes",
"trigger": "Valeurs ({count})",
"noneInWindow": "Aucune valeur sur cette période",
"series": {
"BLOOD_PRESSURE_SYS": "Tension systolique",
@@ -12470,7 +12471,7 @@
},
"selection": {
"empty": "Aucune entrée sur cette période",
"ongoing": "en cours",
"since": "depuis le {date}",
"range": "{from} au {to}",
"mean": "Ø {values}",
"week": "Semaine du {date}",
@@ -12481,8 +12482,8 @@
"months": "Mois",
"years": "Années",
"groupingLabel": "Regroupement",
"gap": "{from} à {to} · aucune entrée",
"gapSingle": "{period} · aucune entrée",
"empty": "Aucune entrée de {from} à {to}",
"emptySingle": "Aucune entrée en {period}",
"ended": "Fin : {label}",
"afterDaysOne": "après 1 jour",
"afterDaysFew": "après {count} jours",
@@ -12504,7 +12505,8 @@
"to": "Au",
"label": "Période choisie",
"open": "Choisir une période",
"clear": "Tout afficher"
"clear": "Tout afficher",
"apply": "Appliquer"
},
"item": {
"labValuesOne": "1 valeur de laboratoire",
+9 -7
View File
@@ -12308,7 +12308,7 @@
"title": "Cronologia",
"subtitle": "Cosa è successo e quando, accanto ai tuoi valori.",
"standing": "Permanente",
"startMissing": "Data di inizio mancante",
"startUnknown": "inizio sconosciuto",
"openDay": "Apri {date}",
"zoom": {
"all": "Tutto",
@@ -12433,6 +12433,7 @@
"legendMeanMonth": "Media mensile",
"legendMeanWeek": "Media settimanale",
"legendThin": "Meno di 3 misurazioni",
"legendGap": "Breve pausa senza misurazioni",
"today": "Oggi",
"noDataYet": "Ancora nessun dato",
"layers": "Livelli",
@@ -12446,8 +12447,8 @@
"description": "Le voci con data compariranno qui appena ce ne saranno."
},
"values": {
"menuLabel": "Fino a sei valori",
"none": "Nessun valore",
"menuTitle": "Valori sotto le corsie",
"trigger": "Valori ({count})",
"noneInWindow": "Nessun valore in questo periodo",
"series": {
"BLOOD_PRESSURE_SYS": "Pressione sistolica",
@@ -12470,7 +12471,7 @@
},
"selection": {
"empty": "Nessuna voce in questo periodo",
"ongoing": "in corso",
"since": "dal {date}",
"range": "dal {from} al {to}",
"mean": "Ø {values}",
"week": "Settimana del {date}",
@@ -12481,8 +12482,8 @@
"months": "Mesi",
"years": "Anni",
"groupingLabel": "Raggruppamento",
"gap": "Da {from} a {to} · nessuna voce",
"gapSingle": "{period} · nessuna voce",
"empty": "Nessuna voce da {from} a {to}",
"emptySingle": "{period}: nessuna voce",
"ended": "Fine: {label}",
"afterDaysOne": "dopo 1 giorno",
"afterDaysFew": "dopo {count} giorni",
@@ -12504,7 +12505,8 @@
"to": "Al",
"label": "Periodo scelto",
"open": "Scegli un periodo",
"clear": "Mostra tutto"
"clear": "Mostra tutto",
"apply": "Applica"
},
"item": {
"labValuesOne": "1 valore di laboratorio",
+9 -7
View File
@@ -12308,7 +12308,7 @@
"title": "타임라인",
"subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.",
"standing": "지속",
"startMissing": "시작일 없음",
"startUnknown": "시작일 미상",
"openDay": "{date} 열기",
"zoom": {
"all": "전체",
@@ -12433,6 +12433,7 @@
"legendMeanMonth": "월평균",
"legendMeanWeek": "주간 평균",
"legendThin": "측정 3회 미만",
"legendGap": "측정 없는 짧은 공백",
"today": "오늘",
"noDataYet": "아직 데이터 없음",
"layers": "레이어",
@@ -12446,8 +12447,8 @@
"description": "날짜가 있는 기록이 생기면 여기에 나타나요."
},
"values": {
"menuLabel": "최대 여섯 가지 수치",
"none": "수치 없음",
"menuTitle": "레인 아래 수치",
"trigger": "수치 ({count})",
"noneInWindow": "이 기간에 수치 없음",
"series": {
"BLOOD_PRESSURE_SYS": "수축기 혈압",
@@ -12470,7 +12471,7 @@
},
"selection": {
"empty": "이 기간에는 항목이 없습니다",
"ongoing": "진행 중",
"since": "{date}부터",
"range": "{from}~{to}",
"mean": "Ø {values}",
"week": "{date} 주",
@@ -12481,8 +12482,8 @@
"months": "월",
"years": "연도",
"groupingLabel": "묶기",
"gap": "{from}~{to} · 기록 없음",
"gapSingle": "{period} · 기록 없음",
"empty": "{from}~{to} 기록 없음",
"emptySingle": "{period} 기록 없음",
"ended": "{label} 끝남",
"afterDaysOne": "{count}일 후",
"afterDaysFew": "{count}일 후",
@@ -12504,7 +12505,8 @@
"to": "종료",
"label": "선택한 기간",
"open": "기간 선택",
"clear": "전체 보기"
"clear": "전체 보기",
"apply": "적용"
},
"item": {
"labValuesOne": "검사 수치 {count}개",
+9 -7
View File
@@ -12308,7 +12308,7 @@
"title": "Oś czasu",
"subtitle": "Co i kiedy się działo, obok Twoich wartości.",
"standing": "Stałe",
"startMissing": "Brak daty rozpoczęcia",
"startUnknown": "początek nieznany",
"openDay": "Otwórz {date}",
"zoom": {
"all": "Wszystko",
@@ -12433,6 +12433,7 @@
"legendMeanMonth": "Średnia miesięczna",
"legendMeanWeek": "Średnia tygodniowa",
"legendThin": "Mniej niż 3 pomiary",
"legendGap": "Krótka przerwa bez pomiarów",
"today": "Dziś",
"noDataYet": "Jeszcze brak danych",
"layers": "Warstwy",
@@ -12446,8 +12447,8 @@
"description": "Wpisy z datą pojawią się tutaj, gdy tylko jakieś będą."
},
"values": {
"menuLabel": "Do sześciu wartości",
"none": "Brak wartości",
"menuTitle": "Wartości pod ścieżkami",
"trigger": "Wartości ({count})",
"noneInWindow": "Brak wartości w tym okresie",
"series": {
"BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe",
@@ -12470,7 +12471,7 @@
},
"selection": {
"empty": "Brak wpisów w tym okresie",
"ongoing": "trwa",
"since": "od {date}",
"range": "od {from} do {to}",
"mean": "Ø {values}",
"week": "Tydzień od {date}",
@@ -12481,8 +12482,8 @@
"months": "Miesiące",
"years": "Lata",
"groupingLabel": "Grupowanie",
"gap": "Od {from} do {to} · brak wpisów",
"gapSingle": "{period} · brak wpisów",
"empty": "Brak wpisów od {from} do {to}",
"emptySingle": "Brak wpisów: {period}",
"ended": "Koniec: {label}",
"afterDaysOne": "po 1 dniu",
"afterDaysFew": "po {count} dniach",
@@ -12504,7 +12505,8 @@
"to": "Do",
"label": "Wybrany zakres",
"open": "Wybierz zakres",
"clear": "Pokaż wszystko"
"clear": "Pokaż wszystko",
"apply": "Zastosuj"
},
"item": {
"labValuesOne": "1 wynik badania",
+1 -1
View File
@@ -45,7 +45,7 @@ export const GET = apiHandler(async (request: Request) => {
}
const seriesKeys = parseSeriesKeys(parsed.data.values);
if (seriesKeys === null) {
return apiError("Unknown value series, or more than six", 422, {
return apiError("Unknown value series", 422, {
errorCode: "timeline.invalid",
});
}
@@ -48,6 +48,7 @@ describe("chronicleEntries", () => {
label: "x",
sub: null,
href: null,
group: null,
});
expect(chronicleEntries(tl, TODAY).some((e) => e.date > TODAY)).toBe(false);
});
@@ -0,0 +1,233 @@
/**
* v1.42 (#613) — one medication is one thing. The server sends a medication
* as up to four kinds of item (its own span, its courses, its dose changes,
* its pauses), all tagged with the medication's id as `group`. Drawn item by
* item, "Mounjaro 7.5 mg" and "Mounjaro" were two bars on two rows for one
* medication. These pin the grouping every surface reads through.
*/
import { describe, expect, it } from "vitest";
import type { TimelineItem } from "@/lib/day/contract";
import {
doseAt,
doseSegments,
medicationGroups,
medicationListItems,
medicationPeriodItem,
strayDoses,
} from "../medication-rows";
import { item } from "./timeline-fixture";
const TODAY = "2025-12-31";
/** A GLP-1 medication with a course, three doses and a pause. */
function mounjaro(): TimelineItem[] {
return [
item({
id: "mj",
group: "mj",
kind: "medication",
start: "2025-01-06",
open: true,
label: "Mounjaro",
sub: "7.5 mg",
}),
item({
id: "mj-course",
group: "mj",
kind: "course",
start: "2025-01-06",
open: true,
label: "Mounjaro",
}),
item({
id: "mj-25",
group: "mj",
kind: "doseChange",
start: "2025-01-06",
label: "Mounjaro",
sub: "2.5 mg",
}),
item({
id: "mj-5",
group: "mj",
kind: "doseChange",
start: "2025-03-03",
label: "Mounjaro",
sub: "5 mg",
}),
item({
id: "mj-75",
group: "mj",
kind: "doseChange",
start: "2025-06-02",
label: "Mounjaro",
sub: "7.5 mg",
}),
item({
id: "mj-pause",
group: "mj",
kind: "pause",
start: "2025-08-01",
end: "2025-08-20",
label: "Mounjaro",
}),
];
}
/** A standing medication without courses and with one dose change. */
function ramipril(): TimelineItem[] {
return [
item({
id: "rp",
group: "rp",
kind: "medication",
start: "2024-02-01",
open: true,
label: "Ramipril",
sub: "5 mg",
}),
item({
id: "rp-5",
group: "rp",
kind: "doseChange",
start: "2025-04-14",
label: "Ramipril",
sub: "5 mg",
}),
];
}
describe("medicationGroups", () => {
it("makes one group per medication, however many items it arrives as", () => {
const groups = medicationGroups([...ramipril(), ...mounjaro()]);
expect(groups.map((g) => [g.key, g.label])).toEqual([
["rp", "Ramipril"],
["mj", "Mounjaro"],
]);
const mj = groups[1];
// The course is the stretch it was taken; its own span is not drawn
// beside it.
expect(mj.periods.map((p) => p.id)).toEqual(["mj-course"]);
expect(mj.doses.map((d) => d.id)).toEqual(["mj-25", "mj-5", "mj-75"]);
expect(mj.pauses.map((p) => p.id)).toEqual(["mj-pause"]);
// Without a course, the medication's own span is the stretch.
expect(groups[0].periods.map((p) => p.id)).toEqual(["rp"]);
});
it("keeps an item without a group on its own", () => {
const groups = medicationGroups([
item({ id: "a", kind: "course", start: "2025-01-01", end: "2025-02-01" }),
item({ id: "b", kind: "course", start: "2025-03-01", end: "2025-04-01" }),
]);
expect(groups.map((g) => g.key)).toEqual(["a", "b"]);
});
});
describe("dose segments", () => {
it("cuts a stretch at each dose change and names each piece's dose", () => {
const [mj] = medicationGroups(mounjaro());
const pieces = doseSegments(mj, mj.periods[0]);
expect(
pieces.map(({ start, end, dose }) => ({ start, end, dose })),
).toEqual([
{ start: "2025-01-06", end: "2025-03-02", dose: "2.5 mg" },
{ start: "2025-03-03", end: "2025-06-01", dose: "5 mg" },
{ start: "2025-06-02", end: null, dose: "7.5 mg" },
]);
expect(pieces.map((p) => [p.first, p.last])).toEqual([
[true, false],
[false, false],
[false, true],
]);
});
it("leaves the dose before the first recorded change unnamed", () => {
const [rp] = medicationGroups(ramipril());
expect(doseSegments(rp, rp.periods[0]).map((p) => p.dose)).toEqual([
null,
"5 mg",
]);
expect(doseAt(rp, "2024-06-01")).toBeNull();
// With no dose change at all, the medication's own dose.
const [plain] = medicationGroups([ramipril()[0]]);
expect(doseAt(plain, "2024-06-01")).toBe("5 mg");
});
it("keeps a dose change outside every stretch as a mark of its own", () => {
const items = [
item({
id: "c1",
group: "vd",
kind: "course",
start: "2024-11-01",
end: "2025-03-31",
label: "Vitamin D",
}),
item({
id: "vd-dose",
group: "vd",
kind: "doseChange",
start: "2025-06-01",
label: "Vitamin D",
sub: "1000 IE",
}),
];
const [vd] = medicationGroups(items);
expect(strayDoses(vd).map((d) => d.id)).toEqual(["vd-dose"]);
});
});
describe("medicationListItems", () => {
it("lists a start once, with the dose taken then, and folds a dose change on that day", () => {
const listed = medicationListItems(mounjaro());
expect(listed.map((i) => [i.id, i.kind, i.sub])).toEqual([
["mj-course", "course", "2.5 mg"],
["mj-5", "doseChange", "5 mg"],
["mj-75", "doseChange", "7.5 mg"],
["mj-pause", "pause", null],
]);
});
});
describe("medicationPeriodItem", () => {
const [mj] = medicationGroups(mounjaro());
it("names a dose change in the period with the new dose, from its day", () => {
expect(
medicationPeriodItem(mj, "2025-03-01", "2025-03-31", TODAY),
).toMatchObject({
id: "mj",
kind: "medication",
label: "Mounjaro",
sub: "5 mg",
start: "2025-03-03",
open: true,
});
});
it("names a pause in the period as a pause", () => {
expect(
medicationPeriodItem(mj, "2025-08-01", "2025-08-31", TODAY),
).toMatchObject({
id: "mj",
kind: "pause",
label: "Mounjaro",
start: "2025-08-01",
end: "2025-08-20",
});
});
it("leaves out a month where nothing happens to a running medication", () => {
expect(
medicationPeriodItem(mj, "2025-10-01", "2025-10-31", TODAY),
).toBeNull();
});
it("names the start month once, with the first dose", () => {
expect(
medicationPeriodItem(mj, "2025-01-01", "2025-01-31", TODAY),
).toMatchObject({ sub: "2.5 mg", start: "2025-01-06" });
});
});
@@ -12,6 +12,10 @@
* running items and its events) and asserts that the visible text holds no
* upper-case code and no sentinel. A new kind or a new code that is not
* worded fails here instead of on a screenshot.
*
* The timeline's surfaces also hold no middle dot: an entry's parts stand
* apart by a space, a comma or a line of their own, never by "·" (the beta
* read "Ramipril resumed · after 11 days" as one run-on line).
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -88,6 +92,9 @@ function WithFormat({
return <>{build(useSeriesValueFormat())}</>;
}
/** The separator glyph the timeline no longer joins anything with. */
const MIDDLE_DOT = "\u00b7";
/** Every day of September 2026, one per item, so one month holds them all. */
let nextDay = 1;
const day = () => `2026-09-${String(nextDay++).padStart(2, "0")}`;
@@ -174,6 +181,7 @@ function everyKind(): TimelineResponse {
items: [
item({
id: "med",
group: "med",
kind: "medication",
start: "2026-09-01",
open: true,
@@ -182,6 +190,7 @@ function everyKind(): TimelineResponse {
}),
item({
id: "course",
group: "vit-d",
kind: "course",
start: "2026-09-05",
end: "2026-09-25",
@@ -189,6 +198,7 @@ function everyKind(): TimelineResponse {
}),
item({
id: "dose",
group: "med",
kind: "doseChange",
start: "2026-09-10",
label: "Ramipril",
@@ -196,6 +206,7 @@ function everyKind(): TimelineResponse {
}),
item({
id: "pause",
group: "med",
kind: "pause",
start: "2026-09-14",
end: "2026-09-24",
@@ -243,6 +254,7 @@ function everyKind(): TimelineResponse {
],
standing: [],
series: [],
availableSeries: [],
notable: [],
};
// The fixture itself must reach every kind, or the guard proves less.
@@ -278,6 +290,7 @@ describe.each<Locale>(["de", "en"])("the timeline in %s", (locale) => {
),
);
expect(rawCodes(text)).toEqual([]);
expect(text).not.toContain(MIDDLE_DOT);
});
it("words every code in the selection bar", () => {
@@ -301,6 +314,7 @@ describe.each<Locale>(["de", "en"])("the timeline in %s", (locale) => {
);
expect(text).toContain(locale === "de" ? "5 Laborwerte" : "5 lab values");
expect(rawCodes(text)).toEqual([]);
expect(text).not.toContain(MIDDLE_DOT);
});
it("words every code in the screen-reader table", () => {
@@ -326,6 +340,7 @@ describe.each<Locale>(["de", "en"])("the timeline in %s", (locale) => {
);
const table = html.slice(html.indexOf('data-slot="timeline-table"'));
expect(rawCodes(textOf(table))).toEqual([]);
expect(textOf(html)).not.toContain(MIDDLE_DOT);
});
it("words every code in the lane labels and their hover titles", () => {
@@ -342,6 +357,7 @@ describe.each<Locale>(["de", "en"])("the timeline in %s", (locale) => {
const labels = layout.lanes.flatMap((l) => l.labels.map((x) => x.text));
expect(labels.length).toBeGreaterThan(10);
expect(rawCodes(labels.join("\n"))).toEqual([]);
expect(labels.join("\n")).not.toContain(MIDDLE_DOT);
const titles = textOf(
render(
@@ -375,6 +391,7 @@ describe.each<Locale>(["de", "en"])("the timeline in %s", (locale) => {
expect(rawCodes(titles.replace(/timeline\.selection\.\w+/g, ""))).toEqual(
[],
);
expect(titles).not.toContain(MIDDLE_DOT);
});
});
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
SERIES_FALLBACK_COLOR,
SERIES_PALETTE,
assignSeriesColors,
preferredSeriesColor,
@@ -10,7 +9,8 @@ import { VALUE_OPTIONS } from "../timeline-view";
/**
* A value line takes the colour its type carries in the measurement list,
* and no two chosen lines ever share one.
* no two of the first five chosen lines share one, and from the sixth on
* the five data tokens repeat in palette order.
*/
describe("assignSeriesColors", () => {
it("gives a type on its own the colour it has in the list", () => {
@@ -26,18 +26,18 @@ describe("assignSeriesColors", () => {
}
});
it("only ever hands out data tokens, and the foreground once they run out", () => {
it("only ever hands out data tokens", () => {
for (const key of VALUE_OPTIONS) {
const own = preferredSeriesColor(key);
if (own) expect(SERIES_PALETTE).toContain(own);
}
});
it("never repeats a colour among six chosen lines", () => {
// Every six-line choice from the selector's options.
it("never repeats a colour among five chosen lines", () => {
// Every five-line choice from the selector's options.
const options = [...VALUE_OPTIONS];
const pick = (from: number, chosen: string[]): string[][] =>
chosen.length === 6
chosen.length === 5
? [chosen]
: options
.slice(from)
@@ -46,11 +46,28 @@ describe("assignSeriesColors", () => {
expect(choices.length).toBeGreaterThan(100);
for (const keys of choices) {
const colours = assignSeriesColors(keys, VALUE_OPTIONS);
expect(colours.size).toBe(6);
expect(new Set(colours.values()).size).toBe(6);
for (const colour of colours.values()) {
expect([...SERIES_PALETTE, SERIES_FALLBACK_COLOR]).toContain(colour);
}
expect(colours.size).toBe(5);
expect(new Set(colours.values()).size).toBe(5);
}
});
it("repeats the five data tokens in order past the fifth line, never the foreground", () => {
const keys = [
...VALUE_OPTIONS,
"BODY_TEMPERATURE",
"VO2_MAX",
"OXYGEN_SATURATION",
"RESPIRATORY_RATE",
];
expect(keys.length).toBe(15);
const colours = assignSeriesColors(keys, keys);
expect(colours.size).toBe(15);
const used = [...colours.values()];
for (const colour of used) expect(SERIES_PALETTE).toContain(colour);
// Every token is used before any is used again, and none more than
// three times across fifteen lines.
for (const token of SERIES_PALETTE) {
expect(used.filter((c) => c === token).length).toBe(3);
}
});
@@ -156,7 +156,7 @@ describe("chronicle", () => {
)}
/>,
);
expect(html).toContain("April bis Oktober 2026 · keine Einträge");
expect(html).toContain("Keine Einträge von April bis Oktober 2026");
expect(html).toContain("Dauerhaft");
expect(html).toContain("2 Allergien");
expect(html).toContain("Erkältung vorbei");
@@ -209,7 +209,7 @@ describe("chronicle", () => {
)}
/>,
);
expect(text(html)).toContain("129/82 mmHg · 82,6 kg");
expect(text(html)).toContain("129/82 mmHg, 82,6 kg");
});
it("names a quarter once, at its newest month, with its span", () => {
@@ -275,8 +275,11 @@ describe("selection bar", () => {
it("lists the month's entries and offers the selected day", () => {
const html = bar("2026-01-03");
expect(html).toContain("Januar 2026");
expect(html).toContain("Erkältung · 31. Dez. bis 8. Jan.");
expect(html).toContain("Vitamin D (Winter) · laufend");
expect(html).toContain("Erkältung, 31. Dez. bis 8. Jan.");
// A closed course that runs through the month reads its own days; no
// "ongoing" word stands for them.
expect(html).toContain("Vitamin D (Winter), 1. Nov. bis 31. März");
expect(html).not.toContain("laufend");
expect(html).not.toContain("Bluthochdruck");
expect(html).toContain("3. Jan. öffnen");
expect(html).toContain('data-date="2026-01-03"');
@@ -284,22 +287,22 @@ describe("selection bar", () => {
it("names a pause as a pause", () => {
const html = text(bar("2021-06-05"));
expect(html).toContain("Ramipril pausiert · 1. Juni bis 11. Juni");
expect(html).toContain("Ramipril pausiert, 1. Juni bis 11. Juni");
});
it("names the bucket's means with the readings behind each", () => {
expect(text(bar("2026-01-03"))).toContain(
"Ø Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen) · Gewicht 82,6 kg (Schnitt aus 3 Messungen)",
"Ø Blutdruck 129/82 mmHg (Schnitt aus 4 Messungen), Gewicht 82,6 kg (Schnitt aus 3 Messungen)",
);
expect(text(bar("2026-07-15"))).toContain(
"Ø Blutdruck sys. 131 mmHg (1 Messung) · Blutdruck dia. kein Wert · Gewicht kein Wert",
"Ø Blutdruck sys. 131 mmHg (1 Messung), Blutdruck dia. kein Wert, Gewicht kein Wert",
);
});
it("says a missing month has no value instead of interpolating one", () => {
const html = bar("2026-02-10");
expect(text(html)).toContain(
"Ø Blutdruck sys. kein Wert · Blutdruck dia. kein Wert · Gewicht kein Wert",
"Ø Blutdruck sys. kein Wert, Blutdruck dia. kein Wert, Gewicht kein Wert",
);
// January's 129 and March's 133 would make 131 between them.
expect(html).not.toMatch(/\d mmHg/);
@@ -361,7 +364,7 @@ describe("selection bar head and hint", () => {
expect(html).toContain('data-period-from="2026-01-01"');
expect(html).toContain('data-period-to="2026-03-31"');
expect(text(html)).toContain("Januar bis März 2026");
expect(text(html)).toContain("Erkältung · 31. Dez. bis 8. Jan.");
expect(text(html)).toContain("Erkältung, 31. Dez. bis 8. Jan.");
});
it("calls a bucket empty only when it holds neither an entry nor a value", () => {
@@ -481,11 +484,14 @@ describe("value line colours", () => {
);
for (const key of ["BLOOD_PRESSURE_SYS", "WEIGHT"]) {
const part = html.slice(html.indexOf(`data-series="${key}"`));
expect(part.slice(0, part.indexOf("</span>"))).toContain(
const dot = part.slice(part.indexOf('data-slot="timeline-series-dot"'));
expect(dot.slice(0, dot.indexOf(">"))).toContain(
`style="background:${seriesColor(key)}"`,
);
}
expect(text(html)).toBe("129/82 mmHg · 82,6 kg");
// Apart by space on screen, a comma for a screen reader; never a dot.
expect(text(html)).toBe("129/82 mmHg, 82,6 kg");
expect(html).toContain('class="sr-only">, </span>');
});
});
@@ -1,7 +1,7 @@
/**
* A made-up record for the timeline tests: a chronic condition and several
* episodes, two allergies, a standing medication with a dose change, a winter
* course three years running, a medication whose start is unknown, visits
* episodes, two allergies, a standing medication with a dose change and a
* pause, a winter course three years running (one medication, one row), a medication whose start is unknown, visits
* with two procedures, documents, vaccinations and three life events. All
* data invented.
*/
@@ -35,6 +35,7 @@ export function item(
label: over.id,
sub: null,
href: null,
group: null,
...over,
};
}
@@ -142,6 +143,7 @@ export function fullTimeline(): TimelineResponse {
items: [
item({
id: "med-1",
group: "med-1",
kind: "medication",
start: "2019-04-02",
open: true,
@@ -150,6 +152,7 @@ export function fullTimeline(): TimelineResponse {
}),
item({
id: "dose-1",
group: "med-1",
kind: "doseChange",
start: "2020-08-12",
label: "Ramipril",
@@ -157,6 +160,7 @@ export function fullTimeline(): TimelineResponse {
}),
item({
id: "med-2",
group: "med-2",
kind: "medication",
start: "2022-11-03",
end: "2022-12-10",
@@ -164,6 +168,7 @@ export function fullTimeline(): TimelineResponse {
}),
item({
id: "course-1",
group: "vit-d",
kind: "course",
start: "2023-11-01",
end: "2024-03-31",
@@ -171,6 +176,7 @@ export function fullTimeline(): TimelineResponse {
}),
item({
id: "course-2",
group: "vit-d",
kind: "course",
start: "2024-11-01",
end: "2025-03-31",
@@ -178,6 +184,7 @@ export function fullTimeline(): TimelineResponse {
}),
item({
id: "course-3",
group: "vit-d",
kind: "course",
start: "2025-11-01",
end: "2026-03-31",
@@ -185,6 +192,7 @@ export function fullTimeline(): TimelineResponse {
}),
item({
id: "pause-1",
group: "med-1",
kind: "pause",
start: "2021-06-01",
end: "2021-06-11",
@@ -275,6 +283,7 @@ export function fullTimeline(): TimelineResponse {
points: [{ t: "2026-01-01", mean: 82.6, count: 3 }],
},
],
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
@@ -198,16 +198,156 @@ describe("layoutLane", () => {
expect(layout.rows).toBe(2);
});
it("lays a dose change and a pause over the medication they belong to", () => {
it("draws one row per medication: its dose changes cut the bar, its pause is a gap in it", () => {
const lane = fullTimeline().lanes.find((l) => l.key === "medications")!;
const layout = layoutLane({ lane, top: 0 }, window, scale, opts);
const host = layout.spans.find((s) => s.item.id === "med-1")!;
expect(layout.points.find((p) => p.item.id === "dose-1")?.row).toBe(
host.row,
// Ramipril, Ibuprofen and the winter Vitamin D: three medications,
// three rows, though the lane holds seven items.
expect(layout.rows).toBe(3);
const rowOf = (id: string) =>
new Set(
[...layout.spans, ...layout.points]
.filter((m) => m.item.id === id)
.map((m) => m.row),
);
// Ramipril's bar is cut at its dose change into two pieces, each
// carrying its own dose, on one row with its pause.
const ramipril = layout.spans.filter(
(s) => s.item.id === "med-1" && !s.pause,
);
expect(ramipril.map((s) => s.segment?.dose)).toEqual([null, "5 mg"]);
expect(ramipril.map((s) => s.segment?.change?.id ?? null)).toEqual([
null,
"dose-1",
]);
expect(ramipril[0].xEnd).toBeLessThan(ramipril[1].xStart);
const pause = layout.spans.find((s) => s.item.id === "pause-1")!;
expect(pause.pause).toBe(true);
expect(pause.row).toBe(host.row);
expect([...rowOf("med-1")]).toEqual([pause.row]);
// The dose change is no mark of its own any more: it is the cut.
expect(layout.points.some((p) => p.item.id === "dose-1")).toBe(false);
// Three winter courses of one medication share one row.
const vitamin = new Set([
...rowOf("course-1"),
...rowOf("course-2"),
...rowOf("course-3"),
]);
expect(vitamin.size).toBe(1);
expect(vitamin.has(pause.row)).toBe(false);
});
it("names the dose of a piece that runs into the window, set by a change before it", () => {
const lane = fullTimeline().lanes.find((l) => l.key === "medications")!;
const year = { from: "2024-01-01", to: "2024-12-31" };
const layout = layoutLane(
{ lane, top: 0 },
year,
createScale(year, 164, 1200),
opts,
);
// Ramipril's first piece ended in 2020 and is not drawn; the piece in
// the window carries the dose of the 2020 change, outside the window.
const ramipril = layout.spans.filter(
(s) => s.item.id === "med-1" && !s.pause,
);
expect(ramipril.map((s) => s.segment?.dose)).toEqual(["5 mg"]);
expect(ramipril[0].clippedLeft).toBe(true);
expect(layout.labels.map((l) => l.text)).toContain("Ramipril 5 mg");
// Ibuprofen (2022) has nothing in 2024: no row for it.
expect(layout.spans.some((s) => s.item.id === "med-2")).toBe(false);
expect(layout.rows).toBe(2);
// The 2021 pause lies outside the window and is not drawn at its edge.
expect(layout.spans.some((s) => s.item.id === "pause-1")).toBe(false);
});
it("does not draw a medication twice when it has a course", () => {
// One medication sent as its own span, a course and two dose changes:
// the Mounjaro case, once two bars on two rows.
const items = [
item({
id: "mj",
group: "mj",
kind: "medication",
start: "2025-01-06",
open: true,
label: "Mounjaro",
sub: "7,5 mg",
}),
item({
id: "mj-course",
group: "mj",
kind: "course",
start: "2025-01-06",
open: true,
label: "Mounjaro",
}),
item({
id: "mj-25",
group: "mj",
kind: "doseChange",
start: "2025-01-06",
label: "Mounjaro",
sub: "2,5 mg",
}),
item({
id: "mj-5",
group: "mj",
kind: "doseChange",
start: "2025-03-03",
label: "Mounjaro",
sub: "5 mg",
}),
item({
id: "mj-75",
group: "mj",
kind: "doseChange",
start: "2025-06-02",
label: "Mounjaro",
sub: "7,5 mg",
}),
item({
id: "mj-pause",
group: "mj",
kind: "pause",
start: "2025-08-01",
end: "2025-08-20",
label: "Mounjaro",
}),
];
const wide = createScale(
{ from: "2025-01-01", to: "2025-12-31" },
164,
1400,
);
const layout = layoutLane(
{ lane: { key: "medications", items }, top: 0 },
{ from: "2025-01-01", to: "2025-12-31" },
wide,
{ ...opts, today: "2025-12-31" },
);
expect(layout.rows).toBe(1);
const pieces = layout.spans.filter((s) => !s.pause);
expect(pieces.every((s) => s.item.id === "mj-course")).toBe(true);
expect(pieces.map((s) => s.segment?.dose)).toEqual([
"2,5 mg",
"5 mg",
"7,5 mg",
]);
// Only the last piece runs on; only the first could carry a lead-in.
expect(pieces.map((s) => s.segment?.last)).toEqual([false, false, true]);
expect(pieces.map((s) => s.segment?.first)).toEqual([true, false, false]);
// The name stands once, with the first dose; the later doses above
// where they begin.
expect(layout.labels.map((l) => l.text)).toEqual([
"Mounjaro 2,5 mg",
"5 mg",
"7,5 mg",
"Mounjaro pausiert",
]);
const pause = layout.spans.find((s) => s.pause)!;
expect(pause.row).toBe(0);
expect(pause.xStart).toBeGreaterThan(pieces[2].xStart);
expect(pause.xEnd).toBeLessThan(pieces[2].xEnd);
});
it("labels a pause as a pause, above the medication it interrupts", () => {
@@ -247,7 +387,7 @@ describe("layoutLane", () => {
sub: "5 mg",
});
expect(itemLine(unknown, WORDS, "Startdatum fehlt")).toBe(
"Ramipril · 5 mg · Startdatum fehlt",
"Ramipril 5 mg (Startdatum fehlt)",
);
const layout = layoutLane(
{ lane: { key: "medications", items: [unknown] }, top: 0 },
@@ -540,9 +680,10 @@ describe("interaction", () => {
TODAY,
);
const ids = entries.map((e) => e.item.id);
expect(ids).toEqual(["ill-5", "course-3", "v-4"]);
// The winter course is named by its medication, once.
expect(ids).toEqual(["ill-5", "vit-d", "v-4"]);
// The winter course neither starts nor ends in January: it runs through.
expect(entries.find((e) => e.item.id === "course-3")?.through).toBe(true);
expect(entries.find((e) => e.item.id === "vit-d")?.through).toBe(true);
expect(entries.find((e) => e.item.id === "ill-5")?.through).toBe(false);
// Open since 2019 and 2018: the backdrop of every month, left out.
expect(ids).not.toContain("ill-chronic");
@@ -0,0 +1,417 @@
/**
* v1.42 (#613) — the timeline after the beta round:
*
* - one medication is one entry on the phone and in the selection bar,
* with its dose in the text;
* - as many value lines as the record has kinds of value, drawn one row
* each, the five data colours repeating past the fifth;
* - the value picker is one compact button, "Values (n)";
* - choosing a range opens over the page and does not push the chart;
* - the legend explains the dashed stroke and the hollow point, in every
* language the app ships.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { TimelineResponse, TimelineSeries } from "@/lib/day/contract";
import { I18nProvider } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import { ZoomControl } from "../range-picker";
import { SelectionBar } from "../selection-bar";
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 { ValueSeriesMenu } from "../timeline-menus";
import { TimelineLegend } from "../timeline-view";
import { useSeriesValueFormat } from "../use-series-value-format";
import { item, wordsIn } from "./timeline-fixture";
const LOCALES: Locale[] = ["de", "en", "es", "fr", "it", "pl", "ko"];
function render(node: React.ReactElement, locale: Locale = "en") {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
function text(html: string): string {
let out = "";
let inTag = false;
for (const ch of html) {
if (ch === "<") inTag = true;
else if (ch === ">") inTag = false;
else if (!inTag) out += ch;
}
return out;
}
function WithFormat({
build,
}: {
build: (format: SeriesValueFormat) => React.ReactNode;
}) {
return <>{build(useSeriesValueFormat())}</>;
}
const TODAY = "2025-12-31";
const noop = () => undefined;
/** Two medications as the server sends them: four bars, drawn item by item. */
function twoMedications(): TimelineResponse {
return {
zoom: "year",
range: { from: "2025-01-01", to: TODAY, dataFrom: "2024-02-01" },
bucket: "month",
lanes: [
{
key: "medications",
items: [
item({
id: "rp",
group: "rp",
kind: "medication",
start: "2024-02-01",
open: true,
label: "Ramipril",
sub: "5 mg",
}),
item({
id: "rp-course",
group: "rp",
kind: "course",
start: "2025-02-01",
end: "2025-05-31",
label: "Ramipril",
}),
item({
id: "mj",
group: "mj",
kind: "medication",
start: "2025-01-06",
open: true,
label: "Mounjaro",
sub: "7.5 mg",
}),
item({
id: "mj-course",
group: "mj",
kind: "course",
start: "2025-01-06",
open: true,
label: "Mounjaro",
}),
item({
id: "mj-25",
group: "mj",
kind: "doseChange",
start: "2025-01-06",
label: "Mounjaro",
sub: "2.5 mg",
}),
item({
id: "mj-5",
group: "mj",
kind: "doseChange",
start: "2025-03-03",
label: "Mounjaro",
sub: "5 mg",
}),
item({
id: "mj-pause",
group: "mj",
kind: "pause",
start: "2025-08-01",
end: "2025-08-20",
label: "Mounjaro",
}),
],
},
],
standing: [],
series: [],
availableSeries: [],
notable: [],
};
}
describe("one medication, one entry", () => {
it("lays two medications on two rows, not four", () => {
const layout = layoutTimeline({
width: 1200,
window: { from: "2025-01-01", to: TODAY },
lanes: twoMedications().lanes,
series: [],
bucket: "month",
words: wordsIn("en"),
startMissing: "start unknown",
today: TODAY,
});
const lane = layout.lanes[0];
expect(lane.rows).toBe(2);
expect(new Set(lane.spans.map((s) => s.row))).toEqual(new Set([0, 1]));
const names = lane.labels.map((l) => l.text);
expect(names.filter((n) => n.startsWith("Mounjaro "))).toEqual([
"Mounjaro 2.5 mg",
"Mounjaro paused",
]);
expect(names).toContain("5 mg");
});
it("names a medication once in the selection bar, with its dose", () => {
const html = render(
<WithFormat
build={(format) => (
<SelectionBar
timeline={twoMedications()}
selected="2025-03-10"
today={TODAY}
seriesLabel={(key) => key}
seriesColor={() => "var(--chart-1)"}
seriesFormat={format}
onOpenDay={noop}
/>
)}
/>,
);
const chips = [
...html.matchAll(
/data-slot="timeline-selection-chip"[^>]*>(.*?)<\/button>/g,
),
].map((m) => text(m[1]));
expect(chips.filter((c) => c.startsWith("Mounjaro"))).toHaveLength(1);
expect(chips.find((c) => c.startsWith("Mounjaro"))).toMatch(
/^Mounjaro 5 mg, since \S/,
);
// Ramipril's course runs through March, closed: its own days, once.
expect(chips.filter((c) => c.startsWith("Ramipril"))).toHaveLength(1);
expect(text(html)).not.toMatch(/ongoing/i);
});
it("lists a medication's start once on the phone, with the dose taken then", () => {
const html = text(
render(
<WithFormat
build={(format) => (
<TimelineChronicle
timeline={twoMedications()}
today={TODAY}
grouping="month"
selected={null}
seriesColor={() => "var(--chart-1)"}
seriesFormat={format}
onOpenDay={noop}
onEditLifeEvent={null}
/>
)}
/>,
),
);
// One "Mounjaro" chip in the running block, one start row with its
// first dose, the next dose as its own row, the pause and its end.
expect(html.match(/Mounjaro2\.5 mg/g)).toHaveLength(1);
expect(html.match(/Mounjaro5 mg/g)).toHaveLength(1);
expect(html).toContain("Mounjaro paused");
expect(html).toContain("Mounjaro resumed");
expect(html).not.toContain("7.5 mg");
});
});
/** Fifteen value lines, a point a month for a year. */
function fifteenSeries(): TimelineSeries[] {
const keys = [
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"WEIGHT",
"RESTING_HEART_RATE",
"PULSE",
"SLEEP_DURATION",
"BLOOD_GLUCOSE",
"BODY_FAT",
"HEART_RATE_VARIABILITY",
"ACTIVITY_STEPS",
"MOOD",
"BODY_TEMPERATURE",
"VO2_MAX",
"OXYGEN_SATURATION",
"RESPIRATORY_RATE",
];
return keys.map((key, i) => ({
key,
unit: null,
points: Array.from({ length: 12 }, (_, m) => ({
t: `2025-${String(m + 1).padStart(2, "0")}-01`,
mean: 50 + i + m,
count: 5,
})),
}));
}
describe("no cap on value lines", () => {
it("lays out fifteen lines, one row each, the chart growing downwards", () => {
const series = fifteenSeries();
const started = performance.now();
const layout = layoutTimeline({
width: 1200,
window: { from: "2025-01-01", to: TODAY },
lanes: twoMedications().lanes,
series,
bucket: "month",
words: wordsIn("en"),
startMissing: "start unknown",
today: TODAY,
});
const took = performance.now() - started;
expect(layout.series).toHaveLength(15);
expect(layout.series.every((s) => s.points.length === 12)).toBe(true);
const lanesBottom = layout.lanes.at(-1)!.top + layout.lanes.at(-1)!.height;
expect(layout.seriesTop).toBe(lanesBottom + SERIES_GAP);
expect(layout.height).toBe(layout.seriesTop + 15 * SERIES_HEIGHT + 4);
// Rows never overlap.
for (let i = 1; i < layout.series.length; i++) {
expect(layout.series[i].top).toBe(
layout.series[i - 1].top + SERIES_HEIGHT,
);
}
// Generous for a slow runner; the layout is arithmetic over 180 points.
expect(took).toBeLessThan(250);
});
it("draws every line, each named, the five data colours repeating", () => {
const series = fifteenSeries();
const colours = assignSeriesColors(series.map((s) => s.key));
const layout = layoutTimeline({
width: 1200,
window: { from: "2025-01-01", to: TODAY },
lanes: [],
series,
bucket: "month",
words: wordsIn("en"),
startMissing: "",
today: TODAY,
});
const html = render(
<WithFormat
build={(format) => (
<svg>
<SeriesLines
layout={layout}
width={1200}
selectedBucket={null}
bucket="month"
seriesLabel={(key) => `Line ${key}`}
seriesColor={(key) => colours.get(key)!}
intl="en-GB"
t={(key) => key}
fmt={format}
/>
</svg>
)}
/>,
);
const drawn = [
...html.matchAll(/data-series="([A-Z0-9_]+)" data-color="([^"]+)"/g),
];
expect(drawn).toHaveLength(15);
for (const [, , colour] of drawn) {
expect(SERIES_PALETTE).toContain(colour);
}
expect(html).not.toContain('var(--foreground)" color');
for (const s of series) expect(text(html)).toContain(`Line ${s.key}`);
});
});
describe("the value picker", () => {
it("is one button that counts the chosen lines and lists none of them", () => {
const chosen = fifteenSeries().map((s) => s.key);
for (const locale of LOCALES) {
const html = render(
<ValueSeriesMenu
options={chosen}
selected={chosen}
label={(key) => `Name of ${key}`}
color={() => "var(--chart-1)"}
onChange={noop}
/>,
locale,
);
expect(html).toContain('data-slot="timeline-values-trigger"');
expect(html).toContain('data-count="15"');
expect(text(html)).toContain("(15)");
expect(text(html)).not.toContain("Name of");
expect(text(html)).not.toMatch(/timeline\./);
}
});
});
describe("choosing a range", () => {
it("keeps the fields out of the page until the range segment opens them", () => {
const html = render(
<ZoomControl
options={[
{ value: "all", label: "All" },
{ value: "year", label: "12 months" },
{ value: "quarter", label: "3 months" },
{ value: "range", label: "Range" },
]}
zoom="range"
label="View range"
initialRange={{ from: "2025-03-01", to: "2025-04-15" }}
today={TODAY}
dataFrom="2024-02-01"
onZoom={noop}
onApply={noop}
/>,
);
// The zoom control alone: no date fields in the flow above the chart,
// so opening and closing them cannot move it.
expect(html).toContain('data-slot="timeline-zoom"');
expect(html).toContain('data-value="range"');
expect(html).toMatch(
/data-value="range"[^>]*aria-checked="true"|aria-checked="true"[^>]*data-value="range"/,
);
expect(html).not.toContain('data-slot="timeline-range"');
expect(html).not.toContain("timeline-range-from");
});
});
describe("the legend", () => {
it.each(LOCALES)(
"explains the dashed stroke and the hollow point, each with its symbol (%s)",
(locale) => {
const html = render(<TimelineLegend bucket="month" hasSeries />, locale);
for (const slot of ["timeline-legend-gap", "timeline-legend-thin"]) {
const start = html.indexOf(`data-slot="${slot}"`);
expect(start).toBeGreaterThan(-1);
const entry = html.slice(start, html.indexOf("</span>", start));
expect(entry).toContain("<svg");
expect(text(`<x ${entry}`).trim().length).toBeGreaterThan(3);
}
const gap = html.slice(html.indexOf('data-slot="timeline-legend-gap"'));
expect(gap.slice(0, gap.indexOf("</svg>"))).toContain(
'stroke-dasharray="3 3"',
);
const thin = html.slice(html.indexOf('data-slot="timeline-legend-thin"'));
expect(thin.slice(0, thin.indexOf("</svg>"))).toContain(
'fill="var(--card)"',
);
expect(text(html)).not.toMatch(/timeline\./);
expect(text(html)).not.toContain("·");
},
);
it("leaves the value marks out when no line is drawn", () => {
const html = render(<TimelineLegend bucket="month" hasSeries={false} />);
expect(html).not.toContain("timeline-legend-gap");
expect(html).not.toContain("timeline-legend-thin");
});
});
+34 -9
View File
@@ -9,9 +9,14 @@
* runs for a while and ends (an episode, a winter course) is drawn as a thin
* rail beside the rows it spans.
*
* Empty stretches are said out loud: "April to August 2025 · no entries"
* rather than months silently skipped, so a gap reads as a gap and not as a
* list that forgot something.
* Empty stretches are said out loud: "No entries from April to August
* 2025" rather than months silently skipped, so a gap reads as a gap and not
* as a list that forgot something.
*
* A medication is listed once per thing that happened to it
* (`medicationListItems`): its start with the dose taken then, each later
* dose change, each pause and its end, and its end. A course and the
* medication it belongs to never both start on the same row.
*/
import type {
DayNotableKind,
@@ -27,6 +32,7 @@ import {
monthsBetween,
startOfMonth,
} from "./timeline-dates";
import { groupKey, medicationListItems } from "./medication-rows";
import { LANE_ORDER, isSpan } from "./timeline-geometry";
export type ChronicleGrouping = "month" | "year";
@@ -71,6 +77,13 @@ export interface StandingChip {
count: number;
}
/** A lane's items as a list shows them: a medication's once per event. */
function listItems(lane: TimelineLane): readonly TimelineItem[] {
return lane.key === "medications"
? medicationListItems(lane.items)
: lane.items;
}
/** The group a date belongs to: `YYYY-MM-01` or `YYYY-01-01`. */
export function groupOf(date: string, grouping: ChronicleGrouping): string {
return grouping === "month"
@@ -100,7 +113,8 @@ function groupsBetween(
/**
* The Ongoing block: what has no start (`standing`) and every period still
* open, each period named by `label` (`item-words.ts`, so a running pause
* reads as paused). Allergies fold into one chip when there is more than one.
* reads as paused). A medication is one chip, its running pause before its
* running course. Allergies fold into one chip when there is more than one.
*/
export function standingChips(
timeline: Pick<TimelineResponse, "lanes" | "standing">,
@@ -116,9 +130,20 @@ export function standingChips(
for (const key of LANE_ORDER) {
if (key === "life" || key === "cycle") continue;
const lane = timeline.lanes.find((l) => l.key === key);
for (const item of lane?.items ?? []) {
if (item.open)
push({ lane: key, id: item.id, label: label(item), count: 1 });
const open = (lane?.items ?? []).filter((item) => item.open);
const shown = new Set<string>();
// A pause first: a medication on pause reads as paused, not as taken.
const ordered =
key === "medications"
? [...open].sort(
(a, b) => Number(b.kind === "pause") - Number(a.kind === "pause"),
)
: open;
for (const item of ordered) {
const id = key === "medications" ? groupKey(item) : item.id;
if (shown.has(id)) continue;
shown.add(id);
push({ lane: key, id, label: label(item), count: 1 });
}
}
const ordered = chips.sort(
@@ -143,7 +168,7 @@ export function chronicleEntries(
): ChronicleEntry[] {
const out: ChronicleEntry[] = [];
for (const lane of timeline.lanes) {
for (const item of lane.items) {
for (const item of listItems(lane)) {
if (!isSpan(item)) {
out.push({
kind: "item",
@@ -196,7 +221,7 @@ function railSpans(lanes: readonly TimelineLane[]) {
const out: Array<{ lane: RailLane; start: string; end: string }> = [];
for (const lane of lanes) {
if (!(RAIL_LANES as readonly string[]).includes(lane.key)) continue;
for (const item of lane.items) {
for (const item of listItems(lane)) {
if (item.open || !item.end || item.end === item.start) continue;
out.push({
lane: lane.key as RailLane,
+6 -5
View File
@@ -124,7 +124,8 @@ export function makeItemWords(
/**
* The line an item reads as: its label, its second line (a dose change's
* dose is drawn as its own mark, so it is left out here) and the
* unknown-start note.
* unknown-start note in brackets: "Ramipril 5 mg (start unknown)". The
* parts are set apart by a space, never by a separator glyph.
*/
export function itemLine(
item: TimelineItem,
@@ -132,10 +133,10 @@ export function itemLine(
startMissing: string,
): string {
const { label, sub } = words(item);
const parts = [label];
if (sub && item.kind !== "doseChange") parts.push(sub);
if (!item.startKnown) parts.push(startMissing);
return parts.filter(Boolean).join(" · ");
const base = [label, item.kind === "doseChange" ? null : sub]
.filter(Boolean)
.join(" ");
return item.startKnown || !startMissing ? base : `${base} (${startMissing})`;
}
/** {@link makeItemWords} with the bundle in hand. */
+256
View File
@@ -0,0 +1,256 @@
/**
* One medication, one row (v1.42, #613).
*
* The server reads a medication from four tables and sends what each holds:
* the medication's own start and end, its courses, its dose changes and its
* pauses, every one of them tagged with the medication's id as `group`.
* Drawn item by item, a medication with a course read as two medications
* ("Mounjaro 7.5 mg" above "Mounjaro"), and a dose change as a third thing
* beside them. Here the items of one medication become one thing again:
*
* - the stretches it was taken are its courses, or its own span when it
* has none (the span of a medication with courses covers the gaps
* between them, when it was not taken);
* - each dose change cuts the stretch it falls into, and every piece
* carries the dose in effect, so the bar reads "2.5 mg", then "5 mg";
* - a pause is a gap in the same bar.
*
* The chart, the selection bar, the phone chronicle and the screen-reader
* table all read a medication through here, so they agree on what it is.
*/
import type { TimelineItem } from "@/lib/day/contract";
import { dayKey, dayNumber } from "./timeline-dates";
/** What every item of one medication says together. */
export interface MedicationGroup {
key: string;
/** The medication's name. */
label: string;
href: string | null;
/** The stretches it was taken, oldest first: its courses, or its span. */
periods: TimelineItem[];
/** Taken once (a one-time medication): a point, not a stretch. */
points: TimelineItem[];
pauses: TimelineItem[];
/** Dose changes, oldest first. */
doses: TimelineItem[];
/** The medication's own item, when it was sent. */
medication: TimelineItem | null;
}
/** The group an item belongs to; an item without one stands alone. */
export function groupKey(item: TimelineItem): string {
return item.group ?? item.id;
}
const byStart = (a: TimelineItem, b: TimelineItem) =>
a.start < b.start ? -1 : a.start > b.start ? 1 : 0;
/** A span is anything with an end or still open; the rest are points. */
function spans(item: TimelineItem): boolean {
return item.open || (item.end !== null && item.end !== item.start);
}
/** The medications lane's items, one group per medication, oldest first. */
export function medicationGroups(
items: readonly TimelineItem[],
): MedicationGroup[] {
const byKey = new Map<string, TimelineItem[]>();
for (const item of items) {
const key = groupKey(item);
const list = byKey.get(key);
if (list) list.push(item);
else byKey.set(key, [item]);
}
const groups: MedicationGroup[] = [];
for (const [key, list] of byKey) {
const medication = list.find((i) => i.kind === "medication") ?? null;
const courses = list.filter((i) => i.kind === "course").sort(byStart);
const own = medication && courses.length === 0 ? [medication] : [];
const stretches = [...courses, ...own];
groups.push({
key,
label: medication?.label ?? list[0].label,
href: medication?.href ?? list[0].href,
periods: stretches.filter(spans).sort(byStart),
points: stretches.filter((i) => !spans(i)).sort(byStart),
pauses: list.filter((i) => i.kind === "pause").sort(byStart),
doses: list.filter((i) => i.kind === "doseChange").sort(byStart),
medication,
});
}
const first = (g: MedicationGroup) =>
[...g.periods, ...g.points, ...g.pauses, ...g.doses]
.map((i) => i.start)
.sort()[0] ?? "";
return groups.sort((a, b) =>
first(a) < first(b) ? -1 : first(a) > first(b) ? 1 : 0,
);
}
/**
* The dose in effect on `day`: the last dose change on or before it. With
* no dose change at all, the medication's own dose; before the first one,
* nothing, because what was taken then is not recorded.
*/
export function doseAt(group: MedicationGroup, day: string): string | null {
if (group.doses.length === 0) return group.medication?.sub ?? null;
let dose: string | null = null;
for (const change of group.doses) {
if (change.start > day) break;
dose = change.sub;
}
return dose;
}
/** One piece of a stretch: the days one dose was taken. */
export interface DoseSegment {
start: string;
/** Last day, inclusive; null while the stretch is open. */
end: string | null;
dose: string | null;
/** The dose change the piece begins with, or null for the first piece. */
change: TimelineItem | null;
first: boolean;
last: boolean;
}
/** A stretch cut at every dose change that falls inside it. */
export function doseSegments(
group: MedicationGroup,
period: TimelineItem,
): DoseSegment[] {
const end = period.open ? null : (period.end ?? period.start);
const cuts = group.doses.filter(
(d) => d.start > period.start && (end === null || d.start <= end),
);
const out: DoseSegment[] = [];
let start = period.start;
let change: TimelineItem | null = null;
for (const cut of cuts) {
if (cut.start === start) {
change = cut;
continue;
}
out.push({
start,
end: dayKey(dayNumber(cut.start) - 1),
dose: change ? change.sub : doseAt(group, start),
change,
first: false,
last: false,
});
start = cut.start;
change = cut;
}
out.push({
start,
end,
dose: change ? change.sub : doseAt(group, start),
change,
first: false,
last: false,
});
out[0].first = true;
out[out.length - 1].last = true;
return out;
}
/** Dose changes that fall into no stretch: drawn as a mark of their own. */
export function strayDoses(group: MedicationGroup): TimelineItem[] {
return group.doses.filter(
(d) =>
!group.periods.some(
(p) => d.start >= p.start && (p.open || d.start <= (p.end ?? p.start)),
),
);
}
/**
* The medications lane as the phone chronicle and the screen-reader table
* list it: one medication's start and end once (from its courses when it has
* any), each with the dose taken then, and a dose change that falls on a
* start folded into that start rather than listed beside it.
*/
export function medicationListItems(
items: readonly TimelineItem[],
): TimelineItem[] {
const out: TimelineItem[] = [];
for (const group of medicationGroups(items)) {
const starts = new Set(
[...group.periods, ...group.points].map((p) => p.start),
);
for (const p of [...group.periods, ...group.points]) {
out.push({ ...p, label: group.label, sub: doseAt(group, p.start) });
}
out.push(...group.pauses);
out.push(...group.doses.filter((d) => !starts.has(d.start)));
}
return out.sort(byStart);
}
/**
* One medication in the selection bar for the period `[from, to]`, or null
* when nothing of it happens there. The entry is one item, in the shapes
* the bar already words: a pause in the period names it as paused; else a
* dose change in the period names the new dose from its day; else the
* stretch that touches the period, with the dose taken at its end. A
* stretch open since before the period is its backdrop and left out, as
* every other lane leaves it.
*/
export function medicationPeriodItem(
group: MedicationGroup,
from: string,
to: string,
today: string,
): TimelineItem | null {
const touches = (item: TimelineItem) => {
const end = item.open ? today : (item.end ?? item.start);
return item.start <= to && end >= from;
};
const pause = group.pauses.filter(touches).at(-1);
if (pause) return { ...pause, id: group.key, label: group.label };
const stretches = [...group.periods, ...group.points]
.filter(touches)
.sort(byStart);
const change = group.doses
.filter((d) => d.start >= from && d.start <= to)
.at(-1);
const base = {
id: group.key,
kind: "medication" as const,
label: group.label,
href: group.href,
group: group.key,
precision: "DAY" as const,
};
if (change) {
const host = stretches.find(
(s) =>
s.start <= change.start &&
(s.open || (s.end ?? s.start) >= change.start),
);
return {
...base,
start: change.start,
end: host ? host.end : null,
open: host ? host.open : false,
startKnown: true,
sub: change.sub,
};
}
const last = stretches.at(-1);
if (!last) return null;
if (last.open && last.start < from) return null;
const end = last.open ? today : (last.end ?? last.start);
return {
...base,
start: last.start,
end: last.end,
open: last.open,
startKnown: last.startKnown,
precision: last.precision,
sub: doseAt(group, end < to ? end : to),
};
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
/**
* Choosing a range (v1.42, #613). On a wide screen the "Range" segment of
* the zoom control opens a popover anchored to it; on a phone the range
* button opens a sheet. Both hold the two date fields and "Apply", and
* neither pushes the chart down: the timeline stays where it was until the
* range is applied, and only then does the URL change (`?zoom=range&from=
* &to=`), so Back and a bookmark keep working as before.
*
* The fields edit a draft. Each change is made honest on the way in
* (`RangeFields` clamps it), and the draft starts as the range in the URL,
* or, before there is one, as what the chart shows.
*/
import { useState, type ReactElement } from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent } from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import type { TimelineZoom } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
import { RangeFields } from "./range-fields";
import { Segmented, type SegmentedOption } from "./segmented";
import type { TimelineRange } from "./timeline-url";
interface RangeLimits {
today: string;
/** The record's first entry, once known. */
dataFrom: string | null;
}
/** The zoom control, with the range popover hung on its "Range" segment. */
export function ZoomControl({
options,
zoom,
label,
initialRange,
today,
dataFrom,
onZoom,
onApply,
}: RangeLimits & {
options: ReadonlyArray<SegmentedOption<TimelineZoom>>;
zoom: TimelineZoom;
label: string;
/** Where the draft starts when the popover opens. */
initialRange: TimelineRange;
onZoom: (next: Exclude<TimelineZoom, "range">) => void;
onApply: (range: TimelineRange) => void;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(initialRange);
const anchor = (value: TimelineZoom, button: ReactElement) =>
value === "range" ? (
<PopoverPrimitive.Anchor asChild>{button}</PopoverPrimitive.Anchor>
) : (
button
);
return (
<Popover open={open} onOpenChange={setOpen}>
<Segmented
options={options}
value={zoom}
onChange={(next) => {
if (next !== "range") {
setOpen(false);
onZoom(next);
return;
}
setDraft(initialRange);
setOpen(true);
}}
label={label}
slot="timeline-zoom"
wrapOption={anchor}
/>
<PopoverContent
align="start"
className="w-auto max-w-none space-y-4 p-4 text-sm"
data-slot="timeline-range-popover"
aria-label={t("timeline.range.open")}
>
<p className="font-semibold">{t("timeline.range.open")}</p>
<RangeFields
range={draft}
today={today}
dataFrom={dataFrom}
onChange={setDraft}
/>
<div className="flex justify-end">
<Button
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="timeline-range-apply"
onClick={() => {
onApply(draft);
setOpen(false);
}}
>
{t("timeline.range.apply")}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
/** The phone's range sheet: the fields, "Apply", and the way back. */
export function RangeSheet({
open,
onOpenChange,
initialRange,
today,
dataFrom,
onApply,
onClear,
}: RangeLimits & {
open: boolean;
onOpenChange: (open: boolean) => void;
initialRange: TimelineRange;
onApply: (range: TimelineRange) => void;
/** Back to the whole record; offered only while a range is chosen. */
onClear: (() => void) | null;
}) {
const { t } = useTranslations();
const [draft, setDraft] = useState(initialRange);
// The draft starts over each time the sheet opens.
const [wasOpen, setWasOpen] = useState(open);
if (open !== wasOpen) {
setWasOpen(open);
if (open) setDraft(initialRange);
}
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("timeline.range.open")}
footer={
<>
{onClear && (
<Button
variant="outline"
className="min-h-11"
data-slot="timeline-range-clear"
onClick={() => {
onClear();
onOpenChange(false);
}}
>
{t("timeline.range.clear")}
</Button>
)}
<Button
className="min-h-11"
data-slot="timeline-range-apply"
onClick={() => {
onApply(draft);
onOpenChange(false);
}}
>
{t("timeline.range.apply")}
</Button>
</>
}
>
<RangeFields
range={draft}
today={today}
dataFrom={dataFrom}
onChange={setDraft}
/>
</ResponsiveSheet>
);
}
+16 -1
View File
@@ -9,6 +9,8 @@
* the sheets' discard guard reads any checked radio as the person's input,
* and a precision that starts on "Day" is a default, not an answer.
*/
import { Fragment, type ReactElement, type ReactNode } from "react";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { cn } from "@/lib/utils";
@@ -26,6 +28,7 @@ export function Segmented<T extends string>({
className,
stretch = false,
asToggleButtons = false,
wrapOption,
}: {
options: ReadonlyArray<SegmentedOption<T>>;
value: T;
@@ -35,6 +38,11 @@ export function Segmented<T extends string>({
className?: string;
stretch?: boolean;
asToggleButtons?: boolean;
/**
* Wraps one segment's button, for a popover anchored to it (the range
* segment). Returns the button itself for every other segment.
*/
wrapOption?: (value: T, button: ReactElement) => ReactNode;
}) {
const selectedIndex = options.findIndex((o) => o.value === value);
const { getRadioProps } = useRovingRadioGroup({
@@ -55,7 +63,7 @@ export function Segmented<T extends string>({
>
{options.map((option, index) => {
const checked = option.value === value;
return (
const button = (
<button
key={option.value}
type="button"
@@ -79,6 +87,13 @@ export function Segmented<T extends string>({
{option.label}
</button>
);
return wrapOption ? (
<Fragment key={option.value}>
{wrapOption(option.value, button)}
</Fragment>
) : (
button
);
})}
</div>
);
+25 -17
View File
@@ -1,7 +1,7 @@
"use client";
/**
* The bar under the chart (v1.42, #613): a cross-section of one bucket, the
* The cross-section under the chart (v1.42, #613): one bucket, the
* one that holds the selected day. Its head names the bucket outright ("July
* to September 2026", "September 2026", "Week of 22 September 2026"), so a
* value that changes with the zoom says why: it is the mean of a different
@@ -12,6 +12,12 @@
*
* It is also where a label the chart had to leave out is read in full, and
* where each keyboard step on the chart is announced (`aria-live`).
*
* It sits on the card's own surface, set off from the chart by a hairline
* 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`).
*/
import { ArrowRight } from "lucide-react";
@@ -86,7 +92,7 @@ export function SelectionBar({
return (
<div
className="bg-muted/60 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg px-3 py-2.5"
className="border-border flex flex-wrap items-center gap-x-3 gap-y-2 border-t pt-3"
data-slot="timeline-selection-bar"
data-bucket={bucket}
data-period-from={periodFrom}
@@ -122,32 +128,34 @@ export function SelectionBar({
{t("timeline.selection.empty")}
</span>
)}
{entries.map(({ lane, item, through }) => {
{entries.map(({ lane, item }) => {
// A chip opens the day its entry touches inside this period.
const day =
item.start >= periodFrom && item.start <= periodTo
? item.start
: periodFrom;
const canOpenDay = item.precision === "DAY" && day <= today;
const when =
item.open || through
? t("timeline.selection.ongoing")
: item.end && item.end !== item.start
? t("timeline.selection.range", {
from: formatDayMonth(item.start, intl),
to: formatDayMonth(item.end, intl),
})
: item.precision === "DAY"
? formatDayMonth(item.start, intl)
: formatAtPrecision(item.start, item.precision, intl);
const at = (date: string) =>
item.precision === "DAY"
? formatDayMonth(date, intl)
: formatAtPrecision(date, item.precision, intl);
const when = item.open
? t("timeline.selection.since", { date: at(item.start) })
: item.end && item.end !== item.start
? t("timeline.selection.range", {
from: at(item.start),
to: at(item.end),
})
: at(item.start);
const { label, sub } = words(item);
const text = [label, sub, when].filter(Boolean).join(" · ");
const name = [label, sub].filter(Boolean).join(" ");
const text = `${name}, ${when}`;
const editable = item.kind === "lifeEvent" && onEditLifeEvent;
if (!canOpenDay && !editable) {
return (
<span
key={`${lane}-${item.id}`}
className="bg-background text-foreground text-2xs inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium"
className="bg-muted text-foreground text-2xs inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium"
>
<span
className="size-1.5 shrink-0 rounded-full"
@@ -167,7 +175,7 @@ export function SelectionBar({
onClick={() =>
editable ? onEditLifeEvent(item.id) : onOpenDay(day)
}
className="bg-background text-foreground text-2xs hover:bg-accent focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 rounded-full px-2.5 py-1 font-medium outline-none focus-visible:ring-2 sm:min-h-7"
className="bg-muted text-foreground text-2xs hover:bg-accent focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 rounded-full px-2.5 py-1 font-medium outline-none focus-visible:ring-2 sm:min-h-7"
>
<span
className="size-1.5 shrink-0 rounded-full"
+7 -9
View File
@@ -6,11 +6,11 @@
* same colour on both pages. Where two chosen lines would share a colour
* (systolic and diastolic, or resting pulse beside blood pressure) or a type
* has none (mood), the later one takes the first `--chart-N` still free.
* There are five data tokens and up to six lines; a sixth line with nothing
* free left is drawn in `--foreground`, which is still apart from the other
* five. The order of `rank` decides who keeps a contested colour, so the
* same set of lines is coloured the same way whatever order it was picked
* in.
* There are five data tokens and no cap on the lines: once all five are
* taken, the next lines take them again in palette order. Every line is
* named at its left, so a repeated colour never leaves a line unnamed. The
* order of `rank` decides who keeps a contested colour, so the same set of
* lines is coloured the same way whatever order it was picked in.
*/
import { MEASUREMENT_TYPE_COLORS } from "@/components/measurements/measurement-type-colors";
@@ -22,9 +22,6 @@ export const SERIES_PALETTE = [
"var(--chart-5)",
] as const;
/** The colour once every data token is taken. */
export const SERIES_FALLBACK_COLOR = "var(--foreground)";
/** The type's own colour, as a `var(--chart-N)` string, or null. */
export function preferredSeriesColor(key: string): string | null {
const match = /\btext-chart-([1-5])\b/.exec(
@@ -59,10 +56,11 @@ export function assignSeriesColors(
taken.add(own);
}
}
let repeat = 0;
for (const key of ordered) {
if (out.has(key)) continue;
const free = SERIES_PALETTE.find((c) => !taken.has(c));
const color = free ?? SERIES_FALLBACK_COLOR;
const color = free ?? SERIES_PALETTE[repeat++ % SERIES_PALETTE.length];
out.set(key, color);
taken.add(color);
}
+60 -36
View File
@@ -51,7 +51,8 @@ import type {
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { useItemWords, type ItemWordsFn } from "./item-words";
import { itemLine, useItemWords, type ItemWordsFn } from "./item-words";
import { medicationListItems } from "./medication-rows";
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
import {
bucketAfter,
@@ -168,7 +169,7 @@ export function TimelineChart({
series: timeline.series,
bucket: timeline.bucket,
words,
startMissing: t("timeline.startMissing"),
startMissing: t("timeline.startUnknown"),
today,
})
: null,
@@ -426,9 +427,11 @@ function ChartBody({
>
{fitText(t(TIMELINE_LANE_LABEL_KEY[lane.key]), scale.x0 - 30, 13)}
</text>
{/* A medication's bar is drawn in pieces of one item, so a
piece is keyed by its item and its own first day. */}
{lane.spans.map((span) => (
<SpanMark
key={span.item.id}
key={`${span.item.id}:${span.segment?.start ?? span.item.start}`}
span={span}
color={color}
x0={scale.x0}
@@ -444,9 +447,9 @@ function ChartBody({
intl={intl}
/>
))}
{lane.labels.map((label) => (
{lane.labels.map((label, i) => (
<text
key={`label-${label.itemId}`}
key={`label-${label.itemId}-${i}`}
x={label.x}
y={label.y}
fontSize={11}
@@ -676,20 +679,31 @@ export function SeriesLines({
);
}
/**
* A bar's hover title: "Ramipril 5 mg: 12 Aug 2020 to 1 Jun 2021", or
* "since 12 Aug 2020" while it runs on. A dose's piece of a medication's
* bar names that dose and its own days.
*/
function spanTitle(
span: PlacedSpan,
intl: string,
t: ReturnType<typeof useTranslations>["t"],
words: ItemWordsFn,
): string {
const { item } = span;
const { item, segment } = span;
const { label, sub } = words(item);
const from = formatAtPrecision(item.start, item.precision, intl);
const to = item.open
? t("timeline.selection.ongoing")
: formatAtPrecision(item.end ?? item.start, item.precision, intl);
const name = sub ? `${label} · ${sub}` : label;
return `${name}: ${t("timeline.selection.range", { from, to })}`;
const name = [label, segment ? segment.dose : sub].filter(Boolean).join(" ");
const start = segment?.start ?? item.start;
const end = segment ? segment.end : item.open ? null : item.end;
const from = formatAtPrecision(start, item.precision, intl);
const when =
end === null
? t("timeline.selection.since", { date: from })
: t("timeline.selection.range", {
from,
to: formatAtPrecision(end, item.precision, intl),
});
return `${name}: ${when}`;
}
export function SpanMark({
@@ -710,7 +724,7 @@ export function SpanMark({
const title = spanTitle(span, intl, t, words);
if (span.pause) {
return (
<g data-kind="pause">
<g data-kind="pause" data-item={item.id} data-row={span.row}>
<title>{title}</title>
<rect
x={xStart}
@@ -726,10 +740,19 @@ export function SpanMark({
);
}
const thick = item.open ? 3 : 7;
// Only a bar's first piece draws an unknown start, only its last an open
// end; a bar that is not cut is both.
const first = span.segment?.first ?? true;
const last = span.segment?.last ?? true;
return (
<g data-kind={item.kind} data-item={item.id}>
<g
data-kind={item.kind}
data-item={item.id}
data-dose={span.segment?.dose ?? undefined}
data-row={span.row}
>
<title>{title}</title>
{!item.startKnown && (
{!item.startKnown && first && (
<line
x1={Math.max(x0, xStart - 28)}
x2={xStart}
@@ -750,7 +773,7 @@ export function SpanMark({
fill={color}
opacity={item.open ? 0.75 : 0.9}
/>
{item.open && (
{item.open && last && (
<path
d={`M${xEnd + 1} ${y - 4} l6 4 l-6 4z`}
fill={color}
@@ -781,7 +804,7 @@ export function PointMark({
}) {
const { x, y, item, shape } = point;
const { label, sub } = useItemWords()(item);
const title = `${label}${sub ? ` · ${sub}` : ""}: ${formatAtPrecision(item.start, item.precision, intl)}`;
const title = `${[label, sub].filter(Boolean).join(" ")}: ${formatAtPrecision(item.start, item.precision, intl)}`;
let glyph: React.ReactNode;
switch (shape) {
case "diamond":
@@ -860,7 +883,7 @@ export function PointMark({
);
}
return (
<g data-kind={item.kind} data-item={item.id}>
<g data-kind={item.kind} data-item={item.id} data-row={point.row}>
<title>{title}</title>
{glyph}
</g>
@@ -884,7 +907,12 @@ function TimelineTable({
const words = useItemWords();
const rows = timeline.lanes
.filter((lane) => !hiddenLanes.has(lane.key))
.flatMap((lane) => lane.items.map((item) => ({ lane: lane.key, item })))
.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 (
<table className="sr-only" data-slot="timeline-table">
@@ -901,22 +929,12 @@ function TimelineTable({
{rows.map(({ lane, item }) => (
<tr key={`${lane}-${item.id}`}>
<td>{t(TIMELINE_LANE_LABEL_KEY[lane])}</td>
<td>
{[
words(item).label,
words(item).sub,
item.startKnown ? null : t("timeline.startMissing"),
]
.filter(Boolean)
.join(" · ")}
</td>
<td>{itemLine(item, words, t("timeline.startUnknown"))}</td>
<td>{formatAtPrecision(item.start, item.precision, intl)}</td>
<td>
{item.open
? t("timeline.selection.ongoing")
: isSpan(item) && item.end
? formatAtPrecision(item.end, item.precision, intl)
: ""}
{!item.open && isSpan(item) && item.end
? formatAtPrecision(item.end, item.precision, intl)
: ""}
</td>
</tr>
))}
@@ -1012,7 +1030,9 @@ function SeriesTable({
/**
* A means line with each value marked by its line's colour: "● 129/82 mmHg
* · ● 82,6 kg". The dot is decoration; the text reads the same without it.
* ● 82,6 kg". The values stand apart by space, not by a separator glyph;
* a screen reader hears a comma between them. The dot is decoration; the
* text reads the same without it.
*/
export function MeanPartsLine({
parts,
@@ -1022,8 +1042,12 @@ export function MeanPartsLine({
seriesColor: (key: string) => string;
}) {
return parts.map((part, i) => (
<span key={part.key} data-series={part.key}>
{i > 0 ? " · " : null}
<span
key={part.key}
data-series={part.key}
className={i > 0 ? "ml-3" : undefined}
>
{i > 0 ? <span className="sr-only">, </span> : null}
<span
data-slot="timeline-series-dot"
className="mr-1 inline-block size-2 rounded-full align-middle"
@@ -168,13 +168,16 @@ export function TimelineChronicle({
grouping === "month" ? formatMonthYear(group, intl) : group.slice(0, 4);
const gapLabel = (from: string, to: string) => {
if (from === to)
return t("timeline.chronicle.gapSingle", { period: groupLabel(from) });
return t("timeline.chronicle.emptySingle", { period: groupLabel(from) });
const sameYear = from.slice(0, 4) === to.slice(0, 4);
const fromText =
grouping === "month" && sameYear
? formatMonthLong(from, intl)
: groupLabel(from);
return t("timeline.chronicle.gap", { from: fromText, to: groupLabel(to) });
return t("timeline.chronicle.empty", {
from: fromText,
to: groupLabel(to),
});
};
function entryText(entry: ChronicleEntry): {
@@ -201,9 +204,9 @@ export function TimelineChronicle({
: null,
};
}
const meta = [sub, item.startKnown ? null : t("timeline.startMissing")]
const meta = [sub, item.startKnown ? null : t("timeline.startUnknown")]
.filter(Boolean)
.join(" · ");
.join(", ");
return { title: label, meta: meta || null };
}
+298 -60
View File
@@ -27,6 +27,13 @@ import type {
} from "@/lib/day/contract";
import { itemLine, type ItemWordsFn } from "./item-words";
import {
doseSegments,
medicationGroups,
medicationPeriodItem,
strayDoses,
type MedicationGroup,
} from "./medication-rows";
import {
addMonths,
bucketAfter,
@@ -401,7 +408,6 @@ const POINT_SHAPE: Partial<Record<TimelineItemKind, PointShape>> = {
procedure: "square",
labDay: "ring",
document: "tick",
doseChange: "doseMark",
};
export interface PlacedSpan {
@@ -414,6 +420,21 @@ export interface PlacedSpan {
clippedLeft: boolean;
/** A pause, drawn as an outlined gap over the period it interrupts. */
pause: boolean;
/**
* One dose's piece of a medication's stretch (`medication-rows.ts`): the
* dose it carries, the dose change it begins with, and whether it is the
* stretch's first or last piece (only the first draws an unknown start,
* only the last an open end).
*/
segment?: {
/** The piece's first and last day; `end` null while it runs on. */
start: string;
end: string | null;
dose: string | null;
change: TimelineItem | null;
first: boolean;
last: boolean;
};
}
export interface PlacedPoint {
@@ -463,8 +484,9 @@ interface LaneInput {
/**
* Lay out one lane: rows for the periods (first row that is free), points on
* their own row above them, pauses and dose marks over the medication they
* belong to, then the labels in priority order through one occupancy map.
* their own row above them, then the labels in priority order through one
* occupancy map. The medications lane is laid out per medication
* ({@link layoutMedicationLane}).
*/
export function layoutLane(
{ lane, top }: LaneInput,
@@ -477,16 +499,20 @@ export function layoutLane(
today: string;
},
): LaneLayout {
if (lane.key === "medications") {
return layoutMedicationLane(
lane.key,
top,
lane.items,
window,
scale,
options,
);
}
const visible = lane.items.filter((item) => inWindow(item, window));
const pointItems = visible.filter(
(item) => !isSpan(item) && item.kind !== "doseChange",
);
const doseItems = visible.filter((item) => item.kind === "doseChange");
const pauseItems = visible.filter(
(item) => item.kind === "pause" && isSpan(item),
);
const pointItems = visible.filter((item) => !isSpan(item));
const spanItems = visible
.filter((item) => isSpan(item) && item.kind !== "pause")
.filter((item) => isSpan(item))
.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : 0));
// Points take the first row(s); procedures sit on a second point row so
@@ -529,29 +555,6 @@ export function layoutLane(
});
}
// Pauses lie over the period they interrupt; one without a host gets a row.
for (const item of pauseItems) {
const xStart = Math.max(scale.x0, scale.x(item.start));
const xEnd = Math.min(
scale.x1,
item.end ? scale.x(dayNumber(item.end) + 1) : todayX,
);
const host = spans.find(
(s) => !s.pause && s.xStart <= xStart && s.xEnd >= xStart,
);
const row = host ? host.row : pointRows + rowEnds.length;
if (!host) rowEnds.push(xEnd);
spans.push({
item,
row,
y: rowY(row),
xStart,
xEnd: Math.max(xStart + 4, xEnd),
clippedLeft: false,
pause: true,
});
}
const points: PlacedPoint[] = [];
for (const item of pointItems) {
const row = item.kind === "procedure" ? procedureRow : 0;
@@ -563,14 +566,6 @@ export function layoutLane(
shape: POINT_SHAPE[item.kind] ?? "dot",
});
}
// Dose marks sit on the medication line that runs through their date.
for (const item of doseItems) {
const x = scale.xMid(item.start);
const host = spans.find((s) => !s.pause && s.xStart <= x && s.xEnd >= x);
const row = host ? host.row : pointRows;
if (!host && rowEnds.length === 0) rowEnds.push(x);
points.push({ item, row, y: rowY(row), x, shape: "doseMark" });
}
const rows = Math.max(1, pointRows + rowEnds.length);
const height = LANE_PAD_TOP + (rows - 1) * ROW_HEIGHT + LANE_PAD_BOTTOM;
@@ -585,12 +580,249 @@ export function layoutLane(
return { key: lane.key, top, height, rows, spans, points, labels };
}
/** Air between two dose pieces of one bar, so the cut reads as a cut. */
const SEGMENT_GAP = 1.5;
/**
* The medications lane: one row per medication (`medication-rows.ts`),
* oldest first, for every medication with something inside the window.
* Each stretch it was taken is drawn in pieces, one per dose, a pause lies
* over the stretch as a gap, a one-time medication is a point and a dose
* change outside every stretch keeps its own mark.
*
* A medication is grouped from all its items, not only the ones inside the
* window: the dose of a piece that runs into the window was set by a dose
* change that may lie before it.
*/
function layoutMedicationLane(
key: TimelineLaneKey,
top: number,
items: readonly TimelineItem[],
window: TimeWindow,
scale: Scale,
options: { words: ItemWordsFn; startMissing: string; today: string },
): LaneLayout {
const groups = medicationGroups(items).filter((group) =>
[...group.periods, ...group.points, ...group.pauses, ...group.doses].some(
(item) => inWindow(item, window),
),
);
const rowY = (row: number) => top + LANE_PAD_TOP + row * ROW_HEIGHT;
const todayX = scale.x(dayNumber(options.today) + 1);
const endX = (end: string | null) =>
Math.min(scale.x1, end ? scale.x(dayNumber(end) + 1) : todayX);
const spans: PlacedSpan[] = [];
const points: PlacedPoint[] = [];
groups.forEach((group, row) => {
const y = rowY(row);
for (const period of group.periods) {
const pieces = doseSegments(group, period);
pieces.forEach((piece, i) => {
const rawStart = scale.x(piece.start);
const rawEnd = endX(piece.end);
if (rawStart >= scale.x1 || rawEnd <= scale.x0) return;
const xStart = Math.max(scale.x0, rawStart) + (i > 0 ? SEGMENT_GAP : 0);
spans.push({
item: period,
row,
y,
xStart,
xEnd: Math.min(scale.x1, Math.max(xStart + 4, rawEnd)),
clippedLeft: rawStart < scale.x0,
pause: false,
segment: {
start: piece.start,
end: piece.end,
dose: piece.dose,
change: piece.change,
first: piece.first,
last: piece.last,
},
});
});
}
for (const pause of group.pauses) {
if (!isSpan(pause) || !inWindow(pause, window)) continue;
const xStart = Math.max(scale.x0, scale.x(pause.start));
spans.push({
item: pause,
row,
y,
xStart,
xEnd: Math.max(xStart + 4, endX(pause.end)),
clippedLeft: false,
pause: true,
});
}
for (const point of group.points) {
if (!inWindow(point, window)) continue;
points.push({
item: point,
row,
y,
x: scale.xMid(point.start),
shape: "dot",
});
}
for (const dose of strayDoses(group)) {
if (!inWindow(dose, window)) continue;
points.push({
item: dose,
row,
y,
x: scale.xMid(dose.start),
shape: "doseMark",
});
}
});
const rows = Math.max(1, groups.length);
const height = LANE_PAD_TOP + (rows - 1) * ROW_HEIGHT + LANE_PAD_BOTTOM;
const labels = placeMedicationLabels(
groups,
spans,
points,
scale,
options.words,
options.startMissing,
);
return { key, top, height, rows, spans, points, labels };
}
/**
* A medication's labels: its name once per row, with the dose of the piece
* it stands over ("Mounjaro 2.5 mg"), above the first piece, else right of
* the last, else left of the first; then the dose of every later piece above
* where it begins ("5 mg"), then pauses and stray dose marks, each only
* where it fits.
*/
function placeMedicationLabels(
groups: readonly MedicationGroup[],
spans: readonly PlacedSpan[],
points: readonly PlacedPoint[],
scale: Scale,
words: ItemWordsFn,
startMissing: string,
): PlacedLabel[] {
const occ = new LineOccupancy();
const line = (row: number, where: "on" | "above") => `${row}:${where}`;
for (const s of spans) {
const lead =
s.segment?.first && !s.item.startKnown ? UNKNOWN_START_LEAD : 0;
const arrow = s.item.open && (s.segment?.last ?? true) ? 7 : 0;
occ.add(line(s.row, "on"), s.xStart - lead, s.xEnd + arrow);
}
for (const p of points) {
const half = GLYPH_HALF_WIDTH[p.shape];
occ.add(line(p.row, "on"), p.x - half, p.x + half);
}
const labels: PlacedLabel[] = [];
const fits = (a: number, b: number) => a >= scale.x0 && b <= scale.x1 - 2;
const place = (
id: string,
row: number,
where: "on" | "above",
a: number,
y: number,
text: string,
margin = LABEL_MARGIN,
) => {
const b = a + estimateTextWidth(text);
if (!fits(a, b) || !occ.tryAdd(line(row, where), a, b, margin)) {
return false;
}
labels.push({
itemId: id,
text,
x: a,
y: where === "above" ? y - 7 : y + 4,
strong: false,
});
return true;
};
groups.forEach((group, row) => {
const pieces = spans.filter((s) => s.row === row && !s.pause);
const marks = points.filter((p) => p.row === row);
const first = pieces[0];
if (first) {
const named = itemLine(
{ ...first.item, label: group.label, sub: first.segment?.dose ?? null },
words,
startMissing,
);
const last = pieces[pieces.length - 1];
const a = first.xStart + (first.clippedLeft ? 26 : 2);
const placed =
place(first.item.id, row, "above", a, first.y, named) ||
place(
first.item.id,
row,
"on",
last.xEnd + 6 + (last.item.open ? 7 : 0),
last.y,
named,
BESIDE_MARGIN,
) ||
place(
first.item.id,
row,
"on",
first.xStart - 6 - estimateTextWidth(named),
first.y,
named,
BESIDE_MARGIN,
);
// A later stretch that starts over with a dose of its own says so.
let shown = placed ? (first.segment?.dose ?? null) : null;
for (const piece of pieces.slice(1)) {
const dose = piece.segment?.dose ?? null;
if (!dose || dose === shown) continue;
if (place(piece.item.id, row, "above", piece.xStart + 2, piece.y, dose))
shown = dose;
}
} else if (marks[0]) {
const mark = marks[0];
const named = itemLine(
{ ...mark.item, label: group.label },
words,
startMissing,
);
place(
mark.item.id,
row,
"on",
mark.x + GLYPH_HALF_WIDTH[mark.shape] + BESIDE_MARGIN + 1,
mark.y,
named,
BESIDE_MARGIN,
);
}
for (const pause of spans.filter((s) => s.row === row && s.pause)) {
place(
pause.item.id,
row,
"above",
pause.xStart + 2,
pause.y,
words(pause.item).label,
);
}
for (const mark of marks.filter((p) => p.shape === "doseMark")) {
const dose = words(mark.item).sub;
if (dose) place(mark.item.id, row, "above", mark.x + 2, mark.y, dose);
}
});
return labels;
}
/**
* Labels through one occupancy map per text line. Bars and point glyphs are
* registered first as obstacles, so a label never covers a mark either.
*
* Priority: open periods (they define the lane), life events, procedures,
* closed periods from the longest down, dose marks.
* closed periods from the longest down.
*/
function placeLabels(
spans: PlacedSpan[],
@@ -607,7 +839,6 @@ function placeLabels(
occ.add(line(s.row, "on"), s.xStart - lead, s.xEnd + (s.item.open ? 7 : 0));
}
for (const p of points) {
if (p.shape === "doseMark") continue;
const half = GLYPH_HALF_WIDTH[p.shape];
occ.add(line(p.row, "on"), p.x - half, p.x + half);
}
@@ -615,11 +846,10 @@ function placeLabels(
const labels: PlacedLabel[] = [];
const fitsCard = (a: number, b: number) => a >= scale.x0 && b <= scale.x1 - 2;
const openSpans = spans.filter((s) => s.item.open && !s.pause);
const openSpans = spans.filter((s) => s.item.open);
const closedSpans = spans
.filter((s) => !s.item.open && !s.pause)
.filter((s) => !s.item.open)
.sort((a, b) => b.xEnd - b.xStart - (a.xEnd - a.xStart));
const pausesWithLabel = spans.filter((s) => s.pause);
const aboveLabel = (s: PlacedSpan, text: string) => {
const a = s.xStart + (s.clippedLeft ? 26 : 2);
@@ -696,16 +926,6 @@ function placeLabels(
leftLabel(s.item.id, s.row, s.xStart - 6, s.y, text, strong);
if (placed) shownOnRow.add(rowKey);
}
for (const s of pausesWithLabel) aboveLabel(s, words(s.item).label);
for (const p of points.filter((p) => p.shape === "doseMark")) {
const dose = words(p.item);
const text = dose.sub ?? dose.label;
const a = p.x + 2;
const b = a + estimateTextWidth(text);
if (fitsCard(a, b) && occ.tryAdd(line(p.row, "above"), a, b)) {
labels.push({ itemId: p.item.id, text, x: a, y: p.y - 7, strong: false });
}
}
// A labelled point with no room left is simply drawn without its name.
return labels;
}
@@ -928,8 +1148,10 @@ export function dateAtPointer(
x >= s.xStart - 2 &&
x <= s.xEnd + 2
) {
const startInside = s.item.start >= layout.scale.dayAt(layout.scale.x0);
return startInside ? s.item.start : layout.scale.dayAt(x);
// A dose's piece of a bar selects the day that dose began.
const start = s.segment?.change?.start ?? s.item.start;
const startInside = start >= layout.scale.dayAt(layout.scale.x0);
return startInside ? start : layout.scale.dayAt(x);
}
}
}
@@ -963,7 +1185,10 @@ export function stepSelection(
* since before the period (a chronic condition, a standing medication, an
* allergy) is the backdrop of every period and is left out, as are
* documents, which the day lists with their visit. Lane order kept. The
* same rule holds for a week, a month and a quarter.
* same rule holds for a week, a month and a quarter. A medication is one
* entry however many of its items touch the period
* (`medicationPeriodItem`), so a course and its medication never read as
* two.
*/
export function itemsInPeriod(
lanes: readonly TimelineLane[],
@@ -980,6 +1205,19 @@ export function itemsInPeriod(
if (key === "documents") continue;
const lane = lanes.find((l) => l.key === key);
if (!lane) continue;
if (key === "medications") {
for (const group of medicationGroups(lane.items)) {
const item = medicationPeriodItem(group, from, to, today);
if (!item) continue;
const end = item.open ? today : (item.end ?? item.start);
out.push({
lane: key,
item,
through: item.start < from && !item.open && end > to,
});
}
continue;
}
for (const item of lane.items) {
const end = item.open ? today : (item.end ?? item.start);
if (item.start > to || end < from) continue;
+16 -16
View File
@@ -2,8 +2,12 @@
/**
* The two menus above the chart (v1.42, #613): which value lines run under
* the lanes (at most six, the server's cap), and which lanes show, with the way back into
* the readiness inventory ("Data coverage").
* the lanes, and which lanes show, with the way back into the readiness
* inventory ("Data coverage").
*
* 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.
*/
import { ChartLine, ChevronDown, Layers } from "lucide-react";
@@ -17,14 +21,11 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TIMELINE_MAX_SERIES, type TimelineLaneKey } from "@/lib/day/contract";
import type { TimelineLaneKey } from "@/lib/day/contract";
import { useTranslations } from "@/lib/i18n/context";
import { TIMELINE_LANE_LABEL_KEY } from "./label-keys";
/** The most value lines the chart runs under its lanes. */
export const MAX_VALUE_SERIES = TIMELINE_MAX_SERIES;
export function ValueSeriesMenu({
options,
selected,
@@ -40,34 +41,33 @@ export function ValueSeriesMenu({
onChange: (next: string[]) => void;
}) {
const { t } = useTranslations();
const summary =
selected.length > 0
? selected.map(label).join(", ")
: t("timeline.values.none");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 max-w-full sm:min-h-9"
className="min-h-11 sm:min-h-9"
data-slot="timeline-values-trigger"
data-count={selected.length}
>
<ChartLine className="size-4" aria-hidden="true" />
<span className="truncate">{summary}</span>
{t("timeline.values.trigger", { count: selected.length })}
<ChevronDown className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>{t("timeline.values.menuLabel")}</DropdownMenuLabel>
<DropdownMenuContent
align="end"
className="w-64"
data-slot="timeline-values-menu"
>
<DropdownMenuLabel>{t("timeline.values.menuTitle")}</DropdownMenuLabel>
{options.map((key) => {
const checked = selected.includes(key);
const full = !checked && selected.length >= MAX_VALUE_SERIES;
return (
<DropdownMenuCheckboxItem
key={key}
checked={checked}
disabled={full}
data-slot="timeline-values-option"
data-key={key}
onSelect={(event) => event.preventDefault()}
+148 -70
View File
@@ -3,9 +3,10 @@
/**
* `/timeline` (v1.42, #613): years at a glance. Conditions, allergies,
* medications, vaccinations, visits, documents and the person's own life
* events in lanes, up to six value lines below them, each in its own
* colour (`series-colors.ts`), all on one
* time axis. Nothing on the page draws a connection between them.
* events in lanes, the value lines the person picks below them (as many as
* the record has kinds of value), each in its own colour
* (`series-colors.ts`), all on one time axis. Nothing on the page draws a
* connection between them.
*
* The page hands off to the day view through `?day=`: "Open 3 Jan." (and
* Enter on the chart, a double click, a chip, a chronicle row) pushes the
@@ -17,7 +18,7 @@
* 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.
*/
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { CalendarRange, Plus } from "lucide-react";
@@ -58,13 +59,14 @@ import {
} from "./readiness-model";
import { TimelineReadinessSheet } from "./readiness-sheet";
import { Segmented } from "./segmented";
import { SERIES_FALLBACK_COLOR, assignSeriesColors } from "./series-colors";
import { SERIES_PALETTE, assignSeriesColors } from "./series-colors";
import { useSeriesValueFormat } from "./use-series-value-format";
import { SelectionBar } from "./selection-bar";
import { TimelineChart } from "./timeline-chart";
import { TimelineChronicle } from "./timeline-chronicle";
import { dayKey, dayNumber, todayKeyIn } from "./timeline-dates";
import { RangeFields } from "./range-fields";
import { RangeSheet, ZoomControl } from "./range-picker";
import {
clampRange,
parseTimelineUrl,
@@ -72,11 +74,7 @@ import {
type TimelineRange,
} from "./timeline-url";
import { LANE_ORDER, latestDataDate, windowFor } from "./timeline-geometry";
import {
LayersMenu,
MAX_VALUE_SERIES,
ValueSeriesMenu,
} from "./timeline-menus";
import { LayersMenu, ValueSeriesMenu } from "./timeline-menus";
import {
isBoolean,
isStringArray,
@@ -112,6 +110,21 @@ export const VALUE_OPTIONS = Object.keys(VALUE_OPTION_MODULE) as Array<
keyof typeof VALUE_OPTION_MODULE
>;
/**
* Every series name the page can ask for: each measurement type, and the
* mood score. A stored choice outside it is dropped before it reaches the
* request, so a stale entry never turns the page into a 422.
*/
const KNOWN_SERIES: ReadonlySet<string> = new Set([
...Object.keys(MEASUREMENT_TYPE_LABEL_KEYS),
"MOOD",
]);
/** The order colours are handed out in: the selector's own, then the rest. */
const SERIES_RANK: readonly string[] = [
...new Set([...VALUE_OPTIONS, ...KNOWN_SERIES]),
];
export const DEFAULT_VALUES = [
"BLOOD_PRESSURE_SYS",
"WEIGHT",
@@ -152,6 +165,7 @@ export function TimelineView() {
: (dayParam ?? picked?.date ?? null);
const setSelected = (date: string) => setPicked({ date, param: dayParam });
const [readinessOpen, setReadinessOpen] = useState(false);
const [rangeSheetOpen, setRangeSheetOpen] = useState(false);
const [lifeEventOpen, setLifeEventOpen] = useState(false);
const [editing, setEditing] = useState<LifeEventDTO | null>(null);
@@ -172,30 +186,31 @@ export function TimelineView() {
);
const modules = user?.modules;
const valueOptions = useMemo(
() =>
VALUE_OPTIONS.filter((key) => {
const owner: ModuleKey | null = VALUE_OPTION_MODULE[key];
return owner === null || modules?.[owner] !== false;
}),
// 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((v) => (valueOptions as readonly string[]).includes(v))
.slice(0, MAX_VALUE_SERIES),
[storedValues, valueOptions],
() => 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, VALUE_OPTIONS),
() => assignSeriesColors(values, SERIES_RANK),
[values],
);
const seriesFormat = useSeriesValueFormat();
const seriesColor = (key: string) =>
seriesColors.get(key) ?? SERIES_FALLBACK_COLOR;
seriesColors.get(key) ?? SERIES_PALETTE[0];
const hidden = useMemo(
() => new Set(hiddenList as TimelineLaneKey[]),
[hiddenList],
@@ -257,17 +272,8 @@ export function TimelineView() {
window.history.pushState(null, "", `${pathname}${search}`);
}
function setZoom(next: TimelineZoom) {
if (next !== "range") {
navigate(next, null);
return;
}
// A new range starts as what is on screen, up to today.
const shown = window_ ?? {
from: dayKey(dayNumber(today) - 29),
to: today,
};
navigate("range", clampRange(shown, today, data?.range.dataFrom ?? null));
function setZoom(next: Exclude<TimelineZoom, "range">) {
navigate(next, null);
}
// The shell's day layer owns `?day=` and its history entries; this only
@@ -302,6 +308,23 @@ export function TimelineView() {
zoom === "all" || zoom === "range" ? data.range : null,
)
: null;
// A new range starts as the chosen one, or as what is on screen, up to
// today.
const rangeDraft = clampRange(
range ??
window_ ?? {
from: dayKey(dayNumber(today) - 29),
to: today,
},
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 presentLanes = (data?.lanes ?? [])
.filter((l) => l.items.length > 0)
.map((l) => l.key)
@@ -338,27 +361,17 @@ export function TimelineView() {
</Button>
) : null;
// The chosen range's fields, under the zoom; on a phone with a way back to
// the whole record, since the phone has no zoom control to switch with.
const rangeControls =
// A chosen stretch with nothing in it keeps its fields on the page, so the
// next choice is one step away; everywhere else they open in a popover
// (or a sheet on a phone) and never push the chart down.
const emptyRangeFields =
zoom === "range" && range ? (
<div className="flex flex-wrap items-end gap-3">
<RangeFields
range={range}
today={today}
dataFrom={data?.range.dataFrom ?? null}
onChange={(next) => navigate("range", next)}
/>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-10 md:hidden"
onClick={() => setZoom("all")}
data-slot="timeline-range-clear"
>
{t("timeline.range.clear")}
</Button>
</div>
<RangeFields
range={range}
today={today}
dataFrom={data?.range.dataFrom ?? null}
onChange={(next) => navigate("range", next)}
/>
) : null;
const layers = (iconOnly: boolean) => (
@@ -408,7 +421,7 @@ export function TimelineView() {
// A chosen stretch with nothing in it: the fields stay, so the next
// choice is one step away.
<div className="space-y-4" data-slot="timeline-range-empty">
{rangeControls}
{emptyRangeFields}
<EmptyState
title={t("timeline.selection.empty")}
action={
@@ -435,14 +448,20 @@ export function TimelineView() {
<div
className="hidden space-y-4 md:block"
data-slot="timeline-desktop"
data-zoom={zoom}
data-from={range?.from}
data-to={range?.to}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<Segmented
<ZoomControl
options={zoomOptions}
value={zoom}
onChange={setZoom}
zoom={zoom}
label={t("timeline.zoomLabel")}
slot="timeline-zoom"
initialRange={rangeDraft}
today={today}
dataFrom={data.range.dataFrom}
onZoom={setZoom}
onApply={(next) => navigate("range", next)}
/>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<ValueSeriesMenu
@@ -455,7 +474,6 @@ export function TimelineView() {
{layers(false)}
</div>
</div>
{rangeControls}
<Card className="md:gap-4">
<CardContent className="space-y-4">
<TimelineChart
@@ -482,7 +500,7 @@ export function TimelineView() {
onOpenDay={openDay}
onEditLifeEvent={canAddLifeEvent ? editLifeEvent : undefined}
/>
<Legend
<TimelineLegend
bucket={data.bucket}
hasSeries={data.series.length > 0}
/>
@@ -491,7 +509,13 @@ export function TimelineView() {
</div>
{/* Phone: the chronicle. */}
<div className="space-y-4 md:hidden" data-slot="timeline-mobile">
<div
className="space-y-4 md:hidden"
data-slot="timeline-mobile"
data-zoom={zoom}
data-from={range?.from}
data-to={range?.to}
>
<div className="flex items-center justify-between gap-3">
<Segmented
options={groupingOptions}
@@ -507,7 +531,7 @@ export function TimelineView() {
className="size-11"
aria-label={t("timeline.range.open")}
aria-pressed={zoom === "range"}
onClick={() => setZoom(zoom === "range" ? "all" : "range")}
onClick={() => setRangeSheetOpen(true)}
data-slot="timeline-range-toggle"
>
<CalendarRange className="size-4" aria-hidden="true" />
@@ -515,7 +539,15 @@ export function TimelineView() {
{layers(true)}
</div>
</div>
{rangeControls}
<RangeSheet
open={rangeSheetOpen}
onOpenChange={setRangeSheetOpen}
initialRange={rangeDraft}
today={today}
dataFrom={data.range.dataFrom}
onApply={(next) => navigate("range", next)}
onClear={zoom === "range" ? () => setZoom("all") : null}
/>
<TimelineChronicle
timeline={{
...data,
@@ -549,7 +581,14 @@ export function TimelineView() {
);
}
function Legend({
/**
* 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,
}: {
@@ -559,7 +598,7 @@ function Legend({
const { t } = useTranslations();
return (
<div
className="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs"
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">
@@ -586,11 +625,50 @@ function Legend({
<span className="bg-foreground h-0.5 w-4" aria-hidden="true" />
{t(TIMELINE_LEGEND_MEAN_KEY[bucket])}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="border-foreground bg-card size-2 rounded-full border"
<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>
</>
+3 -8
View File
@@ -274,13 +274,6 @@ export const TIMELINE_RANGE_MAX_DAYS = 15 * 366;
export type TimelineBucket = (typeof TIMELINE_BUCKETS)[number];
/**
* The most value series one request may name. Each series is its own row
* under the lanes, drawn in neutral ink and labelled at the left, so the cap
* is the chart's height, not a palette: six rows add 360 px under the lanes.
*/
export const TIMELINE_MAX_SERIES = 6;
/**
* The lanes, top to bottom. An empty lane is not sent. Each lane a module
* owns is a surface (`timeline-lane:<key>`) and disappears with it.
@@ -288,7 +281,9 @@ export const TIMELINE_MAX_SERIES = 6;
* life life events, and travel periods from the environment module
* illness episodes, chronic conditions as open lines
* allergies allergies with an onset; the rest are `standing`
* medications medications and courses, dose marks, pauses as gaps
* medications medications and courses, dose marks, pauses as gaps; every
* item of one medication carries its id as `group`, and a
* client draws a group on one row
* vaccinations doses
* visits visits and procedures
* labs days with results
+12 -1
View File
@@ -327,7 +327,7 @@ export const timelineQuerySchema = z
.string()
.optional()
.describe(
"Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.",
"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.",
),
})
.refine((q) => q.zoom !== "range" || (q.from && q.to), {
@@ -376,6 +376,12 @@ const timelineItem = z
"The second line, by kind: a code for a life event (its category), a visit or procedure (its encounter kind) and a document (its kind); the analyte count for a lab day; the dose as text for a medication or a dose change. The client words every code.",
),
href: z.string().nullable(),
group: z
.string()
.nullable()
.describe(
"Items that belong to one thing share a group and are drawn on one row: a medication's own span, its courses, its dose changes and its pauses all carry the medication's id. Null for an item that stands alone.",
),
})
.meta({ id: "TimelineItem" });
@@ -442,6 +448,11 @@ export const timelineResponseSchema = z
"The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month` from 120 days, `week` from 42 days, `day` below.",
),
series: z.array(timelineSeries),
availableSeries: z
.array(z.string())
.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`.",
),
notable: z.array(
z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }),
),
+16 -4
View File
@@ -121,17 +121,29 @@ describe("window", () => {
expect(parseSeriesKeys("WEIGHT,DROP TABLE")).toBeNull();
});
it("accepts up to six series and refuses a seventh", () => {
const six = [
it("has no cap on the count: every known series, each once", () => {
const many = [
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"WEIGHT",
"PULSE",
"MOOD",
"BODY_FAT",
"BLOOD_GLUCOSE",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"OXYGEN_SATURATION",
"BODY_TEMPERATURE",
"VO2_MAX",
"RESPIRATORY_RATE",
];
expect(parseSeriesKeys(six.join(","))).toEqual(six);
expect(parseSeriesKeys([...six, "BLOOD_GLUCOSE"].join(","))).toBeNull();
expect(parseSeriesKeys(many.join(","))).toEqual(many);
// A name given twice counts once, so the list is bounded by the names
// that exist.
expect(parseSeriesKeys([...many, ...many].join(","))).toEqual(many);
expect(parseSeriesKeys([...many, "NOPE"].join(","))).toBeNull();
});
});
+16 -2
View File
@@ -10,6 +10,13 @@
* pretend. Something with no date at all is not drawn on the axis; it is
* listed once under `standing`.
*
* A medication is read from four tables (its own start and end, its
* courses, its dose changes, its pauses), so it arrives as several items.
* They share the medication's id as `group`, and a client draws one row per
* group: the courses are the stretches it was taken, the dose changes cut
* them into segments, and a pause is a gap in the same bar. Drawn item by
* item, one medication with a course read as two medications.
*
* Each lane belongs to one sharing domain and, where a module owns it, to
* that module; a lane the caller may not see is not read. The `life` lane
* holds life events (the timeline module's own content) and travel periods
@@ -82,15 +89,18 @@ interface LaneFrame {
access: TimelineAccess;
}
type ItemDefaults = "precision" | "startKnown" | "sub" | "href" | "group";
function item(
rest: Omit<TimelineItem, "precision" | "startKnown" | "sub" | "href"> &
Partial<Pick<TimelineItem, "precision" | "startKnown" | "sub" | "href">>,
rest: Omit<TimelineItem, ItemDefaults> &
Partial<Pick<TimelineItem, ItemDefaults>>,
): TimelineItem {
return {
precision: "DAY",
startKnown: true,
sub: null,
href: null,
group: null,
...rest,
};
}
@@ -308,6 +318,7 @@ async function medicationsLane(frame: LaneFrame): Promise<LaneRead> {
label: med.name,
sub: med.dose,
href,
group: med.id,
}),
);
}
@@ -322,6 +333,7 @@ async function medicationsLane(frame: LaneFrame): Promise<LaneRead> {
open: end === null,
label: c.medication.name,
href: `/medications/${c.medicationId}`,
group: c.medicationId,
}),
);
}
@@ -336,6 +348,7 @@ async function medicationsLane(frame: LaneFrame): Promise<LaneRead> {
label: d.medication.name,
sub: `${d.doseValue} ${d.doseUnit}`,
href: `/medications/${d.medicationId}/history`,
group: d.medicationId,
}),
);
}
@@ -350,6 +363,7 @@ async function medicationsLane(frame: LaneFrame): Promise<LaneRead> {
open: end === null,
label: p.medication.name,
href: `/medications/${p.medicationId}`,
group: p.medicationId,
}),
);
}
+43 -5
View File
@@ -19,7 +19,6 @@ import type { MeasurementType } from "@/generated/prisma/enums";
import {
DAY_NOTABLE_MAX_SPAN_DAYS,
TIMELINE_LANE_KEYS,
TIMELINE_MAX_SERIES,
type TimelineBucket,
type TimelineItem,
type TimelineQuery,
@@ -29,6 +28,7 @@ import {
import { loadNotableRange } from "@/lib/day/notable";
import { measurementTypeVisible } from "@/lib/day/sections";
import { prisma } from "@/lib/db";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
LANE_READERS,
@@ -38,6 +38,7 @@ import {
} from "@/lib/timeline/lanes";
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";
/** The series sent when the request names none. */
export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [
@@ -105,8 +106,9 @@ 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 or there are more than {@link TIMELINE_MAX_SERIES}
* (the route answers 422).
* 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.
*/
export function parseSeriesKeys(raw: string | undefined): string[] | null {
if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES];
@@ -118,13 +120,47 @@ export function parseSeriesKeys(raw: string | undefined): string[] | null {
.filter((k) => k.length > 0),
),
];
if (keys.length > TIMELINE_MAX_SERIES) return null;
for (const key of keys) {
if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null;
}
return keys;
}
/**
* Every series the record can show, whatever the window: the measurement
* types with live readings the caller may see, events left out (they are
* occurrences, not values a mean can be taken of), and the mood score when
* it is visible and has an entry. Measurement types come in the enum's
* order, mood last.
*/
export async function loadAvailableSeries(args: {
recordId: string;
access: TimelineAccess;
}): Promise<string[]> {
const { recordId, access } = args;
const moodVisible =
access.modules.mood !== false && access.domainVisible("mind");
const [types, mood] = await Promise.all([
access.domainVisible("measurements")
? listLiveMeasurementTypes(recordId)
: Promise.resolve([] as MeasurementType[]),
moodVisible
? prisma.moodEntry.findFirst({
where: { userId: recordId, deletedAt: null },
select: { id: true },
})
: Promise.resolve(null),
]);
return [
...types.filter(
(type) =>
!EVENT_MEASUREMENT_TYPES.has(type) &&
measurementTypeVisible(type, access.modules),
),
...(mood ? [MOOD_SERIES_KEY] : []),
];
}
/** Whether an item touches `[from, to]`. An open span runs up to today. */
export function overlaps(
item: Pick<TimelineItem, "start" | "end" | "open">,
@@ -201,7 +237,7 @@ export async function loadTimeline(args: {
? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1))
: from;
const bucket = timelineBucket(query.zoom, from, to);
const [series, notable] = await Promise.all([
const [series, notable, availableSeries] = await Promise.all([
loadTimelineSeries({
userId: recordId,
keys: seriesKeys,
@@ -223,6 +259,7 @@ export async function loadTimeline(args: {
gaps: false,
})
: Promise.resolve([]),
loadAvailableSeries({ recordId, access }),
]);
return {
@@ -232,6 +269,7 @@ export async function loadTimeline(args: {
standing,
bucket,
series,
availableSeries,
notable: notable.map(({ date, kind }) => ({ date, kind })),
};
}
+170 -10
View File
@@ -969,22 +969,182 @@ describe("timeline", () => {
).toBe(422);
}
const six =
"BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,WEIGHT,PULSE,BODY_FAT,MOOD";
// No cap on the count: seven, and an unknown name still refused.
const seven =
"BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,WEIGHT,PULSE,BODY_FAT,MOOD,BLOOD_GLUCOSE";
expect(
(await call(GET as Handler, "GET", `/api/timeline?values=${six}`)).status,
(await call(GET as Handler, "GET", `/api/timeline?values=${seven}`))
.status,
).toBe(200);
expect(
(
await call(
GET as Handler,
"GET",
`/api/timeline?values=${six},BLOOD_GLUCOSE`,
)
).status,
(await call(GET as Handler, "GET", `/api/timeline?values=${seven},NOPE`))
.status,
).toBe(422);
});
it("tags every item of one medication with its id, so it draws on one row", async () => {
const db = getPrismaClient();
const owner = await makeUser("tl-group", { timeline: true });
const med = await db.medication.create({
data: {
userId: owner.id,
name: "Mounjaro",
dose: "7.5 mg",
startsOn: dayKeyAsUtcMidnight("2026-01-05"),
},
});
const other = await db.medication.create({
data: {
userId: owner.id,
name: "Ramipril",
dose: "5 mg",
startsOn: dayKeyAsUtcMidnight("2025-06-01"),
},
});
await db.medicationCourse.create({
data: {
userId: owner.id,
medicationId: med.id,
startsOn: dayKeyAsUtcMidnight("2026-01-05"),
},
});
await db.medicationDoseChange.createMany({
data: [
{
medicationId: med.id,
effectiveFrom: new Date("2026-01-05T08:00:00.000Z"),
doseValue: 2.5,
doseUnit: "mg",
},
{
medicationId: med.id,
effectiveFrom: new Date("2026-02-02T08:00:00.000Z"),
doseValue: 5,
doseUnit: "mg",
},
],
});
await db.medicationPauseEra.create({
data: {
userId: owner.id,
medicationId: med.id,
pausedAt: new Date("2026-03-01T08:00:00.000Z"),
resumedAt: new Date("2026-03-10T08:00:00.000Z"),
},
});
await signIn(owner.id);
const { GET } = await import("@/app/api/timeline/route");
const timeline = await json<import("@/lib/day/contract").TimelineResponse>(
await call(
GET as Handler,
"GET",
"/api/timeline?zoom=year&to=2026-03-31",
),
);
const items = timeline.lanes.find((l) => l.key === "medications")!.items;
const byGroup = new Map<string | null, string[]>();
for (const i of items) {
byGroup.set(i.group, [...(byGroup.get(i.group) ?? []), i.kind]);
}
// Six items, two medications: Mounjaro's own span, its course, its
// two dose changes and its pause all carry its id.
expect(items).toHaveLength(6);
expect([...byGroup.keys()].sort()).toEqual([med.id, other.id].sort());
expect(byGroup.get(med.id)!.sort()).toEqual(
["course", "doseChange", "doseChange", "medication", "pause"].sort(),
);
expect(byGroup.get(other.id)).toEqual(["medication"]);
expect(
items.find((i) => i.kind === "doseChange" && i.start === "2026-02-02"),
).toMatchObject({ label: "Mounjaro", sub: "5 mg", group: med.id });
});
it("offers every value series the record has, and answers fifteen at once", async () => {
const db = getPrismaClient();
const owner = await makeUser("tl-many", {
timeline: true,
mood: true,
glucose: true,
recovery: true,
});
const types = [
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"WEIGHT",
"PULSE",
"BODY_FAT",
"BLOOD_GLUCOSE",
"ACTIVITY_STEPS",
"OXYGEN_SATURATION",
"HEART_RATE_VARIABILITY",
"BODY_TEMPERATURE",
"VO2_MAX",
"RESPIRATORY_RATE",
"MUSCLE_MASS",
"BONE_MASS",
] as const;
// A reading a day for a year, every type: about five thousand rows.
const rows = [];
for (const [i, type] of types.entries()) {
for (let d = 0; d < 365; d++) {
const at = new Date(Date.UTC(2025, 3, 1 + d, 7, i));
rows.push({
userId: owner.id,
type,
value: 50 + (d % 30) + i,
unit: "x",
measuredAt: at,
});
}
}
await db.measurement.createMany({ data: rows });
// An event type is an occurrence, not a value: never offered.
await db.measurement.create({
data: {
userId: owner.id,
type: "HIGH_HEART_RATE_EVENT",
value: 1,
unit: "count",
measuredAt: new Date("2026-01-10T07:00:00.000Z"),
},
});
await db.moodEntry.create({
data: {
userId: owner.id,
date: "2026-01-10",
score: 4,
mood: "GUT",
moodLoggedAt: new Date("2026-01-10T19:00:00.000Z"),
},
});
await signIn(owner.id);
const { GET } = await import("@/app/api/timeline/route");
const values = [...types, "MOOD"].join(",");
expect(values.split(",")).toHaveLength(15);
const started = performance.now();
const res = await call(
GET as Handler,
"GET",
`/api/timeline?zoom=year&to=2026-03-31&values=${values}`,
);
const took = performance.now() - started;
const timeline =
await json<import("@/lib/day/contract").TimelineResponse>(res);
expect(timeline.series).toHaveLength(15);
expect(timeline.series.every((s) => s.points.length > 0)).toBe(true);
expect(timeline.availableSeries).toEqual(
expect.arrayContaining([...types, "MOOD"]),
);
expect(timeline.availableSeries).toHaveLength(15);
expect(timeline.availableSeries).not.toContain("HIGH_HEART_RATE_EVENT");
// Fifteen series over a year of daily readings answer in about a tenth
// of a second against the test database; the bound is generous so a
// slow runner does not fail it, and still catches a read gone quadratic.
expect(took).toBeLessThan(5_000);
});
it("averages quarters over the years, live before the fold window and rolled up after it", async () => {
const db = getPrismaClient();
const owner = await makeUser("tl-quarters", { timeline: true });