Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 128 additions & 35 deletions lib/ical/recur_iterator.js
Original file line number Diff line number Diff line change
Expand Up @@ -353,26 +353,29 @@ class RecurIterator {
throw new Error("Malformed values in BYDAY part");
}
} else if (this.has_by_data("BYMONTHDAY")) {
// Change the day value so that normalisation won't change the month.
this.last.day = 1;

// Get a sorted list of days in the starting month that match the rule.
let normalized = this.normalizeByMonthDayRules(
this.last.year,
this.last.month,
this.rule.parts.BYMONTHDAY
).filter(d => d >= this.last.day);

if (normalized.length) {
// There's at least one valid day, use it.
this.last.day = normalized[0];
this.by_data.BYMONTHDAY = normalized;
if (this.has_by_data("BYSETPOS")) {
this._initMonthlyByMonthDaySetPos();
} else {
// There's no occurrence in this month, find the next valid month.
// The longest possible sequence of skipped months is February-April-June,
// so we might need to call next_month up to three times.
if (!this.next_month() && !this.next_month() && !this.next_month()) {
throw new InvalidRecurrenceRuleError();
// Change the day value so that normalisation won't change the month.
this.last.day = 1;
// Get a sorted list of days in the starting month that match the rule.
let normalized = this.normalizeByMonthDayRules(
this.last.year,
this.last.month,
this.rule.parts.BYMONTHDAY
).filter(d => d >= this.last.day);

if (normalized.length) {
// There's at least one valid day, use it.
this.last.day = normalized[0];
this.by_data.BYMONTHDAY = normalized;
} else {
// There's no occurrence in this month, find the next valid month.
// The longest possible sequence of skipped months is February-April-June,
// so we might need to call next_month up to three times.
if (!this.next_month() && !this.next_month() && !this.next_month()) {
throw new InvalidRecurrenceRuleError();
}
}
}
}
Expand Down Expand Up @@ -802,28 +805,32 @@ class RecurIterator {
}
}
} else if (this.has_by_data("BYMONTHDAY")) {
this.by_indices.BYMONTHDAY++;
if (this.has_by_data("BYSETPOS")) {
data_valid = this._nextMonthlyByMonthDaySetPos();
} else {
Comment on lines +808 to +810

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered cases where we have BYMONTHDAY, BYSETPOS, and other rule parts? I think theoretically a BYDAY could appears as well.

this.by_indices.BYMONTHDAY++;

if (this.by_indices.BYMONTHDAY >= this.by_data.BYMONTHDAY.length) {
this.by_indices.BYMONTHDAY = 0;
this.increment_month();
if (this.by_indices.BYMONTHDAY >= this.by_data.BYMONTHDAY.length) {
return 0;
this.by_indices.BYMONTHDAY = 0;
this.increment_month();
if (this.by_indices.BYMONTHDAY >= this.by_data.BYMONTHDAY.length) {
return 0;
}
}
}

let daysInMonth = Time.daysInMonth(this.last.month, this.last.year);
let day = this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY];
let daysInMonth = Time.daysInMonth(this.last.month, this.last.year);
let day = this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY];

if (day < 0) {
day = daysInMonth + day + 1;
}
if (day < 0) {
day = daysInMonth + day + 1;
}

if (day > daysInMonth) {
this.last.day = 1;
data_valid = this.is_day_in_byday(this.last);
} else {
this.last.day = day;
if (day > daysInMonth) {
this.last.day = 1;
data_valid = this.is_day_in_byday(this.last);
} else {
this.last.day = day;
}
}
} else {
this.increment_month();
Expand Down Expand Up @@ -1359,6 +1366,92 @@ class RecurIterator {
return 0;
}

/**
* Find the first BYMONTHDAY in the current month at a BYSETPOS position
* on or after minDay.
*
* @private
* @param {Number} minDay
* @return {Number|null}
*/
_findByMonthDaySetPos(minDay) {
let monthDays = this.normalizeByMonthDayRules(
this.last.year,
this.last.month,
this.rule.parts.BYMONTHDAY
);

for (let spIndex = 0; spIndex < monthDays.length; spIndex++) {
let day = monthDays[spIndex];
if (day < minDay) {
continue;
}
if (this.check_set_position(spIndex + 1) ||
this.check_set_position(spIndex - monthDays.length)) {
return day;
}
}

return null;
}

/**
* Initialize MONTHLY iteration with BYMONTHDAY and BYSETPOS.
*
* @private
*/
_initMonthlyByMonthDaySetPos() {
let day = this._findByMonthDaySetPos(this.last.day);
let attempts = 0;

while (!day && attempts++ < 48) {
this.last.day = 1;
this.increment_month();
day = this._findByMonthDaySetPos(1);
}

if (!day) {
throw new InvalidRecurrenceRuleError();
}

this.last.day = day;
this.by_data.BYMONTHDAY = this.normalizeByMonthDayRules(
this.last.year,
this.last.month,
this.rule.parts.BYMONTHDAY
);
}

/**
* Advance MONTHLY iteration with BYMONTHDAY and BYSETPOS.
*
* @private
* @return {Number} 1 if a valid day was found, 0 otherwise
*/
_nextMonthlyByMonthDaySetPos() {
let day = this._findByMonthDaySetPos(this.last.day + 1);
let attempts = 0;

while (!day && attempts++ < 48) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a comment on why 48 attempts, here and in the last function?

this.last.day = 1;
this.increment_month();
day = this._findByMonthDaySetPos(1);
}

if (!day) {
return 0;
}

this.last.day = day;
this.by_data.BYMONTHDAY = this.normalizeByMonthDayRules(
this.last.year,
this.last.month,
this.rule.parts.BYMONTHDAY
);

return 1;
}

