Best practices when working with universal time

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 why toViewDate()'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.

# TZ=America/Los_Angeles node -e 'console.log(new Date(2018, 2, 11, 1, 59).getTimezoneOffset())'
480
# TZ=America/Los_Angeles node -e 'console.log(new Date(2018, 2, 11, 3, 0).getTimezoneOffset())'
420
Clock is forwarding when DST starts
# TZ=America/Los_Angeles node -e 'console.log(new Date(2018, 10, 4, 0, 59).getTimezoneOffset())'
420
# TZ=America/Los_Angeles node -e 'console.log(new Date(2018, 10, 4, 1, 0).getTimezoneOffset())'
420
# TZ=America/Los_Angeles node -e 'console.log(new Date(2018, 10, 4, 2, 0).getTimezoneOffset())'
480
Clock is backwarding when DST ends

Two wall-clock labels point at the same position in the timeline:

# TZ=America/Los_Angeles node -e 'console.log(new Date(2018, 2, 11, 2).getTime() === new Date(2018, 2, 11, 3).getTime())'
true
Non-existing wall-clock label is forwarded to the next hour
# TZ=America/Los_Angeles node -e 'console.log(new Date(2018, 10, 4, 1).getTime() - new Date(2018, 10, 4, 0, 59, 59, 999).getTime())'
1
# TZ=America/Los_Angeles node -e 'console.log(new Date(2018, 10, 4, 2).getTime() - new Date(2018, 10, 4, 1, 59, 59, 999).getTime())'
3600001
# TZ=America/Los_Angeles node -e 'console.log(new Date(2018, 10, 4, 3).getTime() - new Date(2018, 10, 4, 2, 59, 59, 999).getTime())' 
1
When Ambiguity, the later point in the timeline is used 

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 passes 27/11/2018 3:00PM in UTC+9, the client receives this date object and wants to display it exactly 27/11/2018 3:00PM in the client view. If the client just uses date.toLocaleString(), the printed value will be 27/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). Use toAbsDate(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'))
Buy Me A Coffee