datetime.js

/**
 * 
 * @class DateTime contains various date and time utility functions.
 * @hideconstructor
 *
 * @copyright (c) 2021 TLF Research Ltd.
 */
function DateTime() { }

DateTime.JANUARY = 0;
DateTime.FEBRUARY = 1;
DateTime.MARCH = 2;
DateTime.APRIL = 3;
DateTime.MAY = 4;
DateTime.JUNE = 5;
DateTime.JULY = 6;
DateTime.AUGUST = 7;
DateTime.SEPTEMBER = 8;
DateTime.OCTOBER = 9;
DateTime.NOVEMBER = 10;
DateTime.DECEMBER = 11;

DateTime.MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
DateTime.MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

DateTime.SUNDAY = 0;
DateTime.MONDAY = 1;
DateTime.TUESDAY = 2;
DateTime.WEDNESDAY = 3;
DateTime.THURSDAY = 4;
DateTime.FRIDAY = 5;
DateTime.SATURDAY = 6;

DateTime.DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
DateTime.DAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];

DateTime.rxDateISO8601YearMonth = /^(\d{4})-(\d{2})$/;
DateTime.rxDateISO8601Short = /^(\d{4})-(\d{2})-(\d{2})$/;
DateTime.rxDateISO8601Long = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:[+\-]\d\d:?\d\d|[+\-]\d\d|Z)?$/;

/**
 * Returns an object contaiing a set of preset date ranges, optionally based on the given date.
 *
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56');
 * const presets = DateTime.getPresets(base, 3);
 * // presets is:
 * // {
 * //   today: {starts: Sun Mar 15 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Sun Mar 15 2020 23:59:59 GMT+0000 (Greenwich Mean Time)},
 * //   yesterday: {starts: Sat Mar 14 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Sat Mar 14 2020 23:59:59 GMT+0000 (Greenwich Mean Time)},
 * //   thisWeek: {starts: Mon Mar 09 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Sun Mar 15 2020 23:59:59 GMT+0000 (Greenwich Mean Time)},
 * //   lastWeek: {starts: Mon Mar 02 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Sun Mar 08 2020 23:59:59 GMT+0000 (Greenwich Mean Time)},
 * //   thisMonth: {starts: Sun Mar 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Tue Mar 31 2020 23:59:59 GMT+0100 (British Summer Time)},
 * //   thisMonthLastYear: {starts: Fri Mar 01 2019 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Sun Mar 31 2019 23:59:59 GMT+0100 (British Summer Time)},
 * //   lastMonth: {starts: Sat Feb 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Sat Feb 29 2020 23:59:59 GMT+0000 (Greenwich Mean Time)},
 * //   thisQuarter: {starts: Wed Jan 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Tue Mar 31 2020 23:59:59 GMT+0100 (British Summer Time)},
 * //   lastQuarter: {starts: Tue Oct 01 2019 00:00:00 GMT+0100 (British Summer Time), ends: Tue Dec 31 2019 23:59:59 GMT+0000 (Greenwich Mean Time)},
 * //   thisYear: {starts: Wed Jan 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Thu Dec 31 2020 23:59:59 GMT+0000 (Greenwich Mean Time)},
 * //   lastYear: {starts: Tue Jan 01 2019 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Tue Dec 31 2019 23:59:59 GMT+0000 (Greenwich Mean Time)}
 * //   thisFinancialYear: {starts: Wed Apr 01 2019 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Thu Mar 31 2020 23:59:59 GMT+0000 (Greenwich Mean Time)},
 * //   lastFinancialYear: {starts: Wed Apr 01 2018 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Thu Mar 31 2019 23:59:59 GMT+0000 (Greenwich Mean Time)},
 * //   twoYearsAgoFinancialYear: {starts: Wed Apr 01 2017 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Thu Mar 31 2018 23:59:59 GMT+0000 (Greenwich Mean Time)},
 * // }
 * 
 * @param {date} [date=today] Base date for presets
 * @param {date} [quarterStartMonth=0] Zer-based month when a new quarter starts (default is 0 = Jan)
 * 
 * @return {object}
 */
