Time zones in software
This is another one of those posts whereby I want to document my thoughts/approach to a subject (in relation to a company product) and figured that I may as well do so in the public domain.
For a great overview of time zones check out the Stack Overflow tag info page for time zones.
I use timestamps for a number of pieces of functionality in the Training Plan app (and on the website). For example when a user creates a training plan, the start date is (currently at least) stored in our database as a unix timestamp.
Unix timestamps represent a single point in time. In practice however users consume timestamps as user friendly date strings. These date strings are influenced by where the user is located - their time zone. That is to say, a single point in time can be represented as two (or more) different 'times' depending on where in the world the user is.
How we manipulate time.
The web app is written in PHP. The front end is Javascript. The iOS app is written in Swift, and the Android app is written in Kotlin (and Java).
Each programming language works with dates and their unix timestamp representation in different ways. This section will briefly outline the language features that we are currently using.
PHP
Some forms inputs submit a string representation of a date to a PHP backend. We use strtotime
to convert that string into a timestamp. The outputted timestamp depends on the servers timezone setting which can be outputted with date_default_timezone_get()
.
On my development computer, the following code:
echo 'date_default_timezone_set: ' . date_default_timezone_get() . '<br />';
echo strtotime("31st May 1990") . "<br/>";
date_default_timezone_set('Europe/London');
echo 'date_default_timezone_set: ' . date_default_timezone_get() . '<br />';
echo strtotime("31st May 1990") . "<br/>";
Outputs:
date_default_timezone_set: UTC
644112000
date_default_timezone_set: Europe/London
644108400
Javascript
Other parts of the website create timestamps in the frontend Javascript code and then use them / manipulate them / send them to the backend as appropriate.
For example, there is a piece of code that finds the timestamp of the start of the year as follows:
var minUnixTimestamp = Date.parse("01/01/" + year + " 00:00:01") / 1000;
Date
objects reference a single point in time - a UTC timestamp. Always. Prototype methods on the Date
object like toString
format and output. toString
will by default output the date in the local date format giving consideration to the users timezone.
If you parse a date string that contains timezone information it will be converted to its UTC timestamp under the hood.
I had a play with an interactive node console on the command line and the chrome console in my browser on my local machine.
var offset = new Date().getTimezoneOffset();
console.log(offset);
This code returns -60
. As outlined here:
Javascript date getTimezoneOffset() method returns the time-zone offset in minutes for the current locale. The time-zone offset is the minutes in difference, the Greenwich Mean Time (GMT) is relative to your local time.
For example, if your time zone is GMT+10, -600 will be returned. Daylight savings time prevents this value from being a constant.
This makes sense. It is 13:11 BST here in the UK at the moment (UTC +1), BUT this is different to the way in which many other languages provide UTC offsets.
In Javascript the offset basically says UTC is 60 minutes behind the time here.
In Swift for example (see below) the offset basically says that we are 3600 seconds ahead of UTC.
On my server, this code returns 0 - the server time is in UTC. The default timezone on Ubuntu is defined in /etc/timezone
.
This answer provided a lot of clarity on this for me.
iOS
On iOS (Swift) I am working with dates using DateFormatter
, and Calendar
.
As outlined here:
If unspecified, the system time zone is used.
As such to get a UTC timestamp for example I use the following:
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd/MM/yyyy H:mm"
dateFormatter.timeZone = TimeZone(identifier: "UTC")
let dateString = dateFormatter.date(from: dateString)
let dateTimestamp = dateString!.timeIntervalSince1970
Similarly to Javascript:
A Date
value encapsulate a single point in time, independent of any particular calendrical system or time zone. Date values represent a time interval relative to an absolute reference date.
So essentially regardless of how you create a Date object, its underlying representation is a unix timestamp and its output will be formatted as though in your local timezone unless explicitly specified. That is to say that you need to specifically set the timeZone
property on Calendar
and DateFormatter
.
Android
In Android I use GregorianCalendar
which the docs state uses:
the current time in the default time zone with the default Locale.Category#FORMAT
locale.
I also use Date
objects (which can be accessed from the time
property of a Calendar
instance, and SimpleDateFormat
for formatting output.
Product Application
Training Plans
All of the above is relevant because a number of areas of the site are date dependent and a number of areas of the site are date and time dependent.
For example if I create a training plan that starts today (15th September 2020) where I am (Manchester, UK - (Europe/London - BST)) I want day 1 of my plan to be displayed as 15th September, day 2 as 16th September etc. The time is not necessarily important, but it is important that the date is consistently and correctly displayed.
There was previously a bug whereby the user would create a plan and we would store the local timestamp for midnight of the start date. In this case the local timezone was BST. We would then consume the timestamp as though it was a UTC timestamp which is 11pm on the day before. As such dates were being displayed as being one day prior to what the user was expecting.
For this use case the resolution was to consistently utilise UTC. We would discern the timestamp for 15th September 2020 00:00 in UTC and store it in our database. If the current UTC timestamp was the 16th September UTC we would consider it Day 2 etc. This is fundamentally flawed, and is basically why this blog post exists. The difference between UTC and BST was only an hour so there was a very short period each day in which a British user would see anything wrong. In other areas of the world the issues would be more prevalent, and obvious.
Searching Races
Races occur on specific dates around the world. The location of the race is relevant.
For example:
09:00 Tuesday, in Los Angeles, CA, USA is 17:00 Tuesday, in London, UK
That said, if I am based in the UK and planning on racing in Los Angeles I am probably aware that the timezones differ. In terms of user interfaces and user experience I want to simply display the timezone alongside the date. I.E. I want to display that a race takes place on the 20th September Los Angeles time (even if that is 19th September in the users local timezone).
As a convoluted but real example, if I am in Brisbane, Australia (UTC + 10) relative to London (UTC+1) a race taking place on 2nd October at 8am in London is technically taking place on 1st October 11pm (Brisbane time).
Whilst timezones do matter, again the original opted for resolution was to store dates as UTC timestamps and parse them as UTC timestamps. This is prone to the same issues mentioned above.
Now.. lets use the Tokyo Marathon for a more in depth example.
The 2020 edition took places on March 1st 2020. It is implied when an organiser puts out a start date/time that it is the date/time in the local timezone. After all March 1st 2020 in Tokyo could be a different day in a different part of the world.
Point 1. We want to display the race date as it is advertised by its organisers to our user.
Now. If a user is searching for races they are simply going to input a range of dates. They are not considering the fact that in different places at any given time it is a different date.
If we search for races on March 1st, we want to display the Tokyo Marathon even if it is a different date in the users local time zone.
Note: In our database we store a race date as a UNIX timestamp of the point in time equivalent to 00:00 on the day of the race in the local timezone. We don't store or display race times as different races have multiple race distances and multiple potential start times.
If we simply use timestamps, this might not work as expected. We would search for races with a timestamp. The timestamp range for the start of March 1st and the end of March 1st in the Europe/London timezone will not necessarily cover the timestamp of the Tokyo marathon in our database.
Point 2. To universally show races that occur on a particular date in their respective timezones we need to convert our search timestamps and our races timestamp to a consistent format - one that represents the date in UTC.
Example:
1st March 2020 in Europe/London is 1583020800. The UTC offset on that date is 0 seconds.
1st March 2020 in Asia/Tokyo is 1582988400. The UTC offset on that date is 32400 seconds.
Our SQL query looks for races where dateTimestamp + utcOffset >= searchTimestamp + searchUtcOffset
.
In this example utcOffset
represents the offset from UTC of the time zone in which the race takes place whilst searchUtcTimestamp
represents the offset from UTC of the users timezone.
The database essentially stores the fact that the race occurs on 1st March at 00:00 in Tokyo.
We are looking for races that take place after 1st March at 00:00 in London.
Our search essentially normalises things as if no timezones were offset from UTC.
Plans targeting races
The above thought experiment came about whilst trying to discern how to create plans targeting races on specific dates.
Given the assumptions made above (namely that people want to know the date of a race in the context of its local timezone), we want to provide a good user experience to users whilst doing their plan (back home).
1st March 2020 00:00 in Tokyo is the 29th February here in the UK. If we displayed the race date in their plan as 29th February that would be super confusing. That said, we do want to display the date of each day of the plan.
Using timestamps (again) is confusing because if we work in the time zone of the target race it is difficult to show contextual messages such as 'This is today' on a given day of the plan.
My approach to this is simple - your race is on 1st March, therefore the last day of your plan is the 29th February etc. I.E. Training Plan views assume that the race is taking place in your local time zone even though in practice it is not. Only once we have that thought process nailed down do we approach the data storage and manipulation side of things.
We assume that the user does most of their training in their 'home' location - where they created their plan. This is fixed on creation. Even if you move around the world, your plan stays fixed in that initial time zone. If it is the 29th February in your home, then it is the last day of your plan. If it is the 1st March in your home, it is race day (even if this might not strictly be true).
The approach here is one of simplicity. Perfection would make everything way more complicated without much benefit to the majority of users. If you are racing in a foreign country you have hopefully booked your flights, booked your hotel, and read every inch of the organisers website - you know when/where the race is. Your training plan is a guide/tool, and this simplified approach to time zones makes things.. simple.
Strava Activities
The Strava API has some interesting intricacies which make manipulating dates (which as you can see is already complex) even harder.
As outlined here, Strava's API returns incorrectly formatted dates.
They provide a start_date
, a local_start_date
a timezone
, and a utc_offset
input when you query data on an activity.
Both the start_date
and local_start_date
values are returned in ISO8601 format with the UTC timezone identifier Z
appended. This is not correct - the local date should have a local time zone identifier. e.g 2020-07-02T13:12:26+0100
.
Using Javascript for an example:
new Date("2020-09-17T10:33:50+0100").toISOString()
"2020-09-17T09:33:50.000Z"
The timezone
response is also not particularly clear in that it doesn't give consideration for daylight savings. An activity taking place in London during the British summer returns (GMT+00:00) Europe/London
and then provides a utcOffset
of 3600
whereas I believe it should return (GMT+01:00) Europe/London
.
This is probably still incorrect for legacy reasons, but as a matter of interest I went ahead and took a look at how Strava's user interface adapts to activities completed in different timezones. The answer is - it doesn't.
I changed my phone time zone to Los Angeles and completed an activity. Strava simply shows the time the activity was completed as though it were taking place in Los Angeles. Previous activities below simply show the time in the UK that activities took place. This is not particularly clear, but I assume that their view is that regular travellers are an edge case and that they don't want to confused the masses to cater for the few.
As for how we handle Strava activities in the app..
Going forward the intention is to handle them in a similar manner to races. Search will allow searching based on the date the event occurred independent of the timezone. A search for activities completed on 1st March will show activities completed on the 1st March in any time zone.
For clarity we will display the timezone on any activity which took place somewhere different to the users 'home' location.
Dates Elsewhere
We do utilise dates elsewhere in the app. For example, the weight tracking tool.
Again, we manipulate these dates in reference to a fixed 'home' time zone set when the user first installs the app.
END