The Strange Date Range Case
There are only two hard things in Computer Science: cache invalidation and naming things
- Phil Karlton
There are only two hard problems in CS: Cache invalidation and How to quit Vim
- Aaron Patterson
Luckily for me, after 10 years of having vim open, I managed to learn how to quit. When it comes to naming things, I always blame the “non native speaker” in me, so that is also out of the way.
Given this, I see time, Timezones and dates as a pain and hard thing to solve correctly as well. Today, I’d like to write a bit more about dates in particular.
Recently, we were developing a simple small DateRange class for our Rails application, which I’m sure many of us have written before. The main goal of this DateRange is to provide us with an array of dates between start and end dates, as well as this same date range in different steps.
So initially we wrote something along these lines:
class DateRange
def initialize(start_date = nil, end_date = nil)
@start_date = start_date || Date.today
@end_date = end_date || Date.today
end
def days_grouped_by_month
# NOTE: Consider providing the to_a method for the date range
# instances if we start to use more often.
date_range.to_a.group_by { |date| "#{date.month}-#{date.year}" }
end
def in_monthly_step
dates = []
current_date = @start_date
while current_date <= @end_date
dates << current_date
current_date = current_date >> 1
end
dates
end
private
def date_range
(@start_date..@end_date)
end
end
Let’s look at our initial use case scenario:
start_date = Date.parse('01-01-2019')
end_date = Date.parse('03-04-2019')
dr = DateRange.new(start_date, end_date)
monthly_dates = dr.in_monthly_step
# => [Tue, 01 Jan 2019, Fri, 01 Feb 2019, Fri, 01 Mar 2019, Mon, 01 Apr 2019]
This seemed to work perfectly fine until we had to rely on end of months date ranges.
When we use current_date >> 1
this shifts the date one month forward. The problem is
that it doesn’t behave consistently. It is quite understandable why. Putting a bit of
thought into this, leads to the usual question: when you have months with different
number of days (e.g. Jan-31, Feb-28/29, Apr-30, …) how do you decide what a monthly step
is? I think this leads to a lot of discussion because it is not clear and there is no
consensus on how this should behave.
What is the expected behavior when you have, for example:
start_date = Date.parse('01-01-2019')
next_date = start_date + 1.month
# next_date = ?
In this case next_date
will be Fri, 01 Feb 2019
so it means we’ve travelled 31 days. But then, when we have:
start_date = Date.parse('31-01-2019')
next_date = start_date + 1.month
# next_date = ?
next_date
will be Thu, 28 Feb 2019
so it means we’ve travelled 28 days.
In my opinion, the behavior would be consistent here if
start_date = Date.parse('28-02-2019')
next_date = start_date + 1.month
# next_date = ?
were to return Sun, 31 Mar 2019
but instead, it returns Thu, 28 Mar 2019
.
To add to the confusion, if we were to add 2 months instead of 1 in January 31,
we do get Sun, 31 Mar 2019
.
start_date = Date.parse('31-01-2019')
next_date = start_date + 2.month
Given this, the outcome of our discussion was that based on our use cases we should have two separate methods:
-
in_monthly_step
- that will give us the dates moving on a monthly basis, taking in account the monthly shifts that might happen. (e.g 30/01/2019, 28/02/2019, 30/03/2019 or 15/01/2019, 15/02/2019, 15/03/2019) -
end_of_months_in_date_range
- this will give us all date ranges that fall within the start and end dates.
sd=30/01/2019
ed=30/04/2019
end_of_months_in_date_range => [31/01/2019, 28/02/2019, 31/03/2019, 30/04/2019]
While
sd=01/01/2019, ed=25/04/2019
end_of_months_in_date_range => [31/01/2019, 28/02/2019, 31/03/2019]
Here is the simplified final version:
class DateRange
def initialize(start_date = nil, end_date = nil)
@start_date = start_date || Date.today
@end_date = end_date || Date.today
end
def days_grouped_by_month
# NOTE: Consider providing the to_a method for the date range
# instances if we start to use more often.
date_range.to_a.group_by { |date| "#{date.month}-#{date.year}" }
end
def end_of_month_in_range
dates = days_grouped_by_month.values.map(&:last)
dates.pop unless @end_date == @end_date.end_of_month
dates
end
def in_monthly_step
dates = []
current_date = @start_date
while current_date <= @end_date
dates << current_date
current_date = current_date >> 1
end
dates
end
private
def date_range
(@start_date..@end_date)
end
end
Because I felt that we could benefit in the future from it, I decided to extract the code to a gem itself that does not rely on Rails' Date methods nor DateTime. At the time of writing I haven’t yet tested it with rails for any unforseen conflicts, but in theory because its written on top of Ruby’s Date, it should support Rails with no issues. You might want to take a look at it and feel free to comment, use it or open any issues you might find.
The gem implementation has a couple more useful methods that I don’t mention here
such as include?
, overlap?
and overlap
. These make sense to exist as part
of the DateRange.