How to use the TZInfo Gem to Send Timely Emails

Brad Pauly — May 29, 2015

Imagine you have an app that sends an email to users every morning at 8am. Maybe its a to-do app and you want to send a list of things to get done every morning. The problem is that your users live all over the world. You have the time zone for each user, but how do you know when it is 8:00 in each one?

I was working on this problem for GroupBuzz and found that it was a little trickier than I expected. This is what I did and how you can do it too.

First, there are a few things you should know about time zones. They change. New zones are added and existing zones adjusted. Some of them have offsets that change during daylight saving and when that offset begins and ends can change from year to year. On top of that, some offsets are not whole hours. For example, Australia/Eucla is UTC+08:45 and Asia/Kolkata is UTC+05:30.

Rails uses the TZinfo gem to get information about time zones as well as translating times between zones. Our problem is that we want to go the other direction. At any given time on our server, how do we know if it is 8:00am local time for any of our users?

The solution: check all zones every 15 minutes

That might sound inefficient, but it isnt that bad and you only need to check the zones that you have for your users.
You gain simplicity. It isn't the job of your app to keep track of time zone data so dont do it. Let TZInfo do that for you.

To illustrate the approach take a look at the script below. It starts at midnight and checks all zones for each 15 minute increment for a whole day. Along the way it prints the time in UTC and the zones where it is locally 8:00.

require 'TZInfo'

today = Time.now
(0..23).each do |hour|
  (0..45).each do |minute|
    utc_time_to_check = Time.utc(today.year, today.month, today.day, hour, minute, 0)
    TZInfo::Timezone.all_identifiers.each do |zone_name|
      local_tz = TZInfo::Timezone.get(zone_name)
      local_time = local_tz.utc_to_local(utc_time_to_check).to_datetime
      if local_time.minute == 0 && local_time.hour == 8
        puts utc_time_to_check.strftime("%H:%M") + ' UTC => ' + local_time.strftime("%H:%M") + ' in ' + zone_name
      end
    end
  end
end

The output will look something like this:

00:00 UTC => 08:00 in Antarctica/Casey
00:00 UTC => 08:00 in Asia/Brunei
00:00 UTC => 08:00 in Asia/Chita
...
15:00 UTC => 08:00 in US/Pacific
...
22:30 UTC => 08:00 in Australia/South
...
23:15 UTC => 08:00 in Australia/Eucla

I included a couple interesting zones in Australia to show that not all offsets are in full hour increments. I live in California, which, in May 2015, has an offset of UTC-7. Therefore 8:00 in California should occur at 15:00 UTC. Occording to our script, that is correct. Yay! Unfortunately, after November 8, the offset becomes UTC-8 and 8:00 local time in California occurs at 16:00 UTC. This is the key. For any time zone we don't know when daylight saving begins and ends or is observed at all. Of course we could know. We could look it up, but that's not the business we want to be in. It is the business of TZInfo and the maintainers do an awesome job.

A more complete solution

At any moment in time, we can find time zones that have a specific local time. So far we've been using 8:00 as an example, but it could be any hour of the day.

Let's take it further and see if we can make it more useful by applying it to a possible use case in a rails app. Back to the to-do app. Assuming we have an ActiveRecord based User model with at least two columns: email and time_zone_name . Every 15 minutes we'll check the unique time zones in our system to see if it's 8:00 local time. For all the zones that it is, we'll send our "to-do today" email.

Let's start by putting the logic to find zones with a specified local time in a class called TimeZoneSearch with a descriptive method "zones_with_local_hour". Using it to do the 8:00 search would look like this:

TimeZoneSearch.new(TZInfo::Timezone.all_identifiers).zones_with_local_hour(8)

This is the full implementation:

require 'TZInfo'

class TimeZoneSearch
  def initialize(zones_to_search)
    @zones_to_search = zones_to_search
  end

  def zones_with_local_hour(hour_to_match, utc_time = Time.now.utc.to_datetime)
    zone_matches = []
    utc_minute = utc_time.minute

    # Round to the closest 15 minute mark.
    minute_to_check = utc_minute - (utc_minute % 15)

    # UTC time to the closest 15 minute mark excluding seconds.
    utc_time_to_check = Time.utc(utc_time.year, utc_time.month, utc_time.day, utc_time.hour, minute_to_check, 0)

    @zones_to_search.each do |zone_name|
      local_zone = time_zone(zone_name)
      local_time = local_zone.utc_to_local(utc_time_to_check).to_datetime

      # Check for 0 minute because offset minutes are multiples of 15.
      if local_time.minute == 0 && local_time.hour == hour_to_match
        zone_matches << local_zone.identifier
      end
    end

    zone_matches
  end

  def time_zone(name)
    TZInfo::Timezone.get(name)
  end
end

We're giving it the zones to search because determining which zones to search will come from querying the users table. Below is the User class with a class method to lookup all the zones being used and a scope to find users by time zone name.

class User < ActiveRecord::Base
  scope :in_time_zones, -> { |zones| where(time_zone_name: zones) }

  def self.unique_time_zone_names
    select("DISTINCT time_zone_name").pluck(:time_zone_name).compact
  end
end

We can combine these to send out an email at 8:00. I like to define a class for this so that it can easily be called from the console, a rails runner, or a rake task.

class TodoSummaryJob
  def self.send_daily_summary
    zones_to_search = User.unique_time_zone_names
    zones_ready_for_email = TimeZoneSearch.new(zones_to_search).zones_with_local_hour(8)
    User.in_time_zones(zones_ready_for_email).each do |user|
    	UserMailer.daily_summary(user, user.todos.unfinished).deliver
    end
  end
end

This is easily wrapped in a rake task or called with a rails runner direclty from the command line.

bundle exec rails -r "TodoSummaryJob.send_daily_summary"

By running this every 15 minutes we can be confident that our users will be getting their to-do lists at the right time year round. I'm leaving it up to you to create UserMailer and Todo with an association to User. =)

How are you using time zones in your app?

Want new posts sent to your email?

I'm always looking for new topics to write about. Stuck on a problem or working on something interesting? You can reach me on Twitter @bradpauly or send me an email.