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
+264 -26
View File
@@ -6,10 +6,14 @@ import {
type LaneId,
} from "../../shared/custom.ts";
import {
addUnits,
comesRoundEarly,
movesOccurrences,
repeatModeOf,
repeatSpanning,
RepeatUnit,
type Repeat,
type RepeatMode,
} from "../../shared/recurrence.ts";
import { EventType } from "../../shared/schema.ts";
import { useGameMeta } from "../state/gameMeta.tsx";
@@ -95,6 +99,99 @@ export function repeatFrom(
return { unit, interval, until: existingUntil };
}
/**
* The instant a successor opens if it opens the moment this window closes.
*
* `readerInstant` resolves an end the reader gave no time to as 23:59:59 —
* the last second of the day they named, because that is what "runs until the
* 8th" means to a person. The next window therefore opens at midnight, one
* second later, not on that final second. An end they *did* give a time to is
* an instant they chose, and a successor opens on it exactly.
*
* One second is not a fudge factor: it is the exact distance between this
* form's end-of-day convention and the midnight that follows it. The
* convention lives here rather than in `recurrence.ts` because this form is
* what wrote the boundary in the first place.
*/
function contiguousOpening(endsMs: number, endHasTime: boolean): number {
return endHasTime ? endsMs : endsMs + 1000;
}
/**
* What to put in the number-and-unit pair when the form opens.
*
* Both the delay control and the hand-stated cadence share one pair, because
* only one of them is ever on screen and carrying the number across when the
* reader changes their mind is kinder than resetting it to 1.
*
* For a rule that already has a gap this recovers the gap itself — the stored
* interval spans the window *and* the wait, and the reader entered the wait —
* so reopening shows them the number they typed rather than the one derived
* from it. A rule with no gap has none to recover and falls back to its own
* cadence, which is the sensible starting point if they switch to a delay.
*/
function openingControls(
startsAt: string | null,
endsAt: string | null,
endHasTime: boolean,
rule: Repeat | null,
): { unit: RepeatUnit; amount: number } {
if (rule === null || startsAt === null) return { unit: "weeks", amount: 1 };
const contiguousMs =
endsAt === null ? null : contiguousOpening(Date.parse(endsAt), endHasTime);
const gap =
contiguousMs === null
? null
: repeatSpanning(
contiguousMs,
addUnits(Date.parse(startsAt), rule.unit, rule.interval),
);
return gap === null
? { unit: rule.unit, amount: rule.interval }
: { unit: gap.unit, amount: gap.interval };
}
/**
* The rule the three-way control currently describes.
*
* Pulled out of the component because it is the one place the three answers
* become the single `{unit, interval}` the schema stores, and that translation
* is worth reading in one piece rather than spread through the render.
*
* A delay is expressed by where it lands: the next opening is the wait added
* to the close, and the interval is whatever spans the anchor to there. That
* is why a delay can never produce a rule that comes round before it ends —
* the next opening is at or after the close by construction. Only a
* hand-stated cadence can, which is why `comesRoundEarly` still guards the
* form.
*/
function repeatOf(input: {
mode: RepeatMode;
measuring: boolean;
measured: Repeat | null;
startMs: number | null;
contiguousMs: number | null;
unit: RepeatUnit;
amount: number;
until: string | null;
}): Repeat | null {
const { mode, measuring, measured, startMs, contiguousMs, unit, amount, until } =
input;
if (mode === "never") return null;
if (mode === "forever") {
return measuring && measured !== null
? repeatFrom(measured.unit, measured.interval, until)
: repeatFrom(unit, amount, until);
}
if (startMs === null || contiguousMs === null) return null;
if (!Number.isInteger(amount) || amount < 1 || amount > 365) return null;
const span = repeatSpanning(startMs, addUnits(contiguousMs, unit, amount));
return span === null ? null : repeatFrom(span.unit, span.interval, until);
}
function labelClass(): string {
return "block text-xs font-medium text-muted";
}
@@ -211,6 +308,24 @@ export function EventForm({
const start = fields(initial?.startsAt ?? null);
const end = fields(initial?.endsAt ?? null);
// Round-tripped through the same fields-then-readerInstant path the live
// form uses, rather than read straight off the record. The two disagree: a
// day-precision end is stored as whatever instant it was written at, and the
// form re-resolves it to the end of the reader's own day. Deriving the
// opening state from the stored value and everything after it from the
// re-resolved one is how the control opens saying "with a delay" about a
// rule that has no gap at all.
const initialStartTime = initial?.startPrecision === "exact" ? start.time : "";
const initialEndTime = initial?.endPrecision === "exact" ? end.time : "";
const initialStartsAt =
start.date === ""
? null
: (readerInstant(start.date, initialStartTime, "start") ?? null);
const initialEndsAt =
initial?.endsAt == null || end.date === ""
? null
: (readerInstant(end.date, initialEndTime, "end") ?? null);
// The reader's own games first, and so the default too. Someone filling this
// in by hand is usually doing it *because* the game isn't tracked; making
// them scroll past nine that are gets the common case backwards. Stable
@@ -227,23 +342,36 @@ export function EventForm({
const [summary, setSummary] = useState(initial?.summary ?? "");
const [startDate, setStartDate] = useState(start.date);
const [startTime, setStartTime] = useState(
initial?.startPrecision === "exact" ? start.time : "",
initialStartTime,
);
// Separate from an empty end date so "I don't know" is a thing the reader
// states, not a field they leave blank and hope about.
const [endKnown, setEndKnown] = useState(initial ? initial.endsAt !== null : true);
const [endDate, setEndDate] = useState(end.date);
const [endTime, setEndTime] = useState(
initial?.endPrecision === "exact" ? end.time : "",
initialEndTime,
);
// "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),
// Three answers rather than a unit and a number, because "how often" is
// rarely the question a reader actually 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.
const [repeatMode, setRepeatMode] = useState<RepeatMode>(() =>
initial === undefined
? "never"
: repeatModeOf(
Date.parse(initialStartsAt ?? initial.startsAt),
initialEndsAt === null
? null
: contiguousOpening(Date.parse(initialEndsAt), initialEndTime !== ""),
initial.repeat,
),
);
// Measuring is the convenience, not a cage: an irregular rotation still has
// to be sayable when the first window does not describe it.
const [ownCadence, setOwnCadence] = useState(false);
const opening = openingControls(initialStartsAt, initialEndsAt, initialEndTime !== "", initial?.repeat ?? null);
const [cadenceUnit, setCadenceUnit] = useState<RepeatUnit>(opening.unit);
const [cadenceAmount, setCadenceAmount] = useState(String(opening.amount));
const startsAt = startDate === "" ? null : readerInstant(startDate, startTime, "start");
const endsAt =
@@ -252,10 +380,44 @@ 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 = repeatFrom(repeatUnit, interval, initial?.repeat?.until ?? null);
const startMs = startsAt === null ? null : Date.parse(startsAt);
const endMs = endsAt === null ? null : Date.parse(endsAt);
const amount = Number(cadenceAmount);
const amountValid = Number.isInteger(amount) && amount >= 1 && amount <= 365;
const existingUntil = initial?.repeat?.until ?? null;
// What the dates already say, when they say anything. Null covers both "no
// end given yet" and a window that is no whole number of any unit — two
// exact times a few hours apart — where rounding would move a boundary the
// reader chose, so the form asks instead.
const contiguousMs =
endMs === null ? null : contiguousOpening(endMs, endTime !== "");
const measured =
startMs === null || contiguousMs === null
? null
: repeatSpanning(startMs, contiguousMs);
const measuring = repeatMode === "forever" && !ownCadence && measured !== null;
// A delay is measured from the end, so it has nothing to work with until one
// is given. Forever still does: the reader states the cadence and each
// occurrence runs until the next opens.
const delayNeedsEnd = repeatMode === "delay" && endMs === null;
const repeat = repeatOf({
mode: repeatMode,
measuring,
measured,
startMs,
contiguousMs,
unit: cadenceUnit,
amount,
until: existingUntil,
});
const repeatIncomplete =
(repeatMode === "forever" && !measuring && !amountValid) ||
(repeatMode === "delay" && (delayNeedsEnd || repeat === 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.
@@ -275,7 +437,7 @@ export function EventForm({
!endMissing &&
(!endKnown || endDate !== "") &&
!earlyReturn &&
(repeatUnit === "never" || intervalValid);
!repeatIncomplete;
const draft: EventDraft | null =
startsAt === null
@@ -414,15 +576,64 @@ export function EventForm({
</div>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
<label className={labelClass()}>
Repeats
<label className={`${labelClass()} mt-3`}>
Repeat
<select
value={repeatUnit}
onChange={(e) => setRepeatUnit(e.target.value as RepeatUnit | "never")}
value={repeatMode}
onChange={(e) => setRepeatMode(e.target.value as RepeatMode)}
className={inputClass()}
>
<option value="never">never</option>
<option value="forever">forever</option>
<option value="delay" disabled={endMs === null}>
with a delay
</option>
</select>
</label>
{repeatMode !== "never" && endMs === null && (
<p className="mt-1.5 text-xs leading-relaxed text-faint">
A delay needs an end date to be measured from. With none, each one
just runs until the next opens.
</p>
)}
{/* Measured, and said out loud — a cadence the form worked out silently
would be a date the reader never agreed to, which is the one thing
this product does not do. */}
{measuring && measured !== null && (
<p className="mt-2 text-xs leading-relaxed text-faint">
{cadenceLabel(measured)} · worked out from your dates.{" "}
<button
type="button"
onClick={() => setOwnCadence(true)}
className="underline transition-colors hover:text-ink"
>
state it myself
</button>
</p>
)}
{repeatMode === "forever" && !measuring && (
<div className="mt-2 grid grid-cols-2 gap-2">
<label className={labelClass()}>
Every
<input
type="number"
min={1}
max={365}
value={cadenceAmount}
onChange={(e) => setCadenceAmount(e.target.value)}
className={inputClass()}
/>
</label>
<label className={labelClass()}>
<span className="invisible">Unit</span>
<select
value={cadenceUnit}
onChange={(e) => setCadenceUnit(e.target.value as RepeatUnit)}
className={inputClass()}
>
{RepeatUnit.options.map((u) => (
<option key={u} value={u}>
{u}
@@ -430,20 +641,47 @@ export function EventForm({
))}
</select>
</label>
{repeatUnit !== "never" && (
</div>
)}
{repeatMode === "delay" && (
<>
<div className="mt-2 grid grid-cols-2 gap-2">
<label className={labelClass()}>
Every
Wait
<input
type="number"
min={1}
max={365}
value={repeatInterval}
onChange={(e) => setRepeatInterval(e.target.value)}
value={cadenceAmount}
onChange={(e) => setCadenceAmount(e.target.value)}
className={inputClass()}
/>
</label>
)}
<label className={labelClass()}>
<span className="invisible">Unit</span>
<select
value={cadenceUnit}
onChange={(e) => setCadenceUnit(e.target.value as RepeatUnit)}
className={inputClass()}
>
{RepeatUnit.options.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</label>
</div>
{/* Both readings, so the reader can check one against the other: a
week's wait after a week's window is a fortnightly rule, and
seeing that spelled out is how they catch a wrong number. */}
<p className="mt-1.5 text-xs leading-relaxed text-faint">
after it ends
{repeat !== null ? ` · ${cadenceLabel(repeat)} in all` : ""}
</p>
</>
)}
{earlyReturn && (
<p className="mt-2 text-xs text-critical">
@@ -462,13 +700,13 @@ export function EventForm({
/>
</label>
{!endKnown && repeatUnit === "never" && (
{!endKnown && repeatMode === "never" && (
<p className="mt-2 text-xs leading-relaxed text-faint">
It'll show with no countdown and no daily checklist, the same as an
event whose source hasn't announced an end.
</p>
)}
{!endKnown && repeatUnit !== "never" && (
{!endKnown && repeatMode !== "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.
+12 -3
View File
@@ -161,18 +161,27 @@ export type RepeatMode = "never" | "forever" | "delay";
* state describes it, rather than in whichever state happens to be the
* default.
*
* **`contiguousMs` is the instant a successor would open if it opened the
* moment this one closed — which is not always the stored end.** A boundary
* the reader gave a time to is an instant, and a successor opens on it. One
* they gave only a date to is stored as the last second of that day, so its
* successor opens a second later, at the following midnight. Passing the
* stored end for both would read every day-precision rule as having a gap it
* does not have. The caller owns that convention because the caller is what
* wrote the boundary.
*
* An unstated end is `forever`: the reader gave a cadence and no end, so each
* occurrence runs until the next opens. There is no gap to describe, and a
* delay would have nothing to be measured from.
*/
export function repeatModeOf(
startsMs: number,
endsMs: number | null,
contiguousMs: number | null,
repeat: Repeat | null,
): RepeatMode {
if (repeat === null) return "never";
if (endsMs === null) return "forever";
return addUnits(startsMs, repeat.unit, repeat.interval) === endsMs
if (contiguousMs === null) return "forever";
return addUnits(startsMs, repeat.unit, repeat.interval) === contiguousMs
? "forever"
: "delay";
}
+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", () => {