├── .gitignore
├── .rspec
├── .rubocop.yml
├── .travis.yml
├── .yardopts
├── Changelog.md
├── Gemfile
├── LICENSE.txt
├── README.md
├── Rakefile
├── lib
├── time_calc.rb
└── time_calc
│ ├── diff.rb
│ ├── dst.rb
│ ├── op.rb
│ ├── sequence.rb
│ ├── types.rb
│ ├── units.rb
│ ├── value.rb
│ └── version.rb
├── spec
├── .rubocop.yml
├── fixtures
│ ├── ceil.csv
│ ├── floor.csv
│ ├── minus.csv
│ ├── plus.csv
│ └── round.yml
├── spec_helper.rb
├── time_calc
│ ├── diff_spec.rb
│ ├── op_spec.rb
│ ├── sequence_spec.rb
│ ├── value_math_spec.rb
│ └── value_spec.rb
└── time_calc_spec.rb
└── time_calc.gemspec
/.gitignore:
--------------------------------------------------------------------------------
1 | Gemfile.lock
2 | examples
3 | coverage
4 | tmp
5 | .yardoc
6 | doc
7 | pkg
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | -r./spec/spec_helper
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | require: rubocop-rspec
2 |
3 | AllCops:
4 | Include:
5 | - 'lib/**/*.rb'
6 | - 'spec/**/*.rb'
7 | DisplayCopNames: true
8 | TargetRubyVersion: 2.4
9 |
10 | Layout/LineLength:
11 | Max: 100
12 | IgnoredPatterns: ['\#.*'] # ignore long comments
13 |
14 | Style/ParallelAssignment:
15 | Enabled: false
16 |
17 | Style/AndOr:
18 | EnforcedStyle: conditionals
19 |
20 | Style/LambdaCall:
21 | Enabled: false
22 |
23 | Style/MultilineBlockChain:
24 | Enabled: false
25 |
26 | Layout/SpaceInsideHashLiteralBraces:
27 | EnforcedStyle: no_space
28 |
29 | Naming/MethodParameterName:
30 | Enabled: false
31 |
32 | Style/FormatStringToken:
33 | Enabled: false
34 |
35 | Style/FormatString:
36 | EnforcedStyle: percent
37 |
38 | Style/BlockDelimiters:
39 | Enabled: false
40 |
41 | Style/SignalException:
42 | EnforcedStyle: semantic
43 |
44 | Metrics/MethodLength:
45 | Max: 15
46 |
47 | Style/AsciiComments:
48 | Enabled: false
49 |
50 | Metrics/ClassLength:
51 | Enabled: false
52 |
53 | Style/EmptyCaseCondition:
54 | Enabled: false
55 |
56 | # Should always be configured:
57 | Lint/RaiseException:
58 | Enabled: true
59 |
60 | Lint/StructNewOverride:
61 | Enabled: true
62 |
63 | Style/HashEachMethods:
64 | Enabled: true
65 |
66 | Style/HashTransformKeys:
67 | Enabled: true
68 |
69 | Style/HashTransformValues:
70 | Enabled: true
71 |
72 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | cache: bundler
2 | language: ruby
3 | rvm:
4 | - "2.3.0"
5 | - "2.4.0"
6 | - "2.5.0"
7 | - "2.6.0"
8 | - ruby-head
9 | - jruby-9.2.5.0
10 | matrix:
11 | allow_failures:
12 | # Some RVM problems with fetching it?..
13 | - jruby-9.2.5.0
14 | install:
15 | - bundle install --retry=3
16 | script:
17 | - bundle exec rspec
18 | - bundle exec rubocop
19 |
--------------------------------------------------------------------------------
/.yardopts:
--------------------------------------------------------------------------------
1 | --markup=markdown
2 | --no-private
3 |
--------------------------------------------------------------------------------
/Changelog.md:
--------------------------------------------------------------------------------
1 | # TimeCalc changelog
2 |
3 | ## 0.0.4 / 2020-04-11
4 |
5 | * Support `ActiveSupport::TimeWithZone` as a calculation target.
6 |
7 |
8 | ## 0.0.3 / 2019-12-14
9 |
10 | * Add `TimeCalc#iterate` to easily operate in "business date/time" contexts.
11 |
12 | ## 0.0.2 / 2019-07-08
13 |
14 | * Alias `TimeCalc[tm]` for those who disapporve on `TimeCalc.(tm)`;
15 | * More accurate zone info preservation when time is in local timezone of current machine.
16 |
17 | ## 0.0.1 / 2019-07-05
18 |
19 | First release. Rip-off of [time_math2](https://github.com/zverok/time_math2) with full API redesign and dropping of some less-used features ("resamplers").
20 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gemspec
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019 Victor 'Zverok' Shepelev
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining
6 | a copy of this software and associated documentation files (the
7 | "Software"), to deal in the Software without restriction, including
8 | without limitation the rights to use, copy, modify, merge, publish,
9 | distribute, sublicense, and/or sell copies of the Software, and to
10 | permit persons to whom the Software is furnished to do so, subject to
11 | the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be
14 | included in all copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TimeCalc -- next generation of Time arithmetic library
2 |
3 | [](http://badge.fury.io/rb/time_calc)
4 | [](https://travis-ci.org/zverok/time_calc)
5 | [](http://rubydoc.info/gems/time_calc/frames)
6 |
7 | **TimeCalc** tries to provide a way to do **simple time arithmetic** in a modern, readable, idiomatic, no-"magic" Ruby.
8 |
9 | _**NB:** TimeCalc is a continuation of [TimeMath](https://github.com/zverok/time_math2) project. As I decided to change API significantly (completely, in fact) and drop a lot of "nice to have but nobody uses" features, it is a new project rather than "absolutely incompatible new version". See [API design](#api-design) section to understand how and why TimeCalc is different._
10 |
11 | ## Features
12 |
13 | * Small, clean, pure-Ruby, idiomatic, no monkey-patching, no dependencies (except `backports`);
14 | * Arithmetic akin to what Ruby numbers provide: `+`/`-`, `floor`/`ceil`/`round`, enumerable sequences (`step`/`to`);
15 | * Works with `Time`, `Date` and `DateTime` and allows to mix them freely (e.g. create sequences from `Date` to `Time`, calculate their diffs);
16 | * Tries its best to preserve timezone/offset information:
17 | * **on Ruby 2.6+**, for `Time` with real timezones, preserves them;
18 | * on Ruby < 2.6, preserves at least `utc_offset` of `Time`;
19 | * for `DateTime` preserves zone name;
20 | * _Since 0.0.4,_ supports `ActiveSupport::TimeWithZone`, too. While in ActiveSupport-enabled context TimeCalc may seem redundant (you'll probably use `time - 1.day` anyways), some of the functionality is easier with TimeCalc (rounding to different units) or just not present in ActiveSupport (time sequences, iterate with skippking); also may be helpful for third-party libraries which want to use TimeCalc underneath but don't want to be broken in Rails context.
21 |
22 | ## Synopsis
23 |
24 | ### Arithmetic with units
25 |
26 | ```ruby
27 | require 'time_calc'
28 |
29 | TC = TimeCalc
30 |
31 | t = Time.parse('2019-03-14 08:06:15')
32 |
33 | TC.(t).+(3, :hours)
34 | # => 2019-03-14 11:06:15 +0200
35 | TC.(t).round(:week)
36 | # => 2019-03-11 00:00:00 +0200
37 |
38 | # TimeCalc.call(Time.now) shortcut:
39 | TC.now.floor(:day)
40 | # => beginning of the today
41 | ```
42 |
43 | Operations supported:
44 |
45 | * `+`, `-`
46 | * `ceil`, `round`, `floor`
47 |
48 | Units supported:
49 |
50 | * `:sec` (also `:second`, `:seconds`);
51 | * `:min` (`:minute`, `:minutes`);
52 | * `:hour`/`:hours`;
53 | * `:day`/`:days`;
54 | * `:week`/`:weeks`;
55 | * `:month`/`:months`;
56 | * `:year`/`:years`.
57 |
58 | Timezone preservation on Ruby 2.6:
59 |
60 | ```ruby
61 | require 'tzinfo'
62 | t = Time.new(2019, 9, 1, 14, 30, 12, TZInfo::Timezone.get('Europe/Kiev'))
63 | # => 2019-09-01 14:30:12 +0300
64 | # ^^^^^
65 | TimeCalc.(t).+(3, :months) # jump over DST: we have +3 in summer and +2 in winter
66 | # => 2019-12-01 14:30:12 +0200
67 | # ^^^^^
68 | ```
69 | (Random fun fact: it is Kyiv, not Kiev!)
70 |
71 | ### Math with skipping "non-business time"
72 |
73 | [TimeCalc#iterate](https://www.rubydoc.info/gems/time_calc/TimeCalc#iterate-instance_method) allows to advance or decrease time values by skipping some of them (like weekends, holidays, and non-working hours):
74 |
75 | ```ruby
76 | # add 10 working days (weekends are not counted)
77 | TimeCalc.(Time.parse('2019-07-03 23:28:54')).iterate(10, :days) { |t| (1..5).cover?(t.wday) }
78 | # => 2019-07-17 23:28:54 +0300
79 |
80 | # add 12 working hours
81 | TimeCalc.(Time.parse('2019-07-03 13:28:54')).iterate(12, :hours) { |t| (9...18).cover?(t.hour) }
82 | # => 2019-07-04 16:28:54 +0300
83 |
84 | # negative spans are working, too:
85 | TimeCalc.(Time.parse('2019-07-03 13:28:54')).iterate(-12, :hours) { |t| (9...18).cover?(t.hour) }
86 | # => 2019-07-02 10:28:54 +0300
87 |
88 | # zero span could be used to robustly enforce value into acceptable range
89 | # (increasing forward till block is true):
90 | TimeCalc.(Time.parse('2019-07-03 23:28:54')).iterate(0, :hours) { |t| (9...18).cover?(t.hour) }
91 | # => 2019-07-04 09:28:54 +0300
92 | ```
93 |
94 | ### Difference of two values
95 |
96 | ```ruby
97 | diff = TC.(t) - Time.parse('2019-02-30 16:30')
98 | # => #
99 | diff.days # or any other supported unit
100 | # => 11
101 | diff.factorize
102 | # => {:year=>0, :month=>0, :week=>1, :day=>4, :hour=>15, :min=>36, :sec=>15}
103 | ```
104 |
105 | There are several options to [Diff#factorize](https://www.rubydoc.info/gems/time_calc/TimeCalc/Diff#factorize-instance_method) to obtain the most useful result.
106 |
107 | ### Chains of operations
108 |
109 | ```ruby
110 | TC.wrap(t).+(1, :hour).round(:min).unwrap
111 | # => 2019-03-14 09:06:00 +0200
112 |
113 | # proc constructor synopsys:
114 | times = ['2019-06-01 14:30', '2019-06-05 17:10', '2019-07-02 13:40'].map { |t| Time.parse(t) }
115 | times.map(&TC.+(1, :hour).round(:min))
116 | # => [2019-06-01 15:30:00 +0300, 2019-06-05 18:10:00 +0300, 2019-07-02 14:40:00 +0300]
117 | ```
118 |
119 | ### Enumerable time sequences
120 |
121 | ```ruby
122 | TC.(t).step(2, :weeks)
123 | # => #
124 | TC.(t).step(2, :weeks).first(3)
125 | # => [2019-03-14 08:06:15 +0200, 2019-03-28 08:06:15 +0200, 2019-04-11 09:06:15 +0300]
126 | TC.(t).to(Time.parse('2019-04-30 16:30')).step(3, :weeks).to_a
127 | # => [2019-03-14 08:06:15 +0200, 2019-04-04 09:06:15 +0300, 2019-04-25 09:06:15 +0300]
128 | TC.(t).for(3, :months).step(4, :weeks).to_a
129 | # => [2019-03-14 08:06:15 +0200, 2019-04-11 09:06:15 +0300, 2019-05-09 09:06:15 +0300, 2019-06-06 09:06:15 +0300]
130 | ```
131 |
132 | ## API design
133 |
134 | The idea of this library (as well as the idea of the previous one) grew of the simple question "how do you say ` + 1 hour` in good Ruby?" This question also leads (me) to notifying that other arithmetical operations (like rounding, or ` up to with step `) seem to be applicable to `Time` or `Date` values as well.
135 |
136 | Prominent ActiveSupport's answer of extending simple numbers to respond to `1.year` never felt totally right to me. I am not completely against-any-monkey-patches kind of guy, it just doesn't sit right, to say "number has a method to produce duration". One of the attempts to find an alternative has led me to the creation of [time_math2](https://github.com/zverok/time_math2), which gained some (modest) popularity by presenting things this way: `TimeMath.year.advance(time, 1)`.
137 |
138 | TBH, using the library myself only eventually, I have never been too happy with it: it never felt really natural, so I constantly forgot "what should I do to calculate '2 days ago'". This simplest use case (some time from now) in `TimeMath` looked too far from "how you pronounce it":
139 |
140 | ```ruby
141 | # Natural language: 2 days ago
142 | # "Formalized": now - 2 days
143 |
144 | # ActiveSupport:
145 | Time.now - 2.days
146 | # also there is 2.days.ago, but I am not a big fan of "1000 synonyms just for naturality"
147 |
148 | # TimeMath:
149 | TimMath.day.decrease(Time.now, 2) # Ughhh what? "Day decrease now 2"?
150 | ```
151 |
152 | The thought process that led to the new library is:
153 |
154 | * `(2, days)` is just a _tuple_ of two unrelated data elements
155 | * `days` is "internal name that makes sense inside the code", which we represent by `Symbol` in Ruby
156 | * Math operators can be called just like regular methods: `.+(something)`, which may look unusual at first, but can be super-handy even with simple numbers, in method chaining -- I am grateful to my Verbit's colleague Roman Yarovoy to pointing at that fact (or rather its usefulness);
157 | * To chain some calculations with Ruby core type without extending this type, we can just "wrap" it into a monad-like object, do the calculations, and unwrap at the end (TimeMath itself, and my Hash-processing gem [hm](https://github.com/zverok/hm) have used this approach).
158 |
159 | So, here we go:
160 | ```ruby
161 | TimeCalc.(Time.now).-(2, :days)
162 | # Small shortcut, as `Time.now` is the frequent start value for such calculations:
163 | TimeCalc.now.-(2, :days)
164 | ```
165 |
166 | The rest of the design (see examples above) just followed naturally. There could be different opinions on the approach, but for myself the resulting API looks straightforward, hard to forget and very regular (in fact, all the hard time calculations, including support for different types, zones, DST and stuff, are done in two core methods, and the rest was easy to define in terms of those methods, which is a sign of consistency).
167 |
168 | ¯\\\_(ツ)_/¯
169 |
170 | ## Author & license
171 |
172 | * [Victor Shepelev](https://zverok.github.io)
173 | * [MIT](https://github.com/zverok/time_calc/blob/master/LICENSE.txt).
174 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'bundler/setup'
2 | $LOAD_PATH.unshift 'lib'
3 | require 'pathname'
4 | require 'rubygems/tasks'
5 | Gem::Tasks.new
6 |
--------------------------------------------------------------------------------
/lib/time_calc.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'date'
4 | require 'time'
5 |
6 | require_relative 'time_calc/units'
7 | require_relative 'time_calc/types'
8 | require_relative 'time_calc/dst'
9 | require_relative 'time_calc/value'
10 |
11 | # Module for time arithmetic.
12 | #
13 | # Examples of usage:
14 | #
15 | # ```ruby
16 | # TimeCalc.(Time.now).+(1, :day)
17 | # # => 2019-07-04 23:28:54 +0300
18 | # TimeCalc.(Time.now).round(:hour)
19 | # # => 2019-07-03 23:00:00 +0300
20 | #
21 | # # Operations with Time.now and Date.today also have their shortcuts:
22 | # TimeCalc.now.-(3, :days)
23 | # # => 2019-06-30 23:28:54 +0300
24 | # TimeCalc.today.ceil(:month)
25 | # # => #
26 | #
27 | # # If you need to perform several operations TimeCalc.from wraps your value:
28 | # TimeCalc.from(Time.parse('2019-06-14 13:40')).+(10, :days).floor(:week).unwrap
29 | # # => 2019-06-24 00:00:00 +0300
30 | #
31 | # # TimeCalc#- also can be used to calculate difference between time values
32 | # diff = TimeCalc.(Time.parse('2019-07-03 23:32')) - Time.parse('2019-06-14 13:40')
33 | # # => #
34 | # diff.days # => 19
35 | # diff.hours # => 465
36 | # diff.factorize
37 | # # => {:year=>0, :month=>0, :week=>2, :day=>5, :hour=>9, :min=>52, :sec=>0}
38 | # diff.factorize(max: :day)
39 | # # => {:day=>19, :hour=>9, :min=>52, :sec=>0}
40 | #
41 | # # Enumerable sequences of time values
42 | # sequence = TimeCalc.(Time.parse('2019-06-14 13:40'))
43 | # .to(Time.parse('2019-07-03 23:32'))
44 | # .step(5, :hours)
45 | # # => #
46 | # sequence.to_a
47 | # # => [2019-06-14 13:40:00 +0300, 2019-06-14 18:40:00 +0300, 2019-06-14 23:40:00 +0300, ...
48 | # sequence.first(2)
49 | # # => [2019-06-14 13:40:00 +0300, 2019-06-14 18:40:00 +0300]
50 | #
51 | # # Construct operations to apply as a proc:
52 | # times = ['2019-06-01 14:30', '2019-06-05 17:10', '2019-07-02 13:40'].map { |t| Time.parse(t) }
53 | # # => [2019-06-01 14:30:00 +0300, 2019-06-05 17:10:00 +0300, 2019-07-02 13:40:00 +0300]
54 | # times.map(&TimeCalc.+(1, :week).round(:day))
55 | # # => [2019-06-09 00:00:00 +0300, 2019-06-13 00:00:00 +0300, 2019-07-10 00:00:00 +0300]
56 | # ```
57 | #
58 | # See method docs below for details and supported arguments.
59 | #
60 | class TimeCalc
61 | class << self
62 | alias call new
63 | alias [] new
64 |
65 | # Shortcut for `TimeCalc.(Time.now)`
66 | # @return [TimeCalc]
67 | def now
68 | new(Time.now)
69 | end
70 |
71 | # Shortcut for `TimeCalc.(Date.today)`
72 | # @return [TimeCalc]
73 | def today
74 | new(Date.today)
75 | end
76 |
77 | # Returns {Value} wrapper, useful for performing several operations at once:
78 | #
79 | # ```ruby
80 | # TimeCalc.from(Time.parse('2019-06-14 13:40')).+(10, :days).floor(:week).unwrap
81 | # # => 2019-06-24 00:00:00 +0300
82 | # ```
83 | #
84 | # @param date_or_time [Time, Date, DateTime]
85 | # @return [Value]
86 | def from(date_or_time)
87 | Value.new(date_or_time)
88 | end
89 |
90 | # Shortcut for `TimeCalc.from(Time.now)`
91 | # @return [Value]
92 | def from_now
93 | from(Time.now)
94 | end
95 |
96 | # Shortcut for `TimeCalc.from(Date.today)`
97 | # @return [Value]
98 | def from_today
99 | from(Date.today)
100 | end
101 |
102 | alias wrap from
103 | alias wrap_now from_now
104 | alias wrap_today from_today
105 | end
106 |
107 | # @private
108 | attr_reader :value
109 |
110 | # Creates a "temporary" wrapper, which would be unwrapped after first operation:
111 | #
112 | # ```ruby
113 | # TimeCalc.new(Time.now).round(:hour)
114 | # # => 2019-07-03 23:00:00 +0300
115 | # ```
116 | #
117 | # The constructor also aliased as `.call` which allows for nicer (for some eyes) code:
118 | #
119 | # ```ruby
120 | # TimeCalc.(Time.now).round(:hour)
121 | # # => 2019-07-03 23:00:00 +0300
122 | #
123 | # # There is another shortcut for those who disapprove on `.()`
124 | # TimeCalc[Time.now].+(1, :month)
125 | # ```
126 | #
127 | # See {.from} if you need to perform several math operations on same value.
128 | #
129 | # @param date_or_time [Time, Date, DateTime]
130 | def initialize(date_or_time)
131 | @value = Value.new(date_or_time)
132 | end
133 |
134 | # @private
135 | def inspect
136 | '#<%s(%s)>' % [self.class, @value.unwrap]
137 | end
138 |
139 | # @return [true,false]
140 | def ==(other)
141 | other.is_a?(self.class) && other.value == value
142 | end
143 |
144 | # @!method merge(**attrs)
145 | # Replaces specified components of date/time, preserves the rest.
146 | #
147 | # @example
148 | # TimeCalc.(Date.parse('2018-06-01')).merge(year: 1983)
149 | # # => #
150 | #
151 | # @param attrs [Hash Integer>]
152 | # @return [Time, Date, DateTime] value of the same type that was initial wrapped value.
153 |
154 | # @!method floor(unit)
155 | # Floors (rounds down) date/time to nearest `unit`.
156 | #
157 | # @example
158 | # TimeCalc.(Time.parse('2018-06-23 12:30')).floor(:month)
159 | # # => 2018-06-01 00:00:00 +0300
160 | #
161 | # @param unit [Symbol]
162 | # @return [Time, Date, DateTime] value of the same type that was initial wrapped value.
163 |
164 | # @!method ceil(unit)
165 | # Ceils (rounds up) date/time to nearest `unit`.
166 | #
167 | # @example
168 | # TimeCalc.(Time.parse('2018-06-23 12:30')).ceil(:month)
169 | # # => 2018-07-01 00:00:00 +0300
170 | #
171 | # @param unit [Symbol]
172 | # @return [Time, Date, DateTime] value of the same type that was initial wrapped value.
173 |
174 | # @!method round(unit)
175 | # Rounds (up or down) date/time to nearest `unit`.
176 | #
177 | # @example
178 | # TimeCalc.(Time.parse('2018-06-23 12:30')).round(:month)
179 | # # => 2018-07-01 00:00:00 +0300
180 | #
181 | # @param unit [Symbol]
182 | # @return [Time, Date, DateTime] value of the same type that was initial wrapped value.
183 |
184 | # @!method +(span, unit)
185 | # Add `` to wrapped value
186 | # @example
187 | # TimeCalc.(Time.parse('2019-07-03 23:28:54')).+(1, :day)
188 | # # => 2019-07-04 23:28:54 +0300
189 | # @param span [Integer]
190 | # @param unit [Symbol]
191 | # @return [Date, Time, DateTime] value of the same type that was initial wrapped value.
192 |
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 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 |
224 | # @!method -(span_or_other, unit=nil)
225 | # @overload -(span, unit)
226 | # Subtracts `span units` from wrapped value.
227 | # @param span [Integer]
228 | # @param unit [Symbol]
229 | # @return [Date, Time, DateTime] value of the same type that was initial wrapped value.
230 | # @overload -(date_or_time)
231 | # Produces {Diff}, allowing to calculate structured difference between two points in time.
232 | # @example
233 | # t1 = Time.parse('2019-06-01 14:50')
234 | # t2 = Time.parse('2019-06-15 12:10')
235 | # (TimeCalc.(t2) - t1).days
236 | # # => 13
237 | # @param date_or_time [Date, Time, DateTime]
238 | # @return [Diff]
239 | # @return [Time or Diff]
240 |
241 | # @!method to(date_or_time)
242 | # Produces {Sequence} from this value to `date_or_time`
243 | #
244 | # @param date_or_time [Date, Time, DateTime]
245 | # @return [Sequence]
246 |
247 | # @!method step(span, unit = nil)
248 | # Produces endless {Sequence} from this value, with step specified.
249 | #
250 | # @overload step(unit)
251 | # Shortcut for `step(1, unit)`
252 | # @param unit [Symbol]
253 | # @overload step(span, unit)
254 | # @example
255 | # TimeCalc.(Time.parse('2019-06-01 14:50')).step(1, :day).take(3)
256 | # # => [2019-06-01 14:50:00 +0300, 2019-06-02 14:50:00 +0300, 2019-06-03 14:50:00 +0300]
257 | # @param span [Integer]
258 | # @param unit [Symbol]
259 | # @return [Sequence]
260 |
261 | # @!method for(span, unit)
262 | # Produces {Sequence} from this value to `this + `
263 | #
264 | # @example
265 | # TimeCalc.(Time.parse('2019-06-01 14:50')).for(2, :weeks).step(1, :day).count
266 | # # => 15
267 | # @param span [Integer]
268 | # @param unit [Symbol]
269 | # @return [Sequence]
270 |
271 | # @private
272 | MATH_OPERATIONS = %i[merge truncate floor ceil round + - iterate].freeze
273 | # @private
274 | OPERATIONS = MATH_OPERATIONS.+(%i[to step for]).freeze
275 |
276 | OPERATIONS.each do |name|
277 | # https://bugs.ruby-lang.org/issues/16421 :shrug:
278 | # FIXME: In fact, the only kwargs op seem to be merge(...). If the problem is unsolvable,
279 | # it is easier to define it separately.
280 | if RUBY_VERSION < '2.7'
281 | define_method(name) { |*args, &block|
282 | @value.public_send(name, *args, &block)
283 | .then { |res| res.is_a?(Value) ? res.unwrap : res }
284 | }
285 | else
286 | define_method(name) { |*args, **kwargs, &block|
287 | @value.public_send(name, *args, **kwargs, &block)
288 | .then { |res| res.is_a?(Value) ? res.unwrap : res }
289 | }
290 | end
291 | end
292 |
293 | class << self
294 | MATH_OPERATIONS.each do |name|
295 | define_method(name) { |*args, &block| Op.new([[name, args, block].compact]) }
296 | end
297 |
298 | # @!parse
299 | # # Creates operation to perform {#+}`(span, unit)`
300 | # # @return [Op]
301 | # def TimeCalc.+(span, unit); end
302 | # # Creates operation to perform {#iterate}`(span, unit, &block)`
303 | # # @return [Op]
304 | # def TimeCalc.iterate(span, unit, &block); end
305 | # # Creates operation to perform {#-}`(span, unit)`
306 | # # @return [Op]
307 | # def TimeCalc.-(span, unit); end
308 | # # Creates operation to perform {#floor}`(unit)`
309 | # # @return [Op]
310 | # def TimeCalc.floor(unit); end
311 | # # Creates operation to perform {#ceil}`(unit)`
312 | # # @return [Op]
313 | # def TimeCalc.ceil(unit); end
314 | # # Creates operation to perform {#round}`(unit)`
315 | # # @return [Op]
316 | # def TimeCalc.round(unit); end
317 | end
318 | end
319 |
320 | require_relative 'time_calc/op'
321 | require_relative 'time_calc/sequence'
322 | require_relative 'time_calc/diff'
323 |
--------------------------------------------------------------------------------
/lib/time_calc/diff.rb:
--------------------------------------------------------------------------------
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 `.` methods (like {#years})
87 | # @param unit [Symbol] Any of supported units (see {TimeCalc})
88 | #
89 | # @return [Integer] Number of whole ``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 | # ` `. 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 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
292 |
--------------------------------------------------------------------------------
/lib/time_calc/dst.rb:
--------------------------------------------------------------------------------
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
55 |
--------------------------------------------------------------------------------
/lib/time_calc/op.rb:
--------------------------------------------------------------------------------
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 | # # =>
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.` (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, &block| Op.new([*@chain, [name, args, block].compact]) }
30 | end
31 |
32 | # @!method +(span, unit)
33 | # Adds `+(span, unit)` to method chain
34 | # @see TimeCalc#+
35 | # @return [Op]
36 | # @!method iterate(span, unit, &block)
37 | # Adds `iterate(span, unit, &block)` to method chain
38 | # @see TimeCalc#iterate
39 | # @return [Op]
40 | # @!method -(span, unit)
41 | # Adds `-(span, unit)` to method chain
42 | # @see TimeCalc#-
43 | # @return [Op]
44 | # @!method floor(unit)
45 | # Adds `floor(span, unit)` to method chain
46 | # @see TimeCalc#floor
47 | # @return [Op]
48 | # @!method ceil(unit)
49 | # Adds `ceil(span, unit)` to method chain
50 | # @see TimeCalc#ceil
51 | # @return [Op]
52 | # @!method round(unit)
53 | # Adds `round(span, unit)` to method chain
54 | # @see TimeCalc#round
55 | # @return [Op]
56 |
57 | # Performs the whole chain of operation on parameter, returning the result.
58 | #
59 | # @param date_or_time [Date, Time, DateTime]
60 | # @return [Date, Time, DateTime] Type of the result is always the same as type of the parameter
61 | def call(date_or_time)
62 | @chain.reduce(Value.new(date_or_time)) { |val, (name, args, block)|
63 | val.public_send(name, *args, &block)
64 | }.unwrap
65 | end
66 |
67 | # Allows to pass operation with `&operation`.
68 | #
69 | # @return [Proc]
70 | def to_proc
71 | method(:call).to_proc
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/lib/time_calc/sequence.rb:
--------------------------------------------------------------------------------
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 | # # => #
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 | # # => #
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
131 |
--------------------------------------------------------------------------------
/lib/time_calc/types.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class TimeCalc
4 | # @private
5 | # Tries to encapsulate all the differences between Time, Date, DateTime
6 | module Types
7 | extend self
8 |
9 | ATTRS = {
10 | 'Time' => %i[year month day hour min sec subsec utc_offset],
11 | 'Date' => %i[year month day],
12 | 'DateTime' => %i[year month day hour min sec sec_fraction zone],
13 | 'ActiveSupport::TimeWithZone' => %i[year month day hour min sec sec_fraction time_zone]
14 | }.freeze
15 |
16 | # @private
17 | # Because AS::TimeWithZone so frigging smart that it returns "Time" from redefined class name.
18 | CLASS_NAME = Class.instance_method(:name)
19 |
20 | def compatible?(v1, v2)
21 | [v1, v2].all?(Date) || [v1, v2].all?(Time)
22 | end
23 |
24 | def compare(v1, v2)
25 | compatible?(v1, v2) ? v1 <=> v2 : v1.to_time <=> v2.to_time
26 | end
27 |
28 | def convert(v, klass)
29 | return v if v.class == klass
30 |
31 | v.public_send("to_#{klass.name.downcase}")
32 | end
33 |
34 | def merge_time(value, **attrs)
35 | _merge(value, **attrs)
36 | .tap { |h| h[:sec] += h.delete(:subsec) }
37 | .then { |h| fix_time_zone(h, value) }
38 | .values.then { |components| Time.new(*components) }
39 | end
40 |
41 | def merge_date(value, **attrs)
42 | _merge(value, **attrs).values.then { |components| Date.new(*components) }
43 | end
44 |
45 | def merge_datetime(value, **attrs)
46 | # When we truncate, we use :subsec key as a sign to zeroefy second fractions
47 | attrs[:sec_fraction] ||= attrs.delete(:subsec) if attrs.key?(:subsec)
48 |
49 | _merge(value, **attrs)
50 | .tap { |h| h[:sec] += h.delete(:sec_fraction) }
51 | .values.then { |components| DateTime.new(*components) }
52 | end
53 |
54 | def merge_activesupport__timewithzone(value, **attrs)
55 | # You'd imagine we should be able to use just value.change(...) ActiveSupport's API here...
56 | # But it is not available if you don't require all the core_ext's of Time, so I decided to
57 | # be on the safe side and use similar approach everywhere.
58 |
59 | # When we truncate, we use :subsec key as a sign to zeroefy second fractions
60 | attrs[:sec_fraction] ||= attrs.delete(:subsec) if attrs.key?(:subsec)
61 |
62 | _merge(value, **attrs)
63 | .then { |components|
64 | zone = components.delete(:time_zone)
65 | components.merge!(mday: components.delete(:day), mon: components.delete(:month))
66 | zone.__send__(:parts_to_time, components, value)
67 | }
68 | end
69 |
70 | private
71 |
72 | REAL_TIMEZONE = ->(z) { z.respond_to?(:utc_to_local) } # Ruby 2.6 real timezones
73 |
74 | def fix_time_zone(attrs, origin)
75 | case origin.zone
76 | when nil, '' # "" is JRuby's way to say "no zone known"
77 | attrs
78 | when String
79 | # Y U NO Hash#except, Ruby???
80 | attrs.slice(*attrs.keys.-([:utc_offset])) # Then it would be default, then it would set system's zone
81 | when REAL_TIMEZONE
82 | attrs.merge(utc_offset: origin.zone) # When passed in place of utc_offset, timezone object becomes Time's zone
83 | end
84 | end
85 |
86 | def _merge(value, attrs)
87 | attr_names = ATTRS.fetch(CLASS_NAME.bind(value.class).call)
88 | attr_names.to_h { |u| [u, value.public_send(u)] }.merge(**attrs.slice(*attr_names))
89 | end
90 | end
91 | end
92 |
--------------------------------------------------------------------------------
/lib/time_calc/units.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class TimeCalc
4 | # @private
5 | # Unit-related constants and utilities for fetching their values
6 | module Units
7 | ALL = %i[year month week day hour min sec].freeze
8 | NATURAL = %i[year month day hour min sec].freeze
9 | STRUCTURAL = %i[year month day hour min sec subsec].freeze
10 |
11 | SYNONYMS = {
12 | second: :sec,
13 | seconds: :sec,
14 | minute: :min,
15 | minutes: :min,
16 | hours: :hour,
17 | days: :day,
18 | weeks: :week,
19 | months: :month,
20 | years: :year
21 | }.freeze
22 |
23 | DEFAULTS = {
24 | month: 1,
25 | day: 1,
26 | hour: 0,
27 | min: 0,
28 | sec: 0,
29 | subsec: 0
30 | }.freeze
31 |
32 | MULTIPLIERS = {
33 | sec: 1,
34 | min: 60,
35 | hour: 60 * 60,
36 | day: 24 * 60 * 60
37 | }.freeze
38 |
39 | def self.call(unit)
40 | SYNONYMS.fetch(unit, unit)
41 | .tap { |u| ALL.include?(u) or fail ArgumentError, "Unsupported unit: #{u}" }
42 | end
43 |
44 | def self.multiplier_for(klass, unit, precise: false)
45 | res = MULTIPLIERS.fetch(unit)
46 | d = MULTIPLIERS.fetch(:day)
47 | case klass.name
48 | when 'Time'
49 | res
50 | when 'DateTime'
51 | res / d.to_f
52 | when 'Date'
53 | precise ? res / d.to_f : res / d
54 | end
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/lib/time_calc/value.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'backports/2.6.0/enumerable/to_h'
4 | require 'backports/2.6.0/array/to_h'
5 | require 'backports/2.6.0/hash/to_h'
6 | require 'backports/2.6.0/kernel/then'
7 | require 'backports/2.5.0/hash/slice'
8 | require 'backports/2.5.0/enumerable/all'
9 | require 'backports/2.7.0/enumerator/produce'
10 |
11 | class TimeCalc
12 | # Wrapper (one can say "monad") around date/time value, allowing to perform several TimeCalc
13 | # operations in a chain.
14 | #
15 | # @example
16 | # TimeCalc.wrap(Time.parse('2019-06-01 14:50')).+(1, :year).-(1, :month).round(:week).unwrap
17 | # # => 2020-05-04 00:00:00 +0300
18 | #
19 | class Value
20 | # @private
21 | TIMEY = proc { |t| t.respond_to?(:to_time) }
22 |
23 | # @private
24 | # Because AS::TimeWithZone so frigging smart that it returns "Time" from redefined class name.
25 | CLASS_NAME = Class.instance_method(:name)
26 |
27 | # @private
28 | def self.wrap(value)
29 | case value
30 | when Time, Date, DateTime
31 | # NB: ActiveSupport::TimeWithZone will also pass to this branch if
32 | # active_support/core_ext/time is required. But it is doubtfully it is not -- TWZ will be
33 | # mostly unusable :)
34 | new(value)
35 | when Value
36 | value
37 | when TIMEY
38 | wrap(value.to_time)
39 | else
40 | fail ArgumentError, "Unsupported value: #{value}"
41 | end
42 | end
43 |
44 | # @private
45 | attr_reader :internal
46 |
47 | # @note
48 | # Prefer {TimeCalc.wrap} to create a Value.
49 | # @param time_or_date [Time, Date, DateTime]
50 | def initialize(time_or_date)
51 | @internal = time_or_date
52 | end
53 |
54 | # @return [Time, Date, DateTime] The value of the original type that was wrapped and processed
55 | def unwrap
56 | @internal
57 | end
58 |
59 | # @private
60 | def inspect
61 | '#<%s(%s)>' % [self.class, internal]
62 | end
63 |
64 | # @return [1, 0, -1]
65 | def <=>(other)
66 | return unless other.is_a?(self.class)
67 |
68 | Types.compare(internal, other.internal)
69 | end
70 |
71 | include Comparable
72 |
73 | Units::ALL.each { |u| define_method(u) { internal.public_send(u) } }
74 |
75 | def dst?
76 | return unless internal.respond_to?(:dst?)
77 |
78 | internal.dst?
79 | end
80 |
81 | # Produces new value with some components of underlying time/date replaced.
82 | #
83 | # @example
84 | # TimeCalc.from(Date.parse('2018-06-01')).merge(year: 1983)
85 | # # => #
86 | #
87 | # @param attrs [Hash Integer>]
88 | # @return [Value]
89 | def merge(**attrs)
90 | class_name = CLASS_NAME.bind(internal.class).call.tr(':', '_')
91 | Value.new(Types.public_send("merge_#{class_name.downcase}", internal, **attrs))
92 | end
93 |
94 | # Truncates all time components lower than `unit`. In other words, "floors" (rounds down)
95 | # underlying date/time to nearest `unit`.
96 | #
97 | # @example
98 | # TimeCalc.from(Time.parse('2018-06-23 12:30')).floor(:month)
99 | # # => #
100 | #
101 | # @param unit [Symbol]
102 | # @return Value
103 | def truncate(unit)
104 | unit = Units.(unit)
105 | return floor_week if unit == :week
106 |
107 | Units::STRUCTURAL
108 | .drop_while { |u| u != unit }
109 | .drop(1)
110 | .then { |keys| Units::DEFAULTS.slice(*keys) }
111 | .then { |attrs| merge(**attrs) } # can't simplify to &method(:merge) due to 2.7 keyword param problem
112 | end
113 |
114 | alias floor truncate
115 |
116 | # Ceils (rounds up) underlying date/time to nearest `unit`.
117 | #
118 | # @example
119 | # TimeCalc.from(Time.parse('2018-06-23 12:30')).ceil(:month)
120 | # # => #
121 | #
122 | # @param unit [Symbol]
123 | # @return [Value]
124 | def ceil(unit)
125 | floor(unit).then { |res| res == self ? res : res.+(1, unit) }
126 | end
127 |
128 | # Rounds up or down underlying date/time to nearest `unit`.
129 | #
130 | # @example
131 | # TimeCalc.from(Time.parse('2018-06-23 12:30')).round(:month)
132 | # # => #
133 | #
134 | # @param unit [Symbol]
135 | # @return Value
136 | def round(unit)
137 | f, c = floor(unit), ceil(unit)
138 |
139 | (internal - f.internal).abs < (internal - c.internal).abs ? f : c
140 | end
141 |
142 | # Add `` to wrapped value.
143 | #
144 | # @param span [Integer]
145 | # @param unit [Symbol]
146 | # @return [Value]
147 | def +(span, unit)
148 | unit = Units.(unit)
149 | case unit
150 | when :sec, :min, :hour, :day
151 | plus_seconds(span, unit)
152 | when :week
153 | self.+(span * 7, :day)
154 | when :month
155 | plus_months(span)
156 | when :year
157 | merge(year: year + span)
158 | end
159 | end
160 |
161 | # @overload -(span, unit)
162 | # Subtracts `span units` from wrapped value.
163 | # @param span [Integer]
164 | # @param unit [Symbol]
165 | # @return [Value]
166 | # @overload -(date_or_time)
167 | # Produces {Diff}, allowing to calculate structured difference between two points in time.
168 | # @param date_or_time [Date, Time, DateTime]
169 | # @return [Diff]
170 | # Subtracts `span units` from wrapped value.
171 | def -(span_or_other, unit = nil)
172 | unit.nil? ? Diff.new(self, span_or_other) : self.+(-span_or_other, unit)
173 | end
174 |
175 | # Like {#+}, but allows conditional skipping of some periods. Increases value by `unit`
176 | # at least `span` times, on each iteration checking with block provided if this point
177 | # matches desired period; if it is not, it is skipped without increasing iterations
178 | # counter. Useful for "business date/time" algorithms.
179 | #
180 | # See {TimeCalc#iterate} for examples.
181 | #
182 | # @param span [Integer]
183 | # @param unit [Symbol]
184 | # @return [Value]
185 | # @yield [Time/Date/DateTime] Object of wrapped class
186 | # @yieldreturn [true, false] If this point in time is "suitable". If the falsey value is returned,
187 | # iteration is skipped without increasing the counter.
188 | def iterate(span, unit)
189 | block_given? or fail ArgumentError, 'No block given'
190 | Integer === span or fail ArgumentError, 'Only integer spans are supported' # rubocop:disable Style/CaseEquality
191 |
192 | Enumerator.produce(self) { |v| v.+((span <=> 0).nonzero? || 1, unit) }
193 | .lazy.select { |v| yield(v.internal) }
194 | .drop(span.abs).first
195 | end
196 |
197 | # Produces {Sequence} from this value to `date_or_time`
198 | #
199 | # @param date_or_time [Date, Time, DateTime]
200 | # @return [Sequence]
201 | def to(date_or_time)
202 | Sequence.new(from: self).to(date_or_time)
203 | end
204 |
205 | # Produces endless {Sequence} from this value, with step specified.
206 | #
207 | # @overload step(unit)
208 | # Shortcut for `step(1, unit)`
209 | # @param unit [Symbol]
210 | # @overload step(span, unit)
211 | # @param span [Integer]
212 | # @param unit [Symbol]
213 | # @return [Sequence]
214 | def step(span, unit = nil)
215 | span, unit = 1, span if unit.nil?
216 | Sequence.new(from: self).step(span, unit)
217 | end
218 |
219 | # Produces {Sequence} from this value to `this + `
220 | #
221 | # @param span [Integer]
222 | # @param unit [Symbol]
223 | # @return [Sequence]
224 | def for(span, unit)
225 | to(self.+(span, unit))
226 | end
227 |
228 | # @private
229 | def convert(klass)
230 | return dup if internal.class == klass
231 |
232 | Value.new(Types.convert(internal, klass))
233 | end
234 |
235 | private
236 |
237 | def floor_week
238 | extra_days = (internal.wday.nonzero? || 7) - 1
239 | floor(:day).-(extra_days, :days)
240 | end
241 |
242 | def plus_months(span)
243 | target = month + span.to_i
244 | m = (target - 1) % 12 + 1
245 | dy = (target - 1) / 12
246 | merge(year: year + dy, month: m)
247 | end
248 |
249 | def plus_seconds(span, unit)
250 | Value.new(internal + span * Units.multiplier_for(internal.class, unit))
251 | .then { |res| unit == :day ? DST.fix_value(res, self) : res }
252 | end
253 | end
254 | end
255 |
--------------------------------------------------------------------------------
/lib/time_calc/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class TimeCalc
4 | # @private
5 | VERSION = '0.0.4'
6 | end
7 |
--------------------------------------------------------------------------------
/spec/.rubocop.yml:
--------------------------------------------------------------------------------
1 | inherit_from: ../.rubocop.yml
2 |
3 | RSpec/ImplicitSubject:
4 | Enabled: false
5 |
6 | Layout/LineLength:
7 | Enabled: false
8 |
9 | Metrics/BlockLength:
10 | Enabled: false
11 |
12 | RSpec/EmptyExampleGroup:
13 | CustomIncludeMethods:
14 | - its
15 | - its_call
16 | - its_block
17 | - its_map
18 |
19 | Style/SymbolArray:
20 | Enabled: false
21 |
22 | RSpec/DescribeMethod:
23 | Enabled: false
24 |
25 | RSpec/NestedGroups:
26 | Enabled: false
27 |
28 | RSpec/ExampleLength:
29 | Enabled: false
30 |
31 | RSpec/EmptyLineAfterExample:
32 | Enabled: false
33 |
34 | RSpec/MultipleExpectations:
35 | Exclude:
36 | - 'time_calc_spec.rb'
37 |
--------------------------------------------------------------------------------
/spec/fixtures/ceil.csv:
--------------------------------------------------------------------------------
1 | '2015-03-28 11:40:20 +03',sec,'2015-03-28 11:40:20 +03'
2 | '2015-03-28 11:40:20 +03',min,'2015-03-28 11:41:00 +03'
3 | '2015-03-28 11:40:20 +03',hour,'2015-03-28 12:00:00 +03'
4 | '2015-03-28 11:40:20 +03',day,'2015-03-29 00:00:00 +03'
5 | '2015-03-28 11:40:20 +03',week,'2015-03-30 00:00:00 +03'
6 | '2015-03-28 11:40:20 +03',month,'2015-04-01 00:00:00 +03'
7 |
--------------------------------------------------------------------------------
/spec/fixtures/floor.csv:
--------------------------------------------------------------------------------
1 | '2015-03-28 11:40:20.123 +03',sec,'2015-03-28 11:40:20 +03'
2 | '2015-03-28 11:40:20.123 +03',min,'2015-03-28 11:40:00 +03'
3 | '2015-03-28 11:40:20.123 +03',hour,'2015-03-28 11:00:00 +03'
4 | '2015-03-28 11:40:20.123 +03',day,'2015-03-28 00:00:00 +03'
5 | '2015-03-28 11:40:20.123 +03',week,'2015-03-23 00:00:00 +03'
6 | '2015-03-28 11:40:20.123 +03',month,'2015-03-01 00:00:00 +03'
7 | '2015-03-28 11:40:20.123 +03',year,'2015-01-01 00:00:00 +03'
8 |
--------------------------------------------------------------------------------
/spec/fixtures/minus.csv:
--------------------------------------------------------------------------------
1 | 2015-03-27 11:40:20,1,sec,2015-03-27 11:40:19
2 | 2015-03-27 11:40:20,1,min,2015-03-27 11:39:20
3 | 2015-03-27 11:40:20,1,hour,2015-03-27 10:40:20
4 | 2015-03-27 11:40:20,1,day,2015-03-26 11:40:20
5 | 2015-03-27 11:40:20,1,week,2015-03-20 11:40:20
6 | 2015-03-27 11:40:20,1,month,2015-02-27 11:40:20
7 | 2015-03-27 11:40:20,1,year,2014-03-27 11:40:20
8 |
--------------------------------------------------------------------------------
/spec/fixtures/plus.csv:
--------------------------------------------------------------------------------
1 | # 1
2 | 2015-03-27 11:40:20 +03,1,sec,2015-03-27 11:40:21 +03
3 | 2015-03-27 11:40:20 +03,1,min,2015-03-27 11:41:20 +03
4 | 2015-03-27 11:40:20 +03,1,hour,2015-03-27 12:40:20 +03
5 | 2015-03-27 11:40:20 +03,1,day,2015-03-28 11:40:20 +03
6 | 2015-03-27 11:40:20 +03,1,week,2015-04-03 11:40:20 +03
7 | 2015-03-27 11:40:20 +03,1,month,2015-04-27 11:40:20 +03
8 | 2015-03-27 11:40:20 +03,1,year,2016-03-27 11:40:20 +03
9 | # 3
10 | 2015-03-27 11:40:20 +03,3,sec,2015-03-27 11:40:23 +0300
11 | 2015-03-27 11:40:20 +03,3,min,2015-03-27 11:43:20 +0300
12 | 2015-03-27 11:40:20 +03,3,hour,2015-03-27 14:40:20 +0300
13 | 2015-03-27 11:40:20 +03,3,day,2015-03-30 11:40:20 +0300
14 | 2015-03-27 11:40:20 +03,3,week,2015-04-17 11:40:20 +0300
15 | 2015-03-27 11:40:20 +03,3,month,2015-06-27 11:40:20 +0300
16 | 2015-03-27 11:40:20 +03,3,year,2018-03-27 11:40:20 +0300
17 | # 100
18 | 2015-03-27 11:40:20 +03,100,sec,2015-03-27 11:42:00 +0300
19 | 2015-03-27 11:40:20 +03,100,min,2015-03-27 13:20:20 +0300
20 | 2015-03-27 11:40:20 +03,100,hour,2015-03-31 15:40:20 +0300
21 | 2015-03-27 11:40:20 +03,100,day,2015-07-05 11:40:20 +0300
22 | 2015-03-27 11:40:20 +03,100,week,2017-02-24 11:40:20 +0300
23 | 2015-03-27 11:40:20 +03,100,month,2023-07-27 11:40:20 +0300
24 | 2015-03-27 11:40:20 +03,100,year,2115-03-27 11:40:20 +0300
25 | # 1000
26 | 2015-03-27 11:40:20 +03,1000,sec,2015-03-27 11:57:00 +0300
27 | 2015-03-27 11:40:20 +03,1000,min,2015-03-28 04:20:20 +0300
28 | 2015-03-27 11:40:20 +03,1000,hour,2015-05-08 03:40:20 +0300
29 | 2015-03-27 11:40:20 +03,1000,day,2017-12-21 11:40:20 +0300
30 | 2015-03-27 11:40:20 +03,1000,week,2034-05-26 11:40:20 +0300
31 | 2015-03-27 11:40:20 +03,1000,month,2098-07-27 11:40:20 +0300
32 | 2015-03-27 11:40:20 +03,1000,year,3015-03-27 11:40:20 +0300
33 | # 0
34 | 2015-03-27 11:40:20 +03,0,sec,2015-03-27 11:40:20 +0300
35 | 2015-03-27 11:40:20 +03,0,min,2015-03-27 11:40:20 +0300
36 | 2015-03-27 11:40:20 +03,0,hour,2015-03-27 11:40:20 +0300
37 | 2015-03-27 11:40:20 +03,0,day,2015-03-27 11:40:20 +0300
38 | 2015-03-27 11:40:20 +03,0,week,2015-03-27 11:40:20 +0300
39 | 2015-03-27 11:40:20 +03,0,month,2015-03-27 11:40:20 +0300
40 | 2015-03-27 11:40:20 +03,0,year,2015-03-27 11:40:20 +0300
41 | # -1
42 | 2015-03-27 11:40:20 +03,-1,sec,2015-03-27 11:40:19 +0300
43 | 2015-03-27 11:40:20 +03,-1,min,2015-03-27 11:39:20 +0300
44 | 2015-03-27 11:40:20 +03,-1,hour,2015-03-27 10:40:20 +0300
45 | 2015-03-27 11:40:20 +03,-1,day,2015-03-26 11:40:20 +0300
46 | 2015-03-27 11:40:20 +03,-1,week,2015-03-20 11:40:20 +0300
47 | 2015-03-27 11:40:20 +03,-1,month,2015-02-27 11:40:20 +0300
48 | 2015-03-27 11:40:20 +03,-1,year,2014-03-27 11:40:20 +0300
49 | # 1/2
50 | 2015-03-27 11:40:20 +03,1/2,sec,2015-03-27 11:40:20.500 +0300
51 | 2015-03-27 11:40:20 +03,1/2,min,2015-03-27 11:40:50 +0300
52 | 2015-03-27 11:40:20 +03,1/2,hour,2015-03-27 12:10:20 +0300
53 | 2015-03-27 11:40:20 +03,1/2,day,2015-03-27 23:40:20 +0300
54 | 2015-03-27 11:40:20 +03,1/2,week,2015-03-30 23:40:20 +0300
55 | 2015-03-27 11:40:20 +03,1/2,month,2015-03-27 11:40:20 +0300
56 | 2015-03-27 11:40:20 +03,1/2,year,2015-03-27 11:40:20 +0300
57 | # edge cases
58 | 2017-05-29 00:00:00 +02,9,month,2018-03-01 00:00:00 +02
59 | 2017-05-29 00:00:00 +02,10,month,2018-03-29 00:00:00 +02
--------------------------------------------------------------------------------
/spec/fixtures/round.yml:
--------------------------------------------------------------------------------
1 | :sec:
2 | :true: '2015-03-28 11:40:20'
3 | :false: '2015-03-28 11:40:20.123'
4 | :min:
5 | :true: '2015-03-28 11:40:00'
6 | :false: '2015-03-28 11:40:20'
7 | :hour:
8 | :true: '2015-03-28 11:00:00'
9 | :false: '2015-03-28 11:40:00'
10 | :day:
11 | :true: '2015-03-28 00:00:00'
12 | :false: '2015-03-28 11:00:00'
13 | :week:
14 | :true: '2015-03-23 00:00:00'
15 | :false: '2015-03-24 00:00:00'
16 | :month:
17 | :true: '2015-03-01 00:00:00'
18 | :false: '2015-03-28 00:00:00'
19 | :year:
20 | :true: '2015-01-01 00:00:00'
21 | :false: '2015-03-01 00:00:00'
22 |
23 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'time'
4 | require 'date'
5 | require 'yaml'
6 | require 'csv'
7 |
8 | require 'pp'
9 | require 'rspec/its'
10 | require 'saharspec'
11 | require 'simplecov'
12 |
13 | require 'active_support/time_with_zone'
14 | require 'active_support/core_ext/time' # otherwise TimeWithZone can't #to_s itself :facepalm:
15 |
16 | if RUBY_VERSION >= '2.6'
17 | require 'tzinfo'
18 |
19 | # In tests, we want to use ActiveSupport, which still depends on "old" TZinfo (1.1+)
20 | # But in other tests we want to test with "proper" timezones, so we imitate some API
21 | # of TZInfo 2+ to not have conflicting gem versions
22 | class TZInfo::Timezone # rubocop:disable Style/ClassAndModuleChildren
23 | def dst?(time = Time.now)
24 | period_for_local(time).dst?
25 | end
26 | end
27 | end
28 |
29 | SimpleCov.start
30 |
31 | require 'time_calc'
32 |
33 | def t(str)
34 | Time.parse(str)
35 | end
36 |
37 | def d(str)
38 | Date.parse(str)
39 | end
40 |
41 | def dt(str)
42 | DateTime.parse(str)
43 | end
44 |
45 | def vt(str)
46 | TimeCalc::Value.new(t(str))
47 | end
48 |
49 | def vd(str)
50 | TimeCalc::Value.new(d(str))
51 | end
52 |
53 | def vdt(str)
54 | TimeCalc::Value.new(dt(str))
55 | end
56 |
57 | def tvz(str, zone)
58 | # :shrug:
59 | ActiveSupport::TimeZone[zone].parse(str, Time.now)
60 | end
61 |
62 | def vtvz(str, zone)
63 | TimeCalc::Value.new(tvz(str, zone))
64 | end
65 |
--------------------------------------------------------------------------------
/spec/time_calc/diff_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe TimeCalc::Diff do
4 | subject(:diff) { described_class.new(t('2020-06-12 12:28 +03'), t('2019-06-01 14:50 +03')) }
5 |
6 | its(:inspect) { is_expected.to eq '#' }
7 |
8 | describe '#divmod' do
9 | subject { diff.method(:divmod) }
10 |
11 | its_call(1, :hour) { is_expected.to ret [9045, t('2020-06-12 11:50 +03')] }
12 | its_call(1, :week) { is_expected.to ret [53, t('2020-06-06 14:50 +03')] }
13 | its_call(1, :day) { is_expected.to ret [376, t('2020-06-11 14:50 +03')] }
14 | its_call(1, :month) { is_expected.to ret [12, t('2020-06-01 14:50 +03')] }
15 | its_call(1, :year) { is_expected.to ret [1, t('2020-06-01 14:50 +03')] }
16 |
17 | its_call(:month) { is_expected.to ret [12, t('2020-06-01 14:50 +03')] }
18 |
19 | its_call(5, :months) { is_expected.to ret [2, t('2020-04-01 14:50 +03')] }
20 |
21 | context 'when negative' do
22 | let(:diff) { described_class.new(t('2019-06-01 14:50 +03'), t('2020-06-12 12:28 +03')) }
23 |
24 | its_call(1, :year) { is_expected.to ret [-1, t('2019-06-12 12:28 +03')] }
25 | end
26 |
27 | context 'with Date' do
28 | let(:diff) { described_class.new(d('2020-06-12'), d('2019-06-01')) }
29 |
30 | # its_call(1, :hour) { is_expected.to ret [9045, t('2020-06-12 11:50 +03')] }
31 | its_call(1, :week) { is_expected.to ret [53, d('2020-06-06')] }
32 | its_call(1, :day) { is_expected.to ret [377, d('2020-06-12')] }
33 | its_call(1, :month) { is_expected.to ret [12, d('2020-06-01')] }
34 | its_call(1, :year) { is_expected.to ret [1, d('2020-06-01')] }
35 | end
36 |
37 | context 'with DateTime' do
38 | let(:diff) { described_class.new(dt('2020-06-12 12:28 +03'), dt('2019-06-01 14:50 +03')) }
39 |
40 | its_call(1, :hour) { is_expected.to ret [9045, dt('2020-06-12 11:50 +03')] }
41 | its_call(1, :week) { is_expected.to ret [53, dt('2020-06-06 14:50 +03')] }
42 | its_call(1, :day) { is_expected.to ret [376, dt('2020-06-11 14:50 +03')] }
43 | its_call(1, :month) { is_expected.to ret [12, dt('2020-06-01 14:50 +03')] }
44 | its_call(1, :year) { is_expected.to ret [1, dt('2020-06-01 14:50 +03')] }
45 | end
46 |
47 | context 'with different types' do
48 | let(:diff) { described_class.new(t('2020-06-12 12:28 +05'), d('2019-06-01')) }
49 |
50 | its_call(1, :hour) { is_expected.to ret [9060, t('2020-06-12 12:00 +05')] }
51 | its_call(1, :day) { is_expected.to ret [377, t('2020-06-12 00:00 +05')] }
52 | its_call(1, :week) { is_expected.to ret [53, t('2020-06-06 00:00 +05')] }
53 | its_call(1, :month) { is_expected.to ret [12, t('2020-06-01 00:00 +05')] }
54 | its_call(1, :year) { is_expected.to ret [1, t('2020-06-01 00:00 +05')] }
55 | end
56 |
57 | if RUBY_VERSION >= '2.6'
58 | require 'tzinfo'
59 |
60 | context 'when calculated over DST' do
61 | context 'when autumn' do
62 | let(:before) { Time.new(2019, 10, 26, 14, 30, 12, TZInfo::Timezone.get('Europe/Kiev')) }
63 | let(:after) { Time.new(2019, 10, 27, 14, 30, 12, TZInfo::Timezone.get('Europe/Kiev')) }
64 |
65 | it { expect(described_class.new(after, before).div(:day)).to eq 1 }
66 | it { expect(described_class.new(before, after).div(:day)).to eq(-1) }
67 | end
68 |
69 | context 'when spring' do
70 | let(:before) { Time.new(2019, 3, 30, 14, 30, 12, TZInfo::Timezone.get('Europe/Kiev')) }
71 | let(:after) { Time.new(2019, 3, 31, 14, 30, 12, TZInfo::Timezone.get('Europe/Kiev')) }
72 |
73 | it { expect(described_class.new(after, before).div(:day)).to eq 1 }
74 | it { expect(described_class.new(before, after).div(:day)).to eq(-1) }
75 | end
76 | end
77 | end
78 | end
79 |
80 | describe '#div' # tested by #divmod, in fact
81 |
82 | describe '#modulo' do
83 | subject { diff.method(:modulo) }
84 |
85 | its_call(5, :months) { is_expected.to ret t('2020-04-01 14:50 +03') }
86 | end
87 |
88 | describe '#' do
89 | its(:days) { is_expected.to eq 376 }
90 | end
91 |
92 | describe '#factorize' do
93 | subject { diff.method(:factorize) }
94 |
95 | its_call { is_expected.to ret(year: 1, month: 0, week: 1, day: 3, hour: 21, min: 38, sec: 0) }
96 | its_call(max: :month) { is_expected.to ret(month: 12, week: 1, day: 3, hour: 21, min: 38, sec: 0) }
97 | its_call(max: :month, min: :hour) { is_expected.to ret(month: 12, week: 1, day: 3, hour: 21) }
98 | its_call(max: :month, min: :hour, weeks: false) { is_expected.to ret(month: 12, day: 10, hour: 21) }
99 |
100 | context 'when zeroes' do
101 | let(:diff) { described_class.new(t('2019-06-12 12:28 +03'), t('2019-06-01 14:50 +03')) }
102 |
103 | its_call { is_expected.to ret(year: 0, month: 0, week: 1, day: 3, hour: 21, min: 38, sec: 0) }
104 | its_call(zeroes: false) { is_expected.to ret(week: 1, day: 3, hour: 21, min: 38, sec: 0) }
105 | end
106 |
107 | context 'with Date' do
108 | let(:diff) { described_class.new(d('2019-06-12'), t('2019-06-01 14:50 +03')) }
109 |
110 | its_call { is_expected.to ret(year: 0, month: 0, week: 1, day: 3, hour: 9, min: 10, sec: 0) }
111 | end
112 |
113 | context 'when negative' do
114 | let(:diff) { -super() }
115 |
116 | its_call { is_expected.to ret(year: -1, month: 0, week: -1, day: -3, hour: -21, min: -38, sec: 0) }
117 | end
118 | end
119 | end
120 |
--------------------------------------------------------------------------------
/spec/time_calc/op_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe TimeCalc::Op do
4 | subject(:op) { described_class.new([[:+, [1, :day]], [:round, [:hour]]]) }
5 |
6 | its(:chain) { is_expected.to eq [[:+, [1, :day]], [:round, [:hour]]] }
7 | its(:inspect) { is_expected.to eq '' }
8 |
9 | describe '#' do
10 | subject { op.floor(:day).-(1, :hour) }
11 |
12 | it {
13 | is_expected
14 | .to be_a(described_class)
15 | .and have_attributes(chain: [[:+, [1, :day]], [:round, [:hour]], [:floor, [:day]], [:-, [1, :hour]]])
16 | }
17 | end
18 |
19 | describe '#call' do
20 | subject { op.method(:call) }
21 |
22 | its_call(t('2019-06-28 14:30 +03')) { is_expected.to ret t('2019-06-29 15:00 +03') }
23 | end
24 |
25 | describe '#to_proc' do
26 | subject { op.to_proc }
27 |
28 | it { is_expected.to be_a Proc }
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/spec/time_calc/sequence_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe TimeCalc::Sequence do
4 | subject(:seq) { described_class.new(**args) }
5 |
6 | let(:args) { {from: vt('2019-06-01 14:30 +03'), to: vt('2019-06-05 17:20 +03'), step: [1, :day]} }
7 |
8 | its(:inspect) {
9 | is_expected.to eq '#'
10 | }
11 |
12 | describe '#each' do
13 | subject(:enum) { seq.each }
14 |
15 | context 'when fully defined' do
16 | it { is_expected.to be_a Enumerator }
17 | its(:to_a) {
18 | is_expected.to eq [
19 | t('2019-06-01 14:30 +03'),
20 | t('2019-06-02 14:30 +03'),
21 | t('2019-06-03 14:30 +03'),
22 | t('2019-06-04 14:30 +03'),
23 | t('2019-06-05 14:30 +03')
24 | ]
25 | }
26 | end
27 |
28 | context 'when step not defined' do
29 | let(:args) { super().slice(:from, :to) }
30 |
31 | its_block { is_expected.to raise_error TypeError, /No step defined/ }
32 | end
33 |
34 | context 'when to is not defined' do
35 | subject { enum.first(6) }
36 |
37 | let(:args) { super().slice(:from, :step) }
38 |
39 | it {
40 | is_expected.to eq [
41 | t('2019-06-01 14:30 +03'),
42 | t('2019-06-02 14:30 +03'),
43 | t('2019-06-03 14:30 +03'),
44 | t('2019-06-04 14:30 +03'),
45 | t('2019-06-05 14:30 +03'),
46 | t('2019-06-06 14:30 +03')
47 | ]
48 | }
49 | end
50 |
51 | context 'when downwards' do
52 | let(:args) { {from: vt('2019-06-05 14:30 +03'), to: vt('2019-06-01 17:20 +03'), step: [-1, :day]} }
53 |
54 | its(:to_a) {
55 | is_expected.to eq [
56 | t('2019-06-05 14:30 +03'),
57 | t('2019-06-04 14:30 +03'),
58 | t('2019-06-03 14:30 +03'),
59 | t('2019-06-02 14:30 +03')
60 | ]
61 | }
62 | end
63 |
64 | context 'when from=>to and step have different directions' do
65 | let(:args) { {from: vt('2019-06-05 14:30 +03'), to: vt('2019-06-01 17:20 +03'), step: [1, :day]} }
66 |
67 | its(:to_a) { is_expected.to eq [] }
68 | end
69 | end
70 |
71 | describe '#step' do
72 | its(:step) { is_expected.to eq [1, :day] }
73 | context 'with explicit span' do
74 | subject { seq.step(3, :days) }
75 |
76 | it { is_expected.to be_a(described_class).and have_attributes(from: seq.from, to: seq.to, step: [3, :days]) }
77 | end
78 |
79 | context 'with implicit span' do
80 | subject { seq.step(:day) }
81 |
82 | it { is_expected.to be_a(described_class).and have_attributes(from: seq.from, to: seq.to, step: [1, :day]) }
83 | end
84 | end
85 |
86 | describe '#to' do
87 | its(:to) { is_expected.to eq vt('2019-06-05 17:20 +03') }
88 |
89 | context 'when updating' do
90 | subject { seq.to(t('2019-06-12 15:20 +03')) }
91 |
92 | it { is_expected.to be_a(described_class).and have_attributes(from: seq.from, to: vt('2019-06-12 15:20 +03'), step: [1, :day]) }
93 | end
94 | end
95 |
96 | describe '#for' do
97 | subject { seq.for(3, :months) }
98 |
99 | it { is_expected.to be_a(described_class).and have_attributes(from: seq.from, to: vt('2019-09-01 14:30 +03'), step: [1, :day]) }
100 | end
101 |
102 | describe '#each_range'
103 | end
104 |
--------------------------------------------------------------------------------
/spec/time_calc/value_math_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'time_calc/value'
4 |
5 | RSpec.describe TimeCalc::Value, 'math' do
6 | {
7 | plus: :+,
8 | minus: :-,
9 | floor: :floor,
10 | ceil: :ceil
11 | # round: :round
12 | }.each do |filename, sym|
13 | describe "##{sym}" do
14 | CSV.read("spec/fixtures/#{filename}.csv")
15 | .reject { |r| r.first.start_with?('#') } # hand-made CSV comments!
16 | .each do |source, *args, expected_str|
17 | context "#{source} #{sym} #{args.join(' ')}" do
18 | subject { value.public_send(sym, *real_args).unwrap }
19 |
20 | let(:value) { described_class.new(t(source)) }
21 | let(:expected) { t(expected_str) }
22 | let(:real_args) { args.count == 1 ? args.last.to_sym : [args.first.to_r, args.last.to_sym] }
23 |
24 | it { is_expected.to eq expected }
25 | end
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/spec/time_calc/value_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'time_calc/value'
4 |
5 | RSpec.describe TimeCalc::Value do
6 | subject(:value) { described_class.new(source) }
7 |
8 | describe '.wrap' do
9 | subject { described_class.method(:wrap) }
10 |
11 | its_call(t('2019-06-28 14:28:48.123 +03')) {
12 | is_expected.to ret be_a(described_class).and have_attributes(unwrap: t('2019-06-28 14:28:48.123 +03'))
13 | }
14 | its_call(d('2019-06-28')) {
15 | is_expected.to ret be_a(described_class).and have_attributes(unwrap: d('2019-06-28'))
16 | }
17 | its_call(dt('2019-06-28 14:28:48.123 +03')) {
18 | is_expected.to ret be_a(described_class).and have_attributes(unwrap: dt('2019-06-28 14:28:48.123 +03'))
19 | }
20 | its_call('2019-06-28 14:28:48.123 +03') {
21 | is_expected.to raise_error ArgumentError
22 | }
23 | its_call(tvz('2019-06-28 14:28:48.123', 'Europe/Kiev')) {
24 | is_expected.to ret be_a(described_class)
25 | .and have_attributes(unwrap: ActiveSupport::TimeWithZone)
26 | .and have_attributes(unwrap: tvz('2019-06-28 14:28:48.123', 'Europe/Kiev'))
27 | }
28 |
29 | o1 = Object.new.tap { |obj|
30 | def obj.to_time
31 | t('2019-06-28 14:28:48.123 +03')
32 | end
33 | }
34 | its_call(o1) {
35 | is_expected.to ret be_a(described_class).and have_attributes(unwrap: t('2019-06-28 14:28:48.123 +03'))
36 | }
37 | o2 = Object.new.tap { |obj| # not something time-alike
38 | def obj.to_time
39 | '2019-06-28 14:28:48.123 +03'
40 | end
41 | }
42 | its_call(o2) {
43 | is_expected.to raise_error ArgumentError
44 | }
45 | end
46 |
47 | context 'with Time' do
48 | let(:source) { t('2019-06-28 14:28:48.123 +03') }
49 |
50 | its(:unwrap) { is_expected.to eq source }
51 | its(:inspect) { is_expected.to eq '#' }
52 |
53 | describe '#merge' do
54 | subject(:merge) { value.method(:merge) }
55 |
56 | its_call(year: 2018) { is_expected.to ret vt('2018-06-28 14:28:48.123 +03') }
57 |
58 | describe 'source symoblic timezone preservation' do
59 | subject { merge.(year: 2018) }
60 |
61 | # Without zone specification, it will have "system" zone, on my machine:
62 | # Time.parse('2019-06-28 14:28:48.123').zone => "EEST"
63 | let(:source) { t('2019-06-28 14:28:48.123') }
64 |
65 | its(:'unwrap.zone') { is_expected.to eq source.zone }
66 | end
67 | end
68 |
69 | describe '#truncate' do
70 | subject { value.method(:truncate) }
71 |
72 | its_call(:month) { is_expected.to ret vt('2019-06-01 00:00:00 +03') }
73 | its_call(:hour) { is_expected.to ret vt('2019-06-28 14:00:00 +03') }
74 | its_call(:sec) { is_expected.to ret vt('2019-06-28 14:28:48 +03') }
75 | end
76 |
77 | # Just basic "if it works" check. For comprehensive math correctness checks see math_spec.rb
78 | describe '#+' do
79 | subject { value.method(:+) }
80 |
81 | its_call(1, :year) { is_expected.to ret vt('2020-06-28 14:28:48.123 +03') }
82 | its_call(1, :month) { is_expected.to ret vt('2019-07-28 14:28:48.123 +03') }
83 | its_call(1, :hour) { is_expected.to ret vt('2019-06-28 15:28:48.123 +03') }
84 | end
85 |
86 | describe '#-' do
87 | subject { value.method(:-) }
88 |
89 | its_call(1, :year) { is_expected.to ret vt('2018-06-28 14:28:48.123 +03') }
90 | its_call(1, :month) { is_expected.to ret vt('2019-05-28 14:28:48.123 +03') }
91 | its_call(1, :hour) { is_expected.to ret vt('2019-06-28 13:28:48.123 +03') }
92 |
93 | context 'with other time' do
94 | subject { value - t('2018-06-28 14:28:48.123 +03') }
95 |
96 | it { is_expected.to be_kind_of(TimeCalc::Diff) }
97 | end
98 | end
99 |
100 | describe '#floor' do
101 | subject { value.method(:floor) }
102 |
103 | its_call(:month) { is_expected.to ret vt('2019-06-01 00:00:00 +03') }
104 | end
105 |
106 | describe '#ceil' do
107 | subject { value.method(:ceil) }
108 |
109 | its_call(:month) { is_expected.to ret vt('2019-07-01 00:00:00 +03') }
110 | its_call(:day) { is_expected.to ret vt('2019-06-29 00:00:00 +03') }
111 | end
112 |
113 | describe '#round' do
114 | subject { value.method(:round) }
115 |
116 | its_call(:month) { is_expected.to ret vt('2019-07-01 00:00:00 +03') }
117 | its_call(:hour) { is_expected.to ret vt('2019-06-28 14:00:00 +03') }
118 | end
119 |
120 | describe '#to' do
121 | subject { value.to(t('2019-07-01')) }
122 |
123 | it {
124 | is_expected
125 | .to be_a(TimeCalc::Sequence)
126 | .and have_attributes(from: value, to: vt('2019-07-01'), step: nil)
127 | }
128 | end
129 |
130 | describe '#step' do
131 | subject { value.step(3, :days) }
132 |
133 | it {
134 | is_expected
135 | .to be_a(TimeCalc::Sequence)
136 | .and have_attributes(from: value, step: [3, :days])
137 | }
138 | end
139 |
140 | describe '#iterate' do
141 | context 'without additional conditions' do
142 | subject { value.iterate(10, :days) }
143 |
144 | its_block { is_expected.to raise_error ArgumentError, 'No block given' }
145 | end
146 |
147 | context 'without trivial condition' do
148 | subject { value.iterate(10, :days) { true } }
149 |
150 | it { is_expected.to eq value.+(10, :days) }
151 | end
152 |
153 | context 'with condition' do
154 | subject { value.iterate(10, :days) { |t| (1..5).cover?(t.wday) } }
155 |
156 | it { is_expected.to eq value.+(14, :days) }
157 | end
158 |
159 | context 'with negative span' do
160 | subject { value.iterate(-10, :days) { true } }
161 |
162 | it { is_expected.to eq value.-(10, :days) }
163 | end
164 |
165 | context 'with zero span' do
166 | context 'when no changes necessary' do
167 | subject { value.iterate(0, :days) { true } }
168 |
169 | it { is_expected.to eq value }
170 | end
171 |
172 | context 'when changes necessary' do
173 | subject { value.iterate(0, :days) { |t| t.day < 20 } }
174 |
175 | it { is_expected.to eq vt('2019-07-01 14:28:48.123 +03') }
176 | end
177 | end
178 |
179 | context 'with non-integer span' do
180 | subject { value.iterate(10.5, :days) { true } }
181 |
182 | its_block { is_expected.to raise_error ArgumentError }
183 | end
184 | end
185 |
186 | if RUBY_VERSION >= '2.6'
187 | context 'with real time zones' do
188 | let(:zone) { TZInfo::Timezone.get('Europe/Zagreb') }
189 | let(:source) { Time.new(2019, 7, 5, 14, 30, 18, zone) }
190 |
191 | it 'preserves zone' do # rubocop:disable RSpec/MultipleExpectations
192 | expect(value.merge(month: 10).unwrap.zone).to eq zone
193 | expect(value.+(1, :hour).unwrap.zone).to eq zone
194 | expect(value.+(1, :month).unwrap.zone).to eq zone
195 | expect(value.+(1, :year).unwrap.zone).to eq zone
196 | expect(value.floor(:year).unwrap.zone).to eq zone
197 | end
198 |
199 | it 'works well over DST' do # rubocop:disable RSpec/MultipleExpectations
200 | t1 = Time.new(2019, 10, 26, 14, 30, 12, TZInfo::Timezone.get('Europe/Kiev'))
201 | t2 = Time.new(2019, 10, 27, 14, 30, 12, TZInfo::Timezone.get('Europe/Kiev'))
202 | expect(TimeCalc.(t1).+(1, :day)).to eq t2
203 | expect(TimeCalc.(t2).-(1, :day)).to eq t1
204 | end
205 | end
206 | end
207 | end
208 |
209 | context 'with Date' do
210 | let(:source) { d('2019-06-28') }
211 |
212 | its(:unwrap) { is_expected.to eq source }
213 | its(:inspect) { is_expected.to eq '#' }
214 |
215 | describe '#merge' do
216 | subject { value.method(:merge) }
217 |
218 | its_call(year: 2018) { is_expected.to ret vd('2018-06-28') }
219 | end
220 |
221 | describe '#truncate' do
222 | subject { value.method(:truncate) }
223 |
224 | its_call(:month) { is_expected.to ret vd('2019-06-01') }
225 | its_call(:hour) { is_expected.to ret vd('2019-06-28') }
226 | its_call(:sec) { is_expected.to ret vd('2019-06-28') }
227 | end
228 |
229 | describe '#+' do
230 | subject { value.method(:+) }
231 |
232 | its_call(1, :year) { is_expected.to ret vd('2020-06-28') }
233 | its_call(1, :month) { is_expected.to ret vd('2019-07-28') }
234 | its_call(1, :hour) { is_expected.to ret vd('2019-06-28') }
235 | end
236 | end
237 |
238 | context 'with DateTime' do
239 | let(:source) { dt('2019-06-28 14:28:48.123 +03') }
240 |
241 | its(:unwrap) { is_expected.to eq source }
242 | its(:inspect) { is_expected.to eq '#' }
243 |
244 | describe '#merge' do
245 | subject { value.method(:merge) }
246 |
247 | its_call(year: 2018) { is_expected.to ret vdt('2018-06-28 14:28:48.123 +03') }
248 | end
249 |
250 | describe '#truncate' do
251 | subject { value.method(:truncate) }
252 |
253 | its_call(:month) { is_expected.to ret vdt('2019-06-01 00:00:00 +03') }
254 | its_call(:hour) { is_expected.to ret vdt('2019-06-28 14:00:00 +03') }
255 | its_call(:sec) { is_expected.to ret vdt('2019-06-28 14:28:48 +03') }
256 | end
257 |
258 | describe '#+' do
259 | subject { value.method(:+) }
260 |
261 | its_call(1, :year) { is_expected.to ret vdt('2020-06-28 14:28:48.123 +03') }
262 | its_call(1, :month) { is_expected.to ret vdt('2019-07-28 14:28:48.123 +03') }
263 | its_call(1, :hour) { is_expected.to ret vdt('2019-06-28 15:28:48.123 +03') }
264 | end
265 | end
266 |
267 | context 'with ActiveSupport::TimeWithZone' do
268 | let(:source) { tvz('2019-06-28 14:28:48.123', 'Europe/Kiev') }
269 |
270 | its(:unwrap) { is_expected.to eq source }
271 | its(:inspect) { is_expected.to eq '#' }
272 |
273 | describe '#merge' do
274 | subject { value.method(:merge) }
275 |
276 | its_call(year: 2018) { is_expected.to ret vtvz('2018-06-28 14:28:48.123', 'Europe/Kiev') }
277 | end
278 |
279 | describe '#truncate' do
280 | subject { value.method(:truncate) }
281 |
282 | its_call(:month) { is_expected.to ret vtvz('2019-06-01 00:00:00', 'Europe/Kiev') }
283 | its_call(:hour) { is_expected.to ret vtvz('2019-06-28 14:00:00', 'Europe/Kiev') }
284 | its_call(:sec) { is_expected.to ret vtvz('2019-06-28 14:28:48', 'Europe/Kiev') }
285 | end
286 |
287 | describe '#+' do
288 | subject { value.method(:+) }
289 |
290 | its_call(1, :year) { is_expected.to ret vtvz('2020-06-28 14:28:48.123', 'Europe/Kiev') }
291 | its_call(1, :month) { is_expected.to ret vtvz('2019-07-28 14:28:48.123', 'Europe/Kiev') }
292 | its_call(1, :hour) { is_expected.to ret vtvz('2019-06-28 15:28:48.123', 'Europe/Kiev') }
293 | end
294 | end
295 | end
296 |
--------------------------------------------------------------------------------
/spec/time_calc_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe TimeCalc do
4 | describe 'instance' do
5 | subject(:calc) { described_class.new(start) }
6 |
7 | let(:start) { t('2018-03-01 18:30:45 +02') }
8 |
9 | its(:inspect) { is_expected.to eq '#' }
10 |
11 | it 'delegates operations that return time' do
12 | expect(calc.+(2, :days)).to eq t('2018-03-03 18:30:45 +02')
13 | expect(calc.-(2, :days)).to eq t('2018-02-27 18:30:45 +02')
14 | expect(calc.floor(:day)).to eq t('2018-03-01 00:00:00 +02')
15 | expect(calc.ceil(:day)).to eq t('2018-03-02 00:00:00 +02')
16 | expect(calc.round(:day)).to eq t('2018-03-02 00:00:00 +02')
17 | end
18 |
19 | it 'delegates operations that return sequences' do
20 | expect(calc.to(t('2019-01-01'))).to eq TimeCalc::Sequence.new(from: start, to: t('2019-01-01'))
21 | expect(calc.step(3, :days)).to eq TimeCalc::Sequence.new(from: start, step: [3, :days])
22 | expect(calc.for(3, :days)).to eq TimeCalc::Sequence.new(from: start, to: t('2018-03-04 18:30:45 +02'))
23 | end
24 |
25 | it 'delegates operations that return diff' do
26 | expect(calc - t('2019-01-01')).to be_a TimeCalc::Diff
27 | end
28 | end
29 |
30 | describe 'class' do
31 | it 'has shortcuts for self-creation and Value' do
32 | allow(Time).to receive(:now).and_return(t('2018-03-01 18:30:45'))
33 | allow(Date).to receive(:today).and_return(d('2018-03-01'))
34 | expect(described_class.now).to eq described_class.new(Time.now)
35 | expect(described_class.today).to eq described_class.new(Date.today)
36 | expect(described_class.from_now).to eq TimeCalc::Value.new(Time.now)
37 | expect(described_class.from_today).to eq TimeCalc::Value.new(Date.today)
38 | end
39 |
40 | it 'has shortcuts for op creation' do
41 | expect(described_class.+(5, :days).floor(:hour))
42 | .to be_an(TimeCalc::Op).and have_attributes(chain: [[:+, [5, :days]], [:floor, [:hour]]])
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/time_calc.gemspec:
--------------------------------------------------------------------------------
1 | require './lib/time_calc/version'
2 |
3 | Gem::Specification.new do |s|
4 | s.name = 'time_calc'
5 | s.version = TimeCalc::VERSION
6 | s.authors = ['Victor Shepelev']
7 | s.email = 'zverok.offline@gmail.com'
8 | s.homepage = 'https://github.com/zverok/time_calc'
9 | s.metadata = {
10 | 'bug_tracker_uri' => 'https://github.com/zverok/time_calc/issues',
11 | 'changelog_uri' => 'https://github.com/zverok/time_calc/blob/master/Changelog.md',
12 | 'documentation_uri' => 'https://www.rubydoc.info/gems/time_calc/',
13 | 'homepage_uri' => 'https://github.com/zverok/time_calc',
14 | 'source_code_uri' => 'https://github.com/zverok/time_calc'
15 | }
16 |
17 | s.summary = 'Easy time math'
18 | s.description = <<-EOF
19 | TimeCalc is a library for idiomatic time calculations, like "plus N days", "floor to month start",
20 | "how many hours between those dates", "sequence of months from this to that". It intends to
21 | be small and easy to remember without any patching of core classes.
22 | EOF
23 | s.licenses = ['MIT']
24 |
25 | s.required_ruby_version = '>= 2.3.0'
26 |
27 | s.files = `git ls-files lib LICENSE.txt *.md`.split($RS)
28 | s.require_paths = ["lib"]
29 |
30 | s.add_runtime_dependency 'backports', '>= 3.17.0'
31 |
32 | s.add_development_dependency 'rubocop', '~> 0.81.0'
33 | s.add_development_dependency 'rubocop-rspec', '~> 1.38.0'
34 |
35 | s.add_development_dependency 'rspec', '>= 3.8'
36 | s.add_development_dependency 'rspec-its', '~> 1'
37 | s.add_development_dependency 'saharspec', '>= 0.0.7'
38 | s.add_development_dependency 'simplecov', '~> 0.9'
39 | s.add_development_dependency 'tzinfo', '~> 1.1'
40 |
41 | s.add_development_dependency 'activesupport', '>= 5.0' # to test with TimeWithZone
42 |
43 | s.add_development_dependency 'rake'
44 | s.add_development_dependency 'rubygems-tasks'
45 |
46 | s.add_development_dependency 'yard'
47 | end
48 |
--------------------------------------------------------------------------------