DateTime.getPresets = function (date, quarterStartMonth = 0) {

    date = date || new Date();

    const todayStarts = DateTime.setStartOfDay(date);
    const todayEnds = DateTime.setEndOfDay(date);
    const lastMondayStarts = date.getDay() === 1 ? DateTime.setStartOfDay(date) : DateTime.getPreviousDay(todayStarts, DateTime.MONDAY);
    const lastSundayEnds = new Date(lastMondayStarts.getTime() - 1000);
    const firstOfThisMonth = DateTime.getFirstOfMonth(todayStarts);
    const firstOfThisMonthLastYear = DateTime.addYears(firstOfThisMonth, -1);
    const thisYearStarts = DateTime.getFirstOfYear(todayStarts);

    return {
        today: {
            starts: todayStarts,
            ends: todayEnds
        },
        yesterday: {
            starts: DateTime.addDays(todayStarts, -1),
            ends: DateTime.addDays(todayEnds, -1)
        },
        thisWeek: {
            starts: lastMondayStarts,
            ends: DateTime.setEndOfDay(DateTime.addDays(lastMondayStarts, 6))
        },
        lastWeek: {
            starts: DateTime.setStartOfDay(DateTime.getPreviousDay(lastSundayEnds, DateTime.MONDAY)),
            ends: lastSundayEnds
        },
        thisMonth: {
            starts: firstOfThisMonth,
            ends: DateTime.getLastOfMonth(date)
        },
        thisMonthLastYear: {
            starts: firstOfThisMonthLastYear,
            ends: DateTime.getLastOfMonth(firstOfThisMonthLastYear)
        },
        lastMonth: {
            starts: DateTime.addMonths(firstOfThisMonth, -1),
            ends: DateTime.setEndOfDay(DateTime.addDays(firstOfThisMonth, -1))
        },
        thisQuarter: DateTime.getQuarter(0, DateTime.JANUARY, date),
        lastQuarter: DateTime.getQuarter(-1, DateTime.JANUARY, date),
        thisYear: {
            starts: thisYearStarts,
            ends: DateTime.getLastOfYear(date)
        },
        lastYear: {
            starts: DateTime.addYears(thisYearStarts, -1),
            ends: DateTime.setEndOfDay(DateTime.addDays(thisYearStarts, -1))
        },
        thisFinancialYear: this.getCurrentFinancialYear(todayStarts, quarterStartMonth),
        lastFinancialYear: this.getCurrentFinancialYear(DateTime.addYears(todayStarts, -1), quarterStartMonth),
        twoYearsAgoFinancialYear: this.getCurrentFinancialYear(DateTime.addYears(todayStarts, -2), quarterStartMonth),
    }
};

/**
 * Adds (subtracts if <code>months</code> is negative) the given number of months to the given date.  The supplied <code>date</code> object remains unchanged.
 * It's smart about handling months of different lengths so e.g. 31 Oct + 1 month = 30 Nov and 31 March - 1 month = 28 (or 29) Feb.
 * 
 * @example
 * 
 * const base = new Date('March 31, 2020 12:34:56');
 * const d1 = DateTime.addMonths(base, 1);
 * const d2 = DateTime.addMonths(base, -1);
 * // d1 is Thu Apr 30 2020 12:34:56 GMT+0100 (British Summer Time)
 * // d2 is Sat Feb 29 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 *
 * @param {date} date Base date for calculation
 * @param {number} months Number of months to add or subtract if negative)
 *
 * @returns {date} Modified date
 */
DateTime.addMonths = function (date, months) {

    if (!(date instanceof Date)) {
        console.error(`${date} is not a valid Date object`);
        return date;
    };

    let clone = new Date(date.getTime());
    const currentDate = clone.getDate();
    const currentLastDate = new Date(clone.getFullYear(), clone.getMonth() + 1, 0).getDate();
    const targetLastDate = new Date(clone.getFullYear(), clone.getMonth() + 1 + months, 0).getDate();

    if (currentDate > targetLastDate) {
        clone.setDate(targetLastDate);
    }

    clone.setMonth(clone.getMonth() + months);

    if (currentDate === currentLastDate) {
        clone.setDate(targetLastDate);
    }

    return clone;
};

