Handling Dates & Timezones in Ruby & Rails

I’ve spent almost the last two weeks dealing with Date and Time Zone related issues within one of the applications I contract on. This is a just a list of notable behaviours I expereinced whilst working with Ruby, Rails, Dates & TimeZones.

It's dangerous
to go alone! Take this.

Note: DST is in effect at the time of writing.

Inconsistent Handling of DST

In the code below we instantiate an ActiveSupport::TimeZone object. I would love to think that this instance would know everything about that Time Zone. Its offset inside and outside of DST. I would expect the method TimeZone#formatted_offset to account for DST and return the offset accordingly. It doesn’t. It outputs the offset for Standard time only. Ideally I’d like to see more concise method naming. standard_offset vs daylight_offset or dst_offset.

Alas, after we have the zone object we can pass that zone into a in_time_zone method to calculate a time in a particular zone based off an existing time. This method does in fact take DST into consideration.

time = DateTime.now
zone = ActiveSupport::TimeZone.new("Pacific Time (US & Canada)")
zone.formatted_offset
=> "-08:00"
time.in_time_zone(zone).formatted_offset
=> "-07:00"
time.in_time_zone(zone)
=> Wed, 21 Aug 2013 21:05:36 PDT -07:00

This can be problematic. Different methods were utilized in different sections of the app in turn DST and Time Zone offsets were being calculated differently.

You could do the following to acheive the same conversion using the helpful new_offset method and passing in the offset but it would result in a Standard time conversion:

time = DateTime.now
zone = ActiveSupport::TimeZone.new("Pacific Time (US & Canada)")
zone.formatted_offset
=> "-08:00"
time = time.new_offset(zone.formatted_offset)
=> Wed, 21 Aug 2013 20:05:36 -0800 

The above gives you exactly what you ask it for. A time with a specific offset. The downside to this is the offset being fetched above is again a method that does not take DST into consideration and would throw off your result.

Converting to different Time formats to utilize certain methods

Another issue was the requirement to flip flop between types to utilize specific methods. I’d have to call to_datetime to utilize changing offsets without adjusting the current point in time. Then follow it with to_time to pass it into a Timecop method.

@time = Time.now
@time_zone_offset = '-03:00' #Atlantic Time (Canada)
zone = ActiveSupport::TimeZone.new("Pacific Time (US & Canada)")
time = (@time.beginning_of_day - 2.seconds)
=> Sun, 25 Aug 2013 23:59:58 UTC +00:00
time.class
=> Time
time = time.to_datetime
=> Sun, 25 Aug 2013 23:59:58 +0000
time.class
=> DateTime
time = time.change(:offset => @time_zone_offset)
=> Sun, 25 Aug 2013 23:59:58 -0700
time = time.in_time_zone(zone).to_time
=> Mon, 26 Aug 2013 07:59:58 +0000
time.class
=> ActiveSupport::TimeWithZone

Another annoyance here is the 8th & 12th line:

time = time.to_datetime
time = time.change(:offset => @time_zone_offset)

