Best practices when working with universal time
Wall clock and Timeline
Let's divide Date's object methods into 2 categories:
Wall clock type:
.getWallClockLabels()
family:.getFullYear()
,.getMonth()
,.getDate()
,.getHours()
,.getMinutes()
,.getSeconds()
,.getMiliseconds()
,.getDay()
..setWallClockLabels()
family:.setFullYear()
,.setMonth()
,.setDate()
,.setHours()
,.setMinutes()
,.setSeconds()
,.setMiliseconds()
,.setDay()
.- Wall clock constructor:
new Date(year, month, date, hour, minutes, seconds, miliseconds)
Timeline point type:
.getTime()
.setTime()
- Timeline constructor:
new Date(time)
ECMAScript's temporal proposal categorizes Clock Time vs. Exact Time, or, Plain vs. Instant.
TLDR
In Javascript, especially when there are multiple runtime environments (browser, server, local development, etc.)
.getWallClockLabels()
,.setWallClockLabels()
, wall clock constructor are ALL INCORRECT. You should never use these methods directly on your Date object or use directly use the object created by this constructor..getTime()
,.setTime()
, timeline constructor are, instead, reliable and can be safely used.
Why
Two date objects pointing to the same position in the timeline (same .getTime()
value), have wall-lock methods behaviours differently in different runtime environments.
TLDR, quick yet efficient solutions: with toAbsDate(date)
, toViewDate(date)
defined below
- Always pass the object through
toViewDate()
before calling.getWallClockLabels()
/.setWallClockLabels()
. - After that, convert the result back with
toAbsDate()
. - Always convert date object created by wall-clock constructor with
toAbsDate()
.
const defaultOffset = -540 // Tokyo, replace with the target timezone you want to get wall clock label
const oneMin = 60_000 // in milisec
export const toAbsDate = (date: Date, offset = defaultOffset) => new Date(
date.getTime()
+ oneMin * (
offset - (date.getTimezoneOffset() ?? new Date().getTimezoneOffset())
)
)
export const toViewDate = (date: Date, offset = defaultOffset) => {
const utcTime = new Date(date.getTime() - offset * oneMin)
return new Date(
utcTime.getUTCFullYear(),
utcTime.getUTCMonth(),
utcTime.getUTCDate(),
utcTime.getUTCHours(),
utcTime.getUTCMinutes(),
utcTime.getUTCSeconds(),
utcTime.getUTCMilliseconds()
)
}
Note that this solution works in most cases, but it does not cover 100% of cases. To know exactly what cases this solution fails to work, scroll down to the next section.
Example
To get starting of the day of a date
object.
const getToday = (date: Date) => {
const viewDate = toViewDate(date)
return toAbsDate(
new Date(
viewDate.getFullYear(),
viewDate.getMonth(),
viewDate.getDate()
)
)
}
To get first day of the next month:
// recommended
const firstDayOfNextMonth = (date: Date) => {
const viewDate = toViewDate(date)
return toAbsDate(
new Date(
viewDate.getFullYear(),
viewDate.getMonth() + 1,
1
)
)
}
// or
const firstDayOfNextMonth = (date: Date) => {
const viewDate = toViewDate(date)
viewDate.setDate(1)
viewDate.setHours(0)
viewDate.setMinutes(0)
viewDate.setSeconds(0)
viewDate.setMiliseconds(0)
viewDate.setMonth(viewDate.getMonth() - 1) // this MUST be placed after .setDate() call
return toAbsDate(viewDate)
}
To get same time of the next day:
// ignoring Daylight saving time (DST), just +24h
new Date(date.getTime() + ms('24 hours'))
// respect DST
const viewDate = toViewDate(date)
new Date(
viewDate.getFullYear(),
viewDate.getMonth(),
viewDate.getDate() + 1,
viewDate.getHours(),
viewDate.getMinutes(),
viewDate.getSeconds(),
viewDate.getMiliseconds()
)
Caveats
toViewDate()
/toAbsDate()
requires the target timezone. If the timezone offset is not available, you need to query some offset database to obtain the target timezone offset. Such as to convert lat/long to timezone. Consider some 3rd-party api such as Google Timezone API, or, manually build your own service based on IANA timezone database.- Even in the same runtime environment, timezone varies by the time value, especially, where Daylight Saving Time (DST) is used.
.setWallClockLabels()
may change the result of.getTimezoneOffset()
. This explains whytoViewDate()
's implementation uses.getUTCWallClockLabels()
, NOT.getWallClockLabels()
+.getTimezoneOffset()
combination.
Deeper analysis
See examples with California, timezone America/Los_Angeles, whose DST shift happens on 2018/3/11 and 2018/11/4.
- On Mar 11, 2018, DST starts: 12:00 AM → 1:59:59:999 AM → 3:00 AM (2:00 AM → 2:59:59:999 AM does not exist).
- On November 4, 2018, DST ends: 12:00 AM → 1:00 AM → 2:00 AM → 2:59:59:999 AM → 2:00 AM → 2:59:59:999 → 3:00 AM → 4:00 AM (2:00 AM → 2:59:59:999 AM happnes twice).
In the same runtime, timezone offset is different at different positions in the timeline.
Two wall-clock labels point at the same position in the timeline:
When the toViewDate() / toAbsDate() fail to work?
toViewDate()
/ toAbsDate()
when the wall clock labels of the date object with regard to the target timezone fall into the timezone shifting range in the runtime's timezone. For example:
The user, who stays in California, uses his/her browser to reserve a restaurant in Japan from 2:30 AM -> 4:30 AM (that less likely to happen, btw) on March 11, 2018. The client construct a Date object at wall clock label 2:30, in browser timezone, then convert the date object with toAbsDate()
and send to the server. But 2:30 AM wall clock label does not exist and it refers to the next hour. As a consequence, the booking time becomes 3:30AM -> 4:30 AM.
Check the timezone setting of the current machine
Method 1: use the date command and check the timezone part (JST or +07) of the output:
On Linux (archlinux):
# date
Tue Aug 9 01:01:42 AM JST 2022
On macOS:
# date
Mon Aug 8 23:01:42 +07 2022
Method 2: use command ls -al /etc/localtime
On Linux (Ubuntu):
# ls -al /etc/localtime
lrwxrwxrwx 1 root root 30 Oct 31 06:01 /etc/localtime -> /usr/share/zoneinfo/Asia/Tokyo
On Linux (Archlinux)
# ls -al /etc/localtime
lrwxrwxrwx 1 root root 32 Jun 4 10:16 /etc/localtime -> ../usr/share/zoneinfo/Asia/Tokyo
On macOS:
# ls -al /etc/localtime
lrwxr-xr-x 1 root wheel 42 Aug 3 16:12 /etc/localtime -> /var/db/timezone/zoneinfo/Asia/Ho_Chi_Minh
To list all timezone names
On Linux (Archlinux)
# ls -al /usr/share/zoneinfo
total 452
drwxr-xr-x 20 root root 4096 May 25 15:44 .
drwxr-xr-x 275 root root 12288 Jul 31 20:59 ..
drwxr-xr-x 2 root root 4096 May 25 15:44 Africa
drwxr-xr-x 6 root root 4096 May 25 15:44 America
drwxr-xr-x 2 root root 4096 May 25 15:44 Antarctica
drwxr-xr-x 2 root root 4096 May 25 15:44 Arctic
drwxr-xr-x 2 root root 4096 May 25 15:44 Asia
drwxr-xr-x 2 root root 4096 May 25 15:44 Atlantic
drwxr-xr-x 2 root root 4096 May 25 15:44 Australia
drwxr-xr-x 2 root root 4096 May 25 15:44 Brazil
drwxr-xr-x 2 root root 4096 May 25 15:44 Canada
-rw-r--r-- 1 root root 2094 Mar 18 01:35 CET
drwxr-xr-x 2 root root 4096 May 25 15:44 Chile
-rw-r--r-- 1 root root 2310 Mar 18 01:35 CST6CDT
-rw-r--r-- 2 root root 2416 Mar 18 01:35 Cuba
-rw-r--r-- 1 root root 1908 Mar 18 01:35 EET
-rw-r--r-- 2 root root 1955 Mar 18 01:35 Egypt
-rw-r--r-- 2 root root 3492 Mar 18 01:35 Eire
-rw-r--r-- 1 root root 114 Mar 18 01:35 EST
-rw-r--r-- 1 root root 2310 Mar 18 01:35 EST5EDT
drwxr-xr-x 2 root root 4096 May 25 15:44 Etc
drwxr-xr-x 2 root root 4096 May 25 15:44 Europe
-rw-r--r-- 1 root root 116 Mar 18 01:35 Factory
-rw-r--r-- 7 root root 3648 Mar 18 01:35 GB
-rw-r--r-- 7 root root 3648 Mar 18 01:35 GB-Eire
-rw-r--r-- 10 root root 114 Mar 18 01:35 GMT
-rw-r--r-- 10 root root 114 Mar 18 01:35 GMT+0
-rw-r--r-- 10 root root 114 Mar 18 01:35 GMT-0
-rw-r--r-- 10 root root 114 Mar 18 01:35 GMT0
-rw-r--r-- 10 root root 114 Mar 18 01:35 Greenwich
-rw-r--r-- 2 root root 1203 Mar 18 01:35 Hongkong
-rw-r--r-- 1 root root 115 Mar 18 01:35 HST
-rw-r--r-- 2 root root 1162 Mar 18 01:35 Iceland
drwxr-xr-x 2 root root 4096 May 25 15:44 Indian
-rw-r--r-- 2 root root 2582 Mar 18 01:35 Iran
-rw-r--r-- 1 root root 4463 Mar 18 01:35 iso3166.tab
-rw-r--r-- 3 root root 2388 Mar 18 01:35 Israel
-rw-r--r-- 2 root root 482 Mar 18 01:35 Jamaica
-rw-r--r-- 2 root root 309 Mar 18 01:35 Japan
-rw-r--r-- 2 root root 316 Mar 18 01:35 Kwajalein
-rw-r--r-- 1 root root 3392 Mar 18 01:35 leapseconds
-rw-r--r-- 1 root root 10666 Mar 18 01:35 leap-seconds.list
-rw-r--r-- 2 root root 625 Mar 18 01:35 Libya
-rw-r--r-- 1 root root 2094 Mar 18 01:35 MET
drwxr-xr-x 2 root root 4096 May 25 15:44 Mexico
-rw-r--r-- 1 root root 114 Mar 18 01:35 MST
-rw-r--r-- 1 root root 2310 Mar 18 01:35 MST7MDT
-rw-r--r-- 4 root root 2444 Mar 18 01:35 Navajo
-rw-r--r-- 4 root root 2437 Mar 18 01:35 NZ
-rw-r--r-- 2 root root 2068 Mar 18 01:35 NZ-CHAT
drwxr-xr-x 2 root root 4096 May 25 15:44 Pacific
-rw-r--r-- 2 root root 2654 Mar 18 01:35 Poland
-rw-r--r-- 2 root root 3497 Mar 18 01:35 Portugal
drwxr-xr-x 18 root root 4096 May 25 15:44 posix
-rw-r--r-- 3 root root 3536 Mar 18 01:35 posixrules
-rw-r--r-- 5 root root 561 Mar 18 01:35 PRC
-rw-r--r-- 1 root root 2310 Mar 18 01:35 PST8PDT
drwxr-xr-x 18 root root 4096 May 25 15:44 right
-rw-r--r-- 2 root root 761 Mar 18 01:35 ROC
-rw-r--r-- 2 root root 617 Mar 18 01:35 ROK
-rw-r--r-- 1 root root 773 Mar 18 01:35 SECURITY
-rw-r--r-- 2 root root 383 Mar 18 01:35 Singapore
-rw-r--r-- 3 root root 1947 Mar 18 01:35 Turkey
-rw-r--r-- 1 root root 112785 Mar 18 01:35 tzdata.zi
-rw-r--r-- 8 root root 114 Mar 18 01:35 UCT
-rw-r--r-- 8 root root 114 Mar 18 01:35 Universal
drwxr-xr-x 2 root root 4096 May 25 15:44 US
-rw-r--r-- 8 root root 114 Mar 18 01:35 UTC
-rw-r--r-- 1 root root 1905 Mar 18 01:35 WET
-rw-r--r-- 2 root root 1535 Mar 18 01:35 W-SU
-rw-r--r-- 1 root root 17593 Mar 18 01:35 zone1970.tab
-rw-r--r-- 1 root root 19419 Mar 18 01:35 zone.tab
-rw-r--r-- 8 root root 114 Mar 18 01:35 Zulu
On macOS:
# ls -al /usr/share/zoneinfo
lrwxr-xr-x 1 root wheel 25 Jul 14 15:48 /usr/share/zoneinfo -> /var/db/timezone/zoneinfo
# ls -al /var/db/timezone/zoneinfo
lrwxr-xr-x 1 root wheel 38 Jul 21 15:21 /var/db/timezone/zoneinfo -> /var/db/timezone/tz/2022a.1.0/zoneinfo
# ls -al /var/db/timezone/tz/2022a.1.0/zoneinfo
total 472
-rw-r--r-- 1 root wheel 6 Mar 18 08:37 +VERSION
drwxr-xr-x 68 root wheel 2176 Mar 23 10:39 .
drwxr-xr-x 5 root wheel 160 Mar 23 10:39 ..
drwxr-xr-x 56 root wheel 1792 Mar 23 10:39 Africa
drwxr-xr-x 147 root wheel 4704 Mar 23 10:39 America
drwxr-xr-x 14 root wheel 448 Mar 23 10:39 Antarctica
drwxr-xr-x 3 root wheel 96 Mar 23 10:39 Arctic
drwxr-xr-x 101 root wheel 3232 Mar 23 10:39 Asia
drwxr-xr-x 14 root wheel 448 Mar 23 10:39 Atlantic
drwxr-xr-x 25 root wheel 800 Mar 23 10:39 Australia
drwxr-xr-x 6 root wheel 192 Mar 23 10:39 Brazil
-rw-r--r-- 1 root wheel 2102 Mar 18 08:37 CET
-rw-r--r-- 1 root wheel 2294 Mar 18 08:37 CST6CDT
drwxr-xr-x 10 root wheel 320 Mar 23 10:39 Canada
drwxr-xr-x 4 root wheel 128 Mar 23 10:39 Chile
-rw-r--r-- 1 root wheel 2411 Mar 18 08:37 Cuba
-rw-r--r-- 1 root wheel 1876 Mar 18 08:37 EET
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 EST
-rw-r--r-- 1 root wheel 2294 Mar 18 08:37 EST5EDT
-rw-r--r-- 1 root wheel 1946 Mar 18 08:37 Egypt
-rw-r--r-- 1 root wheel 3517 Mar 18 08:37 Eire
drwxr-xr-x 37 root wheel 1184 Mar 23 10:39 Etc
drwxr-xr-x 65 root wheel 2080 Mar 23 10:39 Europe
-rw-r--r-- 1 root wheel 120 Mar 18 08:37 Factory
-rw-r--r-- 1 root wheel 3661 Mar 18 08:37 GB
-rw-r--r-- 1 root wheel 3661 Mar 18 08:37 GB-Eire
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 GMT
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 GMT+0
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 GMT-0
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 GMT0
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 Greenwich
-rw-r--r-- 1 root wheel 119 Mar 18 08:37 HST
-rw-r--r-- 1 root wheel 1217 Mar 18 08:37 Hongkong
-rw-r--r-- 1 root wheel 1174 Mar 18 08:37 Iceland
drwxr-xr-x 13 root wheel 416 Mar 23 10:39 Indian
-rw-r--r-- 1 root wheel 9776 Mar 18 08:37 Iran
-rw-r--r-- 1 root wheel 9113 Mar 18 08:37 Israel
-rw-r--r-- 1 root wheel 481 Mar 18 08:37 Jamaica
-rw-r--r-- 1 root wheel 292 Mar 18 08:37 Japan
-rw-r--r-- 1 root wheel 309 Mar 18 08:37 Kwajalein
-rw-r--r-- 1 root wheel 641 Mar 18 08:37 Libya
-rw-r--r-- 1 root wheel 2102 Mar 18 08:37 MET
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 MST
-rw-r--r-- 1 root wheel 2294 Mar 18 08:37 MST7MDT
drwxr-xr-x 5 root wheel 160 Mar 23 10:39 Mexico
-rw-r--r-- 1 root wheel 2434 Mar 18 08:37 NZ
-rw-r--r-- 1 root wheel 2047 Mar 18 08:37 NZ-CHAT
-rw-r--r-- 1 root wheel 2427 Mar 18 08:37 Navajo
-rw-r--r-- 1 root wheel 556 Mar 18 08:37 PRC
-rw-r--r-- 1 root wheel 2294 Mar 18 08:37 PST8PDT
drwxr-xr-x 46 root wheel 1472 Mar 23 10:39 Pacific
-rw-r--r-- 1 root wheel 2679 Mar 18 08:37 Poland
-rw-r--r-- 1 root wheel 3483 Mar 18 08:37 Portugal
-rw-r--r-- 1 root wheel 764 Mar 18 08:37 ROC
-rw-r--r-- 1 root wheel 645 Mar 18 08:37 ROK
-rw-r--r-- 1 root wheel 384 Mar 18 08:37 Singapore
-rw-r--r-- 1 root wheel 1930 Mar 18 08:37 Turkey
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 UCT
drwxr-xr-x 14 root wheel 448 Mar 23 10:39 US
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 UTC
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 Universal
-rw-r--r-- 1 root wheel 1518 Mar 18 08:37 W-SU
-rw-r--r-- 1 root wheel 1873 Mar 18 08:37 WET
-rw-r--r-- 1 root wheel 118 Mar 18 08:37 Zulu
-rw-r--r-- 1 root wheel 4463 Mar 18 08:37 iso3166.tab
-rw-r--r-- 1 root wheel 3392 Mar 18 08:37 leapseconds
-rw-r--r-- 1 root wheel 3519 Mar 18 08:37 posixrules
-rw-r--r-- 1 root wheel 19419 Mar 18 08:37 zone.tab
Check a timezone file type: (it is a binary file)
# file /usr/share/zoneinfo/Asia/Tokyo
/usr/share/zoneinfo/Asia/Tokyo: timezone data (fat), version 2, 4 gmt time flags, 4 std time flags, no leap seconds, 9 transition times, 4 local time types, 12 abbreviation chars
To set timezone permanently
Create a symlink from timezone file to /etc/localtime
.
rm -rf /etc/localtime
ln -sf /usr/share/zoneinfo/America/Los_Angeles /etc/localtime
With nodejs/javascript
To start node server with specific timezone setting
TZ="UTC" node app.js
To start chrome in a specific timezone setting
TZ='Asia/Bangkok' google-chrome "--user-data-dir=$HOME/chrome-profile"
To check the running environment timezone offset in Javascript
console.log(new Date().getTimezoneOffset())
# output: -540
# -540 minutes => -9 hours => UTC+9 (Asia/Tokyo)
Usage example for helper functions described in the TLDR section:
- To display a time object received from the server. Use
toViewDate(date).toLocaleString()
. For example, server passes27/11/2018 3:00PM
in UTC+9, the client receives this date object and wants to display it exactly27/11/2018 3:00PM
in the client view. If the client just usesdate.toLocaleString()
, the printed value will be27/11/2018 1:00PM
- To convert date objected created in the client (for e.g.
27/11/2018 3:00PM
in UTC+7), and want to send to the server the data object with the same timing but in server timezone setting (i.e.27/11/2018 3:00PM
in UTC+9). UsetoAbsDate(date)
Note that on the same machine, Date.getTimezoneOffset()
does NOT always return the same value when the date change.
The results are even different from browser to browser, OS to OS.
In IE, in Windows 10.
In Edge/Chrome, Windows 10.
In Chrome, windows server.
In IE, windows server.
In chrome/Arch linux:
In nodejs 15.5.1
console.log(new Date(195, 8, 2).getTimezoneOffset()) // -426
console.log(new Date(1945, 8, 1).getTimezoneOffset()) // -540
console.log(new Date(1945, 8, 2).getTimezoneOffset()) // -420
console.log(new Date(1947, 2, 31).getTimezoneOffset()) // -420
console.log(new Date(1947, 3, 1).getTimezoneOffset()) // -480
console.log(new Date(1975, 5, 12).getTimezoneOffset()) // -480
console.log(new Date(1975, 5, 13).getTimezoneOffset()) // -420
new Date(-767955600000) // Sat Sep 01 1945 00:00:00 GMT+0900 (Indochina Time)
new Date(-767869200000) // Sat Sep 01 1945 22:00:00 GMT+0700 (Indochina Time)
new Date(1945, 8, 2)
// Sun Sep 02 1945 00:00:00 GMT+0700 (Indochina Time)
new Date(1945, 8, 1)
// Sat Sep 01 1945 00:00:00 GMT+0900 (Indochina Time)
The above toServerDate
, toViewDate
are guaranteed to work in any environment.
Note 2: .setDate()
-like function does change the date object's timezone offset.
Note 3: new Date(year, month, day)
with negative values does not affect the timezone offset as if constructed with the equivalent non-negative values.
Note 4: if you want to get the label of the date related to an existing date.
In client mode (modify wall clock labels): never use new Date(d.getTime() + relativeTime)
. Instead, you should create a new date and use setDate
-like functions. This way takes one more statement, but it is correct in a dynamic environment.
In server mode (absolute time, modified by the time offset): the reverse is applied. Never use setDate
-like functions.
const date = new Date(1945, 8, 1)
// Not recommended
const nextDay = new Date(date.getTime() + ms('1 day'))
// Recommended
const nextDay = new Date(date)
nextDay.setDate(nextDay.getDate() + 1)
// Wrong
const nextAbsDate = toAbsDate(date)
nextAbsDate.setDate(nextAbsDate.getDate() + 1)
// Recommended
const nextAbsDate = new Date(toAbsDate(date).getTime() + ms('1 day'))