Current UTC ISO Time

new Date().toISOString();
// "2021-05-25T23:04:33.116Z"

Current Timezone and Locale

Timezone:

Intl.DateTimeFormat().resolvedOptions().timeZone;
// America/Denver

Locale:

Intl.DateTimeFormat().resolvedOptions().locale;
// en-US

And the rest of it, for reference:

Intl.DateTimeFormat().resolvedOptions();
/*
  {
    "locale": "en-US",
    "calendar": "gregory",
    "numberingSystem": "latn",
    "timeZone": "America/Denver",
    "year": "numeric",
    "month": "numeric",
    "day": "numeric"
  }
*/

Surprisingly, Intl has actually made it into ECMAScript proper. It works in Browsers and Node.js.

Intl.DateTimeFormat vs tz.js

While writing this I found that Intl.DateTimeFormat is difficult to use directly or with short snippets, so I created xtz.js:

Demo at https://therootcompany.github.io/tz.js/

Translate UTC time to a TimeZone

Intl.DateTimeFormat can translate timezones, but it does not have the ability to output an ISO-compatible time.

Furthermore, new Intl.DateTimeFormat("default", opts).format(date) cannot be guaranteed to support any particular locale on any particular system.

However, using new Intl.DateTimeFormat("default", opts).formatToParts(date) makes it possible to get a date in a guaranteed format.

We could create a helper function toTimeZone() and use it like this:

var tzDate = toTimeZone('2021-01-01T13:59:59Z', 'America/New_York');

console.log(JSON.stringify(tzDate, null, 2));
{
    "year": 2021,
    "month": 0,
    "day": 1,
    "hour": 8,
    "minute": 59,
    "second": 59,
    "millisecond": 0,
    "timeZoneName": "Eastern Standard Time",
    "timeZone": "America/New_York"
}

Here’s an implementation for toTimeZone(), and another helper partsToWhole (for converting formatToParts() array into a more usable object):

function toTimeZone(date, timeZone) {
    // ISO string or existing date object
    date = new Date(date);
    var options = {
        timeZone: timeZone,
        year: 'numeric',
        month: 'numeric',
        day: 'numeric',
        hour12: false,
        hour: 'numeric',
        minute: 'numeric',
        second: 'numeric',
        fractionalSecondDigits: 3
    };

    var tzOpts = Object.assign({ timeZoneName: 'long' }, options);
    var parts = new Intl.DateTimeFormat('default', tzOpts).formatToParts(date);
    // [ { type: 'year', value: '2021', ... } ]

    var whole = partsToWhole(parts);
    whole.timeZone = timeZone;
    return whole;
}

function partsToWhole(parts) {
    // millisecond explicitly 0 for Safari's lack of fractionalSecond support
    var whole = { millisecond: 0 };
    parts.forEach(function (part) {
        var val = part.value;
        switch (part.type) {
            case 'literal':
                // ignore separators and whitespace characters
                return;
            case 'timeZoneName':
                // keep as is - it's a string
                break;
            case 'month':
                // months are 0-indexed for new Date()
                val = parseInt(val, 10) - 1;
                break;
            case 'hour':
                // because sometimes 24 is used instead of 0, make 24 0
                // (also hourCycle is not widely supported)
                val = parseInt(val, 10) % 24;
                break;
            case 'fractionalSecond':
                // fractionalSecond is a dumb name - should be millisecond
                whole.millisecond = parseInt(val, 10);
                return;
            default:
                val = parseInt(val, 10);
        }
        // ex: whole.month = 0;
        whole[part.type] = val;
    });
    return whole;
}

Snippet adapted from xtz.js.

Intl.DateTimeFormat to ISO Time

Having used the array from formatToParts(date) to create an object with all of the individual pieces, you can then construct an ISO string.

The ISO string will NOT be correct for the given time zone, as it lacks the necessary offset, but can be used to get that offset (later on below).

toISOString(whole);
// 2021-01-01T08:59:59.000Z
function toISOString(d) {
    var YYYY = d.year;
    var MM = (d.month + 1).padStart(2, '0');
    var DD = d.day.padStart(2, '0');
    var hh = d.hour.padStart(2, '0');
    var mm = d.minute.padStart(2, '0');
    var ss = d.second.padStart(2, '0');
    var ms = (d.millisecond || 0).padStart(3, '0');
    return `${YYYY}-${MM}-${DD}T${hh}:${mm}:${ss}.${ms}Z`;
}

Snippet adapted from xtz.js.

Get Time Zone offset relative to a UTC time

Once you have ISO string, you can calculate the difference between the source date and the target date to arrive at the offset:

var offset = getOffset('2021-01-01T13:59:59Z', '2021-01-01T08:59:59.000Z');
// -300
formatOffset(offset);
// -0500

