Introducing TzTime

Posted by Jamis on February 02, 2007 @ 01:52 PM

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!

Posted in Announcements

Comments

Have something to add? Click here to leave a comment.

02 Feb 2007

1. Zack Chandler said...

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. Daniel Schierbeck said...

Generally, it would be nice if Ruby’s date and time classes were redesigned completely.

3. coder_ said...

Wow! That is neat! :D Thanks a bunch :)

4. Jamis said...

Zack, yes, it depends on the tzinfo_timezone plugin, which in turn requires the tzinfo gem.

5. gcnovus said...

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?

6. atmos said...

Thank you Jamis. We’ve been putting this off, now I’m glad we did. :)

7. Tom said...

gcnovus: that’s how around_filter works.

8. Jamis said...

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.

9. Chris Mear said...

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.

10. tobi said...

Shopify now runs on tztime plugin. It has been using tzinfo_timezone since launch. Thanks a lot jamis for making my life much easier :)

11. Alex said...

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?

12. Jason said...

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!

13. jamis_im_a_guy said...

but i swear, i will hav your baby some day.

14. Jamis said...

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. ;)

03 Feb 2007

15. Rob said...

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.”

16. Jamis said...

Rob, no, this plugin does not. However, the TZInfo lib, which this plugin relies on, might. You’d need to check with that.

17. Dan Manges said...

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.

18. Jamis said...

Dan, yes, it allows you to assign a TzTime instance to an ActiveRecord and have it handled like a Time instance:

1
record.alert_at = TzTime.now

19. Kjell said...

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?

TzTime.zone = TZInfo::Timezone._load(‘UTC’) => #<tzinfo::linkedtimezone:> TzTime.now => 2007-02-04 03:20:25 UTC TzTime.at(Time.now) => 2007-02-03 21:20:31 UTC Time.now => Sat Feb 03 21:20:47 -0600 2007

20. Kjell said...

ugh, have a look here: http://pastie.caboo.se/37774

21. Jamis said...

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.

04 Feb 2007

22. Phil Ross said...

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.

05 Feb 2007

23. Adam Greene said...

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?

def period
  @period ||= zone.is_a?(TzinfoTimezone) ? zone.tzinfo.period_for_local(time) : zone.period_for_local(time)
end

Thanks again, Adam

10 Feb 2007

24. Rudi Cilibrasi said...

After years of phobia, I finally feel ready to make a timezone-aware application. Thank you!

25. Tieg said...

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?

26. Jamis said...

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.

27. Tieg said...

Ah, that makes sense now. Thanks for the explanation!

12 Feb 2007

28. Justin said...

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?

14 Feb 2007

29. Henry said...

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”?

TzTime.zone = TimeZone[“Mountain Time (US & Canada)”] => #<tzinfotimezone:0x2749e14> morning = TzTime.now.beginning_of_day => 2007-02-14 00:00:00 MST morning.strftime(“%Z”) => “UTC”

30. Jamis said...

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>

1
2
3
time = TzTime.now.beginning_of_day
p time.strftime("%Y-%m-%d %H:%M:S") + " " + time.period.abbreviation.to_s
#=> "2007-02-14 00:00:00 MST"

31. Geoff B said...

Just to confirm, this plugin only works for Edge Rails, not the 1.2.2 gem version, correct?

15 Feb 2007

32. Henry said...

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?

17 Feb 2007

33. Tom Smyth said...

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.

19 Feb 2007

34. Jamis said...

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.

21 Feb 2007

35. Fred said...

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)

22 Feb 2007

36. Jamis said...

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).

37. Geoff B said...

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 ....