DayRange (#92)

by Bryan Donovan

If you've ever created a web application that deals with scheduling recurring events, you may have found yourself creating a method to convert a list of days into a more human-readable string.

For example, suppose a musician plays at a certain venue on Monday, Tuesday, Wednesday, and Saturday. You could pass a list of associated day numbers to your object or method, which might return "Mon-Wed, Sat".

The purpose of this quiz is to find the best "Ruby way" to generate this sentence-like string.

Basically, the rules are:

* The class's constructor should accept a list of arguments that can be day
numbers (see day number hash below), day abbreviations ('Mon', 'Tue', etc.),
or the full names of the days ('Monday', 'Tuesday', etc.).
* If an invalid day id is included in the argument list, the constructor
should raise an ArgumentError.
* The days should be sorted starting with Monday.
* Three or more consecutive days should be represented by listing the first
day followed by a hyphen (-), followed by the last day of the range.
* Individual days and the above day ranges should be separated by commas.
* The class should number days (accepting Integers or Strings) as follows:
1: Mon
2: Tue
3: Wed
4: Thu
5: Fri
6: Sat
7: Sun
* The class needs a method named #to_s that returns the day range string.
Here are some example lists of days and their expected returned strings:
1,2,3,4,5,6,7: Mon-Sun
1,2,3,6,7: Mon-Wed, Sat, Sun
1,3,4,5,6: Mon, Wed-Sat
2,3,4,6,7: Tue-Thu, Sat, Sun
1,3,4,6,7: Mon, Wed, Thu, Sat, Sun
7: Sun
1,7: Mon, Sun
1,8: ArgumentError

This is not intended to be a difficult quiz, but I think the solutions would be useful in many situations, especially in web applications. The solution I have come up with works and is relatively fast (fast enough for my purposes anyway), but isn't very elegant. I'm very interested in seeing how others approach the problem.


Quiz Summary

A couple of submitters mentioned that this problem isn't quite as simple as it looks like it should be and I agree. When I initially read it, I was convinced I could come up with a clever iterator call that spit out the output. People got it down to a few lines, but it's still just not as straightforward as I expected it to be.

A large number of the submitted solutions included tests this time around. I think that's because the quiz did a nice job of laying down the ground rules and this is one of those cases where it's very easy to quickly layout a set of expected behaviors.

A lot of solutions also added some additional functionality, beyond what the quiz called for. Many interesting additions were offered including enumeration, support for Date methods, mixed input, and configurable output. A lot of good ideas in there.

Below, I want to examine Robin Stocker's solution, which did include a neat extra feature. Let's begin with the tests:

ruby
require 'test/unit'

class DayRangeTest < Test::Unit::TestCase

def test_english
tests = {
[1,2,3,4,5,6,7] => 'Mon-Sun',
[1,2,3,6,7] => 'Mon-Wed, Sat, Sun',
[1,3,4,5,6] => 'Mon, Wed-Sat',
[2,3,4,6,7] => 'Tue-Thu, Sat, Sun',
[1,3,4,6,7] => 'Mon, Wed, Thu, Sat, Sun',
[7] => 'Sun',
[1,7] => 'Mon, Sun',
%w(Mon Tue Wed) => 'Mon-Wed',
%w(Frid Saturd Sund) => 'Fri-Sun',
%w(Monday Wednesday Thursday Friday) => 'Mon, Wed-Fri',
[1, 'Tuesday', 3] => 'Mon-Wed'
}
tests.each do |days, expected|
assert_equal expected, DayRange.new(days).to_s
end
end

# ...

Here we see a set of hand-picked cases being tried for expected results. Most submitted tests iterated over some cases like this, since it's a pretty easy way to spot check basic functionality.

Do note the final test case handling mixed input. Robin's code supports that, as many others did.

Here are some tests for Robin's extra feature, language translation:

ruby
# ...

def test_german
tests = {
[1,2,3,4,5,6,7] => 'Mo-So',
[1,2,3,6,7] => 'Mo-Mi, Sa, So',
[1,3,4,5,6] => 'Mo, Mi-Sa',
[2,3,4,6,7] => 'Di-Do, Sa, So',
[1,3,4,6,7] => 'Mo, Mi, Do, Sa, So',
[7] => 'So',
[1,7] => 'Mo, So',
%w(Mo Di Mi) => 'Mo-Mi',
%w(Freit Samst Sonnt) => 'Fr-So',
%w(Montag Mittwoch Donnerstag Freitag) => 'Mo, Mi-Fr',
[1, 'Dienstag', 3] => 'Mo-Mi'
}
tests.each do |days, expected|
assert_equal expected, DayRangeGerman.new(days).to_s
end
end

def test_translation
eng = %w(Mon Tue Wed Fri)
assert_equal 'Mo-Mi, Fr',
DayRangeGerman.new(DayRange.new(eng).days).to_s
end

# ...

This time the spot checking is done in German, the other language included in this solution. You can also see support for translating between languages, in the second test here.

One last test:

ruby
# ...

def test_should_raise
assert_raise ArgumentError do
DayRange.new([1, 8])
end
end

end

This time the test ensures that the code does not accept invalid arguments. Some people chose to spot check several edge cases here as well.

OK, let's get to the solution:

ruby
require 'abbrev'

class DayRange

def self.use_day_names(week, abbrev_length=3)
@day_numbers = {}
@day_abbrevs = {}
week.abbrev.each do |abbr, day|
num = week.index(day) + 1
@day_numbers[abbr] = num
if abbr.length == abbrev_length
@day_abbrevs[num] = abbr
end
end
end

use_day_names \
%w(Monday Tuesday Wednesday Thursday Friday Saturday Sunday)

def day_numbers; self.class.class_eval{ @day_numbers } end
def day_abbrevs; self.class.class_eval{ @day_abbrevs } end

# ...

The main work horse here is DayRange::use_day_names, which you can see used just below the definition. This associates seven names with the day indices the program uses to work.

Array#abbrev is used here so the code can create a lookup table for all possible abbreviations to the actual numbers. Another lookup table is populated for the code to use in output Strings and this one accepts a target abbreviation size.

The two instance methods below provide access to the lookup tables. Note that these two methods could use Object#instance_variable_get as opposed to Module#class_eval if desired.

Next chunk of code, coming right up:

ruby
# ...

attr_reader :days

def initialize(days)
@days = days.collect{ |d| day_numbers[d] or d }
if not (@days - day_abbrevs.keys).empty?
raise ArgumentError
end
end

# ...

Nothing too tricky here. DayRange#initialize handles the mixed input by trying to find it in the lookup table or defaulting to what was passed. There's also a check in here to make sure we end up with only days we have a name for. This handles bounds checking of the input.

Other solutions varied the initialization process a bit. I particularly liked how Marshall T. Vandergrift allowed for multiple arguments, a single Array, or even Ranges to be passed with some nice Array#flatten work.

Alright, let's get back to Robin's solution:

ruby
# ...

def to_s
ranges = []
number_ranges.each do |range|
case range[1] - range[0]
when 0; ranges << day_abbrevs[range[0]]
when 1; ranges.concat day_abbrevs.values_at(*range)
else ranges << day_abbrevs.values_at(*range).join('-')
end
end
ranges.join(', ')
end

def number_ranges
@days.inject([]) do |l, d|
if l.last and l.last[1] + 1 == d
l.last[1] = d
else
l << [d, d]
end
l
end
end

end

This is the heart of the String building process and many solutions landed on code similar to this. DayRange#number_ranges starts the process by building an Array of Arrays with the days divided into groups of start and end days. Days that run in succession are grouped together and lone days appear as both the start and end. For example, the days 1, 2, 3, and 6 would be divided into `[[1, 3], [6, 6]]`.

DayRange#to_s takes the Array from that process and turns it into the output String. It just separates the groups by the number of members they have. One and two day groups just have their days added to an Array used to build up the output. Longer groups are turned into strings with a hyphen between the first and last entries. Finally, the Array is joined with commas creating the desired String result.

Ready to see how much extra work it was to translate this class to German?

ruby
class DayRangeGerman < DayRange
use_day_names \
%w(Montag Dienstag Mittwoch Donnerstag Freitag Samstag Sonntag), 2
end

The only required step is to supply the day names and abbreviation level, as you can see. It wouldn't be much work to add a whole slew of supported languages to this solution. Very nice.

My thanks to the many people who came out of the woodwork to show just how creative you can be. You all made such cool solutions and I hope others will take some time to browse through them.

Tomorrow, we will attempt to psychoanalyze Ruby's Integer class...