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.
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:
- Ruby
- Ruby Standard Library
- Date 1.9.3 - 2.0
- Time 1.9.3 - 2.0
- DateTime 1.9.3 - 2.0
- Rails
- Date
- Time
- DateTime
- ActiveSupport::TimeZone
- ActiveSupport::TimeWithZone
- TZInfo (included gem)
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.