Date Math Across Time Zones with Moment.js

Time zones—two words that strike fear deep in the heart of every developer. And rightly so. Humans started keeping time over a century ago, pegging their concept of “noon” to the point in the day when the sun is directly overhead. Since then, the world has steadily been moving to where we are now, with humans and computers communicating with each other in sub-millisecond time around the globe. This situation has created a morass of rules that no programmer could reasonably expect to keep in their head.

"Clocks", by tehusagent. Licensed under CC BY-ND 2.0.
“Clocks”, by tehusagent. Licensed under CC BY-ND 2.0.

Unfortunately, in order to be useful to the humans who use them, computers need to work with this situation. Thankfully, the application of time zone rules can be delegated to libraries like Moment Timezone, but programmers still need to be conscious of several concepts so they can use these libraries effectively and steer clear of bugs.

Context is Everything

If I gave you the time “Monday, July 11, 2016 at 9:25:11 a.m.,” what would it mean to you?

Given that I’m sitting in the Eastern Time Zone, and you may be reading this in the Central Time Zone, it could mean the same thing or something slightly different. From the point of view of local time, we both experienced 9:25:11 a.m. on July 11 (not necessarily consciously—maybe you were sleeping, but I was working on writing this post).

But we didn’t experience it at the same time. When it was 9:25:11 a.m. for me, it was 8:25:11 a.m. for you. Your 9:25:11 a.m. happened at my 10:25:11 a.m. The 9:25:11 a.m. I gave you, with no time zone information, is what is known as local time—a time specification only meaningful where it is observed.

In order for a computer to make use of this, it needs to assume context. Humans generally assume that you mean the clock in their local time zone read “9:25:11 a.m.” Since humans program computers, in many cases, they’ve developed their time-handling libraries to do the same thing.

This is the very first thing you need to be aware of–and quite possibly to fight–if you intend to build a system that needs to operate across time zones.

Locally Sourced, Organic Dates

If you’re developing in JavaScript and dealing with dates, sooner or later you’ll run into the JavaScript Date object. This object not only makes the same assumption that we do above—that every date is in the “local” time zone—but it is only capable of working in that space. It’s also important to understand what “local” means. Since JavaScript has broken out of the browser into other spaces, it can be affected by your users’ possibly incorrect time zone settings and the time zone where your server operates.

To illustrate how this works, I’m going to use the Node.js REPL from the command line. Here’s one cool trick you can try when exploring how date libraries work: use the TZ environment variable to tell Node.js you’re actually in a different time zone than your computer thinks you are.

Compare:

$ node
> new Date('2016-07-11 09:25:11 AM');
Mon Jul 11 2016 09:25:11 GMT-0400 (EDT)

vs.

$ TZ=UTC node
> new Date('2016-07-11 09:25:11 AM');
Mon Jul 11 2016 09:25:11 GMT+0000 (UTC)

Although both of these times say “9:25:11,” they refer to two very different instances of time, as you can see by asking for the ISO 8601 representation:

$ node
> new Date('2016-07-11 09:25:11 AM').toISOString();
'2016-07-11T13:25:11.000Z'
$ TZ=UTC node
> new Date('2016-07-11 09:25:11 AM').toISOString();
'2016-07-11T09:25:11.000Z'

If you’ve worked with ISO 8601 time before, you’ve likely seen that Z (or possibly +00:00, which means the same thing). This means that the offset from UTC is zero—literally; the given time is zero minutes’ difference from UTC. For an introduction to offsets and time zones and why they’re different, see Time Zones Aren’t Offsets–Offsets Aren’t Time Zones. Because there’s an offset specified, these time representations are unambiguous and directly comparable: they are four hours’ difference from each other.

