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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 16:05:40 +02:00
1 parent 86a3856ae9
commit 9f3da5ac7d
8 files changed
+488 -19

No files matched your search

@@ -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,
@@ -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> = {}): 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<TimelineItem>,
) =>
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);
});
});
@@ -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(
<WithFormat
build={(format) => (
<TimelineChronicle
timeline={fullTimeline()}
timeline={eventsOnly}
today={TODAY}
grouping="month"
selected="2026-01-03"
@@ -157,7 +169,6 @@ describe("chronicle", () => {
/>,
);
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"><button[^>]*bg-muted/);
});
it("never calls a month empty while a medication was taken in it", () => {
const html = render(
<WithFormat
build={(format) => (
<TimelineChronicle
timeline={fullTimeline()}
today={TODAY}
grouping="month"
selected={null}
seriesColor={seriesColor}
seriesFormat={format}
onOpenDay={() => 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(/<svg[^>]*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(
+81 -4
View File
@@ -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<TimelineResponse, "lanes" | "notable" | "range">,
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();
+25 -2
View File
@@ -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,
@@ -331,8 +331,17 @@ export function TimelineChronicle({
<span className="text-muted-foreground text-xs leading-5 whitespace-nowrap tabular-nums">
{dateText}
</span>
{/* 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. */}
<Icon
className="text-muted-foreground mt-0.5 size-4"
style={
entry.kind === "item"
? { color: LANE_COLOR[entry.lane] }
: undefined
}
data-lane={entry.kind === "item" ? entry.lane : undefined}
aria-hidden="true"
/>
<span className="min-w-0">
@@ -0,0 +1,87 @@
/**
* The window filter keeps a medication whole: when one of its items touches
* the window, every item of it is sent, because the client reads the
* stretches from its courses and the dose of a piece from the last dose
* change before it, which may lie outside the window.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
import type { TimelineItem } from "@/lib/day/contract";
import { keepTouchingGroups } from "../load-timeline";
const base = {
end: null,
open: false,
precision: "DAY" as const,
startKnown: true,
sub: null,
href: null,
};
const entry = (
over: Partial<TimelineItem> & Pick<TimelineItem, "id" | "kind" | "start">,
): TimelineItem =>
({ ...base, label: over.id, group: null, ...over }) as TimelineItem;
describe("keepTouchingGroups", () => {
const items = [
entry({
id: "mj",
group: "mj",
kind: "medication",
start: "2024-01-01",
open: true,
}),
entry({
id: "c1",
group: "mj",
kind: "course",
start: "2024-01-01",
end: "2024-06-30",
}),
entry({
id: "c2",
group: "mj",
kind: "course",
start: "2026-01-01",
open: true,
}),
entry({
id: "d1",
group: "mj",
kind: "doseChange",
start: "2024-01-01",
sub: "2.5 mg",
}),
entry({
id: "old",
group: "old",
kind: "medication",
start: "2020-01-01",
end: "2020-03-01",
}),
entry({ id: "v1", kind: "vaccination", start: "2023-05-01" }),
];
it("sends every item of a medication that touches the window", () => {
const kept = keepTouchingGroups(
items,
"2026-07-01",
"2026-10-09",
"2026-10-09",
);
expect(kept.map((i) => i.id).sort()).toEqual(["c1", "c2", "d1", "mj"]);
});
it("leaves out a group and a lone item that do not touch it", () => {
const kept = keepTouchingGroups(
items,
"2026-07-01",
"2026-10-09",
"2026-10-09",
);
expect(kept.some((i) => i.id === "old" || i.id === "v1")).toBe(false);
});
});
+26 -3
View File
@@ -172,6 +172,29 @@ export function overlaps(
return item.start <= to && end >= from;
}
/**
* The items of a lane that touch `[from, to]`, kept whole per `group`: when
* one item of a medication touches the window, every item of it is sent.
* The client reads a medication from all of them together
* (`medication-rows.ts`): its courses decide the stretches it was taken, and
* the dose of a piece that runs into the window was set by a dose change
* that may lie before it. Cut at the window edge, a course outside it was
* lost (the medication's own span stood in for it and bridged the gap), and
* the first piece in the window lost its dose.
*/
export function keepTouchingGroups(
items: readonly TimelineItem[],
from: string,
to: string,
today: string,
): TimelineItem[] {
const touching = new Set<string>();
for (const it of items) {
if (overlaps(it, from, to, today)) touching.add(it.group ?? it.id);
}
return items.filter((it) => touching.has(it.group ?? it.id));
}
export async function loadTimeline(args: {
recordId: string;
query: TimelineQuery;
@@ -216,9 +239,9 @@ export async function loadTimeline(args: {
const lanes: TimelineResponse["lanes"] = [];
const standing: StandingItem[] = [];
visibleLanes.forEach((key, i) => {
const items = reads[i].items
.filter((it) => overlaps(it, from, to, today))
.sort((a, b) => a.start.localeCompare(b.start));
const items = keepTouchingGroups(reads[i].items, from, to, today).sort(
(a, b) => a.start.localeCompare(b.start),
);
if (items.length > 0) lanes.push({ key, items });
standing.push(...reads[i].standing);
});