From c161c4a0dc522d7bff9ad5a5507e759cecf53a32 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 27 Aug 2026 15:24:34 +0200 Subject: [PATCH] Let the form state a repeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaults to never, so the form a reader already knows is unchanged until they reach for this. The unknown-end note had to change with it. "It'll show with no countdown and no daily checklist" is true of an unbounded event and false once an interval bounds it — and leaving it there would talk a reader out of the simplest way to record a weekly reset. With a rule set it says each one runs until the next one opens. Refusal reuses comesRoundEarly rather than restating it, so the form cannot drift from the schema it has to agree with. Co-Authored-By: Claude Opus 5 (1M context) --- src/client/components/CustomForms.tsx | 81 +++++++++++++++++++++++++-- test/custom-ui.test.tsx | 73 ++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 5 deletions(-) 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"); + }); +});