DateUtils.js

/* global Utils */

/**
 * Created by Blake McBride on 3/15/18.
 */

'use strict';

/**
 Function names are meant to tell what the input and output date types they work on.
 In terms of understanding the data types used to represent dates:

 int          20180322
 str          "3/22/[20]18" (or similar international format)
 str2         "3/22/18" (or similar international format)
 str4         "3/22/2018" (or similar international format)
 SQL          "2018-03-22"
 Date         JavaScript Date object
 */
class DateUtils {

    /**
     * Detects the date format based on the given locale.
     *
     * @param {string} [locale=navigator.language] - optional locale used to determine the date format.
     * @return {string} The detected date format. Possible values are 'MM/DD/YYYY', 'DD/MM/YYYY', or 'Unknown Format'.
     */
    static detectDateFormat(locale = navigator.language) {
        if (!DateUtils.intFormat || DateUtils.locale !== locale) {
            DateUtils.locale = locale;
            const testDate = new Date(2023, 0, 21); // 21st January 2023
            const formattedDate = new Intl.DateTimeFormat(locale, {
                year: 'numeric',
                month: '2-digit',
                day: '2-digit'
            }).format(testDate);

            const parts = formattedDate.match(/(\d{2})/g);
            if (parts.length > 2) {
                if (parts[0] === '01') {
                    DateUtils.intFormat = 'MM/DD/YYYY';
                } else if (parts[1] === '01') {
                    DateUtils.intFormat = 'DD/MM/YYYY';
                }
            }
            if (!DateUtils.intFormat)
                DateUtils.intFormat = 'Unknown Format';
        }
        return DateUtils.intFormat;
    }