/**
 * Returns a 'YYYY-MM-DD' formatted date string suitable for inclusion in an SQL query.
 * 
 * @example
 * 
 * const base = new Date('March 31, 2020 12:34:56');
 * const sql = DateTime.getSQLDate(base);
 * // sql is "2020-03-31"
 * 
 * @param {date} date Date to be formatted
 * @return {string} Formatted date
 */
DateTime.getSQLDate = function (date) {
    return date ? date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') + '-' + String(date.getDate()).padStart(2, '0') : '';
};

/**
 * Returns a 'YYYY-MM' formatted date string suitable for inclusion in an SQL query.
 * 
 * @example
 * 
 * const base = new Date('March 31, 2020 12:34:56');
 * const sql = DateTime.getSQLDate(base);
 * // sql is "2020-03"
 * 
 * @param {date} date Date to be formatted
 * @return {string} Formatted date
 */
DateTime.getSQLMonth = function (date) {
    return date ? date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0') : '';
};

/**
 * Returns a 'Qn yyyy' formatted date string
 * 
 * @example
 * 
 * const base = new Date('March 31, 2020 12:34:56');
 * const s = DateTime.toQuarter(base);
 * // s is "Q1 2020"
 * 
 * @param {date|string} Date to be formatted
 * @param {number} [starts] Month that Q1 starts (0-based) - default 0 (January)
 * @return {string} Formatted date
 */
DateTime.toQuarter = function (date, starts = 0) {
    const dateString = typeof date === 'string' ? date : DateTime.getSQLDate(date);

    const dateParts = dateString.split('-');
    if (dateParts.length < 2) {
        console.error(`${date} is not a valid date - must be of the form YYYY-MM or YYYY-MM-DD`);
        return 'INVALID';
    }

    const year = parseInt(dateParts[0], 10);
    if (Number.isNaN(year) || year < 2015 || year > 2035) {
        console.error(`${date} is not a valid date - must be of the form YYYY-MM or YYYY-MM-DD`);
        return 'INVALID';
    }

    const month = parseInt(dateParts[1], 10);
    if (Number.isNaN(month) || month < 1 || month > 12) {
        console.error(`${date} is not a valid date - must be of the form YYYY-MM or YYYY-MM-DD`);
        return 'INVALID';
    }

    const q = Math.ceil((month - starts) / 3);
    const quarter = q < 1 ? q + 4 : q;

    return `Q${quarter} ${year}`;
};

/**
 * Returns an SQL <code>BETWEEN</code> clause to select all dates in the given range.
 * 
 * @example
 * 
 * const start = new Date('March 15, 2020 12:34:56');
 * const end = new Date('July 11, 2020 12:34:56');
 * const sql = DateTime.getSQLRange(start, end);
 * // sql is 'BETWEEN "2020-03-15 00:00:00" AND "2020-07-11 23:59:59"'
 *
 * @param {date} from Start of date range
 * @param {date} to End of date range
 *
 * @return {string} SQL <code>BETWEEN</code> clause required to <code>SELECT</code> the given range
 */
DateTime.getSQLRange = function (from, to) {
    return 'BETWEEN "' + DateTime.getSQLDate(from) + ' 00:00:00" AND "' + DateTime.getSQLDate(to) + ' 23:59:59"';
};

/**
 * Adds (subtracts if <code>years</code> is negative) the given number of years to the given date (subtracts if year is negative). The supplied <code>date</code> object remains unchanged.
 * It handles leap years properly so 29 Feb 2020 - 1 year = 28 Feb 2019, but 29 Feb 2020 - 4 years = 29 Feb 2016
 * 
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56');
 * const d1 = DateTime.addYears(base, 1);
 * const d2 = DateTime.addYears(base, -1);
 * // d1 is Mon Mar 15 2021 12:34:56 GMT+0000 (Greenwich Mean Time)
 * // d2 is Fri Mar 15 2019 12:34:56 GMT+0000 (Greenwich Mean Time)
 * 
 * @param {date} date Base date for calculation
 * @param {number} years Number of years to add (or subtract if negative)
 *
 * @returns {date} Modified date
 */
