@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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -2
  3. package/dist/alterations.d.ts +6 -0
  4. package/dist/alterations.d.ts.map +1 -0
  5. package/dist/alterations.js +61 -0
  6. package/dist/alterations.js.map +1 -0
  7. package/dist/cjs/alterations.d.ts +5 -0
  8. package/dist/cjs/alterations.js +96 -0
  9. package/dist/cjs/dispatcher.d.ts +38 -0
  10. package/dist/cjs/dispatcher.js +165 -0
  11. package/dist/cjs/dispatcher_store.d.ts +9 -0
  12. package/dist/cjs/dispatcher_store.js +15 -0
  13. package/dist/cjs/generators/anchored.d.ts +3 -0
  14. package/dist/cjs/generators/anchored.js +57 -0
  15. package/dist/cjs/generators/days_of_month.d.ts +3 -0
  16. package/dist/cjs/generators/days_of_month.js +76 -0
  17. package/dist/cjs/generators/days_of_week.d.ts +5 -0
  18. package/dist/cjs/generators/days_of_week.js +65 -0
  19. package/dist/cjs/generators/days_of_year.d.ts +3 -0
  20. package/dist/cjs/generators/days_of_year.js +70 -0
  21. package/dist/cjs/generators/instant.d.ts +3 -0
  22. package/dist/cjs/generators/instant.js +15 -0
  23. package/dist/cjs/generators/local_date_time.d.ts +3 -0
  24. package/dist/cjs/generators/local_date_time.js +31 -0
  25. package/dist/cjs/generators/months_of_year.d.ts +3 -0
  26. package/dist/cjs/generators/months_of_year.js +109 -0
  27. package/dist/cjs/generators/types.d.ts +9 -0
  28. package/dist/cjs/generators/types.js +19 -0
  29. package/dist/cjs/generators/weeks_of_month.d.ts +3 -0
  30. package/dist/cjs/generators/weeks_of_month.js +99 -0
  31. package/dist/cjs/generators/yearly.d.ts +3 -0
  32. package/dist/cjs/generators/yearly.js +52 -0
  33. package/dist/cjs/in_memory_port.d.ts +18 -0
  34. package/dist/cjs/in_memory_port.js +59 -0
  35. package/dist/cjs/index.d.ts +6 -0
  36. package/dist/cjs/index.js +14 -0
  37. package/dist/cjs/occurrence_key.d.ts +5 -0
  38. package/dist/cjs/occurrence_key.js +21 -0
  39. package/dist/cjs/package.json +3 -0
  40. package/dist/cjs/port.d.ts +15 -0
  41. package/dist/cjs/port.js +2 -0
  42. package/dist/cjs/scheduler.d.ts +24 -0
  43. package/dist/cjs/scheduler.js +460 -0
  44. package/dist/cjs/types.d.ts +149 -0
  45. package/dist/cjs/types.js +58 -0
  46. package/dist/cjs/tz.d.ts +22 -0
  47. package/dist/cjs/tz.js +197 -0
  48. package/dist/dispatcher.d.ts +39 -0
  49. package/dist/dispatcher.d.ts.map +1 -0
  50. package/dist/dispatcher.js +111 -0
  51. package/dist/dispatcher.js.map +1 -0
  52. package/dist/dispatcher_store.d.ts +10 -0
  53. package/dist/dispatcher_store.d.ts.map +1 -0
  54. package/dist/dispatcher_store.js +13 -0
  55. package/dist/dispatcher_store.js.map +1 -0
  56. package/dist/generators/anchored.d.ts +4 -0
  57. package/dist/generators/anchored.d.ts.map +1 -0
  58. package/dist/generators/anchored.js +42 -0
  59. package/dist/generators/anchored.js.map +1 -0
  60. package/dist/generators/days_of_month.d.ts +4 -0
  61. package/dist/generators/days_of_month.d.ts.map +1 -0
  62. package/dist/generators/days_of_month.js +53 -0
  63. package/dist/generators/days_of_month.js.map +1 -0
  64. package/dist/generators/days_of_week.d.ts +6 -0
  65. package/dist/generators/days_of_week.d.ts.map +1 -0
  66. package/dist/generators/days_of_week.js +43 -0
  67. package/dist/generators/days_of_week.js.map +1 -0
  68. package/dist/generators/days_of_year.d.ts +4 -0
  69. package/dist/generators/days_of_year.d.ts.map +1 -0
  70. package/dist/generators/days_of_year.js +50 -0
  71. package/dist/generators/days_of_year.js.map +1 -0
  72. package/dist/generators/instant.d.ts +4 -0
  73. package/dist/generators/instant.d.ts.map +1 -0
  74. package/dist/generators/instant.js +15 -0
  75. package/dist/generators/instant.js.map +1 -0
  76. package/dist/generators/local_date_time.d.ts +4 -0
  77. package/dist/generators/local_date_time.d.ts.map +1 -0
  78. package/dist/generators/local_date_time.js +20 -0
  79. package/dist/generators/local_date_time.js.map +1 -0
  80. package/dist/generators/months_of_year.d.ts +4 -0
  81. package/dist/generators/months_of_year.d.ts.map +1 -0
  82. package/dist/generators/months_of_year.js +64 -0
  83. package/dist/generators/months_of_year.js.map +1 -0
  84. package/dist/generators/types.d.ts +10 -0
  85. package/dist/generators/types.d.ts.map +1 -0
  86. package/dist/generators/types.js +14 -0
  87. package/dist/generators/types.js.map +1 -0
  88. package/dist/generators/weeks_of_month.d.ts +4 -0
  89. package/dist/generators/weeks_of_month.d.ts.map +1 -0
  90. package/dist/generators/weeks_of_month.js +61 -0
  91. package/dist/generators/weeks_of_month.js.map +1 -0
  92. package/dist/generators/yearly.d.ts +4 -0
  93. package/dist/generators/yearly.d.ts.map +1 -0
  94. package/dist/generators/yearly.js +37 -0
  95. package/dist/generators/yearly.js.map +1 -0
  96. package/dist/in_memory_port.d.ts +19 -0
  97. package/dist/in_memory_port.d.ts.map +1 -0
  98. package/dist/in_memory_port.js +45 -0
  99. package/dist/in_memory_port.js.map +1 -0
  100. package/dist/index.d.ts +7 -0
  101. package/dist/index.d.ts.map +1 -0
  102. package/dist/index.js +6 -0
  103. package/dist/occurrence_key.d.ts +6 -0
  104. package/dist/occurrence_key.d.ts.map +1 -0
  105. package/dist/occurrence_key.js +25 -0
  106. package/dist/occurrence_key.js.map +1 -0
  107. package/dist/port.d.ts +16 -0
  108. package/dist/port.d.ts.map +1 -0
  109. package/dist/scheduler.d.ts +25 -0
  110. package/dist/scheduler.d.ts.map +1 -0
  111. package/dist/scheduler.js +332 -0
  112. package/dist/scheduler.js.map +1 -0
  113. package/dist/types.d.ts +150 -0
  114. package/dist/types.d.ts.map +1 -0
  115. package/dist/types.js +29 -0
  116. package/dist/types.js.map +1 -0
  117. package/dist/tz.d.ts +23 -0
  118. package/dist/tz.d.ts.map +1 -0
  119. package/dist/tz.js +131 -0
  120. package/dist/tz.js.map +1 -0
  121. package/package.json +70 -4
  122. package/src/__tests__/alterations.test.ts +177 -0
  123. package/src/__tests__/anchored.test.ts +107 -0
  124. package/src/__tests__/days_of_month.test.ts +162 -0
  125. package/src/__tests__/days_of_week.test.ts +134 -0
  126. package/src/__tests__/days_of_year.test.ts +158 -0
  127. package/src/__tests__/dispatcher.test.ts +317 -0
  128. package/src/__tests__/instant.test.ts +50 -0
  129. package/src/__tests__/local_date_time.test.ts +139 -0
  130. package/src/__tests__/months_of_year.test.ts +229 -0
  131. package/src/__tests__/scheduler.test.ts +888 -0
  132. package/src/__tests__/tz.test.ts +201 -0
  133. package/src/__tests__/weeks_of_month.test.ts +195 -0
  134. package/src/__tests__/yearly.test.ts +108 -0
  135. package/src/alterations.ts +108 -0
  136. package/src/dispatcher.ts +184 -0
  137. package/src/dispatcher_store.ts +26 -0
  138. package/src/generators/anchored.ts +69 -0
  139. package/src/generators/days_of_month.ts +100 -0
  140. package/src/generators/days_of_week.ts +101 -0
  141. package/src/generators/days_of_year.ts +88 -0
  142. package/src/generators/instant.ts +18 -0
  143. package/src/generators/local_date_time.ts +38 -0
  144. package/src/generators/months_of_year.ts +143 -0
  145. package/src/generators/types.ts +26 -0
  146. package/src/generators/weeks_of_month.ts +133 -0
  147. package/src/generators/yearly.ts +73 -0
  148. package/src/in_memory_port.ts +70 -0
  149. package/src/index.ts +24 -0
  150. package/src/occurrence_key.ts +42 -0
  151. package/src/port.ts +31 -0
  152. package/src/scheduler.ts +499 -0
  153. package/src/types.ts +369 -0
  154. package/src/tz.ts +246 -0
  155. package/tsconfig.json +25 -0
