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
+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";
}