Assuming you’re not crossing a daylight savings boundary (that’s covered in xtz.js), getting the offset is a simple matter of subtracting one time from the other:

function getOffset(dateA, dateB) {
    var date1 = new Date(dateA);
    var date2 = new Date(dateB);
    var diff = Math.round((date2.valueOf() - date1.valueOf()) / (60 * 1000));
    return diff;
}

Formatting is also pretty simple - just separate the hours using floored division and modulus the total to get the remainder in minutes:

function formatOffset(offset) {
    if (!minutes) {
        return 'Z';
    }

    var h = Math.floor(Math.abs(minutes) / 60);
    var m = Math.abs(minutes) % 60;
    var offset = '';
    if (minutes > 0) {
        offset = '+';
    } else if (minutes < 0) {
        offset = '-';
    }

    // +0500, -0730
    return (
        offset + h.toString().padStart(2, '0') + m.toString().padStart(2, '0')
    );
}

Snippet adapted from xtz.js.

Caveats with Offsets

Keep in mind that the offset may be different based on the time of year.

If you’re starting from a UTC time and calculating an offset in another time zone as if it were UTC, you’ll still get the wrong answer crossing daylight savings boundaries.

Also, offsets are political - they rarely, but sometimes change from one year to another as dictated by local governments.

Get Time Zone offset at it’s own time

If you need accurate timezone offsets, even at 1:30am during a Daylight Savings time switch…

Just use xtz.js:
https://therootcompany.github.io/tz.js/

XTZ.toUTC('2021-03-14 03:30:59.001', 'America/New_York').toISOString();
// 2021-03-14T03:30:59.001-0400

XTZ.toUTC('2021-03-14 03:30:59.001', 'America/Denver').toISOString();
// 2021-03-14T03:30:59.001-0600

XTZ.toUTC('2021-03-14 03:30:59.001', 'America/Phoenix').toISOString();
// 2021-03-14T03:30:59.001-0700

XTZ.toUTC('2021-03-14 03:30:59.001', 'America/Los_Angeles').toISOString();
// 2021-03-14T03:30:59.001-0700

XTZ.toUTC('2021-03-14 03:30:59.001', 'UTC').toISOString();
// 2021-03-14T03:30:59.001Z

XTZ.toUTC('2021-03-14 03:30:59.001', 'Australia/Adelaide').toISOString();
// 2021-03-14T03:30:59.001+1030

XTZ.toUTC('2021-03-14 03:30:59.001', 'Asia/Kathmandu').toISOString();
// 2021-03-14T03:30:59.001+0545

Essentially, the only method by which it is possible to do these lookups without requiring between 100s of kilobytes (or megabytes) of lookup tables (such as moment.js, date-fns-tz, and Temporal Polyfill) is to follow a “guess and check” strategy:

  1. Get the offset, as mentioned above
  2. Create a date from an ISO string, replacing Z with the offset
    • i.e. new Date('2021-03-14T03:30:59.001-0400')
  3. Pass that date through formatToParts(date) a second time
  4. Create a new date from that, after translating to an ISO string + offset
  5. Calculate the difference between the getUTCHour() and getUTCMinute() of both dates
    • if 0, you’ve got the correct time
    • otherwise, adjust the hour, minute, and offset accordingly

That’s the method XTZ.js uses, and it will always yield the correct time, in all circumstances.

Caveats with Daylight Savings

Wherever Daylight Savings (or equivalent) is used, there is one day at which one hour does not exist at all - 2:30am on 13 March, 2021 in New York, for example - and one day at which an hour exists twice - 1:30am on 7 Nov, 2021 in New York, for example.

When to represent events scheduled at those times on a calendar is up for debate.

Convert between Time Zones

Once you have a method by which to convert from a zoned time to UTC, you can simply then convert that to a different zone:

var nyTime = XTZ.toUTC('2021-01-01 00:59:59', 'America/New_York');
nyTime.toISOString();
// 2021-01-01T00:59:59.000-0500

var kTime = XTZ.toTimeZone(new Date(nyTime.toISOString()), 'Asia/Kathmandu');
kTime.toISOString();
// 2021-01-01T11:44:59.000+0545

Final Notes

Since time changes over time, you’re safest bet for scheduling far-off events (say more than 6 months away), is to store the target Time Zone and Time (i.e. 2021-03-14 01:30:00.000 @ America/New_York) and then recalculate the absolute UTC time around the 6 month mark.

It is highly unlikely that a government will change a time zone or daylight savings time without well more than 6 months of official notice (plus perhaps years of unofficial notice).

That said, an operating system (wether phone, desktop, or server) must receive updates from time to time that include new Time Zone alterations.