@forcecalendar/core 2.4.0 → 2.5.1

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.
@@ -216,10 +216,26 @@ export class RRuleParser {
216
216
  }
217
217
 
218
218
  /**
219
- * Validate and normalize rule
219
+ * Validate and normalize a rule.
220
+ *
221
+ * Works on a copy: rule objects handed in are typically the stored
222
+ * recurrenceRule of an Event, and normalising them in place would make
223
+ * the stored event differ from the data it was created from (a spurious
224
+ * update on the next reconcile) and let the engines' per-rule caches leak
225
+ * into it. The copy is shallow except for the array fields, which are
226
+ * copied as well.
227
+ * @param {Object} rule - Rule object (not modified)
228
+ * @returns {Object} Normalised copy
220
229
  * @private
221
230
  */
222
231
  static validateRule(rule) {
232
+ rule = { ...rule };
233
+ for (const field of ['byDay', 'bySetPos', 'exceptions']) {
234
+ if (Array.isArray(rule[field])) {
235
+ rule[field] = [...rule[field]];
236
+ }
237
+ }
238
+
223
239
  // Ensure frequency is set
224
240
  if (!rule.freq) {
225
241
  rule.freq = 'DAILY';
@@ -230,8 +246,8 @@ export class RRuleParser {
230
246
  throw new Error('RRULE cannot have both COUNT and UNTIL');
231
247
  }
232
248
 
233
- // Validate interval
234
- if (rule.interval < 1) {
249
+ // Validate interval (RFC 5545 default is 1; rule objects may omit it)
250
+ if (!Number.isInteger(rule.interval) || rule.interval < 1) {
235
251
  rule.interval = 1;
236
252
  }
237
253
 
@@ -4,6 +4,11 @@ import { RRuleParser } from './RRuleParser.js';
4
4
 
5
5
  const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
6
6
 
7
+ // Timezone databases know only local mean time before the 19th century, so
8
+ // a span that ends before this instant is checked at its ends rather than
9
+ // probed week by week
10
+ const PRE_TZDATA_FLOOR_MS = Date.UTC(1800, 0, 1);
11
+
7
12
  /**
8
13
  * RecurrenceEngine - Handles expansion of recurring events
9
14
  * Full support for RFC 5545 (iCalendar) RRULE specification
@@ -92,6 +97,335 @@ export class RecurrenceEngine {
92
97
  return occurrences;
93
98
  }
94
99
 
100
+ /**
101
+ * Lazily iterate the occurrences of an event in chronological order.
102
+ *
103
+ * Yields the same occurrence objects, in the same order, that expandEvent
104
+ * returns for the window — but one at a time, so a caller can stop after
105
+ * any number of them without the rest of the series being generated.
106
+ * Daily, weekly and sub-daily rules are seeked to `after` arithmetically,
107
+ * so finding the first occurrence after a far-away instant does not step
108
+ * through the series from its start.
109
+ *
110
+ * Both bounds are exclusive unless `inclusive` is set: an occurrence that
111
+ * starts exactly at `after` or `before` is skipped by default, which lets
112
+ * `iterateOccurrences(event, { after: previous.start })` continue a series
113
+ * without repeating `previous`. With `inclusive: true` the window is
114
+ * closed on both ends, exactly like expandEvent's range. An omitted bound
115
+ * leaves that end of the window open.
116
+ *
117
+ * COUNT, UNTIL, INTERVAL, BYDAY/BYMONTHDAY/BYSETPOS and exception dates
118
+ * are honoured as in expandEvent; BYSETPOS rules are yielded one period at
119
+ * a time, since the set positions of a period are only known once it is
120
+ * complete. A non-recurring event yields its single occurrence when it
121
+ * falls inside the window. Iteration ends at COUNT or UNTIL, at `before`,
122
+ * or — as a guard for rules that produce no occurrences — after
123
+ * MAX_ITERATIONS_HARD_LIMIT consecutive steps without one.
124
+ *
125
+ * The generator is single-use; call this method again for a fresh one.
126
+ *
127
+ * @example
128
+ * for (const occurrence of RecurrenceEngine.iterateOccurrences(event, { after: new Date() })) {
129
+ * if (occurrence.start > deadline) break;
130
+ * schedule(occurrence);
131
+ * }
132
+ *
133
+ * @param {import('./Event.js').Event} event - The event to iterate
134
+ * @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Window and timezone
135
+ * @returns {Generator<import('../types.js').EventOccurrence, void, undefined>} Occurrences in chronological order
136
+ * @throws {TypeError} If `after` or `before` is not a valid Date or timestamp
137
+ */
138
+ static iterateOccurrences(event, options = {}) {
139
+ const window = this._occurrenceWindow(options);
140
+ if (!event.recurring || !event.recurrenceRule) {
141
+ return this._iterateSingle(
142
+ { start: event.start, end: event.end, timezone: event.timeZone },
143
+ window
144
+ );
145
+ }
146
+
147
+ const rule = this._getParsedRule(event.recurrenceRule);
148
+ let rangeEndMs = window.endMs;
149
+ // Same UNTIL clamp as expandEvent (a non-Date UNTIL compares false)
150
+ if (rule.until && rule.until < rangeEndMs) {
151
+ rangeEndMs = rule.until.valueOf();
152
+ }
153
+
154
+ let occurrences = this._iterateRule(
155
+ event,
156
+ rule,
157
+ window.startMs,
158
+ rangeEndMs,
159
+ options.timezone || event.timeZone || 'UTC',
160
+ TimezoneManager.getInstance(),
161
+ event.end - event.start
162
+ );
163
+ if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
164
+ occurrences = this._iterateBySetPos(occurrences, rule);
165
+ }
166
+ return occurrences;
167
+ }
168
+
169
+ /**
170
+ * First occurrence of an event after an instant, or null when the series
171
+ * has no occurrence after it (past COUNT or UNTIL, or a non-recurring
172
+ * event that already started).
173
+ *
174
+ * `after` is exclusive unless `options.inclusive` is set, so passing the
175
+ * start of a known occurrence returns the one that follows it. Not to be
176
+ * confused with getNextOccurrence, which steps a parsed rule once
177
+ * without regard to COUNT, UNTIL or exceptions.
178
+ *
179
+ * @example
180
+ * const upcoming = RecurrenceEngine.nextOccurrence(event, new Date());
181
+ *
182
+ * @param {import('./Event.js').Event} event - The event to query
183
+ * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
184
+ * @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Further window options
185
+ * @returns {import('../types.js').EventOccurrence|null} The next occurrence, or null
186
+ */
187
+ static nextOccurrence(event, after = null, options = {}) {
188
+ for (const occurrence of this.iterateOccurrences(event, { ...options, after })) {
189
+ return occurrence;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * The first `count` occurrences of an event inside a window, generated
196
+ * lazily so an open-ended series costs only the occurrences taken.
197
+ * `count` is capped at MAX_OCCURRENCES_HARD_LIMIT; fewer are returned
198
+ * when the series or the window ends first.
199
+ *
200
+ * @example
201
+ * const nextFive = RecurrenceEngine.takeOccurrences(event, 5, { after: new Date() });
202
+ *
203
+ * @param {import('./Event.js').Event} event - The event to query
204
+ * @param {number} count - Maximum number of occurrences to return (fractions are floored)
205
+ * @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Window and timezone
206
+ * @returns {import('../types.js').EventOccurrence[]} Up to `count` occurrences in chronological order
207
+ */
208
+ static takeOccurrences(event, count, options = {}) {
209
+ const limit = Math.floor(Math.min(count, RecurrenceEngine.MAX_OCCURRENCES_HARD_LIMIT));
210
+ const taken = [];
211
+ if (!(limit > 0)) {
212
+ return taken;
213
+ }
214
+ for (const occurrence of this.iterateOccurrences(event, options)) {
215
+ taken.push(occurrence);
216
+ if (taken.length >= limit) {
217
+ break;
218
+ }
219
+ }
220
+ return taken;
221
+ }
222
+
223
+ /**
224
+ * Resolve iterator options into a closed window on numeric timestamps.
225
+ * Exclusive bounds are shifted by one millisecond, the resolution of
226
+ * Date, so the expansion loops only ever compare inclusively.
227
+ * @param {import('../types.js').OccurrenceIteratorOptions} options - Iterator options
228
+ * @returns {{ startMs: number, endMs: number }} Inclusive bounds (infinite when open)
229
+ * @throws {TypeError} If a bound is not a valid Date or timestamp
230
+ * @private
231
+ */
232
+ static _occurrenceWindow(options) {
233
+ const { after = null, before = null, inclusive = false } = options;
234
+ const shift = inclusive ? 0 : 1;
235
+ return {
236
+ startMs: after == null ? -Infinity : this._boundMs(after, 'after') + shift,
237
+ endMs: before == null ? Infinity : this._boundMs(before, 'before') - shift
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Timestamp of a window bound given as a Date or a number
243
+ * @param {Date|number} value - Bound to convert
244
+ * @param {string} name - Option name for the error message
245
+ * @returns {number} Timestamp in milliseconds
246
+ * @throws {TypeError} If the value is not a valid Date or timestamp
247
+ * @private
248
+ */
249
+ static _boundMs(value, name) {
250
+ const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : NaN;
251
+ if (Number.isNaN(ms)) {
252
+ throw new TypeError(`RecurrenceEngine: ${name} must be a valid Date or timestamp`);
253
+ }
254
+ return ms;
255
+ }
256
+
257
+ /**
258
+ * Yield a single occurrence if it starts inside the window
259
+ * @param {import('../types.js').EventOccurrence} occurrence - The occurrence
260
+ * @param {{ startMs: number, endMs: number }} window - Inclusive bounds
261
+ * @returns {Generator<import('../types.js').EventOccurrence, void, undefined>}
262
+ * @private
263
+ */
264
+ static *_iterateSingle(occurrence, window) {
265
+ const ms = new Date(occurrence.start).getTime();
266
+ if (ms >= window.startMs && ms <= window.endMs) {
267
+ yield occurrence;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Lazy counterpart of the expansion loops: seeks to the window, then
273
+ * advances a Date cursor per step and yields each in-window occurrence,
274
+ * applying the same DST adjustment and exception filtering.
275
+ * @private
276
+ */
277
+ static *_iterateRule(event, rule, rangeStartMs, rangeEndMs, eventTimezone, tzManager, duration) {
278
+ const currentDate = new Date(event.start);
279
+ let currentMs = currentDate.getTime();
280
+ if (Number.isNaN(currentMs) || rangeStartMs > rangeEndMs) {
281
+ return;
282
+ }
283
+ // Steps taken from DTSTART, which is what COUNT measures
284
+ let count = 0;
285
+ let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
286
+ const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
287
+
288
+ if (currentMs < rangeStartMs) {
289
+ const seek = this._seekToWindow(currentMs, currentDate.getDay(), rule, rangeStartMs);
290
+ if (seek) {
291
+ currentMs = seek.ms;
292
+ count = seek.steps;
293
+ currentDate.setTime(currentMs);
294
+ }
295
+ }
296
+
297
+ let stuckCount = 0;
298
+ let idleSteps = 0;
299
+ while (currentMs <= rangeEndMs) {
300
+ if (currentMs >= rangeStartMs) {
301
+ const occurrenceStart = new Date(currentMs);
302
+ const occurrenceEnd = new Date(currentMs + duration);
303
+
304
+ const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
305
+ if (currentOffset !== lastOffset) {
306
+ const offsetDiff = lastOffset - currentOffset;
307
+ occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
308
+ occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
309
+ }
310
+ lastOffset = currentOffset;
311
+
312
+ if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
313
+ idleSteps = 0;
314
+ yield {
315
+ start: occurrenceStart,
316
+ end: occurrenceEnd,
317
+ recurringEventId: event.id,
318
+ timezone: eventTimezone,
319
+ originalStart: event.start
320
+ };
321
+ }
322
+ }
323
+
324
+ if (rule.count && count + 1 >= rule.count) {
325
+ return;
326
+ }
327
+
328
+ this._advanceInPlace(currentDate, rule);
329
+ const previousMs = currentMs;
330
+ currentMs = currentDate.getTime();
331
+ count++;
332
+
333
+ if (currentMs === previousMs) {
334
+ stuckCount++;
335
+ if (stuckCount >= 3) {
336
+ console.warn('RecurrenceEngine: Date not advancing, breaking to prevent infinite loop');
337
+ return;
338
+ }
339
+ } else {
340
+ stuckCount = 0;
341
+ }
342
+
343
+ idleSteps++;
344
+ if (idleSteps >= RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT) {
345
+ return;
346
+ }
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Seek the iteration cursor to the last occurrence before rangeStartMs
352
+ * using the same arithmetic as expandEvent. The system-transition scan
353
+ * is bounded by the target itself, so an open-ended window costs no
354
+ * more than a closed one.
355
+ * @param {number} fromMs - Cursor position (DTSTART)
356
+ * @param {number} weekday - Weekday of the cursor
357
+ * @param {Object} rule - Parsed recurrence rule
358
+ * @param {number} rangeStartMs - Seek target
359
+ * @returns {{ ms: number, steps: number }|null} New cursor, or null for rules that cannot seek
360
+ * @private
361
+ */
362
+ static _seekToWindow(fromMs, weekday, rule, rangeStartMs) {
363
+ const maxSteps = rule.count ? rule.count - 1 : Infinity;
364
+ if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
365
+ const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
366
+ if (daySet.size === 0) {
367
+ return null;
368
+ }
369
+ rule._byDayDeltas = rule._byDayDeltas || this._buildByDayDeltas(daySet);
370
+ return this._seekWeekCycle(fromMs, weekday, rangeStartMs, rangeStartMs, rule, maxSteps);
371
+ }
372
+ const stepMs = this._seekStepMs(rule);
373
+ if (stepMs <= 0) {
374
+ return null;
375
+ }
376
+ return this._seekFixedStep(fromMs, rangeStartMs, rangeStartMs, stepMs, maxSteps, cursor =>
377
+ this._advanceInPlace(cursor, rule)
378
+ );
379
+ }
380
+
381
+ /**
382
+ * Milliseconds per _advanceInPlace step for every rule whose step is a
383
+ * fixed duration between system-timezone transitions: the sub-daily
384
+ * frequencies, DAILY and plain WEEKLY
385
+ * @param {Object} rule - Parsed recurrence rule
386
+ * @returns {number} Step length in milliseconds, or 0 when not fixed
387
+ * @private
388
+ */
389
+ static _seekStepMs(rule) {
390
+ const interval = rule.interval;
391
+ if (!Number.isInteger(interval) || interval <= 0) {
392
+ return 0;
393
+ }
394
+ if (rule.freq === 'DAILY') {
395
+ return interval * 86400000;
396
+ }
397
+ if (rule.freq === 'WEEKLY') {
398
+ return 7 * interval * 86400000;
399
+ }
400
+ return this._fixedStepMs(rule);
401
+ }
402
+
403
+ /**
404
+ * Streaming BYSETPOS filter: buffers the occurrences of one period and
405
+ * yields its selected positions once the next period begins. Periods
406
+ * arrive contiguously and in order, so the result matches _applyBySetPos.
407
+ * @param {Iterable<import('../types.js').EventOccurrence>} source - Occurrences in order
408
+ * @param {Object} rule - Rule with bySetPos
409
+ * @returns {Generator<import('../types.js').EventOccurrence, void, undefined>}
410
+ * @private
411
+ */
412
+ static *_iterateBySetPos(source, rule) {
413
+ let key = null;
414
+ let group = [];
415
+ for (const occurrence of source) {
416
+ const occurrenceKey = this._bySetPosKey(occurrence, rule);
417
+ if (occurrenceKey !== key && group.length > 0) {
418
+ yield* this._selectBySetPos(group, rule).sort((a, b) => a.start - b.start);
419
+ group = [];
420
+ }
421
+ key = occurrenceKey;
422
+ group.push(occurrence);
423
+ }
424
+ if (group.length > 0) {
425
+ yield* this._selectBySetPos(group, rule).sort((a, b) => a.start - b.start);
426
+ }
427
+ }
428
+
95
429
  /**
96
430
  * General expansion loop: advances a Date cursor per step. Handles every
97
431
  * frequency and degenerate rules (non-advancing dates, invalid intervals).
@@ -493,7 +827,9 @@ export class RecurrenceEngine {
493
827
 
494
828
  /**
495
829
  * Find the next system-timezone offset transition after fromMs.
496
- * Cached module-wide: the system timezone is fixed for the process.
830
+ * Cached module-wide: the system timezone is fixed for the process. The
831
+ * cache grows incrementally, so extending coverage (a view navigating
832
+ * forward, a series with a far-past DTSTART) scans only the new span.
497
833
  * @param {number} fromMs - Search from this timestamp (exclusive)
498
834
  * @param {number} toMs - Extend cache coverage at least this far
499
835
  * @returns {number} Transition timestamp, or Infinity if none within coverage
@@ -504,18 +840,34 @@ export class RecurrenceEngine {
504
840
  return Infinity;
505
841
  }
506
842
  let cache = this._systemTransitions;
507
- if (!cache || fromMs < cache.from || toMs > cache.to) {
508
- const from = Math.min(fromMs, cache ? cache.from : fromMs);
509
- const to = Math.max(toMs, cache ? cache.to : toMs);
510
- cache = { from, to, transitions: this._scanSystemTransitions(from, to) };
843
+ if (!cache) {
844
+ cache = { from: fromMs, to: toMs, transitions: this._scanSystemTransitions(fromMs, toMs) };
511
845
  this._systemTransitions = cache;
846
+ } else {
847
+ if (fromMs < cache.from) {
848
+ cache.transitions = this._scanSystemTransitions(fromMs, cache.from).concat(
849
+ cache.transitions
850
+ );
851
+ cache.from = fromMs;
852
+ }
853
+ if (toMs > cache.to) {
854
+ cache.transitions = cache.transitions.concat(this._scanSystemTransitions(cache.to, toMs));
855
+ cache.to = toMs;
856
+ }
512
857
  }
513
- for (const t of cache.transitions) {
514
- if (t > fromMs) {
515
- return t;
858
+ // First transition after fromMs; the list is sorted
859
+ const transitions = cache.transitions;
860
+ let lo = 0;
861
+ let hi = transitions.length;
862
+ while (lo < hi) {
863
+ const mid = (lo + hi) >>> 1;
864
+ if (transitions[mid] > fromMs) {
865
+ hi = mid;
866
+ } else {
867
+ lo = mid + 1;
516
868
  }
517
869
  }
518
- return Infinity;
870
+ return lo < transitions.length ? transitions[lo] : Infinity;
519
871
  }
520
872
 
521
873
  /**
@@ -529,6 +881,12 @@ export class RecurrenceEngine {
529
881
  const transitions = [];
530
882
  let lo = fromMs;
531
883
  let loOffset = new Date(lo).getTimezoneOffset();
884
+ // The pre-tzdata era has a single offset (local mean time) and is
885
+ // skipped in one step; probing it week by week could take minutes
886
+ if (lo < PRE_TZDATA_FLOOR_MS) {
887
+ lo = Math.min(toMs, PRE_TZDATA_FLOOR_MS);
888
+ loOffset = new Date(lo).getTimezoneOffset();
889
+ }
532
890
  while (lo < toMs) {
533
891
  const hi = Math.min(lo + WEEK, toMs);
534
892
  const hiOffset = new Date(hi).getTimezoneOffset();
@@ -564,17 +922,7 @@ export class RecurrenceEngine {
564
922
  // Group occurrences by period
565
923
  const groups = new Map();
566
924
  for (const occ of occurrences) {
567
- let key;
568
- switch (rule.freq) {
569
- case 'YEARLY':
570
- key = occ.start.getFullYear();
571
- break;
572
- case 'WEEKLY':
573
- key = `${occ.start.getFullYear()}-W${DateUtils.getWeekNumber(occ.start)}`;
574
- break;
575
- default:
576
- key = `${occ.start.getFullYear()}-${occ.start.getMonth()}`;
577
- }
925
+ const key = this._bySetPosKey(occ, rule);
578
926
  if (!groups.has(key)) groups.set(key, []);
579
927
  groups.get(key).push(occ);
580
928
  }
@@ -582,17 +930,55 @@ export class RecurrenceEngine {
582
930
  // Filter each group by BYSETPOS positions
583
931
  const filtered = [];
584
932
  for (const group of groups.values()) {
585
- for (const pos of rule.bySetPos) {
586
- const idx = pos > 0 ? pos - 1 : group.length + pos;
587
- if (idx >= 0 && idx < group.length) {
588
- filtered.push(group[idx]);
589
- }
590
- }
933
+ filtered.push(...this._selectBySetPos(group, rule));
591
934
  }
592
935
 
593
936
  return filtered.sort((a, b) => a.start - b.start);
594
937
  }
595
938
 
939
+ /**
940
+ * Key of the BYSETPOS period an occurrence belongs to
941
+ * @param {import('../types.js').EventOccurrence} occurrence - The occurrence
942
+ * @param {Object} rule - Recurrence rule
943
+ * @returns {string|number} Period key
944
+ * @private
945
+ */
946
+ static _bySetPosKey(occurrence, rule) {
947
+ switch (rule.freq) {
948
+ case 'YEARLY':
949
+ return occurrence.start.getFullYear();
950
+ case 'WEEKLY': {
951
+ // ISO week-year: the week's Thursday decides the year, so the days
952
+ // of a week that straddles New Year share one period key
953
+ const thursday = new Date(occurrence.start);
954
+ thursday.setHours(0, 0, 0, 0);
955
+ thursday.setDate(thursday.getDate() + 4 - (thursday.getDay() || 7));
956
+ return `${thursday.getFullYear()}-W${DateUtils.getWeekNumber(occurrence.start)}`;
957
+ }
958
+ default:
959
+ return `${occurrence.start.getFullYear()}-${occurrence.start.getMonth()}`;
960
+ }
961
+ }
962
+
963
+ /**
964
+ * Occurrences of one period selected by the rule's BYSETPOS positions,
965
+ * in BYSETPOS order
966
+ * @param {Array} group - Occurrences of a single period, in order
967
+ * @param {Object} rule - Rule with bySetPos
968
+ * @returns {Array} Selected occurrences
969
+ * @private
970
+ */
971
+ static _selectBySetPos(group, rule) {
972
+ const selected = [];
973
+ for (const pos of rule.bySetPos) {
974
+ const idx = pos > 0 ? pos - 1 : group.length + pos;
975
+ if (idx >= 0 && idx < group.length) {
976
+ selected.push(group[idx]);
977
+ }
978
+ }
979
+ return selected;
980
+ }
981
+
596
982
  /**
597
983
  * Parse an RRULE string into a rule object
598
984
  * @param {string|import('../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
@@ -686,11 +1072,21 @@ export class RecurrenceEngine {
686
1072
  case 'MONTHLY':
687
1073
  if (rule.byMonthDay && rule.byMonthDay.length > 0) {
688
1074
  // Specific day(s) of month
689
- const currentMonth = next.getMonth();
690
- next.setMonth(currentMonth + rule.interval);
691
- // Clamp to last day of month if day doesn't exist
692
- const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
693
- next.setDate(Math.min(rule.byMonthDay[0], daysInMonth));
1075
+ const monthDay = rule.byMonthDay[0];
1076
+ if (monthDay < 0) {
1077
+ // Counted from the end of the month (-1 is the last day). Move
1078
+ // to the first so the month step cannot overflow from a 31st.
1079
+ next.setDate(1);
1080
+ next.setMonth(next.getMonth() + rule.interval);
1081
+ const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
1082
+ next.setDate(Math.max(1, daysInMonth + monthDay + 1));
1083
+ } else {
1084
+ const currentMonth = next.getMonth();
1085
+ next.setMonth(currentMonth + rule.interval);
1086
+ // Clamp to last day of month if day doesn't exist
1087
+ const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
1088
+ next.setDate(Math.min(monthDay, daysInMonth));
1089
+ }
694
1090
  } else if (rule.byDay && rule.byDay.length > 0) {
695
1091
  // Specific weekday of month (e.g., "2nd Tuesday")
696
1092
  next.setMonth(next.getMonth() + rule.interval);