#106 Time Zones (revised)
- Download:
- source codeProject Files in Zip (61.1 KB)
- mp4Full Size H.264 Video (14.3 MB)
- m4vSmaller H.264 Video (8.69 MB)
- webmFull Size VP8 Video (9.79 MB)
- ogvFull Size Theora Video (17.8 MB)
Below is a screenshot from an application called You Haiku which allows users to enter Haikus and share them with others. When we add a Haiku it will be displayed at the top of the list along with the time that at which it was added.
We’ve just added a new Haiku and it looks correct except for the time at which it was added: it’s currently 8:11pm in our timezone, not 7:11pm. By default Rails sets all times to UTC which is why the time shown doesn’t match our local time. If we look at the app’s configuration file we’ll see a commented-out option for setting the time zone and without this set the time zone will default to UTC. We can see a list of the time zones that we can set by running rake time:zones:all
. This returns a long list of time zones divided up in to their UTC offsets. We can also run rake time:zones:local
to see the time zones that match our machine’s UTC offset.
$ rake time:zones:local * UTC +01:00 * Amsterdam Belgrade Berlin Bern Bratislava Brussels Budapest Copenhagen Ljubljana Madrid Paris Prague Rome Sarajevo Skopje Stockholm Vienna Warsaw West Central Africa Zagreb
It’s a good idea to use a time zone that matches the one where most of our app’s users are located so we’ll use North American Central Time here, even though that’s not where we’re located. In development it’s a good idea to use a time zone different from the one on our local machine so that we can more easily detect time zone issues.
config.time_zone = 'Central Time (US & Canada)'
Allowing Users to Set Their Time Zone
When we restart our server now then reload the page the times are shown in this time zone but this might not match each user’s time zone so we’ll give them a way to set their preferred time zone. We already have some user authentication in place in our app and we have an “edit profile” page where we can change our name and password when we’re signed in. We’ll add the ability to associate a time zone with each account as well. To do this we’ll start by creating a migration to add a time zone field to the users
table then migrate the database.
$ rails g migration add_time_zone_to_users time_zone $ rake db:migrate
We’ll need to add this new field to the list in attr_accessible
in the User
model so that it can be set through mass-assignment.
class User < ActiveRecord::Base has_secure_password attr_accessible :name, :password, :password_confirmation, :time_zone validates_uniqueness_of :name validates_inclusion_of :time_zone, in: ActiveSupport::TimeZone.zones_map(&:name) has_many :haikus end
We’ve also added a validator to ensure that any time zone added is included in Rails’ list of valid time zones. Next we’ll add a new field to the form for a user’s profile. Rails provides a helper method called time_zone_select
that will provide a select menu of all the valid time zones so we’ll use it here.
<div class="field"> <%= f.label :time_zone %><br /> <%= f.time_zone_select :time_zone %> </div>
If most of our customers are in a specific region we might want to prioritize this time zone at the top of the menu and we can do this by adding a second argument to time_zone_select
. For example if most of the users will be in the USA we can move the US zones to the top of the list like this.
<div class="field"> <%= f.label :time_zone %><br /> <%= f.time_zone_select :time_zone, ActiveSupport::TimeZone.us_zones %> </div>
When we reload the page now the US time zones will appear at the top of the list.
Even when we change our profile’s time zone the Haiku’s times are still displayed for the Central Time Zone. We need to apply the user’s preferred time zone to these times and a good way to do this is with a filter in the ApplicationController
so that it’s applied to every action. We’ll use an around_filter
here.
class ApplicationController < ActionController::Base protect_from_forgery around_filter :user_time_zone, if: :current_user private def current_user @current_user ||= User.find(session[:user_id]) if session[:user_id] end helper_method :current_user def user_time_zone(&block) Time.use_zone(current_user.time_zone, &block) end end
In the user_time_zone
method here we use the use_zone
method on the Time class which is a method that ActiveSupport adds and we pass this the current user’s time zone. This method expects a block to be passed to it and sets the time zone for the duration of that block. We can pass in the block that’s used by the around filter so that the original time zone is set back when the request completes. If we used a before_filter
here instead the time zone would leak through to other requests so it’s necessary to use an around filter instead. When we reload the page now the Haiku’s times are correctly set to the right time zone.
Common Problems
Rails makes working with time zones pretty easy but it’s a good idea to understand how it works so that we can avoid unexpected problems. We’ll show some of these in the Rails console. First we’ll fetch the last Haiku and look at its published_at
attribute.
>> h = Haiku.last >> h.published_at => Thu, 15 Nov 2012 13:11:15 CST -06:00
This time looks correct but it isn’t the time that’s stored in the database. We can check that stored time by called published_at_before_type_cast
.
>> h.published_at_before_type_cast
=> "2012-11-15 19:11:15.517923"
This value is actually the UTC time which means that behind the scenes Rails is converting to UTC when ever it writes to or reads from the database. This is good as it means that our data is stored in a consistent time zone.
Let’s say that we want to fetch the current time. A common way to do this is to call Time.now
which returns the time of the current system based on its time zone. If we want to get the time in the zone of the Rails application we should call Time.zone
.now to return the time in the time zone set in the configuration file.
>> Time.now => 2012-11-15 23:18:22 +0100 >> Time.zone.now => Thu, 15 Nov 2012 16:18:23 CST -06:00
The output from these two commands looks different and this is because the methods return instances of different classes. One is a Time
class and the other is ActiveSupport::TimeWithZone
. This works in a similar way to Time
but has added support for different time zones. One way to convert from one to the other is to call in_time_zone
on a Time
object which will return a TimeWithZone
object.
Another thing we need to watch out for is dates. Calling Date.today
will use the local system’s time zone which is probably not what we want. To use the Rails-configured time zone we can call Date.current
.
>> Date.today => Thu, 15 Nov 2012 >> Date.current => Thu, 15 Nov 2012
These return the same date here but they could differ if we were in a different time zone. It’s easy to mix these up so we should always go through Time.zone.now
then convert it to date if that’s what we need. In short, if we want to avoid time zone headaches in a Rails application we should always go through Time.zone
. Most of the Time
class’s methods are supported here so that we can use, say, parse
to parse a string into a date. This returns a date time in the Rails application’s time zone and even handle daylight-savings time correctly too. In addition to Time.zone
we can also use the convenience methods that ActiveSupport gives us such as weeks.ago
and these will use the correct time zone.
If we want some help to detect time zone issues in our Rails applications we can use the Zonebie gem. This can set the time zone to a random one each time we run our tests. Timecop is another gem that can be useful when we work with times in our test suite. Episode 276 has more information on this.
There are better ways to present time zones than what we’ve done here. One option is to use relative times so that we can show, say, “5 minutes ago” instead of the exact time. We could also use JavaScript to detect the user’s time zone so that we don’t have to submit it through the form.