DateTime.addYears = function (date, years) {

    if (!(date instanceof Date)) {
        console.error(`${date} is not a valid Date object`);
        return date;
    };

    let result = new Date(date.getTime());

    const wasFeb28 = result.getMonth() === 1 && result.getDate() === 28;
    const wasFeb29 = result.getMonth() === 1 && result.getDate() === 29;
    const oldYearWasLeap = result.getFullYear() % 4 === 0;
    const newYearIsLeap = (result.getFullYear() + years) % 4 === 0;

    if (wasFeb29 && !newYearIsLeap) {
        result.setDate(28);
    }

    result.setYear(result.getFullYear() + years);

    if (wasFeb28 && !oldYearWasLeap && newYearIsLeap) {
        result.setDate(29);
    }

    return result;
};

/**
 * Returns a date with one or more elements set in the given base date.
 * The supplied <code>date</code> object remains unchanged.
 * NOTE: The m parameter is zero-based.
 * 
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56');
 * const d1 = DateTime.getAbsoluteDate(1999, null, null, base);
 * const d2 = DateTime.getAbsoluteDate(null, 1, null, base);
 * const d3 = DateTime.getAbsoluteDate(null, null, 3, base);
 * // d1 is Mon Mar 15 1999 12:34:56 GMT+0000 (Greenwich Mean Time)
 * // d2 is Sat Feb 15 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 * // d3 is Tue Mar 03 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 * 
 * @param {number|null} y Set absolute year.
 * @param {number|null} m Set absolute month index (0-based).
 * @param {number|null} d Set absolute date.
 * @param {date} [base=today] Base date to apply the absolute values to.
 *
 * @returns {date} Modified date
 */
DateTime.getAbsoluteDate = function (y, m, d, base) {

    let clone = base ? new Date(base.getTime()) : new Date();

    if (y !== null) {
        clone.setFullYear(y)
    }
    if (m !== null) {
        clone.setMonth(m)
    }
    if (d !== null) {
        clone.setDate(d)
    }

    return clone;
};

/**
 * Adds (subtracts if <code>days</code> is negative) the given number of days to the given date.  The supplied <code>date</code> object remains unchanged.
 * 
 * @example
 * 
 * const base = new Date('March 1, 2020 12:34:56');
 * const d1 = DateTime.addDays(base, 1);
 * const d2 = DateTime.addDays(base, -1);
 * // d1 is Mon Mar 02 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 * // d2 is Sat Feb 29 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 *
 * @param {date} date Base date for calculation
 * @param {number} days Number of days to add (or subtract if negative)
 *
 * @returns {date} Modified date
 */
DateTime.addDays = function (date, days) {

    if (!(date instanceof Date)) {
        console.error(`${date} is not a valid Date object`);
        return date;
    };

    let clone = new Date(date.getTime());
    clone.setDate(clone.getDate() + days);

    return clone;
};

/**
 * Returns the date representing the first day of the year in the given date (or the first day of the current year if no date is provided).
 * The supplied <code>date</code> object remains unchanged.
 * 
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56');
 * const d = DateTime.getFirstOfYear(base);
 * // d is Wed Jan 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time)
 * 
 * @param {date} [date=today] Base date for calculation
 *
 * @return {date} First day of the year for the given date
 */
DateTime.getFirstOfYear = function (date) {
    date = date || new Date();

    return new Date(date.getFullYear(), 0, 1);
};

/**
 * Returns the date representing the last day of the year in the given date (or the last day of the current year if no date is provided).
 * The supplied <code>date</code> object remains unchanged.
 * 
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56');
 * const d = DateTime.getLastOfYear(base);
 * // d is Thu Dec 31 2020 23:59:59 GMT+0000 (Greenwich Mean Time)
 * 
 * @param {date} [date=today] Base date for calculation
 *
 * @return {date} Last day of the year for the given date
 */
DateTime.getLastOfYear = function (date) {
    date = date || new Date();

    return DateTime.setEndOfDay(new Date(date.getFullYear(), 11, 31));
};

