biz 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 27809869df3ee991371e2258e892863d22b5ad2e
4
- data.tar.gz: 184a044cb47ccdb0437c5227da62bcbde65534aa
3
+ metadata.gz: e3d348e477f526c87601d6b59362fbb9e17b2dba
4
+ data.tar.gz: 83019f10ea87e74a41f317dd388dbe9d7ee204b6
5
5
  SHA512:
6
- metadata.gz: 5f9fef46fd079453c482963e9e89b8ef9368451275f4626567b923119bf97c68af7cda4d44f73dcae27cf7f458aeabb980a9d6130dcd5c093beade3c32b48df4
7
- data.tar.gz: f2f7dace5f2eb6b7baff6f6dc275afc7edb606e349ac7cddd7b32273ca3498434e137a1cb6b01ff3bc6d11a2b906baed602a81ac6eb003d1b2b3168b2da86c52
6
+ metadata.gz: c1c66c64493a052cc7d99aef3268cedb1691bbacde44f7cc731345a397e61627a7d589c789b071b7696add9585392ddbbe449cb61a185247e97a14bf6b8e055f
7
+ data.tar.gz: 523a4308dbda6e8e37d24d62474f70ed783b2d10ef65caabbb0047b0e21d3283d53d6b9678dff21ea46879968fd31d938aa74a272276c764b4dc6d2eb686421a
data/README.md CHANGED
@@ -11,14 +11,15 @@ Time calculations using business hours.
11
11
  ## Features
12
12
 
13
13
  * Support for:
14
- - Intervals spanning the entire day.
15
- - Interday intervals and holidays.
16
14
  - Multiple intervals per day.
17
15
  - Multiple schedule configurations.
18
- * Second-level precision on all calculations.
19
- * Accurate handling of Daylight Saving Time.
16
+ - Intervals spanning the entire day.
17
+ - Holidays.
18
+ - Breaks (time-segment holidays).
19
+ * Second-level calculation precision.
20
+ * Seamless Daylight Saving Time handling.
20
21
  * Schedule intersection.
21
- * Thread-safe.
22
+ * Thread safety.
22
23
 
23
24
  ## Anti-Features
24
25
 
@@ -51,12 +52,20 @@ Biz.configure do |config|
51
52
  sat: {'10:00' => '14:00'}
52
53
  }
53
54
 
55
+ config.breaks = {
56
+ Date.new(2006, 1, 2) => {'10:00' => '11:30'},
57
+ Date.new(2006, 1, 3) => {'14:15' => '14:30', '15:40' => '15:50'}
58
+ }
59
+
54
60
  config.holidays = [Date.new(2016, 1, 1), Date.new(2016, 12, 25)]
55
61
 
56
62
  config.time_zone = 'America/Los_Angeles'
57
63
  end
58
64
  ```
59
65
 
66
+ Periods occurring on holidays are disregarded. Similarly, any segment of a
67
+ period that overlaps with a break is treated as inactive.
68
+
60
69
  If global configuration isn't your thing, configure an instance instead:
61
70
 
62
71
  ```ruby
@@ -86,11 +95,19 @@ Biz.within(Time.utc(2015, 3, 7), Time.utc(2015, 3, 14)).in_seconds
86
95
  # Determine if a time is in business hours
87
96
  Biz.in_hours?(Time.utc(2015, 1, 10, 9))
88
97
 
89
- # Determine if a time is on a holiday
98
+ # Determine if a time is during a break
99
+ Biz.on_break?(Time.utc(2016, 6, 3))
100
+
101
+ # Determine if a time is during a holiday
90
102
  Biz.on_holiday?(Time.utc(2014, 1, 1))
