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. */}