/* global DateUtils, TimeUtils */
/**
* Created by Blake McBride on 6/16/18.
*/
'use strict';
/**
* Class to deal with dates and times together.
*
* Generally, date/times can be represented in any of the following formats:
*
* - Date objects
* - Epoch timestamps (in milliseconds)
* - Numeric in the form YYYYMMDDHHMM (ymdhm)
*
*/
class DateTimeUtils {
/**
* Returns the current date/time as an integer in the format YYYYMMDDHHMM
* for the specified timezone. If timezone is null or undefined,
* the system's local timezone is used.
*
* @param {string|null|undefined} timeZone - IANA timezone string, e.g. "America/New_York".
* If null, the local timezone is used.
* @returns {number} - Current date/time as yyyymmddhhmm.
*/
static now(timeZone) {
const options = {
timeZone: timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
};
const formatter = new Intl.DateTimeFormat('en-CA', options);
const parts = formatter.formatToParts(new Date());
const obj = Object.fromEntries(parts.map(p => [p.type, p.value]));
const y = obj.year;
const m = obj.month;
const d = obj.day;
const h = obj.hour;
const min = obj.minute;
return parseInt(`${y}${m}${d}${h}${min}`, 10);
}
/**
* Creates an int date/time out of an int date and int time.
*
* @param dt YYYYMMDD
* @param tm HHMM
* @returns {number} YYYYMMDDHHMM
*/
static create(dt, tm) {
return dt * 10000 + tm;
}
/**
* Format a moment as "Wed Jan 4, 2022 12:31 PM CST".
*
* @param {Date|number|string} dt – Date object, epoch-millis, YYYYMMDDHHMM, 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) {
if (!dt)
return '';
/* ---------- normalise input ---------- */
if (typeof dt === 'string') {
dt = dt.trim();
if (!dt)
return '';
dt = Number(dt);
}
if (typeof dt === 'number')
dt = this.toDate(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')}`;
}
/**
* Converts a given epoch or YYYYMMDDHHMM date/time into a Date object.
*
* @param {number|Date} dt - the date to convert
* @returns {Date} - the converted date
*/
static toDate(dt) {
if (dt > 250001010000)
return new Date(dt);
const year = Math.floor(dt / 100000000);
const month = Math.floor(dt / 1000000) % 100;
const day = Math.floor(dt / 10000) % 100;
const hour = Math.floor(dt / 100) % 100;
const minute= dt % 100;
// Note: JavaScript months are 0-based
return new Date(year, month - 1, day, hour, minute);
}
/**
* 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 = this.toDate(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 an integer date and integer time into a Date object in the local timezone.
*
* @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, ymdhm,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 = this.toDate(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 or YYYYMMDDHHMM
* @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') {
if (dt > 190000000000) {
// user supplied YYYYMMDDHHMM
dt = DateTimeUtils.toDate(dt);
} else
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 = this.toDate(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 = this.toDate(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 transmit 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;
}
/**
* Converts a date (YYYYMMDD) and a time (HHMM) into a compact local date-time
* representation as YYYYMMDDHHMM (number).
*
* @param {number} date - date in YYYYMMDD format
* @param {number} time - time in HHMM format
* @returns {number} date-time representation in YYYYMMDDHHMM format
*/
static newYmdhm(date, time) {
return (Math.trunc(date) * 10000) + Math.trunc(time);
}
/**
* Convert a Date objects or epoch timestamps (in milliseconds or seconds) into a compact local date-time representation as YYYYMMDDHHMM (number).
*
* @param {Date|number} input - date-time to convert, either a Date object or an epoch timestamp
* @param {string} zoneId - IANA time zone name (e.g. "America/New_York")
* @returns {number} date-time representation in YYYYMMDDHHMM format
*/
static toYmdhm(input, zoneId) {
if (!input)
return 0;
if (typeof input === 'number' && input < 250001010000)
input = this.toEpoch(input, zoneId);
const zone = this.#normZone(zoneId);
const ms = input instanceof Date ? input.getTime() : input;
const comps = this.#epochToZonedComponents(ms, zone);
return this.#compose(comps);
}
/**
* Converts a date-time to an epoch timestamp in milliseconds (UTC).
*
* @param {number} dt - date-time in YYYYMMDDHHMM format or a Date object
* @param {string} zoneId - IANA time zone name (e.g. "America/New_York")
* @returns {number} epoch timestamp in milliseconds (UTC)
*/
static toEpoch(dt, zoneId) {
if (!dt || dt <= 0)
return 0;
if (dt instanceof Date)
dt = this.toYmdhm(dt);
if (dt > 250001010000)
return dt; // already an epoch
const zone = this.#normZone(zoneId);
const { year, month, day, hour, minute } = this.#decompose(dt);
// Basic field validation
if (
month < 1 || month > 12 ||
day < 1 || day > 31 ||
hour < 0 || hour > 23 ||
minute < 0 || minute > 59
) {
throw new Error(`Invalid date/time: ${dt}`);
}
return this.#zonedTimeToUtc(year, month, day, hour, minute, zone);
}
/**
* Extracts the date in YYYYMMDD format from a date-time in YYYYMMDDHHMM format.
*
* @param {number} dt - date-time in YYYYMMDDHHMM format
* @returns {number} date in YYYYMMDD format
*/
static getDate(dt) { return Math.trunc(dt / 10000); }
/**
* Extracts the time in HHMM format from a date-time in YYYYMMDDHHMM format.
*
* @param {number} dt - date-time in YYYYMMDDHHMM format
* @returns {number} time in HHMM format
*/
static getTime(dt) { return Math.trunc(dt % 10000); }
// ---- Adders (timezone/DST-aware) ----
/**
* Adds a specified number of years to a date-time in YYYYMMDDHHMM format
*
* @param {number} ymdhm - date-time in YYYYMMDDHHMM format
* @param {number} years - number of years to add
* @param {string} zoneId - IANA time zone name (e.g. "America/New_York")
* @returns {number} date-time after adding years in YYYYMMDDHHMM format
*/
static addYears(ymdhm, years, zoneId) {
return this.#add(ymdhm, zoneId, { years, months: 0, days: 0 }, { hours: 0, minutes: 0 });
}
/**
* Adds a specified number of months to a date-time in YYYYMMDDHHMM format
*
* @param {number} ymdhm - date-time in YYYYMMDDHHMM format
* @param {number} months - number of months to add
* @param {string} zoneId - IANA time zone name (e.g. "America/New_York")
* @returns {number} date-time after adding months in YYYYMMDDHHMM format
*/
static addMonths(ymdhm, months, zoneId) {
return this.#add(ymdhm, zoneId, { years: 0, months, days: 0 }, { hours: 0, minutes: 0 });
}
/**
* Adds a specified number of days to a date-time in YYYYMMDDHHMM format
*
* @param {number} ymdhm - date-time in YYYYMMDDHHMM format
* @param {number} days - number of days to add
* @param {string} zoneId - IANA time zone name (e.g. "America/New_York")
* @returns {number} date-time after adding days in YYYYMMDDHHMM format
*/
static addDays(ymdhm, days, zoneId) {
return this.#add(ymdhm, zoneId, { years: 0, months: 0, days }, { hours: 0, minutes: 0 });
}
/**
* Adds a specified number of hours to a date-time in YYYYMMDDHHMM format
*
* @param {number} ymdhm - date-time in YYYYMMDDHHMM format
* @param {number} hours - number of hours to add
* @param {string} zoneId - IANA time zone name (e.g. "America/New_York")
* @returns {number} date-time after adding hours in YYYYMMDDHHMM format
*/
static addHours(ymdhm, hours, zoneId) {
return this.#add(ymdhm, zoneId, { years: 0, months: 0, days: 0 }, { hours, minutes: 0 });
}
/**
* Adds a specified number of minutes to a date-time in YYYYMMDDHHMM format
*
* @param {number} ymdhm - date-time in YYYYMMDDHHMM format
* @param {number} minutes - number of minutes to add
* @param {string} zoneId - IANA time zone name (e.g. "America/New_York")
* @returns {number} date-time after adding minutes in YYYYMMDDHHMM format
*/
static addMinutes(ymdhm, minutes, zoneId) {
return this.#add(ymdhm, zoneId, { years: 0, months: 0, days: 0 }, { hours: 0, minutes });
}
/**
* Calculate the difference in minutes between two YYYYMMDDHHMM date-time values in the same time zone.
*
* @param {number} fromYmdhm - starting date-time in YYYYMMDDHHMM format
* @param {number} subYmdhm - date-time in YYYYMMDDHHMM format to subtract
* @param {string} zoneId - IANA time zone name (e.g. "America/New_York")
* @returns {number} difference in minutes between the two date-time values
*/
static diffMinutes(fromYmdhm, subYmdhm, zoneId) {
const zone = this.#normZone(zoneId);
const subMs = this.toEpoch(subYmdhm, zone);
const fromMs = this.toEpoch(fromYmdhm, zone);
return Math.trunc((fromMs - subMs) / 60_000);
}
/**
* Return the local timezone (IANA string) e.g. "America/New_York".
*
* @returns {string}
*/
static localZone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
/**
* Compute the timezone offset in milliseconds for a given epoch timestamp and IANA timezone.
*
* @param {number} epochMs - timestamp in milliseconds since 1970 UTC
* @param {string} timeZone - IANA time zone name (e.g. "America/New_York")
* @returns {number} the timezone offset in milliseconds
*
* This function takes into account both DST transitions and the base UTC offset.
* The returned value is the difference between the given epoch timestamp and the wall clock time in the specified time zone.
* This value can be used to convert a timestamp in milliseconds since 1970 UTC to a wall clock time in a given time zone.
*/
static getTimeZoneOffsetMs(epochMs, timeZone) {
const d = new Date(epochMs);
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = Object.fromEntries(dtf.formatToParts(d).map(p => [p.type, p.value]));
const ms = d.getUTCMilliseconds(); // preserve ms exactly
const wallAsUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
ms
);
// wallAsUtc ≈ epochMs + offset → offset = wallAsUtc - epochMs
return wallAsUtc - epochMs;
}
/**
* Compute the display epoch for a given epoch timestamp and IANA timezone.
* The result is the epoch timestamp that, when converted to a wall clock time in the given time zone,
* yields the same wall clock time as the input epoch timestamp when converted to a wall clock time in the local time zone.
*
* @param {number} t - timestamp in milliseconds since 1970 UTC
* @param {string} realTimezone - IANA time zone name (e.g. "America/New_York")
* @param {string} [displayTimezone] - IANA time zone name; omit/falsy ⇒ use the host’s local zone
* @returns {number} the display epoch in milliseconds since 1970 UTC
*
* This function takes into account both DST transitions and the base UTC offset.
* The returned value is the epoch timestamp that, when converted to a wall clock time in the specified time zone,
* yields the same wall clock time as the input epoch timestamp when converted to a wall clock time in the local time zone.
*/
static epochToDisplayEpoch(t, realTimezone, displayTimezone = this.localZone()) {
// If target zone is the same as local, identity is exact across DST:
if (realTimezone === displayTimezone)
return t;
// Desired wall-time value A = t + offset_target(t)
const offTarget = this.getTimeZoneOffsetMs(t, realTimezone);
const A = t + offTarget;
// Solve x + off_local(x) = A via fixed-point iteration.
// Start with local offset at t (fast convergence in practice).
let x = A - this.getTimeZoneOffsetMs(t, displayTimezone);
for (let i = 0; i < 6; i++) {
const offLocal = this.getTimeZoneOffsetMs(x, displayTimezone);
const xNext = A - offLocal;
if (xNext === x)
break; // exact
if (Math.abs(xNext - x) < 1) {
x = Math.round(xNext);
break;
} // within 1ms
x = xNext;
}
return x;
}
/**
* Compute the epoch timestamp for a given display epoch and IANA timezone.
* The result is the epoch timestamp that, when converted to a wall clock time in the specified time zone,
* yields the same wall clock time as the input display epoch when converted to a wall clock time in the local time zone.
*
* @param {number} x - display epoch in milliseconds since 1970 UTC
* @param {string} realTimezone - IANA time zone name (e.g. "America/New_York")
* @param {string} [displayTimezone] - IANA time zone name; omit/falsy ⇒ use the host’s local zone
* @returns {number} the epoch timestamp in milliseconds since 1970 UTC
*
* This function takes into account both DST transitions and the base UTC offset.
* The returned value is the epoch timestamp that, when converted to a wall clock time in the specified time zone,
* yields the same wall clock time as the input display epoch when converted to a wall clock time in the local time zone.
*/
static displayEpochToEpoch(x, realTimezone, displayTimezone = this.localZone()) {
// If target zone is the same as local, identity:
if (realTimezone === displayTimezone)
return x;
// B = x + off_local(x)
const B = x + this.getTimeZoneOffsetMs(x, displayTimezone);
// Solve t + off_target(t) = B ⇒ t = B - off_target(t)
let t = B - this.getTimeZoneOffsetMs(B, realTimezone);
for (let i = 0; i < 6; i++) {
const offTarget = this.getTimeZoneOffsetMs(t, realTimezone);
const tNext = B - offTarget;
if (tNext === t)
break;
if (Math.abs(tNext - t) < 1) {
t = Math.round(tNext);
break;
}
t = tNext;
}
return t;
}
/**
* Tests the round-trip conversion from epoch timestamp to display epoch and back again
* for several time zones.
*
* This function is primarily used for testing and debugging purposes.
*
* For each given epoch timestamp, it will print the original local machine’s display
* followed by the target time zone’s display, and then the recovered local display
* after converting the epoch timestamp to the target time zone’s display epoch and back again.
*
* @private
*/
static testUSConversions() {
const zones = [
'America/New_York', // Eastern
'America/Chicago', // Central (Tennessee)
'America/Denver', // Mountain
'America/Los_Angeles' // Pacific
];
const mine = this.localZone();
// Pick any epochs you want; these cover standard/DST/transition windows (2025)
const tests = [
Date.UTC(2025, 0, 15, 12, 0, 0, 123),
Date.UTC(2025, 2, 9, 7, 0, 0, 456),
Date.UTC(2025, 6, 15, 12, 0, 0, 789),
Date.UTC(2025, 10, 2, 7, 0, 0, 42)
];
const fmtLocal = (ms) => new Intl.DateTimeFormat('en-US', {
timeZone: mine, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
}).format(new Date(ms));
const fmtIn = (ms, tz) => new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
}).format(new Date(ms));
console.log('Local zone:', mine);
tests.forEach((t, idx) => {
console.log(`\n=== Test #${idx + 1} (UTC: ${new Date(t).toISOString()}) ===`);
zones.forEach(zone => {
// 1) Straight display of the original Date (local machine’s zone)
const straightLocal = fmtLocal(t);
// 2) How that same instant appears in the specified target time zone
const targetView = fmtIn(t, zone);
// 3) Convert to display-epoch for that zone, convert back, and show the recovered local display
const disp = this.epochToDisplayEpoch(t, zone);
const back = this.displayEpochToEpoch(disp, zone);
const recoveredLocal = fmtLocal(back);
const exact = back === t ? '✅ exact' : `❌ Δ=${back - t}ms`;
console.log(`Zone: ${zone}`);
console.log(` 1) Local (original): ${straightLocal}`);
console.log(` 2) Target-zone view: ${targetView}`);
console.log(` 3) After converting back (local): ${recoveredLocal} ${exact}`);
});
});
}
//------------------------------------
/**
* Takes an epoch time and returns an epoch time that only has the date portion (midnight local time)
*
* @param {number} epochMillis - the epoch time in milliseconds since Jan 1, 1970
* @returns {number} the date portion of the epoch time in milliseconds since Jan 1, 1970
*/
static dateOnlyEpoch(epochMillis) {
const d = new Date(epochMillis);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/**
* Takes an epoch time and returns an epoch time that only represents the time portion of the epoch time
* (i.e. milliseconds since midnight local time)
*
* @param {number} epochMillis - the epoch time in milliseconds since Jan 1, 1970
* @returns {number} the time portion of the epoch time in milliseconds since midnight local time
*/
static timeOnlyEpoch(epochMillis) {
const d = new Date(epochMillis);
return (d.getHours() * 3600000) +
(d.getMinutes() * 60000) +
(d.getSeconds() * 1000) +
d.getMilliseconds();
}
/**
* Takes a date (YYYYMMDD) and time (HHMM) and returns the epoch time (milliseconds since Jan 1, 1970)
* dateInt and timeInt are assumed in the local timezone.
* If timezone is passed, the epoch time is converted to that timezone.
*
* @param {number} dateInt - date in YYYYMMDD format
* @param {number} timeInt - time in HHMM format
* @param {string} timezone - optional IANA time zone name (e.g. "America/New_York")
*
* @returns {number} epoch time in milliseconds since Jan 1, 1970
*/
static epochFromDateAndTime(dateInt, timeInt, timezone) {
const year = Math.floor(dateInt / 10000);
let month = Math.floor((dateInt % 10000) / 100) - 1; // JS months are 0-based
if (month < 0)
month = 0;
const day = dateInt % 100;
const hours = Math.floor(timeInt / 100);
const mins = timeInt % 100;
const d = new Date(year, month, day, hours, mins, 0, 0);
let et = d.getTime();
if (timezone)
et = this.displayEpochToEpoch(et, timezone);
return et;
}
// =================
// Private helpers
// =================
/**
* Normalizes a provided timezone string.
*
* This helper ensures that a valid IANA timezone identifier is always returned.
* If the given `tz` value is `null`, `undefined`, empty, or only whitespace,
* the system's default timezone is returned instead.
*
* @param tz
* @returns {string}
*/
static #normZone(tz) {
return (tz ?? "").toString().trim() || Intl.DateTimeFormat().resolvedOptions().timeZone;
}
static #decompose(ymdhm) {
// Accept number or string; produce zero-padded 12 chars
const s = (typeof ymdhm === "number" ? String(Math.trunc(ymdhm)) : String(ymdhm)).padStart(12, "0");
return {
year: Number(s.slice(0, 4)),
month: Number(s.slice(4, 6)),
day: Number(s.slice(6, 8)),
hour: Number(s.slice(8, 10)),
minute: Number(s.slice(10, 12)),
};
}
static #compose({ year, month, day, hour, minute }) {
return (year * 100000000) + (month * 1000000) + (day * 10000) + (hour * 100) + minute;
}
// Resolve zone offset (ms) for a given UTC Date in a specific IANA timeZone.
static #getOffsetMs(utcDate, timeZone) {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone, hour12: false,
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
const parts = dtf.formatToParts(utcDate);
const map = Object.fromEntries(parts.map(p => [p.type, p.value]));
const y = Number(map.year);
const m = Number(map.month);
const d = Number(map.day);
const H = Number(map.hour);
const M = Number(map.minute);
const S = Number(map.second);
const asIfUTC = Date.UTC(y, m - 1, d, H, M, S);
return asIfUTC - utcDate.getTime();
}
// Convert a zone-local wall time to epoch ms (UTC), DST-safe.
static #zonedTimeToUtc(year, month, day, hour, minute, timeZone) {
// Initial guess: interpret wall time *as if* it were UTC
const guess = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
const off1 = this.#getOffsetMs(new Date(guess), timeZone);
const candidate = guess - off1;
const off2 = this.#getOffsetMs(new Date(candidate), timeZone);
// If offset changes after moving to candidate (gap/overlap), adjust once
return off2 !== off1 ? (guess - off2) : candidate;
}
// Convert epoch ms → zone-local components.
static #epochToZonedComponents(epochMs, timeZone) {
const dtf = new Intl.DateTimeFormat("en-US", {
timeZone, hour12: false,
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
const parts = dtf.formatToParts(new Date(epochMs));
const map = Object.fromEntries(parts.map(p => [p.type, p.value]));
return {
year: Number(map.year),
month: Number(map.month),
day: Number(map.day),
hour: Number(map.hour),
minute: Number(map.minute),
};
}
static #daysInMonth(year, month) {
return new Date(Date.UTC(year, month, 0)).getUTCDate(); // month: 1..12
}
// Calendar (years/months/days) + duration (hours/minutes) add, zone-aware/DST-safe.
static #add(ymdhm, zoneId, dateDelta, timeDelta) {
const zone = this.#normZone(zoneId);
let { year, month, day, hour, minute } = this.#decompose(ymdhm);
// 1) Years + Months (in local calendar)
const monthsTotal = (year * 12 + (month - 1)) + (dateDelta.years ?? 0) * 12 + (dateDelta.months ?? 0);
year = Math.floor(monthsTotal / 12);
month = (monthsTotal % 12) + 1;
// Clamp day to end-of-month
const maxDay = this.#daysInMonth(year, month);
if (day > maxDay)
day = maxDay;
// 2) Days (calendar roll)
let d = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0));
if (dateDelta.days)
d.setUTCDate(d.getUTCDate() + (dateDelta.days ?? 0));
year = d.getUTCFullYear();
month = d.getUTCMonth() + 1;
day = d.getUTCDate();
hour = d.getUTCHours();
minute = d.getUTCMinutes();
// 3) Hours + Minutes as timeline duration across DST
const addMinTotal = (timeDelta.hours ?? 0) * 60 + (timeDelta.minutes ?? 0);
if (addMinTotal !== 0) {
const baseEpoch = this.#zonedTimeToUtc(year, month, day, hour, minute, zone);
const resultEpoch = baseEpoch + addMinTotal * 60_000;
const comps = this.#epochToZonedComponents(resultEpoch, zone);
return this.#compose(comps);
}
// No duration change: return resulting wall time
return this.#compose({ year, month, day, hour, minute });
}
}