Skip to content

Commit

Permalink
Merge pull request #51 from zendesk/craig/schedule-intersection
Browse files Browse the repository at this point in the history
Add ability to intersect schedules
  • Loading branch information
craiglittle committed Mar 30, 2016
2 parents c78dd84 + 24b9f12 commit b659d17
Show file tree
Hide file tree
Showing 10 changed files with 431 additions and 53 deletions.
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,7 +51,7 @@ Biz.configure do |config|
sat: {'10:00' => '14:00'}
}

config.holidays = [Date.new(2014, 1, 1), Date.new(2014, 12, 25)]
config.holidays = [Date.new(2016, 1, 1), Date.new(2016, 12, 25)]

config.time_zone = 'America/Los_Angeles'
end
Expand Down Expand Up @@ -120,6 +121,74 @@ Biz.periods
# #<Biz::TimeSegment start_time=2015-04-27 20:36:57 UTC end_time=2015-04-28 00:00:00 UTC>]
```

## 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

Optional extensions to core classes (`Date`, `Fixnum`, and `Time`) are available
Expand Down
1 change: 0 additions & 1 deletion lib/biz.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
require 'date'
require 'delegate'
require 'forwardable'
require 'set'

require 'clavius'
require 'tzinfo'
Expand Down
11 changes: 9 additions & 2 deletions lib/biz/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ def initialize
yield raw if block_given?

Validation.perform(raw)

raw.freeze
end

def intervals
Expand All @@ -21,7 +23,12 @@ def intervals

def holidays
@holidays ||= begin
raw.holidays.map { |date| Holiday.new(date, time_zone) }.freeze
raw
.holidays
.uniq
.map { |date| Holiday.new(date, time_zone) }
.sort
.freeze
end
end

Expand All @@ -30,7 +37,7 @@ def time_zone
end

def weekdays
@weekdays ||= raw.hours.keys.to_set.freeze
@weekdays ||= raw.hours.keys.uniq.freeze
end

protected
Expand Down
33 changes: 27 additions & 6 deletions lib/biz/interval.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,38 @@
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

attr_reader :start_time,
:end_time,
:time_zone

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))
Expand All @@ -27,18 +47,19 @@ 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)

[start_time, end_time, time_zone] <=>
[other.start_time, other.end_time, other.time_zone]
end

protected

attr_reader :start_time,
:end_time,
:time_zone

end
end
17 changes: 17 additions & 0 deletions lib/biz/schedule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 4 additions & 14 deletions lib/biz/time_segment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ def contains?(time)
end

def &(other)
self.class.new(
lower_bound(other),
[lower_bound(other), upper_bound(other)].max
)
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)
end

def <=>(other)
Expand All @@ -48,15 +48,5 @@ def <=>(other)
[start_time, end_time] <=> [other.start_time, other.end_time]
end

private

def lower_bound(other)
[self, other].map(&:start_time).max
end

def upper_bound(other)
[self, other].map(&:end_time).min
end

end
end
51 changes: 36 additions & 15 deletions spec/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
end

describe '#intervals' do
context 'when left unconfigured' do
context 'when unconfigured' do
subject(:configuration) {
Biz::Configuration.new do |config|
config.holidays = holidays
Expand Down Expand Up @@ -126,35 +126,56 @@
end

describe '#holidays' do
context 'when left unconfigured' do
subject(:configuration) {
Biz::Configuration.new do |config|
config.hours = hours
config.time_zone = time_zone
end
}

it 'returns the default set of holidays' do
expect(configuration.holidays).to eq []
end
end
let(:holidays) {
[Date.new(2006, 12, 25), Date.new(2006, 1, 1), Date.new(2006, 7, 4)]
}

it 'returns the proper holidays' do
expect(configuration.holidays).to eq [
Biz::Holiday.new(
Date.new(2006, 1, 1),
TZInfo::Timezone.get('America/New_York')
),
Biz::Holiday.new(
Date.new(2006, 7, 4),
TZInfo::Timezone.get('America/New_York')
),
Biz::Holiday.new(
Date.new(2006, 12, 25),
TZInfo::Timezone.get('America/New_York')
)
]
end

context 'when unconfigured' do
subject(:configuration) {
Biz::Configuration.new do |config|
config.hours = hours
config.time_zone = time_zone
end
}

it 'returns the default set of holidays' do
expect(configuration.holidays).to eq []
end
end

context 'when configured with duplicate holidays' do
let(:holidays) { Array.new(3) { Date.new(2006, 1, 1) } }

it 'removes the duplicate dates' do
expect(configuration.holidays).to eq [
Biz::Holiday.new(
Date.new(2006, 1, 1),
TZInfo::Timezone.get('America/New_York')
)
]
end
end
end

describe '#time_zone' do
context 'when left unconfigured' do
context 'when unconfigured' do
subject(:configuration) {
Biz::Configuration.new do |config|
config.hours = hours
Expand All @@ -176,7 +197,7 @@

describe '#weekdays' do
it 'returns the active weekdays for the configured schedule' do
expect(configuration.weekdays).to eq Set.new(%i[mon tue wed thu fri sat])
expect(configuration.weekdays).to eq %i[mon tue wed thu fri sat]
end
end
end
Loading

0 comments on commit b659d17

Please sign in to comment.