91
103
  ```
92
104
 
93
- Note that all returned times are in UTC.
105
+ All returned times are in UTC.
106
+
107
+ If a schedule will be configured with a large number of holidays and performance
108
+ is a particular concern, it's recommended that holidays are filtered down to
109
+ those relevant to the calculation(s) at hand before configuration to improve
110
+ performance.
94
111
 
95
112
  By dropping down a level, you can get access to the underlying time segments,
96
113
  which you can use to do your own custom calculations or just get a better idea
@@ -136,6 +153,11 @@ schedule_1 = Biz::Schedule.new do |config|
136
153
  sat: {'11:00' => '14:30'}
137
154
  }
138
155
 
156
+ config.breaks = {
157
+ Date.new(2016, 6, 2) => {'09:00' => '10:30', '16:00' => '16:30'},
158
+ Date.new(2016, 6, 3) => {'12:15' => '12:45'}
159
+ }
160
+
139
161
  config.holidays = [Date.new(2016, 1, 1), Date.new(2016, 12, 25)]
140
162
 
141
163
  config.time_zone = 'Etc/UTC'
@@ -150,6 +172,11 @@ schedule_2 = Biz::Schedule.new do |config|
150
172
  thu: {'11:00' => '12:00', '13:00' => '14:00'}
151
173
  }
152
174
 
175
+ config.breaks = {
176
+ Date.new(2016, 6, 3) => {'13:30' => '14:00'},
177
+ Date.new(2016, 6, 4) => {'11:00' => '12:00'}
178
+ }
179
+
153
180
  config.holidays = [
154
181
  Date.new(2016, 1, 1),
155
182
  Date.new(2016, 7, 4),
@@ -163,8 +190,8 @@ schedule_1 & schedule_2
163
190
  ```
164
191
 
165
192
  The resulting schedule will be a combination of the two schedules: an
166
- intersection of the intervals, a union of the holidays, and the time zone of the
167
- first schedule.
193
+ intersection of the intervals, a union of the breaks and holidays, and the time
194
+ zone of the first schedule.
168
195
 
169
196
  For the above example, the resulting schedule would be equivalent to one with
170
197
  the following configuration:
@@ -178,6 +205,12 @@ Biz::Schedule.new do |config|
178
205
  thu: {'11:00' => '12:00', '13:00' => '14:00'}
179
206
  }
180
207
 
208
+ config.breaks = {
209
+ Date.new(2016, 6, 2) => {'09:00' => '10:30', '16:00' => '16:30'},
210
+ Date.new(2016, 6, 3) => {'12:15' => '12:45', '13:30' => '14:00'},
211
+ Date.new(2016, 6, 4) => {'11:00' => '12:00'}
212
+ }
213
+
181
214
  config.holidays = [
182
215
  Date.new(2016, 1, 1),
183
216
  Date.new(2016, 7, 4),
@@ -207,6 +240,8 @@ require 'biz/core_ext'
207
240
 
208
241
  Time.utc(2015, 8, 20, 9, 30).business_hours?
209
242
 
243
+ Time.utc(2016, 6, 3, 12).on_break?
244
+
210
245
  Time.utc(2014, 1, 1, 12).on_holiday?
211
246
 
212
247
  Date.new(2015, 12, 10).business_day?
data/lib/biz.rb CHANGED
@@ -25,6 +25,7 @@ module Biz
25
25
  within
26
26
  in_hours?
27
27
  business_hours?
28
+ on_break?
28
29
  on_holiday?
29
30
  ] => :schedule
30
31
 
@@ -6,4 +6,5 @@ end
6
6
  require 'biz/calculation/active'
7
7
  require 'biz/calculation/duration_within'
8
8
  require 'biz/calculation/for_duration'
9
+ require 'biz/calculation/on_break'
9
10
  require 'biz/calculation/on_holiday'
@@ -8,7 +8,8 @@ module Biz
8
8
  end
9
9
 
10
10
  def result
11
- schedule.intervals.any? { |interval| interval.contains?(time) } &&
11
+ schedule.intervals.any? { |interval| interval.contains?(time) } &&
12
+ schedule.breaks.none? { |break_| break_.contains?(time) } &&
12
13
  schedule.holidays.none? { |holiday| holiday.contains?(time) }
