Scheduling things in user's time zone
Doing something at a time convenient for the user is a recurring (sic!) challenge with web applications. And the more users you have across a multitude of time zones, the more pressing it becomes to do it well.
It is actually not that hard, but it does have a few fiddly bits which can be challenging to put together. So, let’s do some time traveling.
What makes time zones so tricky?
Time zones are tricky because they change over time. There is a global UTC clock, which has leap seconds – that’s already a bit tricky, but not that tricky. Timezones are tricky because they are data over time, and it matters not only what time you need to convert from local to UTC and back, but also from what time. Let me explain.
Imagine you are a person in 2010 and you are planning to go to an event in Moscow, and the organizers have told you the event is going to take place at 18:00 on the 25th of November 2011. You put the event in your calendar, setting its time and date.
Meanwhile, a newly-elected marionette alcoholist president with interest in Tuscanian wine decides to put his imprint on the country. Since he is not capable of doing anything that is measurably positive, he decides to crackdown on smoking and to change time zones. By a new edict, the Russian time zones will, henceforth, permanently observe daylight saving time (DST), also in the winter. Also, the official language of San Marcos will be Swedish, but that is irrelevant to our topic right now.
What is relevant is that while your planned event is still supposed to occur at 18:00 on the 25th of November, it will now occur at a different UTC time. Namely - it will take place one hour earlier, because of the DST alteration for the timezone. What this means is:
- If there was an alarm your device should have given you, it should now happen at a different time
- If there is a calculation of time you spend on something, it needs to be adjusted
- If there are other events which should be properly lined up with your appearance at 18:00 - like travel from a different timezone - you may need to review it. If your flight arrives from Europe at 17:00 local - congratulations, it now likely arrives at 18:00 instead, and you won’t make it to the event on time.
This is why the conversion from a time in time zone to UTC is bound to the point in time when you do the conversion.
Now let’s fast-forward a decade or more. You know that some kind of event took place in Moscow at 18:00 local time, on the 25th of November 2011. At that event, a pitch drop experiment involving a very interesting type of Black Goop™ has been started, and you want to know how much time has passed since then.
You look at the state of time zones right now and the current time zone definition for Moscow tells you that Russia observes daylight-saving time, and at the time of event it should not have been in effect. You do your computation… and you are now off by an hour! Why?
Because, after a couple of years of learning Swedish, the previous ageing dictator has assumed his post yet again and has determined that this timezones nonsense construed by his marionette appointee was just a bunch of nonsense, and swiftly reverted the decision. DST was to be made summer-only again, and we have always been at war with Eastasia. Which means that neither your calculation from 2010 nor your calculation from today would be correct if you only rely on the timezone information from the time you do it.
What you need to do instead is follow the data-over-time approach and follow the intervals, like so:
- Calculate what timezone was - or is going to be - in effect at a particular location at a particular local time
- Use that timezone to convert from local time to UTC
If you need to set an alarm, a database task, or do an operation that involves durations, you need to follow the same procedure.
This is why timezones are hard. But they are possible to grok if we approach the challenge from this temporal perspective.
It also means that you must keep your tzinfo data up to date, and the unpleasant bit is that you have to do it across your entire stack: from the OS to the database server to your Ruby gem versions. Because it carries a dataset which changes over time!
At the start of every workday
Let’s do an exercise. Suppose we want to send our user some kind of update at the start of their workday.
Very important to understand: with recurring events in a timezone (or timezones) you can have either regular intervals (~24 hours between occurrences) or events occurring at the same local time but not both. Think from the product perspective here - which one do you want for this user experience, and pick accordingly.
If we are good with the “at this time” approach, it involves the following steps:
- What is their preferred timezone?
- What is considered “workday” for them?
The first item is important and a good thing to tackle. Modern browsers do provide you with the timezone information like so:
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
console.log(tz);
This can be shuttled into a hidden form input, or into a cookie, or into a fetch()
request parameter easily enough. Note that the name you will get will not be the name of the time zone provided by the Rails time_zone_select
- but the IANA time zone identifier. This is important. Compare:
- In Rails parlance, New York is
Eastern Time (US & Canada)
- In IANA parlance, New York is
America/New_York
Out of these two, always (always!) pick the latter, because this is what the TZInfo database contains and what most software uses to resolve time zones. Save it into a column on your User
:
class User < ApplicationRecord
def time_zone=(zone_or_name)
case zone_or_name
when String
as_tz = ActiveSupport::TimeZone.new(zone_or_name)
write_attribute("time_zone", as_tz.tzinfo.name)
when ActiveSupport::TimeZone
write_attribute("time_zone", zone_or_name.tzinfo.name)
when TZInfo::DataTimezone
write_attribute("time_zone", zone_or_name.name)
else
write_attribute("time_zone", "UTC") # or raise
end
end
def time_zone
ActiveSupport::TimeZone.new(read_attribute("time_zone") || "UTC")
end
end
Notice how we always store the IANA identifier - this can be done also from the browser name.
Next, we need to figure out the workdays. For most countries, we can assume Monday to Friday (inclusive) for the moment, but it is a good idea to place this somewhere:
workdays = %w( Monday Tuesday Wednesday Thursday Friday)
This can be modified later, but will do for the moment. We will also codify what we consider “start of working day” - let’s assume it is 9:00 in the morning:
times = %w( 9:00 )
Next comes the actual scheduling. Which is easy, but we have to use a tool which will:
- Find the moment from which we are computing the time of the next occurrence
- Find the timezone configuration at time of the event
- Do a conversion into UTC using that information and give us a UTC timestamp
The perfect tool for this in the Ruby world is fugit and if you are using any kind of ActiveJob adapter that allows scheduling or cron tables - it is likely already present in your Gemfile.lock
, you just need to use it properly.
To get a timestamp, we need to compose a definition of our recurrence pattern that Fugit can parse. With our powers we can do it like this for any of our users:
# Build a pattern which looks like
# "9:00 every Tuesday,Wednesday in America/New_York"
pattern = "#{times.join(',')} every #{workdays.join(',')} in #{user.time_zone.tzinfo.name}"
cron = Fugit.do_parse_cronish(pattern)
reference_time = Time.current
occurs_at_utc = cron.next_time(reference_time).utc
Pretty easy, and there is a multitude of edge cases Fugit will take care of. For example: there are hours and even days which disappear on timezone transitions or DST transitions. The event will be skipped if that happens, which is likely what you want.
To summarize
You can absolutely do recurring events for users in their time zone. Just don’t compute the intervals yourself, and remember that timezones change over time.
Time to go now.