How to Deal with Timezones the Active Support Way

“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.