    /**
     * Converts a string in any of the following formats to an int YYYYMMDD:
     *     "mM/dD/yyYY" or "dD/mM/yyYY"  (depending on locale)
     *     "mmddyyyy" or "ddmmyyyy" (depending on locale)
     *     "yyyymmdd"
     *     "YYYY-MM-DD" or "YYYY/MM/DD"
     * Bad dates return 0
     * Takes into account local standard formats.
     *
     * @param {string} dateString
     * @returns {number}
     */
    static strToInt(dateString) {
        const format = DateUtils.detectDateFormat();
        dateString = dateString.trim();
        dateString = dateString.replace(/-/g, '/');
        dateString = dateString.replace(/\./g, '/');

        let day;
        let month;
        let year;

        if (format === "MM/DD/YYYY" && /^\d{1,2}\/\d{1,2}\/\d{2,4}/.test(dateString)) {  //  mM/dD/yyYY
            let sp = dateString.indexOf(" ");
            if (sp > 5)
                dateString = dateString.substring(0, sp);
            const parts = dateString.split("/");
            day = parseInt(parts[1], 10);
            month = parseInt(parts[0], 10);
            year = parseInt(parts[2], 10);
        } else if (format === "DD/MM/YYYY" && /^\d{1,2}\/\d{1,2}\/\d{2,4}/.test(dateString)) {  //  dD/mM/yyYY
            let sp = dateString.indexOf(" ");
            if (sp > 5)
                dateString = dateString.substring(0, sp);
            const parts = dateString.split("/");
            month = parseInt(parts[1], 10);
            day = parseInt(parts[0], 10);
            year = parseInt(parts[2], 10);
        } else if (/^\d{4}[-/]\d{2}[-/]\d{2}/.test(dateString)) {  // YYYY-MM-DD or YYYY/MM/DD
            year = parseInt(dateString.substring(0, 4), 10);
            month = parseInt(dateString.substring(5, 7), 10);
            day = parseInt(dateString.substring(8, 10), 10);
        } else if (/^\d{8}$/.test(dateString)) {  // NNNNNNNN
            if (format === "MM/DD/YYYY") {
                // assume MMDDYYYY
                month = parseInt(dateString.substring(0, 2), 10);
                day = parseInt(dateString.substring(2, 4), 10);
                year = parseInt(dateString.substring(4, 8), 10);
            } else {
                // assume DDMMYYYY
                day = parseInt(dateString.substring(0, 2), 10);
                month = parseInt(dateString.substring(2, 4), 10);
                year = parseInt(dateString.substring(4, 8), 10);
            }
            if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1900 || year > 2100) {
                // assume YYYYMMDD
                year = parseInt(dateString.substring(0, 4), 10);
                month = parseInt(dateString.substring(4, 6), 10);
                day = parseInt(dateString.substring(6, 8), 10);
            }
            if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1900 || year > 2100)
                return 0;
        } else
            return 0;

        if (year < 100) {
            let currentYear = new Date().getFullYear();
            let y19 = 1900 + year;
            let y20 = 2000 + year;
            year = Math.abs(currentYear - y19) < Math.abs(currentYear - y20) ? y19 : y20;
        }

        if (year < 1000 || year > 3000 || month < 1 || month > 12 || day < 1)
            return 0;

        const monthLength = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

        // Adjust for leap years
        if (year % 400 === 0 || (year % 100 !== 0 && year % 4 === 0))
            monthLength[1] = 29;

        if (day > monthLength[month - 1])
            return 0;

        return year * 10000 + month * 100 + day;
    }

    /**
     * Converts a string date ("YYYY-MM-DD") to an integer of the form YYYYMMDD.
     * Bad dates return 0
     *
     * @param {string} dateString
     * @returns {number}
     */
    static SQLtoInt(dateString) {
        dateString = dateString.trim();
        dateString = dateString.replace(/-/g, '/');
        dateString = dateString.replace(/\./g, '/');

        if (!/^\d{4,4}\/\d{1,2}\/\d{1,2}$/.test(dateString))
            return 0;

        const parts = dateString.split("/");
        const day = parseInt(parts[2], 10);
        const month = parseInt(parts[1], 10);
        let year = parseInt(parts[0], 10);

        if (year < 100) {
            let currentYear = new Date().getFullYear();
            let y19 = 1900 + year;
            let y20 = 2000 + year;
            year = Math.abs(currentYear - y19) < Math.abs(currentYear - y20) ? y19 : y20;
        }

        if (year < 1000 || year > 3000 || month < 1 || month > 12 || day < 1)
            return 0;

        let monthLength = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

        // Adjust for leap years
        if (year % 400 === 0 || (year % 100 !== 0 && year % 4 === 0))
            monthLength[1] = 29;

        if (day > monthLength[month - 1])
            return 0;

        return year * 10000 + month * 100 + day;
    }

    /**
     * Convert a number of milliseconds since 1970 UTC to an integer date YYYYMMDD.
     * This takes into account the local timezone.
     *
     * @param m {number} number of milliseconds since 1970 UTC
     * @returns {number} YYYYMMDD
     *
     * @see DateTimeUtils.toMilliseconds()
     * @see TimeUtils.millsToInt()
     */
    static millsToInt(m) {
        if (!m)
            return 0;
        const dt = new Date(m);
        return DateUtils.dateToInt(dt);
    }

    /**
     * Is dt a valid date?
     *
     * @param dt number, string, or date
     * @returns {boolean} true if valid date
     */
    static isValid(dt) {
        if (!dt)
            return false;
        if (typeof dt === 'string')
            dt = DateUtils.strToInt(dt);
        else if (typeof dt === 'object')
            dt = DateUtils.dateToInt(dt);
        return dt === DateUtils.calendar(DateUtils.julian(dt)) && dt >= 19000101 && dt <= 21000101;
    }

    /**
     * Verify SQL date formatted like YYYY-MM-DD
     *
     * @param {string} val
     * @returns {boolean}
     */
    static isSQLDate(val) {
        if (val)
            val = $.trim(val);
        if (!val)
            return false;
        return !!DateUtils.SQLtoInt(val);
    }

    /**
     * Convert numeric YYYYMMDD to string mm/dd/yyyy or whatever format is locally appropriate
     *
     * @param {number} dt
     * @param {string} [locale=navigator.language] - optional locale to use for formatting the date. Defaults to the user's browser language.
     * @returns {string}
     */
    static intToStr4(dt, locale = navigator.language) {
        if (typeof dt === 'string')
            dt = Number(dt);
        if (!dt)
            return '';
        const year = parseInt(dt.toString().slice(0, 4), 10);
        const month = parseInt(dt.toString().slice(4, 6), 10) - 1; // Subtract 1 because months are 0-indexed
        const day = parseInt(dt.toString().slice(6, 8), 10);

        const date = new Date(year, month, day);

        const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
        return new Intl.DateTimeFormat(locale, options).format(date);
    }

    /**
     * Convert numeric YYYYMMDD to string yyyy-mm-dd
     *
     * @param {number} dt
     * @returns {string}
     */
    static intToSQL(dt) {
        if (!dt)
            return '';
        const y = Math.floor(dt / 10000);
        dt -= y * 10000;
        const m = Math.floor(dt / 100);
        const d = Math.floor(dt - m * 100);
        return Utils.format(y, "Z", 4, 0) + '-' + Utils.format(m, "Z", 2, 0) + '-' + Utils.format(d, "Z", 2, 0);
    }

    /**
     * Convert numeric YYYYMMDD to string mm/dd/yy or whatever format is locally appropriate
     *
     * @param {number} dt
     * @param {string} [locale=navigator.language] - optional locale to use for formatting the date. Defaults to the user's browser language.
     * @returns {string}
     */
    static intToStr2(dt, locale = navigator.language) {
        if (typeof dt === 'string')
            dt = Number(dt);
        if (!dt)
            return '';
        const year = parseInt(dt.toString().slice(0, 4), 10);
        const month = parseInt(dt.toString().slice(4, 6), 10) - 1; // Subtract 1 because months are 0-indexed
        const day = parseInt(dt.toString().slice(6, 8), 10);

        const date = new Date(year, month, day);

        const options = { year: '2-digit', month: '2-digit', day: '2-digit' };
        return new Intl.DateTimeFormat(locale, options).format(date);
    }

    /**
     * Convert a Date object to YYYYMMDD int
     *
     * @param {Date} dt
     * @returns {number}
     */
    static dateToInt(dt) {
        return dt.getFullYear() * 10000 + (dt.getMonth() + 1) * 100 + dt.getDate();
    }

    /**
     * Convert an int date YYYYMMDD to a JavaScrip Date object
     *
     * @param {number} dt
     * @returns {Date}
     */
    static intToDate(dt) {
        const y = Math.floor(dt / 10000);
        dt -= y * 10000;
        const m = Math.floor(dt / 100);
        const d = Math.floor(dt - m * 100);
        return new Date(y, m - 1, d);
    }

    /**
     * Add days to a Date object
     *
     * @param {Date} dt
     * @param {number} days number of days to add or subtract
     * @returns {Date}
     */
    static dateAddDays(dt, days) {
        return DateUtils.intToDate(DateUtils.intAddDays(DateUtils.dateToInt(dt), days));
    }

    /**
     * Add days to an int date
     *
     * @param {number} dt int date YYYYMMDD
     * @param {number} days number of days to add or subtract
     * @returns {int}
     */
    static intAddDays(dt, days) {
        return DateUtils.calendar(DateUtils.julian(dt) + days);
    }

    /**
     * Return the number of months between two dates.
     *
     * @param idt1 {number} Date or integer date YYYYMMDD
     * @param idt2 {number} Date or integer date YYYYMMDD
     * @returns {number} number of months between the two dates
     */
    static monthsDifference(idt1, idt2) {
        const dt1 = idt1 instanceof Date ? idt1 : DateUtils.intToDate(idt1);
        const dt2 = idt2 instanceof Date ? idt2 : DateUtils.intToDate(idt2);
        let months = (dt2.getFullYear() - dt1.getFullYear()) * 12;
        months -= dt1.getMonth() + 1;
        months += dt2.getMonth() + 1;
        if (dt1.getDate() > dt2.getDate())
            months--;
        return months <= 0 ? 0 : months;
    }

    /**
     * Convert an integer date into a julian date.
     *
     * @param {number} dt integer date formatted as YYYYMMDD
     * @returns {number} the julian date
     */
    static julian(dt) {
        /* This can't be done some of the more obvious ways because of changes in daylight savings time.  */
        /* And leap years?  */
        let d, y, m;

        if (dt <= 0)
            return dt;
        y = Math.floor(dt / 10000);
        m = Math.floor((dt % 10000) / 100);
        d = Math.floor(dt % 100);
        d += Math.floor(.5 + (m - 1) * 30.57);
        if (m >	2) {
            d--;
            if (0 !== y % 400 && (0 !== y % 4 || 0 === y % 100))
                d--;
        }
        d += Math.floor(365.25 * --y);
        d += Math.floor(y / 400);
        d -= Math.floor(y / 100);
        return d;
    }

    /**
     * Convert a julian date into an integer date.
     *
     * @param d {number} d the julian date
     * @returns {number} the integer date formatted as YYYYMMDD
     */
    static calendar(d) {
        let	y, m, t;

        if (d <= 0)
            return d;
        y = Math.floor(1.0 + d / 365.2425);
        t = y -	1;
        d -= Math.floor(t * 365.25);
        d -= Math.floor(t / 400);
        d += Math.floor(t / 100);
        if (d >	59  &&	 0 !== y % 400	 &&  (0 !== y %	4  ||  0 === y % 100))
            d++;
        if (d >	60)
            d++;
        m = Math.floor((d + 30) / 30.57);
        d -= Math.floor(.5 + (m - 1) * 30.57);
        if (m === 13)  {
            m = 1;
            ++y;
        }  else if (m === 0)  {
            m = 12;
            --y;
        }
        return 10000 * y + m * 100 + d;
    }

    /**
     * Takes an integer date formatted as YYYYMMDD and returns an integer
     * indicating the day of the week.
     *
     * 0 Sunday
     * 1 Monday
     * 2 Tuesday
     * 3 Wednesday
     * 4 Thursday
     * 5 Friday
     * 6 Saturday
     *
     * @param {number} dt integer date formatted as YYYYMMDD
     * @returns {number} an integer indicating the day of the week
     */
    static dayOfWeek(dt) {
        return DateUtils.julian(dt) % 7;
    }

    /**
     * Return the number of days between two dates.
     *
     * @param {number} dt1 date formatted as YYYYMMDD
     * @param {number} dt2 date formatted as YYYYMMDD
     * @returns {number} returns the number of days dt1 - dt2
     */
    static daysDifference(dt1, dt2) {
        return DateUtils.julian(dt1) - DateUtils.julian(dt2);
    }

    /**
     * Takes an integer date formatted as YYYYMMDD and returns the
     * name of the day of the week as a string such as
     * "Sunday", "Monday", etc.
     *
     * @param {number} dt integer date formatted as YYYYMMDD
     * @returns {string} the name of the day of the week
     */
    static dayOfWeekName(dt) {
        switch (DateUtils.dayOfWeek(dt)) {
            case 0:  return "Sunday";
            case 1:  return "Monday";
            case 2:  return "Tuesday";
            case 3:  return "Wednesday";
            case 4:  return "Thursday";
            case 5:  return "Friday";
            case 6:  return "Saturday";
            default: return '';
        }
    }

    /**
     * Takes an integer date formatted as YYYYMMDD and returns the
     * name of the month as a string such as
     * "January", "February", etc.
     *
     * @param {number} dt integer date formatted as YYYYMMDD
     * @returns {string} the name of the day of the week
     */
    static monthName(dt) {
        const y = Math.floor(dt / 10000);
        dt -= y * 10000;
        const m = Math.floor(dt / 100);
        switch (m) {
            case 1: return 'January';
            case 2: return 'February';
            case 3: return 'March';
            case 4: return 'April';
            case 5: return 'May';
            case 6: return 'June';
            case 7: return 'July';
            case 8: return 'August';
            case 9: return 'September';
            case 10: return 'October';
            case 11: return 'November';
            case 12: return 'December';
            default: return '';
        }
    }

    /**
     * Formats a date into a string format as in:  Mon January 12, 2021
     *
     * @param dt {number} integer date formatted as YYYYMMDD
     * @returns {string}
     */
    static longFormat(dt) {
        if (typeof dt === 'string')
            dt = Number(dt);
        if (!dt)
            return '';
        const y = Math.floor(dt / 10000);
        const t = dt - y * 10000;
        const m = Math.floor(t / 100);
        const d = Math.floor(t - m * 100);
        const dayOfWeek = Utils.take(DateUtils.dayOfWeekName(dt), 3);
        const monthName = DateUtils.monthName(dt);
        return dayOfWeek + " " + monthName + " " + d + ", " + y;
    }

    /**
     * Returns the current date as an int formatted as YYYYMMDD
     *
     * @returns {number}
     */
    static today() {
        return DateUtils.dateToInt(new Date());
    }

    /**
     * Return the year portion of a date.
     *
     * @param dt {number|Date} integer date formatted as YYYYMMDD or a Date object
     * @returns {number}
     */
    static year(dt) {
        if (typeof dt === 'object')
            return dt.getFullYear();
        return dt ? Math.floor(dt / 10000) : 0;
    }

    /**
     * Return the month portion of a date.
     *
     * @param dt {number|Date} integer date formatted as YYYYMMDD or a Date object
     * @returns {number}
     */
    static month(dt) {
        if (!dt)
            return 0;
        if (typeof dt === 'object')
            return dt.getMonth() + 1;
        const y = Math.floor(dt / 10000);
        dt -= y * 10000;
        return Math.floor(dt / 100);
    }

    /**
     * Return the day portion of a date.
     *
     * @param dt {number|Date} integer date formatted as YYYYMMDD or a Date object
     * @returns {number}
     */
    static day(dt) {
        if (!dt)
            return 0;
        if (typeof dt === 'object')
            return dt.getDate();
        const y = Math.floor(dt / 10000);
        dt -= y * 10000;
        const m = Math.floor(dt / 100);
        return Math.floor(dt - m * 100);
    }
}