It’s important to remember that even though most ISO 8601 uses include offsets and have this property of direct comparability, ISO 8601 does not require the use of an offset. So if your ISO 8601-expecting code gets a time string without an offset, that’s actually local time and may have the local time zone applied. In other words, in the Eastern Time Zone, the ISO 8601 string 2016-07-11T09:25:11 is equivalent to 2016-07-11T13:25:11.000Z. This can be the source of difficult-to-trace bugs, so make sure you always send and expect ISO 8601 time with offsets specified.

All of this is probably enough if your application just wants to display time to users in a format they understand and communicate with computers using ISO 8601 timestamps. You can even ask the question of whether one time is before or after another time, so long as you’re working in offset time and not local time.

But when you start needing to ask even seemingly innocent questions like, “Did this time happen on July 10 or July 11?” or “I need to schedule something to run at 8 a.m. tomorrow— how do I do that?” or “A user in Eastern Time said they want something to happen at 11 a.m. in Pacific Time—how do I do that?” you’ll need to bring in heavier firepower in the form of Moment Timezone.

What moment is this…?

Let’s start with the first question I presented: When I experienced 9:25:11 a.m., was it July 10 or July 11? You may immediately say “July 11, of course,” but that’s local time rearing its head again. The correct answer can only be obtained after first answering two other questions: What time zone is the 9:25:11 a.m. in, and in what time zone do we want to make the July 10 or 11 determination?

Let’s break down what kinds of time we’re working with at each step:

  1. When we know that it’s “9:25:11 a.m.,” that’s local time.
  2. Before we can do any calculations, we need to decide what time zone that local time is in. This upgrades our time object to something I’ll call zoned time.
  3. To determine whether it’s July 10 or 11, we’ll need to jump time zones to get another zoned time in our target time zone.
  4. Finally, we can determine if it’s July 10 or 11 by looking at local time in our target time zone.

Here are those steps in the Node.js REPL, if we want to know what day it is in Detroit when it’s 9:25:11 a.m. on July 11 in Tokyo:

$ node
> const moment = require('moment-timezone');
> const sourceLocalTime = '2016-07-11T09:25:11';
> const sourceZonedTime = moment.tz(sourceLocalTime, 'Asia/Tokyo');
> const targetZonedTime = sourceZonedTime.clone().tz('America/Detroit');
> targetZonedTime.format();
'2016-07-10T20:25:11-04:00'

Let’s unpack what’s going on here.

  1. sourceLocalTime starts us off. It is a local time in ISO 8601 format.
  2. We feed sourceLocalTime into Moment’s tz and tell it that said local time is in Tokyo’s time zone. This gives us a Moment object we can use to perform all future Moment manipulation in that time zone.
  3. We clone the sourceZonedTime object. Moment objects are mutated by every operation, so this gives us a chance to inspect sourceZonedTime later—and it’s a good habit to get into anyway. It helps avoid bugs with Moment objects suddenly referring to unexpectedly different times. We can then translate the clone to another time zone, keeping the exact same global instance in time, but telling Moment to perform all further operations in Detroit’s time zone.
  4. We call format to get a human-readable representation of what time it is in Detroit, which is done with some simple math using Detroit’s offset at the time the Moment object refers to. Note that this string does show us local time, but it’s actually offset time because of the -04:00 offset. You should not use toString to determine in code what day it is in a particular time zone—there are better APIs for that—but it’s useful for inspecting a Moment object in a REPL.

I am Moment, Manipulator of Time

Next, let’s answer this question: “I need to schedule something to run at 8 a.m. tomorrow— how do I do that?” Assuming our scheduling interface is sane and takes unambiguous, offset ISO 8601 times as its input, we only need to ask, “8 a.m. in what time zone?” The steps look like this:

  1. We figure out what time it is right now. This could be internally represented as offset time or zoned time, but it doesn’t actually matter which, because…
  2. …we’ll be immediately translating it to our target time zone. Since we’re not changing the global instance in time before this translation, an offset time is sufficient until this point.
  3. We’ll add one day to get tomorrow in our target time zone. This usually moves us forward 24 hours, but if we’re dealing with Daylight Saving Time, we could actually be moving forward 23 or 25 hours. (This is why it’s important to use a library that understands these things, rather than try to do these calculations on your own.)
  4. We’ll set the time to 8:00 a.m. in our target time zone. Again, the actual movement in time could be affected by an offset change due to DST, or any other factor.
  5. We’ll take the result and output an unambiguous, offsetted ISO 8601 time for our scheduling interface.

