Monday, February 17, 2014

Buidling Ranges of Date/Time Intervals in Ruby

There are a lot of features that define a good programming language. One of them has to be how it enables a developer to represent and manipulate date and time values. As usual, Ruby has a class (actually several) that abstract the representation of date/time and provide a nice set of methods to manipulate it. Adding in Active Support provides even more functionality. My latest challenge was to divide a period of time into a series of even intervals. Using the built in methods seemed like the right path but I quickly experienced some difficulties:

> DateTime.now.step( 1.week.from_now ).to_a
=> []


After a closer examination of the documentation and I realize that the limit needed to be a Date object, not DateTime. As it turns out, this mix-and-match typing has caused me the most issues when working with dates and times in Ruby. Either you get an error or the results are not what you expected. Based on the documentation on the step function, it should default to an interval of one which I assumed meant one day. Once I switched to the right type, that's what happened:

> DateTime.now.step( 1.week.from_now.to_date ).to_a
=> [Sat, 15 Feb 2014 07:52:23 -1000, 
    Sun, 16 Feb 2014 07:52:23 -1000, 
    Mon, 17 Feb 2014 07:52:23 -1000, 
    Tue, 18 Feb 2014 07:52:23 -1000, 
    Wed, 19 Feb 2014 07:52:23 -1000, 
    Thu, 20 Feb 2014 07:52:23 -1000, 
    Fri, 21 Feb 2014 07:52:23 -1000]


So, what if you want a different time interval? Active Support provides a convenient way to express time, but its not the expected type that the step function wants:

> DateTime.now.step( 1.month.from_now.to_date, 1.week ).to_a
TypeError: expected numeric
    (ripl):40:in `step'
    (ripl):40:in `each'
    (ripl):40:in `to_a'
    (ripl):40:in `
'


However, even converting the type still doesn't make it work as expected:

> DateTime.now.step( 1.month.from_now.to_date, 1.week.to_i ).to_a
=> [Sat, 15 Feb 2014 07:54:36 -1000]


Remembering that time is stored internally as seconds made me realize that the number being passed was seconds, not days so I added some math to the process:

>> DateTime.now.step( 1.month.from_now.to_date, 1.week / 1.day ).to_a
=> [Sat, 15 Feb 2014 07:55:04 -1000, 
    Sat, 22 Feb 2014 07:55:04 -1000, 
    Sat, 01 Mar 2014 07:55:04 -1000, 
    Sat, 08 Mar 2014 07:55:04 -1000]


Now, that enables me to work with intervals over days. But what if I want to create interval ranges during a day in seconds, minutes, or hours? When I try to divide a time value by 1.day, the output doesn't make sense:
>> ( DateTime.now..1.hour.from_now ).step( 12.hour.to_f / 1.day.to_f ).to_a.length
=> 7201


I should get an empty array since I want to step every 12 hours over a 1 hour range. Instead, I get 7201 numbers (not even dates). It appears the Date step function functionality wasn't going to work, but the Range type also has a step function and the above expression can be represented as a Range:

>> ( DateTime.now..1.month.from_now ).step( 1.week / 1.day ).to_a


Which, when coupled with converting to the base representation of time (ie seconds), we can simply work with numbers to build our ranges:

>> ( DateTime.now.to_i..1.hour.from_now.to_i ).step( 10.minutes ).to_a
=> [1392471205, 1392471805, 1392472405, 1392473005, 1392473605, 1392474205, 1392474805]


And instead of leaving them as numbers, we can switch them back to a DateTime object via the Time::at class method:


>> ( DateTime.now.to_i..1.hour.from_now.to_i ).step( 10.minutes ).
              map{ |t| Time.at( t ).to_datetime }

=> [Sun, 16 Feb 2014 07:02:54 -0600, 
    Sun, 16 Feb 2014 07:12:54 -0600, 
    Sun, 16 Feb 2014 07:22:54 -0600, 
    Sun, 16 Feb 2014 07:32:54 -0600, 
    Sun, 16 Feb 2014 07:42:54 -0600, 
    Sun, 16 Feb 2014 07:52:54 -0600, 
    Sun, 16 Feb 2014 08:02:54 -0600]


Unfortunately, we lose some information by switching types like the time zone. Once you convert to a numeric and then back to a Date object, you'll be back in local time, which may not be the zone you started in. If its important to keep that, you'll have to store it before iterating and then restore it on each step:


>> from_tm = Time.now.localtime('-08:00')
>> to_tm = from_tm + 1.hour
>> tm_zone = from_tm.gmt_offset

>> ( from_tm.to_i..to_tm.to_i ).step( 10.minutes ).
            map{ |t| Time.at( t ).localtime( tm_zone ).to_datetime }

=> [Sun, 16 Feb 2014 04:01:46 -0800, 
    Sun, 16 Feb 2014 04:11:46 -0800, 
    Sun, 16 Feb 2014 04:21:46 -0800, 
    Sun, 16 Feb 2014 04:31:46 -0800, 
    Sun, 16 Feb 2014 04:41:46 -0800, 
    Sun, 16 Feb 2014 04:51:46 -0800, 
    Sun, 16 Feb 2014 05:01:46 -0800]



With a little experimenting, you can very quickly express a range of time and step through a certain intervals between those points. Instead of building an array, you might perform a series of more complex operations on the generated values. Maybe the biggest issue with some of these examples is the loss of abstraction of the representation of the date/time. The last set of examples for creating time ranges requires you to toss out the DateTime object and directly manipulate an integer representation of the time. It would be nice if the DateTime#step function could take any type of date object as a limit and and properly apply steps of less than a day without coercing the types of any inputs. Depending on your needs, it might make sense to build a few utility functions or classes to assist with these types of operations.