Skip to content

Commit ac03945

Browse files
committed
Add TimeCalc#iterate
1 parent 0945039 commit ac03945

File tree

6 files changed

+135
-10
lines changed

6 files changed

+135
-10
lines changed

lib/time_calc.rb

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,37 @@ def ==(other)
190190
# @param unit [Symbol]
191191
# @return [Date, Time, DateTime] value of the same type that was initial wrapped value.
192192

193+
# @!method iterate(span, unit)
194+
# Like {#+}, but allows conditional skipping of some periods. Increases value by `unit`
195+
# at least `span` times, on each iteration checking with block provided if this point
196+
# matches desired period; if it is not, it is skipped without increasing iterations
197+
# counter. Useful for "business date/time" algorithms.
198+
#
199+
# @example
200+
# # add 10 working days.
201+
# TimeCalc.(Time.parse('2019-07-03 23:28:54')).iterate(10, :days) { |t| (1..5).cover?(t.wday) }
202+
# # => 2019-07-17 23:28:54 +0300
203+
#
204+
# # add 12 working hours
205+
# TimeCalc.(Time.parse('2019-07-03 13:28:54')).iterate(12, :hours) { |t| (9...18).cover?(t.hour) }
206+
# # => 2019-07-04 16:28:54 +0300
207+
#
208+
# # negative spans are working, too:
209+
# TimeCalc.(Time.parse('2019-07-03 13:28:54')).iterate(-12, :hours) { |t| (9...18).cover?(t.hour) }
210+
# # => 2019-07-02 10:28:54 +0300
211+
#
212+
# # zero span could be used to robustly change enforce value into acceptable range
213+
# # (increasing forward till block is true):
214+
# TimeCalc.(Time.parse('2019-07-03 23:28:54')).iterate(0, :hours) { |t| (9...18).cover?(t.hour) }
215+
# # => 2019-07-04 09:28:54 +0300
216+
#
217+
# @param span [Integer] Could be positive or negative
218+
# @param unit [Symbol]
219+
# @return [Date, Time, DateTime] value of the same type that was initial wrapped value.
220+
# @yield [Time/Date/DateTime] Object of wrapped class
221+
# @yieldreturn [true, false] If this point in time is "suitable". If the falsey value is returned,
222+
# iteration is skipped without increasing the counter.
223+
193224
# @!method -(span_or_other, unit=nil)
194225
# @overload -(span, unit)
195226
# Subtracts `span units` from wrapped value.
@@ -238,25 +269,28 @@ def ==(other)
238269
# @return [Sequence]
239270

240271
# @private
241-
MATH_OPERATIONS = %i[merge truncate floor ceil round + -].freeze
272+
MATH_OPERATIONS = %i[merge truncate floor ceil round + - iterate].freeze
242273
# @private
243274
OPERATIONS = MATH_OPERATIONS.+(%i[to step for]).freeze
244275

