Tricky, tricky dates

Arseni Mourzenko
Founder and lead developer
177
articles
April 5, 2021
Tags: featured 8 testing 8 unit-testing 6 javascript 3

Any pro­gram­mer who worked with dates would tell that dates are a com­plex sub­ject. Time zones, UTC, day­light sav­ing time, leap years, and, my fa­vorite of all—leap sec­onds that “are not en­tire­ly pre­dictable”—can make your life mis­er­able very quick­ly.

And it did.

I was work­ing on a fea­ture of a pro­ject I in­her­it­ed from a group of de­vel­op­ers, when I no­ticed that the dates weren't be­hav­ing very nice­ly. The in­ter­face had a form, and in this form, there was a field spec­i­fy­ing a time: hour, minute, and sec­ond. For ex­am­ple: “09:37:20.” When the form was sub­mit­ted, this val­ue shift­ed mag­i­cal­ly ap­prox­i­mate­ly nine and a half min­utes back in time. Check­ing the code, I dis­cov­ered a few in­ter­est­ing things.

Date ini­tial­iza­tion

The date was ini­tial­ized like this: new Date(0, 0, 0, hour, minute, second, 0). It may take a mo­ment to no­tice that there is an er­ror here. And an­oth­er mo­ment to un­der­stand that what it looks like is not at all what hap­pens un­der the hood. Let's go to the Mozil­la's doc­u­men­ta­tion page of Date con­struc­tor to see what the dif­fer­ent ar­gu­ments mean here.

The doc­u­men­ta­tion starts with a fun fact:

Any miss­ing fields are giv­en the low­est pos­si­ble val­ue (1 for day and 0 for every oth­er com­po­nent).

So ac­tu­al­ly, the min­i­mum val­ue for day is 1, but JavaScript doesn't com­plain if one spec­i­fies a low­er val­ue. In­deed:

$ new Date(2021, 5, 1, 14, 30, 45, 0)
2021-06-01T12:30:45.000Z

