mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge the timeline rework into release/v1.42.0
This commit is contained in:
commit
4ca2334805
37 files changed
+2507
-390
No files matched your search
+19
-4
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user