diff --git a/src/client/App.tsx b/src/client/App.tsx index d05c7fe..a714e8a 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchFeed, type FeedState } from "./api.ts"; import { Controls } from "./components/Controls.tsx"; import { Dailies } from "./components/Dailies.tsx"; @@ -265,29 +265,32 @@ export function App() { const focus = resolveFocus(prefs.focusGame, enabled); /** - * Everything the reader could be looking at, before focus narrows it. The - * focus chips count off this, so a chip can say what is waiting in a game - * that is not the one currently on screen. + * The filters that decide whether a row is on screen at all. + * + * Extracted from `inScope` so the timeline's expanded occurrences pass + * through the same four questions. Restating them there would be a second + * copy that drifts, and each drift is a row the reader told us to hide + * appearing on the board. */ - const inScope = useMemo( - () => - allRows + const inScopeOf = useCallback( + (rows: RowEvent[]) => + rows .filter((r) => !prefs.hiddenGames.includes(r.event.game)) .filter((r) => !r.clock.ended) // Ignored events are gone from both views unless deliberately revealed // — that is the whole point of ignoring one. .filter((r) => prefs.showIgnored || !isIgnored(r.event.id)) .filter((r) => prefs.showCompleted || !isDone(r.event.id)), - [ - allRows, - prefs.hiddenGames, - prefs.showCompleted, - prefs.showIgnored, - prog.progress, - ignored.marks, - ], + [prefs.hiddenGames, prefs.showCompleted, prefs.showIgnored, prog.progress, ignored.marks], ); + /** + * Everything the reader could be looking at, before focus narrows it. The + * focus chips count off this, so a chip can say what is waiting in a game + * that is not the one currently on screen. + */ + const inScope = useMemo(() => inScopeOf(allRows), [allRows, inScopeOf]); + const visible = useMemo( () => inScope @@ -298,6 +301,27 @@ export function App() { [inScope, focus, prefs.sort, prog.progress, daily.logs], ); + /** + * Fills the timeline's settled window with the rest of a rule's rhythm. + * + * Passed to `` as `expand` rather than inlined there — a hook + * cannot be called inside JSX. Filtered through `inScopeOf` and the focus + * chip so an expanded occurrence obeys exactly what a base row obeys: a + * hidden game, an ignored event, or a finished one stays off the board. + */ + const expandOccurrences = useCallback( + (min: number, max: number) => + inScopeOf( + custom + .occurrencesIn(min, max) + .map((event) => ({ event, clock: clockFor(event, prefs.region, now) })), + ).filter((r) => focus === null || r.event.game === focus), + // `now` is deliberately coarse here, as it is for `allRows`: + // re-expanding every rule each second would be wasted work. + // eslint-disable-next-line react-hooks/exhaustive-deps + [custom.occurrencesIn, inScopeOf, focus, prefs.region, Math.floor(now / 60_000)], + ); + const live = visible.filter((r) => r.clock.live); const upcoming = visible.filter((r) => r.clock.upcoming); /** @@ -585,6 +609,7 @@ export function App() { gameOrder={ordered} onOpen={setOpenId} isDone={isDone} + expand={expandOccurrences} /> )} diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index b144c7b..efe8807 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -1,7 +1,7 @@ import { Fragment, useLayoutEffect, useRef } from "react"; import { useGameMeta } from "../state/gameMeta.tsx"; import type { LaneId } from "../../shared/custom.ts"; -import { DAY } from "../../shared/time.ts"; +import { DAY, endingSoonestFirst } from "../../shared/time.ts"; import type { RowEvent } from "./EventRow.tsx"; import { URGENCY_COLOR } from "./Meter.tsx"; import { @@ -103,6 +103,7 @@ export function Timeline({ gameOrder, onOpen, isDone, + expand, }: { rows: RowEvent[]; now: number; @@ -152,6 +153,18 @@ export function Timeline({ * that would say "finished" about something they have not started. */ isDone: (id: string) => boolean; + /** + * More rows to draw, once the board's range is known. + * + * Called with the settled window rather than returning everything up front, + * because a repeating rule has no natural end — it fills whatever it is + * given. **It must be called after `boardWindow`, never before:** the window + * takes its `max` from the ends it is handed, so feeding expanded + * occurrences back into it would widen the window, generate more + * occurrences, and widen it again, and a rule with no `until` would never + * terminate. + */ + expand?: ((minMs: number, maxMs: number) => RowEvent[]) | undefined; }) { const gameMeta = useGameMeta(); const scroller = useRef(null); @@ -162,9 +175,26 @@ export function Timeline({ const waiting = rows.filter((r) => r.clock.upcoming); const plotted = showUpcoming ? rows : rows.filter((r) => !r.clock.upcoming); + // From `plotted` alone, and settled before `expand` is called. See the prop's + // note: this is the ordering that keeps a repeating rule from growing the + // board it is being drawn onto. const ends = plotted.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY); const starts = plotted.map((r) => r.clock.startsMs); const { min, max } = boardWindow(starts, ends, now); + + // Rules fill the settled window. Deduplicated because the first two + // occurrences of every rule are already in `plotted` — they are what the + // lists carry — and drawn on top of each other they would read as a bolder + // bar rather than as a duplicate. + const seen = new Set(plotted.map((r) => r.event.id)); + const extra = (expand?.(min, max) ?? []).filter( + (r) => !seen.has(r.event.id) && (showUpcoming || !r.clock.upcoming), + ); + // Re-sorted only when there is something to merge, so a reader with no + // repeating events sees byte-identical behaviour. `endingSoonestFirst` is the + // order `splitAt` relies on — live before upcoming — and appending unsorted + // rows would break the split point it looks for. + const drawn = extra.length === 0 ? plotted : [...plotted, ...extra].sort(endingSoonestFirst); const totalDays = Math.ceil((max - min) / DAY); const chartWidth = totalDays * dayWidth; /** One coordinate space for everything: bars, gridlines and the now rule. */ @@ -222,7 +252,7 @@ export function Timeline({ ); } - const lanes = timelineLanes(plotted, group, splitUpcoming, gameOrder); + const lanes = timelineLanes(drawn, group, splitUpcoming, gameOrder); const marks = startMarkers(plotted, x); const months = monthBoundaries(min, max); diff --git a/test/views.test.tsx b/test/views.test.tsx index 185192b..ca94969 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -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. + }); +});