gives the 1st of June (in JavaScript, it's 5th month in a range start­ing from zero, so June, not May), while:

$ new Date(2021, 5, 0, 14, 30, 45, 0)
2021-05-31T12:30:45.000Z

moves us one day in the past, that is, the 31th of May. Ac­tu­al­ly, JavaScript doesn't even com­plain if you spec­i­fy a neg­a­tive val­ue, so new Date(2021, 5, -9, 14, 30, 45, 0) leads to the 22th of May, and new Date(2021, -5, -9, 14, 30, 45, 0) is the 22th of July, 2020. Nor does it ar­gue with you when you want it to cre­ate 32th of June, which would be con­ve­nient in a mo­ment.

Year is a dif­fer­ent beast. Ac­cord­ing to the same doc­u­men­ta­tion:

Val­ues from 0 to 99 map to the years 1900 to 1999. All oth­er val­ues are the ac­tu­al year.

In oth­er words, the date of death of Cae­sar Ner­va Tra­janus (8 Au­gust 117) can be rep­re­sent­ed with a Date con­struc­tor in JavaScript, but his birth­date (18 Sep­tem­ber 53) can­not. Not that you have to deal with those sorts of dates in JavaScript on dai­ly ba­sis.

The speci­fici­ty of the treat­ment of the year ar­gu­ment, and the fact that zero is low­er than the min­i­mum ac­cept­ed val­ue for the day ar­gu­ment means that 0, 0, 0 leads to the 31th of De­cem­ber of the year 1899. Since the ap­pli­ca­tion ma­nip­u­lates only the time, is it re­al­ly that im­por­tant whether the date hap­pens to be in 2021 or a cen­tu­ry ago? It ap­pears that it is im­por­tant, as soon as you con­sid­er the time zones.

Time zones

Most ma­nip­u­la­tions with the dates in­volve a some­times im­plic­it con­ver­sion from a time zone to an­oth­er. The sole way to call a con­struc­tor builds a lo­cal time. You may have no­ticed that in the first ex­am­ple in this ar­ti­cle, Node.js gave an UTC rep­re­sen­ta­tion of the date I cre­at­ed. 14:30 GMT+2 was trans­lat­ed into 12:30 UTC. De­pend­ing on the day and month, on my ma­chine Node.js will be us­ing ei­ther the Cen­tral Eu­ro­pean Stan­dard Time (GMT+1) or Cen­tral Eu­ro­pean Sum­mer Time (GMT+2). For in­stance, new Date(2021, 1, 26, 14, 30, 45, 0) gives ...13:​30:​45.​000Z which means that it's GMT+1 which was used in Feb­ru­ary 2021 in my time zone.

Let's list the days when the time zone changed:

var prev = undefined;
for (var i = 1; i < 45000; ++i) {
    var d = new Date(0, 0, i, 12, 0, 0, 0);
    var offset = d.getTimezoneOffset();
    if (offset != prev) {
        prev = offset;
        console.log(d.toString().substr(0, 15), Math.abs(offset));
    }
}

Here's the re­sult:

1
1
DST in­tro­duced in mul­ti­ple coun­tries for the first time.
DST in­tro­duced in mul­ti­ple coun­tries for the first t...
More coun­tries adopt DST “to con­serve coal dur­ing wartime.”
More coun­tries adopt DST “to con­serve coal dur­ing wartim...
Wikipedia
Wikipedia
The same 0/60 pat­tern re­peats every year.
The same 0/60 pat­tern re­peats every year.
Lots of
Lots of
events
events
hap­pen­ing dur­ing the oc­cu­pa­tion by Nazi Ger­many.
hap­pen­ing dur­ing the oc­cu­pa­tion by Nazi Ger­man...
“[DST] was wide­ly adopt­ed […] as a re­sult of the 1970s en­er­gy cri­sis.”
“[DST] was wide­ly adopt­ed […] as a re­sult of the 1970s en­er­gy...
Pro­vi­sion­al Gov­ern­ment of the French Re­pub­lic aban­dons DST.
Pro­vi­sion­al Gov­ern­ment of the French Re­pub­lic aban­dons D...
Wikipedia
Wikipedia
EU di­rec­tive
EU di­rec­tive
on sum­mer-time arrange­ments.
on sum­mer-time arrange­ments.
97/44/EC
97/44/EC
Mon Jan 01 1900   9
Sat Mar 11 1911   0
Thu Jun 15 1916   60
Mon Oct 02 1916   0
Sun Mar 25 1917   60
Mon Oct 08 1917   0
Sun Mar 10 1918   60
Mon Oct 07 1918   0
Sun Mar 02 1919   60
Mon Oct 06 1919   0
Sun Feb 15 1920   60
Sun Oct 24 1920   0
Tue Mar 15 1921   60
Wed Oct 26 1921   0
        ⋮        
Sun Mar 27 1938   60
Sun Oct 02 1938   0
Sun Apr 16 1939   60
Sun Nov 19 1939   0
Sun Feb 25 1940   60
Sat Jun 15 1940   120
Mon Nov 02 1942   60
Mon Mar 29 1943   120
Mon Oct 04 1943   60
Mon Apr 03 1944   120
Sun Oct 08 1944   60
Mon Apr 02 1945   120
Sun Sep 16 1945   60
Sun Mar 28 1976   120
Sun Sep 26 1976   60
Sun Apr 03 1977   120
Sun Sep 25 1977   60
Sun Apr 02 1978   120
Sun Oct 01 1978   60
        ⋮        ⋮
Sun Mar 29 2020   120
Sun Oct 25 2020   60
Sun Mar 28 2021   120
Sun Oct 31 2021   60
Sun Mar 27 2022   120
Sun Oct 30 2022   60
Mon Jan 01 1900   9...
2
2
View­er does not sup­port full SVG 1.1

Steady pat­tern of two changes per year be­tween 1916 and the Sec­ond World War, then be­tween the oil cri­sis of 1970 and to­day. Maybe not the eas­i­est thing to fol­low, es­pe­cial­ly around the change from GMT+0/GMT+1 to GMT+1/GMT+2 1 dur­ing the Sec­ond World War, but straight­for­ward any­way. Things get very dif­fer­ent at the very be­gin­ning of the cen­tu­ry. Be­fore 1911, in­stead of a usu­al shift ex­pressed in mul­ti­ples of six­ty min­utes (thir­ty in some coun­tries), we have here a time zone with a nine min­utes dif­fer­ence. 2

To see it in ac­tion, let's com­pare two dates:

$ var t1 = new Date(1911, 2, 11, 0, 0, 0);
$ t1.toUTCString();
"Sat, 11 Mar 1911 00:00:00 GMT"
$ var t2 = new Date(1911, 2, 11, 0, 0, -1);
$ t2.toUTCString();
"Fri, 10 Mar 1911 23:50:38 GMT"

What about the mil­lisec­onds BUE?

$ t1.getTime();
-1855958400000
$ t2.getTime();
-1855958962000
$ (t1.getTime() - t2.getTime()) / 1000;
562

Yes, that's a 562 sec­onds dif­fer­ence in one sec­ond. Δ𝑡=9:21. A French page of Wikipedia on this sub­ject ex­plains what hap­pened. On the 9th of March 1911, it was de­cid­ed in France to move to the of­fi­cial GMT sys­tem, which had a dif­fer­ence of 9 min­utes and 20.921 sec­onds. And so they did, and now there is a bug in my app.

The bug it­self

The ap­pli­ca­tion was do­ing two things: con­vert­ing the string rep­re­sen­ta­tion of the time to the Date ob­ject, and then cre­at­ing a string rep­re­sen­ta­tion of it. By mis­take, the string rep­re­sen­ta­tion was us­ing UTC, while the date it­self, when cre­at­ed with the con­struc­tor of the Date class, uses the lo­cal time zone. The orig­i­nal date such as new Date(0, 0, 0, 14, 30, 45, 0) would be­come Sun Dec 31 1899 14:30:45 GMT+0009, and when this date ob­ject was then con­vert­ed to UTC, the time part would be­come 14:21:24. Very con­ve­nient­ly, the hour re­mains the same, but the min­utes and sec­onds are dif­fer­ent from the orig­i­nal time.

Fun­ny thing: the orig­i­nal de­vel­op­ers have pos­si­bly test­ed their code, and no­ticed no er­rors at all. The pro­ject was orig­i­nal­ly de­vel­oped in the Unit­ed King­dom, and with GMT, this weird piece of code works per­fect­ly well. The new Date(0, 0, 0, 14, 30, 45, 0) re­sults in 1899-12-31T14:30:45.000Z, and once con­vert­ed to UTC, gives 14:30:45—the same time as the orig­i­nal in­put.

Ide­al­ly, one would test code us­ing dif­fer­ent time zones. Just like in­ter­na­tion­al­ized ap­pli­ca­tions are test­ed us­ing dif­fer­ent cul­tures—well, at least in the rare cas­es where they are ac­tu­al­ly test­ed. Node.js al­lows to change the time zone by set­ting the val­ue of process.env.TZ. In the cur­rent case, the ex­act state­ment would be: process.env.TZ = 'Europe/Paris'.

On the oth­er hand, you can't set the time zone in the brows­er. With a ma­ture test in­fra­struc­ture, it should be pos­si­ble to set the lo­cal time zone of a giv­en agent run­ning the tests. Most pro­jects, how­ev­er, don't have the in­fra­struc­ture as ma­ture.

An­oth­er is­sue is to know what to test. I learned about the events of 1911 be­cause I live in France and be­cause I no­ticed the bug on my ma­chine. I have no idea what fan­cy rules were ap­plied a cen­tu­ry ago in In­done­sia, Ar­me­nia, or Paraguay. Nei­ther do I ex­pect de­vel­op­ers from the UK or any oth­er coun­try to know what hap­pened in France on the 9th of March.

The fact is, dates are com­plex. And JavaScript, by not san­i­tiz­ing the in­puts cor­rect­ly, is not very help­ful. Us­ing ma­ture li­braries, such as Mo­ment.js, which hide quite well the idio­syn­crasies of JavaScript while al­low­ing to set the time zone glob­al­ly, is an op­tion. With or with­out such li­braries, one should al­ways keep in mind that any­thing re­lat­ed to dates is as tricky as Uni­code.