diff --git a/src/client/components/CustomForms.tsx b/src/client/components/CustomForms.tsx index 16e4da4..3564264 100644 --- a/src/client/components/CustomForms.tsx +++ b/src/client/components/CustomForms.tsx @@ -5,6 +5,7 @@ import { type CustomGames, type LaneId, } from "../../shared/custom.ts"; +import { comesRoundEarly, RepeatUnit } from "../../shared/recurrence.ts"; import { EventType } from "../../shared/schema.ts"; import { useGameMeta } from "../state/gameMeta.tsx"; import { readerInstant, type EventDraft } from "../state/useCustom.ts"; @@ -172,6 +173,14 @@ export function EventForm({ const [endTime, setEndTime] = useState( initial?.endPrecision === "exact" ? end.time : "", ); + // "never" rather than a null unit, so the select has one vocabulary and the + // default reads as an answer the reader gave rather than a field they left. + const [repeatUnit, setRepeatUnit] = useState( + initial?.repeat?.unit ?? "never", + ); + const [repeatInterval, setRepeatInterval] = useState( + String(initial?.repeat?.interval ?? 1), + ); const startsAt = startDate === "" ? null : readerInstant(startDate, startTime, "start"); const endsAt = @@ -179,13 +188,34 @@ export function EventForm({ const endMissing = endKnown && endDate !== "" && endsAt === null; const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt; + + const interval = Number(repeatInterval); + const intervalValid = + Number.isInteger(interval) && interval >= 1 && interval <= 365; + const repeat = + repeatUnit === "never" || !intervalValid + ? null + : { unit: repeatUnit, interval, until: null }; + + // The same predicate the schema refines on, so the form cannot start + // refusing saves the schema would accept or promising ones it will reject. + const earlyReturn = + startsAt !== null && + comesRoundEarly( + Date.parse(startsAt), + endsAt === null ? null : Date.parse(endsAt), + repeat, + ); + const valid = title.trim().length > 0 && game !== "" && startsAt !== null && !backwards && !endMissing && - (!endKnown || endDate !== ""); + (!endKnown || endDate !== "") && + !earlyReturn && + (repeatUnit === "never" || intervalValid); return (
@@ -317,6 +345,43 @@ export function EventForm({ )} +
+ + {repeatUnit !== "never" && ( + + )} +
+ + {earlyReturn && ( +

+ That comes round before it ends. +

+ )} + - {!endKnown && ( + {!endKnown && repeatUnit === "never" && (

It'll show with no countdown and no daily checklist, the same as an event whose source hasn't announced an end.

)} + {!endKnown && repeatUnit !== "never" && ( + /* Not a degraded answer here — the interval bounds it. */ +

+ Each one runs until the next one opens, so it still counts down. +

+ )} {backwards && (

That ends before it starts. diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx index af3af8c..c03c365 100644 --- a/test/custom-ui.test.tsx +++ b/test/custom-ui.test.tsx @@ -342,3 +342,76 @@ describe("Colophon freshness notice (PRD F7)", () => { expect(html).not.toMatch(/support me|buy me|donate|tip jar/i); }); }); + +describe("stating a repeat", () => { + const repeating = (over: Record = {}) => + CustomEvent.parse({ + id: "myevent:k3f9qa2m01", + game: "mygame:limbus-company", + title: "Abyss", + type: "challenge", + summary: null, + startsAt: "2026-09-01T00:00:00.000Z", + startPrecision: "day", + endsAt: "2026-09-08T00:00:00.000Z", + endPrecision: "day", + repeat: { unit: "weeks", interval: 2, until: null }, + at: AT, + updatedAt: AT, + ...over, + }); + + test("a fresh form offers a repeat, set to never", () => { + const html = renderToStaticMarkup( + {}} onCancel={() => {}} />, + ); + expect(html).toContain("Repeats"); + // The interval field is hidden until there is something to count, so the + // form a reader already knows is unchanged until they reach for this. + expect(html).not.toContain("Every"); + }); + + test("editing a rule shows the rule it already has", () => { + const html = renderToStaticMarkup( + {}} + onCancel={() => {}} + />, + ); + expect(html).toContain("Every"); + expect(html).toContain('value="2"'); + }); + + test("an unknown end with a rule stops claiming there is no countdown", () => { + // "It'll show with no countdown and no daily checklist" is true of an + // unbounded event and false once an interval bounds it. Leaving it there + // would talk a reader out of the simplest way to record a weekly reset. + const html = renderToStaticMarkup( + {}} + onCancel={() => {}} + />, + ); + expect(html).toContain("until the next one opens"); + expect(html).not.toContain("no countdown"); + }); + + test("an unknown end with no rule keeps the original note", () => { + const html = renderToStaticMarkup( + {}} + onCancel={() => {}} + />, + ); + expect(html).toContain("no countdown"); + }); +});