Skip to content

Instantly share code, notes, and snippets.

@mudge

mudge/README Secret

Created June 12, 2010 13:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mudge/85341b62c6181ccda5b4 to your computer and use it in GitHub Desktop.
Save mudge/85341b62c6181ccda5b4 to your computer and use it in GitHub Desktop.
Paul Mucur's entry to RPCFN: Business Hours (#10)
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.
# 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
# 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