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); 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. * What separates a rule from one of its occurrences.
* *
+97 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { addUnits, comesRoundEarly, Repeat, isOccurrenceId, occurrenceId, occurrenceForId, ruleIdOf, movesOccurrences, nextOccurrences, occurrencesOf, strandedOccurrences, type RepeatingEvent } from "../src/shared/recurrence.ts"; import { addUnits, comesRoundEarly, Repeat, repeatSpanning, repeatModeOf, isOccurrenceId, occurrenceId, occurrenceForId, ruleIdOf, movesOccurrences, nextOccurrences, occurrencesOf, strandedOccurrences, type RepeatingEvent } from "../src/shared/recurrence.ts";
import { CustomEventId, isCustomEventId } from "../src/shared/custom.ts"; import { CustomEventId, isCustomEventId } from "../src/shared/custom.ts";
// Pinned so the DST cases mean something. Copenhagen is UTC+1 in winter and // Pinned so the DST cases mean something. Copenhagen is UTC+1 in winter and
@@ -455,3 +455,99 @@ describe("nextOccurrences", () => {
expect(got).toEqual([]); expect(got).toEqual([]);
}); });
}); });
describe("repeatSpanning", () => {
// The three-state repeat control ("never / forever / with a delay") never
// asks the reader for a cadence when it can measure one. Forever asks for
// the next opening to land exactly where this occurrence closes; a delay
// pushes that target later. Both reduce to the same question, which is what
// this function answers: which {unit, interval} steps from the anchor to
// that target exactly.
test("a window that is a calendar month reads as every month", () => {
// Not "every 31 days" — a fixed day count drifts out of step by February,
// and the reader who typed 1 Jul to 1 Aug meant the month.
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2026-08-01T09:00:00")))
.toEqual({ unit: "months", interval: 1, until: null });
});
test("whole weeks read as weeks, not days", () => {
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2026-08-12T09:00:00")))
.toEqual({ unit: "weeks", interval: 6, until: null });
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2026-07-15T09:00:00")))
.toEqual({ unit: "weeks", interval: 2, until: null });
});
test("anything else falls back to days", () => {
// The walkthrough case: 1 Jul to 26 Jul is 25 days, which is neither a
// month nor whole weeks, so it stays in the unit the reader can verify.
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2026-07-26T09:00:00")))
.toEqual({ unit: "days", interval: 25, until: null });
});
test("a delayed target is the same question asked later", () => {
// A 7-day window with a 21-day delay: the next opening is 28 days after
// the anchor, which is four whole weeks.
const anchor = at("2026-07-01T09:00:00");
const closes = at("2026-07-08T09:00:00");
const delayed = addUnits(closes, "days", 21);
expect(repeatSpanning(anchor, delayed))
.toEqual({ unit: "weeks", interval: 4, until: null });
});
test("a span crossing a DST transition is still exact", () => {
// Spring forward is 29 March 2026. Measured in milliseconds this span is
// an hour short of four weeks and would fall through to no answer at all.
expect(repeatSpanning(at("2026-03-15T09:00:00"), at("2026-04-12T09:00:00")))
.toEqual({ unit: "weeks", interval: 4, until: null });
});
test("a target at or before the anchor has no answer", () => {
const anchor = at("2026-07-01T09:00:00");
expect(repeatSpanning(anchor, anchor)).toBe(null);
expect(repeatSpanning(anchor, at("2026-06-30T09:00:00"))).toBe(null);
});
test("a span that is not whole days has no answer", () => {
// Two exact times a few hours apart. Rounding to a day would move the
// reader's boundary, so the form asks them instead of guessing.
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2026-07-08T14:30:00"))).toBe(null);
});
test("a span past the schema's ceiling has no answer", () => {
// `interval` is capped at 365, so a span no unit can express within that
// returns null rather than a rule the schema would reject.
expect(repeatSpanning(at("2026-07-01T09:00:00"), at("2030-07-01T09:00:00"))).toBe(null);
});
});
describe("repeatModeOf", () => {
// Which of the three states the form should open in. Deliberately derived
// from the rule rather than stored beside it: "forever" and "a delay of
// zero" are the same rule, so remembering which button was pressed would be
// remembering something that makes no difference to the reader's schedule.
const start = at("2026-07-01T09:00:00");
const close = at("2026-07-08T09:00:00");
test("no rule is never", () => {
expect(repeatModeOf(start, close, null)).toBe("never");
});
test("a window that reopens exactly as it closes is forever", () => {
expect(repeatModeOf(start, close, { unit: "weeks", interval: 1, until: null }))
.toBe("forever");
});
test("a gap between closing and reopening is a delay", () => {
expect(repeatModeOf(start, close, { unit: "weeks", interval: 4, until: null }))
.toBe("delay");
});
test("an unstated end is forever, because there is no gap to describe", () => {
// "Weekly missions, resets Monday": the reader gave a cadence and no end,
// so each occurrence runs until the next opens. Contiguous by definition,
// and a delay would have nothing to be measured from.
expect(repeatModeOf(start, null, { unit: "weeks", interval: 1, until: null }))
.toBe("forever");
});
});