Friday, 24 January 2014

Time Zone in Rails

There’s pretty good info out there about using time zones in Rails, and Rails itself does a lot of the heavy lifting. The Railscast pretty much covers it. It’s only missing a discussion of using Javascript to figure out the client browser’s time zone.

Time Zone from the Browser

To get the time zone from the browser, use the detect_timezone_rails gem. The instructions give you what you need to know to set up a form with an input field that will return the time zone that the browser figured out. That would work perfectly if you were implementing a traditional web site sign-up/sign-in form.
However, I needed to do something different. Since I’m using third party identity providers (Google, Twitter, Facebook, etc.) via the excellent Omniauth gems, I needed to be able to put the time zone as a parameter on the URL of the identity provider’s authorization request. Omniauth arranges for that parameter to come back from the identity provider, so it’s available to my app’s controller when I set up the session.
To add the parameter, I added this jQuery script to the head of the welcome page:
<script type="text/javascript">
  $(document).ready(function(){
        $('a.time_zone')
          .each(function() {
            this.href = this.href + "?time_zone=" +
              encodeURIComponent($().get_timezone());
      });
  });
</script>
This added the time zone, appropriately escaped, to the URL for the identity provider (the href of the <a> elements). This worked because I had set each of the links to the identity providers to have class="time_zone", like this:
<div class="idp">
  <%= link_to image_tag("sign-in-with-twitter-link.png", alt: "Twitter"), 
    "/auth/twitter", 
    class: "time_zone" %></div>
In the controller, I did this (along with all the other logging in stuff):
if env["omniauth.params"] &&
  env["omniauth.params"]["time_zone"]
  tz = Rack::Utils.unescape(env["omniauth.params"]["time_zone"])
  if user.time_zone.blank? 
    user.time_zone = tz
    user.save!
    flash.notice = "Your time zone has been set to #{user.time_zone}." +
      " If this is wrong," +
      " please click #{view_context.link_to('here', edit_user_path(user))}" +
      " to change your profile."
  elsif user.time_zone != tz
    flash.notice = "It appears you are now in the #{tz} time zone. " +
      "Please click #{view_context.link_to(edit_user_path(user), 'here')}" +
      " if you want to change your time zone."
  end
else
  logger.error("#{user.name} (id: #{user.id}) logged in with no time zone from browser.")
end      
Of course, you may want to do something different in your controller.

Testing Time Zones

However you get your time zones, you need to be testing your app to see how it works with different time zones. YAML, at least for a Rails fixture, interprets something that looks like a date or time as UTC. So by default, that’s what you’re testing with. But that might not be the best thing.
I had read that a good trick for testing is to pick a time zone that isn’t the one your computer is in. Finding such a time zone might be hard if you have contributors around the world. I like the Samoa time zone for testing: Far away from UTC, not too many people living in the time zone, and it has DST.
If you want a particular time zone in your fixtures, you have to use ERB. For example, in my fixtures I might put this:
created_at: <%= Time.find_zone('Samoa').parse('2014-01-30T12:59:43.1') %>
And in the test files, something like this:
test "routines layout" do
  Time.zone = 'Samoa'
  correct_hash = {
    routines(:routine_index_one)=> {
      Time.zone.local(2014, 01, 30)=> [
        completed_routines(:routine_index_one_one)
      ],
      ...

Gotchas

I found a few gotchas that I hadn’t seen mentioned elsewhere:
  • Rails applies the time zone magic when it queries the database, so if you change your time zone after you retrieve the data, then you have to force a requery, or the cached times will still be in the model. Shouldn’t be a problem when running tests, but is when using the console to figure things out
  • You can’t use database functions to turn times into dates, as these won’t use the time zone. No group by to_date(...) or anything like that

No comments: