UNPKG

@rschedule/rschedule

Version:

A typescript library for working with recurring dates and events.

1,376 lines (1,359 loc) 173 kB
class IntersectionOperatorConfig { } class RuleConfig { } class MergeDurationOperatorConfig { } class SplitDurationOperatorConfig { } class RScheduleConfig { } RScheduleConfig.Rule = RuleConfig; RScheduleConfig.IntersectionOperator = IntersectionOperatorConfig; RScheduleConfig.MergeDurationOperator = MergeDurationOperatorConfig; RScheduleConfig.SplitDurationOperator = SplitDurationOperatorConfig; class ArgumentError extends Error { } class InfiniteLoopError extends Error { } // Taken from https://stackoverflow.com/a/53985533/5490505 // export type TupleUnshift<A, B extends readonly [...any[]]> = ((a: A, ...r: ForcedTuple<B>) => void) extends ( // ...a: infer R // ) => any // ? R // : never; // type ForcedTuple<T> = T extends [ // infer A, // infer B, // infer C, // infer D, // infer E, // infer F, // infer G, // infer H, // infer I, // infer J, // infer K, // infer L, // infer M, // infer N, // infer O, // infer P, // infer Q, // infer R, // infer S, // infer T, // infer U, // infer V, // infer W, // infer X, // infer Y, // infer Z // ] // ? [A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z] // : T; function numberSortComparer(a, b) { if (a > b) { return 1; } else if (b > a) { return -1; } else { return 0; } } function freqToGranularity(freq) { switch (freq) { case 'YEARLY': return 'year'; case 'MONTHLY': return 'month'; case 'WEEKLY': return 'week'; case 'DAILY': return 'day'; case 'HOURLY': return 'hour'; case 'MINUTELY': return 'minute'; case 'SECONDLY': return 'second'; default: return 'millisecond'; } } function cloneJSON(json) { return JSON.parse(JSON.stringify(json)); } var _a; const WEEKDAYS = [ 'SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA', ]; const MILLISECONDS_IN_SECOND = 1000; const MILLISECONDS_IN_MINUTE = MILLISECONDS_IN_SECOND * 60; const MILLISECONDS_IN_HOUR = MILLISECONDS_IN_MINUTE * 60; const MILLISECONDS_IN_DAY = MILLISECONDS_IN_HOUR * 24; const MILLISECONDS_IN_WEEK = MILLISECONDS_IN_DAY * 7; class InvalidDateTimeError extends Error { } const DATETIME_ID = Symbol.for('b1231462-3560-4770-94f0-d16295d5965c'); class DateTime { constructor(date, timezone, duration) { /** * This property contains an ordered array of the generator objects * responsible for producing this DateAdapter. * * - If this DateAdapter was produced by a `Rule` object, this array * will just contain the `Rule` object. * - If this DateAdapter was produced by a `Schedule` object, this * array will contain the `Schedule` object as well as the `Rule` * or `Dates` object which generated it. * - If this DateAdapter was produced by a `Calendar` object, this * array will contain, at minimum, the `Calendar`, `Schedule`, and * `Rule`/`Dates` objects which generated it. */ this.generators = []; this[_a] = true; this.date = new Date(date); this.timezone = timezone || null; this.duration = duration; this.assertIsValid(); } /** * Similar to `Array.isArray()`, `isInstance()` provides a surefire method * of determining if an object is a `DateTime` by checking against the * global symbol registry. */ static isInstance(object) { return !!(object && object[DATETIME_ID]); } static fromJSON(json) { const date = new Date(Date.UTC(json.year, json.month - 1, json.day, json.hour, json.minute, json.second, json.millisecond)); return new DateTime(date, json.timezone, json.duration); } static fromDateAdapter(adapter) { return DateTime.fromJSON(adapter.toJSON()); } /** * Returns `undefined` if `this.duration` is falsey. Else returns * the `end` date. */ get end() { if (!this.duration) return; if (this._end) return this._end; this._end = this.add(this.duration, 'millisecond'); return this._end; } // While we constrain the argument to be another DateAdapter in typescript // we handle the case of someone passing in another type of object in javascript isEqual(object) { if (!object) { return false; } assertSameTimeZone(this, object); return this.valueOf() === object.valueOf(); } isBefore(object) { assertSameTimeZone(this, object); return this.valueOf() < object.valueOf(); } isBeforeOrEqual(object) { assertSameTimeZone(this, object); return this.valueOf() <= object.valueOf(); } isAfter(object) { assertSameTimeZone(this, object); return this.valueOf() > object.valueOf(); } isAfterOrEqual(object) { assertSameTimeZone(this, object); return this.valueOf() >= object.valueOf(); } isOccurring(object) { if (!this.duration) { throw new Error('DateTime#isOccurring() is only applicable to DateTimes with durations'); } assertSameTimeZone(this, object); return (object.isAfterOrEqual(this) && object.isBeforeOrEqual(this.add(this.duration, 'millisecond'))); } add(amount, unit) { switch (unit) { case 'year': return this.forkDateTime(addUTCYears(this.date, amount)); case 'month': return this.forkDateTime(addUTCMonths(this.date, amount)); case 'week': return this.forkDateTime(addUTCWeeks(this.date, amount)); case 'day': return this.forkDateTime(addUTCDays(this.date, amount)); case 'hour': return this.forkDateTime(addUTCHours(this.date, amount)); case 'minute': return this.forkDateTime(addUTCMinutes(this.date, amount)); case 'second': return this.forkDateTime(addUTCSeconds(this.date, amount)); case 'millisecond': return this.forkDateTime(addUTCMilliseconds(this.date, amount)); default: throw new ArgumentError('Invalid unit provided to `DateTime#add`'); } } subtract(amount, unit) { switch (unit) { case 'year': return this.forkDateTime(subUTCYears(this.date, amount)); case 'month': return this.forkDateTime(subUTCMonths(this.date, amount)); case 'week': return this.forkDateTime(subUTCWeeks(this.date, amount)); case 'day': return this.forkDateTime(subUTCDays(this.date, amount)); case 'hour': return this.forkDateTime(subUTCHours(this.date, amount)); case 'minute': return this.forkDateTime(subUTCMinutes(this.date, amount)); case 'second': return this.forkDateTime(subUTCSeconds(this.date, amount)); case 'millisecond': return this.forkDateTime(subUTCMilliseconds(this.date, amount)); default: throw new ArgumentError('Invalid unit provided to `DateTime#subtract`'); } } get(unit) { switch (unit) { case 'year': return this.date.getUTCFullYear(); case 'month': return (this.date.getUTCMonth() + 1); case 'yearday': return getUTCYearDay(this.date); case 'weekday': return WEEKDAYS[this.date.getUTCDay()]; case 'day': return this.date.getUTCDate(); case 'hour': return this.date.getUTCHours(); case 'minute': return this.date.getUTCMinutes(); case 'second': return this.date.getUTCSeconds(); case 'millisecond': return this.date.getUTCMilliseconds(); default: throw new ArgumentError('Invalid unit provided to `DateTime#set`'); } } set(unit, value) { if (unit === 'duration') { return new DateTime(this.date, this.timezone, value); } let date = new Date(this.date); switch (unit) { case 'year': date.setUTCFullYear(value); break; case 'month': { // If the current day of the month // is greater than days in the month we are moving to, we need to also // set the day to the end of that month. const length = monthLength(value, date.getUTCFullYear()); const day = date.getUTCDate(); if (day > length) { date.setUTCDate(1); date.setUTCMonth(value); date = subUTCDays(date, 1); } else { date.setUTCMonth(value - 1); } break; } case 'day': date.setUTCDate(value); break; case 'hour': date.setUTCHours(value); break; case 'minute': date.setUTCMinutes(value); break; case 'second': date.setUTCSeconds(value); break; case 'millisecond': date.setUTCMilliseconds(value); break; default: throw new ArgumentError('Invalid unit provided to `DateTime#set`'); } return this.forkDateTime(date); } granularity(granularity, opt = {}) { let date = this.forkDateTime(this.date); switch (granularity) { case 'year': date = date.set('month', 1); case 'month': date = date.set('day', 1); break; case 'week': date = setDateToStartOfWeek(date, opt.weekStart); } switch (granularity) { case 'year': case 'month': case 'week': case 'day': date = date.set('hour', 0); case 'hour': date = date.set('minute', 0); case 'minute': date = date.set('second', 0); case 'second': date = date.set('millisecond', 0); case 'millisecond': return date; default: throw new ArgumentError('Invalid granularity provided to `DateTime#granularity`: ' + granularity); } } endGranularity(granularity, opt = {}) { let date = this.forkDateTime(this.date); switch (granularity) { case 'year': date = date.set('month', 12); case 'month': date = date.set('day', monthLength(date.get('month'), date.get('year'))); break; case 'week': date = setDateToEndOfWeek(date, opt.weekStart); } switch (granularity) { case 'year': case 'month': case 'week': case 'day': date = date.set('hour', 23); case 'hour': date = date.set('minute', 59); case 'minute': date = date.set('second', 59); case 'second': date = date.set('millisecond', 999); case 'millisecond': return date; default: throw new ArgumentError('Invalid granularity provided to `DateTime#granularity`: ' + granularity); } } toISOString() { return this.date.toISOString(); } toDateTime() { return this; } toJSON() { return { timezone: this.timezone, duration: this.duration, year: this.get('year'), month: this.get('month'), day: this.get('day'), hour: this.get('hour'), minute: this.get('minute'), second: this.get('second'), millisecond: this.get('millisecond'), }; } valueOf() { return this.date.valueOf(); } assertIsValid() { if (isNaN(this.valueOf())) { throw new InvalidDateTimeError('DateTime has invalid date.'); } return true; } forkDateTime(date) { return new DateTime(date, this.timezone, this.duration); } } _a = DATETIME_ID; function assertSameTimeZone(x, y) { if (x.timezone !== y.timezone) { throw new InvalidDateTimeError('Attempted to compare a datetime to another date in a different timezone: ' + JSON.stringify(x) + ' and ' + JSON.stringify(y)); } return true; } function setDateToStartOfWeek(date, wkst) { const index = orderedWeekdays(wkst).indexOf(date.get('weekday')); return date.subtract(index, 'day'); } function setDateToEndOfWeek(date, wkst) { const index = orderedWeekdays(wkst).indexOf(date.get('weekday')); return date.add(6 - index, 'day'); } function dateTimeSortComparer(a, b) { if (a.isAfter(b)) return 1; if (a.isBefore(b)) return -1; if (a.duration && b.duration) { if (a.duration > b.duration) return 1; if (a.duration < b.duration) return -1; } return 0; } function uniqDateTimes(dates) { return Array.from(new Map(dates.map(date => [date.toISOString(), date])).values()); } function orderedWeekdays(wkst = 'SU') { const wkdays = WEEKDAYS.slice(); let index = wkdays.indexOf(wkst); while (index !== 0) { shiftArray(wkdays); index--; } return wkdays; } function shiftArray(array, from = 'first') { if (array.length === 0) { return array; } else if (from === 'first') { array.push(array.shift()); } else { array.unshift(array.pop()); } return array; } /** * Returns the days in the given month. * * @param month base-1 * @param year */ function monthLength(month, year) { const block = { 1: 31, 2: getDaysInFebruary(year), 3: 31, 4: 30, 5: 31, 6: 30, 7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31, }; return block[month]; } function getDaysInFebruary(year) { return isLeapYear(year) ? 29 : 28; } // taken from date-fn function isLeapYear(year) { return year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0); } function getDaysInYear(year) { return isLeapYear(year) ? 366 : 365; } function getUTCYearDay(now) { const start = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); const diff = now.valueOf() - start.valueOf(); return 1 + Math.floor(diff / MILLISECONDS_IN_DAY); } /** * These functions are basically lifted from `date-fns`, but changed * to use the UTC date methods, which `date-fns` doesn't support. */ function toInteger(input) { if (input === null || input === true || input === false) { return NaN; } const int = Number(input); if (isNaN(int)) { return int; } return int < 0 ? Math.ceil(int) : Math.floor(int); } function addMilliseconds(dirtyDate, dirtyAmount) { if (arguments.length < 2) { throw new TypeError('2 arguments required, but only ' + arguments.length + ' present'); } const timestamp = dirtyDate.valueOf(); const amount = toInteger(dirtyAmount); return new Date(timestamp + amount); } function addUTCYears(date, input) { const amount = toInteger(input); return addUTCMonths(date, amount * 12); } function addUTCMonths(date, input) { const amount = toInteger(input); date = new Date(date); const desiredMonth = date.getUTCMonth() + amount; const dateWithDesiredMonth = new Date(0); dateWithDesiredMonth.setUTCFullYear(date.getUTCFullYear(), desiredMonth, 1); dateWithDesiredMonth.setUTCHours(0, 0, 0, 0); const daysInMonth = monthLength(dateWithDesiredMonth.getUTCMonth() + 1, dateWithDesiredMonth.getUTCFullYear()); // Set the last day of the new month // if the original date was the last day of the longer month date.setUTCMonth(desiredMonth, Math.min(daysInMonth, date.getUTCDate())); return date; } function addUTCWeeks(date, input) { const amount = toInteger(input); const days = amount * 7; return addUTCDays(date, days); } function addUTCDays(date, input) { // by adding milliseconds rather than days, we supress the native Date object's automatic // daylight savings time conversions which we don't want in UTC mode return addUTCMilliseconds(date, toInteger(input) * MILLISECONDS_IN_DAY); } function addUTCHours(date, input) { const amount = toInteger(input); return addMilliseconds(date, amount * MILLISECONDS_IN_HOUR); } function addUTCMinutes(date, input) { const amount = toInteger(input); return addMilliseconds(date, amount * MILLISECONDS_IN_MINUTE); } function addUTCSeconds(date, input) { const amount = toInteger(input); return addMilliseconds(date, amount * MILLISECONDS_IN_SECOND); } function addUTCMilliseconds(date, input) { const amount = toInteger(input); const timestamp = date.getTime(); return new Date(timestamp + amount); } function subUTCYears(date, amount) { return addUTCYears(date, -amount); } function subUTCMonths(date, amount) { return addUTCMonths(date, -amount); } function subUTCWeeks(date, amount) { return addUTCWeeks(date, -amount); } function subUTCDays(date, amount) { return addUTCDays(date, -amount); } function subUTCHours(date, amount) { return addUTCHours(date, -amount); } function subUTCMinutes(date, amount) { return addUTCMinutes(date, -amount); } function subUTCSeconds(date, amount) { return addUTCSeconds(date, -amount); } function subUTCMilliseconds(date, amount) { return addUTCMilliseconds(date, -amount); } var _a$1; class InvalidDateAdapterError extends Error { } const DATE_ADAPTER_ID = Symbol.for('9d2c0b75-7a72-4f24-b57f-c27e131e37b2'); class DateAdapter { constructor(_date, _options) { /** * An array of OccurrenceGenerator objects which produced this DateAdapter. * * #### Details * * When a Rule object creates a DateAdapter, that Rule object adds itself to * the DateAdapter's generators property before yielding the DateAdapter. If you are using a Rule * object directly, the process ends there and the DateAdapter is yielded to you (in this case, * generators will have the type `[Rule]`) * * If you are using another object, like a Schedule however, then each DateAdapter is generated * by either a Dates (rdates) or Rule (rrule) within the Schedule. After being originally * generated by a Dates/Rule, the DateAdapter is then filtered by any exdate/exrules and, * assuming it passes, then the DateAdapter "bubbles up" to the Schedule object itself. At this * point the Schedule adds itself to the generators array of the DateAdapter and yields the date * to you. So each DateAdapter produced by a Schedule has a generators property of type * `[Schedule, Rule | Dates]`. * * The generators property pairs well with the `data` property on many OccurrenceGenerators. You * can access the OccurrenceGenerators which produced a DateAdapter via `generators`, and then * access any arbitrary data via the `data` property. * * _Note: occurrence operators are never included in the generators array._ * */ // using `unknown[]` instead of `never[]` to support convenient generator typing in `Calendar`. // If `never[]` is used, then `Calendar#schedules` *must* be typed as a tuple in order to // access any values in `generators` beyond the first (Calendar) value (the rest of the values // get typed as `never`). This would prevent passing a variable to `Calendar#schedules`. this.generators = []; this[_a$1] = true; } /** * Similar to `Array.isArray()`, `isInstance()` provides a surefire method * of determining if an object is a `DateAdapter` by checking against the * global symbol registry. */ static isInstance(object) { return !!(object && typeof object === 'object' && object[DATE_ADAPTER_ID]); } static isDate(_object) { throw unimplementedError('isDate()'); } static fromJSON(_json) { throw unimplementedError('fromJSON()'); } static fromDateTime(_datetime) { throw unimplementedError('fromDateTime()'); } /** * Returns `undefined` if `this.duration` is falsey. Else returns * the `end` date. */ get end() { throw unimplementedError('end'); } set(_prop, _value) { throw unimplementedError('set()'); } valueOf() { throw unimplementedError('valueOf()'); } toISOString() { throw unimplementedError('toISOString()'); } toDateTime() { const date = DateTime.fromJSON(this.toJSON()); date.generators.push(...this.generators); return date; } toJSON() { throw unimplementedError('toJSON()'); } assertIsValid() { throw unimplementedError('assertIsValid()'); } } _a$1 = DATE_ADAPTER_ID; DateAdapter.hasTimezoneSupport = false; function unimplementedError(name) { return new Error(`You must implement the "${name}" method for this DateAdapter class`); } class OccurrenceIterator { constructor(iterable, args) { this.iterable = iterable; this.args = args; this[Symbol.iterator] = () => this._run(); this.iterator = iterable._run(args); this.isInfinite = iterable.isInfinite; } next(args) { return this._run(args).next(); } toArray() { if (this.args.end || this.args.take || !this.isInfinite) { return Array.from(this._run()); } throw new InfiniteLoopError('OccurrenceIterator#toArray() can only be called if the iterator ' + 'is not infinite, or you provide and `end` argument, or you provide ' + 'a `take` argument.'); } *_run(rawArgs) { let args = this.normalizeRunArgs(rawArgs); let date = this.iterator.next(args).value; while (date) { const yieldArgs = yield this.normalizeDateOutput(date); args = this.normalizeRunArgs(yieldArgs); date = this.iterator.next(args).value; } } normalizeRunArgs(args) { return { skipToDate: this.normalizeDateInput(args && args.skipToDate), }; } normalizeDateInput(date) { if (!date) { return; } return DateAdapter.isInstance(date) ? date.set('timezone', this.iterable.timezone).toDateTime() : new this.iterable.dateAdapter(date).set('timezone', this.iterable.timezone).toDateTime(); } normalizeDateOutput(date) { if (!date) { return; } return this.iterable.dateAdapter.fromDateTime(date); } } class CollectionIterator { constructor(iterable, args) { this.iterable = iterable; this.args = args; this.granularity = 'INSTANTANIOUSLY'; this[Symbol.iterator] = () => this.iterator; if (args.granularity) { this.granularity = args.granularity; } if (args.weekStart) { this.weekStart = args.weekStart; } if (args.reverse) { throw new Error('`Calendar#collections()` does not support iterating in reverse. ' + 'Though `Calendar#occurrences()` does support iterating in reverse.'); } // Set the end arg, if present, to the end of the period. this.args = { ...args, start: args.start || iterable._run().next().value, end: args.end && this.getPeriod(args.end).end, }; this.startDate = (this.args.start && this.normalizeDateOutput(this.getPeriod(this.args.start).start)) || null; this.iterator = this._run(); } next() { return this.iterator.next(); } /** * While `next()` and `[Symbol.iterator]` both share state, * `toArray()` does not share state and always returns the whole * collections array. */ toArray() { if (this.args.end || this.args.take || !this.iterable.isInfinite) { const collections = []; for (const collection of this._run()) { collections.push(collection); } return collections; } throw new InfiniteLoopError('CollectionIterator#toArray() can only be called if the iterator ' + 'is not infinite, or you provide and `end` argument, or you provide ' + 'a `take` argument.'); } normalizeDateOutput(date) { if (!date) return; return this.iterable.dateAdapter.fromDateTime(date); } *_run() { if (!this.startDate) return; let iterator = this.occurrenceIterator(this.iterable, this.args); let date = iterator.next().value; if (!date) return; // `period` === `periodStart` unless the granularity // is `MONTHLY` and a `weekStart` param was provided. In this case, // period holds a date === the first of the current month while // periodStart holds a date === the beginning of the first week of the month // (which might be in the the previous month). Read the // `Calendar#collections()` description for more info. let period = this.getPeriod(this.args.start); let dates = []; let index = 0; while (date && (this.args.take === undefined || this.args.take > index)) { while (date && date.isBeforeOrEqual(period.end)) { dates.push(date); date = iterator.next().value; } yield new Collection(dates.map(date => this.normalizeDateOutput(date)), this.granularity, this.normalizeDateOutput(period.start), this.normalizeDateOutput(period.end)); if (!date) return; dates = []; period = this.args.incrementLinearly ? this.getPeriod(this.incrementPeriod(period.period)) : this.getPeriod(date); // With these args, periods may overlap and the same date may show up // in two periods. Because of this, we need to reset the iterator // (otherwise it won't spit out a date it has already spit out). if (this.granularity === 'MONTHLY' && this.weekStart) { iterator = this.iterable._run({ start: period.start, end: this.args.end, }); date = iterator.next().value; } index++; } } getPeriod(date) { const granularity = freqToGranularity(this.granularity); let start; let end; let period; if (this.granularity === 'MONTHLY' && this.weekStart) { start = date.granularity('month').granularity('week', { weekStart: this.weekStart }); end = date.endGranularity('month').endGranularity('week', { weekStart: this.weekStart }); period = start; } else if (this.granularity === 'WEEKLY') { if (!this.weekStart) { throw new ArgumentError('"WEEKLY" granularity requires `weekStart` arg'); } start = date.granularity('week', { weekStart: this.weekStart }); end = date.endGranularity('week', { weekStart: this.weekStart }); period = start; } else { start = date.granularity(granularity); end = date.endGranularity(granularity); period = start; } return { start, end, period }; } incrementPeriod(date) { switch (this.granularity) { case 'YEARLY': return date.add(1, 'year'); case 'MONTHLY': return date.add(1, 'month'); case 'WEEKLY': return date.add(1, 'week'); case 'DAILY': return date.add(1, 'day'); case 'HOURLY': return date.add(1, 'hour'); case 'MINUTELY': return date.add(1, 'minute'); case 'SECONDLY': return date.add(1, 'second'); case 'INSTANTANIOUSLY': default: return date.add(1, 'millisecond'); } } occurrenceIterator(iterable, args) { let start = args.start || iterable._run().next().value; if (!start) return iterable._run(args); start = this.getPeriod(start).start; return iterable._run({ start, end: args.end, }); } } class Collection { constructor(dates = [], granularity, periodStart, periodEnd) { this.dates = dates; this.granularity = granularity; this.periodStart = periodStart; this.periodEnd = periodEnd; } } class PipeError extends Error { } class PipeRuleBase { constructor(args) { this.start = args.start; this.end = args.end; this.options = args.options; } } class PipeRule extends PipeRuleBase { nextValidDate(args, skipToDate) { return this.nextPipe.run({ date: args.date, invalidDate: true, skipToDate, }); } } /** * The `FrequencyPipe` is the first pipe in the chain of rule pipes. It is * responsible for incrementing the date, as appropriate, while taking into * account the `RRULE` frequency and interval. */ class FrequencyPipe extends PipeRule { constructor() { super(...arguments); this.intervalUnit = freqToGranularity(this.options.frequency); this.intervalStartDate = this.normalizedStartDate(this.start); this.intervalEndDate = this.normalizedEndDate(this.intervalStartDate); } run(args) { let date = args.date; // if a date is invalid, skipToDate will always be present // skipToDate may also be passed by a user on an otherwise valid date if (args.skipToDate) { date = args.skipToDate; this.skipToIntervalOnOrAfter(date); if (!this.dateIsWithinInterval(date)) { // this only applies when the interval is not 1 date = this.intervalStartDate; } } else if (this.dateIsWithinInterval(date) && this.dateIsWithinInterval(date.add(1, 'millisecond'))) { date = date.add(1, 'millisecond'); } else { this.incrementInterval(this.options.interval); date = this.intervalStartDate; } return this.nextPipe.run({ date }); } normalizedStartDate(date) { if (this.options.frequency === 'WEEKLY') { return date.granularity('week', { weekStart: this.options.weekStart }); } return date.granularity(this.intervalUnit); } normalizedEndDate(start) { switch (this.options.frequency) { case 'YEARLY': return start.add(1, 'year'); case 'MONTHLY': return start.add(1, 'month'); case 'WEEKLY': return start.add(1, 'week'); case 'DAILY': return start.add(1, 'day'); case 'HOURLY': return start.add(1, 'hour'); case 'MINUTELY': return start.add(1, 'minute'); case 'SECONDLY': return start.add(1, 'second'); case 'MILLISECONDLY': return start.add(1, 'millisecond'); } } incrementInterval(amount) { this.intervalStartDate = this.normalizedStartDate(this.intervalStartDate.add(amount, this.intervalUnit)); this.intervalEndDate = this.normalizedEndDate(this.intervalStartDate); } skipToIntervalOnOrAfter(date) { this.incrementInterval(intervalDifferenceBetweenDates({ first: this.intervalStartDate, second: date, unit: this.intervalUnit, interval: this.options.interval, weekStart: this.options.weekStart, })); } dateIsWithinInterval(date) { return this.intervalStartDate.isBeforeOrEqual(date) && this.intervalEndDate.isAfter(date); } } /** * Given the frequency (unit) and interval, this function finds * how many jumps forward the first date needs in order to equal * or exceed the second date. * * For example: * * 1. Unit is daily and interval is 1. The second date is 3 days * after the first. This will return 3. * 2. Unit is yearly and interval is 1. The second date is 3 days * after the first. This will return 0. * 3. Unit is yearly and interval is 3. The second date is 4 years * after the first. This will return 6. */ function intervalDifferenceBetweenDates({ first, second, unit, interval, weekStart, }) { let difference = (() => { let intervalDuration; switch (unit) { case 'year': let years = (second.get('year') - first.get('year')) * 12; years = years + second.get('month') - first.get('month'); return Math.floor(years / 12); case 'month': let months = (second.get('year') - first.get('year')) * 12; months = months + second.get('month') - first.get('month'); return months; case 'week': first = first.granularity('week', { weekStart }); intervalDuration = MILLISECONDS_IN_WEEK; break; case 'day': intervalDuration = MILLISECONDS_IN_DAY; break; case 'hour': intervalDuration = MILLISECONDS_IN_HOUR; break; case 'minute': intervalDuration = MILLISECONDS_IN_MINUTE; break; case 'second': intervalDuration = MILLISECONDS_IN_SECOND; break; case 'millisecond': intervalDuration = 1; break; default: throw new Error('Unexpected `unit` value'); } const diff = second.valueOf() - first.valueOf(); const sign = Math.sign(diff); return Math.floor(Math.abs(diff) / intervalDuration) * sign; })(); if (difference > 0 && difference < interval) { difference = interval; } else if (difference > interval) { difference = Math.ceil(difference / interval) * interval; } return difference; } class ByMonthOfYearPipe extends PipeRule { run(args) { if (args.invalidDate) { return this.nextPipe.run(args); } let { date } = args; const currentMonth = date.get('month'); for (const month of this.options.byMonthOfYear) { if (currentMonth > month) continue; if (currentMonth === month) return this.nextPipe.run({ date }); return this.nextValidDate(args, date.granularity('year').set('month', month)); } date = date .granularity('year') .add(1, 'year') .set('month', this.options.byMonthOfYear[0]); return this.nextValidDate(args, date); } } function getDifferenceBetweenWeekdays(x, y) { if (x === y) return 0; const result = WEEKDAYS.indexOf(x) - WEEKDAYS.indexOf(y); return result > 0 ? 7 - result : Math.abs(result); } function getNextWeekday(date, weekday) { return date.add(getDifferenceBetweenWeekdays(date.get('weekday'), weekday), 'day'); } function getPreviousWeekday(date, weekday) { const diff = getDifferenceBetweenWeekdays(date.get('weekday'), weekday); return date.subtract(diff === 0 ? 0 : 7 - diff, 'day'); } function getNthWeekdayOfMonth(date, weekday, nth) { let base = date.set('day', 1); if (nth < 0) { base = base.add(1, 'month'); } base = getNextWeekday(base, weekday); // when nth is negative, adding it will act as subtraction return nth < 0 ? base.add(nth, 'week') : base.add(nth - 1, 'week'); } function getNthWeekdayOfYear(date, weekday, nth) { let base = date.set('month', 1).set('day', 1); if (nth < 0) { base = base.add(1, 'year'); } base = getNextWeekday(base, weekday); // when nth is negative, adding it will act as subtraction return nth < 0 ? base.add(nth, 'week') : base.add(nth - 1, 'week'); } class ByDayOfMonthPipe extends PipeRule { run(args) { if (args.invalidDate) { return this.nextPipe.run(args); } let { date } = args; const normalizedByDayOfMonth = normalizeByDayOfMonth(date, this.options.byDayOfMonth, this.options.byDayOfWeek); const currentDay = date.get('day'); for (const day of normalizedByDayOfMonth) { if (currentDay > day) continue; if (currentDay === day) return this.nextPipe.run({ date }); return this.nextValidDate(args, date.granularity('month').set('day', day)); } let next; let nextMonth = date; let index = 0; while (!next && index < 30) { nextMonth = nextMonth.granularity('month').add(1, 'month'); next = normalizeByDayOfMonth(nextMonth, this.options.byDayOfMonth, this.options.byDayOfWeek)[0]; index++; } if (index >= 13) { throw new PipeError('byDayOfMonth Infinite while loop'); } date = nextMonth.set('day', next); return this.nextValidDate(args, date); } } /** * Does a few things: * * 1. filters out byDayOfMonth entries which are not applicable * to current month * 2. negative entries to positive ones * 3. if a byDayOfWeek option is given, removes days which are * not on the correct day of the week */ function normalizeByDayOfMonth(date, byDayOfMonth, byDayOfWeek) { const lengthOfMonth = date.endGranularity('month').get('day'); let normalizedByDayOfMonth = byDayOfMonth .filter(day => lengthOfMonth >= Math.abs(day)) .map(day => (day > 0 ? day : lengthOfMonth + day + 1)); if (byDayOfWeek) { const base = date.granularity('month'); const filteredByDayOfMonth = []; byDayOfWeek.forEach(entry => { if (typeof entry === 'string') { filteredByDayOfMonth.push(...normalizedByDayOfMonth.filter(day => base.set('day', day).get('weekday') === entry)); return; } const nthWeekdayOfMonth = getNthWeekdayOfMonth(date, ...entry).get('day'); if (normalizedByDayOfMonth.includes(nthWeekdayOfMonth)) { filteredByDayOfMonth.push(nthWeekdayOfMonth); } }); normalizedByDayOfMonth = Array.from(new Set(filteredByDayOfMonth)); } return normalizedByDayOfMonth.sort((a, b) => { if (a > b) return 1; if (a < b) return -1; else return 0; }); } class ByDayOfWeekPipe extends PipeRule { run(args) { if (args.invalidDate) { return this.nextPipe.run(args); } if (this.options.frequency === 'YEARLY') { return this.options.byMonthOfYear === undefined ? this.expandYearly(args) : this.expandMonthly(args); } else if (this.options.frequency === 'MONTHLY') { return this.expandMonthly(args); } return this.expand(args); } expandYearly(args) { const { date } = args; let next = getNextWeekdaysOfYear(date, this.options.byDayOfWeek)[0]; let index = 0; let base = date; // If we can't find a valid date this year, // search next year. Only search the next 28 years. // (the calendar repeats on a 28 year cycle, according // to the internet). while (!next && index < 28) { index++; base = base.granularity('year').add(1, 'year'); next = getNextWeekdaysOfYear(base, this.options.byDayOfWeek)[0]; } if (!next) { throw new PipeError('The byDayOfWeek rule appears to contain an impossible combination'); } if (next.isEqual(date)) { return this.nextPipe.run({ date }); } return this.nextValidDate(args, next.granularity('day')); } expandMonthly(args) { const { date } = args; let next = getNextWeekdaysOfMonth(date, this.options.byDayOfWeek)[0]; let index = 0; let base = date; // TODO: performance improvment // If, in the first year, a match isn't found, we should be able to // jumpt to the next leap year and check that. Or, if already on // a leap year, we can just error immediately. // If we can't find a valid date this month, // search the next month. Only search the next 4 years // (to account for leap year). while (!next && index < 50) { index++; base = base.granularity('month').add(1, 'month'); next = getNextWeekdaysOfMonth(base, this.options.byDayOfWeek)[0]; } if (!next) { throw new PipeError('The byDayOfWeek rule appears to contain an impossible combination'); } if (next.isEqual(date)) { return this.nextPipe.run({ date }); } return this.nextValidDate(args, next.granularity('day')); } expand(args) { const { date } = args; const next = this.options.byDayOfWeek .map(weekday => getNextWeekday(date, weekday)) .sort(dateTimeSortComparer)[0]; if (next.isEqual(date)) { return this.nextPipe.run({ date }); } return this.nextValidDate(args, next.granularity('day')); } } /** For each byDayOfWeek entry, find the next DateTime */ function getNextWeekdaysOfYear(date, byDayOfWeek) { const normalizedNthWeekdaysOfYear = byDayOfWeek .filter(entry => Array.isArray(entry)) .map(entry => getNthWeekdayOfYear(date, ...entry)); const normalizedNextWeekdays = byDayOfWeek .filter(entry => typeof entry === 'string') .map(weekday => getNextWeekday(date, weekday)) .filter(entry => entry.get('year') === date.get('year')); return uniqDateTimes([...normalizedNthWeekdaysOfYear, ...normalizedNextWeekdays]) .filter(entry => entry.isAfterOrEqual(date)) .sort(dateTimeSortComparer); } /** For each byDayOfWeek entry, find the next DateTime */ function getNextWeekdaysOfMonth(date, byDayOfWeek) { const normalizedNthWeekdaysOfMonth = byDayOfWeek .filter(entry => Array.isArray(entry)) .map(entry => getNthWeekdayOfMonth(date, ...entry)); const normalizedNextWeekdays = byDayOfWeek .filter(entry => typeof entry === 'string') .map(weekday => getNextWeekday(date, weekday)) .filter(entry => entry.get('year') === date.get('year') && entry.get('month') === date.get('month')); return uniqDateTimes([...normalizedNthWeekdaysOfMonth, ...normalizedNextWeekdays]) .filter(entry => entry.isAfterOrEqual(date)) .sort(dateTimeSortComparer); } class ByHourOfDayPipe extends PipeRule { run(args) { if (args.invalidDate) { return this.nextPipe.run(args); } let { date } = args; const currentHour = date.get('hour'); for (const hour of this.options.byHourOfDay) { if (currentHour > hour) continue; if (currentHour === hour) return this.nextPipe.run({ date }); return this.nextValidDate(args, date.granularity('day').set('hour', hour)); } date = date .granularity('day') .add(1, 'day') .set('hour', this.options.byHourOfDay[0]); return this.nextValidDate(args, date); } } class ByMinuteOfHourPipe extends PipeRule { run(args) { if (args.invalidDate) { return this.nextPipe.run(args); } let date = args.date; const currentMinute = date.get('minute'); for (const minute of this.options.byMinuteOfHour) { if (currentMinute > minute) continue; if (currentMinute === minute) return this.nextPipe.run({ date }); return this.nextValidDate(args, date.granularity('hour').set('minute', minute)); } date = date .granularity('hour') .add(1, 'hour') .set('minute', this.options.byMinuteOfHour[0]); return this.nextValidDate(args, date); } } class BySecondOfMinutePipe extends PipeRule { run(args) { if (args.invalidDate) { return this.nextPipe.run(args); } let date = args.date; const currentSecond = date.get('second'); for (const second of this.options.bySecondOfMinute) { if (currentSecond > second) continue; if (currentSecond === second) return this.nextPipe.run({ date }); return this.nextValidDate(args, date.granularity('minute').set('second', second)); } date = date .granularity('minute') .add(1, 'minute') .set('second', this.options.bySecondOfMinute[0]); return this.nextValidDate(args, date); } } class ResultPipe extends PipeRule { constructor() { super(...arguments); this.invalidIterationCount = 0; } // This pipe exists to facilitate the adding of dev callbacks to an iteration // of the pipe. It is meant to always be the last pipe in the chain. run(args) { if (this.end && args.date.isAfter(this.end)) { return null; } if (args.invalidDate) { // To prevent getting into an infinite loop. // - I somewhat arbitrarily chose 50 // - I noticed that, when limited to 10 iterations, some tests failed this.invalidIterationCount++; if (this.invalidIterationCount > 50) { throw new PipeError('Failed to find a single matching occurrence in 50 iterations. ' + `Last iterated date: "${args.date.toISOString()}"`); } } else { if (this.previousIterationDate && this.previousIterationDate.isAfterOrEqual(args.date)) { console.error(`Previous run's date is after or equal current run's date of "${args.date.toISOString()}". ` + 'This is probably caused by a bug.'); return null; } this.previousIterationDate = args.date; this.invalidIterationCount = 0; } return args.invalidDate ? this.firstPipe.run(args) : args.date; } } class ByMillisecondOfSecondPipe extends PipeRule { run(args) { if (args.invalidDate) { return this.nextPipe.run(args); } let date = args.date; const currentMillisecond = date.get('millisecond'); for (const millisecond of this.options.byMillisecondOfSecond) { if (currentMillisecond > millisecond) continue; if (currentMillisecond === millisecond) return this.nextPipe.run({ date }); return this.nextValidDate(args, date.granularity('second').set('millisecond', millisecond)); } date = date .granularity('second') .add(1, 'second') .set('millisecond', this.options.byMillisecondOfSecond[0]); return this.nextValidDate(args, date); } } /** * The `RevFrequencyPipe` is the first pipe in the chain of rule pipes. It