From 27715c06e0c5974c7c34e8a91b50d7f5f062792b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 16:45:23 +0200 Subject: [PATCH] fix(timeline): a name right of its own bar, one band per pair of rows, one line per notable A closed period's name could not stand right of its own bar: the room check counted the bar it names as the next mark ahead, so "Away" went above its bar and sat on top of the life event named on the row above. The room ahead now counts only what starts to the right. A row's names and the names above the next row's bars share one band a few pixels tall, so whatever is placed on one is written into the other as well. The phone chronicle listed "First value of a measure" twice on a day when two metrics began on it, under one React key; it lists one line per kind of notable a day. --- .../__tests__/chronicle-model.test.ts | 15 ++++++++++ .../__tests__/timeline-geometry.test.ts | 28 ++++++++++------- src/components/timeline/chronicle-model.ts | 6 ++++ src/components/timeline/timeline-geometry.ts | 30 ++++++++++++++++--- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/src/components/timeline/__tests__/chronicle-model.test.ts b/src/components/timeline/__tests__/chronicle-model.test.ts index a2e6eec33..445d0b516 100644 --- a/src/components/timeline/__tests__/chronicle-model.test.ts +++ b/src/components/timeline/__tests__/chronicle-model.test.ts @@ -256,3 +256,18 @@ describe("standingChips", () => { ); }); }); + +describe("notable lines", () => { + it("lists one line per kind on a day, however many metrics share it", () => { + const tl = fullTimeline(); + tl.notable = [ + { date: "2026-08-30", kind: "firstValue" }, + { date: "2026-08-30", kind: "firstValue" }, + { date: "2026-08-30", kind: "extremeHigh" }, + ] as typeof tl.notable; + const lines = chronicleEntries(tl, TODAY).filter( + (e) => e.kind === "notable" && e.date === "2026-08-30", + ); + expect(lines).toHaveLength(2); + }); +}); diff --git a/src/components/timeline/__tests__/timeline-geometry.test.ts b/src/components/timeline/__tests__/timeline-geometry.test.ts index 0ea5ad030..e3eee440f 100644 --- a/src/components/timeline/__tests__/timeline-geometry.test.ts +++ b/src/components/timeline/__tests__/timeline-geometry.test.ts @@ -763,6 +763,12 @@ describe("label room (v1.42 final)", () => { expect(long[1].endsWith("…")).toBe(true); }); + it("names a closed period right of its own bar when nothing follows it", () => { + const occ = new LineOccupancy(); + occ.add("1:on", 100, 200); + expect(placeText(occ, "1:on", 206, "Away", 1000, 4)).toBe("Away"); + }); + it("keeps a gap before the next mark, cuts a name short, or leaves it out", () => { const occ = new LineOccupancy(); occ.add("0:on", 200, 210); @@ -819,20 +825,20 @@ describe("label room (v1.42 final)", () => { } }); - it("never lets two names of one lane line overlap or crowd each other", () => { + it("never lets two names of one band overlap, on a row or across two", () => { for (const width of [390, 700, 1100, 1600]) { const layout = layoutAt(width); for (const lane of layout.lanes) { - const byLine = new Map>(); - for (const box of boxes(lane)) { - const list = byLine.get(box.y) ?? []; - list.push(box); - byLine.set(box.y, list); - } - for (const list of byLine.values()) { - list.sort((p, q) => p.a - q.a); - for (let i = 1; i < list.length; i++) { - expect(list[i].a).toBeGreaterThanOrEqual(list[i - 1].b); + // Two names closer than a text line apart vertically share a band + // (a row's names and the names above the next row's bars): they + // must not overlap sideways either. + const all = boxes(lane); + for (let i = 0; i < all.length; i++) { + for (let j = i + 1; j < all.length; j++) { + const p = all[i]; + const q = all[j]; + if (Math.abs(p.y - q.y) >= 12) continue; + expect(p.b <= q.a || q.b <= p.a).toBe(true); } } } diff --git a/src/components/timeline/chronicle-model.ts b/src/components/timeline/chronicle-model.ts index 905ce85c9..d1c7e962c 100644 --- a/src/components/timeline/chronicle-model.ts +++ b/src/components/timeline/chronicle-model.ts @@ -211,7 +211,13 @@ export function chronicleEntries( } } } + // One line per kind of notable on a day: two metrics seen for the first + // time on one day are one "First value" line, not two identical ones. + const notables = new Set(); for (const n of timeline.notable) { + const key = `${n.date}:${n.kind}`; + if (notables.has(key)) continue; + notables.add(key); out.push({ kind: "notable", date: n.date, notable: n.kind }); } const laneRank = (e: ChronicleEntry) => diff --git a/src/components/timeline/timeline-geometry.ts b/src/components/timeline/timeline-geometry.ts index e79fd8554..bb95c69c5 100644 --- a/src/components/timeline/timeline-geometry.ts +++ b/src/components/timeline/timeline-geometry.ts @@ -242,14 +242,15 @@ export class LineOccupancy { /** * How far right a text starting at `a` may run on `line`: up to `margin` - * before the next thing there. `a` itself when `a` is already too close to - * something, Infinity when nothing lies to its right. + * before the next thing that starts to its right. `a` itself when `a` lies + * inside something, Infinity when nothing lies to its right. What ends + * before `a` (the mark the text names) is the caller's `fits` to judge. */ room(line: string, a: number, margin = LABEL_MARGIN): number { let limit = Infinity; for (const [c, d] of this.lines.get(line) ?? []) { - if (a < d + margin && a > c - margin) return a; - if (c - margin >= a) limit = Math.min(limit, c - margin); + if (c <= a && a < d) return a; + if (c > a) limit = Math.min(limit, c - margin); } return limit; } @@ -261,6 +262,23 @@ export const NEIGHBOUR_GAP = 12; /** A label cut shorter than this many characters is left out instead. */ export const MIN_LABEL_CHARS = 6; +/** + * A name on one row's line and a name above the next row's bar sit a few + * pixels apart vertically: the two lines share a band. Whatever is placed on + * one is written into the other too, so the two never stack two names over + * each other ("Moved into a bigger flat" over "Away"). + */ +export function shareBand( + occ: LineOccupancy, + row: number, + where: "on" | "above", + a: number, + b: number, +): void { + if (where === "on") occ.add(`${row + 1}:above`, a, b); + else if (row > 0) occ.add(`${row - 1}:on`, a, b); +} + /** * Put `text` on `line` from `a`: whole when it keeps {@link NEIGHBOUR_GAP} * from the next thing to its right and `margin` from everything else, else @@ -816,6 +834,7 @@ function placeMedicationLabels( margin, ); if (placed === null) return false; + shareBand(occ, row, where, a, a + estimateTextWidth(placed)); labels.push({ itemId: id, text: placed, @@ -942,6 +961,7 @@ function placeLabels( if (a < scale.x0) return false; const placed = placeText(occ, line(s.row, "above"), a, text, scale.x1 - 2); if (placed === null) return false; + shareBand(occ, s.row, "above", a, a + estimateTextWidth(placed)); labels.push({ itemId: s.item.id, text: placed, @@ -973,6 +993,7 @@ function placeLabels( BESIDE_MARGIN, ); if (placed === null) return false; + shareBand(occ, row, "on", a, a + estimateTextWidth(placed)); labels.push({ itemId: id, text: placed, x: a, y: y + 4, strong }); return true; }; @@ -989,6 +1010,7 @@ function placeLabels( const a = b - estimateTextWidth(text); if (!fitsCard(a, b)) return false; if (!occ.tryAdd(line(row, "on"), a, b, BESIDE_MARGIN)) return false; + shareBand(occ, row, "on", a, b); labels.push({ itemId: id, text, x: a, y: y + 4, strong }); return true; };