TLDR: “Dates are broken; use Luxon and subsequently Temporal; test timezones in your unit tests if you can!”.

Part 1 — Picking A Picker

Long ago, we’d started off by ruling out any date pickers tied up in an existing design system — great for some, but hard to style to fit our braaaaaand. Typescript support was desirable, but not a must-have. Really, it just needed to let a user pick dates in a non-frustrating way. That ruled out any library which didn’t use a dropdown for years — clicking back month-by-month to 1972 isn’t the most fun way to spend a half-hour, trust me.

We ended up settling on React Datepicker because it wasn’t too hard to style, and “date library agnostic”. Turns out, this meant the component would deal exclusively in JS Date objects.

If you haven’t dealt much with dates and times, feel free to back out now — I won’t judge you. It’s the sort of thing that you should just be able to leave to other people. Annoyingly, in this case, that’s not entirely possible.

So look, not to get all fancy about it, but we have users in a few different countries. With different timezones. Some of those users occasionally need to pick some dates. We try to accommodate our users in order to get paid — you know how it is.

So when Jean-Luc opens up our date picker in his apartment in sunny Hamburg, he sees a simple date picker that lets him choose three things:

  • The year;
  • The month; and
  • The day.

It seems so simple. So pure. And if Hacker0x01 had simply chosen to return this year, month and day, then they wouldn’t have roughly a trillion issues that look like this:

Many, many issues in GitHub with titles describing issues with setting timezones with React Datepicker

The trouble is, clearly someone over there was a big fan of Using The Platform. Instead of making their own Date structure with these three properties — or just returning a string — they’ve chosen to return a native JS Date.

Part 2 — Dates: They're Bad!

Here’s the issue: JS isn’t magic. A Date object isn’t something which mysteriously “does the right thing” on a bunch of systems. It’s a pretty thin wrapper around a Unix timestamp.

A Unix timestamp is measured in seconds since midnight, Jan 1st, 1970 (UTC). Actually, that’s not quite true — UTC didn’t exist until 1972, so there’s a bunch of weird stuff where you actually have to calculate it from 1972 and then add a constant number of seconds on top. And even then, guess what? Sometimes the Unix timestamp repeats itself with leap seconds. Try and find out what precise second the timestamp “915148800” refers to, if you’re interested. Spoiler alert: there’s two of them!

But anyway, it’s way too easy to get lost in the Forest Of All Knowledge with this stuff. Why do we care about leap seconds and monotonicity all of a sudden? We’re just building a date picker, for crying out loud!

The real problem is, we’ve gone from a simple [year, month, day] representation of data to a much higher-resolution one. In GCSE Physics, we were taught about the difference between “accuracy” and “precision”. Here, we’re just pretending we’ve got more precision than we really have, a bit like rating a restaurant 5.0000000 stars. The user hasn’t indicated anything about what hour, minute or second they’re interested in. We’re just — wrongly — using the Date container because it has a relevant name. Unfortunately, the underlying Unix timestamp implementation has very little to do with dates as our users understand them.

Part 3 — Uh Oh, It Actually Breaks Things

Our problem isn’t just hypothetical, though. In our case, we’re building financial software (boo, hiss). So Jean-Luc is actually interested in some data from 2/3/2014 to 2/3/2016*. In our database, we have a bunch of entries with dates attached, and we want to send him all the entries within that range.

When Jean-Luc chooses a date using Hacker0x01’s date picker, he's just choosing a year/month/day — a date in the conventional, human sense. But as soon as it's converted to a JS Date, it's given a lot of extra data:

First line, a JS prompt: new Date(2014, 2, 2). Second line, the result: Sun Mar 02 2014 00:00:00 GMT+0100 (Central European Standard Time).
(The second argument here is 2 because months start from 0.)

Suddenly, our date looks more like a super-precise timestamp, down to the second. It also looks like it contains information about Jean-Luc's timezone — Central European Standard Time, or CEST for short.

