@j13b/scheduler 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/alterations.d.ts +6 -0
- package/dist/alterations.d.ts.map +1 -0
- package/dist/alterations.js +61 -0
- package/dist/alterations.js.map +1 -0
- package/dist/cjs/alterations.d.ts +5 -0
- package/dist/cjs/alterations.js +96 -0
- package/dist/cjs/dispatcher.d.ts +38 -0
- package/dist/cjs/dispatcher.js +165 -0
- package/dist/cjs/dispatcher_store.d.ts +9 -0
- package/dist/cjs/dispatcher_store.js +15 -0
- package/dist/cjs/generators/anchored.d.ts +3 -0
- package/dist/cjs/generators/anchored.js +57 -0
- package/dist/cjs/generators/days_of_month.d.ts +3 -0
- package/dist/cjs/generators/days_of_month.js +76 -0
- package/dist/cjs/generators/days_of_week.d.ts +5 -0
- package/dist/cjs/generators/days_of_week.js +65 -0
- package/dist/cjs/generators/days_of_year.d.ts +3 -0
- package/dist/cjs/generators/days_of_year.js +70 -0
- package/dist/cjs/generators/instant.d.ts +3 -0
- package/dist/cjs/generators/instant.js +15 -0
- package/dist/cjs/generators/local_date_time.d.ts +3 -0
- package/dist/cjs/generators/local_date_time.js +31 -0
- package/dist/cjs/generators/months_of_year.d.ts +3 -0
- package/dist/cjs/generators/months_of_year.js +109 -0
- package/dist/cjs/generators/types.d.ts +9 -0
- package/dist/cjs/generators/types.js +19 -0
- package/dist/cjs/generators/weeks_of_month.d.ts +3 -0
- package/dist/cjs/generators/weeks_of_month.js +99 -0
- package/dist/cjs/generators/yearly.d.ts +3 -0
- package/dist/cjs/generators/yearly.js +52 -0
- package/dist/cjs/in_memory_port.d.ts +18 -0
- package/dist/cjs/in_memory_port.js +59 -0
- package/dist/cjs/index.d.ts +6 -0
- package/dist/cjs/index.js +14 -0
- package/dist/cjs/occurrence_key.d.ts +5 -0
- package/dist/cjs/occurrence_key.js +21 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/port.d.ts +15 -0
- package/dist/cjs/port.js +2 -0
- package/dist/cjs/scheduler.d.ts +24 -0
- package/dist/cjs/scheduler.js +460 -0
- package/dist/cjs/types.d.ts +149 -0
- package/dist/cjs/types.js +58 -0
- package/dist/cjs/tz.d.ts +22 -0
- package/dist/cjs/tz.js +197 -0
- package/dist/dispatcher.d.ts +39 -0
- package/dist/dispatcher.d.ts.map +1 -0
- package/dist/dispatcher.js +111 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/dispatcher_store.d.ts +10 -0
- package/dist/dispatcher_store.d.ts.map +1 -0
- package/dist/dispatcher_store.js +13 -0
- package/dist/dispatcher_store.js.map +1 -0
- package/dist/generators/anchored.d.ts +4 -0
- package/dist/generators/anchored.d.ts.map +1 -0
- package/dist/generators/anchored.js +42 -0
- package/dist/generators/anchored.js.map +1 -0
- package/dist/generators/days_of_month.d.ts +4 -0
- package/dist/generators/days_of_month.d.ts.map +1 -0
- package/dist/generators/days_of_month.js +53 -0
- package/dist/generators/days_of_month.js.map +1 -0
- package/dist/generators/days_of_week.d.ts +6 -0
- package/dist/generators/days_of_week.d.ts.map +1 -0
- package/dist/generators/days_of_week.js +43 -0
- package/dist/generators/days_of_week.js.map +1 -0
- package/dist/generators/days_of_year.d.ts +4 -0
- package/dist/generators/days_of_year.d.ts.map +1 -0
- package/dist/generators/days_of_year.js +50 -0
- package/dist/generators/days_of_year.js.map +1 -0
- package/dist/generators/instant.d.ts +4 -0
- package/dist/generators/instant.d.ts.map +1 -0
- package/dist/generators/instant.js +15 -0
- package/dist/generators/instant.js.map +1 -0
- package/dist/generators/local_date_time.d.ts +4 -0
- package/dist/generators/local_date_time.d.ts.map +1 -0
- package/dist/generators/local_date_time.js +20 -0
- package/dist/generators/local_date_time.js.map +1 -0
- package/dist/generators/months_of_year.d.ts +4 -0
- package/dist/generators/months_of_year.d.ts.map +1 -0
- package/dist/generators/months_of_year.js +64 -0
- package/dist/generators/months_of_year.js.map +1 -0
- package/dist/generators/types.d.ts +10 -0
- package/dist/generators/types.d.ts.map +1 -0
- package/dist/generators/types.js +14 -0
- package/dist/generators/types.js.map +1 -0
- package/dist/generators/weeks_of_month.d.ts +4 -0
- package/dist/generators/weeks_of_month.d.ts.map +1 -0
- package/dist/generators/weeks_of_month.js +61 -0
- package/dist/generators/weeks_of_month.js.map +1 -0
- package/dist/generators/yearly.d.ts +4 -0
- package/dist/generators/yearly.d.ts.map +1 -0
- package/dist/generators/yearly.js +37 -0
- package/dist/generators/yearly.js.map +1 -0
- package/dist/in_memory_port.d.ts +19 -0
- package/dist/in_memory_port.d.ts.map +1 -0
- package/dist/in_memory_port.js +45 -0
- package/dist/in_memory_port.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/occurrence_key.d.ts +6 -0
- package/dist/occurrence_key.d.ts.map +1 -0
- package/dist/occurrence_key.js +25 -0
- package/dist/occurrence_key.js.map +1 -0
- package/dist/port.d.ts +16 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/scheduler.d.ts +25 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +332 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/dist/tz.d.ts +23 -0
- package/dist/tz.d.ts.map +1 -0
- package/dist/tz.js +131 -0
- package/dist/tz.js.map +1 -0
- package/package.json +70 -4
- package/src/__tests__/alterations.test.ts +177 -0
- package/src/__tests__/anchored.test.ts +107 -0
- package/src/__tests__/days_of_month.test.ts +162 -0
- package/src/__tests__/days_of_week.test.ts +134 -0
- package/src/__tests__/days_of_year.test.ts +158 -0
- package/src/__tests__/dispatcher.test.ts +317 -0
- package/src/__tests__/instant.test.ts +50 -0
- package/src/__tests__/local_date_time.test.ts +139 -0
- package/src/__tests__/months_of_year.test.ts +229 -0
- package/src/__tests__/scheduler.test.ts +888 -0
- package/src/__tests__/tz.test.ts +201 -0
- package/src/__tests__/weeks_of_month.test.ts +195 -0
- package/src/__tests__/yearly.test.ts +108 -0
- package/src/alterations.ts +108 -0
- package/src/dispatcher.ts +184 -0
- package/src/dispatcher_store.ts +26 -0
- package/src/generators/anchored.ts +69 -0
- package/src/generators/days_of_month.ts +100 -0
- package/src/generators/days_of_week.ts +101 -0
- package/src/generators/days_of_year.ts +88 -0
- package/src/generators/instant.ts +18 -0
- package/src/generators/local_date_time.ts +38 -0
- package/src/generators/months_of_year.ts +143 -0
- package/src/generators/types.ts +26 -0
- package/src/generators/weeks_of_month.ts +133 -0
- package/src/generators/yearly.ts +73 -0
- package/src/in_memory_port.ts +70 -0
- package/src/index.ts +24 -0
- package/src/occurrence_key.ts +42 -0
- package/src/port.ts +31 -0
- package/src/scheduler.ts +499 -0
- package/src/types.ts +369 -0
- package/src/tz.ts +246 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Disambiguation, MonthsOfYearRule } from "../types.js";
|
|
2
|
+
import {
|
|
3
|
+
addMonthsInTz,
|
|
4
|
+
computeSpanEnd,
|
|
5
|
+
isNonexistentWallTime,
|
|
6
|
+
monthLength,
|
|
7
|
+
utcToWallParts,
|
|
8
|
+
wallTimeToUtc,
|
|
9
|
+
} from "../tz.js";
|
|
10
|
+
import {
|
|
11
|
+
MAX_OCCURRENCES_PER_GENERATE,
|
|
12
|
+
type Occurrence,
|
|
13
|
+
TooManyOccurrencesError,
|
|
14
|
+
intersects,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
// Positional yearly recurrence — top of the positional cascade.
|
|
18
|
+
// (months × weeks-of-month × weekdays, with yearInterval as cadence.)
|
|
19
|
+
//
|
|
20
|
+
// For each year in cadence, walk the selected months; in each month, find
|
|
21
|
+
// the Nth weekday matches (e.g. "2nd Tuesday") for each (week, day) pair.
|
|
22
|
+
// If a position doesn't exist that month (e.g. 5th Sunday in a 4-Sunday
|
|
23
|
+
// month), silently skip — same policy as WeeksOfMonthRule.
|
|
24
|
+
export function generateMonthsOfYear(
|
|
25
|
+
rule: MonthsOfYearRule,
|
|
26
|
+
tz: string | undefined,
|
|
27
|
+
windowStart: number,
|
|
28
|
+
windowEnd: number,
|
|
29
|
+
disambiguation: Disambiguation = "compatible"
|
|
30
|
+
): Occurrence[] {
|
|
31
|
+
if (rule.yearInterval <= 0) {
|
|
32
|
+
throw new Error(`MonthsOfYearRule.yearInterval must be > 0 (got ${rule.yearInterval})`);
|
|
33
|
+
}
|
|
34
|
+
if (rule.months.length === 0) {
|
|
35
|
+
throw new Error("MonthsOfYearRule.months must be non-empty");
|
|
36
|
+
}
|
|
37
|
+
for (const m of rule.months) {
|
|
38
|
+
if (m < 0 || m > 11) {
|
|
39
|
+
throw new Error(`MonthsOfYearRule.months entries must be in 0..11 (got ${m})`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (rule.weeks.length === 0) {
|
|
43
|
+
throw new Error("MonthsOfYearRule.weeks must be non-empty");
|
|
44
|
+
}
|
|
45
|
+
for (const w of rule.weeks) {
|
|
46
|
+
if (w === 0 || w < -5 || w > 5) {
|
|
47
|
+
throw new Error(`MonthsOfYearRule.weeks entries must be in [-5..-1] or [1..5] (got ${w})`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (rule.days.length === 0) {
|
|
51
|
+
throw new Error("MonthsOfYearRule.days must be non-empty");
|
|
52
|
+
}
|
|
53
|
+
if (rule.count != null && rule.count < 0) {
|
|
54
|
+
throw new Error(`MonthsOfYearRule.count must be >= 0 (got ${rule.count})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const seriesEnd = rule.seriesEnd ?? Number.POSITIVE_INFINITY;
|
|
58
|
+
const effectiveEnd = Math.min(windowEnd, seriesEnd);
|
|
59
|
+
if (rule.seriesStart >= effectiveEnd) return [];
|
|
60
|
+
|
|
61
|
+
const months = [...new Set(rule.months)].sort((a, b) => a - b);
|
|
62
|
+
const weeks = [...new Set(rule.weeks)];
|
|
63
|
+
const weekdays = [...new Set(rule.days)].sort((a, b) => a - b);
|
|
64
|
+
|
|
65
|
+
// Anchor at the start of seriesStart's year (in tz), but actually anchor
|
|
66
|
+
// at its first selected month within that year. We iterate year-by-year.
|
|
67
|
+
const seriesStartParts = utcToWallParts(rule.seriesStart, tz);
|
|
68
|
+
let yearAnchor = wallTimeToUtc(seriesStartParts.year, 0, 1, 0, tz);
|
|
69
|
+
|
|
70
|
+
const out: Occurrence[] = [];
|
|
71
|
+
let count = 0;
|
|
72
|
+
|
|
73
|
+
while (yearAnchor < effectiveEnd) {
|
|
74
|
+
const yp = utcToWallParts(yearAnchor, tz);
|
|
75
|
+
const yearEmits: Occurrence[] = [];
|
|
76
|
+
|
|
77
|
+
for (const month of months) {
|
|
78
|
+
for (const week of weeks) {
|
|
79
|
+
for (const dow of weekdays) {
|
|
80
|
+
const targetDay = _nthWeekdayOfMonth(yp.year, month, dow, week);
|
|
81
|
+
if (targetDay == null) continue; // Nth doesn't exist this month
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
disambiguation === "skip" &&
|
|
85
|
+
isNonexistentWallTime(yp.year, month, targetDay, rule.timeOfDay, tz)
|
|
86
|
+
) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const occStart = wallTimeToUtc(
|
|
90
|
+
yp.year,
|
|
91
|
+
month,
|
|
92
|
+
targetDay,
|
|
93
|
+
rule.timeOfDay,
|
|
94
|
+
tz,
|
|
95
|
+
disambiguation
|
|
96
|
+
);
|
|
97
|
+
const occEnd = computeSpanEnd(occStart, rule.timeSpan, tz);
|
|
98
|
+
|
|
99
|
+
if (occStart < rule.seriesStart) continue;
|
|
100
|
+
if (occStart >= seriesEnd) continue;
|
|
101
|
+
|
|
102
|
+
if (rule.count != null && count >= rule.count) {
|
|
103
|
+
yearEmits.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
104
|
+
out.push(...yearEmits);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
count++;
|
|
108
|
+
|
|
109
|
+
if (intersects(occStart, occEnd, windowStart, windowEnd)) {
|
|
110
|
+
yearEmits.push({ startTimestamp: occStart, endTimestamp: occEnd });
|
|
111
|
+
if (out.length + yearEmits.length > MAX_OCCURRENCES_PER_GENERATE) {
|
|
112
|
+
throw new TooManyOccurrencesError("monthsOfYear");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
yearEmits.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
119
|
+
out.push(...yearEmits);
|
|
120
|
+
// Step yearInterval years. Use addMonthsInTz(12) so leap-year wall-clock
|
|
121
|
+
// arithmetic is correct (it isn't strictly necessary at year granularity,
|
|
122
|
+
// but matches the rest of the codebase).
|
|
123
|
+
yearAnchor = addMonthsInTz(yearAnchor, 12 * rule.yearInterval, tz);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Same helper as WeeksOfMonth uses. Returns day-of-month (1..31) for the
|
|
129
|
+
// Nth occurrence of `dow` in the given month, or null if it doesn't exist.
|
|
130
|
+
function _nthWeekdayOfMonth(year: number, month: number, dow: number, n: number): number | null {
|
|
131
|
+
const len = monthLength(year, month);
|
|
132
|
+
|
|
133
|
+
if (n > 0) {
|
|
134
|
+
const firstDow = new Date(Date.UTC(year, month, 1)).getUTCDay();
|
|
135
|
+
const offset = (dow - firstDow + 7) % 7;
|
|
136
|
+
const day = 1 + offset + (n - 1) * 7;
|
|
137
|
+
return day <= len ? day : null;
|
|
138
|
+
}
|
|
139
|
+
const lastDow = new Date(Date.UTC(year, month, len)).getUTCDay();
|
|
140
|
+
const offset = (lastDow - dow + 7) % 7;
|
|
141
|
+
const day = len - offset + (n + 1) * 7;
|
|
142
|
+
return day >= 1 ? day : null;
|
|
143
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Generators emit raw start/end pairs sorted ascending by startTimestamp.
|
|
2
|
+
// Stable per-occurrence keys are assigned downstream (occurrence_key.ts) so
|
|
3
|
+
// keys can be derived consistently across rule kinds.
|
|
4
|
+
export interface Occurrence {
|
|
5
|
+
startTimestamp: number;
|
|
6
|
+
endTimestamp: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Hard ceiling per generate() call. A misconfigured rule (e.g. 1ms anchored
|
|
10
|
+
// interval over a year-long window) would otherwise produce billions of
|
|
11
|
+
// occurrences; we'd rather throw a clear error than blow heap.
|
|
12
|
+
export const MAX_OCCURRENCES_PER_GENERATE = 10_000;
|
|
13
|
+
|
|
14
|
+
export class TooManyOccurrencesError extends Error {
|
|
15
|
+
constructor(kind: string) {
|
|
16
|
+
super(
|
|
17
|
+
`Generator '${kind}' would emit more than ${MAX_OCCURRENCES_PER_GENERATE} occurrences for the requested window. Narrow the window, increase the interval, or set 'count' on the rule.`
|
|
18
|
+
);
|
|
19
|
+
this.name = "TooManyOccurrencesError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Half-open intersection: [startA, endA) overlaps [startB, endB)?
|
|
24
|
+
export function intersects(startA: number, endA: number, startB: number, endB: number): boolean {
|
|
25
|
+
return Math.max(startA, startB) < Math.min(endA, endB);
|
|
26
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { Disambiguation, WeeksOfMonthRule } from "../types.js";
|
|
2
|
+
import {
|
|
3
|
+
addMonthsInTz,
|
|
4
|
+
computeSpanEnd,
|
|
5
|
+
isNonexistentWallTime,
|
|
6
|
+
monthLength,
|
|
7
|
+
utcToWallParts,
|
|
8
|
+
wallTimeToUtc,
|
|
9
|
+
} from "../tz.js";
|
|
10
|
+
import {
|
|
11
|
+
MAX_OCCURRENCES_PER_GENERATE,
|
|
12
|
+
type Occurrence,
|
|
13
|
+
TooManyOccurrencesError,
|
|
14
|
+
intersects,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
|
|
17
|
+
// "Nth Tuesday of every month", "last Friday of every other month", etc.
|
|
18
|
+
// Iterate month-by-month at monthInterval cadence; for each repeatable day,
|
|
19
|
+
// resolve the target wall date.
|
|
20
|
+
export function generateWeeksOfMonth(
|
|
21
|
+
rule: WeeksOfMonthRule,
|
|
22
|
+
tz: string | undefined,
|
|
23
|
+
windowStart: number,
|
|
24
|
+
windowEnd: number,
|
|
25
|
+
disambiguation: Disambiguation = "compatible"
|
|
26
|
+
): Occurrence[] {
|
|
27
|
+
if (rule.monthInterval <= 0) {
|
|
28
|
+
throw new Error(`WeeksOfMonthRule.monthInterval must be > 0 (got ${rule.monthInterval})`);
|
|
29
|
+
}
|
|
30
|
+
if (rule.weeks.length === 0) {
|
|
31
|
+
throw new Error("WeeksOfMonthRule.weeks must be non-empty");
|
|
32
|
+
}
|
|
33
|
+
for (const w of rule.weeks) {
|
|
34
|
+
if (w === 0 || w < -5 || w > 5) {
|
|
35
|
+
throw new Error(`WeeksOfMonthRule.weeks entries must be in [-5..-1] or [1..5] (got ${w})`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (rule.days.length === 0) {
|
|
39
|
+
throw new Error("WeeksOfMonthRule.days must be non-empty");
|
|
40
|
+
}
|
|
41
|
+
if (rule.count != null && rule.count < 0) {
|
|
42
|
+
throw new Error(`WeeksOfMonthRule.count must be >= 0 (got ${rule.count})`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const seriesEnd = rule.seriesEnd ?? Number.POSITIVE_INFINITY;
|
|
46
|
+
const effectiveEnd = Math.min(windowEnd, seriesEnd);
|
|
47
|
+
if (rule.seriesStart >= effectiveEnd) return [];
|
|
48
|
+
|
|
49
|
+
const days = [...rule.days].sort((a, b) => a - b);
|
|
50
|
+
// Dedup `weeks` so a duplicated entry doesn't double-emit. Don't sort —
|
|
51
|
+
// we sort the per-month emits at the end of each month iteration anyway.
|
|
52
|
+
const weeks = [...new Set(rule.weeks)];
|
|
53
|
+
|
|
54
|
+
// Anchor at seriesStart's month (in tz).
|
|
55
|
+
const seriesStartParts = utcToWallParts(rule.seriesStart, tz);
|
|
56
|
+
let monthAnchor = wallTimeToUtc(seriesStartParts.year, seriesStartParts.month, 1, 0, tz);
|
|
57
|
+
|
|
58
|
+
const out: Occurrence[] = [];
|
|
59
|
+
let count = 0;
|
|
60
|
+
|
|
61
|
+
while (monthAnchor < effectiveEnd) {
|
|
62
|
+
const mp = utcToWallParts(monthAnchor, tz);
|
|
63
|
+
const monthEmits: Occurrence[] = [];
|
|
64
|
+
|
|
65
|
+
for (const week of weeks) {
|
|
66
|
+
for (const dow of days) {
|
|
67
|
+
const targetDay = _nthWeekdayOfMonth(mp.year, mp.month, dow, week);
|
|
68
|
+
if (targetDay == null) continue; // e.g. 5th Friday in a 4-Friday month
|
|
69
|
+
|
|
70
|
+
if (
|
|
71
|
+
disambiguation === "skip" &&
|
|
72
|
+
isNonexistentWallTime(mp.year, mp.month, targetDay, rule.timeOfDay, tz)
|
|
73
|
+
) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const occStart = wallTimeToUtc(
|
|
77
|
+
mp.year,
|
|
78
|
+
mp.month,
|
|
79
|
+
targetDay,
|
|
80
|
+
rule.timeOfDay,
|
|
81
|
+
tz,
|
|
82
|
+
disambiguation
|
|
83
|
+
);
|
|
84
|
+
const occEnd = computeSpanEnd(occStart, rule.timeSpan, tz);
|
|
85
|
+
|
|
86
|
+
if (occStart < rule.seriesStart) continue;
|
|
87
|
+
if (occStart >= seriesEnd) continue;
|
|
88
|
+
|
|
89
|
+
if (rule.count != null && count >= rule.count) {
|
|
90
|
+
// Sort and emit what we collected this month before returning.
|
|
91
|
+
monthEmits.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
92
|
+
out.push(...monthEmits);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
count++;
|
|
96
|
+
|
|
97
|
+
if (intersects(occStart, occEnd, windowStart, windowEnd)) {
|
|
98
|
+
monthEmits.push({ startTimestamp: occStart, endTimestamp: occEnd });
|
|
99
|
+
if (out.length + monthEmits.length > MAX_OCCURRENCES_PER_GENERATE) {
|
|
100
|
+
throw new TooManyOccurrencesError("weeksOfMonth");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Sort within-month emits before appending. With multiple weeks (and
|
|
106
|
+
// negative weeks mixed with positive), per-week iteration order doesn't
|
|
107
|
+
// produce ascending startTimestamps.
|
|
108
|
+
monthEmits.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
109
|
+
out.push(...monthEmits);
|
|
110
|
+
monthAnchor = addMonthsInTz(monthAnchor, rule.monthInterval, tz);
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Returns the day-of-month (1..31) for the Nth weekday of the given month,
|
|
116
|
+
// or null if it doesn't exist (e.g. asking for the 5th Tuesday of a 4-Tuesday
|
|
117
|
+
// month). Negative N counts from the end: -1 = last, -2 = second-to-last.
|
|
118
|
+
function _nthWeekdayOfMonth(year: number, month: number, dow: number, n: number): number | null {
|
|
119
|
+
const len = monthLength(year, month);
|
|
120
|
+
|
|
121
|
+
if (n > 0) {
|
|
122
|
+
// Find the first occurrence of `dow` in the month, then step by 7s.
|
|
123
|
+
const firstDow = new Date(Date.UTC(year, month, 1)).getUTCDay();
|
|
124
|
+
const offset = (dow - firstDow + 7) % 7;
|
|
125
|
+
const day = 1 + offset + (n - 1) * 7;
|
|
126
|
+
return day <= len ? day : null;
|
|
127
|
+
}
|
|
128
|
+
// n < 0: count from end of month.
|
|
129
|
+
const lastDow = new Date(Date.UTC(year, month, len)).getUTCDay();
|
|
130
|
+
const offset = (lastDow - dow + 7) % 7;
|
|
131
|
+
const day = len - offset + (n + 1) * 7;
|
|
132
|
+
return day >= 1 ? day : null;
|
|
133
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Disambiguation, YearlyRule } from "../types.js";
|
|
2
|
+
import {
|
|
3
|
+
computeSpanEnd,
|
|
4
|
+
isNonexistentWallTime,
|
|
5
|
+
monthLength,
|
|
6
|
+
utcToWallParts,
|
|
7
|
+
wallTimeToUtc,
|
|
8
|
+
} from "../tz.js";
|
|
9
|
+
import {
|
|
10
|
+
MAX_OCCURRENCES_PER_GENERATE,
|
|
11
|
+
type Occurrence,
|
|
12
|
+
TooManyOccurrencesError,
|
|
13
|
+
intersects,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
// Simple yearly recurrence: a single month + day. Day clamps to month
|
|
17
|
+
// length (Feb 29 → Feb 28 in non-leap years, Apr 31 → Apr 30, etc.).
|
|
18
|
+
export function generateYearly(
|
|
19
|
+
rule: YearlyRule,
|
|
20
|
+
tz: string | undefined,
|
|
21
|
+
windowStart: number,
|
|
22
|
+
windowEnd: number,
|
|
23
|
+
disambiguation: Disambiguation = "compatible"
|
|
24
|
+
): Occurrence[] {
|
|
25
|
+
if (rule.yearInterval <= 0) {
|
|
26
|
+
throw new Error(`YearlyRule.yearInterval must be > 0 (got ${rule.yearInterval})`);
|
|
27
|
+
}
|
|
28
|
+
if (rule.month < 0 || rule.month > 11) {
|
|
29
|
+
throw new Error(`YearlyRule.month must be in 0..11 (got ${rule.month})`);
|
|
30
|
+
}
|
|
31
|
+
if (rule.day < 1 || rule.day > 31) {
|
|
32
|
+
throw new Error(`YearlyRule.day must be in 1..31 (got ${rule.day})`);
|
|
33
|
+
}
|
|
34
|
+
if (rule.count != null && rule.count < 0) {
|
|
35
|
+
throw new Error(`YearlyRule.count must be >= 0 (got ${rule.count})`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const seriesEnd = rule.seriesEnd ?? Number.POSITIVE_INFINITY;
|
|
39
|
+
const effectiveEnd = Math.min(windowEnd, seriesEnd);
|
|
40
|
+
if (rule.seriesStart >= effectiveEnd) return [];
|
|
41
|
+
|
|
42
|
+
const startYear = utcToWallParts(rule.seriesStart, tz).year;
|
|
43
|
+
const endYear = utcToWallParts(Math.min(effectiveEnd, Number.MAX_SAFE_INTEGER), tz).year;
|
|
44
|
+
|
|
45
|
+
const out: Occurrence[] = [];
|
|
46
|
+
let count = 0;
|
|
47
|
+
|
|
48
|
+
for (let year = startYear; year <= endYear; year += rule.yearInterval) {
|
|
49
|
+
const day = Math.min(rule.day, monthLength(year, rule.month));
|
|
50
|
+
if (
|
|
51
|
+
disambiguation === "skip" &&
|
|
52
|
+
isNonexistentWallTime(year, rule.month, day, rule.timeOfDay, tz)
|
|
53
|
+
) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const occStart = wallTimeToUtc(year, rule.month, day, rule.timeOfDay, tz, disambiguation);
|
|
57
|
+
const occEnd = computeSpanEnd(occStart, rule.timeSpan, tz);
|
|
58
|
+
|
|
59
|
+
if (occStart < rule.seriesStart) continue;
|
|
60
|
+
if (occStart >= seriesEnd) continue;
|
|
61
|
+
|
|
62
|
+
if (rule.count != null && count >= rule.count) break;
|
|
63
|
+
count++;
|
|
64
|
+
|
|
65
|
+
if (intersects(occStart, occEnd, windowStart, windowEnd)) {
|
|
66
|
+
out.push({ startTimestamp: occStart, endTimestamp: occEnd });
|
|
67
|
+
if (out.length > MAX_OCCURRENCES_PER_GENERATE) {
|
|
68
|
+
throw new TooManyOccurrencesError("yearly");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SchedulerPort } from "./port.js";
|
|
2
|
+
import type { Alteration, ScheduleTemplate } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// Reference port. Stores templates and alterations in plain Maps. Returns
|
|
5
|
+
// deep-cloned copies so callers can't mutate internal state.
|
|
6
|
+
//
|
|
7
|
+
// Range queries return everything and let the Scheduler filter — correct,
|
|
8
|
+
// but O(n) per query. A real backend would index on seriesStart/seriesEnd.
|
|
9
|
+
export class InMemorySchedulerPort<TPayload extends { type: string }>
|
|
10
|
+
implements SchedulerPort<TPayload>
|
|
11
|
+
{
|
|
12
|
+
private _templates = new Map<string, ScheduleTemplate<TPayload>>();
|
|
13
|
+
private _alterations = new Map<string, Alteration<TPayload>>();
|
|
14
|
+
|
|
15
|
+
async listTemplates(_start: number, _end: number): Promise<ScheduleTemplate<TPayload>[]> {
|
|
16
|
+
return [...this._templates.values()].map(clone);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async getTemplate(id: string): Promise<ScheduleTemplate<TPayload> | null> {
|
|
20
|
+
const t = this._templates.get(id);
|
|
21
|
+
return t == null ? null : clone(t);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async addTemplate(template: ScheduleTemplate<TPayload>): Promise<void> {
|
|
25
|
+
if (this._templates.has(template.id)) {
|
|
26
|
+
throw new Error(`Template already exists: ${template.id}`);
|
|
27
|
+
}
|
|
28
|
+
this._templates.set(template.id, clone(template));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async updateTemplate(template: ScheduleTemplate<TPayload>): Promise<void> {
|
|
32
|
+
if (!this._templates.has(template.id)) {
|
|
33
|
+
throw new Error(`No template with id ${template.id}`);
|
|
34
|
+
}
|
|
35
|
+
this._templates.set(template.id, clone(template));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async removeTemplate(id: string): Promise<void> {
|
|
39
|
+
this._templates.delete(id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async listAlterations(_start: number, _end: number): Promise<Alteration<TPayload>[]> {
|
|
43
|
+
return [...this._alterations.values()].map(clone);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async listAlterationsByTemplate(templateId: string): Promise<Alteration<TPayload>[]> {
|
|
47
|
+
return [...this._alterations.values()].filter((a) => a.templateId === templateId).map(clone);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async addAlteration(alteration: Alteration<TPayload>): Promise<void> {
|
|
51
|
+
if (this._alterations.has(alteration.id)) {
|
|
52
|
+
throw new Error(`Alteration already exists: ${alteration.id}`);
|
|
53
|
+
}
|
|
54
|
+
this._alterations.set(alteration.id, clone(alteration));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async removeAlteration(id: string): Promise<void> {
|
|
58
|
+
this._alterations.delete(id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async removeAlterationsForTemplate(templateId: string): Promise<void> {
|
|
62
|
+
for (const [id, a] of this._alterations) {
|
|
63
|
+
if (a.templateId === templateId) this._alterations.delete(id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clone<T>(value: T): T {
|
|
69
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
70
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { Scheduler } from "./scheduler.js";
|
|
2
|
+
export { InMemorySchedulerPort } from "./in_memory_port.js";
|
|
3
|
+
export type { SchedulerPort } from "./port.js";
|
|
4
|
+
export { Dispatcher, type DispatcherConfig } from "./dispatcher.js";
|
|
5
|
+
export { type DispatcherStore, InMemoryDispatcherStore } from "./dispatcher_store.js";
|
|
6
|
+
export {
|
|
7
|
+
Day,
|
|
8
|
+
Month,
|
|
9
|
+
type RecurrenceRule,
|
|
10
|
+
type InstantRule,
|
|
11
|
+
type LocalDateTimeRule,
|
|
12
|
+
type AnchoredRule,
|
|
13
|
+
type DaysOfWeekRule,
|
|
14
|
+
type WeeksOfMonthRule,
|
|
15
|
+
type MonthsOfYearRule,
|
|
16
|
+
type YearlyRule,
|
|
17
|
+
type DaysOfMonthRule,
|
|
18
|
+
type DaysOfYearRule,
|
|
19
|
+
type ScheduleTemplate,
|
|
20
|
+
type Alteration,
|
|
21
|
+
type ScheduledEvent,
|
|
22
|
+
type TimeSpan,
|
|
23
|
+
type Disambiguation,
|
|
24
|
+
} from "./types.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Occurrence } from "./generators/types.js";
|
|
2
|
+
import { utcToWallParts } from "./tz.js";
|
|
3
|
+
|
|
4
|
+
// A key identifies a specific occurrence within a template's series. It must
|
|
5
|
+
// be:
|
|
6
|
+
// - Stable across small rule edits (e.g. tweaking timeOfDay)
|
|
7
|
+
// - Unique within a single generate() call's output for one template
|
|
8
|
+
//
|
|
9
|
+
// Format: `YYYY-MM-DD` (in the template's tz), with `#N` suffix when multiple
|
|
10
|
+
// occurrences land on the same calendar day. Index is 0-based by emission
|
|
11
|
+
// order within that day.
|
|
12
|
+
//
|
|
13
|
+
// Trade-off: changing timeOfDay across the date boundary moves an event to
|
|
14
|
+
// a different day, which orphans alterations on it. We accept that as the
|
|
15
|
+
// cost of human-readable, edit-tolerant keys. Same goes for timezone changes.
|
|
16
|
+
|
|
17
|
+
export interface KeyedOccurrence extends Occurrence {
|
|
18
|
+
occurrenceKey: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assignOccurrenceKeys(
|
|
22
|
+
occurrences: readonly Occurrence[],
|
|
23
|
+
tz: string | undefined
|
|
24
|
+
): KeyedOccurrence[] {
|
|
25
|
+
const counts = new Map<string, number>();
|
|
26
|
+
return occurrences.map((occ) => {
|
|
27
|
+
const w = utcToWallParts(occ.startTimestamp, tz);
|
|
28
|
+
const date = `${_pad4(w.year)}-${_pad2(w.month + 1)}-${_pad2(w.day)}`;
|
|
29
|
+
const idx = counts.get(date) ?? 0;
|
|
30
|
+
counts.set(date, idx + 1);
|
|
31
|
+
const occurrenceKey = idx === 0 ? date : `${date}#${idx}`;
|
|
32
|
+
return { ...occ, occurrenceKey };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _pad2(n: number): string {
|
|
37
|
+
return n < 10 ? `0${n}` : String(n);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _pad4(n: number): string {
|
|
41
|
+
return n < 10 ? `000${n}` : n < 100 ? `00${n}` : n < 1000 ? `0${n}` : String(n);
|
|
42
|
+
}
|
package/src/port.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Alteration, ScheduleTemplate } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// Storage adapter for the Scheduler. The Scheduler is pure logic over this
|
|
4
|
+
// port; consumers implement it against whatever backing store they want
|
|
5
|
+
// (in-memory, SQL JSON column, KV, etc.).
|
|
6
|
+
//
|
|
7
|
+
// Range queries (start/end) MUST return any template/alteration that could
|
|
8
|
+
// produce or affect an occurrence in the window — including templates whose
|
|
9
|
+
// seriesStart is before `start` but whose recurrence still emits inside it.
|
|
10
|
+
// The simplest correct implementation returns all templates and lets the
|
|
11
|
+
// scheduler filter. Smarter ports may pre-filter on seriesStart/seriesEnd.
|
|
12
|
+
export interface SchedulerPort<TPayload extends { type: string }> {
|
|
13
|
+
// Templates whose recurrence may emit occurrences intersecting [start, end).
|
|
14
|
+
listTemplates(start: number, end: number): Promise<ScheduleTemplate<TPayload>[]>;
|
|
15
|
+
getTemplate(id: string): Promise<ScheduleTemplate<TPayload> | null>;
|
|
16
|
+
addTemplate(template: ScheduleTemplate<TPayload>): Promise<void>;
|
|
17
|
+
// Replaces the entire stored template. The Scheduler validates before calling.
|
|
18
|
+
updateTemplate(template: ScheduleTemplate<TPayload>): Promise<void>;
|
|
19
|
+
removeTemplate(id: string): Promise<void>;
|
|
20
|
+
|
|
21
|
+
// Alterations affecting occurrences in [start, end).
|
|
22
|
+
listAlterations(start: number, end: number): Promise<Alteration<TPayload>[]>;
|
|
23
|
+
// All alterations for a template (used when wiping on rule changes).
|
|
24
|
+
listAlterationsByTemplate(templateId: string): Promise<Alteration<TPayload>[]>;
|
|
25
|
+
addAlteration(alteration: Alteration<TPayload>): Promise<void>;
|
|
26
|
+
removeAlteration(id: string): Promise<void>;
|
|
27
|
+
// Bulk delete used by the Scheduler when a template's rule changes
|
|
28
|
+
// (existing alterations are no longer meaningful — same policy as
|
|
29
|
+
// activity-board, just made explicit).
|
|
30
|
+
removeAlterationsForTemplate(templateId: string): Promise<void>;
|
|
31
|
+
}
|