time_calc 0.0.1

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.
@@ -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