/**
 * Returns the date representing the first day of the given date's month (or the first day of the current month if no date is provided).
 * The supplied <code>date</code> object remains unchanged.
 * 
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56');
 * const d = DateTime.getFirstOfMonth(base);
 * // d is Sun Mar 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time)
 * 
 * @param {date} [date=today] Base date for calculation
 *
 * @return {date} First day of the given date's month
 */
DateTime.getFirstOfMonth = function (date) {
    date = date || new Date();

    return new Date(date.getFullYear(), date.getMonth(), 1);
};

/**
 * Returns the last day of the given date's month (or the current month if no date is provided).
 * The supplied <code>date</code> object remains unchanged.
 * 
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56');
 * const d = DateTime.getLastOfMonth(base);
 * // d is Tue Mar 31 2020 23:59:59 GMT+0100 (British Summer Time)
 *
 * @param {date} [date=today] Base date for calculation
 *
 * @return {date} Last day of the given date's month
 */
DateTime.getLastOfMonth = function (date) {
    date = date || new Date();

    return DateTime.setEndOfDay(new Date(date.getFullYear(), date.getMonth() + 1, 0));
};

/**
 * Returns the date of the last day of the given month in the given year.
 * 
 * @example
 * 
 * const d1 = DateTime.getLastOfMonthDate(1, 2020);
 * const d2 = DateTime.getLastOfMonthDate(1, 2021);
 * // d1 is 29
 * // d2 is 28
 *
 * @param {number} month Zero-based month
 * @param {number} year Year
 *
 * @return {number} Last day of the given month in the given year
 */
DateTime.getLastOfMonthDate = function (month, year) {
    return new Date(year, month + 1, 0).getDate();
};

/**
 * Returns the date of the previous weekday relative to the given date.
 * If the base <code>date</code>'s weekday matches the required weekday parameter then the returned result is the same as the base date.
 * The supplied <code>date</code> object remains unchanged.
 * 
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56'); // Sunday
 * const d1 = DateTime.getPreviousDay(base, DateTime.SUNDAY);
 * const d2 = DateTime.getPreviousDay(base, DateTime.MONDAY);
 * const d3 = DateTime.getPreviousDay(base, DateTime.SATURDAY); 
 * // d1 is Sun Mar 15 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 * // d2 is Mon Mar 09 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 * // d3 is Sat Mar 14 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 *
 * @param {date} date Base date for calculation
 * @param {number} weekday (0 = Sunday, 1 = Monday...)
 *
 * @return {date} Date of the previous weekday relative to the given date.
 */
DateTime.getPreviousDay = function (date, weekday) {

    if (!(date instanceof Date)) {
        console.error(`${date} is not a valid Date object`);
        return date;
    };

    let clone = new Date(date.getTime());
    const currDay = clone.getDay();
    clone.setDate(clone.getDate() - currDay); // last Sunday
    const delta = weekday < currDay ? weekday : weekday - 7;

    return DateTime.addDays(clone, delta)
};

/**
 * Returns the date of 'this' weekday relative to the given date. The returned date may be in the future.
 * If the given date is already on the requested weekday then it's returned, otherwise the date of the previous weekday is returned.
 * The supplied <code>date</code> object remains unchanged.
 *
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56'); // Sunday
 * const d1 = DateTime.getThisDay(base, DateTime.SUNDAY);
 * const d2 = DateTime.getThisDay(base, DateTime.MONDAY);
 * const d3 = DateTime.getThisDay(base, DateTime.SATURDAY); 
 * // d1 is Sun Mar 15 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 * // d2 is Mon Mar 09 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 * // d3 is Sat Mar 14 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 * 
 * @param {date} date Base date for calculation
 * @param {number} weekday (0 = Sunday, 1 = Monday...)
 *
 * @return {date} Date of 'this' weekday relative to the given date
 */
DateTime.getThisDay = function (date, weekday) {

    if (!(date instanceof Date)) {
        console.error(`${date} is not a valid Date object`);
        return date;
    };

    return date.getDay() === weekday ? date : DateTime.getPreviousDay(DateTime.addDays(date, 7), weekday);
};

