Showing posts with label timezones. Show all posts
Showing posts with label timezones. Show all posts

Wednesday, 15 July 2009

Gotcha: UTC vs Local TimeZones with ActiveResource

So... your database is filled with datetime data and it's all configured to localtime, not UTC... We also have this you-beat nifty ability to set all our datetime-handling functionality to a given timezone by setting, say: config.time_zone = 'London' in config/environments.rb... or do we?

If you also use ActiveResource (or the new, actually-working HyperactiveResource), you'll find that suddenly you're getting a UTC-local timezone issue once more.

The problem is that the xml that comes back from a remote API is converted into a Date/DateTime using the core-extension to the Hash.from_xml method... which has the following LOC:

"datetime"     => Proc.new  { |time|    ::Time.parse(time).utc rescue ::DateTime.parse(time).utc }

The fix

You need to do two things. Firstly. Hack that line[1] and replace it with:

"datetime"     => Proc.new  { |time|    ::Time.zone.parse(time) rescue ::DateTime.zone.parse(time) },

Secondly... somehow it doesn't pick up the timezone even though it's been helpfully added in via the config... so you need to open up config/environments.rb (or create a rails initializer) and put:
Time.zone = 'London'[2]
in there (outside the rails initialization block).

Notes:
[1]To hack rails, you can either
a) hack on your own rails gem = risky... will be overwritten the next time you sudo gem update or
b) rake rails:freeze:edge - which means you have your rails in your own vendor/rails directory... but means you have to rake rails:update manually... up to you which you hate more.

[2]Obviously substituting your own timeZone as appropriate here. See the TimeZone doc for what you can pass in.

Thursday, 10 July 2008

MySQL hates Timezones

We've started using the TzTime plugin which leverages the Tzinfo gem. It's pretty good. I've adjusted it slightly for my own preferences, but it works pretty well out of the box.

Unfortunately, we ran into some odd issues. Rails suddenly started to find records that it shouldn't. Eg if we were looking for all Event objects that occurred between two given dates, (and expecting to find none) suddenly we were finding one. Which played havoc with our tests :P

The occurred_at date on the Event object was clearly outside of the date-range we were looking at. We even tested to make sure the timezone wasn't stuffing it around and moving it into the date-window.

Eventually we discovered that the to_s(:db) command was printing the date out in a weird format using a T: '2008-07-10T00:50:23+00:00'. This is quite different from the standard MySQL format, which would have it as just: '2008-07-10 00:50:23'

MySQL needs to be set up properly to accept the "T-format" for DateTime objects and ours hasn't been. However, in our case it's much easier to simply extend Rails than to go through the required infrastructure politics to get global database settings changed (which will affect all the other projects in the business).

So I just updated the way that the to_s(:db) format works to always use the 'Y-M-D H:M:S' format as below.

ActiveSupport::CoreExtensions::Time::Conversions::DATE_FORMATS.merge!(:db => '%Y-%m-%d %H:%M:%S')
ActiveSupport::CoreExtensions::Date::Conversions::DATE_FORMATS.merge!(:db => '%Y-%m-%d %H:%M:%S')

This seems to work just fine. It means that we'll need to update this if we ever change away from MySQL - but MySQL seems to be hard-coded into this organisation and seems pretty unlikely.

Note: when I first put this code into config/environment.rb it worked fine until I ran the tests - at which point I immediately got an error: uninitialized constant ActiveSupport. This went away the moment I moved the code below the Rails.initializer code (ie underneath the line that says: Include your application configuration below).

Wednesday, 21 May 2008

Rails gotchas: more TzTime

While I'm neck-deep in timezone land, I'll also address one little problem wih the tztime plugin. While running the functional tests I began getting errors like the following:

NoMethodError: undefined method `tzinfo' for #<TZInfo::LinkedTimezone: UTC>

I could fix it by forcing a timezone into the TzInfo object at the beginning of each failing test - but that seemed a bit of a hack. Looks like the problem has been submitted as a bug in rails ticket 11150. The suggested codefix seems to work just fine, but requires more hacking on your vendor/plugins/tztime directory. Still, it's a one-line fix, so not much to do.

The failing tests were replaced with a new error:

<Tue Apr 01 00:00:00 +0000 2008> expected but was
<2008-04-01 00:00:00 UTC>.

The failing line is in the functional test thus:

assert_equal Time.now.at_beginning_of_month, assigns(:month)

This is an instance of a problem I've seen before... ie that you don't seem to be able to compare Times with DateTimes, Dates or (now) TzTimes. The quick solution is to forcibly cast all your time-like objects into a common time-like object before testing for equality. My suggestion is to_datetime, which works for all the instances I've needed, thus the previous test case would become:

assert_equal Time.now.at_beginning_of_month.to_datetime, assigns(:month).to_datetime

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