time_calc 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TimeCalc
4
+ # Represents difference between two time-or-date values.
5
+ #
6
+ # Typically created with just
7
+ #
8
+ # ```ruby
9
+ # TimeCalc.(t1) - t2
10
+ # ```
11
+ #
12
+ # Allows to easily and correctly calculate number of years/monthes/days/etc between two points in
13
+ # time.
14
+ #
15
+ # @example
16
+ # t1 = Time.parse('2019-06-01 14:50')
17
+ # t2 = Time.parse('2019-06-15 12:10')
18
+ # (TimeCalc.(t2) - t1).div(:day)
19
+ # # => 13
20
+ # # the same:
21
+ # (TimeCalc.(t2) - t1).days
22
+ # # => 13
23
+ # (TimeCalc.(t2) - t1).div(3, :hours)
24
+ # # => 111
25
+ #
26
+ # (TimeCalc.(t2) - t1).factorize
27
+ # # => {:year=>0, :month=>0, :week=>1, :day=>6, :hour=>21, :min=>20, :sec=>0}
28
+ # (TimeCalc.(t2) - t1).factorize(weeks: false)
29
+ # # => {:year=>0, :month=>0, :day=>13, :hour=>21, :min=>20, :sec=>0}
30
+ # (TimeCalc.(t2) - t1).factorize(weeks: false, zeroes: false)
31
+ # # => {:day=>13, :hour=>21, :min=>20, :sec=>0}
32
+ #
33
+ class Diff
34
+ # @private
35
+ attr_reader :from, :to
36
+
37
+ # @note
38
+ # Typically you should prefer {TimeCalc#-} to create Diff.
39
+ #
40
+ # @param from [Time,Date,DateTime]
41
+ # @param to [Time,Date,DateTime]
42
+ def initialize(from, to)
43
+ @from, @to = coerce(try_unwrap(from), try_unwrap(to)).map(&Value.method(:wrap))
44
+ end
45
+
46
+ # @private
47
+ def inspect
48
+ '#<%s(%s − %s)>' % [self.class, from.unwrap, to.unwrap]
49
+ end
50
+
51
+ # "Negates" the diff by swapping its operands.
52
+ # @return [Diff]
53
+ def -@
54
+ Diff.new(to, from)
55
+ end
56
+
57
+ # Combination of {#div} and {#modulo} in one operation.
58
+ #
59
+ # @overload divmod(span, unit)
60
+ # @param span [Integer]
61
+ # @param unit [Symbol] Any of supported units (see {TimeCalc})
62
+ #
63
+ # @overload divmod(unit)
64
+ # Shortcut for `divmod(1, unit)`
65
+ # @param unit [Symbol] Any of supported units (see {TimeCalc})
66
+ #
67
+ # @return [(Integer, Time or Date or DateTime)]
68
+ def divmod(span, unit = nil)
69
+ span, unit = 1, span if unit.nil?
70
+ div(span, unit).then { |res| [res, to.+(res * span, unit).unwrap] }
71
+ end
72
+
73
+ # @example
74
+ # t1 = Time.parse('2019-06-01 14:50')
75
+ # t2 = Time.parse('2019-06-15 12:10')
76
+ # (TimeCalc.(t2) - t1).div(:day)
77
+ # # => 13
78
+ # (TimeCalc.(t2) - t1).div(3, :hours)
79
+ # # => 111
80
+ #
81
+ # @overload div(span, unit)
82
+ # @param span [Integer]
83
+ # @param unit [Symbol] Any of supported units (see {TimeCalc})
84
+ #
85
+ # @overload div(unit)
86
+ # Shortcut for `div(1, unit)`. Also can called as just `.<units>` methods (like {#years})
87
+ # @param unit [Symbol] Any of supported units (see {TimeCalc})
88
+ #
89
+ # @return [Integer] Number of whole `<unit>`s between `Diff`'s operands.
90
+ def div(span, unit = nil)
91
+ return -(-self).div(span, unit) if negative?
92
+
93
+ span, unit = 1, span if unit.nil?
94
+ unit = Units.(unit)
95
+ singular_div(unit).div(span)
96
+ end
97
+
98
+ # @!method years
99
+ # Whole years in diff.
100
+ # @return [Integer]
101
+ # @!method months
102
+ # Whole months in diff.
103
+ # @return [Integer]
104
+ # @!method weeks
105
+ # Whole weeks in diff.
106
+ # @return [Integer]
107
+ # @!method days
108
+ # Whole days in diff.
109
+ # @return [Integer]
110
+ # @!method hours
111
+ # Whole hours in diff.
112
+ # @return [Integer]
113
+ # @!method minutes
114
+ # Whole minutes in diff.
115
+ # @return [Integer]
116
+ # @!method seconds
117
+ # Whole seconds in diff.
118
+ # @return [Integer]
119
+
120
+ # Same as integer modulo: the "rest" of whole division of the distance between two time points by
121
+ # `<span> <units>`. This rest will be also time point, equal to `first diff operand - span units`
122
+ #
123
+ # @overload modulo(span, unit)
124
+ # @param span [Integer]
125
+ # @param unit [Symbol] Any of supported units (see {TimeCalc})
126
+ #
127
+ # @overload modulo(unit)
128
+ # Shortcut for `modulo(1, unit)`.
129
+ # @param unit [Symbol] Any of supported units (see {TimeCalc})
130
+ #
131
+ # @return [Time, Date or DateTime] Value is always the same type as first diff operand
132
+ def modulo(span, unit = nil)
133
+ divmod(span, unit).last
134
+ end
135
+
136
+ alias / div
137
+ alias % modulo
138
+
139
+ # "Factorizes" the distance between two points in time into units: years, months, weeks, days.
140
+ #
141
+ # @example
142
+ # t1 = Time.parse('2019-06-01 14:50')
143
+ # t2 = Time.parse('2019-06-15 12:10')
144
+ # (TimeCalc.(t2) - t1).factorize
145
+ # # => {:year=>0, :month=>0, :week=>1, :day=>6, :hour=>21, :min=>20, :sec=>0}
146
+ # (TimeCalc.(t2) - t1).factorize(weeks: false)
147
+ # # => {:year=>0, :month=>0, :day=>13, :hour=>21, :min=>20, :sec=>0}
148
+ # (TimeCalc.(t2) - t1).factorize(weeks: false, zeroes: false)
149
+ # # => {:day=>13, :hour=>21, :min=>20, :sec=>0}
150
+ # (TimeCalc.(t2) - t1).factorize(max: :hour)
151
+ # # => {:hour=>333, :min=>20, :sec=>0}
152
+ # (TimeCalc.(t2) - t1).factorize(max: :hour, min: :min)
153
+ # # => {:hour=>333, :min=>20}
154
+ #
155
+ # @param zeroes [true, false] Include big units (for ex., year), if they are zero
156
+ # @param weeks [true, false] Include weeks
157
+ # @param max [Symbol] Max unit to factorize into, from all supported units list
158
+ # @param min [Symbol] Min unit to factorize into, from all supported units list
159
+ # @return [Hash<Symbol => Integer>]
160
+ def factorize(zeroes: true, max: :year, min: :sec, weeks: true)
161
+ t = to
162
+ f = from
163
+ select_units(max: Units.(max), min: Units.(min), weeks: weeks)
164
+ .inject({}) { |res, unit|
165
+ span, t = Diff.new(f, t).divmod(unit)
166
+ res.merge(unit => span)
167
+ }.then { |res|
168
+ next res if zeroes
169
+
170
+ res.drop_while { |_, v| v.zero? }.to_h
171
+ }
172
+ end
173
+
174
+ Units::SYNONYMS.to_a.flatten.each { |u| define_method(u) { div(u) } }
175
+
176
+ # @private
177
+ def exact
178
+ from.unwrap.to_time - to.unwrap.to_time
179
+ end
180
+
181
+ # @return [true, false]
182
+ def negative?
183
+ exact.negative?
184
+ end
185
+
186
+ # @return [true, false]
187
+ def positive?
188
+ exact.positive?
189
+ end
190
+
191
+ # @return [-1, 0, 1]
192
+ def <=>(other)
193
+ return unless other.is_a?(Diff)
194
+
195
+ exact <=> other.exact
196
+ end
197
+
198
+ include Comparable
199
+
200
+ private
201
+
202
+ def singular_div(unit)
203
+ case unit
204
+ when :sec, :min, :hour, :day
205
+ simple_div(from.unwrap, to.unwrap, unit)
206
+ when :week
207
+ div(7, :day)
208
+ when :month
209
+ month_div
210
+ when :year
211
+ year_div
212
+ end
213
+ end
214
+
215
+ def simple_div(t1, t2, unit)
216
+ return simple_div(t1.to_time, t2.to_time, unit) unless Types.compatible?(t1, t2)
217
+
218
+ t1.-(t2).div(Units.multiplier_for(t1.class, unit, precise: true))
219
+ .then { |res| unit == :day ? DST.fix_day_diff(t1, t2, res) : res }
220
+ end
221
+
222
+ def month_div # rubocop:disable Metrics/AbcSize -- well... at least it is short
223
+ ((from.year - to.year) * 12 + (from.month - to.month))
224
+ .then { |res| from.day >= to.day ? res : res - 1 }
225
+ end
226
+
227
+ def year_div
228
+ from.year.-(to.year).then { |res| to.merge(year: from.year) <= from ? res : res - 1 }
229
+ end
230
+
231
+ def select_units(max:, min:, weeks:)
232
+ Units::ALL
233
+ .drop_while { |u| u != max }
234
+ .reverse.drop_while { |u| u != min }.reverse
235
+ .then { |list|
236
+ next list if weeks
237
+
238
+ list - %i[week]
239
+ }
240
+ end
241
+
242
+ def try_unwrap(tm)
243
+ tm.respond_to?(:unwrap) ? tm.unwrap : tm
244
+ end
245
+
246
+ def coerce(from, to)
247
+ case
248
+ when from.class != to.class
249
+ coerce_classes(from, to)
250
+ when zone(from) != zone(to)
251
+ coerce_zones(from, to)
252
+ else
253
+ [from, to]
254
+ end
255
+ end
256
+
257
+ def zone(tm)
258
+ case tm
259
+ when Time
260
+ # "" is JRuby's way to say "I don't know zone"
261
+ tm.zone&.then { |z| z == '' ? nil : z } || tm.utc_offset
262
+ when Date
263
+ nil
264
+ when DateTime
265
+ tm.zone
266
+ end
267
+ end
268
+
269
+ def coerce_classes(from, to)
270
+ case
271
+ when from.class == Date # not is_a?(Date), it will catch DateTime
272
+ [coerce_date(from, to), to]
273
+ when to.class == Date
274
+ [from, coerce_date(to, from)]
275
+ else
276
+ [from, to.public_send("to_#{from.class.downcase}")].then(&method(:coerce_zones))
277
+ end
278
+ end
279
+
280
+ def coerce_zones(from, to)
281
+ # TODO: to should be in from zone, even if different classes!
282
+ [from, to]
283
+ end
284
+
285
+ # Will coerce Date to Time or DateTime, with the _zone of the latter_
286
+ def coerce_date(date, other)
287
+ TimeCalc.(other)
288
+ .merge(Units::DEFAULTS.merge(year: date.year, month: date.month, day: date.day))
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TimeCalc
4
+ # @private
5
+ module DST
6
+ extend self
7
+
8
+ def fix_value(val, origin)
9
+ case (c = compare(origin.unwrap, val.unwrap))
10
+ when nil, 0
11
+ val
12
+ else
13
+ val.+(c, :hour)
14
+ end
15
+ end
16
+
17
+ def fix_day_diff(from, to, diff)
18
+ # Just add one day when it is (DST - non-DST)
19
+ compare(from, to) == 1 ? diff + 1 : diff
20
+ end
21
+
22
+ private
23
+
24
+ # it returns nil if dst? is not applicable to the value
25
+ def is?(tm)
26
+ # it is not something we can reliably process
27
+ return unless tm.respond_to?(:zone) && tm.respond_to?(:dst?)
28
+
29
+ # We can't say "it is not DST" (like `Time#dst?` will say), only "It is time without DST info"
30
+ # Empty string is what JRuby does when it doesn't know.
31
+ return if tm.zone.nil? || tm.zone == ''
32
+
33
+ # Workaround for: https://bugs.ruby-lang.org/issues/15988
34
+ # In Ruby 2.6, Time with "real" Timezone always return `dst? => true` for some zones.
35
+ # Relates on TZInfo API (which is NOT guaranteed to be present, but practically should be)
36
+ tm.zone.respond_to?(:dst?) ? tm.zone.dst?(tm) : tm.dst?
37
+ end
38
+
39
+ def compare(v1, v2)
40
+ dst1 = is?(v1)
41
+ dst2 = is?(v2)
42
+ case
43
+ when [dst1, dst2].any?(&:nil?)
44
+ nil
45
+ when dst1 == dst2
46
+ 0
47
+ when dst1 # and !dst2
48
+ 1
49
+ else # !dst1 and dst2
50
+ -1
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TimeCalc
4
+ # Abstraction over chain of time math operations that can be applied to a time or date.
5
+ #
6
+ # @example
7
+ # op = TimeCalc.+(1, :day).floor(:hour)
8
+ # # => <TimeCalc::Op +(1 day).floor(hour)>
9
+ # op.call(Time.now)
10
+ # # => 2019-07-04 22:00:00 +0300
11
+ # array_of_time_values.map(&op)
12
+ # # => array of "next day, floor to hour" for each element
13
+ class Op
14
+ # @private
15
+ attr_reader :chain
16
+
17
+ # @note
18
+ # Prefer `TimeCalc.<operation>` (for example {TimeCalc#+}) to create operations.
19
+ def initialize(chain = [])
20
+ @chain = chain
21
+ end
22
+
23
+ # @private
24
+ def inspect
25
+ '<%s %s>' % [self.class, @chain.map { |name, *args| "#{name}(#{args.join(' ')})" }.join('.')]
26
+ end
27
+
28
+ TimeCalc::MATH_OPERATIONS.each do |name|
29
+ define_method(name) { |*args| Op.new([*@chain, [name, *args]]) }
30
+ end
31
+
32
+ # @!method +(span, unit)
33
+ # Adds `+(span, unit)` to method chain
34
+ # @see TimeCalc#+
35
+ # @return [Op]
36
+ # @!method -(span, unit)
37
+ # Adds `-(span, unit)` to method chain
38
+ # @see TimeCalc#-
39
+ # @return [Op]
40
+ # @!method floor(unit)
41
+ # Adds `floor(span, unit)` to method chain
42
+ # @see TimeCalc#floor
43
+ # @return [Op]
44
+ # @!method ceil(unit)
45
+ # Adds `ceil(span, unit)` to method chain
46
+ # @see TimeCalc#ceil
47
+ # @return [Op]
48
+ # @!method round(unit)
49
+ # Adds `round(span, unit)` to method chain
50
+ # @see TimeCalc#round
51
+ # @return [Op]
52
+
53
+ # Performs the whole chain of operation on parameter, returning the result.
54
+ #
55
+ # @param date_or_time [Date, Time, DateTime]
56
+ # @return [Date, Time, DateTime] Type of the result is always the same as type of the parameter
57
+ def call(date_or_time)
58
+ @chain.reduce(Value.new(date_or_time)) { |val, (name, *args)|
59
+ val.public_send(name, *args)
60
+ }.unwrap
61
+ end
62
+
63
+ # Allows to pass operation with `&operation`.
64
+ #
65
+ # @return [Proc]
66
+ def to_proc
67
+ method(:call).to_proc
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TimeCalc
4
+ # `Sequence` is a Enumerable, allowing to iterate from start point in time over defined step, till
5
+ # end point or endlessly.
6
+ #
7
+ # @example
8
+ # seq = TimeCalc.(Time.parse('2019-06-01 14:50')).step(1, :day).for(2, :weeks)
9
+ # # => #<TimeCalc::Sequence (2019-06-01 14:50:00 +0300 - 2019-06-15 14:50:00 +0300):step(1 day)>
10
+ # seq.to_a
11
+ # # => [2019-06-01 14:50:00 +0300, 2019-06-02 14:50:00 +0300, ....
12
+ # seq.select(&:monday?)
13
+ # # => [2019-06-03 14:50:00 +0300, 2019-06-10 14:50:00 +0300]
14
+ #
15
+ # # Endless sequences are useful too:
16
+ # seq = TimeCalc.(Time.parse('2019-06-01 14:50')).step(1, :day)
17
+ # # => #<TimeCalc::Sequence (2019-06-01 14:50:00 +0300 - ...):step(1 day)>
18
+ # seq.lazy.select(&:monday?).first(4)
19
+ # # => [2019-06-03 14:50:00 +0300, 2019-06-10 14:50:00 +0300, 2019-06-17 14:50:00 +0300, 2019-06-24 14:50:00 +0300]
20
+ class Sequence
21
+ # @return [Value] Wrapped sequence start.
22
+ attr_reader :from
23
+
24
+ # @note
25
+ # Prefer TimeCalc#to or TimeCalc#step for producing sequences.
26
+ # @param from [Time, Date, DateTime]
27
+ # @param to [Time, Date, DateTime, nil] `nil` produces endless sequence, which can be
28
+ # limited later with {#to} method.
29
+ # @param step [(Integer, Symbol), nil] Pair of span and unit to advance sequence; no `step`
30
+ # produces incomplete sequence ({#each} will raise), which can be completed later with
31
+ # {#step} method.
32
+ def initialize(from:, to: nil, step: nil)
33
+ @from = Value.wrap(from)
34
+ @to = to&.then(&Value.method(:wrap))
35
+ @step = step
36
+ end
37
+
38
+ # @private
39
+ def inspect
40
+ '#<%s (%s - %s):step(%s)>' %
41
+ [self.class, @from.unwrap, @to&.unwrap || '...', @step&.join(' ') || '???']
42
+ end
43
+
44
+ alias to_s inspect
45
+
46
+ # @overload each
47
+ # @yield [Date/Time/DateTime] Next element in sequence
48
+ # @return [self]
49
+ # @overload each
50
+ # @return [Enumerator]
51
+ # @yield [Date/Time/DateTime] Next element in sequence
52
+ # @return [Enumerator or self]
53
+ def each
54
+ fail TypeError, "No step defined for #{self}" unless @step
55
+
56
+ return to_enum(__method__) unless block_given?
57
+
58
+ return unless matching_direction?(@from)
59
+
60
+ cur = @from
61
+ while matching_direction?(cur)
62
+ yield cur.unwrap
63
+ cur = cur.+(*@step) # rubocop:disable Style/SelfAssignment
64
+ end
65
+ yield cur.unwrap if cur == @to
66
+
67
+ self
68
+ end
69
+
70
+ include Enumerable
71
+
72
+ # @overload step
73
+ # @return [(Integer, Symbol)] current step
74
+ # @overload step(unit)
75
+ # Shortcut for `step(1, unit)`
76
+ # @param unit [Symbol] Any of supported units.
77
+ # @return [Sequence]
78
+ # @overload step(span, unit)
79
+ # Produces new sequence with changed step.
80
+ # @param span [Ineger]
81
+ # @param unit [Symbol] Any of supported units.
82
+ # @return [Sequence]
83
+ def step(span = nil, unit = nil)
84
+ return @step if span.nil?
85
+
86
+ span, unit = 1, span if unit.nil?
87
+ Sequence.new(from: @from, to: @to, step: [span, unit])
88
+ end
89
+
90
+ # @overload to
91
+ # @return [Value] current sequence end, wrapped into {Value}
92
+ # @overload to(date_or_time)
93
+ # Produces new sequence with end changed
94
+ # @param date_or_time [Date, Time, DateTime]
95
+ # @return [Sequence]
96
+ def to(date_or_time = nil)
97
+ return @to if date_or_time.nil?
98
+
99
+ Sequence.new(from: @from, to: date_or_time, step: @step)
100
+ end
101
+
102
+ # Produces sequence ending at `from.+(span, unit)`.
103
+ #
104
+ # @example
105
+ # TimeCalc.(Time.parse('2019-06-01 14:50')).step(1, :day).for(2, :weeks).count
106
+ # # => 15
107
+ #
108
+ # @param span [Integer]
109
+ # @param unit [Symbol] Any of supported units.
110
+ # @return [Sequence]
111
+ def for(span, unit)
112
+ to(from.+(span, unit))
113
+ end
114
+
115
+ # @private
116
+ def ==(other)
117
+ other.is_a?(self.class) && from == other.from && to == other.to && step == other.step
118
+ end
119
+
120
+ private
121
+
122
+ def direction
123
+ (@step.first / @step.first.abs)
124
+ end
125
+
126
+ def matching_direction?(val)
127
+ !@to || (@to <=> val) == direction
128
+ end
129
+ end
130
+ end