It sort of does, as well — if you call .getTimezoneOffset() on this date, it'll happily tell you that the current offset is “-60”: if you take the timestamp as-shown, and subtract sixty minutes, then you'll get the timestamp as it would be in the UTC zone. (Slightly confusing, given that it's written as GMT+1 in the format, but sure.)

Here's what happens if you call .toISOString() on this date:

First line, a JavaScript prompt: d.toISOString(). Second line, the evaluated expression: '2014-03-01T23:00:00.000Z'.

Bizarrely, it applies the timezone offset, and then renders the timestamp to the ISO format, with an offset of zero — marked by the trailing Z. (source)

This is also what happens if you use JSON.stringify on a Date object — the toISOString method gets called, with exactly the same result. (source)

On the server side, we’ll parse this date using... well, anything, really. Regardless of whether we use new Date(...) here, or something third-party, the damage is already done. Any reasonable parser will see that timestamp, and when asked “what date is that timestamp referencing?”, will give the answer 2014-03-01. The first of March, not the second. Sure, it's a bit late at night... but it's the first of March nonetheless.

Part 4: Can We Just, Like, Not

This is baffling. We've taken a built-in Date object, as provided by a third-party library, and serialised it using the default method, to a format which should support timezone offsets. We then parsed it using the default method on our server, which correctly supports timezone offsets. And somehow, we've ended up with a completely different day!

(Parsing dates can also be problematic, as Jacob Jedryszek points out here, although not relevant in this specific case.)

Now, maybe we're tempted to write dirty hacks on the server to get this working. Ooh, maybe we can just find the closest midnight instead of using the current day. Nope — timezone offsets can be more than 12 hours, so this will reliably be wrong for folks in some parts of NZ, Samoa, Tonga, and Kiribati. (source)

At this point, we've gained precision (added a lot of zeroes) and lost accuracy (we're not even talking about the same day any more).

Poor Jean-Luc. All he wanted to do was pick a date. And poor us, to be honest — we did what we'd always been told to do with dates (and displaying maps, and optimising code, and cryptography, etc.): we didn't roll our own code; we relied on the platform, and some popular third-party code with good test coverage. In short, we took the path of least resistance. And it's totally wrong.

Part 5: Alright, Now Get To The Point

For dates — and I'll die on this hill — the correct data structure is a tuple: [year, month, day]. We can add any other features around weekdays or adding intervals or ranges or whatever using a collection of helper functions. The popular date-fns library almost gets this — they've got a fantastic idea of using a simple builtin type, and providing a bunch of useful functions which act on that builtin type. But the problem, of course, is that the builtin type is rubbish.

I'm now very much convinced that a library very similar to date-fns should exist, but that acts on ISO date strings ('YYYY-MM-DD') or date-tuples [year, month, day] instead of native Date objects, or anything third-party like Moment/Luxon. Maybe for time stuff there could be a library which acts on full ISO timestamps, but I see that as a separate problem-space entirely — calendar dates shouldn't rely on timestamp weirdness!

That all being said, I'm not going to build one, because it'll immediately become obsolete as soon as the Temporal API becomes a reality. Check out the PlainDate spec — it's just what a calendar date-picker should deal in!

Anyway, I believe the correct option to make this work right now, in the absence of a reasonable builtin Date type, is some combination of these:

  1. Immediately use the getDay, getMonth and getYear methods to get a [year, month, day] tuple to pass around and format yourself.
  2. Optionally, realise that getYear actually returns the number 114 for this, and discover that you instead have to use getFullYear. Ideally, this should be done only after the code reaches production, in order to tell as many users as possible that their stock market data begins just 14 years after the first appearance of the wheelbarrow in China.**
  3. Add loads of test cases which mock the system's timezone and time — eurgh, fine, we should probably do this. But it sounds really hard.
  4. Use Luxon, which at least does include the current system's timezone by default in its formatted ISO strings, and provides a nice method called toISODate which only returns the actual date bits of the DateTime. Both methods output the date as chosen by the user, in their own timezone — you can choose to keep the timezone info or get rid of it when serialising, but there's no nasty surprises either way.

We're using #4 (and, aspirationally, #3) in our app — Luxon's many methods for adding/subtracting weeks, months and years are too useful to give up. We'll try and make sure that we're using toISODate where possible to introduce as little false precision as possible — serialised calendar dates should look like YYYY-MM-DD, nothing more.

And maybe we'll be the first to open-source a date-picker which uses the Temporal API. Who knows?

*I'm using the European date format here, day/month/year. It doesn't hugely matter though, the format is less important here than the underlying representation.

**I should clarify, this didn't happen to us. At least, not yet.