From 1440ad430190fb0fd90892740a64a6719f45c5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 15:02:14 +0200 Subject: [PATCH] feat(timeline): one row per medication, no cap on value lines, range popover A medication arrived as up to four kinds of item (its own span, its courses, its dose changes, its pauses) and was drawn item by item, so a medication with a course read as two bars on two rows. Every item of one medication now carries the medication's id as `group`, and the lane draws one row per group: the courses are the stretches it was taken, each dose change cuts them into pieces labelled with their dose, and a pause is a gap in the same bar. The selection bar and the phone chronicle list one entry per medication with the dose in the text. The value series cap of six is gone. The response carries `availableSeries`, every series the record can show, and the picker is one compact "Values (n)" button whose menu lists them. Past the fifth line the five data colours repeat; every line is named at its left. Choosing a range opens a popover anchored to the "Range" segment (a sheet on a phone) with "Apply", so the chart no longer moves; the URL keeps the range as before. The selection bar sits on the card under a hairline instead of a grey box, the legend explains the dashed stroke and the hollow point, and no timeline text joins its parts with a middle dot or calls an open span "ongoing". --- docs/api/openapi.yaml | 23 +- e2e/timeline.spec.ts | 209 +++++++-- e2e/utils/mock-timeline.ts | 79 ++++ messages/de.json | 16 +- messages/en.json | 16 +- messages/es.json | 16 +- messages/fr.json | 16 +- messages/it.json | 16 +- messages/ko.json | 16 +- messages/pl.json | 16 +- src/app/api/timeline/route.ts | 2 +- .../__tests__/chronicle-model.test.ts | 1 + .../__tests__/medication-rows.test.ts | 233 ++++++++++ .../__tests__/no-raw-code-guard.test.tsx | 17 + .../timeline/__tests__/series-colors.test.ts | 39 +- .../__tests__/timeline-components.test.tsx | 28 +- .../timeline/__tests__/timeline-fixture.ts | 13 +- .../__tests__/timeline-geometry.test.ts | 157 ++++++- .../__tests__/timeline-rework.test.tsx | 417 ++++++++++++++++++ src/components/timeline/chronicle-model.ts | 43 +- src/components/timeline/item-words.ts | 11 +- src/components/timeline/medication-rows.ts | 256 +++++++++++ src/components/timeline/range-picker.tsx | 177 ++++++++ src/components/timeline/segmented.tsx | 17 +- src/components/timeline/selection-bar.tsx | 42 +- src/components/timeline/series-colors.ts | 16 +- src/components/timeline/timeline-chart.tsx | 96 ++-- .../timeline/timeline-chronicle.tsx | 11 +- src/components/timeline/timeline-geometry.ts | 358 ++++++++++++--- src/components/timeline/timeline-menus.tsx | 32 +- src/components/timeline/timeline-view.tsx | 218 ++++++--- src/lib/day/contract.ts | 11 +- src/lib/day/wire-schemas.ts | 13 +- src/lib/timeline/__tests__/series.test.ts | 20 +- src/lib/timeline/lanes.ts | 18 +- src/lib/timeline/load-timeline.ts | 48 +- tests/integration/day-view.test.ts | 180 +++++++- 37 files changed, 2507 insertions(+), 390 deletions(-) create mode 100644 src/components/timeline/__tests__/medication-rows.test.ts create mode 100644 src/components/timeline/__tests__/timeline-rework.test.tsx create mode 100644 src/components/timeline/medication-rows.ts create mode 100644 src/components/timeline/range-picker.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bc5ec628f..23b49afae 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. @@ -81602,6 +81602,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: @@ -81628,6 +81635,7 @@ components: - standing - bucket - series + - availableSeries - notable additionalProperties: false TimelineItem: @@ -81688,6 +81696,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 @@ -81699,6 +81713,7 @@ components: - label - sub - href + - group additionalProperties: false TimelineStandingItem: type: object diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 857d6e83f..ed0f4e542 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -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', diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts index f43a4f72a..c99ebda10 100644 --- a/e2e/utils/mock-timeline.ts +++ b/e2e/utils/mock-timeline.ts @@ -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 & Pick): Item { @@ -33,6 +35,7 @@ function item(over: Partial & Pick): 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: [], }; } diff --git a/messages/de.json b/messages/de.json index 6ebf927d3..94d6a046b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12298,7 +12298,7 @@ "title": "Zeitleiste", "subtitle": "Was wann war, neben deinen Werten.", "standing": "Dauerhaft", - "startMissing": "Startdatum fehlt", + "startUnknown": "Beginn unbekannt", "openDay": "{date} öffnen", "zoom": { "all": "Alles", @@ -12423,6 +12423,7 @@ "legendMeanMonth": "Monatsmittel", "legendMeanWeek": "Wochenmittel", "legendThin": "Weniger als 3 Messungen", + "legendGap": "Kurze Lücke ohne Messungen", "today": "Heute", "noDataYet": "Noch keine Daten", "layers": "Ebenen", @@ -12436,8 +12437,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.", @@ -12460,7 +12461,7 @@ }, "selection": { "empty": "Keine Einträge in diesem Zeitraum", - "ongoing": "laufend", + "since": "seit {date}", "range": "{from} bis {to}", "mean": "Ø {values}", "week": "Woche vom {date}", @@ -12471,8 +12472,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", @@ -12494,7 +12495,8 @@ "to": "Bis", "label": "Gewählter Zeitraum", "open": "Zeitraum wählen", - "clear": "Alles zeigen" + "clear": "Alles zeigen", + "apply": "Übernehmen" }, "item": { "labValuesOne": "1 Laborwert", diff --git a/messages/en.json b/messages/en.json index 41d4ee148..6f8ae09b4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12298,7 +12298,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", @@ -12423,6 +12423,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", @@ -12436,8 +12437,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.", @@ -12460,7 +12461,7 @@ }, "selection": { "empty": "No entries in this period", - "ongoing": "ongoing", + "since": "since {date}", "range": "{from} to {to}", "mean": "Ø {values}", "week": "Week of {date}", @@ -12471,8 +12472,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", @@ -12494,7 +12495,8 @@ "to": "To", "label": "Chosen range", "open": "Choose a range", - "clear": "Show everything" + "clear": "Show everything", + "apply": "Apply" }, "item": { "labValuesOne": "1 lab value", diff --git a/messages/es.json b/messages/es.json index ad8dd3aaa..8a9f5e48e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12298,7 +12298,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", @@ -12423,6 +12423,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", @@ -12436,8 +12437,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", @@ -12460,7 +12461,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}", @@ -12471,8 +12472,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", @@ -12494,7 +12495,8 @@ "to": "Hasta", "label": "Periodo elegido", "open": "Elegir un periodo", - "clear": "Mostrar todo" + "clear": "Mostrar todo", + "apply": "Aplicar" }, "item": { "labValuesOne": "1 valor de laboratorio", diff --git a/messages/fr.json b/messages/fr.json index bdd6f4e27..ff56cd496 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12298,7 +12298,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", @@ -12423,6 +12423,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", @@ -12436,8 +12437,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", @@ -12460,7 +12461,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}", @@ -12471,8 +12472,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", @@ -12494,7 +12495,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", diff --git a/messages/it.json b/messages/it.json index 84ee7963b..d17b411e3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12298,7 +12298,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", @@ -12423,6 +12423,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", @@ -12436,8 +12437,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", @@ -12460,7 +12461,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}", @@ -12471,8 +12472,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", @@ -12494,7 +12495,8 @@ "to": "Al", "label": "Periodo scelto", "open": "Scegli un periodo", - "clear": "Mostra tutto" + "clear": "Mostra tutto", + "apply": "Applica" }, "item": { "labValuesOne": "1 valore di laboratorio", diff --git a/messages/ko.json b/messages/ko.json index ef5e91477..12bfc6678 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12298,7 +12298,7 @@ "title": "타임라인", "subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.", "standing": "지속", - "startMissing": "시작일 없음", + "startUnknown": "시작일 미상", "openDay": "{date} 열기", "zoom": { "all": "전체", @@ -12423,6 +12423,7 @@ "legendMeanMonth": "월평균", "legendMeanWeek": "주간 평균", "legendThin": "측정 3회 미만", + "legendGap": "측정 없는 짧은 공백", "today": "오늘", "noDataYet": "아직 데이터 없음", "layers": "레이어", @@ -12436,8 +12437,8 @@ "description": "날짜가 있는 기록이 생기면 여기에 나타나요." }, "values": { - "menuLabel": "최대 여섯 가지 수치", - "none": "수치 없음", + "menuTitle": "레인 아래 수치", + "trigger": "수치 ({count})", "noneInWindow": "이 기간에 수치 없음", "series": { "BLOOD_PRESSURE_SYS": "수축기 혈압", @@ -12460,7 +12461,7 @@ }, "selection": { "empty": "이 기간에는 항목이 없습니다", - "ongoing": "진행 중", + "since": "{date}부터", "range": "{from}~{to}", "mean": "Ø {values}", "week": "{date} 주", @@ -12471,8 +12472,8 @@ "months": "월", "years": "연도", "groupingLabel": "묶기", - "gap": "{from}~{to} · 기록 없음", - "gapSingle": "{period} · 기록 없음", + "empty": "{from}~{to} 기록 없음", + "emptySingle": "{period} 기록 없음", "ended": "{label} 끝남", "afterDaysOne": "{count}일 후", "afterDaysFew": "{count}일 후", @@ -12494,7 +12495,8 @@ "to": "종료", "label": "선택한 기간", "open": "기간 선택", - "clear": "전체 보기" + "clear": "전체 보기", + "apply": "적용" }, "item": { "labValuesOne": "검사 수치 {count}개", diff --git a/messages/pl.json b/messages/pl.json index b99bf0d90..43f561c39 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12298,7 +12298,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", @@ -12423,6 +12423,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", @@ -12436,8 +12437,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", @@ -12460,7 +12461,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}", @@ -12471,8 +12472,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", @@ -12494,7 +12495,8 @@ "to": "Do", "label": "Wybrany zakres", "open": "Wybierz zakres", - "clear": "Pokaż wszystko" + "clear": "Pokaż wszystko", + "apply": "Zastosuj" }, "item": { "labValuesOne": "1 wynik badania", diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts index a14be92d3..6180427b2 100644 --- a/src/app/api/timeline/route.ts +++ b/src/app/api/timeline/route.ts @@ -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", }); } diff --git a/src/components/timeline/__tests__/chronicle-model.test.ts b/src/components/timeline/__tests__/chronicle-model.test.ts index 37633251b..7ec6c0329 100644 --- a/src/components/timeline/__tests__/chronicle-model.test.ts +++ b/src/components/timeline/__tests__/chronicle-model.test.ts @@ -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); }); diff --git a/src/components/timeline/__tests__/medication-rows.test.ts b/src/components/timeline/__tests__/medication-rows.test.ts new file mode 100644 index 000000000..0507e2bac --- /dev/null +++ b/src/components/timeline/__tests__/medication-rows.test.ts @@ -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" }); + }); +}); diff --git a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx index 044296ea7..daa7dfbfc 100644 --- a/src/components/timeline/__tests__/no-raw-code-guard.test.tsx +++ b/src/components/timeline/__tests__/no-raw-code-guard.test.tsx @@ -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(["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(["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(["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(["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(["de", "en"])("the timeline in %s", (locale) => { expect(rawCodes(titles.replace(/timeline\.selection\.\w+/g, ""))).toEqual( [], ); + expect(titles).not.toContain(MIDDLE_DOT); }); }); diff --git a/src/components/timeline/__tests__/series-colors.test.ts b/src/components/timeline/__tests__/series-colors.test.ts index d14355803..980dd976c 100644 --- a/src/components/timeline/__tests__/series-colors.test.ts +++ b/src/components/timeline/__tests__/series-colors.test.ts @@ -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); } }); diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx index 43cdcfab9..df366d3c8 100644 --- a/src/components/timeline/__tests__/timeline-components.test.tsx +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -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(""))).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">, '); }); }); diff --git a/src/components/timeline/__tests__/timeline-fixture.ts b/src/components/timeline/__tests__/timeline-fixture.ts index 3a346ef28..79bf546b1 100644 --- a/src/components/timeline/__tests__/timeline-fixture.ts +++ b/src/components/timeline/__tests__/timeline-fixture.ts @@ -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" }], }; } diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index 017872cd6..8b9fe4e26 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -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"); diff --git a/src/components/timeline/__tests__/timeline-rework.test.tsx b/src/components/timeline/__tests__/timeline-rework.test.tsx new file mode 100644 index 000000000..7a8f833a5 --- /dev/null +++ b/src/components/timeline/__tests__/timeline-rework.test.tsx @@ -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( + + {node} + , + ); +} + +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( + ( + 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( + ( + "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( + ( + + `Line ${key}`} + seriesColor={(key) => colours.get(key)!} + intl="en-GB" + t={(key) => key} + fmt={format} + /> + + )} + />, + ); + 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( + `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( + , + ); + // 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(, 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("", start)); + expect(entry).toContain(""))).toContain( + 'stroke-dasharray="3 3"', + ); + const thin = html.slice(html.indexOf('data-slot="timeline-legend-thin"')); + expect(thin.slice(0, thin.indexOf(""))).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(); + expect(html).not.toContain("timeline-legend-gap"); + expect(html).not.toContain("timeline-legend-thin"); + }); +}); diff --git a/src/components/timeline/chronicle-model.ts b/src/components/timeline/chronicle-model.ts index 4baef2406..07ee8f3b7 100644 --- a/src/components/timeline/chronicle-model.ts +++ b/src/components/timeline/chronicle-model.ts @@ -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, @@ -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(); + // 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, diff --git a/src/components/timeline/item-words.ts b/src/components/timeline/item-words.ts index b526dd18c..dafa94dcb 100644 --- a/src/components/timeline/item-words.ts +++ b/src/components/timeline/item-words.ts @@ -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. */ diff --git a/src/components/timeline/medication-rows.ts b/src/components/timeline/medication-rows.ts new file mode 100644 index 000000000..26320374d --- /dev/null +++ b/src/components/timeline/medication-rows.ts @@ -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(); + 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), + }; +} diff --git a/src/components/timeline/range-picker.tsx b/src/components/timeline/range-picker.tsx new file mode 100644 index 000000000..69063c358 --- /dev/null +++ b/src/components/timeline/range-picker.tsx @@ -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>; + zoom: TimelineZoom; + label: string; + /** Where the draft starts when the popover opens. */ + initialRange: TimelineRange; + onZoom: (next: Exclude) => 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" ? ( + {button} + ) : ( + button + ); + return ( + + { + if (next !== "range") { + setOpen(false); + onZoom(next); + return; + } + setDraft(initialRange); + setOpen(true); + }} + label={label} + slot="timeline-zoom" + wrapOption={anchor} + /> + +

{t("timeline.range.open")}

+ +
+ +
+
+
+ ); +} + +/** 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 ( + + {onClear && ( + + )} + + + } + > + + + ); +} diff --git a/src/components/timeline/segmented.tsx b/src/components/timeline/segmented.tsx index 3dc1f0f8d..47033830d 100644 --- a/src/components/timeline/segmented.tsx +++ b/src/components/timeline/segmented.tsx @@ -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({ className, stretch = false, asToggleButtons = false, + wrapOption, }: { options: ReadonlyArray>; value: T; @@ -35,6 +38,11 @@ export function Segmented({ 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({ > {options.map((option, index) => { const checked = option.value === value; - return ( + const button = ( ); + return wrapOption ? ( + + {wrapOption(option.value, button)} + + ) : ( + button + ); })} ); diff --git a/src/components/timeline/selection-bar.tsx b/src/components/timeline/selection-bar.tsx index 64ab30eeb..a1b717684 100644 --- a/src/components/timeline/selection-bar.tsx +++ b/src/components/timeline/selection-bar.tsx @@ -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 (
)} - {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 ( 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" > !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); } diff --git a/src/components/timeline/timeline-chart.tsx b/src/components/timeline/timeline-chart.tsx index 2004eb12d..e516a3611 100644 --- a/src/components/timeline/timeline-chart.tsx +++ b/src/components/timeline/timeline-chart.tsx @@ -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)} + {/* 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) => ( ))} - {lane.labels.map((label) => ( + {lane.labels.map((label, i) => ( ["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 ( - + {title} + {title} - {!item.startKnown && ( + {!item.startKnown && first && ( - {item.open && ( + {item.open && last && ( + {title} {glyph} @@ -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 ( @@ -901,22 +929,12 @@ function TimelineTable({ {rows.map(({ lane, item }) => ( - + ))} @@ -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) => ( - - {i > 0 ? " · " : null} + 0 ? "ml-3" : undefined} + > + {i > 0 ? , : null} { 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 }; } diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index c220aaa2e..86fffc96a 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -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> = { 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; diff --git a/src/components/timeline/timeline-menus.tsx b/src/components/timeline/timeline-menus.tsx index bad95fb11..e33d583de 100644 --- a/src/components/timeline/timeline-menus.tsx +++ b/src/components/timeline/timeline-menus.tsx @@ -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 ( - - {t("timeline.values.menuLabel")} + + {t("timeline.values.menuTitle")} {options.map((key) => { const checked = selected.includes(key); - const full = !checked && selected.length >= MAX_VALUE_SERIES; return ( event.preventDefault()} diff --git a/src/components/timeline/timeline-view.tsx b/src/components/timeline/timeline-view.tsx index a3de9ffb2..954b2a9c6 100644 --- a/src/components/timeline/timeline-view.tsx +++ b/src/components/timeline/timeline-view.tsx @@ -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 = 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(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) { + 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() { ) : 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 ? ( -
- navigate("range", 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.
- {rangeControls} + {emptyRangeFields}
- navigate("range", next)} />
- {rangeControls} - 0} /> @@ -491,7 +509,13 @@ export function TimelineView() {
{/* Phone: the chronicle. */} -
+
setZoom(zoom === "range" ? "all" : "range")} + onClick={() => setRangeSheetOpen(true)} data-slot="timeline-range-toggle" >
- {rangeControls} + navigate("range", next)} + onClear={zoom === "range" ? () => setZoom("all") : null} + /> @@ -586,11 +625,50 @@ function Legend({ - - + + > + + + {t("timeline.legendGap")} + + + {t("timeline.legendThin")} diff --git a/src/lib/day/contract.ts b/src/lib/day/contract.ts index 505bcb7aa..be5ef4c7f 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -237,13 +237,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:`) and disappears with it. @@ -251,7 +244,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 diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index e6468e328..add007abb 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -290,7 +290,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), { @@ -339,6 +339,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" }); @@ -405,6 +411,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) }), ), diff --git a/src/lib/timeline/__tests__/series.test.ts b/src/lib/timeline/__tests__/series.test.ts index f3b985d54..9801e5142 100644 --- a/src/lib/timeline/__tests__/series.test.ts +++ b/src/lib/timeline/__tests__/series.test.ts @@ -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(); }); }); diff --git a/src/lib/timeline/lanes.ts b/src/lib/timeline/lanes.ts index da037607a..f35f91489 100644 --- a/src/lib/timeline/lanes.ts +++ b/src/lib/timeline/lanes.ts @@ -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 & - Partial>, + rest: Omit & + Partial>, ): TimelineItem { return { precision: "DAY", startKnown: true, sub: null, href: null, + group: null, ...rest, }; } @@ -308,6 +318,7 @@ async function medicationsLane(frame: LaneFrame): Promise { label: med.name, sub: med.dose, href, + group: med.id, }), ); } @@ -322,6 +333,7 @@ async function medicationsLane(frame: LaneFrame): Promise { open: end === null, label: c.medication.name, href: `/medications/${c.medicationId}`, + group: c.medicationId, }), ); } @@ -336,6 +348,7 @@ async function medicationsLane(frame: LaneFrame): Promise { 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 { open: end === null, label: p.medication.name, href: `/medications/${p.medicationId}`, + group: p.medicationId, }), ); } diff --git a/src/lib/timeline/load-timeline.ts b/src/lib/timeline/load-timeline.ts index a24ef1a97..e9d14117b 100644 --- a/src/lib/timeline/load-timeline.ts +++ b/src/lib/timeline/load-timeline.ts @@ -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 = 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 { + 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, @@ -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 })), }; } diff --git a/tests/integration/day-view.test.ts b/tests/integration/day-view.test.ts index bac08bd06..aabf3575b 100644 --- a/tests/integration/day-view.test.ts +++ b/tests/integration/day-view.test.ts @@ -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( + 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(); + 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(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 });
{t(TIMELINE_LANE_LABEL_KEY[lane])} - {[ - words(item).label, - words(item).sub, - item.startKnown ? null : t("timeline.startMissing"), - ] - .filter(Boolean) - .join(" · ")} - {itemLine(item, words, t("timeline.startUnknown"))} {formatAtPrecision(item.start, item.precision, intl)} - {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) + : ""}