The process looks like this:

> const now = moment();
> const zonedNow = now.clone().tz('America/Los_Angeles');
> const tomorrow = zonedNow.clone().add(1, 'day');
> const atEight = tomorrow.clone()
                          .hour(8).minute(0).seconds(0).milliseconds(0);
> atEight.format();
'2016-07-12T08:00:00-07:00'
> atEight.toISOString();
'2016-07-12T15:00:00.000Z'

Stepping through this:

  1. now is just right now. Again, we don’t care what time zone.
  2. zonedNow is the same thing, but cloned and then set up so that all further calculations happen in LA.
  3. tomorrow is now plus one day.
  4. atEight is the same day, at 8:00:00.000 a.m. We format it to peek at it. The output of toISOString is the UTC-offset ISO 8601 time we’ll feed to our library.

Aberrant Time

Up until now, we’ve been dealing with nice, clean time with clear paths between local and zoned time representations. But sometimes things aren’t so clear-cut, and you need to do some unpleasant things with time to get the correct answer.

As an example, let’s say you have a black-box user interface that gives a time and date. It runs in the browser and outputs JavaScript Date objects. If the user and the system to which they are providing times are in the same time zone, this works okay, since an ISO 8601 string is easy to get from a Date and will represent the same instance in time.

But if the user’s system is misconfigured into the wrong time zone, or if they are simply picking times that are actually intended for another time zone, this breaks down; 9:25:11 a.m. Eastern does not refer to the same instance in time as 9:25:11 a.m. Pacific. How do you convert one to the other?

How can we solve this problem? Here’s how I’ve done it:

  1. We start by taking our Date object and remove the time zone and offset, creating pure local time.
  2. We now take that local time and use it as if it was from our target time zone, instead of from the Date object’s time zone.
  3. With the resulting zoned time, we can now easily get an ISO 8601 representation of 9:25:11 a.m. as it would be observed in the target time zone.

This particular sin of computing looks like this:

> const date = new Date('2016-07-11 09:25:11');
> const localTime = moment(date).format('YYYY-MM-DDTHH:mm:ss.SSS');
> const targetZonedTime = moment.tz(localTime, 'America/Chicago');
> targetZonedTime.format();
'2016-07-11T09:25:11-05:00'

Breaking it down:

  1. Our black-box library supplies us a Date, normally—but for experimenting in our REPL, we’re building one ourselves from our good friend, 9:25:11 a.m.
  2. We feed the Date object to Moment, which dutifully absorbs our local zone from it. We then force the local zone to be thrown away by telling Moment to render an ISO 8601 local time, without an offset.
  3. We then feed that ISO 8601 local time string to Moment again. This time, we build a zoned time with the same local time as we got from our black box, but we tell Moment that it’s actually from a different time zone. This doesn’t cause any translation, because there is no source time zone to translate from.
  4. We can do any Moment calculations we want on this result. In this case, we’re just going to use format to admire our handiwork.

The arguably more-correct response to the black-box interface’s inability to work in arbitrary time zones is, of course, to change it to work correctly or swap it for something that does. However, you will encounter situations like this where you won’t have that option. Knowing how to leverage Moment’s tools to resolve these issues is very useful.

You Are Now the Master of Time

Hopefully, this short tour through some of the things you can do with Moment Timezone will help you navigate the choppy seas of time that you’ll experience working in JavaScript. I’m very interested to hear if it helped you, or if you have some strange problem that you’ve solved with Moment Timezone—or any other time library—in the comments.

Happy timekeeping!