-
-
Save mudge/85341b62c6181ccda5b4 to your computer and use it in GitHub Desktop.
Paul Mucur's entry to RPCFN: Business Hours (#10)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Your name: Paul Mucur | |
Country of Residence: United Kingdom | |
Code works with Ruby 1.8 / 1.9 / Both: Both | |
The test suite can be run by simply placing both files in the same directory and then running the following from that directory: | |
ruby business_hours_test.rb | |
Alternatively, the class can be used from an irb prompt like so: | |
irb -rbusiness_hours | |
Then it can be used as any standard library: | |
>> hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
=> #<BusinessHours:0x1006b40a8 @days={:wed=>{}, :thu=>{}, :fri=>{}, :sat=>{}, :mon=>{}, :sun=>{}, :tue=>{}}, @default={:opening=>"9:00 AM", :closing=>"3:00 PM"}, @dates={}> | |
One thing to note is that the library supports a business that is closed by default (where every day is marked as closed) and only open on specific days. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# RPCFN: Business Hours (#10) | |
# http://rubylearning.com/blog/2010/05/25/rpcfn-business-hours-10/ | |
# | |
# Author:: Paul Mucur (mailto:mudge@mudge.name) | |
require 'date' | |
require 'time' | |
# This class represents a business schedule specifying opening and | |
# closing times throughout the year. Specific times can be set on | |
# a per day basis (e.g. open at 9:00 AM on Wednesdays) and on a per | |
# date basis (e.g. close at 1:00 PM on December 24th, 2010) as well | |
# as marking a day or date as being completely closed (e.g. closed | |
# on January 1st, 2011). | |
# | |
# A BusinessHours instance can then be used to calculate a deadline | |
# for a piece of work (e.g. given that work will take 3 hours and | |
# it will begin at 9:00 AM on June 5th, 2010, when will it be complete?) | |
class BusinessHours | |
# Specify the valid day symbols and order them so that they can | |
# be looked up using Date#wday. | |
WEEKDAYS = [:sun, :mon, :tue, :wed, :thu, :fri, :sat].freeze | |
# Exception raised when the user specifies an invalid day. | |
class InvalidDay < StandardError; end | |
# Exception raised when business is always closed. | |
class NeverOpen < StandardError; end | |
# Exception raised when the business isn't open enough to fulfil | |
# a request. | |
class NotOpenEnough < StandardError; end | |
# Initialize a new BusinessHours object with the given | |
# default opening and closing times as strings. | |
# | |
# e.g. | |
# hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
def initialize(default_opening, default_closing) | |
# Don't parse the times yet as they will be parsed | |
# when looking at specific days. | |
@default = { | |
:opening => default_opening, | |
:closing => default_closing | |
} | |
# Store date-specific business hours. | |
@dates = {} | |
# Store day-specific business hours. | |
@days = {} | |
WEEKDAYS.each { |day| @days[day] = {} } | |
end | |
# Set specific opening and closing times for a day (identified by | |
# using one of :sun, :mon, :tue, :wed, :thu, :fri or :sat) or a date. | |
# | |
# e.g. | |
# hours.update(:wed, "10:00 AM", "4:00 PM") | |
# hours.update("Dec 24, 2010", "9:00 AM", "1:00 PM") | |
def update(day_or_date, opening, closing) | |
# Similar to the defaults, don't parse opening and closing hours yet. | |
business_hours = { | |
:opening => opening, | |
:closing => closing, | |
:closed => false | |
} | |
# Determine whether day_or_date is a day (as specified by a symbol) | |
# or a string date to be parsed. | |
if day_or_date.is_a?(Symbol) | |
if WEEKDAYS.include?(day_or_date) | |
@days[day_or_date].update(business_hours) | |
else | |
raise InvalidDay, "day must be one of :#{WEEKDAYS.join(", :")}" | |
end | |
else | |
parsed_time = Time.parse(day_or_date) | |
date = Date.civil(parsed_time.year, parsed_time.month, parsed_time.day) | |
@dates[date] ||= {} | |
@dates[date].update(business_hours) | |
end | |
self | |
end | |
# Mark a specific day (identified by using one of :sun, :mon, :tue, | |
# :wed, :thu, :fri, :sat) or date as being completely closed for | |
# business. | |
# | |
# e.g. | |
# hours.closed(:wed, :fri, "Dec 25, 2010") | |
def closed(*days_or_dates) | |
days_or_dates.each do |day_or_date| | |
if day_or_date.is_a?(Symbol) | |
if WEEKDAYS.include?(day_or_date) | |
@days[day_or_date].update(:closed => true) | |
else | |
raise InvalidDay, "day must be one of :#{WEEKDAYS.join(", :")}" | |
end | |
else | |
parsed_time = Time.parse(day_or_date) | |
date = Date.civil(parsed_time.year, parsed_time.month, parsed_time.day) | |
@dates[date] ||= {} | |
@dates[date].update(:closed => true) | |
end | |
end | |
self | |
end | |
# Return the time that a job of interval_in_seconds seconds will be | |
# completed given the specific start_time. | |
# | |
# This method will raise a NeverOpen exception if the business is | |
# not open for duration of the specified request. | |
# | |
# If the business is open but not enough to complete the request, | |
# a NotOpenEnough exception will be raised. | |
# | |
# e.g. | |
# hours.calculate_deadline(2*60*60, "Jun 7, 2010 9:10 AM") | |
# # => Mon Jun 07 11:10:00 2010 | |
def calculate_deadline(interval_in_seconds, start_time) | |
parsed_time = Time.parse(start_time) | |
date = Date.civil(parsed_time.year, parsed_time.month, parsed_time.day) | |
# Calculate how many available seconds there are in the | |
# specific dates but only if every day has been marked as | |
# closed. | |
available_seconds = if every_day_closed? | |
@dates.inject(0) do |total, (day, hours)| | |
if !hours[:closed] && day >= date | |
# If this date is the start, calculate how long is left | |
# from the start time otherwise use the default opening hours. | |
opening = if day == date | |
parsed_time | |
else | |
Time.parse(hours[:opening]) | |
end | |
total + (Time.parse(hours[:closing]) - opening) | |
else | |
total | |
end | |
end | |
end | |
if every_day_closed? && !@dates.any? { |day, hours| !hours[:closed] && day >= date } | |
# If every day is closed and there are no date-specific exceptions in | |
# the future, raise an exception. | |
raise NeverOpen, "the business is closed every day of the week" | |
elsif every_day_closed? && available_seconds < interval_in_seconds | |
# If every day is closed and there aren't enough seconds specified, | |
# raise an exception. | |
raise NotOpenEnough, "the business is not open enough to fulfil your request" | |
else | |
seconds_left = interval_in_seconds | |
deadline_found = false | |
# Until seconds_left has been completely depleted, | |
# keep trying the next business day in sequence. | |
until deadline_found | |
# Keep this day's interval. | |
todays_interval = seconds_left | |
seconds_left -= remaining_interval(parsed_time) | |
if seconds_left <= 0 | |
# The deadline is this day's opening hours plus | |
# the remaining interval. | |
deadline = parsed_time + todays_interval | |
deadline_found = true | |
else | |
parsed_time = next_opening(parsed_time) | |
end | |
end | |
deadline | |
end | |
end | |
# Determine whether the business is open for a specific time | |
# or not. | |
# | |
# e.g. | |
# hours.open?("Dec 25, 2010 10:00 AM") | |
def open?(time) | |
parsed_time = Time.parse(time) | |
hours = hours_for(parsed_time) | |
!hours[:closed] && | |
parsed_time > Time.parse(hours[:opening], parsed_time) && | |
parsed_time < Time.parse(hours[:closing], parsed_time) | |
end | |
# Give the number of business seconds remaining for a given | |
# time. | |
# | |
# e.g. | |
# hours.remaining_interval("Dec 1, 2010 9:00 AM") | |
# # => 21600.0 | |
def remaining_interval(time) | |
# Allow both strings and date/time objects to be passed in. | |
parsed_time = if time.respond_to?(:year) | |
time | |
else | |
Time.parse(time) | |
end | |
hours = hours_for(parsed_time) | |
# Deal with hours that are closed. | |
if hours[:closed] | |
0 | |
else | |
# If the specified time is before opening hours, use the opening | |
# hours instead (as work can't be done before then). | |
start_time = [parsed_time, Time.parse(hours[:opening], parsed_time)].max | |
remaining_seconds = Time.parse(hours[:closing], parsed_time) - start_time | |
# Don't return negative seconds. | |
[remaining_seconds, 0].max | |
end | |
end | |
# Return the next business day's opening time. | |
# | |
# e.g. | |
# hours.next_opening("Dec 1, 2010") | |
# # => Thu Dec 02 09:00:00 2010 | |
def next_opening(time) | |
# Allow both strings and date/time objects to be passed in. | |
parsed_time = if time.respond_to?(:year) | |
time | |
else | |
Time.parse(time) | |
end | |
date = Date.civil(parsed_time.year, parsed_time.month, parsed_time.day) | |
# Check that the business is open at least one day of the week. | |
if every_day_closed? && | |
!@dates.any? { |day, hours| !hours[:closed] && day >= date } | |
raise NeverOpen, "the business is closed every day of the week" | |
else | |
# First, try the day after the given one. | |
next_date = date + 1 | |
next_opening_found = false | |
# Until an open day is found, keep trying each day in sequence. | |
until next_opening_found | |
hours = hours_for(next_date) | |
if !hours[:closed] | |
next_opening = Time.parse(hours[:opening], next_date) | |
next_opening_found = true | |
else | |
next_date += 1 | |
end | |
end | |
next_opening | |
end | |
end | |
# Return whether or not every day of the week is marked as | |
# closed. | |
def every_day_closed? | |
@days.all? { |day, hours| hours[:closed] } | |
end | |
private | |
# Get the closed status, opening and closing times for | |
# a specific Date object. | |
# | |
# e.g. | |
# hours_for(Date.today) | |
# # => {:opening=>"9:00 AM", :closing=>"3:00 PM", :closed=>false} | |
def hours_for(parsed_time) | |
date = Date.civil(parsed_time.year, parsed_time.month, parsed_time.day) | |
day = WEEKDAYS[parsed_time.wday] | |
hours = {} | |
# First check if there are any date-specific rules. | |
if @dates.has_key?(date) | |
hours[:closed] = @dates[date][:closed] | |
hours[:opening] = @dates[date][:opening] | |
hours[:closing] = @dates[date][:closing] | |
end | |
# Then check for day-specific rules. | |
if !@days[day].empty? | |
hours[:closed] = @days[day][:closed] if hours[:closed].nil? | |
hours[:opening] ||= @days[day][:opening] | |
hours[:closing] ||= @days[day][:closing] | |
end | |
# Fall back to the default hours. | |
hours[:closed] = false if hours[:closed].nil? | |
hours[:opening] ||= @default[:opening] | |
hours[:closing] ||= @default[:closing] | |
hours | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# RPCFN: Business Hours (#10) | |
# http://rubylearning.com/blog/2010/05/25/rpcfn-business-hours-10/ | |
# | |
# Author:: Paul Mucur (mailto:mudge@mudge.name) | |
require 'test/unit' | |
require File.join(File.dirname(__FILE__), 'business_hours') | |
class BusinessHoursTest < Test::Unit::TestCase | |
def test_constructor_takes_two_times | |
assert_nothing_raised do | |
@hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
end | |
end | |
def test_presence_of_methods | |
@hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
assert_respond_to @hours, :update | |
assert_respond_to @hours, :closed | |
assert_respond_to @hours, :calculate_deadline | |
end | |
def test_giving_invalid_day | |
@hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
assert_raises BusinessHours::InvalidDay do | |
@hours.closed(:woo) | |
end | |
assert_raises BusinessHours::InvalidDay do | |
@hours.update(:woo, "9:30 AM", "10:30 PM") | |
end | |
end | |
def test_whether_time_is_during_business_hours | |
@hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
assert @hours.open?("Dec 11, 2011 9:01 AM") | |
assert @hours.open?("Jan 4, 2012 2:40 PM") | |
assert !@hours.open?("Oct 23, 2010 8:45 AM") | |
end | |
def test_whether_time_is_during_business_hours_with_closed_dates | |
@hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
@hours.closed("Dec 11, 2011") | |
assert !@hours.open?("Dec 11, 2011 9:50 AM") | |
assert !@hours.open?("Dec 11, 2011 8:00 AM") | |
end | |
def test_whether_time_is_during_business_hours_with_closed_days | |
@hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
@hours.closed(:wed) | |
assert !@hours.open?("Jun 16, 2010 10:00 AM") | |
assert !@hours.open?("Jun 16, 2010 6:00 AM") | |
end | |
def test_remaining_interval | |
@hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
assert_equal 60, @hours.remaining_interval("2:59 PM") | |
assert_equal 0, @hours.remaining_interval("4:00 PM") | |
assert_equal 60*60, @hours.remaining_interval("2:00 PM") | |
assert_equal 6*60*60, @hours.remaining_interval("4:00 AM") | |
@hours.closed "Jan 1 2011" | |
assert_equal 0, @hours.remaining_interval("Jan 1 2011 10:00 AM") | |
end | |
def test_next_opening | |
@hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
assert_equal Time.local(2010, 1, 2, 9, 0, 0), @hours.next_opening("Jan 1 2010") | |
@hours.update "Jan 2 2010", "10:00 AM", "3:00 PM" | |
assert_equal Time.local(2010, 1, 2, 10, 0, 0), @hours.next_opening("Jan 1 2010") | |
@hours.closed "Jan 2 2010" | |
assert_equal Time.local(2010, 1, 3, 9, 0, 0), @hours.next_opening("Jan 1 2010") | |
end | |
def test_examples_in_specification | |
@hours = BusinessHours.new("9:00 AM", "3:00 PM") | |
@hours.update :fri, "10:00 AM", "5:00 PM" | |
@hours.update "Dec 24, 2010", "8:00 AM", "1:00 PM" | |
@hours.closed :sun, :wed, "Dec 25, 2010" | |
assert_equal Time.local(2010, 6, 7, 11, 10, 0), @hours.calculate_deadline(2*60*60, "Jun 7, 2010 9:10 AM") | |
assert_equal Time.local(2010, 6, 10, 9, 3, 0), @hours.calculate_deadline(15*60, "Jun 8, 2010 2:48 PM") | |
assert_equal Time.local(2010, 12, 27, 11, 0, 0), @hours.calculate_deadline(7*60*60, "Dec 24, 2010 6:45 AM") | |
end | |
def test_cross_year_boundary | |
@hours = BusinessHours.new("8:00 AM", "2:00 PM") | |
@hours.closed "Dec 30, 2010", "Dec 31, 2010", "Jan 1, 2011" | |
assert_equal Time.local(2011, 1, 2, 9, 0, 0), @hours.calculate_deadline(2*60*60, "Dec 29, 2010, 1:00 PM") | |
end | |
def test_when_never_open_and_no_specific_dates | |
@hours = BusinessHours.new("8:00 AM", "3:00 PM") | |
@hours.closed :sun, :mon, :tue, :wed, :thu, :fri, :sat | |
assert_raises BusinessHours::NeverOpen do | |
@hours.calculate_deadline(2*60*60, "Jun 7, 2010 9:10 AM") | |
end | |
assert_raises BusinessHours::NeverOpen do | |
@hours.next_opening("Jan 1 2010") | |
end | |
end | |
def test_when_never_open_and_specific_dates_in_the_past | |
@hours = BusinessHours.new("8:00 AM", "3:00 PM") | |
@hours.closed :sun, :mon, :tue, :wed, :thu, :fri, :sat | |
@hours.update "Jan 5, 2009", "8:00 AM", "5:00 PM" | |
assert_raises BusinessHours::NeverOpen do | |
@hours.calculate_deadline(2*60*60, "Jun 7, 2010 9:10 AM") | |
end | |
assert_raises BusinessHours::NeverOpen do | |
@hours.next_opening("Jan 1 2010") | |
end | |
end | |
def test_when_never_open_and_specific_dates_in_the_future | |
@hours = BusinessHours.new("8:00 AM", "3:00 PM") | |
@hours.closed :sun, :mon, :tue, :wed, :thu, :fri, :sat | |
@hours.update "Jan 5, 2011", "8:00 AM", "5:00 PM" | |
assert_nothing_raised do | |
@hours.calculate_deadline(2*60*60, "Jun 7, 2010 9:10 AM") | |
end | |
assert_nothing_raised do | |
@hours.next_opening("Jan 1 2010") | |
end | |
end | |
def test_when_never_open_and_specific_closed_date_in_future | |
@hours = BusinessHours.new("8:00 AM", "3:00 PM") | |
@hours.closed :sun, :mon, :tue, :wed, :thu, :fri, :sat | |
@hours.closed "Jan 5, 2011" | |
assert_raises BusinessHours::NeverOpen do | |
@hours.calculate_deadline(2*60*60, "Jun 5, 2010 9:10 AM") | |
end | |
assert_raises BusinessHours::NeverOpen do | |
@hours.next_opening("Jan 1 2011") | |
end | |
end | |
def test_when_never_open_and_specific_closed_date_in_past | |
@hours = BusinessHours.new("8:00 AM", "3:00 PM") | |
@hours.closed :sun, :mon, :tue, :wed, :thu, :fri, :sat | |
@hours.closed "Jan 5, 2009" | |
assert_raises BusinessHours::NeverOpen do | |
@hours.calculate_deadline(2*60*60, "Jun 5, 2010 9:10 AM") | |
end | |
assert_raises BusinessHours::NeverOpen do | |
@hours.next_opening("Jan 1 2011") | |
end | |
end | |
def test_when_closed_but_overridden_by_date | |
@hours = BusinessHours.new("8:00 AM", "3:00 PM") | |
@hours.closed :wed | |
@hours.update "June 16 2010", "9:00 AM", "4:00 PM" | |
assert @hours.open?("June 16 2010 10:00 AM") | |
assert !@hours.open?("June 9 2010 10:00 AM") | |
end | |
def test_when_asking_for_more_work_than_can_be_done | |
@hours = BusinessHours.new("8:00 AM", "9:00 AM") | |
@hours.closed :sun, :mon, :tue, :wed, :thu, :fri, :sat | |
@hours.update "Jan 1 2011", "8:00 AM", "9:00 AM" | |
assert_raises BusinessHours::NotOpenEnough do | |
@hours.calculate_deadline(6*60*60, "Jan 1 2011 8:00 AM") | |
end | |
end | |
def test_when_asking_for_more_work_than_can_be_done_partway_through_a_day | |
@hours = BusinessHours.new("8:00 AM", "9:00 AM") | |
@hours.closed :sun, :mon, :tue, :wed, :thu, :fri, :sat | |
@hours.update "Jan 1 2011", "8:00 AM", "9:00 AM" | |
assert_raises BusinessHours::NotOpenEnough do | |
@hours.calculate_deadline(1*60*60, "Jan 1 2011 8:30 AM") | |
end | |
end | |
def test_when_asking_for_work_that_can_be_done_partway_through_a_day | |
@hours = BusinessHours.new("8:00 AM", "9:00 AM") | |
@hours.closed :sun, :mon, :tue, :wed, :thu, :fri, :sat | |
@hours.update "Jan 1 2011", "8:00 AM", "9:00 AM" | |
assert_raises BusinessHours::NotOpenEnough do | |
@hours.calculate_deadline(0.5*60*60, "Jan 1 2011 8:30 AM") | |
end | |
end | |
def test_every_day_closed | |
@hours = BusinessHours.new("8:00 AM", "9:00 AM") | |
@hours.closed :sun, :mon, :tue, :wed, :thu, :fri, :sat | |
assert @hours.every_day_closed? | |
@hours.update(:tue, "8:30 AM", "3:30 PM") | |
assert !@hours.every_day_closed? | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment