rrule-temporal
Version:
Recurrence rule processing using Temporal PlainDate/PlainDateTime
1,213 lines (1,211 loc) • 47.1 kB
JavaScript
// src/index.ts
import { Temporal } from "@js-temporal/polyfill";
function unfoldLine(foldedLine) {
return foldedLine.replace(/\r?\n[ \t]/g, "");
}
function parseDateValues(dateValues, tzid) {
const dates = [];
for (const dateValue of dateValues) {
if (/Z$/.test(dateValue)) {
const iso = `${dateValue.slice(0, 4)}-${dateValue.slice(4, 6)}-${dateValue.slice(6, 8)}T${dateValue.slice(9, 15)}Z`;
dates.push(Temporal.Instant.from(iso).toZonedDateTimeISO(
tzid || "UTC"
));
} else {
const isoDate = `${dateValue.slice(0, 4)}-${dateValue.slice(4, 6)}-${dateValue.slice(6, 8)}T${dateValue.slice(9)}`;
dates.push(Temporal.PlainDateTime.from(isoDate).toZonedDateTime(tzid));
}
}
return dates;
}
function parseRRuleString(input, fallbackDtstart) {
const unfoldedInput = unfoldLine(input);
let dtstart;
let tzid = "UTC";
let rruleLine;
let exDate = [];
let rDate = [];
if (/^DTSTART/mi.test(unfoldedInput)) {
const lines = unfoldedInput.split(/\r?\n/);
const dtLine = lines[0];
const rrLine = lines.find((line) => line.match(/^RRULE:/i));
const exLines = lines.filter((line) => line.match(/^EXDATE/i));
const rLines = lines.filter((line) => line.match(/^RDATE/i));
const m = dtLine?.match(/DTSTART(?:;TZID=([^:]+))?:(\d{8}T\d{6})/i);
if (!m) throw new Error("Invalid DTSTART in ICS snippet");
tzid = m[1] || tzid;
const isoDate = `${m[2].slice(0, 4)}-${m[2].slice(4, 6)}-${m[2].slice(6, 8)}T${m[2].slice(9)}`;
dtstart = Temporal.PlainDateTime.from(isoDate).toZonedDateTime(tzid);
rruleLine = rrLine;
for (const exLine of exLines) {
const exMatch = exLine.match(/EXDATE(?:;TZID=([^:]+))?:(.+)/i);
if (exMatch) {
const exTzid = exMatch[1] || tzid;
const dateValues = exMatch[2].split(",");
exDate.push(...parseDateValues(dateValues, exTzid));
}
}
for (const rLine of rLines) {
const rMatch = rLine.match(/RDATE(?:;TZID=([^:]+))?:(.+)/i);
if (rMatch) {
const rTzid = rMatch[1] || tzid;
const dateValues = rMatch[2].split(",");
rDate.push(...parseDateValues(dateValues, rTzid));
}
}
} else {
if (!fallbackDtstart)
throw new Error("dtstart required when parsing RRULE alone");
dtstart = fallbackDtstart;
tzid = fallbackDtstart.timeZoneId;
rruleLine = unfoldedInput.replace(/^RRULE:/i, "RRULE:");
}
const parts = rruleLine ? rruleLine.replace(/^RRULE:/i, "").split(";") : [];
const opts = {
dtstart,
tzid,
exDate: exDate.length > 0 ? exDate : void 0,
rDate: rDate.length > 0 ? rDate : void 0
};
for (const part of parts) {
const [key, val] = part.split("=");
if (!key) continue;
switch (key.toUpperCase()) {
case "FREQ":
opts.freq = val.toUpperCase();
break;
case "INTERVAL":
opts.interval = parseInt(val, 10);
break;
case "COUNT":
opts.count = parseInt(val, 10);
break;
case "UNTIL": {
if (/Z$/.test(val)) {
const iso = `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}T${val.slice(9, 15)}Z`;
opts.until = Temporal.Instant.from(iso).toZonedDateTimeISO(
tzid || "UTC"
);
} else {
const iso = `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}T${val.slice(9, 15)}`;
opts.until = Temporal.PlainDateTime.from(iso).toZonedDateTime(
tzid || "UTC"
);
}
break;
}
case "BYHOUR":
opts.byHour = val.split(",").map((n) => parseInt(n, 10)).sort((a, b) => a - b);
break;
case "BYMINUTE":
opts.byMinute = val.split(",").map((n) => parseInt(n, 10)).sort((a, b) => a - b);
break;
case "BYDAY":
opts.byDay = val.split(",");
break;
case "BYMONTH":
opts.byMonth = val.split(",").map((n) => parseInt(n, 10));
break;
case "BYMONTHDAY":
opts.byMonthDay = val.split(",").map((n) => parseInt(n, 10));
break;
case "BYSECOND":
opts.bySecond = val.split(",").map((n) => parseInt(n, 10)).sort((a, b) => a - b);
break;
case "BYYEARDAY":
opts.byYearDay = val.split(",").map((n) => parseInt(n, 10));
break;
case "BYWEEKNO":
opts.byWeekNo = val.split(",").map((n) => parseInt(n, 10));
break;
case "BYSETPOS":
opts.bySetPos = val.split(",").map((n) => parseInt(n, 10));
break;
case "WKST":
opts.wkst = val;
break;
}
}
return opts;
}
var RRuleTemporal = class {
tzid;
originalDtstart;
opts;
maxIterations;
includeDtstart;
constructor(params) {
let manual;
if ("rruleString" in params) {
manual = { ...params, ...parseRRuleString(params.rruleString) };
this.tzid = manual.tzid || "UTC";
this.originalDtstart = manual.dtstart;
} else {
manual = { ...params };
if (typeof manual.dtstart === "string") {
throw new Error("Manual dtstart must be a ZonedDateTime");
}
manual.tzid = manual.tzid || manual.dtstart.timeZoneId;
this.tzid = manual.tzid;
this.originalDtstart = manual.dtstart;
}
if (!manual.freq) throw new Error("RRULE must include FREQ");
manual.interval = manual.interval ?? 1;
if (manual.interval <= 0) {
throw new Error("Cannot create RRule: interval must be greater than 0");
}
if (manual.until && !(manual.until instanceof Temporal.ZonedDateTime)) {
throw new Error("Manual until must be a ZonedDateTime");
}
this.opts = this.sanitizeOpts(manual);
this.maxIterations = manual.maxIterations ?? 1e4;
this.includeDtstart = manual.includeDtstart ?? false;
}
sanitizeOpts(opts) {
const validDay = /^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$/;
if (opts.byDay) {
opts.byDay = opts.byDay.filter((d) => validDay.test(d));
if (opts.byDay.length === 0) delete opts.byDay;
}
if (opts.byMonth) {
opts.byMonth = opts.byMonth.filter((n) => Number.isInteger(n) && n >= 1 && n <= 12);
if (opts.byMonth.length === 0) delete opts.byMonth;
}
if (opts.byMonthDay) {
opts.byMonthDay = opts.byMonthDay.filter((n) => Number.isInteger(n) && n !== 0 && n >= -31 && n <= 31);
if (opts.byMonthDay.length === 0) delete opts.byMonthDay;
}
if (opts.byYearDay) {
opts.byYearDay = opts.byYearDay.filter((n) => Number.isInteger(n) && n !== 0 && n >= -366 && n <= 366);
if (opts.byYearDay.length === 0) delete opts.byYearDay;
}
if (opts.byWeekNo) {
opts.byWeekNo = opts.byWeekNo.filter((n) => Number.isInteger(n) && n !== 0 && n >= -53 && n <= 53);
if (opts.byWeekNo.length === 0) delete opts.byWeekNo;
}
if (opts.byHour) {
opts.byHour = opts.byHour.filter((n) => Number.isInteger(n) && n >= 0 && n <= 23).sort((a, b) => a - b);
if (opts.byHour.length === 0) delete opts.byHour;
}
if (opts.byMinute) {
opts.byMinute = opts.byMinute.filter((n) => Number.isInteger(n) && n >= 0 && n <= 59).sort((a, b) => a - b);
if (opts.byMinute.length === 0) delete opts.byMinute;
}
if (opts.bySecond) {
opts.bySecond = opts.bySecond.filter((n) => Number.isInteger(n) && n >= 0 && n <= 59).sort((a, b) => a - b);
if (opts.bySecond.length === 0) delete opts.bySecond;
}
if (opts.bySetPos) {
opts.bySetPos = opts.bySetPos.filter((n) => Number.isInteger(n) && n !== 0);
if (opts.bySetPos.length === 0) delete opts.bySetPos;
}
return opts;
}
rawAdvance(zdt) {
const { freq, interval } = this.opts;
switch (freq) {
case "DAILY":
return zdt.add({ days: interval });
case "WEEKLY":
return zdt.add({ weeks: interval });
case "MONTHLY":
return zdt.add({ months: interval });
case "YEARLY":
return zdt.add({ years: interval });
case "HOURLY":
return zdt.add({ hours: interval });
case "MINUTELY":
return zdt.add({ minutes: interval });
case "SECONDLY":
return zdt.add({ seconds: interval });
default:
throw new Error(`Unsupported FREQ: ${freq}`);
}
}
/** Expand one base ZonedDateTime into all BYHOUR × BYMINUTE × BYSECOND
* combinations, keeping chronological order. If the options are not
* present the original date is returned unchanged.
*/
expandByTime(base) {
const hours = this.opts.byHour ?? [base.hour];
const minutes = this.opts.byMinute ?? [base.minute];
const seconds = this.opts.bySecond ?? [base.second];
const out = [];
for (const h of hours) {
for (const m of minutes) {
for (const s of seconds) {
out.push(base.with({ hour: h, minute: m, second: s }));
}
}
}
return out.sort((a, b) => Temporal.ZonedDateTime.compare(a, b));
}
nextCandidateSameDate(zdt) {
const { freq, interval = 1, byHour, byMinute, bySecond } = this.opts;
if (freq === "HOURLY" && byHour && byHour.length === 1) {
return this.applyTimeOverride(zdt.add({ days: interval }));
}
if (freq === "MINUTELY" && byMinute && byMinute.length === 1) {
return this.applyTimeOverride(zdt.add({ hours: interval }));
}
if (freq === "MINUTELY" && byHour && byHour.length > 1 && !byMinute && interval > 1) {
const next = zdt.add({ minutes: interval });
if (byHour.includes(next.hour)) {
return next;
}
const nextHour = byHour.find((h) => h > zdt.hour) || byHour[0];
if (nextHour && nextHour > zdt.hour) {
return zdt.with({ hour: nextHour, minute: 0 });
}
return this.applyTimeOverride(zdt.add({ days: 1 }));
}
if (freq === "SECONDLY" && bySecond && bySecond.length === 1) {
return this.applyTimeOverride(zdt.add({ minutes: interval })).with({ second: bySecond[0] });
}
if (freq === "SECONDLY" && byMinute && byMinute.length === 1) {
const next = zdt.add({ seconds: interval });
if (next.minute === byMinute[0]) return next;
return this.applyTimeOverride(zdt.add({ hours: interval })).with({ second: 0 });
}
if (bySecond && bySecond.length > 1) {
const idx = bySecond.indexOf(zdt.second);
if (idx !== -1 && idx < bySecond.length - 1) {
return zdt.with({ second: bySecond[idx + 1] });
}
}
if (byMinute && byMinute.length > 1) {
const idx = byMinute.indexOf(zdt.minute);
if (idx !== -1 && idx < byMinute.length - 1) {
return zdt.with({
minute: byMinute[idx + 1],
second: bySecond ? bySecond[0] : zdt.second
});
}
}
if (byHour && byHour.length > 1) {
const idx = byHour.indexOf(zdt.hour);
if (idx !== -1 && idx < byHour.length - 1) {
return zdt.with({
hour: byHour[idx + 1],
minute: byMinute ? byMinute[0] : zdt.minute,
second: bySecond ? bySecond[0] : zdt.second
});
}
}
return this.applyTimeOverride(this.rawAdvance(zdt));
}
applyTimeOverride(zdt) {
const { byHour, byMinute, bySecond } = this.opts;
let dt = zdt;
if (byHour) dt = dt.with({ hour: byHour[0] });
if (byMinute) dt = dt.with({ minute: byMinute[0] });
if (bySecond) dt = dt.with({ second: bySecond[0] });
return dt;
}
computeFirst() {
let zdt = this.originalDtstart;
if (this.opts.byDay?.length) {
const dayMap = {
MO: 1,
TU: 2,
WE: 3,
TH: 4,
FR: 5,
SA: 6,
SU: 7
};
let deltas = [];
if (["DAILY", "HOURLY", "MINUTELY", "SECONDLY"].includes(this.opts.freq) && this.opts.byDay.every((tok) => /^[A-Z]{2}$/.test(tok))) {
deltas = this.opts.byDay.map(
(tok) => (dayMap[tok] - zdt.dayOfWeek + 7) % 7
);
} else {
deltas = this.opts.byDay.map((tok) => {
const wdTok = tok.match(/(MO|TU|WE|TH|FR|SA|SU)$/)?.[1];
return wdTok ? (dayMap[wdTok] - zdt.dayOfWeek + 7) % 7 : null;
}).filter((d) => d !== null);
}
if (deltas.length) {
zdt = zdt.add({ days: Math.min(...deltas) });
}
}
const { byHour, byMinute, bySecond } = this.opts;
if (byHour || byMinute || bySecond) {
zdt = this.applyTimeOverride(zdt);
if (Temporal.Instant.compare(
zdt.toInstant(),
this.originalDtstart.toInstant()
) < 0) {
zdt = this.applyTimeOverride(this.rawAdvance(this.originalDtstart));
}
}
return zdt;
}
// inside class RRuleTemporal:
generateWeeklyOccurrences(sample) {
const dayMap = {
MO: 1,
TU: 2,
WE: 3,
TH: 4,
FR: 5,
SA: 6,
SU: 7
};
const tokens = this.opts.byDay?.length ? [...this.opts.byDay] : [Object.entries(dayMap).find(([, d]) => d === sample.dayOfWeek)[0]];
const occs = tokens.flatMap((tok) => {
const targetDow = dayMap[tok];
const delta = (targetDow - sample.dayOfWeek + 7) % 7;
const sameDate = sample.add({ days: delta });
return this.expandByTime(sameDate);
});
return occs.sort((a, b) => Temporal.ZonedDateTime.compare(a, b));
}
// --- NEW: constraint checks ---
// 2) Replace your matchesByDay with this:
matchesByDay(zdt) {
const { byDay } = this.opts;
if (!byDay) return true;
const dayMap = {
MO: 1,
TU: 2,
WE: 3,
TH: 4,
FR: 5,
SA: 6,
SU: 7
};
for (const token of byDay) {
const m = token.match(/^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$/);
if (!m) continue;
const ord = m[1] ? parseInt(m[1], 10) : 0;
const weekday = m[2];
if (!weekday) continue;
const wd = dayMap[weekday];
if (ord === 0) {
if (zdt.dayOfWeek === wd) return true;
continue;
}
const month = zdt.month;
let dt = zdt.with({ day: 1 });
const candidates = [];
while (dt.month === month) {
if (dt.dayOfWeek === wd) candidates.push(dt.day);
dt = dt.add({ days: 1 });
}
const idx = ord > 0 ? ord - 1 : candidates.length + ord;
if (candidates[idx] === zdt.day) return true;
}
return false;
}
matchesByMonth(zdt) {
const { byMonth } = this.opts;
if (!byMonth) return true;
return byMonth.includes(zdt.month);
}
matchesByMonthDay(zdt) {
const { byMonthDay } = this.opts;
if (!byMonthDay) return true;
const lastDay = zdt.with({ day: 1 }).add({ months: 1 }).subtract({ days: 1 }).day;
return byMonthDay.some(
(d) => d > 0 ? zdt.day === d : zdt.day === lastDay + d + 1
);
}
matchesAll(zdt) {
return this.matchesByDay(zdt) && this.matchesByMonth(zdt) && this.matchesByMonthDay(zdt) && this.matchesByYearDay(zdt) && this.matchesByWeekNo(zdt);
}
matchesByYearDay(zdt) {
const { byYearDay } = this.opts;
if (!byYearDay) return true;
const dayOfYear = zdt.dayOfYear;
const last = zdt.with({ month: 12, day: 31 }).dayOfYear;
return byYearDay.some((d) => d > 0 ? dayOfYear === d : dayOfYear === last + d + 1);
}
matchesByWeekNo(zdt) {
const { byWeekNo, wkst } = this.opts;
if (!byWeekNo) return true;
const dayMap = { MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6, SU: 7 };
const startDow = dayMap[wkst ?? "MO"];
const jan1 = zdt.with({ month: 1, day: 1 });
const delta = (jan1.dayOfWeek - startDow + 7) % 7;
const firstWeekStart = jan1.subtract({ days: delta });
const diffDays = zdt.toPlainDate().since(firstWeekStart.toPlainDate()).days;
const week = Math.floor(diffDays / 7) + 1;
const lastWeekDiff = zdt.with({ month: 12, day: 31 }).toPlainDate().since(firstWeekStart.toPlainDate()).days;
const lastWeek = Math.floor(lastWeekDiff / 7) + 1;
return byWeekNo.some((n) => n > 0 ? week === n : week === lastWeek + n + 1);
}
options() {
return this.opts;
}
/**
* Returns all occurrences of the rule.
* @param iterator - An optional callback iterator function that can be used to filter or modify the occurrences.
* @returns An array of Temporal.ZonedDateTime objects representing all occurrences of the rule.
*/
all(iterator) {
if (!this.opts.count && !this.opts.until && !iterator) {
throw new Error("all() requires iterator when no COUNT/UNTIL");
}
const dates = [];
let iterationCount = 0;
if (this.opts.freq === "MONTHLY" && (this.opts.byDay || this.opts.byMonthDay)) {
const start = this.originalDtstart;
let monthCursor = start.with({ day: 1 });
let matchCount2 = 0;
if (this.includeDtstart && !this.matchesAll(start)) {
if (iterator && !iterator(start, matchCount2)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
dates.push(start);
matchCount2++;
if (this.shouldBreakForCountLimit(matchCount2)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
}
outer_month: while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in all()`);
}
const occs = this.generateMonthlyOccurrences(monthCursor);
if (monthCursor.month === start.month && occs.some((o) => Temporal.ZonedDateTime.compare(o, start) < 0)) {
monthCursor = monthCursor.add({ months: this.opts.interval });
continue outer_month;
}
for (const occ of occs) {
if (Temporal.ZonedDateTime.compare(occ, start) < 0) continue;
if (this.opts.until && Temporal.ZonedDateTime.compare(occ, this.opts.until) > 0) {
break outer_month;
}
if (iterator && !iterator(occ, matchCount2)) {
break outer_month;
}
dates.push(occ);
matchCount2++;
if (this.shouldBreakForCountLimit(matchCount2)) {
break outer_month;
}
}
monthCursor = monthCursor.add({ months: this.opts.interval });
}
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
if (this.opts.freq === "WEEKLY") {
const start = this.originalDtstart;
if (this.includeDtstart && !this.matchesAll(start)) {
if (iterator && !iterator(start, 0)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
dates.push(start);
if (this.shouldBreakForCountLimit(1)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
}
const dayMap = {
MO: 1,
TU: 2,
WE: 3,
TH: 4,
FR: 5,
SA: 6,
SU: 7
};
const tokens = this.opts.byDay ? [...this.opts.byDay] : [Object.entries(dayMap).find(([, d]) => d === start.dayOfWeek)[0]];
const dows = tokens.map((tok) => dayMap[tok]).filter((d) => d !== void 0).sort((a, b) => a - b);
const firstWeekDates = dows.map((dw) => {
const delta = (dw - start.dayOfWeek + 7) % 7;
return start.add({ days: delta });
});
let firstOccurrence = firstWeekDates.reduce(
(a, b) => Temporal.ZonedDateTime.compare(a, b) <= 0 ? a : b
);
const wkstDay = dayMap[this.opts.wkst || "MO"] ?? 1;
const firstOccWeekOffset = (firstOccurrence.dayOfWeek - wkstDay + 7) % 7;
let weekCursor = firstOccurrence.subtract({ days: firstOccWeekOffset });
let matchCount2 = 0;
outer_week: while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in all()`);
}
const occs = dows.flatMap((dw) => {
const delta = (dw - wkstDay + 7) % 7;
const sameDate = weekCursor.add({ days: delta });
return this.expandByTime(sameDate);
}).sort((a, b) => Temporal.ZonedDateTime.compare(a, b));
for (const occ of occs) {
if (Temporal.ZonedDateTime.compare(occ, start) < 0) continue;
if (this.opts.until && Temporal.ZonedDateTime.compare(occ, this.opts.until) > 0) {
break outer_week;
}
if (iterator && !iterator(occ, matchCount2)) {
break outer_week;
}
dates.push(occ);
matchCount2++;
if (this.shouldBreakForCountLimit(matchCount2)) {
break outer_week;
}
}
weekCursor = weekCursor.add({ weeks: this.opts.interval });
}
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
if (this.opts.freq === "MONTHLY" && this.opts.byMonth && !this.opts.byDay && !this.opts.byMonthDay) {
const start = this.originalDtstart;
if (this.includeDtstart && !this.matchesAll(start)) {
if (iterator && !iterator(start, 0)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
dates.push(start);
if (this.shouldBreakForCountLimit(1)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
}
const months = [...this.opts.byMonth].sort((a, b) => a - b);
let monthOffset = 0;
let matchCount2 = dates.length;
let startMonthIndex = months.findIndex((m) => m >= start.month);
if (startMonthIndex === -1) {
startMonthIndex = 0;
monthOffset = 1;
}
while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in all()`);
}
const monthIndex = startMonthIndex + monthOffset;
const targetMonth = months[monthIndex % months.length];
const yearsToAdd = Math.floor(monthIndex / months.length);
const candidate = start.with({
year: start.year + yearsToAdd,
month: targetMonth
});
if (this.opts.until && Temporal.ZonedDateTime.compare(candidate, this.opts.until) > 0) {
break;
}
if (Temporal.ZonedDateTime.compare(candidate, start) >= 0) {
if (iterator && !iterator(candidate, matchCount2)) {
break;
}
dates.push(candidate);
matchCount2++;
if (this.shouldBreakForCountLimit(matchCount2)) {
break;
}
}
monthOffset++;
}
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
if (this.opts.freq === "YEARLY" && this.opts.byMonth && !this.opts.byDay && !this.opts.byMonthDay) {
const start = this.originalDtstart;
if (this.includeDtstart && !this.matchesAll(start)) {
if (iterator && !iterator(start, 0)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
dates.push(start);
if (this.shouldBreakForCountLimit(1)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
}
const months = [...this.opts.byMonth].sort((a, b) => a - b);
let yearOffset = 0;
let matchCount2 = 0;
while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in all()`);
}
const year = start.year + yearOffset * this.opts.interval;
for (const month of months) {
let occ = start.with({ year, month });
occ = this.applyTimeOverride(occ);
if (Temporal.ZonedDateTime.compare(occ, start) < 0) {
continue;
}
if (this.opts.until && Temporal.ZonedDateTime.compare(occ, this.opts.until) > 0) {
return dates;
}
if (iterator && !iterator(occ, matchCount2)) {
return dates;
}
dates.push(occ);
matchCount2++;
if (this.shouldBreakForCountLimit(matchCount2)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
}
yearOffset++;
}
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
if (this.opts.freq === "YEARLY" && (this.opts.byDay || this.opts.byMonthDay)) {
const start = this.originalDtstart;
if (this.includeDtstart && !this.matchesAll(start)) {
if (iterator && !iterator(start, 0)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
dates.push(start);
if (this.shouldBreakForCountLimit(1)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
}
let yearCursor = start.with({ month: 1, day: 1 });
let matchCount2 = 0;
outer_year: while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in all()`);
}
const occs = this.generateYearlyOccurrences(yearCursor);
for (const occ of occs) {
if (Temporal.ZonedDateTime.compare(occ, start) < 0) continue;
if (this.opts.until && Temporal.ZonedDateTime.compare(occ, this.opts.until) > 0) {
break outer_year;
}
if (iterator && !iterator(occ, matchCount2)) {
break outer_year;
}
dates.push(occ);
matchCount2++;
if (this.shouldBreakForCountLimit(matchCount2)) {
break outer_year;
}
}
yearCursor = yearCursor.add({ years: this.opts.interval });
}
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
if (this.opts.freq === "YEARLY" && (this.opts.byYearDay || this.opts.byWeekNo)) {
const start = this.originalDtstart;
if (this.includeDtstart && !this.matchesAll(start)) {
if (iterator && !iterator(start, 0)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
dates.push(start);
if (this.shouldBreakForCountLimit(1)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
}
let yearCursor = start.with({ month: 1, day: 1 });
let matchCount2 = 0;
outer_year2: while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in all()`);
}
const occs = this.generateYearlyOccurrences(yearCursor);
for (const occ of occs) {
if (Temporal.ZonedDateTime.compare(occ, start) < 0) continue;
if (this.opts.until && Temporal.ZonedDateTime.compare(occ, this.opts.until) > 0) {
break outer_year2;
}
if (iterator && !iterator(occ, matchCount2)) {
break outer_year2;
}
dates.push(occ);
matchCount2++;
if (this.shouldBreakForCountLimit(matchCount2)) {
break outer_year2;
}
}
yearCursor = yearCursor.add({ years: this.opts.interval });
}
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
let current = this.computeFirst();
let matchCount = 0;
if (this.includeDtstart && Temporal.ZonedDateTime.compare(current, this.originalDtstart) > 0) {
if (iterator && !iterator(this.originalDtstart, matchCount)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
dates.push(this.originalDtstart);
matchCount++;
if (this.shouldBreakForCountLimit(matchCount)) {
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
}
while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in all()`);
}
if (this.opts.until && Temporal.ZonedDateTime.compare(current, this.opts.until) > 0) {
break;
}
if (this.matchesAll(current)) {
if (iterator && !iterator(current, matchCount)) {
break;
}
dates.push(current);
matchCount++;
if (this.shouldBreakForCountLimit(matchCount)) {
break;
}
}
current = this.nextCandidateSameDate(current);
}
return this.applyCountLimitAndMergeRDates(dates, iterator);
}
/**
* Converts rDate entries to ZonedDateTime and merges with existing dates.
* @param dates - Array of dates to merge with
* @returns Merged and deduplicated array of dates
*/
mergeAndDeduplicateRDates(dates) {
if (!this.opts.rDate) return dates;
dates.push(...this.opts.rDate);
dates.sort((a, b) => Temporal.ZonedDateTime.compare(a, b));
const dedup = [];
for (const d of dates) {
if (dedup.length === 0 || Temporal.ZonedDateTime.compare(d, dedup[dedup.length - 1]) !== 0) {
dedup.push(d);
}
}
return dedup;
}
/**
* Excludes exDate entries from the given array of dates.
* @param dates - Array of dates to filter
* @returns Filtered array with exDate entries removed
*/
excludeExDates(dates) {
if (!this.opts.exDate || this.opts.exDate.length === 0) return dates;
return dates.filter((date) => {
return !this.opts.exDate.some(
(exDate) => Temporal.ZonedDateTime.compare(date, exDate) === 0
);
});
}
/**
* Applies count limit and merges rDates with the rule-generated dates.
* @param dates - Array of dates generated by the rule
* @param iterator - Optional iterator function
* @returns Final array of dates after merging and applying count limit
*/
applyCountLimitAndMergeRDates(dates, iterator) {
const merged = this.mergeAndDeduplicateRDates(dates);
const excluded = this.excludeExDates(merged);
if (this.opts.count !== void 0) {
let finalCount = 0;
const finalDates = [];
for (const d of excluded) {
if (finalCount >= this.opts.count) break;
if (iterator && !iterator(d, finalCount)) break;
finalDates.push(d);
finalCount++;
}
return finalDates;
}
return excluded;
}
/**
* Checks if the count limit should break the loop based on rDate presence.
* @param matchCount - Current number of matches
* @returns true if the loop should break
*/
shouldBreakForCountLimit(matchCount) {
if (this.opts.count === void 0) return false;
if (!this.opts.rDate) {
return matchCount >= this.opts.count;
}
const rDateCount = this.opts.rDate.length;
const targetRuleCount = Math.max(this.opts.count - rDateCount, 0);
const safetyMargin = Math.min(targetRuleCount, 10);
return matchCount >= targetRuleCount + safetyMargin;
}
/**
* Returns all occurrences of the rule within a specified time window.
* @param after - The start date or Temporal.ZonedDateTime object.
* @param before - The end date or Temporal.ZonedDateTime object.
* @param inc - Optional boolean flag to include the end date in the results.
* @returns An array of Temporal.ZonedDateTime objects representing all occurrences of the rule within the specified time window.
*/
between(after, before, inc = false) {
const startInst = after instanceof Date ? Temporal.Instant.from(after.toISOString()) : after.toInstant();
const endInst = before instanceof Date ? Temporal.Instant.from(before.toISOString()) : before.toInstant();
const results = [];
let iterationCount = 0;
if (this.opts.freq === "MONTHLY" && (this.opts.byDay || this.opts.byMonthDay)) {
let monthCursor = this.computeFirst().with({ day: 1 });
outer: while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in between()`);
}
const occs = this.generateMonthlyOccurrences(monthCursor);
for (const occ of occs) {
const inst = occ.toInstant();
if (inc ? Temporal.Instant.compare(inst, endInst) > 0 : Temporal.Instant.compare(inst, endInst) >= 0) {
break outer;
}
const startOk = inc ? Temporal.Instant.compare(inst, startInst) >= 0 : Temporal.Instant.compare(inst, startInst) > 0;
if (startOk) {
results.push(occ);
}
}
monthCursor = monthCursor.add({ months: this.opts.interval });
}
return this.mergeRDates(results, startInst, endInst, inc);
}
if (this.opts.freq === "YEARLY" && this.opts.byMonth && !this.opts.byDay && !this.opts.byMonthDay) {
const start = this.originalDtstart;
const months = [...this.opts.byMonth].sort((a, b) => a - b);
let yearOffset = 0;
outer: while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in between()`);
}
const year = start.year + yearOffset * this.opts.interval;
for (const month of months) {
let occ = start.with({ year, month });
occ = this.applyTimeOverride(occ);
const inst = occ.toInstant();
if (inc ? Temporal.Instant.compare(inst, endInst) > 0 : Temporal.Instant.compare(inst, endInst) >= 0) {
break outer;
}
const startOk = inc ? Temporal.Instant.compare(inst, startInst) >= 0 : Temporal.Instant.compare(inst, startInst) > 0;
if (startOk) {
results.push(occ);
}
}
yearOffset++;
}
return this.mergeRDates(results, startInst, endInst, inc);
}
if (this.opts.freq === "YEARLY" && (this.opts.byDay || this.opts.byMonthDay)) {
const start = this.originalDtstart;
let yearCursor = start.with({ month: 1, day: 1 });
outer_year: while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in between()`);
}
const occs = this.generateYearlyOccurrences(yearCursor);
for (const occ of occs) {
const inst = occ.toInstant();
if (inc ? Temporal.Instant.compare(inst, endInst) > 0 : Temporal.Instant.compare(inst, endInst) >= 0) {
break outer_year;
}
const startOk = inc ? Temporal.Instant.compare(inst, startInst) >= 0 : Temporal.Instant.compare(inst, startInst) > 0;
if (startOk) {
results.push(occ);
}
}
yearCursor = yearCursor.add({ years: this.opts.interval });
}
return this.mergeRDates(results, startInst, endInst, inc);
}
if (this.opts.freq === "YEARLY" && (this.opts.byYearDay || this.opts.byWeekNo)) {
const start = this.originalDtstart;
let yearCursor = start.with({ month: 1, day: 1 });
outer_year2: while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in between()`);
}
const occs = this.generateYearlyOccurrences(yearCursor);
for (const occ of occs) {
const inst = occ.toInstant();
if (inc ? Temporal.Instant.compare(inst, endInst) > 0 : Temporal.Instant.compare(inst, endInst) >= 0) {
break outer_year2;
}
const startOk = inc ? Temporal.Instant.compare(inst, startInst) >= 0 : Temporal.Instant.compare(inst, startInst) > 0;
if (startOk) {
results.push(occ);
}
}
yearCursor = yearCursor.add({ years: this.opts.interval });
}
return this.mergeRDates(results, startInst, endInst, inc);
}
if (this.opts.freq === "WEEKLY") {
const startInst2 = after instanceof Date ? Temporal.Instant.from(after.toISOString()) : after.toInstant();
const endInst2 = before instanceof Date ? Temporal.Instant.from(before.toISOString()) : before.toInstant();
let weekCursor = this.computeFirst();
const results2 = [];
outer: while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in between()`);
}
const occs = this.generateWeeklyOccurrences(weekCursor);
for (const occ of occs) {
const inst = occ.toInstant();
if (inc ? Temporal.Instant.compare(inst, endInst2) > 0 : Temporal.Instant.compare(inst, endInst2) >= 0) {
break outer;
}
const startOk = inc ? Temporal.Instant.compare(inst, startInst2) >= 0 : Temporal.Instant.compare(inst, startInst2) > 0;
if (startOk) {
results2.push(occ);
}
}
weekCursor = weekCursor.add({ weeks: this.opts.interval });
}
return this.mergeRDates(results2, startInst2, endInst2, inc);
}
let current = this.computeFirst();
while (true) {
if (++iterationCount > this.maxIterations) {
throw new Error(`Maximum iterations (${this.maxIterations}) exceeded in between()`);
}
const inst = current.toInstant();
if (inc) {
if (Temporal.Instant.compare(inst, endInst) > 0) break;
} else {
if (Temporal.Instant.compare(inst, endInst) >= 0) break;
}
const startOk = inc ? Temporal.Instant.compare(inst, startInst) >= 0 : Temporal.Instant.compare(inst, startInst) > 0;
if (startOk && this.matchesAll(current)) {
results.push(current);
}
current = this.nextCandidateSameDate(current);
}
return this.mergeRDates(results, startInst, endInst, inc);
}
/**
* Returns the next occurrence of the rule after a specified date.
* @param after - The start date or Temporal.ZonedDateTime object.
* @param inc - Optional boolean flag to include occurrences on the start date.
* @returns The next occurrence of the rule after the specified date or null if no occurrences are found.
*/
next(after = /* @__PURE__ */ new Date(), inc = false) {
const afterInst = after instanceof Date ? Temporal.Instant.from(after.toISOString()) : after.toInstant();
let result = null;
this.all((occ) => {
const inst = occ.toInstant();
const ok = inc ? Temporal.Instant.compare(inst, afterInst) >= 0 : Temporal.Instant.compare(inst, afterInst) > 0;
if (ok) {
result = occ;
return false;
}
return true;
});
return result;
}
/**
* Returns the previous occurrence of the rule before a specified date.
* @param before - The end date or Temporal.ZonedDateTime object.
* @param inc - Optional boolean flag to include occurrences on the end date.
* @returns The previous occurrence of the rule before the specified date or null if no occurrences are found.
*/
previous(before = /* @__PURE__ */ new Date(), inc = false) {
const beforeInst = before instanceof Date ? Temporal.Instant.from(before.toISOString()) : before.toInstant();
let prev = null;
this.all((occ) => {
const inst = occ.toInstant();
const beyond = inc ? Temporal.Instant.compare(inst, beforeInst) > 0 : Temporal.Instant.compare(inst, beforeInst) >= 0;
if (beyond) return false;
prev = occ;
return true;
});
return prev;
}
toString() {
const iso = this.originalDtstart.toString({ smallestUnit: "second" }).replace(/[-:]/g, "");
const dtLine = `DTSTART;TZID=${this.tzid}:${iso.slice(0, 15)}`;
const parts = [];
const {
freq,
interval,
count,
until,
byHour,
byMinute,
byDay,
byMonth,
byMonthDay
} = this.opts;
parts.push(`FREQ=${freq}`);
if (interval !== 1) parts.push(`INTERVAL=${interval}`);
if (count !== void 0) parts.push(`COUNT=${count}`);
if (until) {
const u = until.toInstant().toString().replace(/[-:]/g, "");
parts.push(`UNTIL=${u.slice(0, 15)}Z`);
}
if (byHour) parts.push(`BYHOUR=${byHour.join(",")}`);
if (byMinute) parts.push(`BYMINUTE=${byMinute.join(",")}`);
if (byDay) parts.push(`BYDAY=${byDay.join(",")}`);
if (byMonth) parts.push(`BYMONTH=${byMonth.join(",")}`);
if (byMonthDay) parts.push(`BYMONTHDAY=${byMonthDay.join(",")}`);
return [dtLine, `RRULE:${parts.join(";")}`].join("\n");
}
/**
* Given any date in a month, return all the ZonedDateTimes in that month
* matching your opts.byDay and opts.byMonth (or the single "same day" if no BYDAY).
*/
generateMonthlyOccurrences(sample) {
const { byDay, byMonth, byMonthDay } = this.opts;
if (byMonth && !byMonth.includes(sample.month)) return [];
const lastDay = sample.with({ day: 1 }).add({ months: 1 }).subtract({ days: 1 }).day;
let byMonthDayHits = [];
if (byMonthDay && byMonthDay.length > 0) {
byMonthDayHits = byMonthDay.map((d) => d > 0 ? d : lastDay + d + 1).filter((d) => d >= 1 && d <= lastDay);
}
if (!byDay && byMonthDay && byMonthDay.length > 0) {
if (byMonthDayHits.length === 0) {
return [];
}
const dates = byMonthDayHits.map((d) => sample.with({ day: d }));
return dates.flatMap((z) => this.expandByTime(z)).sort((a, b) => Temporal.ZonedDateTime.compare(a, b));
}
if (!byDay) {
return this.expandByTime(sample);
}
const dayMap = {
MO: 1,
TU: 2,
WE: 3,
TH: 4,
FR: 5,
SA: 6,
SU: 7
};
const tokens = byDay.map((tok) => {
const m = tok.match(/^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$/);
if (!m) return null;
return { ord: m[1] ? parseInt(m[1], 10) : 0, wd: dayMap[m[2]] };
}).filter((x) => x !== null);
const buckets = {};
let cursor = sample.with({ day: 1 });
while (cursor.month === sample.month) {
const dow = cursor.dayOfWeek;
(buckets[dow] ||= []).push(cursor.day);
cursor = cursor.add({ days: 1 });
}
const byDayHits = [];
for (const { ord, wd } of tokens) {
const list = buckets[wd] ?? [];
if (!list.length) continue;
if (ord === 0) {
for (const d of list) byDayHits.push(d);
} else {
const idx = ord > 0 ? ord - 1 : list.length + ord;
const dayN = list[idx];
if (dayN) byDayHits.push(dayN);
}
}
let finalDays = byDayHits;
if (byMonthDay && byMonthDay.length > 0) {
if (byMonthDayHits.length === 0) {
return [];
}
finalDays = finalDays.filter((d) => byMonthDayHits.includes(d));
}
const hits = finalDays.map((d) => sample.with({ day: d }));
let expanded = hits.flatMap((z) => this.expandByTime(z)).sort((a, b) => Temporal.ZonedDateTime.compare(a, b));
expanded = this.applyBySetPos(expanded);
return expanded;
}
/**
* Given any date in a year, return all ZonedDateTimes in that year matching
* the BYDAY/BYMONTHDAY/BYMONTH constraints. Months default to DTSTART's month
* if BYMONTH is not specified.
*/
generateYearlyOccurrences(sample) {
const months = this.opts.byMonth ? [...this.opts.byMonth].sort((a, b) => a - b) : [this.originalDtstart.month];
let occs = [];
if (this.opts.byDay && this.opts.byDay.some((t) => /\d{2}/.test(t))) {
const dayMap = {
MO: 1,
TU: 2,
WE: 3,
TH: 4,
FR: 5,
SA: 6,
SU: 7
};
for (const tok of this.opts.byDay) {
const m = tok.match(/^([+-]?\d{1,2})(MO|TU|WE|TH|FR|SA|SU)$/);
if (!m) continue;
const ord = parseInt(m[1], 10);
const wd = dayMap[m[2]];
let dt;
if (ord > 0) {
const jan1 = sample.with({ month: 1, day: 1 });
const delta = (wd - jan1.dayOfWeek + 7) % 7;
dt = jan1.add({ days: delta + 7 * (ord - 1) });
} else {
const dec31 = sample.with({ month: 12, day: 31 });
const delta = (dec31.dayOfWeek - wd + 7) % 7;
dt = dec31.subtract({ days: delta + 7 * (-ord - 1) });
}
if (!this.opts.byMonth || this.opts.byMonth.includes(dt.month)) {
occs.push(...this.expandByTime(dt));
}
}
} else if (!this.opts.byYearDay && !this.opts.byWeekNo) {
occs = months.flatMap((m) => {
const monthSample = sample.with({ month: m, day: 1 });
return this.generateMonthlyOccurrences(monthSample);
});
}
if (this.opts.byYearDay) {
const last = sample.with({ month: 12, day: 31 }).dayOfYear;
for (const d of this.opts.byYearDay) {
const dayNum = d > 0 ? d : last + d + 1;
const dt = sample.with({ month: 1, day: 1 }).add({ days: dayNum - 1 });
if (!this.opts.byMonth || this.opts.byMonth.includes(dt.month)) {
occs.push(...this.expandByTime(dt));
}
}
}
if (this.opts.byWeekNo) {
const dayMap = {
MO: 1,
TU: 2,
WE: 3,
TH: 4,
FR: 5,
SA: 6,
SU: 7
};
const wkst = dayMap[this.opts.wkst || "MO"];
const jan1 = sample.with({ month: 1, day: 1 });
const delta = (jan1.dayOfWeek - wkst + 7) % 7;
const firstWeekStart = jan1.subtract({ days: delta });
const lastWeekDiff = sample.with({ month: 12, day: 31 }).toPlainDate().since(firstWeekStart.toPlainDate()).days;
const lastWeek = Math.floor(lastWeekDiff / 7) + 1;
const tokens = this.opts.byDay?.length ? this.opts.byDay.map((tok) => tok.match(/(MO|TU|WE|TH|FR|SA|SU)$/)?.[1]) : [this.opts.wkst || "MO"];
for (const weekNo of this.opts.byWeekNo) {
const weekIndex = weekNo > 0 ? weekNo - 1 : lastWeek + weekNo;
const weekStart = firstWeekStart.add({ weeks: weekIndex });
for (const tok of tokens) {
if (!tok) continue;
const targetDow = dayMap[tok];
const inst = weekStart.add({ days: (targetDow - wkst + 7) % 7 });
if (!this.opts.byMonth || this.opts.byMonth.includes(inst.month)) {
occs.push(...this.expandByTime(inst));
}
}
}
}
occs = occs.sort((a, b) => Temporal.ZonedDateTime.compare(a, b));
occs = this.applyBySetPos(occs);
return occs;
}
applyBySetPos(list) {
const { bySetPos } = this.opts;
if (!bySetPos || !bySetPos.length) return list;
const sorted = [...list].sort((a, b) => Temporal.ZonedDateTime.compare(a, b));
const out = [];
const len = sorted.length;
for (const pos of bySetPos) {
const idx = pos > 0 ? pos - 1 : len + pos;
if (idx >= 0 && idx < len) out.push(sorted[idx]);
}
return out.sort((a, b) => Temporal.ZonedDateTime.compare(a, b));
}
mergeRDates(list, startInst, endInst, inc) {
if (!this.opts.rDate) return this.excludeExDates(list);
for (const z of this.opts.rDate) {
const inst = z.toInstant();
const startOk = inc ? Temporal.Instant.compare(inst, startInst) >= 0 : Temporal.Instant.compare(inst, startInst) > 0;
const endOk = inc ? Temporal.Instant.compare(inst, endInst) <= 0 : Temporal.Instant.compare(inst, endInst) < 0;
if (startOk && endOk) list.push(z);
}
list.sort((a, b) => Temporal.ZonedDateTime.compare(a, b));
const dedup = [];
for (const d of list) {
if (dedup.length === 0 || Temporal.ZonedDateTime.compare(d, dedup[dedup.length - 1]) !== 0) {
dedup.push(d);
}
}
return this.excludeExDates(dedup);
}
};
export {
RRuleTemporal
};