245276
OPERATIONS.each do |name|
246-
define_method(name) { |*args|
247-
@value.public_send(name, *args).then { |res| res.is_a?(Value) ? res.unwrap : res }
277+
define_method(name) { |*args, &block|
278+
@value.public_send(name, *args, &block).then { |res| res.is_a?(Value) ? res.unwrap : res }
248279
}
249280
end
250281

251282
class << self
252283
MATH_OPERATIONS.each do |name|
253-
define_method(name) { |*args| Op.new([[name, *args]]) }
284+
define_method(name) { |*args, &block| Op.new([[name, args, block]]) }
254285
end
255286

256287
# @!parse
257288
# # Creates operation to perform {#+}`(span, unit)`
258289
# # @return [Op]
259290
# def TimeCalc.+(span, unit); end
291+
# # Creates operation to perform {#iterate}`(span, unit, &block)`
292+
# # @return [Op]
293+
# def TimeCalc.iterate(span, unit, &block); end
260294
# # Creates operation to perform {#-}`(span, unit)`
261295
# # @return [Op]
262296
# def TimeCalc.-(span, unit); end

lib/time_calc/op.rb

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,21 @@ def initialize(chain = [])
2222

2323
# @private
2424
def inspect
25-
'<%s %s>' % [self.class, @chain.map { |name, *args| "#{name}(#{args.join(' ')})" }.join('.')]
25+
'<%s %s>' % [self.class, @chain.map { |name, args, _| "#{name}(#{args.join(' ')})" }.join('.')]
2626
end
2727

2828
TimeCalc::MATH_OPERATIONS.each do |name|
29-
define_method(name) { |*args| Op.new([*@chain, [name, *args]]) }
29+
define_method(name) { |*args, &block| Op.new([*@chain, [name, args, block]]) }
3030
end
3131

3232
# @!method +(span, unit)
3333
# Adds `+(span, unit)` to method chain
3434
# @see TimeCalc#+
3535
# @return [Op]
36+
# @!method iterate(span, unit, &block)
37+
# Adds `iterate(span, unit, &block)` to method chain
38+
# @see TimeCalc#iterate
39+
# @return [Op]
3640
# @!method -(span, unit)
3741
# Adds `-(span, unit)` to method chain
3842
# @see TimeCalc#-
@@ -55,8 +59,8 @@ def inspect
5559
# @param date_or_time [Date, Time, DateTime]
5660
# @return [Date, Time, DateTime] Type of the result is always the same as type of the parameter
5761
def call(date_or_time)
58-
@chain.reduce(Value.new(date_or_time)) { |val, (name, *args)|
59-
val.public_send(name, *args)
62+
@chain.reduce(Value.new(date_or_time)) { |val, (name, args, block)|
63+
val.public_send(name, *args, &block)
6064
}.unwrap
6165
end
6266

lib/time_calc/value.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@
77
require 'backports/2.5.0/hash/slice'
88
require 'backports/2.5.0/enumerable/all'
99

10+
# @private
11+
# TODO: It is included in Ruby 2.7. Replace with backports when it will be there.
12+
class Enumerator
13+
NOVALUE = Object.new.freeze
14+
15+
def self.produce(initial = NOVALUE)
16+
fail ArgumentError, 'No block given' unless block_given?
17+
18+
Enumerator.new do |y|
19+
val = initial == NOVALUE ? yield() : initial
20+
21+
loop do
22+
y << val
23+
val = yield(val)
24+
end
25+
end
26+
end
27+
end
28+
1029
class TimeCalc
1130
# Wrapper (one can say "monad") around date/time value, allowing to perform several TimeCalc
1231
# operations in a chain.
@@ -163,6 +182,28 @@ def -(span_or_other, unit = nil)
163182
unit.nil? ? Diff.new(self, span_or_other) : self.+(-span_or_other, unit)
164183
end
165184

185+
# Like {#+}, but allows conditional skipping of some periods. Increases value by `unit`
186+
# at least `span` times, on each iteration checking with block provided if this point
187+
# matches desired period; if it is not, it is skipped without increasing iterations
188+
# counter. Useful for "business date/time" algorithms.
189+
#
190+
# See {TimeCalc#iterate} for examples.
191+
#
192+
# @param span [Integer]
193+
# @param unit [Symbol]
194+
# @return [Value]
195+
# @yield [Time/Date/DateTime] Object of wrapped class
196+
# @yieldreturn [true, false] If this point in time is "suitable". If the falsey value is returned,
197+
# iteration is skipped without increasing the counter.
198+
def iterate(span, unit)
199+
block_given? or fail ArgumentError, 'No block given'
200+
Integer === span or fail ArgumentError, 'Only integer spans are supported' # rubocop:disable Style/CaseEquality
201+
202+
Enumerator.produce(self) { |v| v.+((span <=> 0).nonzero? || 1, unit) }
203+
.lazy.select { |v| yield(v.internal) }
204+
.drop(span.abs).first
205+
end
206+
166207
# Produces {Sequence} from this value to `date_or_time`
167208
#
168209
# @param date_or_time [Date, Time, DateTime]

lib/time_calc/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22

33
class TimeCalc
44
# @private
5-
VERSION = '0.0.2'
5+
VERSION = '0.0.3'
66
end

spec/time_calc/value_spec.rb

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,52 @@ def obj.to_time
132132
}
133133
end
134134

135+
describe '#iterate' do
136+
context 'without additional conditions' do
137+
subject { value.iterate(10, :days) }
138+
139+
its_block { is_expected.to raise_error ArgumentError, 'No block given' }
140+
end
141+
142+
context 'without trivial condition' do
143+
subject { value.iterate(10, :days) { true } }
144+
145+
it { is_expected.to eq value.+(10, :days) }
146+
end
147+
148+
context 'with condition' do
149+
subject { value.iterate(10, :days) { |t| (1..5).cover?(t.wday) } }
150+
151+
it { is_expected.to eq value.+(14, :days) }
152+
end
153+
154+
context 'with negative span' do
155+
subject { value.iterate(-10, :days) { true } }
156+
157+
it { is_expected.to eq value.-(10, :days) }
158+
end
159+
160+
context 'with zero span' do
161+
context 'when no changes necessary' do
162+
subject { value.iterate(0, :days) { true } }
163+
164+
it { is_expected.to eq value }
165+
end
166+
167+
context 'when changes necessary' do
168+
subject { value.iterate(0, :days) { |t| t.day < 20 } }
169+
170+
it { is_expected.to eq vt('2019-07-01 14:28:48.123 +03') }
171+
end
172+
end
173+
174+
context 'with non-integer span' do
175+
subject { value.iterate(10.5, :days) { true } }
176+
177+
its_block { is_expected.to raise_error ArgumentError }
178+
end
179+
end
180+
135181
if RUBY_VERSION >= '2.6'
136182
require 'tzinfo'
137183
context 'with real time zones' do

time_calc.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Gem::Specification.new do |s|
3434

3535
s.add_development_dependency 'rspec', '>= 3.8'
3636
s.add_development_dependency 'rspec-its', '~> 1'
37-
s.add_development_dependency 'saharspec'
37+
s.add_development_dependency 'saharspec', '>= 0.0.6'
3838
s.add_development_dependency 'simplecov', '~> 0.9'
3939
s.add_development_dependency 'tzinfo'
4040

0 commit comments

Comments
 (0)