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; };