Wednesday, 21 May 2008

Converting a TimeZone to a TzinfoTimezone

The TzInfo gem and plugins: tzinfo_timezone and tztime provide a bunch of timezone-related nifty things that intend to make your life easier by providing extra helper stuff on top of the standard Rails TimeZone class.

Personal preferences

However, I personally hate the dodgy-looking "Country/City" style of timezone list that the plugin provides. I much prefer "GMT+10:00 Sydney" provided with Ruby's TimeZone code. Especially when dealing with clients that can be expected to understand what a timezone is.

I figured I could take davantage of the niceties of TzTime/TzInfo for converting, displaying and updating activerecord etc... but use the standard Rails time_zone_select helpers on the user-preference form, and just convert the users chosen TimeZone string into a TzinfoTimezone before applying it.

But when I tried it, I suddenly started getting something along the lines of:

TZInfo::InvalidTimezoneIdentifier: no such file to load -- tzinfo/definitions/Tijuana

One big problem

Delving into the code, it seems that neither the TzInfo::Timezone class nor the tzinfo_timezone plugin's TzinfoTimezone class actually provide a method to convert from the standard Ruby TimeZone string and back... even though the plugin seems to do this internally (to generate the new list of timezones for the drop-down list).

This seems a bit of an oversight as the plugin already contains a mapping (conveniently named MAPPING) between the two.

The monkeypatch

The required code change is pretty small if done inside of tzinfo_timezone. The TzinfoTimezone class' new method tries to fetch out the appropriate timezone object, returning nil if not found. A one-line change fixes it so that after trying with the given timezone, it checks if it's in the given mapping before trying again thus:

  # replace:
  def new(name)
    self[name]
  end

  # with:
  def new(name)
    self[name] || self[MAPPING[name]]
  end

The form drop-down

Adding the field to a user is fairly simple - assuming the string column is named "time_zone" - I use the pretty standard:

<%= time_zone_select 'user', 'time_zone', TimeZone.all, {:include_blank => true} -%>

The Controller changes:

According to the tutorial on using TzTime, we put an around_filter in the controller to prep TzInfo with the logged-in user's chosen timezone (or a default if they have none, or there's nobody logged in). Becuase the plguin doesn't directly update the TzInfo::Timezone class, we have to alter that to directly call our updated method.

  # old way to set up default timezone
  around_filter :set_timezone
  def set_timezone
    # pull pref from the user - if they've supplied it
    if logged_in? && !current_user.time_zone.nil?
      TzTime.zone = TZInfo::Timezone.new(current_user.time_zone)
    else
      # otherwise use the environment's default = UTC
      TzTime.zone = TZInfo::Timezone.new(ENV['TZ'])
    end
    yield
    TzTime.reset!
  end

  # new way to set up default timezone
  around_filter :set_timezone
  def set_timezone
    # pull pref from the user - if they've supplied it
    if logged_in? && !current_user.time_zone.nil?
      TzTime.zone = TzinfoTimezone.new(current_user.time_zone)
    else
      # otherwise use the environment's default = UTC
      TzTime.zone = TZInfo::Timezone.new(ENV['TZ'])
    end
    yield
    TzTime.reset!
  end

A validation for good measure

just to add to the pile, a validation to make sure the user's chosen timezone actually exists...:

  validates_each :time_zone do |model, attr, val|
       model.errors.add(attr, "is not a valid timezone") unless val.blank? || TimeZone.all.any? {|tz| tz.name == val }
     end

No comments: