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:
co-authored by
Claude Opus 5
parent
60041134ee
commit
c161c4a0dc
@@ -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.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user