├── .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 | [![Gem Version](https://badge.fury.io/rb/time_calc.svg)](http://badge.fury.io/rb/time_calc) 4 | [![Build Status](https://travis-ci.org/zverok/time_calc.svg?branch=master)](https://travis-ci.org/zverok/time_calc) 5 | [![Documentation](http://b.repl.ca/v1/yard-docs-blue.png)](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 | --------------------------------------------------------------------------------