diff --git a/README.md b/README.md index dc8b734..cf323d4 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Time calculations using business hours. - Multiple schedule configurations. * Second-level precision on all calculations. * Accurate handling of Daylight Saving Time. +* Schedule intersection. * Thread-safe. ## Anti-Features @@ -119,6 +120,73 @@ Biz.periods # #, # #] ``` +## Schedule Intersection + +An intersection of two schedules can be found using `&`: + +```ruby +schedule_1 = Biz::Schedule.new do |config| + config.hours = { + mon: {'09:00' => '17:00'}, + tue: {'10:00' => '16:00'}, + wed: {'09:00' => '17:00'}, + thu: {'10:00' => '16:00'}, + fri: {'09:00' => '17:00'}, + sat: {'11:00' => '14:30'} + } + + config.holidays = [Date.new(2016, 1, 1), Date.new(2016, 12, 25)] + + config.time_zone = 'Etc/UTC' +end + +schedule_2 = Biz::Schedule.new do |config| + config.hours = { + sun: {'10:00' => '12:00'}, + mon: {'08:00' => '10:00'}, + tue: {'11:00' => '15:00'}, + wed: {'16:00' => '18:00'}, + thu: {'11:00' => '12:00', '13:00' => '14:00'} + } + + config.holidays = [ + Date.new(2016, 1, 1), + Date.new(2016, 7, 4), + Date.new(2016, 11, 24) + ] + + config.time_zone = 'America/Los_Angeles' +end + +schedule_1 & schedule_2 +``` + +The resulting schedule will be a combination of the two schedules: an +intersection of the intervals, a union of the holidays, and the time zone of the +first schedule. + +For the above example, the resulting schedule would be equivalent to one with +the following configuration: + +```ruby +Biz::Schedule.new do |config| + config.hours = { + mon: {'09:00' => '10:00'}, + tue: {'11:00' => '15:00'}, + wed: {'16:00' => '17:00'}, + thu: {'11:00' => '12:00', '13:00' => '14:00'} + } + + config.holidays = [ + Date.new(2016, 1, 1), + Date.new(2016, 7, 4), + Date.new(2016, 11, 24), + Date.new(2016, 12, 25) + ] + + config.time_zone = 'Etc/UTC' +end +``` ## Core Extensions diff --git a/lib/biz/interval.rb b/lib/biz/interval.rb index aea3bc6..ceb8024 100644 --- a/lib/biz/interval.rb +++ b/lib/biz/interval.rb @@ -1,18 +1,34 @@ module Biz class Interval + extend Forwardable + include Comparable + def self.to_hours(intervals) + intervals.each_with_object( + Hash.new do |hours, wday| hours.store(wday, {}) end + ) do |interval, hours| + hours[interval.wday_symbol].store(*interval.endpoints.map(&:timestamp)) + end + end + def initialize(start_time, end_time, time_zone) @start_time = start_time @end_time = end_time @time_zone = time_zone end + delegate wday_symbol: :start_time + def endpoints [start_time, end_time] end + def empty? + start_time >= end_time + end + def contains?(time) (start_time...end_time).cover?( WeekTime.from_time(Time.new(time_zone).local(time)) @@ -27,6 +43,13 @@ def to_time_segment(week) ) end + def &(other) + lower_bound = [self, other].map(&:start_time).max + upper_bound = [self, other].map(&:end_time).min + + self.class.new(lower_bound, [lower_bound, upper_bound].max, time_zone) + end + def <=>(other) return unless other.is_a?(self.class) diff --git a/lib/biz/schedule.rb b/lib/biz/schedule.rb index 9d371d8..9781f15 100644 --- a/lib/biz/schedule.rb +++ b/lib/biz/schedule.rb @@ -46,6 +46,23 @@ def in_zone Time.new(time_zone) end + def &(other) + self.class.new do |config| + config.hours = Interval.to_hours( + intervals.flat_map { |interval| + other + .intervals + .map { |other_interval| interval & other_interval } + .reject(&:empty?) + } + ) + + config.holidays = [*holidays, *other.holidays].map(&:to_date) + + config.time_zone = time_zone.name + end + end + protected attr_reader :configuration diff --git a/spec/interval_spec.rb b/spec/interval_spec.rb index 02f716a..6cfae20 100644 --- a/spec/interval_spec.rb +++ b/spec/interval_spec.rb @@ -11,6 +11,91 @@ ) } + describe '.to_hours' do + let(:intervals) { + [ + described_class.new( + Biz::WeekTime.start(week_minute(wday: 1, hour: 9)), + Biz::WeekTime.end(week_minute(wday: 1, hour: 17)), + time_zone + ), + described_class.new( + Biz::WeekTime.start(week_minute(wday: 2, hour: 9)), + Biz::WeekTime.end(week_minute(wday: 2, hour: 12)), + time_zone + ), + described_class.new( + Biz::WeekTime.start(week_minute(wday: 2, hour: 13)), + Biz::WeekTime.end(week_minute(wday: 2, hour: 17)), + time_zone + ), + described_class.new( + Biz::WeekTime.start(week_minute(wday: 4, hour: 10)), + Biz::WeekTime.end(week_minute(wday: 4, hour: 16)), + time_zone + ), + described_class.new( + Biz::WeekTime.start(week_minute(wday: 5, hour: 9)), + Biz::WeekTime.end(week_minute(wday: 5, hour: 17)), + time_zone + ) + ] + } + + it 'returns a configuration hash for the provided intervals' do + expect(described_class.to_hours(intervals)).to eq( + mon: {'09:00' => '17:00'}, + tue: {'09:00' => '12:00', '13:00' => '17:00'}, + thu: {'10:00' => '16:00'}, + fri: {'09:00' => '17:00'} + ) + end + end + + describe '#wday_symbol' do + let(:start_time) { week_minute(wday: 1, hour: 12) } + let(:end_time) { week_minute(wday: 2, hour: 12) } + + it 'returns the symbol for the day containing the start time' do + expect(interval.wday_symbol).to eq :mon + end + end + + describe '#endpoints' do + it 'returns the interval endpoints' do + expect(interval.endpoints).to eq [ + Biz::WeekTime.start(start_time), + Biz::WeekTime.end(end_time) + ] + end + end + + describe '#empty?' do + context 'when the start time is before the end time' do + let(:start_time) { end_time.pred } + + it 'returns false' do + expect(interval.empty?).to eq false + end + end + + context 'when the start time is equal to the end time' do + let(:start_time) { end_time } + + it 'returns true' do + expect(interval.empty?).to eq true + end + end + + context 'when the start time is after the end time' do + let(:start_time) { end_time.succ } + + it 'returns true' do + expect(interval.empty?).to eq true + end + end + end + describe '#contains?' do context 'when the time is before the interval' do let(:time) { in_zone('America/New_York') { Time.utc(2006, 1, 2, 11) } } @@ -53,15 +138,6 @@ end end - describe '#endpoints' do - it 'returns the interval endpoints' do - expect(interval.endpoints).to eq [ - Biz::WeekTime.start(start_time), - Biz::WeekTime.end(end_time) - ] - end - end - describe '#to_time_segment' do let(:week) { Biz::Week.new(4) } @@ -103,6 +179,110 @@ end end + describe '#&' do + let(:other) { + described_class.new( + Biz::WeekTime.start(other_start_time), + Biz::WeekTime.end(other_end_time), + time_zone + ) + } + + context 'when the other interval occurs before the interval' do + let(:other_start_time) { week_minute(wday: 0, hour: 9) } + let(:other_end_time) { week_minute(wday: 0, hour: 17) } + + it 'returns a zero-duration interval' do + expect(interval & other).to eq( + described_class.new( + Biz::WeekTime.start(start_time), + Biz::WeekTime.end(start_time), + time_zone + ) + ) + end + end + + context 'when the other interval starts before the interval' do + let(:other_start_time) { week_minute(wday: 1, hour: 8) } + + context 'and ends before the interval' do + let(:other_end_time) { week_minute(wday: 1, hour: 12) } + + it 'returns the correct interval' do + expect(interval & other).to eq( + described_class.new( + Biz::WeekTime.start(start_time), + Biz::WeekTime.end(other_end_time), + time_zone + ) + ) + end + end + + context 'and ends after the interval' do + let(:other_end_time) { week_minute(wday: 1, hour: 18) } + + it 'returns the correct interval' do + expect(interval & other).to eq( + described_class.new( + Biz::WeekTime.start(start_time), + Biz::WeekTime.end(end_time), + time_zone + ) + ) + end + end + end + + context 'when the other interval starts after the interval' do + let(:other_start_time) { week_minute(wday: 1, hour: 12) } + + context 'and ends before the interval' do + let(:other_end_time) { week_minute(wday: 1, hour: 15) } + + it 'returns the correct interval' do + expect(interval & other).to eq( + described_class.new( + Biz::WeekTime.start(other_start_time), + Biz::WeekTime.end(other_end_time), + time_zone + ) + ) + end + end + + context 'and ends after the interval' do + let(:other_end_time) { week_minute(wday: 1, hour: 18) } + + it 'returns the correct interval' do + expect(interval & other).to eq( + described_class.new( + Biz::WeekTime.start(other_start_time), + Biz::WeekTime.end(end_time), + time_zone + ) + ) + end + end + end + + context 'when the other interval occurs after the interval' do + let(:other_start_time) { week_minute(wday: 2, hour: 9) } + let(:other_end_time) { week_minute(wday: 2, hour: 17) } + + it 'returns a zero-duration interval' do + expect(interval & other).to eq( + described_class.new( + Biz::WeekTime.start(other_start_time), + Biz::WeekTime.end(other_start_time), + time_zone + ) + ) + end + end + end + context 'when performing comparison' do context 'and the compared object has an earlier start time' do let(:other) { diff --git a/spec/schedule_spec.rb b/spec/schedule_spec.rb index fc4bd0d..c7bbf01 100644 --- a/spec/schedule_spec.rb +++ b/spec/schedule_spec.rb @@ -141,4 +141,52 @@ ) end end + + describe '#&' do + let(:other) { + described_class.new do |config| + config.hours = { + sun: {'10:00' => '12:00'}, + mon: {'08:00' => '10:00'}, + tue: {'11:00' => '15:00'}, + wed: {'16:00' => '18:00'}, + thu: {'11:00' => '12:00', '13:00' => '14:00'} + } + + config.holidays = [ + Date.new(2006, 1, 1), + Date.new(2006, 7, 4), + Date.new(2006, 11, 24) + ] + + config.time_zone = 'America/Los_Angeles' + end + } + + it 'returns a new schedule' do + expect(schedule & other).to be_a Biz::Schedule + end + + it 'configures the schedule with the intersection of intervals' do + expect(Biz::Interval.to_hours((schedule & other).intervals)).to eq( + mon: {'09:00' => '10:00'}, + tue: {'11:00' => '15:00'}, + wed: {'16:00' => '17:00'}, + thu: {'11:00' => '12:00', '13:00' => '14:00'} + ) + end + + it 'configures the schedule with the union of holidays' do + expect((schedule & other).holidays.map(&:to_date)).to eq [ + Date.new(2006, 1, 1), + Date.new(2006, 7, 4), + Date.new(2006, 11, 24), + Date.new(2006, 12, 25) + ] + end + + it 'configures the schedule with the original time zone' do + expect((schedule & other).time_zone).to eq schedule.time_zone + end + end end