diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a7a7c1148..c65593eab 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. @@ -81664,6 +81664,13 @@ components: type: array items: $ref: "#/components/schemas/TimelineSeries" + availableSeries: + type: array + items: + type: string + description: "Every value series this record can show, whatever the window: the `MeasurementType` names with live + readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has + entries. Each may be named in `values`." notable: type: array items: @@ -81690,6 +81697,7 @@ components: - standing - bucket - series + - availableSeries - notable additionalProperties: false TimelineItem: @@ -81750,6 +81758,12 @@ components: anyOf: - type: string - type: "null" + group: + anyOf: + - type: string + - type: "null" + description: "Items that belong to one thing share a group and are drawn on one row: a medication's own span, its + courses, its dose changes and its pauses all carry the medication's id. Null for an item that stands alone." required: - id - kind @@ -81761,6 +81775,7 @@ components: - label - sub - href + - group additionalProperties: false TimelineStandingItem: type: object 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 be19a50e3..69f274667 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12308,7 +12308,7 @@ "title": "Zeitleiste", "subtitle": "Was wann war, neben deinen Werten.", "standing": "Dauerhaft", - "startMissing": "Startdatum fehlt", + "startUnknown": "Beginn unbekannt", "openDay": "{date} öffnen", "zoom": { "all": "Alles", @@ -12433,6 +12433,7 @@ "legendMeanMonth": "Monatsmittel", "legendMeanWeek": "Wochenmittel", "legendThin": "Weniger als 3 Messungen", + "legendGap": "Kurze Lücke ohne Messungen", "today": "Heute", "noDataYet": "Noch keine Daten", "layers": "Ebenen", @@ -12446,8 +12447,8 @@ "description": "Sobald es Einträge mit Datum gibt, erscheinen sie hier." }, "values": { - "menuLabel": "Bis zu sechs Werte", - "none": "Keine Werte", + "menuTitle": "Werte unter den Spuren", + "trigger": "Werte ({count})", "noneInWindow": "Keine Werte in diesem Zeitraum", "series": { "BLOOD_PRESSURE_SYS": "Blutdruck sys.", @@ -12470,7 +12471,7 @@ }, "selection": { "empty": "Keine Einträge in diesem Zeitraum", - "ongoing": "laufend", + "since": "seit {date}", "range": "{from} bis {to}", "mean": "Ø {values}", "week": "Woche vom {date}", @@ -12481,8 +12482,8 @@ "months": "Monate", "years": "Jahre", "groupingLabel": "Gruppierung", - "gap": "{from} bis {to} · keine Einträge", - "gapSingle": "{period} · keine Einträge", + "empty": "Keine Einträge von {from} bis {to}", + "emptySingle": "{period}: keine Einträge", "ended": "{label} vorbei", "afterDaysOne": "nach einem Tag", "afterDaysFew": "nach {count} Tagen", @@ -12504,7 +12505,8 @@ "to": "Bis", "label": "Gewählter Zeitraum", "open": "Zeitraum wählen", - "clear": "Alles zeigen" + "clear": "Alles zeigen", + "apply": "Übernehmen" }, "item": { "labValuesOne": "1 Laborwert", diff --git a/messages/en.json b/messages/en.json index 5fdb46657..dc46d841f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12308,7 +12308,7 @@ "title": "Timeline", "subtitle": "What happened when, beside your values.", "standing": "Ongoing", - "startMissing": "Start date missing", + "startUnknown": "start unknown", "openDay": "Open {date}", "zoom": { "all": "All", @@ -12433,6 +12433,7 @@ "legendMeanMonth": "Monthly mean", "legendMeanWeek": "Weekly mean", "legendThin": "Fewer than 3 readings", + "legendGap": "Short gap without measurements", "today": "Today", "noDataYet": "No data yet", "layers": "Layers", @@ -12446,8 +12447,8 @@ "description": "Dated entries appear here as soon as there are some." }, "values": { - "menuLabel": "Up to six values", - "none": "No values", + "menuTitle": "Values under the lanes", + "trigger": "Values ({count})", "noneInWindow": "No values in this range", "series": { "BLOOD_PRESSURE_SYS": "Blood pressure sys.", @@ -12470,7 +12471,7 @@ }, "selection": { "empty": "No entries in this period", - "ongoing": "ongoing", + "since": "since {date}", "range": "{from} to {to}", "mean": "Ø {values}", "week": "Week of {date}", @@ -12481,8 +12482,8 @@ "months": "Months", "years": "Years", "groupingLabel": "Grouping", - "gap": "{from} to {to} · no entries", - "gapSingle": "{period} · no entries", + "empty": "No entries from {from} to {to}", + "emptySingle": "No entries in {period}", "ended": "{label} ended", "afterDaysOne": "after 1 day", "afterDaysFew": "after {count} days", @@ -12504,7 +12505,8 @@ "to": "To", "label": "Chosen range", "open": "Choose a range", - "clear": "Show everything" + "clear": "Show everything", + "apply": "Apply" }, "item": { "labValuesOne": "1 lab value", diff --git a/messages/es.json b/messages/es.json index 7e81bef2f..66674e350 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12308,7 +12308,7 @@ "title": "Cronología", "subtitle": "Qué pasó y cuándo, junto a tus valores.", "standing": "Permanente", - "startMissing": "Falta la fecha de inicio", + "startUnknown": "inicio desconocido", "openDay": "Abrir {date}", "zoom": { "all": "Todo", @@ -12433,6 +12433,7 @@ "legendMeanMonth": "Media mensual", "legendMeanWeek": "Media semanal", "legendThin": "Menos de 3 mediciones", + "legendGap": "Hueco corto sin mediciones", "today": "Hoy", "noDataYet": "Aún sin datos", "layers": "Capas", @@ -12446,8 +12447,8 @@ "description": "Las entradas con fecha aparecerán aquí en cuanto haya alguna." }, "values": { - "menuLabel": "Hasta seis valores", - "none": "Sin valores", + "menuTitle": "Valores bajo los carriles", + "trigger": "Valores ({count})", "noneInWindow": "Sin valores en este periodo", "series": { "BLOOD_PRESSURE_SYS": "Presión sistólica", @@ -12470,7 +12471,7 @@ }, "selection": { "empty": "No hay entradas en este periodo", - "ongoing": "en curso", + "since": "desde {date}", "range": "{from} a {to}", "mean": "Ø {values}", "week": "Semana del {date}", @@ -12481,8 +12482,8 @@ "months": "Meses", "years": "Años", "groupingLabel": "Agrupación", - "gap": "{from} a {to} · sin entradas", - "gapSingle": "{period} · sin entradas", + "empty": "Sin entradas de {from} a {to}", + "emptySingle": "Sin entradas en {period}", "ended": "Fin: {label}", "afterDaysOne": "después de 1 día", "afterDaysFew": "después de {count} días", @@ -12504,7 +12505,8 @@ "to": "Hasta", "label": "Periodo elegido", "open": "Elegir un periodo", - "clear": "Mostrar todo" + "clear": "Mostrar todo", + "apply": "Aplicar" }, "item": { "labValuesOne": "1 valor de laboratorio", diff --git a/messages/fr.json b/messages/fr.json index 794dc9e41..e51f85778 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12308,7 +12308,7 @@ "title": "Chronologie", "subtitle": "Ce qui s’est passé et quand, à côté de vos valeurs.", "standing": "Permanent", - "startMissing": "Date de début manquante", + "startUnknown": "début inconnu", "openDay": "Ouvrir le {date}", "zoom": { "all": "Tout", @@ -12433,6 +12433,7 @@ "legendMeanMonth": "Moyenne mensuelle", "legendMeanWeek": "Moyenne hebdomadaire", "legendThin": "Moins de 3 mesures", + "legendGap": "Courte période sans mesures", "today": "Aujourd’hui", "noDataYet": "Pas encore de données", "layers": "Calques", @@ -12446,8 +12447,8 @@ "description": "Les entrées datées apparaîtront ici dès qu’il y en aura." }, "values": { - "menuLabel": "Jusqu’à six valeurs", - "none": "Aucune valeur", + "menuTitle": "Valeurs sous les lignes", + "trigger": "Valeurs ({count})", "noneInWindow": "Aucune valeur sur cette période", "series": { "BLOOD_PRESSURE_SYS": "Tension systolique", @@ -12470,7 +12471,7 @@ }, "selection": { "empty": "Aucune entrée sur cette période", - "ongoing": "en cours", + "since": "depuis le {date}", "range": "{from} au {to}", "mean": "Ø {values}", "week": "Semaine du {date}", @@ -12481,8 +12482,8 @@ "months": "Mois", "years": "Années", "groupingLabel": "Regroupement", - "gap": "{from} à {to} · aucune entrée", - "gapSingle": "{period} · aucune entrée", + "empty": "Aucune entrée de {from} à {to}", + "emptySingle": "Aucune entrée en {period}", "ended": "Fin : {label}", "afterDaysOne": "après 1 jour", "afterDaysFew": "après {count} jours", @@ -12504,7 +12505,8 @@ "to": "Au", "label": "Période choisie", "open": "Choisir une période", - "clear": "Tout afficher" + "clear": "Tout afficher", + "apply": "Appliquer" }, "item": { "labValuesOne": "1 valeur de laboratoire", diff --git a/messages/it.json b/messages/it.json index dbb97e210..e67d78a3a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12308,7 +12308,7 @@ "title": "Cronologia", "subtitle": "Cosa è successo e quando, accanto ai tuoi valori.", "standing": "Permanente", - "startMissing": "Data di inizio mancante", + "startUnknown": "inizio sconosciuto", "openDay": "Apri {date}", "zoom": { "all": "Tutto", @@ -12433,6 +12433,7 @@ "legendMeanMonth": "Media mensile", "legendMeanWeek": "Media settimanale", "legendThin": "Meno di 3 misurazioni", + "legendGap": "Breve pausa senza misurazioni", "today": "Oggi", "noDataYet": "Ancora nessun dato", "layers": "Livelli", @@ -12446,8 +12447,8 @@ "description": "Le voci con data compariranno qui appena ce ne saranno." }, "values": { - "menuLabel": "Fino a sei valori", - "none": "Nessun valore", + "menuTitle": "Valori sotto le corsie", + "trigger": "Valori ({count})", "noneInWindow": "Nessun valore in questo periodo", "series": { "BLOOD_PRESSURE_SYS": "Pressione sistolica", @@ -12470,7 +12471,7 @@ }, "selection": { "empty": "Nessuna voce in questo periodo", - "ongoing": "in corso", + "since": "dal {date}", "range": "dal {from} al {to}", "mean": "Ø {values}", "week": "Settimana del {date}", @@ -12481,8 +12482,8 @@ "months": "Mesi", "years": "Anni", "groupingLabel": "Raggruppamento", - "gap": "Da {from} a {to} · nessuna voce", - "gapSingle": "{period} · nessuna voce", + "empty": "Nessuna voce da {from} a {to}", + "emptySingle": "{period}: nessuna voce", "ended": "Fine: {label}", "afterDaysOne": "dopo 1 giorno", "afterDaysFew": "dopo {count} giorni", @@ -12504,7 +12505,8 @@ "to": "Al", "label": "Periodo scelto", "open": "Scegli un periodo", - "clear": "Mostra tutto" + "clear": "Mostra tutto", + "apply": "Applica" }, "item": { "labValuesOne": "1 valore di laboratorio", diff --git a/messages/ko.json b/messages/ko.json index ba4dfd53d..cc0b5beb8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12308,7 +12308,7 @@ "title": "타임라인", "subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.", "standing": "지속", - "startMissing": "시작일 없음", + "startUnknown": "시작일 미상", "openDay": "{date} 열기", "zoom": { "all": "전체", @@ -12433,6 +12433,7 @@ "legendMeanMonth": "월평균", "legendMeanWeek": "주간 평균", "legendThin": "측정 3회 미만", + "legendGap": "측정 없는 짧은 공백", "today": "오늘", "noDataYet": "아직 데이터 없음", "layers": "레이어", @@ -12446,8 +12447,8 @@ "description": "날짜가 있는 기록이 생기면 여기에 나타나요." }, "values": { - "menuLabel": "최대 여섯 가지 수치", - "none": "수치 없음", + "menuTitle": "레인 아래 수치", + "trigger": "수치 ({count})", "noneInWindow": "이 기간에 수치 없음", "series": { "BLOOD_PRESSURE_SYS": "수축기 혈압", @@ -12470,7 +12471,7 @@ }, "selection": { "empty": "이 기간에는 항목이 없습니다", - "ongoing": "진행 중", + "since": "{date}부터", "range": "{from}~{to}", "mean": "Ø {values}", "week": "{date} 주", @@ -12481,8 +12482,8 @@ "months": "월", "years": "연도", "groupingLabel": "묶기", - "gap": "{from}~{to} · 기록 없음", - "gapSingle": "{period} · 기록 없음", + "empty": "{from}~{to} 기록 없음", + "emptySingle": "{period} 기록 없음", "ended": "{label} 끝남", "afterDaysOne": "{count}일 후", "afterDaysFew": "{count}일 후", @@ -12504,7 +12505,8 @@ "to": "종료", "label": "선택한 기간", "open": "기간 선택", - "clear": "전체 보기" + "clear": "전체 보기", + "apply": "적용" }, "item": { "labValuesOne": "검사 수치 {count}개", diff --git a/messages/pl.json b/messages/pl.json index 5c28ab6a8..80ba6f34d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12308,7 +12308,7 @@ "title": "Oś czasu", "subtitle": "Co i kiedy się działo, obok Twoich wartości.", "standing": "Stałe", - "startMissing": "Brak daty rozpoczęcia", + "startUnknown": "początek nieznany", "openDay": "Otwórz {date}", "zoom": { "all": "Wszystko", @@ -12433,6 +12433,7 @@ "legendMeanMonth": "Średnia miesięczna", "legendMeanWeek": "Średnia tygodniowa", "legendThin": "Mniej niż 3 pomiary", + "legendGap": "Krótka przerwa bez pomiarów", "today": "Dziś", "noDataYet": "Jeszcze brak danych", "layers": "Warstwy", @@ -12446,8 +12447,8 @@ "description": "Wpisy z datą pojawią się tutaj, gdy tylko jakieś będą." }, "values": { - "menuLabel": "Do sześciu wartości", - "none": "Brak wartości", + "menuTitle": "Wartości pod ścieżkami", + "trigger": "Wartości ({count})", "noneInWindow": "Brak wartości w tym okresie", "series": { "BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe", @@ -12470,7 +12471,7 @@ }, "selection": { "empty": "Brak wpisów w tym okresie", - "ongoing": "trwa", + "since": "od {date}", "range": "od {from} do {to}", "mean": "Ø {values}", "week": "Tydzień od {date}", @@ -12481,8 +12482,8 @@ "months": "Miesiące", "years": "Lata", "groupingLabel": "Grupowanie", - "gap": "Od {from} do {to} · brak wpisów", - "gapSingle": "{period} · brak wpisów", + "empty": "Brak wpisów od {from} do {to}", + "emptySingle": "Brak wpisów: {period}", "ended": "Koniec: {label}", "afterDaysOne": "po 1 dniu", "afterDaysFew": "po {count} dniach", @@ -12504,7 +12505,8 @@ "to": "Do", "label": "Wybrany zakres", "open": "Wybierz zakres", - "clear": "Pokaż wszystko" + "clear": "Pokaż wszystko", + "apply": "Zastosuj" }, "item": { "labValuesOne": "1 wynik badania", 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 300189abb..4586f6bf3 100644 --- a/src/lib/day/contract.ts +++ b/src/lib/day/contract.ts @@ -274,13 +274,6 @@ export const TIMELINE_RANGE_MAX_DAYS = 15 * 366; export type TimelineBucket = (typeof TIMELINE_BUCKETS)[number]; -/** - * The most value series one request may name. Each series is its own row - * under the lanes, drawn in neutral ink and labelled at the left, so the cap - * is the chart's height, not a palette: six rows add 360 px under the lanes. - */ -export const TIMELINE_MAX_SERIES = 6; - /** * The lanes, top to bottom. An empty lane is not sent. Each lane a module * owns is a surface (`timeline-lane:`) and disappears with it. @@ -288,7 +281,9 @@ export const TIMELINE_MAX_SERIES = 6; * life life events, and travel periods from the environment module * illness episodes, chronic conditions as open lines * allergies allergies with an onset; the rest are `standing` - * medications medications and courses, dose marks, pauses as gaps + * medications medications and courses, dose marks, pauses as gaps; every + * item of one medication carries its id as `group`, and a + * client draws a group on one row * vaccinations doses * visits visits and procedures * labs days with results diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index d91ecb623..1866a89d9 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -327,7 +327,7 @@ export const timelineQuerySchema = z .string() .optional() .describe( - "Comma-separated value series to include, as `MeasurementType` names or `MOOD`, at most 6. More, or an unknown name, answers 422.", + "Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts once, so the list is bounded by the names that exist; an unknown name answers 422.", ), }) .refine((q) => q.zoom !== "range" || (q.from && q.to), { @@ -376,6 +376,12 @@ const timelineItem = z "The second line, by kind: a code for a life event (its category), a visit or procedure (its encounter kind) and a document (its kind); the analyte count for a lab day; the dose as text for a medication or a dose change. The client words every code.", ), href: z.string().nullable(), + group: z + .string() + .nullable() + .describe( + "Items that belong to one thing share a group and are drawn on one row: a medication's own span, its courses, its dose changes and its pauses all carry the medication's id. Null for an item that stands alone.", + ), }) .meta({ id: "TimelineItem" }); @@ -442,6 +448,11 @@ export const timelineResponseSchema = z "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month` from 120 days, `week` from 42 days, `day` below.", ), series: z.array(timelineSeries), + availableSeries: z + .array(z.string()) + .describe( + "Every value series this record can show, whatever the window: the `MeasurementType` names with live readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has entries. Each may be named in `values`.", + ), notable: z.array( z.object({ date: dateKeySchema, kind: z.enum(DAY_NOTABLE_KINDS) }), ), 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 68128f087..54d24df0d 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) + : ""}