Let a reader's event carry a repeat rule

.default(null) rather than a bare .nullable(), and the distinction is the
whole commit: a record written before this field existed has no `repeat`
key, a bare .nullable() rejects a missing key, and useCustom reads through
validRecords — which drops what fails and persists only the survivors. The
stricter form would have erased every reader's custom events on first
launch with no server-side copy.

Also refuses a window that comes round before it closes, since two live
occurrences of one rule leave "what ends soonest" without an answer. Only
when an end is stated; with none the window runs to the next opening and
cannot overlap.

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 12dbbe3228
commit 1535c2d920
2 changed files with 107 additions and 1 deletions
+76
View File
@@ -14,6 +14,7 @@ import {
} from "../src/shared/custom.ts";
import { metaFor } from "../src/shared/games.ts";
import { dailiesId } from "../src/shared/daily.ts";
import { 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";
@@ -397,3 +398,78 @@ describe("retiring a game, a source or a page", () => {
expect(c.endsMs).not.toBeNull();
});
});
describe("a custom event may carry a repeat rule", () => {
test("a record stored before this field existed still parses", () => {
// THE migration guarantee. readValid drops records that fail this schema,
// and the survivors are what the next write persists — so a required or
// bare-nullable field here would silently delete every custom event on
// every device that has one, with no server-side copy to restore from.
const legacy = {
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Walpurgisnacht",
type: "banner",
summary: null,
startsAt: "2026-08-20T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-09-03T00:00:00.000Z",
endPrecision: "day",
at: AT,
updatedAt: AT,
// no `repeat` key at all — this is the shape already in localStorage
};
const parsed = CustomEvent.safeParse(legacy);
expect(parsed.success).toBe(true);
expect(parsed.data!.repeat).toBe(null);
});
test("accepts a rule whose window closes before it comes round again", () => {
const event = ownEvent({
startsAt: "2026-09-01T00:00:00.000Z",
endsAt: "2026-09-08T00:00:00.000Z",
repeat: Repeat.parse({ unit: "weeks", interval: 2, until: null }),
});
expect(event.repeat?.interval).toBe(2);
});
test("rejects a rule that comes round before it ends", () => {
// A 14-day window repeating every 7 days puts two live occurrences of one
// rule in the same list, which makes "what ends soonest" ambiguous. Refused
// at the schema so an imported file cannot carry one in either.
const overlapping = CustomEvent.safeParse({
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Walpurgisnacht",
type: "banner",
summary: null,
startsAt: "2026-09-01T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-09-15T00:00:00.000Z",
endPrecision: "day",
repeat: { unit: "weeks", interval: 1, until: null },
at: AT,
updatedAt: AT,
});
expect(overlapping.success).toBe(false);
});
test("no stated end means there is no overlap to check", () => {
// The window runs to the next opening by definition, so it cannot overlap.
const parsed = CustomEvent.safeParse({
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Weekly missions",
type: "other",
summary: null,
startsAt: "2026-09-01T00:00:00.000Z",
startPrecision: "day",
endsAt: null,
endPrecision: "unknown",
repeat: { unit: "weeks", interval: 1, until: null },
at: AT,
updatedAt: AT,
});
expect(parsed.success).toBe(true);
});
});