DateTimeUtils.js

/* global DateUtils, TimeUtils */

/**
 * Created by Blake McBride on 6/16/18.
 */

'use strict';


/**
 * Class to deal with dates and times together.
 *
 */
class DateTimeUtils {

    /**
     * Format a moment as "Wed Jan 4, 2022 12:31 PM CST".
     *
     * @param {Date|number|string} dt – Date object, epoch-millis, or numeric string
     * @param {string}            [tz] – IANA zone name (e.g. "America/Chicago");
     *                                   omit/falsy → host’s local zone
     * @returns {string}
     */
    static formatDateLong(dt, tz) {
        /* ---------- normalise input ---------- */
        if (typeof dt === 'string') {
            dt = dt.trim();
            if (!dt)
                return '';
            dt = Number(dt);
        }
        if (typeof dt === 'number')
            dt = new Date(dt);
        if (!(dt instanceof Date) || isNaN(dt))
            return '';

        /* ---------- obtain the pieces we need ---------- */
        const opts = {
            timeZone     : tz || undefined,   // undefined ⇒ keep local zone
            weekday      : 'short',           // Wed
            month        : 'short',           // Jan
            day          : 'numeric',         // 4
            year         : 'numeric',         // 2022
            hour         : 'numeric',         // 12  (no leading zero)
            minute       : '2-digit',         // 31
            hour12       : true,              // AM/PM
            timeZoneName : 'short'            // CST
        };

        const parts = new Intl.DateTimeFormat('en-US', opts).formatToParts(dt);
        const get   = type => parts.find(p => p.type === type)?.value || '';

        /* ---------- assemble in the desired order ---------- */
        return `${get('weekday')} ${get('month')} ${get('day')}, ${get('year')} `
            + `${get('hour')}:${get('minute')} ${get('dayPeriod')} ${get('timeZoneName')}`;
    }

    /**
     * Convert a Date into the canonical interchange string
     *   yyyy-MM-dd HH:mm:ss <offset-in-minutes>
     *
     * @param {Date|number|string} dt   – Date object, millis, or numeric string
     * @param {string}            [tz]  – IANA zone name (e.g. "America/New_York");
     *                                    omit/falsy ⇒ use the host’s local zone
     * @returns {string}
     */
    static dateToStd(dt, tz) {
        /* ---------- normalise input ---------- */
        if (typeof dt === 'string') {
            dt = dt.trim();
            if (!dt) return '';
            dt = Number(dt);
        }
        if (typeof dt === 'number') dt = new Date(dt);
        if (!(dt instanceof Date) || isNaN(dt)) return '';

        /* ---------- Y-M-D H:m:s parts in the target zone ---------- */
        const fmt = new Intl.DateTimeFormat('en-CA', {
            timeZone : tz || undefined,   // undefined ⇒ local zone
            year     : 'numeric',
            month    : '2-digit',
            day      : '2-digit',
            hour     : '2-digit',
            minute   : '2-digit',
            second   : '2-digit',
            hour12   : false
        }).formatToParts(dt);

        const pick = t => fmt.find(p => p.type === t).value;
        const YYYY = pick('year'),
            MM   = pick('month'),
            DD   = pick('day'),
            HH   = pick('hour'),
            mm   = pick('minute'),
            ss   = pick('second');

        /* ---------- offset in **integer** minutes ---------- */
        let offsetMinutes;

        if (!tz) {
            offsetMinutes = dt.getTimezoneOffset();           // already an int
        } else {
            const fakeUTC = Date.UTC(
                Number(YYYY), Number(MM) - 1, Number(DD),
                Number(HH),  Number(mm),     Number(ss)
            );
            // Round to ensure we never emit a fraction like 240.0000001
            offsetMinutes = Math.round((dt.getTime() - fakeUTC) / 60000);
        }

        /* ---------- final string ---------- */
        return `${YYYY}-${MM}-${DD} ${HH}:${mm}:${ss} ${offsetMinutes}`;
    }

