From 50121602db2c7c3d63cb708fe373e9e59d183cd3 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 27 Aug 2026 02:35:03 +0200 Subject: [PATCH] Derive the occurrences a rule stands for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An occurrence with no stated end runs until the next one opens. That is the boundary a bare rotation was missing — docs/SOURCES.md declines to publish arustats' Abyss openings precisely because nothing bounded them — and here it is entailed by the interval the reader typed rather than invented for them. The store still holds endsAt: null; only this projection resolves it. nextOccurrences returns what has not finished rather than what is running, so a rule between cycles answers "opens Saturday" instead of vanishing for its whole off week. The "ancient anchor" test's expected occurrences now include 31 August: with a six-year-old anchor at 09:00 and a query window opening at midnight, that day's occurrence (no stated end, so it runs until 1 September 09:00 opens the next) genuinely overlaps the window's first nine hours — the same edge rule the sibling "overlapping at either edge" test exists to prove. The brief's original expected list omitted it. Co-Authored-By: Claude Opus 5 (1M context) --- src/shared/recurrence.ts | 121 ++++++++++++++++++++++++++ test/recurrence.test.ts | 183 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 303 insertions(+), 1 deletion(-) diff --git a/src/shared/recurrence.ts b/src/shared/recurrence.ts index 70d9a9c..b703990 100644 --- a/src/shared/recurrence.ts +++ b/src/shared/recurrence.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { Precision } from "./schema.ts"; /** * Events that come round again (PRD F13). @@ -179,3 +180,123 @@ export function movesOccurrences( before.repeat?.interval !== after.repeat?.interval ); } + +/** + * The fields a rule is expanded from — everything else is irrelevant. + * + * Structural rather than `CustomEvent` on purpose, and not only for the usual + * reason: `custom.ts` imports this module, so importing it back would be + * circular. It also means the ingest side can adopt this without a second + * implementation when `GachaEvent` grows the same field (see the spec's + * Phase B). + */ +export interface RepeatingEvent { + id: string; + startsAt: string; + startPrecision: Precision; + endsAt: string | null; + endPrecision: Precision; + repeat: Repeat | null; +} + +/** One time round. Both boundaries are resolved; neither is ever null. */ +export interface Occurrence { + id: string; + /** How many times round this is, counting the anchor as 0. */ + index: number; + startsAt: string; + startPrecision: Precision; + endsAt: string; + endPrecision: Precision; +} + +/** + * How far the walk will seek before giving up looking for the window. + * + * Occurrences are walked from the anchor rather than jumped to arithmetically, + * because month stepping clamps and so has no closed form to jump with. Fifty + * years of a daily rule is a fraction of a millisecond and the result is + * memoised, so the simple walk is worth more than the arithmetic would save. + */ +const MAX_SEEK = 20_000; + +/** + * Every occurrence overlapping `[fromMs, toMs]`, oldest first. + * + * A non-repeating event yields nothing: callers keep their existing + * single-event path, so nothing about an event that already exists changes. + * + * **An occurrence with no stated end runs until the next one opens.** That is + * the boundary a bare rotation was missing — `docs/SOURCES.md` § arustats + * declines to publish one precisely because nothing bounded it — and it is + * derived from the interval the reader typed rather than invented for them. The + * store still holds `endsAt: null`; only this projection resolves it. + */ +export function occurrencesOf( + event: RepeatingEvent, + fromMs: number, + toMs: number, + cap: number = MAX_OCCURRENCES, +): Occurrence[] { + const repeat = event.repeat; + if (repeat === null) return []; + + const anchor = Date.parse(event.startsAt); + if (Number.isNaN(anchor)) return []; + + // Held constant and slid forward, rather than recomputed per occurrence: the + // reader stated one window's length, not a rule for deriving lengths. + const stated = event.endsAt === null ? null : Date.parse(event.endsAt) - anchor; + const untilMs = repeat.until === null ? Infinity : Date.parse(repeat.until); + + const out: Occurrence[] = []; + for (let n = 0; n < MAX_SEEK && out.length < cap; n += 1) { + const startsMs = addUnits(anchor, repeat.unit, n * repeat.interval); + if (startsMs > untilMs || startsMs > toMs) break; + + // Always defined, even for the last occurrence of a terminating series: the + // window still closes on schedule, it simply is not followed by another. + const nextOpening = addUnits(startsMs, repeat.unit, repeat.interval); + const endsMs = stated === null ? nextOpening : startsMs + stated; + + // Overlapping the window at either edge counts — a bar half off the left of + // the board is still on the board. + if (endsMs >= fromMs) { + out.push({ + id: occurrenceId(event.id, startsMs), + index: n, + startsAt: new Date(startsMs).toISOString(), + startPrecision: event.startPrecision, + endsAt: new Date(endsMs).toISOString(), + // A derived end is exactly as well known as the anchor it was derived + // from; a stated one keeps the precision the reader stated it to. + endPrecision: stated === null ? event.startPrecision : event.endPrecision, + }); + } + } + return out; +} + +/** + * The next `count` occurrences that have not finished, oldest first. + * + * "Not finished" rather than "running", so a rule between cycles answers + * "opens Saturday" instead of answering nothing — a gap in a rotation is not + * the rotation being over, and the lists would otherwise lose the rule for the + * whole of its off week. + */ +export function nextOccurrences( + event: RepeatingEvent, + nowMs: number, + count: number, +): Occurrence[] { + if (event.repeat === null || count <= 0) return []; + // Bounded rather than open-ended: `count` occurrences can never span more + // than `count` intervals past now, whatever the unit. + const horizon = addUnits( + nowMs, + event.repeat.unit, + event.repeat.interval * (count + 1), + ); + return occurrencesOf(event, nowMs, horizon, count); +} diff --git a/test/recurrence.test.ts b/test/recurrence.test.ts index ededd5c..be9e249 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, ruleIdOf, movesOccurrences } from "../src/shared/recurrence.ts"; +import { addUnits, comesRoundEarly, Repeat, isOccurrenceId, occurrenceId, ruleIdOf, movesOccurrences, nextOccurrences, occurrencesOf, 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 @@ -185,3 +185,184 @@ describe("movesOccurrences", () => { expect(movesOccurrences(a, rule("2026-09-01T07:00:00.000Z", 2))).toBe(false); }); }); + +describe("occurrencesOf", () => { + function rule(over: Partial = {}): RepeatingEvent { + return { + id: "myevent:k3f9qa2m01", + startsAt: new Date("2026-09-01T09:00:00").toISOString(), + startPrecision: "exact", + endsAt: new Date("2026-09-08T09:00:00").toISOString(), + endPrecision: "exact", + repeat: { unit: "weeks", interval: 2, until: null }, + ...over, + }; + } + + const day = (local: string) => new Date(local).getTime(); + + test("a non-repeating event yields nothing", () => { + // Callers keep the existing single-event path; this function is only ever + // about rules, which keeps the blast radius off events that already exist. + expect(occurrencesOf(rule({ repeat: null }), day("2026-01-01T00:00:00"), day("2027-01-01T00:00:00"))).toEqual([]); + }); + + test("slides the stated window forward by the interval", () => { + const got = occurrencesOf(rule(), day("2026-09-01T00:00:00"), day("2026-10-01T00:00:00")); + expect(got.map((o) => o.id)).toEqual([ + "myevent:k3f9qa2m01#2026-09-01", + "myevent:k3f9qa2m01#2026-09-15", + "myevent:k3f9qa2m01#2026-09-29", + ]); + // The duration is held constant, not recomputed. + expect(new Date(got[1]!.endsAt).getTime() - new Date(got[1]!.startsAt).getTime()) + .toBe(7 * 24 * 60 * 60 * 1000); + }); + + test("with no stated end, each occurrence runs until the next opens", () => { + // The point of the whole design: a rule supplies the boundary the rotation + // was missing, so `endsAt: null` here is not the unbounded case + // docs/SOURCES.md refuses. Occurrences are contiguous, with no gap. + const got = occurrencesOf( + rule({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 1, until: null } }), + day("2026-09-01T00:00:00"), + day("2026-09-23T00:00:00"), + ); + expect(got).toHaveLength(4); + expect(got[0]!.endsAt).toBe(got[1]!.startsAt); + expect(got[1]!.endsAt).toBe(got[2]!.startsAt); + // Derived from the reader's own anchor, so it inherits that precision + // rather than claiming to be exact when their start was only a day. + expect(got[0]!.endPrecision).toBe("exact"); + }); + + test("a derived end inherits the anchor's start precision", () => { + const got = occurrencesOf( + rule({ startPrecision: "day", endsAt: null, endPrecision: "unknown" }), + day("2026-09-01T00:00:00"), + day("2026-09-20T00:00:00"), + ); + expect(got[0]!.endPrecision).toBe("day"); + }); + + test("until stops the series, and the last window still closes on schedule", () => { + const got = occurrencesOf( + rule({ + endsAt: null, + endPrecision: "unknown", + repeat: { unit: "weeks", interval: 1, until: new Date("2026-09-16T00:00:00").toISOString() }, + }), + day("2026-09-01T00:00:00"), + day("2026-12-01T00:00:00"), + ); + // Opens 1, 8, 15 Sep. The 22nd is past `until`, so it never opens — but the + // 15th's window still runs its full week rather than being truncated. + expect(got.map((o) => o.id)).toEqual([ + "myevent:k3f9qa2m01#2026-09-01", + "myevent:k3f9qa2m01#2026-09-08", + "myevent:k3f9qa2m01#2026-09-15", + ]); + expect(got[2]!.endsAt).toBe(new Date("2026-09-22T09:00:00").toISOString()); + }); + + test("an occurrence overlapping the window at either edge is included", () => { + // A bar half off the left of the board is still on the board. + const got = occurrencesOf(rule(), day("2026-09-03T00:00:00"), day("2026-09-04T00:00:00")); + expect(got.map((o) => o.id)).toEqual(["myevent:k3f9qa2m01#2026-09-01"]); + }); + + test("monthly rules clamp and do not accumulate", () => { + const got = occurrencesOf( + rule({ + startsAt: new Date("2026-01-31T09:00:00").toISOString(), + endsAt: null, + endPrecision: "unknown", + repeat: { unit: "months", interval: 1, until: null }, + }), + day("2026-01-01T00:00:00"), + day("2026-04-15T00:00:00"), + ); + expect(got.map((o) => o.id)).toEqual([ + "myevent:k3f9qa2m01#2026-01-31", + "myevent:k3f9qa2m01#2026-02-28", + "myevent:k3f9qa2m01#2026-03-31", + ]); + }); + + test("the cap bounds what one call can allocate", () => { + const got = occurrencesOf( + rule({ endsAt: null, endPrecision: "unknown", repeat: { unit: "days", interval: 1, until: null } }), + day("2026-01-01T00:00:00"), + day("2030-01-01T00:00:00"), + 10, + ); + expect(got).toHaveLength(10); + }); + + test("an ancient anchor still reaches a window years later", () => { + const got = occurrencesOf( + rule({ + startsAt: new Date("2020-09-01T09:00:00").toISOString(), + endsAt: null, + endPrecision: "unknown", + repeat: { unit: "days", interval: 1, until: null }, + }), + day("2026-09-01T00:00:00"), + day("2026-09-04T00:00:00"), + ); + // The anchor's 09:00 wall clock does not line up with the window's + // midnight boundary, so 31 August's occurrence — which, having no stated + // end, runs until 1 September 09:00 opens the next one — overlaps the + // window's first nine hours. Same edge rule as the test above, just + // reached from six years back instead of a few days. + expect(got.map((o) => o.id)).toEqual([ + "myevent:k3f9qa2m01#2026-08-31", + "myevent:k3f9qa2m01#2026-09-01", + "myevent:k3f9qa2m01#2026-09-02", + "myevent:k3f9qa2m01#2026-09-03", + ]); + }); +}); + +describe("nextOccurrences", () => { + function rule(over: Partial = {}): RepeatingEvent { + return { + id: "myevent:k3f9qa2m01", + startsAt: new Date("2026-09-01T09:00:00").toISOString(), + startPrecision: "exact", + endsAt: new Date("2026-09-08T09:00:00").toISOString(), + endPrecision: "exact", + repeat: { unit: "weeks", interval: 2, until: null }, + ...over, + }; + } + + test("returns the running occurrence and the one after it", () => { + const now = new Date("2026-09-03T12:00:00").getTime(); + const got = nextOccurrences(rule(), now, 2); + expect(got.map((o) => o.id)).toEqual([ + "myevent:k3f9qa2m01#2026-09-01", + "myevent:k3f9qa2m01#2026-09-15", + ]); + }); + + test("between cycles it returns the next to open, plus the one after", () => { + // A rule with a gap has nothing running on 10 September. "Opens Saturday" + // is the honest answer; showing nothing would read as the rule being over. + const now = new Date("2026-09-10T12:00:00").getTime(); + const got = nextOccurrences(rule(), now, 2); + expect(got.map((o) => o.id)).toEqual([ + "myevent:k3f9qa2m01#2026-09-15", + "myevent:k3f9qa2m01#2026-09-29", + ]); + }); + + test("a series past its until yields nothing", () => { + const got = nextOccurrences( + rule({ repeat: { unit: "weeks", interval: 2, until: new Date("2026-09-02T00:00:00").toISOString() } }), + new Date("2027-01-01T00:00:00").getTime(), + 2, + ); + expect(got).toEqual([]); + }); +});