/**
 * Returns a range object that defines the current financial year.
 * 
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56'); // Sunday
 * const d1 = DateTime.getCurrentFinancialYear(base, DateTime.JANUARY);
 * const d2 = DateTime.getCurrentFinancialYear(base, DateTime.FEBRUARY);
 * const d3 = DateTime.getCurrentFinancialYear(base, DateTime.MARCH); 
 * const d4 = DateTime.getCurrentFinancialYear(base, DateTime.APRIL); 
 * const d5 = DateTime.getCurrentFinancialYear(base, DateTime.OCTOBER); 
 * const d6 = DateTime.getCurrentFinancialYear(base, DateTime.DECEMBER); 
 * // d1 is {starts: Wed Jan 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Thu Dec 31 2020 00:00:00 GMT+0000 (Greenwich Mean Time)}
 * // d2 is {starts: Sat Feb 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Sun Jan 31 2021 00:00:00 GMT+0000 (Greenwich Mean Time)
 * // d3 is {starts: Sun Mar 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Sun Feb 28 2021 00:00:00 GMT+0000 (Greenwich Mean Time)}
 * // d4 is {starts: Mon Apr 01 2019 00:00:00 GMT+0100 (British Summer Time), ends: Tue Mar 31 2020 00:00:00 GMT+0100 (British Summer Time)}
 * // d5 is {starts: Tue Oct 01 2019 00:00:00 GMT+0100 (British Summer Time), ends: Wed Sep 30 2020 00:00:00 GMT+0100 (British Summer Time)}
 * // d6 is {starts: Sun Dec 01 2019 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Mon Nov 30 2020 00:00:00 GMT+0000 (Greenwich Mean Time)}
 *
 * @param {date} [date=today] Date to base the calculation on.
 * @param {number} [starts] Month that the FY starts (0-based) - default 0 (January)
 *
 * @return {object} Range object
 */
DateTime.getCurrentFinancialYear = function (base = new Date(), starts = 0) {
    const fyStarts = new Date(base.getFullYear(), starts, 1);
    const fyEnds = DateTime.addDays(DateTime.addYears(fyStarts, 1), -1);
    const yearHasAlreadyStarted = base >= fyStarts;

    return {
        starts: yearHasAlreadyStarted ? fyStarts : DateTime.addYears(fyStarts, -1),
        ends: yearHasAlreadyStarted ? fyEnds : DateTime.addYears(fyEnds, -1)
    }
};

/**
 * Returns a range object that defines the requested quarter.
 * 
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56'); // Sunday
 * const curr = DateTime.getQuarter(0, DateTime.JANUARY, base);
 * const q1 = DateTime.getQuarter(1, DateTime.JANUARY, base);
 * const q2 = DateTime.getQuarter(2, DateTime.FEBRUARY, base);
 * const lq = DateTime.getQuarter(-1, DateTime.JANUARY, base);
 * // curr is {starts: Wed Jan 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Tue Mar 31 2020 23:59:59 GMT+0100 (British Summer Time)}
 * // q1 is {starts: Wed Jan 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time), ends: Tue Mar 31 2020 23:59:59 GMT+0100 (British Summer Time)}
 * // q2 is {starts: Fri May 01 2020 00:00:00 GMT+0100 (British Summer Time), ends: Fri Jul 31 2020 23:59:59 GMT+0100 (British Summer Time)}
 * // lq is {starts: Tue Oct 01 2019 00:00:00 GMT+0100 (British Summer Time), ends: Tue Dec 31 2019 23:59:59 GMT+0000 (Greenwich Mean Time)}
 * 
 * @param {number} [quarter] If positive, returns the nth quarter (1 - 4); if 0 (default) returns the current quarter; if negative returns n quarters ago.
 * @param {number} [startMonth] Calendar month corresponding to the start of the first quarter (0-based) - default January
 * @param {date} [date=today] Date to base the calculation on.
 *
 * @return {object} Range object
 */