@@ -0,0 +1,201 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { Day } from "../types.js";
3
+ import {
4
+ addDaysInTz,
5
+ addMonthsInTz,
6
+ dayOfWeekInTz,
7
+ monthLength,
8
+ utcToWallParts,
9
+ wallTimeToUtc,
10
+ } from "../tz.js";
11
+
12
+ // Reference moments:
13
+ // 2025-03-09 09:00 UTC = 2025-03-09 02:00 MST = 03:00 MDT (the gap)
14
+ // 2025-11-02 08:00 UTC = 2025-11-02 02:00 MDT = 01:00 MST (the overlap)
15
+ // America/Denver: -07:00 in MDT, -06:00 in MST. Wait — opposite. MST is
16
+ // standard (-07), MDT is daylight (-06). Spring forward = MST→MDT (lose
17
+ // an hour). Fall back = MDT→MST (gain an hour).
18
+
19
+ describe("tz — UTC pass-through", () => {
20
+ test("utcToWallParts treats UTC as itself", () => {
21
+ const w = utcToWallParts(Date.UTC(2026, 4, 11, 14, 30, 45, 250), "UTC");
22
+ expect(w).toEqual({
23
+ year: 2026,
24
+ month: 4,
25
+ day: 11,
26
+ hour: 14,
27
+ minute: 30,
28
+ second: 45,
29
+ ms: 250,
30
+ dayOfWeek: Day.Monday,
31
+ });
32
+ });
33
+
34
+ test("wallTimeToUtc in UTC = Date.UTC", () => {
35
+ const expected = Date.UTC(2026, 4, 11, 14, 30, 45, 250);
36
+ const msOfDay = 14 * 3_600_000 + 30 * 60_000 + 45 * 1000 + 250;
37
+ expect(wallTimeToUtc(2026, 4, 11, msOfDay, "UTC")).toBe(expected);
38
+ });
39
+ });
40
+
41
+ describe("tz — IANA round-trips", () => {
42
+ test("New York: 9am wall = 14:00 UTC in winter (EST)", () => {
43
+ const utc = wallTimeToUtc(2026, 0, 15, 9 * 3_600_000, "America/New_York");
44
+ expect(new Date(utc).toISOString()).toBe("2026-01-15T14:00:00.000Z");
45
+ });
46
+
47
+ test("New York: 9am wall = 13:00 UTC in summer (EDT)", () => {
48
+ const utc = wallTimeToUtc(2026, 6, 15, 9 * 3_600_000, "America/New_York");
49
+ expect(new Date(utc).toISOString()).toBe("2026-07-15T13:00:00.000Z");
50
+ });
51
+
52
+ test("Tokyo: 9am wall = 00:00 UTC year-round (no DST)", () => {
53
+ const winter = wallTimeToUtc(2026, 0, 15, 9 * 3_600_000, "Asia/Tokyo");
54
+ const summer = wallTimeToUtc(2026, 6, 15, 9 * 3_600_000, "Asia/Tokyo");
55
+ expect(new Date(winter).toISOString()).toBe("2026-01-15T00:00:00.000Z");
56
+ expect(new Date(summer).toISOString()).toBe("2026-07-15T00:00:00.000Z");
57
+ });
58
+
59
+ test("Round-trip: utcToWallParts → wallTimeToUtc preserves the instant", () => {
60
+ const original = Date.UTC(2025, 5, 15, 18, 42, 17);
61
+ const w = utcToWallParts(original, "Europe/London");
62
+ const msOfDay = w.hour * 3_600_000 + w.minute * 60_000 + w.second * 1000;
63
+ expect(wallTimeToUtc(w.year, w.month, w.day, msOfDay, "Europe/London")).toBe(original);
64
+ });
65
+ });
66
+
67
+ describe("tz — DST boundaries", () => {
68
+ // Spring forward: 2025-03-09, America/Denver. Clock jumps 02:00 MST → 03:00 MDT.
69
+ // 02:30 wall is "non-existent". We resolve to the EARLIER instant — i.e.
70
+ // treat the wall fields with the pre-transition (MST, -07) offset. So
71
+ // 02:30 → 09:30 UTC (since pre-transition Denver is UTC-7).
72
+ // Wait: actually pre-transition offset is MST (-7), so 02:30 MST → 09:30 UTC.
73
+ // The IMPLEMENTATION returns 08:30 UTC, which corresponds to 02:30 in the
74
+ // pre-jump timeline interpreted as if it were ALREADY MDT (-6). Hmm.
75
+ //
76
+ // What's actually happening: the algorithm reads the offset at the
77
+ // post-correction guess (which lands AFTER the jump), so it uses MDT (-6).
78
+ // 02:30 wall - (-6h) = 08:30 UTC. So we resolve to the EARLIER UTC instant
79
+ // by using the POST-transition (smaller magnitude) offset. This is the
80
+ // Luxon / date-fns-tz default for spring-forward.
81
+ // Default disambiguation is now "compatible": a spring-forward gap moves
82
+ // FORWARD to the later instant. 02:30 (nonexistent) → 09:30 UTC (i.e. the
83
+ // clock jumps to 03:30 MDT). "earlier" recovers the old 08:30 UTC behavior.
84
+ test("spring forward gap: default (compatible) resolves to the later instant", () => {
85
+ const utc = wallTimeToUtc(2025, 2, 9, 2 * 3_600_000 + 30 * 60_000, "America/Denver");
86
+ expect(new Date(utc).toISOString()).toBe("2025-03-09T09:30:00.000Z");
87
+ });
88
+
89
+ test("spring forward gap: disambiguation policies pick gap direction", () => {
90
+ const wall = 2 * 3_600_000 + 30 * 60_000;
91
+ const earlier = wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "earlier");
92
+ const later = wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "later");
93
+ const compatible = wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "compatible");
94
+ expect(new Date(earlier).toISOString()).toBe("2025-03-09T08:30:00.000Z");
95
+ expect(new Date(later).toISOString()).toBe("2025-03-09T09:30:00.000Z");
96
+ expect(compatible).toBe(later);
97
+ expect(() => wallTimeToUtc(2025, 2, 9, wall, "America/Denver", "reject")).toThrow(/gap/);
98
+ });
99
+
100
+ // Fall back: 2025-11-02, America/Denver. Clock falls 02:00 MDT → 01:00 MST.
101
+ // 01:30 wall is ambiguous. We resolve to the EARLIER instant: 07:30 UTC
102
+ // (the first occurrence, while still on MDT), not 08:30 UTC.
103
+ test("fall back ambiguous wall time resolves to earlier instant (compatible default)", () => {
104
+ const utc = wallTimeToUtc(2025, 10, 2, 1 * 3_600_000 + 30 * 60_000, "America/Denver");
105
+ expect(new Date(utc).toISOString()).toBe("2025-11-02T07:30:00.000Z");
106
+ });
107
+
108
+ test("fall back overlap: disambiguation policies pick which occurrence", () => {
109
+ const wall = 1 * 3_600_000 + 30 * 60_000;
110
+ const earlier = wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "earlier");
111
+ const later = wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "later");
112
+ expect(new Date(earlier).toISOString()).toBe("2025-11-02T07:30:00.000Z");
113
+ expect(new Date(later).toISOString()).toBe("2025-11-02T08:30:00.000Z");
114
+ // "compatible" picks the earlier occurrence for an overlap.
115
+ expect(wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "compatible")).toBe(earlier);
116
+ expect(() => wallTimeToUtc(2025, 10, 2, wall, "America/Denver", "reject")).toThrow(/overlap/);
117
+ });
118
+
119
+ // Same wall-clock time, two different UTCs across spring forward.
120
+ test("9am Denver wall is 16:00 UTC before, 15:00 UTC after spring forward", () => {
121
+ const before = wallTimeToUtc(2025, 2, 8, 9 * 3_600_000, "America/Denver"); // Sat
122
+ const after = wallTimeToUtc(2025, 2, 10, 9 * 3_600_000, "America/Denver"); // Mon
123
+ expect(new Date(before).toISOString()).toBe("2025-03-08T16:00:00.000Z");
124
+ expect(new Date(after).toISOString()).toBe("2025-03-10T15:00:00.000Z");
125
+ });
126
+ });
127
+
128
+ describe("tz — addDaysInTz", () => {
129
+ test("adding 1 day across spring forward keeps wall time stable", () => {
130
+ // 2025-03-08 09:00 Denver
131
+ const start = wallTimeToUtc(2025, 2, 8, 9 * 3_600_000, "America/Denver");
132
+ const next = addDaysInTz(start, 1, "America/Denver");
133
+ const w = utcToWallParts(next, "America/Denver");
134
+ expect({ year: w.year, month: w.month, day: w.day, hour: w.hour }).toEqual({
135
+ year: 2025,
136
+ month: 2,
137
+ day: 9,
138
+ hour: 9,
139
+ });
140
+ // The actual elapsed UTC ms is 23 hours, not 24.
141
+ expect(next - start).toBe(23 * 3_600_000);
142
+ });
143
+
144
+ test("adding 1 day across fall back keeps wall time stable", () => {
145
+ const start = wallTimeToUtc(2025, 10, 1, 9 * 3_600_000, "America/Denver");
146
+ const next = addDaysInTz(start, 1, "America/Denver");
147
+ const w = utcToWallParts(next, "America/Denver");
148
+ expect({ year: w.year, month: w.month, day: w.day, hour: w.hour }).toEqual({
149
+ year: 2025,
150
+ month: 10,
151
+ day: 2,
152
+ hour: 9,
153
+ });
154
+ // 25-hour day.
155
+ expect(next - start).toBe(25 * 3_600_000);
156
+ });
157
+
158
+ test("adding negative days works", () => {
159
+ const start = wallTimeToUtc(2026, 4, 11, 12 * 3_600_000, "UTC");
160
+ const before = addDaysInTz(start, -10, "UTC");
161
+ expect(new Date(before).toISOString()).toBe("2026-05-01T12:00:00.000Z");
162
+ });
163
+ });
164
+
165
+ describe("tz — addMonthsInTz", () => {
166
+ test("adds months in wall sense", () => {
167
+ const start = wallTimeToUtc(2026, 0, 31, 12 * 3_600_000, "UTC"); // Jan 31
168
+ const next = addMonthsInTz(start, 1, "UTC"); // Feb 31 → Mar 3 (JS rolls over)
169
+ const w = utcToWallParts(next, "UTC");
170
+ expect({ year: w.year, month: w.month, day: w.day }).toEqual({
171
+ year: 2026,
172
+ month: 2, // March (JS rolls Feb 31 → Mar 3)
173
+ day: 3,
174
+ });
175
+ });
176
+ });
177
+
178
+ describe("tz — dayOfWeekInTz", () => {
179
+ test("crosses date boundary correctly in non-UTC tz", () => {
180
+ // 2026-05-11 23:00 UTC = 2026-05-11 16:00 PDT (Monday in LA, Monday in UTC)
181
+ const utc = Date.UTC(2026, 4, 11, 23, 0, 0);
182
+ expect(dayOfWeekInTz(utc, "UTC")).toBe(Day.Monday);
183
+ expect(dayOfWeekInTz(utc, "America/Los_Angeles")).toBe(Day.Monday);
184
+ });
185
+
186
+ test("date boundary case: 04:00 UTC Tuesday is still Monday in LA", () => {
187
+ // 2026-05-12 04:00 UTC = 2026-05-11 21:00 PDT
188
+ const utc = Date.UTC(2026, 4, 12, 4, 0, 0);
189
+ expect(dayOfWeekInTz(utc, "UTC")).toBe(Day.Tuesday);
190
+ expect(dayOfWeekInTz(utc, "America/Los_Angeles")).toBe(Day.Monday);
191
+ });
192
+ });
193
+
194
+ describe("tz — monthLength", () => {
195
+ test("returns civil month length", () => {
196
+ expect(monthLength(2024, 1)).toBe(29); // Feb 2024 (leap)
197
+ expect(monthLength(2025, 1)).toBe(28); // Feb 2025
198
+ expect(monthLength(2026, 0)).toBe(31); // Jan
199
+ expect(monthLength(2026, 3)).toBe(30); // Apr
200
+ });
201
+ });
@@ -0,0 +1,195 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { generateWeeksOfMonth } from "../generators/weeks_of_month.js";
3
+ import { Day, type WeeksOfMonthRule } from "../types.js";
4
+
5
+ const HOUR = 3_600_000;
6
+
7
+ describe("generateWeeksOfMonth", () => {
8
+ test("2nd Tuesday of every month for a quarter", () => {
9
+ const rule: WeeksOfMonthRule = {
10
+ kind: "weeksOfMonth",
11
+ days: [Day.Tuesday],
12
+ weeks: [2],
13
+ monthInterval: 1,
14
+ timeOfDay: 14 * HOUR,
15
+ timeSpan: { type: "duration", ms: HOUR },
16
+ seriesStart: Date.UTC(2026, 0, 1),
17
+ seriesEnd: null,
18
+ };
19
+ const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 3, 1));
20
+ // 2nd Tuesdays: Jan 13, Feb 10, Mar 10
21
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
22
+ "2026-01-13T14:00:00.000Z",
23
+ "2026-02-10T14:00:00.000Z",
24
+ "2026-03-10T14:00:00.000Z",
25
+ ]);
26
+ });
27
+
28
+ test("last Friday of every month", () => {
29
+ const rule: WeeksOfMonthRule = {
30
+ kind: "weeksOfMonth",
31
+ days: [Day.Friday],
32
+ weeks: [-1],
33
+ monthInterval: 1,
34
+ timeOfDay: 17 * HOUR,
35
+ timeSpan: { type: "duration", ms: HOUR },
36
+ seriesStart: Date.UTC(2026, 0, 1),
37
+ seriesEnd: null,
38
+ };
39
+ const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 3, 1));
40
+ // Last Fridays: Jan 30, Feb 27, Mar 27
41
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
42
+ "2026-01-30T17:00:00.000Z",
43
+ "2026-02-27T17:00:00.000Z",
44
+ "2026-03-27T17:00:00.000Z",
45
+ ]);
46
+ });
47
+
48
+ test("5th Friday: emits only in months that have one", () => {
49
+ const rule: WeeksOfMonthRule = {
50
+ kind: "weeksOfMonth",
51
+ days: [Day.Friday],
52
+ weeks: [5],
53
+ monthInterval: 1,
54
+ timeOfDay: 0,
55
+ timeSpan: { type: "duration", ms: HOUR },
56
+ seriesStart: Date.UTC(2026, 0, 1),
57
+ seriesEnd: null,
58
+ };
59
+ const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1));
60
+ // 2026: months with a 5th Friday are Jan (30), May (29), Jul (31), Oct (30).
61
+ // Wait — let me recompute: 5th Friday means the month has 5 Fridays.
62
+ // Jan 2026 Fridays: 2,9,16,23,30 → 5 Fridays, 5th = 30
63
+ // May 2026 Fridays: 1,8,15,22,29 → 5 Fridays, 5th = 29
64
+ // Jul 2026 Fridays: 3,10,17,24,31 → 5 Fridays, 5th = 31
65
+ // Oct 2026 Fridays: 2,9,16,23,30 → 5 Fridays, 5th = 30
66
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
67
+ "2026-01-30T00:00:00.000Z",
68
+ "2026-05-29T00:00:00.000Z",
69
+ "2026-07-31T00:00:00.000Z",
70
+ "2026-10-30T00:00:00.000Z",
71
+ ]);
72
+ });
73
+
74
+ test("every other month: skips months correctly", () => {
75
+ const rule: WeeksOfMonthRule = {
76
+ kind: "weeksOfMonth",
77
+ days: [Day.Wednesday],
78
+ weeks: [1],
79
+ monthInterval: 2,
80
+ timeOfDay: 12 * HOUR,
81
+ timeSpan: { type: "duration", ms: HOUR },
82
+ seriesStart: Date.UTC(2026, 0, 1),
83
+ seriesEnd: null,
84
+ };
85
+ const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 6, 1));
86
+ // 1st Wed of Jan/Mar/May 2026 = Jan 7, Mar 4, May 6
87
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
88
+ "2026-01-07T12:00:00.000Z",
89
+ "2026-03-04T12:00:00.000Z",
90
+ "2026-05-06T12:00:00.000Z",
91
+ ]);
92
+ });
93
+
94
+ test("throws on week = 0", () => {
95
+ const rule: WeeksOfMonthRule = {
96
+ kind: "weeksOfMonth",
97
+ days: [Day.Monday],
98
+ weeks: [0],
99
+ monthInterval: 1,
100
+ timeOfDay: 0,
101
+ seriesStart: 0,
102
+ seriesEnd: null,
103
+ };
104
+ expect(() => generateWeeksOfMonth(rule, "UTC", 0, 1000)).toThrow(/week/);
105
+ });
106
+
107
+ test("throws on monthInterval = 0", () => {
108
+ const rule: WeeksOfMonthRule = {
109
+ kind: "weeksOfMonth",
110
+ days: [Day.Monday],
111
+ weeks: [1],
112
+ monthInterval: 0,
113
+ timeOfDay: 0,
114
+ seriesStart: 0,
115
+ seriesEnd: null,
116
+ };
117
+ expect(() => generateWeeksOfMonth(rule, "UTC", 0, 1000)).toThrow(/monthInterval/);
118
+ });
119
+
120
+ test("first AND last week: Mon/Wed/Fri on weeks [1, -1] of every month", () => {
121
+ // The user's original use case.
122
+ const rule: WeeksOfMonthRule = {
123
+ kind: "weeksOfMonth",
124
+ days: [Day.Monday, Day.Wednesday, Day.Friday],
125
+ weeks: [1, -1],
126
+ monthInterval: 1,
127
+ timeOfDay: 9 * HOUR,
128
+ timeSpan: { type: "duration", ms: HOUR },
129
+ seriesStart: Date.UTC(2026, 0, 1),
130
+ seriesEnd: null,
131
+ };
132
+ const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 1, 1));
133
+ // Jan 2026 starts Thursday. Days of week:
134
+ // Thu Jan 1, Fri Jan 2 (1st Fri), Sat Jan 3, Sun Jan 4,
135
+ // Mon Jan 5 (1st Mon), Tue Jan 6, Wed Jan 7 (1st Wed), ...
136
+ // Last week of Jan 2026:
137
+ // Mon Jan 26 (last Mon), Wed Jan 28 (last Wed), Fri Jan 30 (last Fri).
138
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
139
+ "2026-01-02T09:00:00.000Z", // 1st Fri
140
+ "2026-01-05T09:00:00.000Z", // 1st Mon
141
+ "2026-01-07T09:00:00.000Z", // 1st Wed
142
+ "2026-01-26T09:00:00.000Z", // last Mon
143
+ "2026-01-28T09:00:00.000Z", // last Wed
144
+ "2026-01-30T09:00:00.000Z", // last Fri
145
+ ]);
146
+ });
147
+
148
+ test("multiple positive weeks: 1st, 3rd, 5th Tuesday", () => {
149
+ const rule: WeeksOfMonthRule = {
150
+ kind: "weeksOfMonth",
151
+ days: [Day.Tuesday],
152
+ weeks: [1, 3, 5],
153
+ monthInterval: 1,
154
+ timeOfDay: 0,
155
+ timeSpan: { type: "duration", ms: HOUR },
156
+ seriesStart: Date.UTC(2026, 5, 1),
157
+ seriesEnd: null,
158
+ };
159
+ // June 2026 Tuesdays: 2, 9, 16, 23, 30 → 1st=2, 3rd=16, 5th=30
160
+ const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 5, 1), Date.UTC(2026, 6, 1));
161
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
162
+ "2026-06-02T00:00:00.000Z",
163
+ "2026-06-16T00:00:00.000Z",
164
+ "2026-06-30T00:00:00.000Z",
165
+ ]);
166
+ });
167
+
168
+ test("duplicate weeks are deduped (no double-emit)", () => {
169
+ const rule: WeeksOfMonthRule = {
170
+ kind: "weeksOfMonth",
171
+ days: [Day.Monday],
172
+ weeks: [1, 1, 1],
173
+ monthInterval: 1,
174
+ timeOfDay: 0,
175
+ timeSpan: { type: "duration", ms: HOUR },
176
+ seriesStart: Date.UTC(2026, 0, 1),
177
+ seriesEnd: null,
178
+ };
179
+ const out = generateWeeksOfMonth(rule, "UTC", Date.UTC(2026, 0, 1), Date.UTC(2026, 1, 1));
180
+ expect(out).toHaveLength(1); // 1st Monday of Jan 2026 = Jan 5
181
+ });
182
+
183
+ test("throws on empty weeks", () => {
184
+ const rule: WeeksOfMonthRule = {
185
+ kind: "weeksOfMonth",
186
+ days: [Day.Monday],
187
+ weeks: [],
188
+ monthInterval: 1,
189
+ timeOfDay: 0,
190
+ seriesStart: 0,
191
+ seriesEnd: null,
192
+ };
193
+ expect(() => generateWeeksOfMonth(rule, "UTC", 0, 1000)).toThrow(/weeks/);
194
+ });
195
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { generateYearly } from "../generators/yearly.js";
3
+ import { Month, type YearlyRule } from "../types.js";
4
+
5
+ const HOUR = 3_600_000;
6
+
7
+ describe("generateYearly", () => {
8
+ test("every June 11", () => {
9
+ const rule: YearlyRule = {
10
+ kind: "yearly",
11
+ month: Month.June,
12
+ day: 11,
13
+ yearInterval: 1,
14
+ timeOfDay: 9 * HOUR,
15
+ timeSpan: { type: "duration", ms: HOUR },
16
+ seriesStart: Date.UTC(2024, 0, 1),
17
+ seriesEnd: null,
18
+ };
19
+ const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2027, 0, 1));
20
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
21
+ "2024-06-11T09:00:00.000Z",
22
+ "2025-06-11T09:00:00.000Z",
23
+ "2026-06-11T09:00:00.000Z",
24
+ ]);
25
+ });
26
+
27
+ test("every other Jan 1", () => {
28
+ const rule: YearlyRule = {
29
+ kind: "yearly",
30
+ month: Month.January,
31
+ day: 1,
32
+ yearInterval: 2,
33
+ timeOfDay: 0,
34
+ timeSpan: { type: "duration", ms: HOUR },
35
+ seriesStart: Date.UTC(2024, 0, 1),
36
+ seriesEnd: null,
37
+ };
38
+ const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2030, 0, 1));
39
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
40
+ "2024-01-01T00:00:00.000Z",
41
+ "2026-01-01T00:00:00.000Z",
42
+ "2028-01-01T00:00:00.000Z",
43
+ ]);
44
+ });
45
+
46
+ test("Feb 29 falls back to Feb 28 in non-leap years", () => {
47
+ const rule: YearlyRule = {
48
+ kind: "yearly",
49
+ month: Month.February,
50
+ day: 29,
51
+ yearInterval: 1,
52
+ timeOfDay: 0,
53
+ timeSpan: { type: "duration", ms: HOUR },
54
+ seriesStart: Date.UTC(2024, 0, 1),
55
+ seriesEnd: null,
56
+ };
57
+ const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2027, 0, 1));
58
+ expect(out.map((o) => new Date(o.startTimestamp).toISOString())).toEqual([
59
+ "2024-02-29T00:00:00.000Z", // leap
60
+ "2025-02-28T00:00:00.000Z", // fallback
61
+ "2026-02-28T00:00:00.000Z", // fallback
62
+ ]);
63
+ });
64
+
65
+ test("count caps emissions", () => {
66
+ const rule: YearlyRule = {
67
+ kind: "yearly",
68
+ month: Month.July,
69
+ day: 4,
70
+ yearInterval: 1,
71
+ timeOfDay: 0,
72
+ timeSpan: { type: "duration", ms: HOUR },
73
+ seriesStart: Date.UTC(2024, 0, 1),
74
+ seriesEnd: null,
75
+ count: 2,
76
+ };
77
+ const out = generateYearly(rule, "UTC", Date.UTC(2024, 0, 1), Date.UTC(2030, 0, 1));
78
+ expect(out).toHaveLength(2);
79
+ });
80
+
81
+ test("throws on month out of range", () => {
82
+ const rule: YearlyRule = {
83
+ kind: "yearly",
84
+ month: 12 as Month,
85
+ day: 1,
86
+ yearInterval: 1,
87
+ timeOfDay: 0,
88
+ timeSpan: { type: "duration", ms: HOUR },
89
+ seriesStart: 0,
90
+ seriesEnd: null,
91
+ };
92
+ expect(() => generateYearly(rule, "UTC", 0, 1000)).toThrow(/month/);
93
+ });
94
+
95
+ test("throws on day < 1 or > 31", () => {
96
+ const rule: YearlyRule = {
97
+ kind: "yearly",
98
+ month: Month.January,
99
+ day: 32,
100
+ yearInterval: 1,
101
+ timeOfDay: 0,
102
+ timeSpan: { type: "duration", ms: HOUR },
103
+ seriesStart: 0,
104
+ seriesEnd: null,
105
+ };
106
+ expect(() => generateYearly(rule, "UTC", 0, 1000)).toThrow(/day/);
107
+ });
108
+ });
@@ -0,0 +1,108 @@
1
+ import { intersects } from "./generators/types.js";
2
+ import type { KeyedOccurrence } from "./occurrence_key.js";
3
+ import type { Alteration, ScheduledEvent } from "./types.js";
4
+ import { computeSpanEnd } from "./tz.js";
5
+
6
+ // Apply a template's alterations to its base occurrences and emit the final
7
+ // ScheduledEvents.
8
+ //
9
+ // Two passes:
10
+ // 1. Walk base occurrences. Cancel suppresses; reschedule replaces (and
11
+ // may move out of window, in which case it's not emitted).
12
+ // 2. Walk reschedules whose original key is NOT in the base list (the
13
+ // original was outside the window) but whose new time IS in the window.
14
+ // Synthesize an event for them. This is what activity-board does in its
15
+ // separate AlterationEventGenerator.
16
+ //
17
+ // `tz` is the template's timezone. Used to interpret hourSpan on the
18
+ // alteration's timeSpan (if any). When the alteration carries no timeSpan,
19
+ // we preserve the original occurrence's duration (end - start in UTC ms).
20
+ //
21
+ // Result is sorted ascending by startTimestamp.
22
+ export function applyAlterations<TPayload extends { type: string }>(
23
+ templateId: string,
24
+ basePayload: TPayload,
25
+ keyedOccurrences: readonly KeyedOccurrence[],
26
+ alterations: readonly Alteration<TPayload>[],
27
+ tz: string | undefined,
28
+ windowStart: number,
29
+ windowEnd: number
30
+ ): ScheduledEvent<TPayload>[] {
31
+ // Index alterations for THIS template by occurrence key. If multiple
32
+ // alterations target the same key, last-write-wins.
33
+ const altByKey = new Map<string, Alteration<TPayload>>();
34
+ for (const alt of alterations) {
35
+ if (alt.templateId === templateId) altByKey.set(alt.occurrenceKey, alt);
36
+ }
37
+
38
+ const out: ScheduledEvent<TPayload>[] = [];
39
+ const seenKeys = new Set<string>();
40
+
41
+ for (const occ of keyedOccurrences) {
42
+ seenKeys.add(occ.occurrenceKey);
43
+ const alt = altByKey.get(occ.occurrenceKey);
44
+
45
+ if (alt == null) {
46
+ out.push({
47
+ templateId,
48
+ occurrenceKey: occ.occurrenceKey,
49
+ startTimestamp: occ.startTimestamp,
50
+ endTimestamp: occ.endTimestamp,
51
+ payload: basePayload,
52
+ });
53
+ continue;
54
+ }
55
+ if (alt.kind === "cancel") continue;
56
+
57
+ // reschedule
58
+ const start = alt.startTimestamp ?? occ.startTimestamp;
59
+ // If the alteration specifies a timeSpan, use it (with tz semantics).
60
+ // Otherwise preserve the original UTC duration.
61
+ const end =
62
+ alt.timeSpan != null
63
+ ? computeSpanEnd(start, alt.timeSpan, tz)
64
+ : start + (occ.endTimestamp - occ.startTimestamp);
65
+ if (!intersects(start, end, windowStart, windowEnd)) continue;
66
+ out.push({
67
+ templateId,
68
+ occurrenceKey: occ.occurrenceKey,
69
+ startTimestamp: start,
70
+ endTimestamp: end,
71
+ payload: _mergePayload(basePayload, alt.payloadOverride),
72
+ alterationId: alt.id,
73
+ });
74
+ }
75
+
76
+ // Orphan reschedules: alteration's occurrenceKey isn't in the base list
77
+ // (because the original fell outside the window) but the rescheduled time
78
+ // is. Cancels for orphans are no-ops — there was nothing to cancel here.
79
+ for (const alt of altByKey.values()) {
80
+ if (seenKeys.has(alt.occurrenceKey)) continue;
81
+ if (alt.kind !== "reschedule") continue;
82
+ if (alt.startTimestamp == null) continue;
83
+ // No original to inherit duration from; default to 1ms if no timeSpan.
84
+ const end =
85
+ alt.timeSpan != null
86
+ ? computeSpanEnd(alt.startTimestamp, alt.timeSpan, tz)
87
+ : alt.startTimestamp + 1;
88
+ if (!intersects(alt.startTimestamp, end, windowStart, windowEnd)) continue;
89
+ out.push({
90
+ templateId,
91
+ occurrenceKey: alt.occurrenceKey,
92
+ startTimestamp: alt.startTimestamp,
93
+ endTimestamp: end,
94
+ payload: _mergePayload(basePayload, alt.payloadOverride),
95
+ alterationId: alt.id,
96
+ });
97
+ }
98
+
99
+ return out.sort((a, b) => a.startTimestamp - b.startTimestamp);
100
+ }
101
+
102
+ function _mergePayload<TPayload extends { type: string }>(
103
+ base: TPayload,
104
+ override: Partial<TPayload> | undefined
105
+ ): TPayload {
106
+ if (override == null) return base;
107
+ return { ...base, ...override } as TPayload;
108
+ }