From 2c301d466d801f75d020dafb404542c9abbcd5b2 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 27 Aug 2026 16:38:46 +0200 Subject: [PATCH] Measure a cadence instead of asking for one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/shared/recurrence.ts | 75 ++++++++++++++++++++++++++++++ test/recurrence.test.ts | 98 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/shared/recurrence.ts b/src/shared/recurrence.ts index ccf9c81..5f2f776 100644 --- a/src/shared/recurrence.ts +++ b/src/shared/recurrence.ts @@ -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. * diff --git a/test/recurrence.test.ts b/test/recurrence.test.ts index 2a7fadd..ee79e11 100644 --- a/test/recurrence.test.ts +++ b/test/recurrence.test.ts @@ -1,5 +1,5 @@ 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"; // Pinned so the DST cases mean something. Copenhagen is UTC+1 in winter and @@ -455,3 +455,99 @@ describe("nextOccurrences", () => { 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"); + }); +});