Measure a cadence instead of asking for one

The repeat control is becoming three answers — never, forever, or after a
delay — and in the last two the form can work the cadence out from dates
the reader has already given. Both reduce to one question: which unit and
interval steps from the anchor to the instant the next occurrence should
open. Forever passes the instant this one closes; a delay pushes it out.

Searched with addUnits rather than divided out of a millisecond span,
because a month is not a fixed number of days and a week across a DST
transition is 167 or 169 hours. Largest honest unit wins, so 1 July to
1 August is "every month" rather than "every 31 days", which would drift
out of step by February.

repeatModeOf derives which state a saved rule is in rather than storing it,
since "forever" and "a delay of zero" are the same rule and remembering
which button produced it would change nothing about a single occurrence.

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 d731c3a367
commit 2c301d466d
2 changed files with 172 additions and 1 deletions
+75
View File
@@ -102,6 +102,81 @@ export function comesRoundEarly(
return endsMs > addUnits(startsMs, repeat.unit, repeat.interval);
}
/**
* The rule whose next opening lands exactly on `toMs`, or null if none does.
*
* The form asks the reader to choose between never repeating, repeating
* forever, and repeating after a delay — and in the last two it measures the
* cadence rather than asking for it. Both reduce to this one question: which
* `{unit, interval}` steps from the anchor to the instant the next occurrence
* should open? Forever passes the instant this one closes; a delay passes that
* instant pushed further out.
*
* **Searched with `addUnits` rather than divided out of a millisecond span.**
* A month is not a fixed number of days and a week is not always 168 hours —
* across a DST transition it is 167 or 169 — so arithmetic on the raw span
* would miss the exact answers this is looking for. Stepping the calendar and
* comparing is the only reading that agrees with how the occurrences are
* actually generated.
*
* Largest honest unit wins, which is why the ladder runs months before weeks
* before days: a reader who typed 1 July to 1 August meant the month, and
* storing "every 31 days" would drift out of step by February. The bounds are
* the schema's own ceiling — nothing here can return an interval `Repeat`
* would reject.
*
* Null is a real answer, not a failure: two exact times a few hours apart span
* no whole number of any unit, and rounding to the nearest day would move a
* boundary the reader chose. The form asks them instead of guessing.
*/
export function repeatSpanning(fromMs: number, toMs: number): Repeat | null {
if (toMs <= fromMs) return null;
const ladder: Array<{ unit: RepeatUnit; max: number }> = [
{ unit: "months", max: 12 },
{ unit: "weeks", max: 52 },
{ unit: "days", max: 365 },
];
for (const { unit, max } of ladder) {
for (let interval = 1; interval <= max; interval += 1) {
if (addUnits(fromMs, unit, interval) === toMs) {
return { unit, interval, until: null };
}
}
}
return null;
}
/** The three answers the form offers for "does this come round again?". */
export type RepeatMode = "never" | "forever" | "delay";
/**
* Which of the three states a saved rule belongs to.
*
* Derived rather than stored beside the rule, because "repeats forever" and "a
* delay of zero" are the same rule — remembering which control produced it
* would be remembering something that makes no difference to a single
* occurrence. It also means a rule that arrived by import opens in whichever
* state describes it, rather than in whichever state happens to be the
* default.
*
* 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,
repeat: Repeat | null,
): RepeatMode {
if (repeat === null) return "never";
if (endsMs === null) return "forever";
return addUnits(startsMs, repeat.unit, repeat.interval) === endsMs
? "forever"
: "delay";
}
/**
* What separates a rule from one of its occurrences.
*