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.
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
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.
$ node > new Date('2016-07-11 09:25:11 AM'); Mon Jul 11 2016 09:25:11 GMT-0400 (EDT)
$ 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:
- When we know that it’s “9:25:11 a.m.,” that’s local time.
- 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.
- 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.
- 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.
sourceLocalTimestarts us off. It is a local time in ISO 8601 format.
- We feed
tzand 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.
sourceZonedTimeobject. Moment objects are mutated by every operation, so this gives us a chance to inspect
sourceZonedTimelater—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.
- We call
formatto 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:00offset. You should not use
toStringto 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:
- 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…
- …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.
- 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.)
- 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.
- 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:
nowis just right now. Again, we don’t care what time zone.
zonedNowis the same thing, but cloned and then set up so that all further calculations happen in LA.
tomorrowis now plus one day.
atEightis the same day, at 8:00:00.000 a.m. We
formatit to peek at it. The output of
toISOStringis the UTC-offset ISO 8601 time we’ll feed to our library.
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.
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:
- We start by taking our
Dateobject and remove the time zone and offset, creating pure local time.
- We now take that local time and use it as if it was from our target time zone, instead of from the
Dateobject’s time zone.
- 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:
- 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.
- We feed the
Dateobject 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.
- 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.
- We can do any Moment calculations we want on this result. In this case, we’re just going to use
formatto 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