13
14
  end
14
15
 
@@ -0,0 +1,21 @@
1
+ module Biz
2
+ module Calculation
3
+ class OnBreak
4
+
5
+ def initialize(schedule, time)
6
+ @schedule = schedule
7
+ @time = time
8
+ end
9
+
10
+ def result
11
+ schedule.breaks.any? { |break_| break_.contains?(time) }
12
+ end
13
+
14
+ protected
15
+
16
+ attr_reader :schedule,
17
+ :time
18
+
19
+ end
20
+ end
21
+ end
@@ -21,6 +21,16 @@ module Biz
21
21
  end
22
22
  end
23
23
 
24
+ def breaks
25
+ @breaks ||= begin
26
+ raw
27
+ .breaks
28
+ .flat_map { |date, hours| break_periods(date, hours) }
29
+ .sort
30
+ .freeze
31
+ end
32
+ end
33
+
24
34
  def holidays
25
35
  @holidays ||= begin
26
36
  raw
@@ -41,12 +51,34 @@ module Biz
41
51
  @weekdays ||= raw.hours.keys.uniq.freeze
42
52
  end
43
53
 
54
+ def &(other)
55
+ self.class.new do |config|
56
+ config.hours = Interval.to_hours(intersected_intervals(other))
57
+ config.breaks = combined_breaks(other)
58
+ config.holidays = [*raw.holidays, *other.raw.holidays].map(&:to_date)
59
+ config.time_zone = raw.time_zone
60
+ end
61
+ end
62
+
44
63
  protected
45
64
 
46
65
  attr_reader :raw
47
66
 
48
67
  private
49
68
 
69
+ def to_proc
70
+ proc do |config|
71
+ config.hours = raw.hours
72
+ config.breaks = raw.breaks
73
+ config.holidays = raw.holidays
74
+ config.time_zone = raw.time_zone
75
+ end
76
+ end
77
+
78
+ def time
79
+ @time ||= Time.new(time_zone)
80
+ end
81
+
50
82
  def weekday_intervals(weekday, hours)
