Derive the occurrences a rule stands for
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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ee43270b2b
commit
50121602db
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { Precision } from "./schema.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Events that come round again (PRD F13).
|
* Events that come round again (PRD F13).
|
||||||
@@ -179,3 +180,123 @@ export function movesOccurrences(
|
|||||||
before.repeat?.interval !== after.repeat?.interval
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+182
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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";
|
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
|
||||||
@@ -185,3 +185,184 @@ describe("movesOccurrences", () => {
|
|||||||
expect(movesOccurrences(a, rule("2026-09-01T07:00:00.000Z", 2))).toBe(false);
|
expect(movesOccurrences(a, rule("2026-09-01T07:00:00.000Z", 2))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("occurrencesOf", () => {
|
||||||
|
function rule(over: Partial<RepeatingEvent> = {}): 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> = {}): 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user