import { ArgumentError } from './basic-utilities'; export const WEEKDAYS: ReadonlyArray<'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA'> = [ 'SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA', ]; export const MILLISECONDS_IN_SECOND = 1000; export const MILLISECONDS_IN_MINUTE = MILLISECONDS_IN_SECOND * 60; export const MILLISECONDS_IN_HOUR = MILLISECONDS_IN_MINUTE * 60; export const MILLISECONDS_IN_DAY = MILLISECONDS_IN_HOUR * 24; export const MILLISECONDS_IN_WEEK = MILLISECONDS_IN_DAY * 7; export interface IDateAdapter { /** Returns the date object this DateAdapter is wrapping */ readonly date: D; readonly timezone: string | null; readonly duration: number | undefined; /** * This property contains an ordered array of the generator objects * responsible for producing this IDateAdapter. * * - If this IDateAdapter was produced by a `Rule` object, this array * will just contain the `Rule` object. * - If this IDateAdapter 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 IDateAdapter was produced by a `Calendar` object, this * array will contain, at minimum, the `Calendar`, `Schedule`, and * `Rule`/`Dates` objects which generated it. */ readonly generators: unknown[]; valueOf(): number; toISOString(): string; // isEqual(object?: IDateAdapter | DateTime): boolean; // isBefore(object: IDateAdapter | DateTime): boolean; // isBeforeOrEqual(object: IDateAdapter | DateTime): boolean; // isAfter(object: IDateAdapter | DateTime): boolean; // isAfterOrEqual(object: IDateAdapter | DateTime): boolean; toDateTime(): DateTime; toJSON(): IDateAdapter.JSON; assertIsValid(): boolean; } export namespace IDateAdapter { export type Weekday = 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA'; export type TimeUnit = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'millisecond'; export interface JSON { timezone: string | null; duration?: number; year: number; month: number; day: number; hour: number; minute: number; second: number; millisecond: number; } export type Year = number; export type YearDay = number; export type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; // >= 1 && <= 31 export type Day = | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31; // >= 0 && <= 23 export type Hour = | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23; // >= 0 && <= 59 export type Minute = | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59; export type Second = Minute; export type Millisecond = number; } export class InvalidDateTimeError extends Error {} const DATETIME_ID = Symbol.for('b1231462-3560-4770-94f0-d16295d5965c'); export class DateTime implements IDateAdapter { /** * 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: any): object is DateTime { return !!(object && object[DATETIME_ID]); } static fromJSON(json: IDateAdapter.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: IDateAdapter) { return DateTime.fromJSON(adapter.toJSON()); } readonly date: Date; /** * 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. */ readonly generators: unknown[] = []; readonly [DATETIME_ID] = true; readonly timezone: string | null; readonly duration: number | undefined; private _end: DateTime | undefined; private constructor(date: Date, timezone?: string | null, duration?: number) { this.date = new Date(date); this.timezone = timezone || null; this.duration = duration; this.assertIsValid(); } /** * Returns `undefined` if `this.duration` is falsey. Else returns * the `end` date. */ get end(): DateTime | undefined { 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?: DateTime): boolean { if (!object) { return false; } assertSameTimeZone(this, object); return this.valueOf() === object.valueOf(); } isBefore(object: DateTime): boolean { assertSameTimeZone(this, object); return this.valueOf() < object.valueOf(); } isBeforeOrEqual(object: DateTime): boolean { assertSameTimeZone(this, object); return this.valueOf() <= object.valueOf(); } isAfter(object: DateTime): boolean { assertSameTimeZone(this, object); return this.valueOf() > object.valueOf(); } isAfterOrEqual(object: DateTime): boolean { assertSameTimeZone(this, object); return this.valueOf() >= object.valueOf(); } isOccurring(object: DateTime) { 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: number, unit: IDateAdapter.TimeUnit | 'week'): DateTime { 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: number, unit: IDateAdapter.TimeUnit | 'week'): DateTime { 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: 'year'): IDateAdapter.Year; get(unit: 'yearday'): IDateAdapter.YearDay; get(unit: 'month'): IDateAdapter.Month; get(unit: 'weekday'): IDateAdapter.Weekday; get(unit: 'day'): IDateAdapter.Day; get(unit: 'hour'): IDateAdapter.Hour; get(unit: 'minute'): IDateAdapter.Minute; get(unit: 'second'): IDateAdapter.Second; get(unit: 'millisecond'): IDateAdapter.Millisecond; get(unit: IDateAdapter.TimeUnit | 'yearday' | 'weekday'): any { switch (unit) { case 'year': return this.date.getUTCFullYear() as IDateAdapter.Year; case 'month': return (this.date.getUTCMonth() + 1) as IDateAdapter.Month; case 'yearday': return getUTCYearDay(this.date) as IDateAdapter.YearDay; case 'weekday': return WEEKDAYS[this.date.getUTCDay()] as IDateAdapter.Weekday; case 'day': return this.date.getUTCDate() as IDateAdapter.Day; case 'hour': return this.date.getUTCHours() as IDateAdapter.Hour; case 'minute': return this.date.getUTCMinutes() as IDateAdapter.Minute; case 'second': return this.date.getUTCSeconds() as IDateAdapter.Second; case 'millisecond': return this.date.getUTCMilliseconds() as IDateAdapter.Millisecond; default: throw new ArgumentError('Invalid unit provided to `DateTime#set`'); } } set(unit: IDateAdapter.TimeUnit | 'duration', value: number): DateTime { 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: IDateAdapter.TimeUnit | 'week', opt: { weekStart?: IDateAdapter.Weekday } = {}, ) { 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: IDateAdapter.TimeUnit | 'week', opt: { weekStart?: IDateAdapter.Weekday } = {}, ) { 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(): IDateAdapter.JSON { 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; } private forkDateTime(date: Date) { return new DateTime(date, this.timezone, this.duration); } } function assertSameTimeZone(x: DateTime | IDateAdapter, y: DateTime | IDateAdapter) { 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: DateTime, wkst: IDateAdapter.Weekday) { const index = orderedWeekdays(wkst).indexOf(date.get('weekday')); return date.subtract(index, 'day'); } function setDateToEndOfWeek(date: DateTime, wkst: IDateAdapter.Weekday) { const index = orderedWeekdays(wkst).indexOf(date.get('weekday')); return date.add(6 - index, 'day'); } export function dateTimeSortComparer(a: DateTime, b: DateTime) { 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; } export function uniqDateTimes(dates: DateTime[]) { return Array.from( new Map(dates.map(date => [date.toISOString(), date]) as Array<[string, DateTime]>).values(), ); } export function orderedWeekdays(wkst: IDateAdapter.Weekday = 'SU') { const wkdays = WEEKDAYS.slice(); let index = wkdays.indexOf(wkst); while (index !== 0) { shiftArray(wkdays); index--; } return wkdays; } function shiftArray(array: any[], from: 'first' | 'last' = '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: number, year: number) { 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 as { [key: number]: number })[month]; } function getDaysInFebruary(year: number) { return isLeapYear(year) ? 29 : 28; } // taken from date-fn export function isLeapYear(year: number) { return year % 400 === 0 || (year % 4 === 0 && year % 100 !== 0); } export function getDaysInYear(year: number) { return isLeapYear(year) ? 366 : 365; } function getUTCYearDay(now: Date) { 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: any) { 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: Date, dirtyAmount: number) { 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: Date, input: number) { const amount = toInteger(input); return addUTCMonths(date, amount * 12); } function addUTCMonths(date: Date, input: number) { 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: Date, input: number) { const amount = toInteger(input); const days = amount * 7; return addUTCDays(date, days); } function addUTCDays(date: Date, input: number) { // 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: Date, input: number) { const amount = toInteger(input); return addMilliseconds(date, amount * MILLISECONDS_IN_HOUR); } function addUTCMinutes(date: Date, input: number) { const amount = toInteger(input); return addMilliseconds(date, amount * MILLISECONDS_IN_MINUTE); } function addUTCSeconds(date: Date, input: number) { const amount = toInteger(input); return addMilliseconds(date, amount * MILLISECONDS_IN_SECOND); } function addUTCMilliseconds(date: Date, input: number) { const amount = toInteger(input); const timestamp = date.getTime(); return new Date(timestamp + amount); } function subUTCYears(date: Date, amount: number) { return addUTCYears(date, -amount); } function subUTCMonths(date: Date, amount: number) { return addUTCMonths(date, -amount); } function subUTCWeeks(date: Date, amount: number) { return addUTCWeeks(date, -amount); } function subUTCDays(date: Date, amount: number) { return addUTCDays(date, -amount); } function subUTCHours(date: Date, amount: number) { return addUTCHours(date, -amount); } function subUTCMinutes(date: Date, amount: number) { return addUTCMinutes(date, -amount); } function subUTCSeconds(date: Date, amount: number) { return addUTCSeconds(date, -amount); } function subUTCMilliseconds(date: Date, amount: number) { return addUTCMilliseconds(date, -amount); }