    /**
     * Parse a standard string date format into a Date object.
     * Expected standard date format looks like this:  2022-06-08 03:27:44 300
     * The 300 is minutes offset from GMT - the timezone
     * This routine parses a date from any timezone and returns a Date object in the local timezone.
     *
     * @param sdt {string}
     * @returns {Date}
     */
    static stdToDate(sdt) {
        if (typeof sdt !== 'string' || !sdt)
            return null;
        if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} -?\d{1,3}$/.test(sdt))
            return null;
        const y = Number(sdt.slice(0, 4));
        const month = Number(sdt.slice(5, 7));
        const d = Number(sdt.slice(8, 10));
        const h = Number(sdt.slice(11, 13));
        const minutes = Number(sdt.slice(14, 16));
        const s = Number(sdt.slice(17, 19));
        const tz1 = Number(sdt.slice(20)); // replaces Utils.drop(sdt, 20)
        const dt = new Date(y, month - 1, d, h, minutes, s);
        const tz2 = dt.getTimezoneOffset();
        return new Date(dt.valueOf() - tz2 * 60000 + tz1 * 60000);
    }

    /**
     * Convert in integer date and integer time into a Date object.
     *
     * @param dt YYYYMMDD
     * @param tm  HHMM
     * @returns {Date}
     */
    static createDate(dt, tm) {
        if (!dt)
            return null;
        const y = Math.floor(dt / 10000);
        dt -= y * 10000;
        const m = Math.floor(dt / 100);
        const d = Math.floor(dt - m * 100);
        const h = Math.floor(tm / 100);
        const min = tm - Math.floor(tm / 100) * 100;
        return new Date(y, m - 1, d, h, min);
    }

    /**
     * Format a Date or milliseconds-since-epoch value to a string
     * "mm/dd/yyyy hh:mm AM/PM"  or  "dd/mm/yyyy hh:mm AM/PM"
     *
     * @param {Date|string|number} dt        – Date object, millis, or numeric string
     * @param {string}             [tz]      – IANA zone (e.g. "America/New_York");
     *                                         omit or pass falsy to use the local zone
     * @returns {string}
     */
    static formatDate(dt, tz) {
        // ----- normalise the first argument -----
        if (typeof dt === 'string') {
            dt = dt.trim();
            if (!dt || dt === '0')
                return '';
            dt = Number(dt);
        }
        if (typeof dt === 'number') {            // millis
            if (!dt)
                return '';
            dt = new Date(dt);
        }
        if (!(dt instanceof Date) || isNaN(dt))
            return '';

        // ----- build the parts for the requested (or local) time-zone -----
        const parts = new Intl.DateTimeFormat(
            undefined,                           // keep user’s locale
            {
                timeZone : tz || undefined,      // undefined → local zone
                year     : 'numeric',
                month    : 'numeric',
                day      : 'numeric',
                hour     : 'numeric',            // already 1-12 in hour12 mode
                minute   : '2-digit',
                hour12   : true
            }
        ).formatToParts(dt);

        // quick helper to pull the values we need
        const pick = type => parts.find(p => p.type === type)?.value;

        const month   = pick('month');   // "1" … "12"
        const day     = pick('day');
        const year    = pick('year');
        const hour    = pick('hour');    // "1" … "12"
        const minute  = pick('minute');  // always 2-digit because of '2-digit'
        const ampm    = pick('dayPeriod'); // "AM" / "PM"

        // ----- honour your existing MM/DD vs DD/MM preference -----
        const mdy = DateUtils.detectDateFormat() === 'MM/DD/YYYY';
        const dateStr = mdy
            ? `${month}/${day}/${year}`
            : `${day}/${month}/${year}`;

        return `${dateStr} ${hour}:${minute} ${ampm}`;
    }

    /**
     * Format a date (YYYYMMDD) and a time (HHMM) as
     *   "mm/dd/yyyy hh:mm AM/PM"  or  "dd/mm/yyyy hh:mm AM/PM"
     *
     * @param {number|string} dt     – calendar date, e.g. 20250508
     * @param {number|string} time   – clock time,   e.g. 1145
     * @param {string}        [tz]   – IANA zone name; omit/falsy ⇒ host’s zone
     * @returns {string}
     */
    static formatDateTime(dt, time, tz) {

        /* ---------- trivial short-circuit ---------- */
        if (!dt && (time === undefined || time === null || time === ''))
            return '';

        /* ---------- normalise inputs ---------- */
        if (typeof dt   === 'string') dt   = Number(dt);
        if (typeof time === 'string') time = Number(time);

        if (isNaN(dt)  || dt <= 0)   return '';
        if (isNaN(time) || time < 0) time = 0;

        /* split YYYYMMDD */
        const y  = Math.floor(dt / 10000);
        const m  = Math.floor((dt % 10000) / 100) - 1;   // JS month 0–11
        const d  = dt % 100;

        /* split HHMM */
        const h  = Math.floor(time / 100);
        const mi = time % 100;

        /* ---------- build Date in the **local** zone ---------- */
        const date = new Date(y, m, d, h, mi);

        /* ---------- format in the target (or local) zone ---------- */
        const parts = new Intl.DateTimeFormat(undefined, {
            timeZone : tz || undefined,   // undefined ⇒ keep host’s zone
            year     : 'numeric',
            month    : 'numeric',
            day      : 'numeric',
            hour     : 'numeric',
            minute   : '2-digit',
            hour12   : true
        }).formatToParts(date);

        const get = t => parts.find(p => p.type === t)?.value;
        const MM  = get('month');
        const DD  = get('day');
        const YYYY= get('year');
        const HH  = get('hour');
        const MMm = get('minute');
        const AP  = get('dayPeriod');

        /* honour the user’s MM/DD vs DD/MM preference */
        const mdy  = DateUtils.detectDateFormat?.() === 'MM/DD/YYYY';
        const dateStr = mdy ? `${MM}/${DD}/${YYYY}` : `${DD}/${MM}/${YYYY}`;

        return `${dateStr} ${HH}:${MMm} ${AP}`;
    }

    /**
     * Convert a moment in time to an integer clock value (HHMM).
     * The date portion is ignored.
     *
     * @param {Date|number} dt          – Date instance **or** milliseconds-since-epoch
     * @param {string}      [zoneId]    – optional IANA time-zone (e.g. "America/New_York");
     *                                    omit or falsy → use the host’s local zone
     * @returns {number}                – integer HHMM, or NaN on bad input
     */
    static toIntTime(dt, zoneId) {
        // ---------- normalise the input ----------
        if (typeof dt === 'number') dt = new Date(dt);
        if (!(dt instanceof Date) || isNaN(dt)) return NaN;

        let hours, minutes;

        if (zoneId) {
            // Use Intl to obtain the clock reading in the requested zone
            const parts = new Intl.DateTimeFormat('en-US', {
                timeZone : zoneId,
                hour     : '2-digit',
                minute   : '2-digit',
                hour12   : false          // 00-23
            }).formatToParts(dt);

            hours   = Number(parts.find(p => p.type === 'hour').value);
            minutes = Number(parts.find(p => p.type === 'minute').value);
        } else {
            // Original behaviour: host computer’s local zone
            hours   = dt.getHours();
            minutes = dt.getMinutes();
        }

        return hours * 100 + minutes;     // e.g. 14 h 07 m → 1407
    }

    /**
     * Extract the calendar date as an integer (YYYYMMDD).
     *
     * @param {Date|number} dt        – Date object *or* milliseconds since epoch.
     * @param {string}      [zoneId]  – optional IANA time-zone, e.g. "America/New_York".
     *                                  Omit/falsy ⇒ use the host’s local zone.
     * @returns {number}              – YYYYMMDD, or NaN on invalid input.
     */
    static toIntDate(dt, zoneId) {
        // ---- normalise the first argument ----
        if (typeof dt === 'number') dt = new Date(dt);
        if (!(dt instanceof Date) || isNaN(dt)) return NaN;

        let year, month, day;

        if (zoneId) {
            // Use Intl to get y-m-d as seen in the requested zone
            const parts = new Intl.DateTimeFormat('en-CA', { // fixed ISO-like order
                timeZone : zoneId,
                year  : 'numeric',
                month : '2-digit',
                day   : '2-digit'
            }).formatToParts(dt);

            const pick = t => parts.find(p => p.type === t).value;
            year  = Number(pick('year'));
            month = Number(pick('month'));
            day   = Number(pick('day'));
        } else {
            // Original behaviour: local zone
            year  = dt.getFullYear();
            month = dt.getMonth() + 1;   // JS months are 0-based
            day   = dt.getDate();
        }

        // Convert to YYYYMMDD integer
        return year * 10000 + month * 100 + day;
    }

    /**
     * Returns the local long timezone text.
     * For example "American/Chicago"
     *
     * @returns {string}
     */
    static getLocalTimezoneLongText() {
        return Intl.DateTimeFormat().resolvedOptions().timeZone;
    }

    /**
     * Returns the local short timezone text.
     * For example "CST"
     *
     * @returns {string}
     */
    static getLocalTimezoneShortText() {
        return new Date().toLocaleTimeString('en-us',{timeZoneName:'short'}).split(' ')[2];
    }

    /**
     * Add hours to a date.
     *
     * @param dt {Date|number|string}
     * @param hours {number}
     * @returns {Date}
     */
    static addHours(dt, hours) {
        if (typeof dt === 'string')
            dt = Number(dt);
        if (typeof dt === 'number')
            dt = new Date(dt);
        return new Date(dt.getTime() + hours * 60 * 60 * 1000);
    }

    /**
     * Add minutes to a date.
     *
     * @param dt {Date|number|string}
     * @param minutes {number}
     * @returns {Date}
     */
    static addMinutes(dt, minutes) {
        if (typeof dt === 'string')
            dt = Number(dt);
        if (typeof dt === 'number')
            dt = new Date(dt);
        return new Date(dt.getTime() + minutes * 60 * 1000);
    }

    /**
     * Combine a date and time into the number of milliseconds since 1970 UTC.
     * This is very valuable when trying to transit a DateTime to a backend without losing timezone info.
     *
     * @param date {number|Date} YYYYMMDD (time portion of a Date is not used)
     * @param time {number|null|undefined} HHMM
     * @returns {number}
     *
     * @see DateUtils.millsToInt()
     * @see TimeUtils.millsToInt()
     */
    static toMilliseconds(date, time) {
        let month = DateUtils.month(date)-1;
        if (month < 0)
            month = 0;
        const dt = new Date(DateUtils.year(date), month, DateUtils.day(date), TimeUtils.hours(time), TimeUtils.minutes(time));
        const n = dt.valueOf();
        return n < 0 ? 0 : n;
    }
}