diff --git a/src/shared/custom.ts b/src/shared/custom.ts index 05c7260..cb8ecc2 100644 --- a/src/shared/custom.ts +++ b/src/shared/custom.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { comesRoundEarly, Repeat } from "./recurrence.ts"; import { EventType, GachaEvent, Precision, slugify } from "./schema.ts"; /** @@ -92,6 +93,19 @@ export const CustomEvent = z endsAt: z.string().datetime().nullable(), endPrecision: Precision, + /** + * How this comes round again, or null when it does not. + * + * **`.default(null)`, never a bare `.nullable()`.** A record written before + * this field existed has no `repeat` key at all, and a bare `.nullable()` + * rejects a *missing* key rather than supplying one. `useCustom` reads + * through `validRecords`, which drops what fails this schema, and the + * survivors are what the next write persists — so the stricter form would + * erase every reader's custom events on first launch, silently, with no + * server-side copy. The same hazard `game: z.string()` above is guarding. + */ + repeat: Repeat.nullable().default(null), + at: z.string().datetime(), updatedAt: z.string().datetime(), }) @@ -105,7 +119,23 @@ export const CustomEvent = z .refine((e) => e.endsAt === null || e.endsAt > e.startsAt, { message: "endsAt must be after startsAt", path: ["endsAt"], - }); + }) + // A window that has not closed by the time it comes round again puts two + // live occurrences of one rule in the same list, and "what ends soonest" no + // longer has an answer. Checked only when an end is stated: with none, the + // window runs to the next opening by definition and cannot overlap. + .refine( + (e) => + !comesRoundEarly( + Date.parse(e.startsAt), + e.endsAt === null ? null : Date.parse(e.endsAt), + e.repeat, + ), + { + message: "a repeat cannot come round before it ends", + path: ["repeat"], + }, + ); export type CustomEvent = z.infer; export const CustomGames = z.record(z.string(), CustomGame); diff --git a/test/custom.test.ts b/test/custom.test.ts index de3ff3a..b84fd35 100644 --- a/test/custom.test.ts +++ b/test/custom.test.ts @@ -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); + }); +});