Skip to content

Commit

Permalink
Add ability to intersect schedules
Browse files Browse the repository at this point in the history
An intersection of two schedules consists of:
  * an intersection of intervals
  * a union of holidays
  * the time zone of the first schedule

See the `README` for an example.

Closes #50.
  • Loading branch information
craiglittle committed Mar 30, 2016
1 parent 572be26 commit b3ca7bb
Show file tree
Hide file tree
Showing 5 changed files with 345 additions and 9 deletions.
68 changes: 68 additions & 0 deletions 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 @@ -119,6 +120,73 @@ Biz.periods
# #<Biz::TimeSegment start_time=2015-04-28 07:00:00 UTC end_time=2015-04-29 07:00:00 UTC>,
# #<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

Expand Down
23 changes: 23 additions & 0 deletions lib/biz/interval.rb
Original file line number Diff line number Diff line change
@@ -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))
Expand All @@ -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)

Expand Down
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
198 changes: 189 additions & 9 deletions spec/interval_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) } }
Expand Down Expand Up @@ -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) }

Expand Down Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit b3ca7bb

Please sign in to comment.