How to Deal with Timezones the Active Support Way
03 Apr 2016“Well the code is bad because we had to…” is a phrase one might hear when discussing timezone offsets or daylight savings time considerations.
Unless a developer is fortunate enough to work at a company whose userbase is entirely located in the UTC timezone, writing software aware of different timezones can be a daunting task. Luckily, Ruby on Rails’ ActiveSupport
library has some very nice built in features that can prove invaluable when facing time related issues.
Built-in Timezones
ActiveSupport
in Ruby on Rails 4.0+ has a built in list of all supported timezones on the TimeZone
class:
ActiveSupport::TimeZone.all.map(&:name)
#=> ["American Samoa", "International Date Line West", "Midway Island", "Hawaii", "Alaska", "Pacific Time (US & Canada)", "Tijuana", "Arizona", "Chihuahua", "Mazatlan", "Mountain Time (US & Canada)", "Central America", "Central Time (US & Canada)", "Guadalajara", ...]
This list of timezones can be used when parsing strings into Time
objects and converting an existing Time
object from one timezone to another. For working with timezones in the United States, a handy us_zones
method is also available on the same class.
One interesting detail about this list of timezones is the lack of daylight savings time qualifiers. Keeping the timezones agnostic of daylight savings helps simplify their use. A developer does not need to worry about using one timezone object over another due to the time of the year.
Time.use_zone
The ActiveSupport
library adds some functionality to built in Ruby classes. One of those additions is the ability to set and retrieve the zone
attribute on the Time
class. After a zone
is set, it can be used when parsing strings into Time
objects:
Time.zone = 'Pacific Time (US & Canada)'
Time.zone.parse('2016-04-01 10:00:00')
#=> Fri, 01 Apr 2016 10:00:00 PDT -07:00
This code takes a timestamp string without a timezone specified and uses the value of Time.zone
to correctly represent the time in the Pacific timezone.
However, an immediate red flag is present in this code. The zone
attribute persists for the rest of the Ruby runtime, potentially causing unexpected behaviour at a later time.
Luckily, ActiveSupport
solves this problem with the use_zone
method. This method accepts the same set of strings as its single argument and expects a block.
Within the passed in block is the only place Time.zone
is affected, eliminating the possibility of a zone
sticking around longer than intended:
Time.zone.name
#=> "UTC"
Time.use_zone('Pacific Time (US & Canada)') do
Time.zone.name
# => "Pacific Time (US & Canada)"
Time.zone.parse('2016-04-01 10:00:00')
#=> Fri, 01 Apr 2016 10:00:00 PDT -07:00
end
Time.zone.name
#=> "UTC"
This temporary zone
setting can be very helpful if a set of users are processed, each with their own respective timezone.
TimeWithZone.in_time_zone
When dealing with Time
, Date
and DateTime
objects, converting each from one timezone to another can be tedious. The in_time_zone
method removes some complexity from those operations.
Used alone, the in_time_zone
can transform an existing object into an instance of TimeWithZone
:
now = Time.now
# => 2016-04-04 03:55:24 +0000
now.in_time_zone('Hawaii')
# => Sun, 03 Apr 2016 17:55:24 HST -10:00
In any sane system, timestamps in a database are always stored in UTC, aka Zulu, time. When exposing these timestamps to users, the in_time_zone
method can quickly switch the timestamp to a user’s local time.
To see a more creative use of in_time_zone
, we can assume that an application must solve a scheduling problem. An example application wants to send its users an email at the exact same time relative to a user’s timezone. Regardless of where a user lives, they should receive an email at 10:30 AM local time.
A naive approach might not yield the intended result:
user.time_zone
# => Hawaii
send_time = Time.new(2016, 04, 01, 10, 30)
# => 2016-04-01 10:30:00 +0000
send_time.in_time_zone(user.time_zone)
# => Fri, 01 Apr 2016 00:30:00 HST -10:00
Whoops, that is not right at all. This code initialized a Time
object for the “correct” send time but then in_time_zone
not only moved the timezone of that object, it also modified the actual time. This would result in all users getting an email at the same exact moment but at an inconvenient time relative to where they live.
Potential solutions to this problem might include modifying the timezone part of an outputted string (the "+0000"
at the end) and re-parsing it, but that solution can be prone to error and hard to understand.
Since the in_time_zone
method works on the Date
class, the simplest approach would be to initialize a new DateTime
object in a user’s timezone and add 10.5 hours to it:
user.time_zone
# => Hawaii
beginning_of_day = Date.today.in_time_zone(user.time_zone)
# => Sun, 03 Apr 2016 00:00:00 HST -10:00
beginning_of_day += (10.5).hours
# => Sun, 03 Apr 2016 10:30:00 HST -10:00
sender = ImportantEmailSender.new(user)
sender.send_at!(beginning_of_day)
Great! An email will be sent at 10:30 AM Hawaii time and we successfully avoided any time string munging or other strange object casting. A user in London can also receive an email at exactly 10:30 AM their local time and this code need not grow in complexity.
Saving Time
Whether in code or everyday life, it seems that struggling with time is something we as humans are simply destined to do. Some good news is that ActiveSupport
may not grant you any more wall clock time, but could save you some when wrangling different pieces of scheduling code together.
As usual, the API of the ActiveSupport::TimeZone
class is much larger than just the two methods illustrated here. For more information, the offical ActiveSupport documentation is a great place to read further.