Easier time zone handling in Rails

Update: with the release of Rails 2.1, much of this article is now obsolete.

The application I’m working on at the moment deals with a lot of dates and times, and its userbase could span many different time zones. Dealing with time zones and converting from one zone to another is tedious work, but there are some things you can do to make your life simpler.

First, always think in UTC. If not for daylight saving time, you could probably ignore this rule, but thanks to DST, you can’t. DST takes a relatively simple add or subtract and turns it into a tangle of what-ifs. Some zones don’t observe any daylight saving time rules. The zones that do may change when DST starts and stops from year to year.

Thinking in UTC means storing your times in UTC in the database, without exception. Rails gives you only a little help in this area, with ActiveRecord::Base.default_timezone. It only helps you with created_at/on and updated_at/on. It won’t touch your other timestamp fields.

Bugs often come about by missing little details, and forgetting to get a Time instance in UTC instead of localtime is just the sort of thing I know I’d do eventually. Ruby makes fixing this once easy. Reopen the Time class, and change the behavior of Time.now. Put this in your environment.rb:

class Time
  def self.now_utc
    return now_local.utc
  end
 
  class << self
    alias_method :now_local, :now
    alias_method :now, :now_utc
  end
end

I think this gets you 90% of the way home. The remaining 10% is handling display issues, and Jamis Buck’s TzTime helps immensely with this part. Set TzTime’s zone at the start of each request to the zone of the user making the request, then make life even easier by ensuring you always use a helper for displaying dates and times. Mine looks like this:

def datetime(object, options = {})
  return "argument is a #{object.class}, not a Date or Time" unless object.is_a?(Date) || object.is_a?(Time)
 
  format = if object.is_a?(Date) || options[:date_only] then "%b %d, %Y"
           elsif options[:time_only] then "%I:%M %p"
           else "%b %d, %Y %I:%M %p"
           end
 
  object = TzTime.zone.utc_to_local(object) if object.is_a?(Time) && object.utc?
  return object.strftime(format)
end

To date, I’ve only found one gotcha, and while it was aggravating to find, it was easy to fix. The TMail that ships in Rails 1.2 (as part of ActionMailer) has a nasty habit of ignoring the @sent_on instance variable when sending mail via SMTP. It will always set this header, contrary to what ActionMailer’s RDoc tells you. Unfortunately, it sets the header using Time.now, which returns UTC with the above modification, but marks the time in the local zone. End result: if your localtime is behind UTC, the mail looks like it’s sent in the future. To fix, put this in environment.rb:

class TMail::Mail
  def add_date
  end
end

With this change, TMail will never add a Date: header, allowing the MTA to add it itself.