Ask how it repeats, not how often

Three answers — never, forever, or after a delay — because "how often" is
rarely the question a reader has. They know it comes back the moment it
ends, or that it comes back after a wait. The cadence follows from that and
from dates they have already typed, so the form reports what it measured
instead of asking for a number, and says it out loud rather than filling a
field silently.

A delay is expressed by where it lands: the next opening is the wait added
to the close, so a delay cannot produce a rule that comes round before it
ends. Only a hand-stated cadence can, and stating one by hand stays
available — measuring is the convenience, not a cage.

Both readings are shown for a delay, since a week's wait after a week's
window is a fortnightly rule and seeing that spelled out is how a wrong
number gets caught.

contiguousOpening is the load-bearing detail. An end given as a date is
stored as 23:59:59, so a successor opening "the moment it closes" opens at
the following midnight, a second later. Comparing against the stored end
instead read every day-precision rule as having a gap it does not have —
and the form has to measure the instants it will save, not the ones the
record happens to hold, or it opens describing a rule it would not write.

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 2c301d466d
commit 1f1d608e95
3 changed files with 362 additions and 45 deletions
+76 -6
View File
@@ -368,17 +368,58 @@ describe("stating a repeat", () => {
...over,
});
test("a fresh form offers a repeat, set to never", () => {
// The fixture runs 1-8 Sep inclusive with no times given, so it closes at
// 23:59:59 on the 8th and a successor opening "the moment it closes" opens
// at midnight on the 9th — eight days after the anchor, not seven. Its rule
// is every two weeks, which leaves a gap, so `repeating()` is the delay
// case; `forever()` narrows the interval to exactly that eight-day step.
const forever = () => repeating({ repeat: { unit: "days", interval: 8, until: null } });
test("a fresh form offers the three answers, 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).toContain("Repeat");
expect(html).toContain("with a delay");
// Nothing to configure until they pick one, so the form a reader already
// knows is unchanged until they reach for this.
expect(html).not.toContain("Every");
expect(html).not.toContain("Wait");
});
test("editing a rule shows the rule it already has", () => {
test("a rule that reopens as it closes shows its cadence rather than a control", () => {
// The whole point of "forever": the dates already say how often, so the
// form reports what it measured instead of asking for a number.
const html = renderToStaticMarkup(
<EventForm
lanes={["mygame:limbus-company"]}
customGames={GAMES}
initial={forever()}
onSave={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain("every 8 days");
expect(html).toContain("from your dates");
expect(html).not.toContain("Wait");
});
test("a measured cadence can still be overridden by hand", () => {
// Measuring is the convenience, not a cage — an irregular rotation has to
// be sayable even when the first window does not describe it.
const html = renderToStaticMarkup(
<EventForm
lanes={["mygame:limbus-company"]}
customGames={GAMES}
initial={forever()}
onSave={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain("state it myself");
});
test("a rule with a gap opens as a delay, showing the gap", () => {
const html = renderToStaticMarkup(
<EventForm
lanes={["mygame:limbus-company"]}
@@ -388,8 +429,37 @@ describe("stating a repeat", () => {
onCancel={() => {}}
/>,
);
expect(html).toContain("Wait");
expect(html).toContain("after it ends");
// A week's gap after a week's window is a fortnightly rule; both readings
// are shown so the reader can check the one against the other.
expect(html).toContain("every 2 weeks");
});
test("with no end date there is nothing to measure, so it asks", () => {
const html = renderToStaticMarkup(
<EventForm
lanes={["mygame:limbus-company"]}
customGames={GAMES}
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 1, until: null } })}
onSave={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain("Every");
expect(html).toContain('value="2"');
});
test("a delay needs an end date to be measured from", () => {
const html = renderToStaticMarkup(
<EventForm
lanes={["mygame:limbus-company"]}
customGames={GAMES}
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 1, until: null } })}
onSave={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain("needs an end date");
});
test("an unknown end with a rule stops claiming there is no countdown", () => {