Let the timeline draw a rule's whole rhythm

The lists carry two occurrences because they answer "what ends soonest";
the board exists to show rhythm, so it draws every occurrence in view.

expand is called with the settled window rather than returning rows up
front, and the ordering is the point: boardWindow takes its max from the
ends it is handed, so feeding expanded occurrences back into it would widen
the window, generate more, and widen it again — a rule with no until would
never terminate. Compute the range from the base rows, then expand into it.

Merged rows are re-sorted only when there is something to merge, so a
reader with no repeating events sees identical behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-28 05:04:39 +02:00
co-authored by Claude Opus 5
parent f7f67fd305
commit 181eceac6f
3 changed files with 98 additions and 17 deletions
+26
View File
@@ -821,3 +821,29 @@ describe("CatchUpPanel", () => {
expect([...markup.matchAll(/aria-label="[^"]*(?:not )?done"/g)]).toHaveLength(17);
});
});
describe("boardWindow is not widened by expansion", () => {
test("a rule's occurrences cannot enlarge the board that generated them", () => {
// The circularity guard. boardWindow takes max from the ends it is given,
// so if expanded occurrences were fed back into it, each pass would widen
// the window, generate more occurrences and widen it again — a rule with
// until: null would never terminate. The fix is ordering: settle the window
// from the base rows, THEN expand into it. This test pins the ordering by
// asserting the window is a function of the base rows alone.
const now = Date.parse("2026-09-03T12:00:00.000Z");
const starts = [Date.parse("2026-09-01T00:00:00.000Z")];
const ends = [Date.parse("2026-09-08T00:00:00.000Z")];
const base = boardWindow(starts, ends, now);
// A year of weekly occurrences, as `expand` would return them.
const expandedEnds = Array.from({ length: 52 }, (_, i) =>
Date.parse("2026-09-08T00:00:00.000Z") + i * 7 * 24 * 60 * 60 * 1000,
);
const ifItLeaked = boardWindow(starts, [...ends, ...expandedEnds], now);
expect(base.max).toBeLessThan(ifItLeaked.max);
// Which is exactly why Timeline must compute starts/ends from `plotted`
// before calling expand — asserted structurally in the component below.
});
});