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:
co-authored by
Claude Opus 5
parent
12dbbe3228
commit
1535c2d920
+31
-1
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { comesRoundEarly, Repeat } from "./recurrence.ts";
|
||||||
import { EventType, GachaEvent, Precision, slugify } from "./schema.ts";
|
import { EventType, GachaEvent, Precision, slugify } from "./schema.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +93,19 @@ export const CustomEvent = z
|
|||||||
endsAt: z.string().datetime().nullable(),
|
endsAt: z.string().datetime().nullable(),
|
||||||
endPrecision: Precision,
|
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(),
|
at: z.string().datetime(),
|
||||||
updatedAt: z.string().datetime(),
|
updatedAt: z.string().datetime(),
|
||||||
})
|
})
|
||||||
@@ -105,7 +119,23 @@ export const CustomEvent = z
|
|||||||
.refine((e) => e.endsAt === null || e.endsAt > e.startsAt, {
|
.refine((e) => e.endsAt === null || e.endsAt > e.startsAt, {
|
||||||
message: "endsAt must be after startsAt",
|
message: "endsAt must be after startsAt",
|
||||||
path: ["endsAt"],
|
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<typeof CustomEvent>;
|
export type CustomEvent = z.infer<typeof CustomEvent>;
|
||||||
|
|
||||||
export const CustomGames = z.record(z.string(), CustomGame);
|
export const CustomGames = z.record(z.string(), CustomGame);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "../src/shared/custom.ts";
|
} from "../src/shared/custom.ts";
|
||||||
import { metaFor } from "../src/shared/games.ts";
|
import { metaFor } from "../src/shared/games.ts";
|
||||||
import { dailiesId } from "../src/shared/daily.ts";
|
import { dailiesId } from "../src/shared/daily.ts";
|
||||||
|
import { Repeat } from "../src/shared/recurrence.ts";
|
||||||
import { eventId, GameId } from "../src/shared/schema.ts";
|
import { eventId, GameId } from "../src/shared/schema.ts";
|
||||||
import { clockFor } from "../src/shared/time.ts";
|
import { clockFor } from "../src/shared/time.ts";
|
||||||
import { readerInstant, validRecords } from "../src/client/state/useCustom.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();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user