/**
* Checks if given value is in BYSETPOS.
*
Expand Down
124 changes: 124 additions & 0 deletions test/recur_iterator_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,130 @@ suite('recur_iterator', function() {
"2015-04-19T08:00:00Z"
]
});
// Choose the last valid day among a list of month-end candidates.
// This covers February and months with 30 or 31 days.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1', {
dtStart: '2026-01-01',
dates: [
'2026-01-31',
'2026-02-28',
'2026-03-31',
'2026-04-30',
'2026-05-31',
'2026-06-30',
'2026-07-31',
'2026-08-31',
'2026-09-30',
'2026-10-31',
'2026-11-30',
'2026-12-31',
]
});
// Pick the first occurrence from multiple BYMONTHDAY values.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=1,15;BYSETPOS=1;COUNT=3', {
dtStart: '2016-01-01',
byCount: true,
dates: [
'2016-01-01',
'2016-02-01',
'2016-03-01',
]
});
// Pick the second occurrence from the same BYMONTHDAY list.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=1,15;BYSETPOS=2;COUNT=3', {
dtStart: '2016-01-01',
byCount: true,
dates: [
'2016-01-15',
'2016-02-15',
'2016-03-15',
]
});
// Pick the last occurrence from the same BYMONTHDAY list.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=1,15;BYSETPOS=-1;COUNT=3', {
dtStart: '2016-01-01',
byCount: true,
dates: [
'2016-01-15',
'2016-02-15',
'2016-03-15',
]
});
// Pick multiple positions from the same sorted list, verifying order.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=1,15;BYSETPOS=1,2;COUNT=4', {
dtStart: '2016-01-01',
byCount: true,
dates: [
'2016-01-01',
'2016-01-15',
'2016-02-01',
'2016-02-15',
]
});
// Combine negative BYMONTHDAY normalization with BYSETPOS selection.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=-1;BYSETPOS=1;COUNT=3', {
dtStart: '2016-01-01',
byCount: true,
dates: [
'2016-01-31',
'2016-02-29',
'2016-03-31',
]
});
// Mixed positive and negative BYMONTHDAY values with BYSETPOS=-1.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=-1,31;BYSETPOS=-1;COUNT=4', {
dtStart: '2016-01-01',
byCount: true,
dates: [
'2016-01-31',
'2016-02-29',
'2016-03-31',
'2016-04-30',
]
});
// Start mid-month and pick the second BYMONTHDAY occurrence each month.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=5,15,25;BYSETPOS=2;COUNT=4', {
dtStart: '2016-01-16',
byCount: true,
dates: [
'2016-02-15',
'2016-03-15',
'2016-04-15',
'2016-05-15',
]
});
// Select the last valid candidate from a list that spans both 30- and 31-day months.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=31,30;BYSETPOS=-1;COUNT=4', {
dtStart: '2016-01-01',
byCount: true,
dates: [
'2016-01-31',
'2016-03-31',
'2016-04-30',
'2016-05-31',
]
});
// 31st with BYSETPOS=1 across a long UNTIL range, ensuring months without
// a 31st are skipped and the recurrence continues correctly.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=31;BYSETPOS=1;UNTIL=20161231', {
dtStart: '2016-01-01',
until: true,
dates: [
'2016-01-31',
'2016-03-31',
'2016-05-31',
'2016-07-31',
'2016-08-31',
'2016-10-31',
'2016-12-31',
]
});
// BYSETPOS is too large for the list of matching BYMONTHDAY values,
// so the rule produces no occurrences.
testRRULE('FREQ=MONTHLY;BYMONTHDAY=1,15;BYSETPOS=3', {
dtStart: '2016-01-01',
noInstance: true
});
});

suite('YEARLY', function() {
Expand Down
Loading