51
83
  hours.map { |start_timestamp, end_timestamp|
52
84
  Interval.new(
@@ -63,7 +95,33 @@ module Biz
63
95
  }
64
96
  end
65
97
 
66
- Raw = Struct.new(:hours, :holidays, :time_zone) do
98
+ def break_periods(date, hours)
99
+ hours.map { |start_timestamp, end_timestamp|
100
+ TimeSegment.new(
101
+ time.on_date(date, DayTime.from_timestamp(start_timestamp)),
102
+ time.on_date(date, DayTime.from_timestamp(end_timestamp))
103
+ )
104
+ }
105
+ end
106
+
107
+ def intersected_intervals(other)
108
+ intervals.flat_map { |interval|
109
+ other
110
+ .intervals
111
+ .map { |other_interval| interval & other_interval }
112
+ .reject(&:empty?)
113
+ }
114
+ end
115
+
116
+ def combined_breaks(other)
117
+ Hash.new do |config, date| config.store(date, {}) end.tap do |combined|
118
+ [raw.breaks, other.raw.breaks].each do |configured|
119
+ configured.each do |date, breaks| combined[date].merge!(breaks) end
120
+ end
121
+ end
122
+ end
123
+
124
+ Raw = Struct.new(:hours, :breaks, :holidays, :time_zone) do
67
125
  module Default
68
126
  HOURS = {
69
127
  mon: {'09:00' => '17:00'},
@@ -73,6 +131,7 @@ module Biz
73
131
  fri: {'09:00' => '17:00'}
74
132
  }.freeze
75
133
 
134
+ BREAKS = [].freeze
76
135
  HOLIDAYS = [].freeze
77
136
  TIME_ZONE = 'Etc/UTC'.freeze
78
137
  end
@@ -81,6 +140,7 @@ module Biz
81
140
  super
82
141
 
83
142
  self.hours ||= Default::HOURS
143
+ self.breaks ||= Default::BREAKS
84
144
  self.holidays ||= Default::HOLIDAYS
85
145
  self.time_zone ||= Default::TIME_ZONE
86
146
  end
@@ -5,6 +5,10 @@ module Biz
5
5
  Biz.in_hours?(self)
6
6
  end
7
7
 
8
+ def on_break?
9
+ Biz.on_break?(self)
10
+ end
11
+
8
12
  def on_holiday?
9
13
  Biz.on_holiday?(self)
10
14
  end
@@ -1,6 +1,8 @@
1
1
  module Biz
2
2
  class Holiday
3
3
 
4
+ extend Forwardable
5
+
4
6
  include Comparable
5
7
 
6
8
  def initialize(date, time_zone)
@@ -8,15 +10,15 @@ module Biz
8
10
  @time_zone = time_zone
9
11
  end
10
12
 
11
- def contains?(time)
12
- date == Time.new(time_zone).local(time).to_date
13
- end
13
+ delegate contains?: :to_time_segment
14
14
 
15
15
  def to_time_segment
16
- TimeSegment.new(
17
- Time.new(time_zone).on_date(date, DayTime.midnight),
18
- Time.new(time_zone).on_date(date, DayTime.endnight)
19
- )
16
+ @time_segment ||= begin
17
+ TimeSegment.new(
18
+ Time.new(time_zone).on_date(date, DayTime.midnight),
19
+ Time.new(time_zone).on_date(date, DayTime.endnight)
20
+ )
21
+ end
20
22
  end
21
23
 
22
24
  def <=>(other)
@@ -11,8 +11,6 @@ module Biz
11
11
  super(periods) do |yielder, period| yielder << period end
12
12
  end
13
13
 
14
- delegate time_zone: :schedule
15
-
16
14
  def timeline
17
15
  Timeline::Proxy.new(self)
18
16
  end
@@ -28,21 +26,27 @@ module Biz
28
26
  weeks
29
27
  .lazy
30
28
  .flat_map { |week| business_periods(week) }
31
- .select { |business_period| relevant?(business_period) }
32
- .map { |business_period| business_period & boundary }
33
- .reject { |business_period|
34
- schedule.holidays.any? { |holiday|
35
- holiday.contains?(business_period.start_time)
36
- }
37
- }
29
+ .select { |period| relevant?(period) }
30
+ .map { |period| period & boundary }
31
+ .flat_map { |period| active_periods(period) }
32
+ .reject { |period| on_holiday?(period) }
33
+ .reject(&:empty?)
38
34
  end
39
35
 
40
36
  def business_periods(week)
41
37
  intervals.lazy.map { |interval| interval.to_time_segment(week) }
42
38
  end
43
39
 
44
- def intervals
45
- schedule.intervals
40
+ def active_periods(period)
41
+ schedule.breaks.reduce([period]) { |periods, break_period|
42
+ periods.flat_map { |active_period| active_period / break_period }
43
+ }
44
+ end
45
+
46
+ def on_holiday?(period)
47
+ schedule.holidays.any? { |holiday|
48
+ holiday.contains?(period.start_time)
49
+ }
46
50
  end
47
51
 
48
52
  end
@@ -10,7 +10,7 @@ module Biz
10
10
 
11
11
  def weeks
12
12
  Range.new(
13
- Week.since_epoch(Time.new(time_zone).local(origin)),
13
+ Week.since_epoch(schedule.in_zone.local(origin)),
14
14
  Week.since_epoch(Time.heat_death)
15
15
  )
16
16
  end
@@ -20,7 +20,11 @@ module Biz
20
20
  end
21
21
 
22
22
  def boundary
23
- TimeSegment.after(origin)
23
+ @boundary ||= TimeSegment.after(origin)
24
+ end
25
+
26
+ def intervals
27
+ @intervals ||= schedule.intervals
24
28
  end
25
29
 
26
30
  end
@@ -10,7 +10,7 @@ module Biz
10
10
 
11
11
  def weeks
12
12
  Week
13
- .since_epoch(Time.new(time_zone).local(origin))
13
+ .since_epoch(schedule.in_zone.local(origin))
14
14
  .downto(Week.since_epoch(Time.big_bang))
15
15
  end
16
16
 
@@ -18,12 +18,16 @@ module Biz
18
18
  origin > period.start_time
19
19
  end
20
20
 
21
+ def active_periods(*)
22
+ super.reverse
23
+ end
24
+
21
25
  def boundary
22
- TimeSegment.before(origin)
26
+ @boundary ||= TimeSegment.before(origin)
23
27
  end
24
28
 
25
29
  def intervals
26
- super.reverse
30
+ @intervals ||= schedule.intervals.reverse
27
31
  end
28
32
 
29
33
  end
@@ -9,6 +9,7 @@ module Biz
9
9
 
10
10
  delegate %i[
11
11
  intervals
12
+ breaks
12
13
  holidays
13
14
  time_zone
14
15
  weekdays
@@ -36,38 +37,27 @@ module Biz
36
37
  Calculation::Active.new(self, time).result
37
38
  end
38
39
 
40
+ alias business_hours? in_hours?
41
+
42
+ def on_break?(time)
43
+ Calculation::OnBreak.new(self, time).result
44
+ end
45
+
39
46
  def on_holiday?(time)
40
47
  Calculation::OnHoliday.new(self, time).result
41
48
  end
42
49
 
43
- alias business_hours? in_hours?
44
-
45
50
  def in_zone
46
51
  Time.new(time_zone)
47
52
  end
48
53
 
49
54
  def &(other)
50
- self.class.new do |config|
51
- config.hours = Interval.to_hours(intersected_intervals(other))
52
- config.holidays = [*holidays, *other.holidays].map(&:to_date)
53
- config.time_zone = time_zone.name
54
- end
55
+ self.class.new(&(configuration & other.configuration))
55
56
  end
56
57
 
57
58
  protected
58
59
 
59
60
  attr_reader :configuration
60
61
 
61
- private
62
-
63
- def intersected_intervals(other)
64
- intervals.flat_map { |interval|
65
- other
66
- .intervals
67
- .map { |other_interval| interval & other_interval }
68
- .reject(&:empty?)
69
- }
70
- end
71
-
72
62
  end
73
63
  end
@@ -32,7 +32,7 @@ module Biz
32
32
  end
33
33
 
34
34
  def contains?(time)
35
- (start_time..end_time).cover?(time)
35
+ (start_time...end_time).cover?(time)
36
36
  end
37
37
 
38
38
  def &(other)
@@ -42,6 +42,13 @@ module Biz
42
42
  self.class.new(lower_bound, [lower_bound, upper_bound].max)
43
43
  end
44
44
 
45
+ def /(other)
46
+ [
47
+ self.class.new(start_time, other.start_time),
48
+ self.class.new(other.end_time, end_time)
49
+ ].reject(&:empty?).map { |potential| self & potential }
50
+ end
51
+
45
52
  def <=>(other)
46
53
  return unless other.is_a?(self.class)
47
54
 
@@ -1,3 +1,3 @@
1
1
  module Biz
2
- VERSION = '1.5.2'.freeze
2
+ VERSION = '1.6.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: biz
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.2
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Craig Little
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2016-04-12 00:00:00.000000000 Z
12
+ date: 2016-06-13 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: clavius
@@ -81,6 +81,7 @@ files:
81
81
  - lib/biz/calculation/active.rb
82
82
  - lib/biz/calculation/duration_within.rb
83
83
  - lib/biz/calculation/for_duration.rb
84
+ - lib/biz/calculation/on_break.rb
84
85
  - lib/biz/calculation/on_holiday.rb
85
86
  - lib/biz/configuration.rb
86
87
  - lib/biz/core_ext.rb