From 9f3da5ac7de7a1e6983e22485bacc5673514a314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 16:05:40 +0200 Subject: [PATCH] fix(timeline): keep every medication still taken on its row, and the chronicle honest An active medication whose courses had all ended had no stretch left in the current window: the courses decided when it was taken, none reached into the window, and the medication's own open span was set aside because it had courses. The row disappeared while the day view listed the medication as running. The time since its last course now stands in as its stretch, open to today. The server cut a lane's items at the window edge, so a medication arrived with only some of its courses and without the dose change that set the dose of the piece running into the window. A medication touching the window is now sent whole. On a phone, every entry wears its lane's colour: the icon in the lane colour, and a medication listed inside an illness keeps a dot on its own rail instead of reading as the illness's orange. A month without an event is no longer called empty when it had readings (its header stays and carries the means) or while a medication was taken in it; only a month with none of the three gets the "No entries" line. --- .../__tests__/chronicle-model.test.ts | 102 +++++++++++++- .../__tests__/medication-rows.test.ts | 125 +++++++++++++++++- .../__tests__/timeline-components.test.tsx | 43 +++++- src/components/timeline/chronicle-model.ts | 85 +++++++++++- src/components/timeline/medication-rows.ts | 27 +++- .../timeline/timeline-chronicle.tsx | 9 ++ .../__tests__/keep-touching-groups.test.ts | 87 ++++++++++++ src/lib/timeline/load-timeline.ts | 29 +++- 8 files changed, 488 insertions(+), 19 deletions(-) create mode 100644 src/lib/timeline/__tests__/keep-touching-groups.test.ts diff --git a/src/components/timeline/__tests__/chronicle-model.test.ts b/src/components/timeline/__tests__/chronicle-model.test.ts index 7ec6c0329..a2e6eec33 100644 --- a/src/components/timeline/__tests__/chronicle-model.test.ts +++ b/src/components/timeline/__tests__/chronicle-model.test.ts @@ -11,7 +11,25 @@ import { standingChips, type ChronicleRow, } from "../chronicle-model"; -import { TODAY, fullTimeline } from "./timeline-fixture"; +import { TODAY, fullTimeline, item } from "./timeline-fixture"; + +/** + * The fixture without what fills the months between its events: the + * standing medication (taken every month) and the value lines. What is left + * shows the gap rows on their own. + */ +function eventsOnly() { + const tl = fullTimeline(); + return { + ...tl, + series: [] as typeof tl.series, + lanes: tl.lanes.map((lane) => + lane.key === "medications" + ? { ...lane, items: lane.items.filter((i) => i.group !== "med-1") } + : lane, + ), + }; +} function headers(rows: ChronicleRow[]) { return rows @@ -64,7 +82,7 @@ describe("chronicleEntries", () => { describe("buildChronicle", () => { it("folds a run of empty months into one honest gap row", () => { - const rows = buildChronicle(fullTimeline(), TODAY, "month"); + const rows = buildChronicle(eventsOnly(), TODAY, "month"); // The last entry is the course ending on 31 March 2026: April to // October 2026 hold nothing. const gap = rows.find((r) => r.type === "gap" && r.to === "2026-10-01"); @@ -77,12 +95,16 @@ describe("buildChronicle", () => { expect(headers(rows)[0]).toBe("2026-03-01"); }); - it("keeps a course's rail through an empty stretch it spans", () => { - const rows = buildChronicle(fullTimeline(), TODAY, "month"); + it("passes over the months a course was taken in, without a gap line", () => { + const rows = buildChronicle(eventsOnly(), TODAY, "month"); // The 2024/25 winter course runs from November to March; nothing else - // is dated in December to February. - const gap = rows.find((r) => r.type === "gap" && r.from === "2024-12-01"); - expect(gap?.type === "gap" && gap.rails.medications).toBe("full"); + // is dated in December to February, and the course was taken then. + expect( + rows.some( + (r) => + r.type === "gap" && r.from <= "2025-02-01" && r.to >= "2024-12-01", + ), + ).toBe(false); }); it("never shows two gap rows next to each other", () => { @@ -132,12 +154,78 @@ describe("buildChronicle", () => { expect(visit?.type === "entry" && visit.rails.illness).toBe("full"); }); + it("gives a month with values but no event its header, never a gap line", () => { + const tl = eventsOnly(); + tl.series = fullTimeline().series; + const rows = buildChronicle(tl, TODAY, "month"); + // July 2026 holds a blood-pressure mean and no event. + expect(headers(rows)).toContain("2026-07-01"); + const gaps = rows.flatMap((r) => + r.type === "gap" ? [[r.from, r.to]] : [], + ); + expect(gaps).toContainEqual(["2026-04-01", "2026-06-01"]); + expect(gaps).toContainEqual(["2026-08-01", "2026-10-01"]); + }); + + it("says nothing about a month in which a medication was taken", () => { + const rows = buildChronicle( + { ...fullTimeline(), series: [] }, + TODAY, + "month", + ); + // Ramipril has been taken since 2019: no month after it is empty. + expect(rows.some((r) => r.type === "gap" && r.to >= "2019-05-01")).toBe( + false, + ); + expect(headers(rows)).not.toContain("2026-07-01"); + }); + + it("still says a truly empty month out loud", () => { + const rows = buildChronicle(eventsOnly(), TODAY, "month"); + expect(rows.some((r) => r.type === "gap")).toBe(true); + }); + + it("marks a medication entry with its own colour inside an illness rail", () => { + const tl = eventsOnly(); + tl.lanes = tl.lanes.map((lane) => + lane.key === "medications" + ? { + ...lane, + items: [ + // Without the winter course, whose rail would pass here too. + ...lane.items.filter((i) => i.group !== "vit-d"), + item({ + id: "vd3", + group: "vd3", + kind: "medication", + // Inside the cold of 1 to 9 January 2026. + start: "2026-01-05", + open: true, + label: "Vitamin D3", + }), + ], + } + : lane, + ); + const rows = buildChronicle(tl, TODAY, "month"); + const row = rows.find( + (r) => + r.type === "entry" && + r.entry.kind === "item" && + r.entry.item.id === "vd3", + ); + expect(row?.type === "entry" && row.rails.illness).toBe("full"); + expect(row?.type === "entry" && row.rails.medications).toBe("point"); + }); + it("is empty for a record without anything dated", () => { expect( buildChronicle( { lanes: [], notable: [], + series: [], + bucket: "month", range: { from: "2026-01-01", to: "2026-12-31", dataFrom: null }, }, TODAY, diff --git a/src/components/timeline/__tests__/medication-rows.test.ts b/src/components/timeline/__tests__/medication-rows.test.ts index 0507e2bac..d737a2571 100644 --- a/src/components/timeline/__tests__/medication-rows.test.ts +++ b/src/components/timeline/__tests__/medication-rows.test.ts @@ -17,7 +17,8 @@ import { medicationPeriodItem, strayDoses, } from "../medication-rows"; -import { item } from "./timeline-fixture"; +import { layoutTimeline } from "../timeline-geometry"; +import { item, wordsIn } from "./timeline-fixture"; const TODAY = "2025-12-31"; @@ -231,3 +232,125 @@ describe("medicationPeriodItem", () => { ).toMatchObject({ sub: "2.5 mg", start: "2025-01-06" }); }); }); + +/** + * Every medication still taken is drawn, on exactly one row, whatever its + * items look like: no course, an open course, dose changes, a pause, or + * courses that all ended while the medication stayed active. A medication + * that is over is drawn only where its stretch lies. This is the guard for + * an active medication silently missing from the chart while the day view + * lists it as running. + */ +describe("every medication still taken has one row in the current window", () => { + const today = "2026-10-09"; + const med = (id: string, over: Partial = {}): TimelineItem => + item({ + id, + group: id, + kind: "medication", + start: "2026-01-15", + open: true, + label: id, + sub: "5 mg", + ...over, + }); + const of = ( + group: string, + kind: TimelineItem["kind"], + over: Partial, + ) => + item({ + id: `${group}-${kind}-${over.start}`, + group, + kind, + label: group, + ...over, + } as never); + + const cases: Array<[string, TimelineItem[], boolean]> = [ + ["no course, open", [med("plain")], true], + ["no course, start unknown", [med("unknown", { startKnown: false })], true], + [ + "an open course", + [ + med("course"), + of("course", "course", { start: "2026-01-15", open: true }), + ], + true, + ], + [ + "an open course and three dose changes", + [ + med("doses"), + of("doses", "course", { start: "2026-01-15", open: true }), + of("doses", "doseChange", { start: "2026-01-15", sub: "2.5 mg" }), + of("doses", "doseChange", { start: "2026-02-12", sub: "5 mg" }), + of("doses", "doseChange", { start: "2026-04-09", sub: "7.5 mg" }), + ], + true, + ], + [ + "a running pause", + [ + med("paused"), + of("paused", "pause", { start: "2026-09-01", open: true }), + ], + true, + ], + [ + "courses that all ended, medication still active", + [ + med("stale"), + of("stale", "course", { start: "2026-01-15", end: "2026-03-01" }), + ], + true, + ], + [ + "ended before the window", + [med("over", { open: false, end: "2026-02-01" })], + false, + ], + ]; + + for (const [name, items, drawn] of cases) { + it(`${name}: ${drawn ? "one row" : "no row"} in the last three months`, () => { + const layout = layoutTimeline({ + width: 1200, + window: { from: "2026-07-11", to: today }, + lanes: [{ key: "medications", items }], + series: [], + bucket: "week", + words: wordsIn("en"), + startMissing: "?", + today, + }); + const lane = layout.lanes.find((l) => l.key === "medications"); + if (!drawn) { + expect(lane).toBeUndefined(); + return; + } + expect(lane?.rows).toBe(1); + expect(new Set(lane?.spans.map((s) => s.row))).toEqual(new Set([0])); + // The bar reaches today. + const right = Math.max(...(lane?.spans ?? []).map((s) => s.xEnd)); + expect(right).toBeGreaterThan(layout.scale.x(today)); + }); + } + + it("draws all of them together, one row each", () => { + const items = cases.filter(([, , drawn]) => drawn).flatMap(([, i]) => i); + const layout = layoutTimeline({ + width: 1200, + window: { from: "2026-07-11", to: today }, + lanes: [{ key: "medications", items }], + series: [], + bucket: "week", + words: wordsIn("en"), + startMissing: "?", + today, + }); + const lane = layout.lanes.find((l) => l.key === "medications")!; + expect(lane.rows).toBe(6); + expect(new Set(lane.spans.map((s) => s.row)).size).toBe(6); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx index df366d3c8..78722f226 100644 --- a/src/components/timeline/__tests__/timeline-components.test.tsx +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -140,11 +140,23 @@ describe("readiness inventory", () => { describe("chronicle", () => { it("names an empty stretch instead of skipping it, and folds the ongoing items", () => { + // Without the standing medication and the value lines, April to October + // 2026 hold nothing at all. + const full = fullTimeline(); + const eventsOnly = { + ...full, + series: [], + lanes: full.lanes.map((lane) => + lane.key === "medications" + ? { ...lane, items: lane.items.filter((i) => i.group !== "med-1") } + : lane, + ), + }; const html = render( ( { />, ); 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"); expect(html).toContain("nach 9 Tagen"); @@ -166,6 +177,34 @@ describe("chronicle", () => { expect(html).toMatch(/data-date="2026-01-03">]*bg-muted/); }); + it("never calls a month empty while a medication was taken in it", () => { + const html = render( + ( + undefined} + onEditLifeEvent={null} + /> + )} + />, + ); + // April to October 2026 hold no event, but Ramipril was taken. + expect(html).not.toContain("Keine Einträge von April bis Oktober 2026"); + expect(html).toContain("Dauerhaft"); + // Every medication entry wears the medication colour. + for (const icon of html.match(/]*data-lane="medications"[^>]*>/g) ?? + []) { + expect(icon).toContain("color:var(--chart-1)"); + } + expect(html).toMatch(/data-lane="medications"/); + }); + it("reads a pause as paused and resumed, never as an end", () => { const html = text( render( diff --git a/src/components/timeline/chronicle-model.ts b/src/components/timeline/chronicle-model.ts index 07ee8f3b7..905ce85c9 100644 --- a/src/components/timeline/chronicle-model.ts +++ b/src/components/timeline/chronicle-model.ts @@ -11,7 +11,12 @@ * * 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. + * as a list that forgot something. The chronicle lists events, not + * readings or intakes, so "empty" means empty: a month without an event + * but with values keeps its header (the month's means ride on it), and a + * month in which a medication was taken is passed over without a line, + * because "no entries" would read as nothing having happened while the + * intakes say otherwise. Only a month with none of the three is a gap. * * A medication is listed once per thing that happened to it * (`medicationListItems`): its start with the dose taken then, each later @@ -27,12 +32,18 @@ import type { } from "@/lib/day/contract"; import { + addMonths, + bucketAfter, dayNumber, endOfMonth, monthsBetween, startOfMonth, } from "./timeline-dates"; -import { groupKey, medicationListItems } from "./medication-rows"; +import { + groupKey, + medicationGroups, + medicationListItems, +} from "./medication-rows"; import { LANE_ORDER, isSpan } from "./timeline-geometry"; export type ChronicleGrouping = "month" | "year"; @@ -259,17 +270,63 @@ export function railsAt( return rails; } +/** + * An entry of a rail lane always shows its own lane's colour beside it: a + * dot on its lane's rail when no period of that lane passes the row. Without + * it a medication listed inside an illness carried only the illness's rail, + * and read as the illness's colour. + */ +function ownRail(rails: ChronicleRails, entry: ChronicleEntry): ChronicleRails { + if (entry.kind !== "item") return rails; + const lane = entry.lane as RailLane; + if (!(RAIL_LANES as readonly string[]).includes(lane) || rails[lane]) { + return rails; + } + return { ...rails, [lane]: "point" }; +} + /** * The rows: a header per group, its entries, and one gap row for every run * of groups without an entry. Groups run from `today`'s back to the oldest * entry, or to `dataFrom` when the record reaches further. */ export function buildChronicle( - timeline: Pick, + timeline: Pick< + TimelineResponse, + "lanes" | "notable" | "range" | "series" | "bucket" + >, today: string, grouping: ChronicleGrouping, ): ChronicleRow[] { const entries = chronicleEntries(timeline, today); + const groupAfter = (group: string) => + grouping === "month" ? addMonths(group, 1) : addMonths(group, 12); + /** A value line has a reading in the group (its bucket overlaps it). */ + const valued = (group: string) => + timeline.series.some((s) => + s.points.some( + (p) => + p.count > 0 && + p.t < groupAfter(group) && + bucketAfter(p.t, timeline.bucket) > group, + ), + ); + /** + * A medication was taken through the group (its intakes are not on the + * timeline, its stretch is). A paused stretch does not count. + */ + const medications = timeline.lanes.find((l) => l.key === "medications"); + const taken = medications + ? medicationGroups(medications.items).flatMap((g) => g.periods) + : []; + const running = (group: string) => { + const end = groupEnd(group, grouping); + return taken.some( + (item) => + item.start <= end && + (item.open ? today : (item.end ?? item.start)) >= group, + ); + }; const spans = railSpans(timeline.lanes); const oldest = [ entries.at(-1)?.date, @@ -309,6 +366,22 @@ export function buildChronicle( for (const group of groupsBetween(first, today, grouping)) { const list = byGroup.get(group); if (!list) { + if (valued(group)) { + // No event, but readings: the header carries the month's means. + flushGap(); + const end = groupEnd(group, grouping); + rows.push({ + type: "header", + group, + rails: railsAt(spans, end < today ? end : today), + }); + continue; + } + if (running(group)) { + // Something was under way: not empty, and nothing to list either. + flushGap(); + continue; + } // Groups run newest first: the first empty one is the gap's newest end. gapTo = gapTo ?? group; gapFrom = group; @@ -322,7 +395,11 @@ export function buildChronicle( rails: railsAt(spans, end < today ? end : today), }); for (const entry of list) { - rows.push({ type: "entry", entry, rails: railsAt(spans, entry.date) }); + rows.push({ + type: "entry", + entry, + rails: ownRail(railsAt(spans, entry.date), entry), + }); } } flushGap(); diff --git a/src/components/timeline/medication-rows.ts b/src/components/timeline/medication-rows.ts index 26320374d..24ad1d9b8 100644 --- a/src/components/timeline/medication-rows.ts +++ b/src/components/timeline/medication-rows.ts @@ -52,6 +52,30 @@ function spans(item: TimelineItem): boolean { return item.open || (item.end !== null && item.end !== item.start); } +/** + * The medication's own span as a stretch, where its courses do not say when + * it was taken: all of it when it has no course, and the time since its last + * course ended when it is still taken but no course is open. Without that + * second arm an active medication whose courses all lay in the past had no + * stretch in the current window and dropped out of the chart, while the day + * view, which reads the medication itself, listed it as running. + */ +function ownStretch( + medication: TimelineItem | null, + courses: readonly TimelineItem[], +): TimelineItem[] { + if (!medication) return []; + if (courses.length === 0) return [medication]; + if (!medication.open || courses.some((c) => c.open)) return []; + const lastEnd = courses + .map((c) => c.end ?? c.start) + .sort() + .at(-1)!; + const start = dayKey(dayNumber(lastEnd) + 1); + if (start <= medication.start) return [medication]; + return [{ ...medication, id: `${medication.id}:since`, start }]; +} + /** The medications lane's items, one group per medication, oldest first. */ export function medicationGroups( items: readonly TimelineItem[], @@ -67,8 +91,7 @@ export function medicationGroups( 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]; + const stretches = [...courses, ...ownStretch(medication, courses)]; groups.push({ key, label: medication?.label ?? list[0].label, diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index ab7d777b6..0a6ec190a 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -331,8 +331,17 @@ export function TimelineChronicle({ {dateText} + {/* Every entry of a kind carries its lane's colour, as on the + chart: a medication is the medication colour wherever it + is listed, never the colour of a rail passing beside it. */}