Expand a rule into rows, two at a time

The lists exist to answer "what ends soonest", so a rule contributes the
occurrence that has not finished and the one after it — no more, however
often it repeats. A weekly rule would otherwise put thirteen rows into the
list F1 is built to keep short.

occurrencesIn answers the timeline's different question and covers a whole
range. It skips non-repeating events deliberately: those are already in
rows, and returning them twice would double every plain event on the board.

useCustom takes the clock as an argument now, because the rows have to
change as time passes — the occurrence on screen rolls to the next one when
the current finishes.

CustomForms.tsx now passes repeat: null when saving a draft — EventDraft
gained the field but the form has no repeat control yet, so every event it
saves stays a single occurrence.

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 c76a0b4e70
commit f7f67fd305
4 changed files with 147 additions and 5 deletions
+62 -1
View File
@@ -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([]);
});
});