Let the form state a repeat

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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-28 05:04:39 +02:00
co-authored by Claude Opus 5
parent 60041134ee
commit c161c4a0dc
2 changed files with 149 additions and 5 deletions
+76 -5
View File
@@ -5,6 +5,7 @@ import {
type CustomGames, type CustomGames,
type LaneId, type LaneId,
} from "../../shared/custom.ts"; } from "../../shared/custom.ts";
import { comesRoundEarly, RepeatUnit } from "../../shared/recurrence.ts";
import { EventType } from "../../shared/schema.ts"; import { EventType } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx"; import { useGameMeta } from "../state/gameMeta.tsx";
import { readerInstant, type EventDraft } from "../state/useCustom.ts"; import { readerInstant, type EventDraft } from "../state/useCustom.ts";
@@ -172,6 +173,14 @@ export function EventForm({
const [endTime, setEndTime] = useState( const [endTime, setEndTime] = useState(
initial?.endPrecision === "exact" ? end.time : "", 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<RepeatUnit | "never">(
initial?.repeat?.unit ?? "never",
);
const [repeatInterval, setRepeatInterval] = useState(
String(initial?.repeat?.interval ?? 1),
);
const startsAt = startDate === "" ? null : readerInstant(startDate, startTime, "start"); const startsAt = startDate === "" ? null : readerInstant(startDate, startTime, "start");
const endsAt = const endsAt =
@@ -179,13 +188,34 @@ export function EventForm({
const endMissing = endKnown && endDate !== "" && endsAt === null; const endMissing = endKnown && endDate !== "" && endsAt === null;
const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt; 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 = const valid =
title.trim().length > 0 && title.trim().length > 0 &&
game !== "" && game !== "" &&
startsAt !== null && startsAt !== null &&
!backwards && !backwards &&
!endMissing && !endMissing &&
(!endKnown || endDate !== ""); (!endKnown || endDate !== "") &&
!earlyReturn &&
(repeatUnit === "never" || intervalValid);
return ( return (
<form <form
@@ -202,9 +232,7 @@ export function EventForm({
startHasTime: startTime !== "", startHasTime: startTime !== "",
endsAt, endsAt,
endHasTime: endTime !== "", endHasTime: endTime !== "",
// This form has no repeat control yet — every event it saves is repeat,
// still a single occurrence, exactly as before.
repeat: null,
}); });
}} }}
> >
@@ -317,6 +345,43 @@ export function EventForm({
</div> </div>
)} )}
<div className="mt-3 grid grid-cols-2 gap-2">
<label className={labelClass()}>
Repeats
<select
value={repeatUnit}
onChange={(e) => setRepeatUnit(e.target.value as RepeatUnit | "never")}
className={inputClass()}
>
<option value="never">never</option>
{RepeatUnit.options.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</label>
{repeatUnit !== "never" && (
<label className={labelClass()}>
Every
<input
type="number"
min={1}
max={365}
value={repeatInterval}
onChange={(e) => setRepeatInterval(e.target.value)}
className={inputClass()}
/>
</label>
)}
</div>
{earlyReturn && (
<p className="mt-2 text-xs text-critical">
That comes round before it ends.
</p>
)}
<label className={`${labelClass()} mt-3`}> <label className={`${labelClass()} mt-3`}>
Note (optional) Note (optional)
<input <input
@@ -328,12 +393,18 @@ export function EventForm({
/> />
</label> </label>
{!endKnown && ( {!endKnown && repeatUnit === "never" && (
<p className="mt-2 text-xs leading-relaxed text-faint"> <p className="mt-2 text-xs leading-relaxed text-faint">
It'll show with no countdown and no daily checklist, the same as an It'll show with no countdown and no daily checklist, the same as an
event whose source hasn't announced an end. event whose source hasn't announced an end.
</p> </p>
)} )}
{!endKnown && repeatUnit !== "never" && (
/* Not a degraded answer here — the interval bounds it. */
<p className="mt-2 text-xs leading-relaxed text-faint">
Each one runs until the next one opens, so it still counts down.
</p>
)}
{backwards && ( {backwards && (
<p className="mt-2 text-xs text-critical"> <p className="mt-2 text-xs text-critical">
That ends before it starts. That ends before it starts.
+73
View File
@@ -342,3 +342,76 @@ describe("Colophon freshness notice (PRD F7)", () => {
expect(html).not.toMatch(/support me|buy me|donate|tip jar/i); expect(html).not.toMatch(/support me|buy me|donate|tip jar/i);
}); });
}); });
describe("stating a repeat", () => {
const repeating = (over: Record<string, unknown> = {}) =>
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(
<EventForm lanes={["mygame:limbus-company"]} customGames={GAMES} onSave={() => {}} 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(
<EventForm
lanes={["mygame:limbus-company"]}
customGames={GAMES}
initial={repeating()}
onSave={() => {}}
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(
<EventForm
lanes={["mygame:limbus-company"]}
customGames={GAMES}
initial={repeating({ endsAt: null, endPrecision: "unknown" })}
onSave={() => {}}
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(
<EventForm
lanes={["mygame:limbus-company"]}
customGames={GAMES}
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: null })}
onSave={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain("no countdown");
});
});