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:
co-authored by
Claude Opus 5
parent
f7f67fd305
commit
181eceac6f
+40
-15
@@ -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 { fetchFeed, type FeedState } from "./api.ts";
|
||||||
import { Controls } from "./components/Controls.tsx";
|
import { Controls } from "./components/Controls.tsx";
|
||||||
import { Dailies } from "./components/Dailies.tsx";
|
import { Dailies } from "./components/Dailies.tsx";
|
||||||
@@ -265,29 +265,32 @@ export function App() {
|
|||||||
const focus = resolveFocus(prefs.focusGame, enabled);
|
const focus = resolveFocus(prefs.focusGame, enabled);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything the reader could be looking at, before focus narrows it. The
|
* The filters that decide whether a row is on screen at all.
|
||||||
* focus chips count off this, so a chip can say what is waiting in a game
|
*
|
||||||
* that is not the one currently on screen.
|
* 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(
|
const inScopeOf = useCallback(
|
||||||
() =>
|
(rows: RowEvent[]) =>
|
||||||
allRows
|
rows
|
||||||
.filter((r) => !prefs.hiddenGames.includes(r.event.game))
|
.filter((r) => !prefs.hiddenGames.includes(r.event.game))
|
||||||
.filter((r) => !r.clock.ended)
|
.filter((r) => !r.clock.ended)
|
||||||
// Ignored events are gone from both views unless deliberately revealed
|
// Ignored events are gone from both views unless deliberately revealed
|
||||||
// — that is the whole point of ignoring one.
|
// — that is the whole point of ignoring one.
|
||||||
.filter((r) => prefs.showIgnored || !isIgnored(r.event.id))
|
.filter((r) => prefs.showIgnored || !isIgnored(r.event.id))
|
||||||
.filter((r) => prefs.showCompleted || !isDone(r.event.id)),
|
.filter((r) => prefs.showCompleted || !isDone(r.event.id)),
|
||||||
[
|
[prefs.hiddenGames, prefs.showCompleted, prefs.showIgnored, prog.progress, ignored.marks],
|
||||||
allRows,
|
|
||||||
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(
|
const visible = useMemo(
|
||||||
() =>
|
() =>
|
||||||
inScope
|
inScope
|
||||||
@@ -298,6 +301,27 @@ export function App() {
|
|||||||
[inScope, focus, prefs.sort, prog.progress, daily.logs],
|
[inScope, focus, prefs.sort, prog.progress, daily.logs],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the timeline's settled window with the rest of a rule's rhythm.
|
||||||
|
*
|
||||||
|
* Passed to `<Timeline>` 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 live = visible.filter((r) => r.clock.live);
|
||||||
const upcoming = visible.filter((r) => r.clock.upcoming);
|
const upcoming = visible.filter((r) => r.clock.upcoming);
|
||||||
/**
|
/**
|
||||||
@@ -585,6 +609,7 @@ export function App() {
|
|||||||
gameOrder={ordered}
|
gameOrder={ordered}
|
||||||
onOpen={setOpenId}
|
onOpen={setOpenId}
|
||||||
isDone={isDone}
|
isDone={isDone}
|
||||||
|
expand={expandOccurrences}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Fragment, useLayoutEffect, useRef } from "react";
|
import { Fragment, useLayoutEffect, useRef } from "react";
|
||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
import type { LaneId } from "../../shared/custom.ts";
|
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 type { RowEvent } from "./EventRow.tsx";
|
||||||
import { URGENCY_COLOR } from "./Meter.tsx";
|
import { URGENCY_COLOR } from "./Meter.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -103,6 +103,7 @@ export function Timeline({
|
|||||||
gameOrder,
|
gameOrder,
|
||||||
onOpen,
|
onOpen,
|
||||||
isDone,
|
isDone,
|
||||||
|
expand,
|
||||||
}: {
|
}: {
|
||||||
rows: RowEvent[];
|
rows: RowEvent[];
|
||||||
now: number;
|
now: number;
|
||||||
@@ -152,6 +153,18 @@ export function Timeline({
|
|||||||
* that would say "finished" about something they have not started.
|
* that would say "finished" about something they have not started.
|
||||||
*/
|
*/
|
||||||
isDone: (id: string) => boolean;
|
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 gameMeta = useGameMeta();
|
||||||
const scroller = useRef<HTMLDivElement>(null);
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
@@ -162,9 +175,26 @@ export function Timeline({
|
|||||||
const waiting = rows.filter((r) => r.clock.upcoming);
|
const waiting = rows.filter((r) => r.clock.upcoming);
|
||||||
const plotted = showUpcoming ? rows : 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 ends = plotted.map((r) => r.clock.endsMs ?? r.clock.startsMs + 14 * DAY);
|
||||||
const starts = plotted.map((r) => r.clock.startsMs);
|
const starts = plotted.map((r) => r.clock.startsMs);
|
||||||
const { min, max } = boardWindow(starts, ends, now);
|
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 totalDays = Math.ceil((max - min) / DAY);
|
||||||
const chartWidth = totalDays * dayWidth;
|
const chartWidth = totalDays * dayWidth;
|
||||||
/** One coordinate space for everything: bars, gridlines and the now rule. */
|
/** 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 marks = startMarkers(plotted, x);
|
||||||
|
|
||||||
const months = monthBoundaries(min, max);
|
const months = monthBoundaries(min, max);
|
||||||
|
|||||||
@@ -821,3 +821,29 @@ describe("CatchUpPanel", () => {
|
|||||||
expect([...markup.matchAll(/aria-label="[^"]*(?:not )?done"/g)]).toHaveLength(17);
|
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.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user