Introducing TzTime
Time zones were created specifically to make programmers’ lives miserable. I’ve been tasked at least three times now to retrofit time zones into an application, and each time has been painful.
Traditionally, I’ve done time zones as follows:
- Install the tzinfo gem
- Install the tzinfo_timezone rails plugin
- Add a
time_zone_id
to the accounts (or users) table - Set ActiveRecord::Base.default_time_zone to :utc
- Define user2utc and utc2user helpers (for converting times between the user’s time zone and UTC)
- Search through the code and find every place that either displays a date/time (
strftime
, etc.) or stores a user-entered time in the database, and make liberal use of user2utc and utc2user
What. A. Pain.
Furthermore, it breaks down in lots of places. Consider the following:
1 2 3 4 |
zone = TimeZone["Mountain Time (US & Canada)"] morning = zone.now.beginning_of_day p morning #-> Fri Feb 02 00:00:00 UTC 2007 p morning.utc #-> Fri Feb 02 00:00:00 UTC 2007 |
Note that the value of “morning” is given as a UTC time; this is because TZInfo has to return something, and since Ruby’s Time class does not allow you to set it to arbitrary time zones, TZInfo simply returns the value as UTC, requiring you, as the programmer, to keep track of its real time zone. Thus, you cannot use Time#utc
to convert the value to real UTC, though that is the most natural operation. Instead you have to do zone.local_to_utc(morning)
, which is non-intuitive and requires that you have access to the time zone object whenever you want to do the conversion.
Now, what if you could do something like this:
1 2 3 4 |
TzTime.zone = TimeZone["Mountain Time (US & Canada)"] morning = TzTime.now.beginning_of_day p morning #-> 2007-02-02 00:00:00 MST p morning.utc #-> Fri Feb 02 07:00:00 UTC 2007 |
Well, you can! Just install the tztime plugin. You’ll want to make sure and set the global zone on each request, though; an around_filter works great:
1 2 3 4 5 6 7 8 9 10 |
class ApplicationController < ActionController::Base around_filter :set_timezone private def set_timezone TzTime.zone = current_user.time_zone yield TzTime.reset! end end |
You can read all about it in the README. Hopefully this saves others as much pain as it has saved us at 37signals!
Reader Comments
Wow – this is great! Timezones really are the worst. I’m definately looking forward to playing around with the plugin. Does it depend on tz_info and tzinfo_timezone?
2 Feb 2007
Generally, it would be nice if Ruby’s date and time classes were redesigned completely.
2 Feb 2007
Wow! That is neat! :D Thanks a bunch :)
2 Feb 2007
Zack, yes, it depends on the tzinfo_timezone plugin, which in turn requires the tzinfo gem.
2 Feb 2007
Sweet! Quite a life saver.
But, uh, what’s the yield in set_timezone for? Under what circumstances would you pass a block to that method?
2 Feb 2007
Thank you Jamis. We’ve been putting this off, now I’m glad we did. :)
2 Feb 2007
gcnovus: that’s how around_filter works.
2 Feb 2007
gcnovus, the around filter gets called with a block. When you yield, the rest of the action occurs, thus letting you wrap code “around” the action.
2 Feb 2007
Film guide: Ride, Rubyo! (1950, MGM) – One man has the courage to stand up for his community against the threat of inconsistent and inefficient Rails practices. Starring Howard Keel as Jamis Cameron.
2 Feb 2007
Shopify now runs on tztime plugin. It has been using tzinfo_timezone since launch. Thanks a lot jamis for making my life much easier :)
2 Feb 2007
So if this uses the tzinfo gem would I be correct in thinking it takes in to account daylight saving time for those timezones that DST applies to?
2 Feb 2007
Jamis: This is great! Time zones aren’t fun to deal with, but I can’t stand Daylight Savings Time.
Thanks for making my life easier too!
2 Feb 2007
but i swear, i will hav your baby some day.
2 Feb 2007
Alex, correct, it will account for daylight savings, which is another huge reason for its existence.
And, im_a_guy…I’m flattered, I think. ;)
2 Feb 2007
Does this plugin take into account the DST changes for 2007 that go into effect for the U.S.?
Taken from a random google hit via “2007 dst changes” :
”....the federal government announced a major change in Daylight Saving Time. In Aug. 2005, Congress passed an energy bill that included extending Daylight Saving Time by about a month. Beginning in 2007, DST will start the second Sunday of March and end on the first Sunday of November.”
3 Feb 2007
Rob, no, this plugin does not. However, the TZInfo lib, which this plugin relies on, might. You’d need to check with that.
3 Feb 2007
Is this why you needed the acts_like method? I thought I might see usage of it in this after seeing your comment http://dev.rubyonrails.org/ticket/7059#comment:4 mentioning you needed it internally.
3 Feb 2007
Dan, yes, it allows you to assign a TzTime instance to an ActiveRecord and have it handled like a Time instance:
record.alert_at = TzTime.now
3 Feb 2007
Shouldn’t TzTime.at(Time.now) match TzTime.now, with both 6 hours ahead of Time.now (CST)? Is there something that I’m not understanding, or should this look different?
3 Feb 2007
ugh, have a look here: http://pastie.caboo.se/37774
3 Feb 2007
Kjell, that’s correct, because Time.now will be interpreted by TzTime.at(time) as being in the UTC time zone. It is not converted to UTC, it is literally considered to be UTC, ignoring whatever time zone Ruby says the time instance has. Thus, if it is 14:00 in your time zone, and you do TzTime.at(Time.now), the result will be a TzTime instance with a value of 14:00 in whatever zone is currently active for TzTime.
Make sense? In other words, TzTime.now is not guaranteed to be the same as TzTime.at(Time.now), unless TzTime.zone is the same time zone as the local host is currently configured for.
3 Feb 2007
Jamis, this looks great. It should prove helpful for a lot of TZInfo users.
Rob, all versions of TZInfo released so far have included the new daylight savings rules for the US. I’d recommend always using the latest version though in order to get the most up to date timezone definitions.
4 Feb 2007
Hi Jamis, first, this is awesome. Thank you for making this public.
I’m not using the TzinfoTimezone because I need the full list of timezones specified in TZInfo. The only dependency on TzinfoTimezone is in #period method. If we change it we can allow TzTime.zone to accept either a ‘TzinfoTimezone’ or a ‘TZInfo::DataTimezone’ object. What do you think of this change?
Thanks again, Adam
5 Feb 2007
After years of phobia, I finally feel ready to make a timezone-aware application. Thank you!
10 Feb 2007
Hey Jamis, thanks for all the useful plugins in the repository. They always prove helpful.
In the README example you originally convert the Task.alert_at time to UTC explicitly before creating the Task. After adding the TzTime plugin you convert the Task.alert_at value to TzTime.at(self.alert_at), which should just give you the given time with the user’s timezone attached if I understand correctly.
So in the first example you’ll store it as the correlating UTC time, and in the second example you’ll store it as the user’s time zone’s time? Doesn’t that add some complexity to Unit tests for models that have any time-related code? And doesn’t it make any kind of reporting or other types of queries more copmlex because they’d rely on the time zone column of the user table (even though it’s just a little more SQL)? Is there a “best practice” for the time zone in which you store data?
10 Feb 2007
Tieg, actually, the time is ALWAYS stored as UTC in the database. TzTime#to_s(:db) will automatically convert the time to UTC, so even though it looks like you are assinging a time in the user’s timezone to that attribute, underneath it will be automatically converted to UTC before being saved.
That’s one of the aspects of TzTime that make it so useful: it helps make sure the times in the DB are always normalized to UTC, without you having to do anything special to ensure it.
10 Feb 2007
Ah, that makes sense now. Thanks for the explanation!
10 Feb 2007
This is great for setting times in the correct TZ, but what about when you get a time back from the database (UTC) and have to display it on a view in the users local time zone?
TzTime.zone.utc_to_local(Time.now.utc) will give you the correct time, however the TZ will still be set to UTC.
Should we be doing something like: TzTime.at(TzTime.zone.utc_to_local(Time.now.utc)
Or, as a helper: def utc2local(time) TzTime.at(TzTime.zone.utc_to_local(time) end
Or am I missing something completely about going back the other way?
12 Feb 2007
I still can’t get my head wrapped around strftime as it relates to TzInfo (and now, by extension, TzTime). See below ... why “UTC” and not “MST”?
14 Feb 2007
Henry, that occurs because Ruby does not store the time zone with Time instances. Or rather, it only lets you know whether the object is in UTC, or “local” (meaning, the time zone of the host machine). TZInfo, then, returns all time objects with a time zone of UTC, and requires the caller to keep track of which time zone the record is REALLY in.
The “%Z” argument to strftime, then, is pretty useless when dealing with time zones, since it can only know about what the underlying Time object reports.
The better way is to use zone.period.abbreviation.to_s to get at the time zone name, instead of %Z>
14 Feb 2007
Just to confirm, this plugin only works for Edge Rails, not the 1.2.2 gem version, correct?
14 Feb 2007
Thanks, Jamis. And of course thanks for a terrific and time saving plugin. (I have lots of apps using variations of the steps you describe above.) On a sidenote, I noticed TzTime is at rev 6155. How did that happen? Do you auto commit or something?
15 Feb 2007
I have several questions about this plugin:
1) I echo Geoff B’s question about the Rails version required for this plugin, because I’m running Rails 1.2.1 and…
2) When I do something like: MyObject.create(:foo_at => t), where t is a TzTime object, t does not automatically get converted to a DB compatible format (rather, Rails attempts to insert the YAML-serialized version of the object into the field). I have to do time.utc or time.to_s(:db) instead, explicitly. If I understand the README correctly, I shouldn’t need to do that.
3) I echo Justin’s question above on how to convert a UTC time fetched from the database back to the user’s timezone. Shouldn’t there be a TzTime.from_utc method or something, since TzTime.at(t) assumes t is in the host’s timezone? (And if this is the case, .at seems like a bad name for that method—.from_local would be more helpful). Maybe I’m missing something obvious here. Please advise.
17 Feb 2007
Geoff/Tom: it works as advertised on edge rails only. It will work with caveats in 1.2.x.
Henry: TzTime shares a repository with Rails, so the revno you see is the revno for the entire Rails repository, not just for the TzTime plugin.
Tom: for #1 and #2, you need edge rails. To convert a UTC time to user local time, I do TzTime.zone.utc_to_local(foo.created_at). It would be trivial to wrap that up in a helper if you find that too verbose, though.
19 Feb 2007
Hi Jamis
I just wondered if you tend to condition your set_timezone code with TzTime.zone = current_user.time_zone unless current_user.nil? or do you use skip_filter in the controllers where there is no current_user (e.g. login or signup)
21 Feb 2007
Fred, it’s definitely easier to use a condition in the filter, but the drawback of that is it can hide bugs. If you expect a particular call to have set the current_user value, it’s nice to have that assumption tested as early in the stack as possible.
So, you have to find a balance. I’ve done it both ways (condition vs. skip_filter).
22 Feb 2007
Re: above question about DST changes for 2007, looks like this already handled by the latest version of the TzInfo gem:
lib/tzinfo/definitions/America/Chicago.rb.... tz.transition 2006, 4, :o2, 1143964800 tz.transition 2006, 10, :o1, 1162105200 tz.transition 2007, 3, :o2, 1173600000 tz.transition 2007, 11, :o1, 1194159600 ....
22 Feb 2007