In Progress
Unit 1, Lesson 21
In Progress

Date Math

Video transcript & code

If you're used to working on Rails applications, you're probably used to doing various date and time calculations using the ActiveSupport extensions.

For instance, calculating the date a year ago today.

Or two weeks into the future.

require "date"
require "active_support/core_ext/time"

Time.now.next_year              # => 2017-07-22 12:31:12 -0400
Time.now.weeks_ago(2)           # => 2016-07-08 12:31:12 -0400

If, after using all of these convenient methods, you found yourself working on a non-Rails project, you might have been rudely surprised to find that Time is a much less helpful object on its own.

Time.now.next_year              # => NoMethodError: undefined method `next_year' for 2016-07-22 12:32:23 -0400:Time

# ~> NoMethodError
# ~> undefined method `next_year' for 2016-07-22 12:32:23 -0400:Time
# ~>
# ~> xmptmp-in16026-RP.rb:1:in `<main>'

The most help we get for doing date calculations from the core Time class is the ability to add or subtract a number of seconds.

So, for instance, we might try to find the date a year ago today by calculating the number of seconds in a year, and then subtracting that number.

Time.now - (365 * 24 * 60 * 60)
# => 2015-07-23 12:43:00 -0400

But this is ugly, and it's also a technique that's prone to inaccuracies. For instance, it doesn't take into account leap years or leap seconds.

The truth is, Ruby's core Time class isn't much more than a thin wrapper over the UNIX concept of a timestamp: a point in time defined by a count of seconds since the UNIX epoch.

We can see this reflected in the fact that it has a #to_i conversion which returns this internal counter.

What is this mysterious Epoch that Time objects are counting up from? We can find out by creating a new Time at the zero point and stripping it of the local time zone.

Time.now.to_i                   # => 1469205406
Time.at(0).utc                  # => 1970-01-01 00:00:00 UTC

As we can see, the UNIX Epoch is January 1, 1970.

Ruby does have a class that's dedicated to doing more advanced date calculations; it's just not the Time class.

It's the Date class, contained in the date standard library.

Just as there's a Time.now, there's a Date.today.

But chances are, in most cases we'll already have a Time object to work with. Even if that object really represents a date.

We can convert a preexisting Time to a Date using the #to_date method that the date library adds.

Then, we can calculate the date a year in the past using prev_year.

If we need more than one year in the past, we can pass a count.

Similarly, there are methods for moving to the past or future by months.

There's also a shortcut for month math: the left-shift and right-shift operators are overloaded to offset a Date by a number of years into the past or future.

…and by days.

Day calculations have another shortcut: the addition and subtraction operators.

Once we have the date we want, we can turn it back into a Time using #to_time.

require "date"

Date.today                      # => #<Date: 2016-07-22 ((2457592j,0s,0n),+0s,2299161j)>
t = Time.new(2016, 7, 22)       # => 2016-07-22 00:00:00 -0400
d = t.to_date                   # => #<Date: 2016-07-22 ((2457592j,0s,0n),+0s,2299161j)>

d.prev_year                     # => #<Date: 2015-07-22 ((2457226j,0s,0n),+0s,2299161j)>
d.prev_year(2)                  # => #<Date: 2014-07-22 ((2456861j,0s,0n),+0s,2299161j)>

d.next_month(6)                 # => #<Date: 2017-01-22 ((2457776j,0s,0n),+0s,2299161j)>
d.prev_month(3)                 # => #<Date: 2016-04-22 ((2457501j,0s,0n),+0s,2299161j)>
d << 6                          # => #<Date: 2016-01-22 ((2457410j,0s,0n),+0s,2299161j)>
d >> 3                          # => #<Date: 2016-10-22 ((2457684j,0s,0n),+0s,2299161j)>

d.next_day(7)                   # => #<Date: 2016-07-29 ((2457599j,0s,0n),+0s,2299161j)>
d.prev_day(14)                  # => #<Date: 2016-07-08 ((2457578j,0s,0n),+0s,2299161j)>
d + 7                           # => #<Date: 2016-07-29 ((2457599j,0s,0n),+0s,2299161j)>
d - 14                          # => #<Date: 2016-07-08 ((2457578j,0s,0n),+0s,2299161j)>

d.prev_year.to_time             # => 2015-07-22 00:00:00 -0400

One point to bear in mind when converting time objects to date objects is that in the process, we're throwing away any time-of-day information.

If we want to keep this part of our time objects intact, for instance if we want to calculate a year ago to the hour, minute, and second, we can switch to converting to DateTime instead of to Date. All the same operations are still available, but the time of day and time zone is preserved.

require "date"

t = Time.new(2016, 7, 22, 13, 4)       # => 2016-07-22 13:04:00 -0400
d = t.to_datetime
# => #<DateTime: 2016-07-22T13:04:00-04:00 ((2457592j,61440s,0n),-14400s,2299161j)>
d.prev_year.to_time             # => 2015-07-22 13:04:00 -0400

The date calculations available on Date and DateTime still aren't quite as rich as those available from ActiveSupport. But they take care of most basic date calculation needs, without having to resort to painful and inaccurate arithmetic using only numbers of seconds.

So now you know what to do if you ever need to perform date calculations outside of a Rails project. Today's episode has also provided a glimpse into why Ruby has Date and DateTime classes that are separate from the Time class. Time is just a lightweight wrapper over a count of seconds. Whereas the Date and DateTime objects encapsulate a true concept of a calendar date, including all the fiddly details of different calendar systems, leap years, and everything else that goes into getting date calculations right.

Happy hacking!

Responses