diff --git a/src/client/App.tsx b/src/client/App.tsx index 8b5d1e2..d05c7fe 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -108,7 +108,7 @@ export function App() { const ignored = useMarkSet(KEYS.ignored); const prog = useProgress(); const daily = useDailyLog(); - const custom = useCustom(); + const custom = useCustom(now); // Colour only: which ground the page is drawn on, written to the document by // the hook. Nothing else in the app asks what it is — the tokens in // styles.css answer for every component — except the hues below. diff --git a/src/client/components/CustomForms.tsx b/src/client/components/CustomForms.tsx index 80f7cc8..16e4da4 100644 --- a/src/client/components/CustomForms.tsx +++ b/src/client/components/CustomForms.tsx @@ -202,6 +202,9 @@ export function EventForm({ startHasTime: startTime !== "", endsAt, endHasTime: endTime !== "", + // This form has no repeat control yet — every event it saves is + // still a single occurrence, exactly as before. + repeat: null, }); }} > diff --git a/src/client/state/useCustom.ts b/src/client/state/useCustom.ts index 13e22fc..45d6574 100644 --- a/src/client/state/useCustom.ts +++ b/src/client/state/useCustom.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { asDisplayEvent, + asOccurrenceEvent, CustomEvent, CustomGame, mintCustomEventId, @@ -11,6 +12,7 @@ import { type DisplayEvent, type LaneId, } from "../../shared/custom.ts"; +import { nextOccurrences, occurrencesOf, type Repeat } from "../../shared/recurrence.ts"; import type { EventType } from "../../shared/schema.ts"; import { KEYS, readJson, writeJson } from "./storage.ts"; @@ -34,6 +36,8 @@ export interface EventDraft { startHasTime: boolean; endsAt: string | null; endHasTime: boolean; + /** How it comes round again, or null when it does not. */ + repeat: Repeat | null; } /** @@ -114,7 +118,65 @@ export function validRecords( return out; } -export function useCustom() { +/** + * How many occurrences of a rule the lists carry. + * + * Two: the one that has not finished, and the one after it. A rule repeating + * weekly would otherwise put thirteen rows into the lists that exist to answer + * "what ends soonest", which is the clutter PRD F1 is built to avoid. The + * timeline is the surface where repetition is the reading, and it asks for the + * whole range instead — see `occurrencesInFor`. + */ +const LIST_OCCURRENCES = 2; + +/** + * The reader's events as rows, rules expanded. + * + * Pure and exported so the expansion is testable without mounting anything — + * the same reason `gameOrder.ts` and `daily.ts` keep their logic outside React. + */ +export function rowsFor(events: CustomEvents, nowMs: number): DisplayEvent[] { + const out: DisplayEvent[] = []; + for (const event of Object.values(events)) { + if (event.repeat === null) { + out.push(asDisplayEvent(event)); + continue; + } + for (const occurrence of nextOccurrences(event, nowMs, LIST_OCCURRENCES)) { + out.push(asOccurrenceEvent(event, occurrence)); + } + } + return out; +} + +/** + * Every occurrence of every rule inside a range. + * + * Only rules. A non-repeating event is already in `rowsFor`, and returning it + * here as well would draw each of the reader's plain events twice on the board. + */ +export function occurrencesInFor( + events: CustomEvents, + minMs: number, + maxMs: number, +): DisplayEvent[] { + const out: DisplayEvent[] = []; + for (const event of Object.values(events)) { + if (event.repeat === null) continue; + for (const occurrence of occurrencesOf(event, minMs, maxMs)) { + out.push(asOccurrenceEvent(event, occurrence)); + } + } + return out; +} + +/** + * `nowMs` is passed in rather than read here, for the reason everything else in + * this codebase takes its clock as an argument — and because the rows have to + * change as time passes: the occurrence a rule is showing rolls to the next one + * when the current one finishes. + */ +export function useCustom(nowMs: number) { const [games, setGames] = useState(() => readValid(KEYS.customGames, CustomGame, "custom game"), ); @@ -186,6 +248,7 @@ export function useCustom() { // An unannounced end is a supported answer here exactly as it is in the // feed. Nobody is made to invent a date to satisfy a form. endPrecision: draft.endsAt === null ? "unknown" : precisionOf(draft.endHasTime), + repeat: draft.repeat, at: now, updatedAt: now, }); @@ -209,6 +272,7 @@ export function useCustom() { startPrecision: precisionOf(draft.startHasTime), endsAt: draft.endsAt, endPrecision: draft.endsAt === null ? "unknown" : precisionOf(draft.endHasTime), + repeat: draft.repeat, updatedAt: new Date().toISOString(), }); return { ...prev, [id]: next }; @@ -240,9 +304,22 @@ export function useCustom() { [], ); - /** The reader's events, in the shape every view reads. */ + /** + * The reader's events, in the shape every view reads, rules expanded. + * + * Bucketed to the minute rather than recomputed per tick: expanding every + * rule each second would be wasted work, and no countdown is wrong by less + * than a minute's delay in rolling to the next occurrence. + */ + const minute = Math.floor(nowMs / 60_000); const rows = useMemo( - () => Object.values(events).map(asDisplayEvent), + () => rowsFor(events, minute * 60_000), + [events, minute], + ); + + /** Every occurrence of every rule inside a range — the timeline's question. */ + const occurrencesIn = useCallback( + (minMs: number, maxMs: number) => occurrencesInFor(events, minMs, maxMs), [events], ); @@ -253,6 +330,7 @@ export function useCustom() { games, events, rows, + occurrencesIn, lanes, addGame, editGame, diff --git a/test/custom.test.ts b/test/custom.test.ts index 4bb9b78..4246f7a 100644 --- a/test/custom.test.ts +++ b/test/custom.test.ts @@ -18,7 +18,12 @@ import { dailiesId } from "../src/shared/daily.ts"; import { nextOccurrences, Repeat } from "../src/shared/recurrence.ts"; import { eventId, GameId } from "../src/shared/schema.ts"; import { clockFor } from "../src/shared/time.ts"; -import { readerInstant, validRecords } from "../src/client/state/useCustom.ts"; +import { + occurrencesInFor, + readerInstant, + rowsFor, + validRecords, +} from "../src/client/state/useCustom.ts"; const AT = "2026-08-17T12:00:00.000Z"; @@ -546,3 +551,59 @@ describe("asOccurrenceEvent", () => { expect(clock.live).toBe(true); }); }); + +describe("expanding rules into rows", () => { + const NOW = new Date("2026-09-03T12:00:00").getTime(); + + const plain = ownEvent({ id: "myevent:plain00001" }); + const repeating = ownEvent({ + id: "myevent:k3f9qa2m01", + startsAt: new Date("2026-09-01T09:00:00").toISOString(), + startPrecision: "exact", + endsAt: new Date("2026-09-08T09:00:00").toISOString(), + endPrecision: "exact", + repeat: { unit: "weeks", interval: 2, until: null }, + }); + const store = { [plain.id]: plain, [repeating.id]: repeating }; + + test("a non-repeating event still yields exactly one row, unchanged", () => { + const rows = rowsFor({ [plain.id]: plain }, NOW); + expect(rows.map((r) => r.id)).toEqual(["myevent:plain00001"]); + }); + + test("a rule yields two rows however often it repeats", () => { + // The lists answer "what ends soonest". Thirteen rows for one weekly rule + // is the clutter F1 exists to avoid. + const rows = rowsFor(store, NOW); + expect(rows.filter((r) => r.id.startsWith("myevent:k3f9qa2m01")).map((r) => r.id)).toEqual([ + "myevent:k3f9qa2m01#2026-09-01", + "myevent:k3f9qa2m01#2026-09-15", + ]); + }); + + test("each occurrence is a separate key, so marks do not bleed between them", () => { + const rows = rowsFor(store, NOW); + const ids = rows.map((r) => r.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test("occurrencesIn covers a whole range, not just the next two", () => { + const rows = occurrencesInFor( + store, + new Date("2026-09-01T00:00:00").getTime(), + new Date("2026-11-01T00:00:00").getTime(), + ); + expect(rows.filter((r) => r.id.startsWith("myevent:k3f9qa2m01"))).toHaveLength(5); + }); + + test("occurrencesIn ignores non-repeating events", () => { + // They are already in `rows`; returning them here would double every one of + // the reader's plain events on the board. + const rows = occurrencesInFor( + { [plain.id]: plain }, + new Date("2026-01-01T00:00:00").getTime(), + new Date("2027-01-01T00:00:00").getTime(), + ); + expect(rows).toEqual([]); + }); +});