DateTime.getQuarter = function (quarter, startMonth, date) {

    const baseDate = date || new Date();

    quarter = quarter || 0;
    startMonth = startMonth || DateTime.JANUARY;

    const thisMonth = baseDate.getMonth();
    const thisYear = baseDate.getFullYear();

    // The base starts off as the beginning of the 1st quarter
    let base = thisMonth < startMonth ? new Date(thisYear - 1, startMonth, 1) : new Date(thisYear, startMonth, 1);

    // For zero or negative quarters move the base to the current quarter 
    if (quarter <= 0) {
        const currentQuarter = thisMonth < startMonth ? Math.floor((12 + thisMonth - startMonth) / 3) + 1 : Math.floor((thisMonth - startMonth) / 3) + 1;
        base = DateTime.addMonths(base, 3 * (currentQuarter - 1))
        quarter = quarter + 1;
    }

    return {
        starts: DateTime.addMonths(base, 3 * (quarter - 1)),
        ends: DateTime.setEndOfDay(DateTime.addDays(DateTime.addMonths(base, 3 * quarter), -1))
    }
};

/**
 * Returns the number of the quarter for the given date and start month.
 * 
 * @example
 * const base = new Date('March 15, 2020 12:34:56'); // Sunday
 * console.log(DateTime.getQuarterNumber(base, DateTime.JANUARY)); // Outputs 1
 *
 * @param {date} date The input date for which we want to find the quarter number.
 * @param {number} startMonth Calendar month corresponding to the start of the first quarter (0-based) - default January
 *
 * @return {number} Quarter number in range 1 to 4
 */
DateTime.getQuarterNumber = function (date, startMonth) {
    const baseDate = date || new Date();
    startMonth = startMonth || DateTime.JANUARY;
    const thisMonth = baseDate.getMonth();
    // Calculate the current quarter based on the input date and start month
    const currentQuarter = Math.floor((thisMonth - startMonth + 3) / 3);

    return currentQuarter;
}

/**
 * Parses a string in an ISO8601-compliant format i.e. <code>YYYY-MM</code>, <code>YYYY-MM-DD</code>, <code>YYYY-MM-DDThh:mm:ss.ssssZ</code> or <code>YYYY-MM-DDThh:mm:ss.ssss+HH:MM</code>
 * <br>If the date is not in a supported format then null is returned.
 * NOTE: If provided the timezone is parsed but its value is ignored. 
 * 
 * @example
 * 
 * const d1 = DateTime.parseISO8601("2020-03");
 * const d2 = DateTime.parseISO8601("2020-03-15");
 * const d3 = DateTime.parseISO8601("2020-03-15T12:34:56Z");
 * const d4 = DateTime.parseISO8601("2020-03-15T12:34:56.789+01:00");
 * const d5 = DateTime.parseISO8601("2020-03-15T12:34:56-02:00");
 * // d1 is Sun Mar 01 2020 00:00:00 GMT+0000 (Greenwich Mean Time)
 * // d2 is Sun Mar 15 2020 00:00:00 GMT+0000 (Greenwich Mean Time)
 * // d2 is Sun Mar 15 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 * // d3 is Sun Mar 15 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 * // d4 is Sun Mar 15 2020 12:34:56 GMT+0000 (Greenwich Mean Time)
 *
 * @param {string} s Date string to be parsed
 *
 * @return {date|null} Parsed date
 */
DateTime.parseISO8601 = function (s) {

    if (!s) {
        console.error('Missing date');
        return null;
    }

    // Handle YYYY-MM format
    let m = s.match(DateTime.rxDateISO8601YearMonth);
    if (m !== null) {
        return new Date(m[1], m[2] - 1, 1);
    }

    // Handle YYYY-MM-DD format
    m = s.match(DateTime.rxDateISO8601Short);
    if (m !== null) {
        return new Date(m[1], m[2] - 1, m[3]);
    }

    // Handle YYYY-MM-DDThh:mm:ss.mmmnnnTZD format
    m = s.match(DateTime.rxDateISO8601Long);
    if (m === null) {
        console.error('Unsupported date format', s);
        return null;
    }

    return new Date(m[1], m[2] - 1, m[3], m[4], m[5], m[6]);
};