If you don’t re-assign time to the new DateTime instance (line #12) the Time instance will not throw an error when calling the change method. It also won’t do anything else including producing the desired result. Which will leave you pulling hair while wondering why you’re not landing in the time with the zone you want.

time.to_datetime
time.class
=> ActiveSupport::TimeWithZone #oops forgot to re-assign
time = time.change(:offset => @time_zone_offset)
=> Sun, 25 Aug 2013 23:59:58 +0000

There we have it. No error and no time zone adjustment.

This is because the Rails Time class adds a change method but it only responds to a hash with the following keys: :year, :month, :day, :hour, :min, :sec, :usec.

Aside Rant: Time has a date. If it was just time then it should have no date. Much like Date has no time. If Time has a date what separates it from DateTime or TimeWithZone? If TimeWithZone has a date shouldn’t it be DateTimeWithZone. For that matter DateTime already has a zone so why have a second DateTimeWithZone class for the same thing? (I actually ended up looking into this more. See below: The State of Time vs DateTime)

Storing in UTC isn’t a be all end all solution

Anytime I mentioned I was working with Time Zones people just said “Store it in UTC”. As if that was the solution to all woes. The problem here is that the dates and times being stored (in UTC) were not being converted to or from UTC to the desired time in zone. String formatted dates sans time & offset were being passed and created as UTC time stamps. This makes those timestamps wrong as 2013-08-25 passed in will be saved as 2013-08-25 00:00:00 UTC and is not the same as 2013-08-25 00:00:00 PST (They have a +7 hour difference). So when doing comparisons to the stored dates you could end up with inaccurate results when just comparing against string values. If the desired offset was high or low enough the UTC date could actually be a different day then what’s expected.

The times were not being converted on their way into the database as the timezone field was independent of each particular time or date fields in related records. If the user changed the Time Zone field callbacks would have to trigger a re-calculation on every time related field on every related record. I’m not going to get into any more specifics but our current method was the most desired way to handle the process as we dind’t want to constantly be re-caluclating times in the databse if a user switched the zone. It’s just that some steps were missed in our comparing of the non adjusted dates.

Testing Time Zone differences

As the Dependecy Injection (DPI) band wagon rolled around using tools like Timecop became “unnessecary.” I did my best to keep DPI in mind while working with Time related methods. Which made unit testing them significantly easier. Here’s a quick run down on why DPI helps with time related methods:

Consider the following code without DPI.

# app/models/milestone.rb
def self.effective_or_nearest
  date = DateTime.now
  milestone = effective_on(date)
  milestone = date < first.start_date ? first : last if milestone.nil?
  milestone
end

The code above checks to see if we’re within a milestone date range currently. If we’re before or after the range then assign the closest milestone (first or last.)

The problem here is trying to test that the last milestone would get returned in the event we are past the milestone range because DateTime.now in this case is before the milestone end times. Instead we can just use DPI and feed in whatever date we want. We can also assign it a smart default as the current time.

# app/models/milestone.rb
def self.effective_or_nearest(date = DateTime.now)
  milestone = effective_on(date)
  milestone = date < first.start_date ? first : last if milestone.nil?
  milestone
end

Now that we can control the comparitor date we can pass in anything we’de like while testing:

# spec/models/milestone_spec.rb
it "assigns the last milestone as the date is after the requirement" do
  date = 18.days.from_now
  milestone = Milestone.effective_or_nearest(date)
  milestone.should equal Milestone.last
end
=> true

Keeping DPI in mind while writing your code can definietly make writing your tests easier. This is is just a single benefeit to DPI. I encourage you to check out other examples.

This doesn’t mean that I don’t feel there’s a place for Timecop in my life. The unit tests ran quick and I was happy with them. The area was sensitive though and I wanted to really ensure we always got the results we wanted. I wanted to write some full stack tests that started manipulating data as a user from other time zones.

In this case I had the user registering for an activity that was setup in the Atlantic time zone (Atlantic Time (Canada)). The start and end times for milestones would be based off the AST timezone. This test let me move the context of the user exactly 2 seconds before the end of the first milestone in AST. Which would be +4 hours PST. Register the user at that time and ensure I recieve the desired results.

def zoned_time(date_time)
  zone = ActiveSupport::TimeZone.new("Pacific Time (US & Canada)")
  time = date_time.to_datetime
  time = time.change(:offset => event.time_zone_offset)
  time = time.in_time_zone(zone).to_time
end

it "assigns the first milestone" do
  time = Milestone.first.start_date
  time = zoned_time(time.beginning_of_day - 2.seconds)
  Timecop.travel(@time) do
    visit '/register'
    signup #fills out a form and submits
  end

  User.last.milestone.should eq Milestone.first
end

Using Timecop like this helped ensure users in different time zones would have all time related constraints met properly. This was more satisfying then just createing a time and injecting it directly into my method. Using DPI left me less wiggle room in creating circumstanes to reveal other possible scenarios under which the time constraints might be calculated improperly under different zones.

Some notes on the different Ruby/Rails Date & Time classes.

A quick map of where different Date and Time classes and modules live:

The rails Date and Time classes bring lots of the fancy niceties that we like to use. Methods such as from_now in lines like 2.days.from_now. They extend this functionality to the base ruby classes for use anywhere.

TZInfo is a ruby library used to “provide daylight savings aware transformations between times in different timezones.” ActiveSupport::TimeZone and ActiveSupport::TimeWithZone are essentially wrappers for TZInfo interaction.

Why would I use DateTime over the Time class?

This Stackoverflow Question seems to sum up the original reason for it: {% blockquote %} DateTime had an advantage over Time on 32 bit machines in Rubies < 1.9.2 - Time was a victim of the Y2K38 problem and limited to a 32 bit range. This problem is solved either on 64 bit machines and/or in recent Ruby versions. You still may need to use DateTime if for example 1.8 compatibility is a must or you rely on using methods from its API which often deviates from that of Time. {% endblockquote %}

The state of Time vs DateTime

After reading through this additional blog post the differences between Time and DateTime seem to be getting smaller since Ruby 1.9.2 hit and you could really pick either with some subtle nuances. Our app deals with many different time zones at any one time so the suggested practice there doesn’t really solve our internal issues. It seems like a decent practice to pickup though. That way if your app does become Time Zone dependent at some point you’ve already taken away a lot of headache.

Reflection

Overall I wish there were less public facing Date & Time related entities. I don’t doubt all of these classes serve a purpose but I don’t think they should all have a public interface and I don’t want to have anything returned that’s not a Date, Time or DateTime instance. After learning how close Time and DateTime have become I’d even propose to eliminate DateTime once the remaining gaps have been bridged. I’d wager Date being moved from Ruby core to the stdlib is a sign that the Time class seems to be seeing more usage.