From 1535c2d920dee05ce19cb2a0254d066eca7d909e Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 27 Aug 2026 02:27:24 +0200 Subject: [PATCH] Let a reader's event carry a repeat rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .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) --- src/shared/custom.ts | 32 ++++++++++++++++++- test/custom.test.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) 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); + }); +});