/**
 * Returns a new Date based on the given date with the time part set to 00:00:00.
 * The supplied <code>date</code> object remains unchanged.
 *
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56'); 
 * const d = DateTime.setStartOfDay(base);
 * // d is Sun Mar 15 2020 00:00:00 GMT+0000 (Greenwich Mean Time)
 *
 * @param {date} date Date to base the calculation on.
 *
 * @return {date} New date
 */
DateTime.setStartOfDay = function (date) {

    if (!(date instanceof Date)) {
        console.error(`${date} is not a valid Date object`);
        return date;
    };

    let clone = new Date(date.getTime());
    clone.setHours(0);
    clone.setMinutes(0);
    clone.setSeconds(0);
    clone.setMilliseconds(0);

    return clone;
};

/**
 * Returns a new Date based on the given date with the time part set to 23:59:59.
 * The supplied <code>date</code> object remains unchanged.
 *
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56'); 
 * const d = DateTime.setEndOfDay(base);
 * // d is Sun Mar 15 2020 23:59:59 GMT+0000 (Greenwich Mean Time)
 *
 * @param {date} date Date to base the calculation on.
 *
 * @return {date} New date
 */
DateTime.setEndOfDay = function (date) {

    if (!(date instanceof Date)) {
        console.error(`${date} is not a valid Date object`);
        return date;
    };

    let clone = new Date(date.getTime());
    clone.setHours(23);
    clone.setMinutes(59);
    clone.setSeconds(59);
    clone.setMilliseconds(999);

    return clone;
};

/**
 * Returns the long name of the month in the given date.
 *
 * @example
 * 
 * const base = new Date('March 15, 2020 12:34:56'); 
 * const d = DateTime.getLongMonthName(base);
 * // d is 'March'
 *
 * @param {date} date Date to base the calculation on.
 * 
 * @return {string}
 */
DateTime.getLongMonthName = function (date) {

    if (!(date instanceof Date)) {
        console.error(`${date} is not a valid Date object`);
        return date;
    };

    return date.toLocaleString('en-gb', {
        month: 'long'
    });
};

/**
 * Returns the short name of the month in the given date.
 *
 * @example
 * 
 * const base = new Date('April 15, 2020 12:34:56'); 
 * const d = DateTime.getShortMonthName(base);
 * // d is 'Apr'
 *
 * @param {date} date Date to base the calculation on.
 * 
 * @return {string} Month name
 */
DateTime.getShortMonthName = function (date) {

    if (!(date instanceof Date)) {
        console.error(`${date} is not a valid Date object`);
        return date;
    };

    return date.toLocaleString('en-gb', {
        month: 'short'
    });
};

/**
 * Returns an array of formatted strings of the form 'YYYY-MM' across the required closed range [start, end].
 *
 * @example
 * 
 * const months = getAllMonths(new Date('2022-02), new Date('2023-01)); 
 * 
 * // months is [
 *     '2022-02',
 *     '2022-03',
 *     '2022-04',
 *     '2022-05',
 *     '2022-06',
 *     '2022-07',
 *     '2022-08',
 *     '2022-09',
 *     '2022-10',
 *     '2022-11',
 *     '2022-12',
 *     '2023-01'
 * ]
 *
 * @param {start} date Start date of the range to be generated.
 * @param {end} date End date of the range to be generated.
 * 
 * @return {array} Array of strings of the form 'YYYY-MM' across the required range.
 */
DateTime.getAllMonths = function (start, end) {

    if (!(start instanceof Date)) {
        console.error(`Start ${date} is not a valid Date object`);
        return date;
    };

    if (!(end instanceof Date)) {
        console.error(`End ${date} is not a valid Date object`);
        return date;
    };

    if (end < start) {
        console.error('start must be before end');
        return;
    }

    start.setDate(1);
    end.setDate(1);

    const results = [];

    try {
        results.push(fmt(start));
        while (fmt(start) < fmt(end)) {
            start = this.addMonths(start, 1);
            results.push(fmt(start));
        }
    } catch (e) {
        console.error(e);
    }

    return results;

    function fmt(d) {
        const year = d.toLocaleString('default', { year: 'numeric' });
        const month = d.toLocaleString('default', { month: '2-digit' });
        return `${year}-${month}`;
    }
}