├── .github └── workflows │ └── build.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── gemfiles ├── Gemfile.activesupport-7.1.x ├── Gemfile.activesupport-7.2.x ├── Gemfile.activesupport-8.x └── Gemfile.activesupport-edge ├── lib ├── working_hours.rb └── working_hours │ ├── computation.rb │ ├── config.rb │ ├── core_ext │ ├── date_and_time.rb │ └── integer.rb │ ├── duration.rb │ ├── duration_proxy.rb │ ├── module.rb │ └── version.rb ├── spec ├── spec_helper.rb ├── working_hours │ ├── computation_spec.rb │ ├── config_spec.rb │ ├── core_ext │ │ ├── date_and_time_spec.rb │ │ └── integer_spec.rb │ ├── duration_proxy_spec.rb │ └── duration_spec.rb └── working_hours_spec.rb └── working_hours.gemspec /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: push 3 | jobs: 4 | specs: 5 | runs-on: ubuntu-latest 6 | timeout-minutes: 10 7 | strategy: 8 | matrix: 9 | ruby_version: 10 | - 3.1.6 11 | - 3.2.6 12 | - 3.3.6 13 | - jruby-9.4.9.0 14 | gemfile: 15 | - gemfiles/Gemfile.activesupport-7.1.x 16 | - gemfiles/Gemfile.activesupport-7.2.x 17 | - gemfiles/Gemfile.activesupport-8.x 18 | exclude: 19 | - gemfile: gemfiles/Gemfile.activesupport-8.x 20 | ruby_version: 3.1.6 21 | - gemfile: gemfiles/Gemfile.activesupport-8.x 22 | ruby_version: jruby-9.4.9.0 23 | env: 24 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v3 28 | with: 29 | fetch-depth: 0 30 | 31 | - name: Setup Ruby 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby_version }} 35 | bundler-cache: true 36 | 37 | - name: Run rspec 38 | run: bundle exec rspec 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .ruby-gemset 7 | .ruby-version 8 | Gemfile.lock 9 | gemfiles/*.lock 10 | InstalledFiles 11 | _yardoc 12 | coverage 13 | doc/ 14 | lib/bundler/man 15 | pkg 16 | rdoc 17 | spec/reports 18 | test/tmp 19 | test/version_tmp 20 | tmp 21 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Unreleased 2 | 3 | [Compare master with v1.5.0](https://github.com/intrepidd/working_hours/compare/v1.5.0...master) 4 | 5 | # v1.5.0 6 | This release does not include any code change, there is only a dependency bump to ActiveSupport >= 7.0 to reflect the actual versions the gem supports. 7 | 8 | * Drop support for unsupported Ruby & ActiveSupport versions 9 | * Add support to ActiveSupport 7 and 8 10 | * Changes gemspec dependency to ActiveSupport >= 7.0 11 | 12 | # v1.4.1 13 | * Add InvalidConfiguration error code to allow custom message or behavior - [#47](https://github.com/Intrepidd/working_hours/pull/47) 14 | 15 | # v1.4.0 16 | * New config option: holiday_hours - [#37](https://github.com/Intrepidd/working_hours/pull/37) 17 | 18 | # v1.3.2 19 | * Improve support for time shifts - [#46](https://github.com/Intrepidd/working_hours/pull/46) 20 | 21 | # v1.3.1 22 | * Improve computation accuracy in `advance_to_working_time` and `working_time_between` by using more exact (integer-based) time operations instead of floating point numbers - [#44](https://github.com/Intrepidd/working_hours/pull/44) 23 | * Raise an exception when we detect an infinite loops in `advance_to_working_time` to improve resilience and make debugging easier - [#44](https://github.com/Intrepidd/working_hours/pull/44) 24 | * Use a Rational number for the midnight value to avoid leaking sub-nanoseconds residue because of floating point accuracy - [#44](https://github.com/Intrepidd/working_hours/pull/44) 25 | 26 | # v1.3.0 27 | * Improve supports for fractional seconds in input times by only rounding results at the end - [#42](https://github.com/Intrepidd/working_hours/issues/42) [#43](https://github.com/Intrepidd/working_hours/pull/43) 28 | * Increase code safety by always initializing an empty hash for each day of the week in the precompiled config (inspired by [#35](https://github.com/Intrepidd/working_hours/pull/35) 29 | 30 | # v1.2.0 31 | * Drop support for ruby 2.0, 2.1, 2.2 and 2.3 32 | * Drop support for jruby 1.7 and 9.0 33 | * Drop support for ActiveSupport 3.x 34 | * Add support for jruby 9.2 35 | * Add support for ruby 2.5, 2.6 and 2.7 36 | * Add support for ActiveSupport 5.x and 6.x 37 | * Fix day computations when origin is a holiday or a non worked day - [#39](https://github.com/Intrepidd/working_hours/pull/39) 38 | 39 | 40 | # v1.1.4 41 | * Fix thread safety - [#36](https://github.com/Intrepidd/working_hours/pull/36) 42 | 43 | # v1.1.3 44 | * Fixed warnings with Ruby 2.4.0+ - [#32](https://github.com/Intrepidd/working_hours/pull/32) 45 | * Fix install bug with jruby 1.7.20 46 | 47 | # v1.1.2 48 | * Fixed an issue of float imprecision causing infinite loop - [#27](https://github.com/Intrepidd/working_hours/pull/27) 49 | * Added #next_working_time and #advance_to_closing_time - [#23](https://github.com/Intrepidd/working_hours/pull/23) 50 | 51 | _06/12/2015_ 52 | 53 | # v1.1.1 54 | * Fix infinite loop happening when rewinding seconds and crossing through midgnight 55 | 56 | _18/08/2015_ 57 | 58 | # v1.1.0 59 | * Config set globally is now properly inherited in new threads. This fixes the issue when setting the config once in an initializer won't work in threaded web servers. 60 | 61 | _03/04/2015_ 62 | 63 | # v1.0.4 64 | * Fixed a nasty stack level too deep error on DateTime#+ and DateTime#- (thanks @jlanatta) 65 | 66 | _27/03/2015_ 67 | 68 | # v1.0.3 69 | 70 | * Relax configuration input formats - [#10](https://github.com/Intrepidd/working_hours/pull/10) 71 | * Small improvements to the Readme 72 | 73 | _08/11/2014_ 74 | 75 | # v1.0.2 76 | 77 | * Dropped use of `prepend` in favor of `alias_method` for core extensions to increase compability with jruby. 78 | 79 | _15/10/2014_ 80 | 81 | # v1.0.1 82 | 83 | * Fix bug when calling ``1.working.hour.ago`` would return a time in your system zone instead of the configured time zone. This was due to a conversion to Time that loses the timezone information. We'll now return an ``ActiveSupport::TimeWithZone``. 84 | 85 | _10/10/2014_ 86 | 87 | # v1.0.0 88 | 89 | * Replace config freeze by hash based caching (config is recompiled when changed), this avoids freezing unwanted objects (nil, timezones, integers, etc..) 90 | 91 | _15/09/2014_ 92 | 93 | # v0.9.0 94 | 95 | * First beta release 96 | 97 | _24/08/2014_ 98 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in working_hours.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Intrepidd 2 | 3 | MIT License 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 | # WorkingHours 2 | 3 | [![build](https://github.com/Intrepidd/working_hours/actions/workflows/build.yml/badge.svg)](https://github.com/Intrepidd/working_hours/actions/workflows/build.yml) 4 | 5 | A modern ruby gem allowing to do time calculation with working hours. 6 | 7 | Compatible and tested with: 8 | - Ruby `3.1`, `3.2`, `3.3`, JRuby `9.4` 9 | - ActiveSupport `7.x`, `8.x` 10 | 11 | ## Installation 12 | 13 | Gemfile: 14 | 15 | ```ruby 16 | gem 'working_hours' 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```ruby 22 | require 'working_hours' 23 | 24 | # Move forward 25 | 1.working.day.from_now 26 | 2.working.hours.from_now 27 | 15.working.minutes.from_now 28 | 29 | # Move backward 30 | 1.working.day.ago 31 | 2.working.hours.ago 32 | 15.working.minutes.ago 33 | 34 | # Start from custom Date or Time 35 | Date.new(2014, 12, 31) + 8.working.days # => Mon, 12 Jan 2015 36 | Time.utc(2014, 8, 4, 8, 32) - 4.working.hours # => 2014-08-01 13:00:00 37 | 38 | # Compute working days between two dates 39 | friday = Date.new(2014, 10, 17) 40 | monday = Date.new(2014, 10, 20) 41 | friday.working_days_until(monday) # => 1 42 | # Time is considered at end of day, so: 43 | # - friday to saturday = 0 working days 44 | # - sunday to monday = 1 working days 45 | 46 | # Compute working duration (in seconds) between two times 47 | from = Time.utc(2014, 8, 3, 8, 32) # sunday 8:32am 48 | to = Time.utc(2014, 8, 4, 10, 32) # monday 10:32am 49 | from.working_time_until(to) # => 5520 (1.hour + 32.minutes) 50 | 51 | # Know if a day is worked 52 | Date.new(2014, 12, 28).working_day? # => false 53 | 54 | # Know if a time is worked 55 | Time.utc(2014, 8, 4, 7, 16).in_working_hours? # => false 56 | 57 | # Advance to next working time 58 | WorkingHours.advance_to_working_time(Time.utc(2014, 8, 4, 7, 16)) # => Mon, 04 Aug 2014 09:00:00 UTC +00:00 59 | 60 | # Advance to next closing time 61 | WorkingHours.advance_to_closing_time(Time.utc(2014, 8, 4, 7, 16)) # => Mon, 04 Aug 2014 17:00:00 UTC +00:00 62 | WorkingHours.advance_to_closing_time(Time.utc(2014, 8, 4, 10, 16)) # => Mon, 04 Aug 2014 17:00:00 UTC +00:00 63 | WorkingHours.advance_to_closing_time(Time.utc(2014, 8, 4, 18, 16)) # => Tue, 05 Aug 2014 17:00:00 UTC +00:00 64 | 65 | # Next working time 66 | sunday = Time.utc(2014, 8, 3) 67 | monday = WorkingHours.next_working_time(sunday) # => Mon, 04 Aug 2014 09:00:00 UTC +00:00 68 | tuesday = WorkingHours.next_working_time(monday) # => Tue, 05 Aug 2014 09:00:00 UTC +00:00 69 | 70 | # Return to previous working time 71 | WorkingHours.return_to_working_time(Time.utc(2014, 8, 4, 7, 16)) # => Fri, 01 Aug 2014 17:00:00 UTC +00:00 72 | ``` 73 | 74 | ## Configuration 75 | 76 | The working hours configuration is thread safe and consists of a hash defining working periods for each day, a time zone and a list of days off. You can set it once, for example in a initializer for rails: 77 | 78 | ```ruby 79 | # Configure working hours 80 | WorkingHours::Config.working_hours = { 81 | :tue => {'09:00' => '12:00', '13:00' => '17:00'}, 82 | :wed => {'09:00' => '12:00', '13:00' => '17:00'}, 83 | :thu => {'09:00' => '12:00', '13:00' => '17:00'}, 84 | :fri => {'09:00' => '12:00', '13:00' => '17:05:30'}, 85 | :sat => {'19:00' => '24:00'} 86 | } 87 | 88 | # Configure timezone (uses activesupport, defaults to UTC) 89 | WorkingHours::Config.time_zone = 'Paris' 90 | 91 | # Configure holidays 92 | WorkingHours::Config.holidays = [Date.new(2014, 12, 31)] 93 | ``` 94 | 95 | Or you can set it for the duration of a block with the `with_config` method, this is particularly useful with `around_filter`: 96 | 97 | ```ruby 98 | WorkingHours::Config.with_config(working_hours: {mon:{'09:00' => '18:00'}}, holidays: [], time_zone: 'Paris') do 99 | # Intense calculations 100 | end 101 | ``` 102 | ``with_config`` uses keyword arguments, you can pass all or some of the supported arguments : 103 | - ``working_hours`` 104 | - ``holidays`` 105 | - ``time_zone`` 106 | 107 | ### Holiday hours 108 | Sometimes you need to configure different working hours as a one-off, e.g. the working day might end earlier on Christmas Eve. 109 | 110 | You can configure this with the `holiday_hours` option, either as an override on the existing working hours, or as a set of hours that *are* being worked on a holiday day. 111 | 112 | If *any* hours are set for a calendar day in `holiday_hours`, then the `working_hours` for that day will be ignored, and only the entries in `holiday_hours` taken into consideration. 113 | 114 | ```ruby 115 | # Configure holiday hours 116 | WorkingHours::Config.holiday_hours = {Date.new(2020, 12, 24) => {'09:00' => '12:00', '13:00' => '15:00'}} 117 | ``` 118 | 119 | ### Handling errors 120 | 121 | If the configuration is erroneous, an ``WorkingHours::InvalidConfiguration`` exception will be raised containing the appropriate error message. 122 | 123 | You can also access the error code in case you want to implement custom behavior or changing one specific message, e.g: 124 | 125 | ```ruby 126 | rescue WorkingHours::InvalidConfiguration => e 127 | if e.error_code == :empty 128 | raise StandardError.new "Config is required" 129 | end 130 | raise e 131 | end 132 | ``` 133 | 134 | ## No core extensions / monkey patching 135 | 136 | Core extensions (monkey patching to add methods on Time, Date, Numbers, etc.) are handy but not appreciated by everyone. WorkingHours can also be used **without any monkey patching**: 137 | 138 | ```ruby 139 | require 'working_hours/module' 140 | 141 | # Move forward 142 | WorkingHours::Duration.new(1, :days).from_now 143 | WorkingHours::Duration.new(2, :hours).from_now 144 | WorkingHours::Duration.new(15, :minutes).from_now 145 | 146 | # Move backward 147 | WorkingHours::Duration.new(1, :days).ago 148 | WorkingHours::Duration.new(2, :hours).ago 149 | WorkingHours::Duration.new(15, :minutes).ago 150 | 151 | # Start from custom Date or Time 152 | WorkingHours::Duration.new(8, :days).since(Date.new(2014, 12, 31)) # => Mon, 12 Jan 2015 153 | WorkingHours::Duration.new(4, :hours).until(Time.utc(2014, 8, 4, 8, 32)) # => 2014-08-01 13:00:00 154 | 155 | # Compute working days between two dates 156 | friday = Date.new(2014, 10, 17) 157 | monday = Date.new(2014, 10, 20) 158 | WorkingHours.working_days_between(friday, monday) # => 1 159 | # Time is considered at end of day, so: 160 | # - friday to saturday = 0 working days 161 | # - sunday to monday = 1 working days 162 | 163 | # Compute working duration (in seconds) between two times 164 | from = Time.utc(2014, 8, 3, 8, 32) # sunday 8:32am 165 | to = Time.utc(2014, 8, 4, 10, 32) # monday 10:32am 166 | WorkingHours.working_time_between(from, to) # => 5520 (1.hour + 32.minutes) 167 | 168 | # Know if a day is worked 169 | WorkingHours.working_day?(Date.new(2014, 12, 28)) # => false 170 | 171 | # Know if a time is worked 172 | WorkingHours.in_working_hours?(Time.utc(2014, 8, 4, 7, 16)) # => false 173 | ``` 174 | 175 | ## Use in your class/module 176 | 177 | If you want to use working hours only inside a specific class or module, you can include its computation methods like this: 178 | 179 | ```ruby 180 | require 'working_hours/module' 181 | 182 | class Order 183 | include WorkingHours 184 | 185 | def shipping_date_estimate 186 | Duration.new(2, :days).since(payment_received_at) 187 | end 188 | 189 | def payment_delay 190 | working_days_between(created_at, payment_received_at) 191 | end 192 | end 193 | ``` 194 | 195 | ## Timezones 196 | 197 | This gem uses a simple but efficient approach in dealing with timezones. When you define your working hours **you have to choose** a timezone associated with it (in the config example, the working hours are in Paris time). Then, any time used in calculation will be converted to this timezone first, so you don't have to worry if your times are local or UTC as long as they are correct :) 198 | 199 | ## Alternatives 200 | 201 | There is a gem called [business_time](https://github.com/bokmann/business_time) already available to do this kind of computation and it was of great help to us. But we decided to start another one because business_time is suffering from a few [bugs](https://github.com/bokmann/business_time/pull/84) and [inconsistencies](https://github.com/bokmann/business_time/issues/50). It also lacks essential features to us (like working minutes computation). 202 | 203 | Another gem called [biz](https://github.com/zendesk/biz) was released after working_hours to bring some alternative. 204 | 205 | ## Contributing 206 | 207 | 1. Fork it ( http://github.com/intrepidd/working_hours/fork ) 208 | 2. Create your feature branch (`git checkout -b my-new-feature`) 209 | 3. Commit your changes (`git commit -am 'Add some feature'`) 210 | 4. Push to the branch (`git push origin my-new-feature`) 211 | 5. Create new Pull Request 212 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | 8 | desc "Open an irb session preloaded with working_hours" 9 | task :console do 10 | sh "irb -rubygems -I lib -r working_hours.rb" 11 | end -------------------------------------------------------------------------------- /gemfiles/Gemfile.activesupport-7.1.x: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec :path => '..' 4 | 5 | gem 'activesupport', '~> 7.1' 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.activesupport-7.2.x: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec :path => '..' 4 | 5 | gem 'activesupport', '~> 7.2' 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.activesupport-8.x: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec :path => '..' 4 | 5 | gem 'activesupport', '~> 8.0' 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.activesupport-edge: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec :path => '..' 4 | 5 | gem 'activesupport', github: 'rails/rails', branch: 'main' 6 | -------------------------------------------------------------------------------- /lib/working_hours.rb: -------------------------------------------------------------------------------- 1 | require "working_hours/module" 2 | require "working_hours/core_ext/integer" 3 | require "working_hours/core_ext/date_and_time" 4 | -------------------------------------------------------------------------------- /lib/working_hours/computation.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/all' 2 | require 'working_hours/config' 3 | 4 | module WorkingHours 5 | module Computation 6 | 7 | def add_days origin, days, config: nil 8 | return origin if days.zero? 9 | 10 | config ||= wh_config 11 | time = in_config_zone(origin, config: config) 12 | time += (days <=> 0).day until working_day?(time, config: config) 13 | 14 | while days > 0 15 | time += 1.day 16 | days -= 1 if working_day?(time, config: config) 17 | end 18 | while days < 0 19 | time -= 1.day 20 | days += 1 if working_day?(time, config: config) 21 | end 22 | convert_to_original_format time, origin 23 | end 24 | 25 | def add_hours origin, hours, config: nil 26 | config ||= wh_config 27 | add_minutes origin, hours * 60, config: config 28 | end 29 | 30 | def add_minutes origin, minutes, config: nil 31 | config ||= wh_config 32 | add_seconds origin, minutes * 60, config: config 33 | end 34 | 35 | def add_seconds origin, seconds, config: nil 36 | config ||= wh_config 37 | time = in_config_zone(origin, config: config) 38 | while seconds > 0 39 | # roll to next business period 40 | time = advance_to_working_time(time, config: config) 41 | # look at working ranges 42 | time_in_day = time.seconds_since_midnight 43 | working_hours_for(time, config: config).each do |from, to| 44 | if time_in_day >= from and time_in_day < to 45 | # take all we can 46 | take = [to - time_in_day, seconds].min 47 | # advance time 48 | time += take 49 | # decrease seconds 50 | seconds -= take 51 | end 52 | end 53 | end 54 | while seconds < 0 55 | # roll to previous business period 56 | time = return_to_exact_working_time(time, config: config) 57 | # look at working ranges 58 | time_in_day = time.seconds_since_midnight 59 | 60 | working_hours_for(time, config: config).reverse_each do |from, to| 61 | if time_in_day > from and time_in_day <= to 62 | # take all we can 63 | take = [time_in_day - from, -seconds].min 64 | # advance time 65 | time -= take 66 | # decrease seconds 67 | seconds += take 68 | end 69 | end 70 | end 71 | convert_to_original_format(time.round, origin) 72 | end 73 | 74 | def advance_to_working_time time, config: nil 75 | config ||= wh_config 76 | time = in_config_zone(time, config: config) 77 | loop do 78 | # skip holidays and weekends 79 | while not working_day?(time, config: config) 80 | time = (time + 1.day).beginning_of_day 81 | end 82 | # find first working range after time 83 | time_in_day = time.seconds_since_midnight 84 | 85 | working_hours_for(time, config: config).each do |from, to| 86 | return time if time_in_day >= from and time_in_day < to 87 | return move_time_of_day(time, from) if from >= time_in_day 88 | end 89 | # if none is found, go to next day and loop 90 | time = (time + 1.day).beginning_of_day 91 | end 92 | end 93 | 94 | def advance_to_closing_time time, config: nil 95 | config ||= wh_config 96 | time = in_config_zone(time, config: config) 97 | loop do 98 | # skip holidays and weekends 99 | while not working_day?(time, config: config) 100 | time = (time + 1.day).beginning_of_day 101 | end 102 | # find next working range after time 103 | time_in_day = time.seconds_since_midnight 104 | working_hours_for(time, config: config).each do |from, to| 105 | return move_time_of_day(time, to) if time_in_day < to 106 | end 107 | # if none is found, go to next day and loop 108 | time = (time + 1.day).beginning_of_day 109 | end 110 | end 111 | 112 | def next_working_time(time, config: nil) 113 | time = advance_to_closing_time(time, config: config) if in_working_hours?(time, config: config) 114 | advance_to_working_time(time, config: config) 115 | end 116 | 117 | def return_to_working_time(time, config: nil) 118 | # return_to_exact_working_time may return values with a high number of milliseconds, 119 | # this is necessary for the end of day hack, here we return a rounded value for the 120 | # public API 121 | return_to_exact_working_time(time, config: config).round 122 | end 123 | 124 | def return_to_exact_working_time time, config: nil 125 | config ||= wh_config 126 | time = in_config_zone(time, config: config) 127 | loop do 128 | # skip holidays and weekends 129 | while not working_day?(time, config: config) 130 | time = (time - 1.day).end_of_day 131 | end 132 | # find last working range before time 133 | time_in_day = time.seconds_since_midnight 134 | working_hours_for(time, config: config).reverse_each do |from, to| 135 | return time if time_in_day > from and time_in_day <= to 136 | return move_time_of_day(time, to) if to <= time_in_day 137 | end 138 | # if none is found, go to previous day and loop 139 | time = (time - 1.day).end_of_day 140 | end 141 | end 142 | 143 | def working_day? time, config: nil 144 | config ||= wh_config 145 | time = in_config_zone(time, config: config) 146 | 147 | (config[:working_hours][time.wday].present? && !config[:holidays].include?(time.to_date)) || 148 | config[:holiday_hours].include?(time.to_date) 149 | end 150 | 151 | def in_working_hours? time, config: nil 152 | config ||= wh_config 153 | time = in_config_zone(time, config: config) 154 | return false if not working_day?(time, config: config) 155 | time_in_day = time.seconds_since_midnight 156 | working_hours_for(time, config: config).each do |from, to| 157 | return true if time_in_day >= from and time_in_day < to 158 | end 159 | false 160 | end 161 | 162 | def working_days_between from, to, config: nil 163 | config ||= wh_config 164 | if to < from 165 | -working_days_between(to, from, config: config) 166 | else 167 | from = in_config_zone(from, config: config) 168 | to = in_config_zone(to, config: config) 169 | days = 0 170 | while from.to_date < to.to_date 171 | from += 1.day 172 | days += 1 if working_day?(from, config: config) 173 | end 174 | days 175 | end 176 | end 177 | 178 | def working_time_between from, to, config: nil 179 | config ||= wh_config 180 | if to < from 181 | -working_time_between(to, from, config: config) 182 | else 183 | from = advance_to_working_time(in_config_zone(from, config: config)) 184 | to = in_config_zone(to, config: config) 185 | distance = 0 186 | while from < to 187 | from_was = from 188 | # look at working ranges 189 | time_in_day = from.seconds_since_midnight 190 | working_hours_for(from, config: config).each do |begins, ends| 191 | if time_in_day >= begins and time_in_day < ends 192 | if (to - from) > (ends - time_in_day) 193 | # take all the range and continue 194 | distance += (ends - time_in_day) 195 | from = move_time_of_day(from, ends) 196 | else 197 | # take only what's needed and stop 198 | distance += (to - from) 199 | from = to 200 | end 201 | end 202 | end 203 | # roll to next business period 204 | from = advance_to_working_time(from, config: config) 205 | raise "Invalid loop detected in working_time_between (from=#{from.iso8601(12)}, to=#{to.iso8601(12)}, distance=#{distance}, config=#{config}), please open an issue ;)" unless from > from_was 206 | end 207 | distance.round # round up to supress miliseconds introduced by 24:00 hack 208 | end 209 | end 210 | 211 | private 212 | 213 | # Changes the time of the day to match given time (in seconds since midnight) 214 | # preserving nanosecond prevision (rational number) and honoring time shifts 215 | # 216 | # This replaces the previous implementation which was: 217 | # time.beginning_of_day + seconds 218 | # (because this one would shift hours during time shifts days) 219 | def move_time_of_day time, seconds 220 | # return time.beginning_of_day + seconds 221 | hour = (seconds / 3600).to_i 222 | seconds %= 3600 223 | minutes = (seconds / 60).to_i 224 | seconds %= 60 225 | # sec/usec separation is required for ActiveSupport <= 5.1 226 | usec = ((seconds % 1) * 10**6) 227 | time.change(hour: hour, min: minutes, sec: seconds.to_i, usec: usec) 228 | end 229 | 230 | def wh_config 231 | WorkingHours::Config.precompiled 232 | end 233 | 234 | # fix for ActiveRecord < 4, doesn't implement in_time_zone for Date 235 | def in_config_zone time, config: nil 236 | if time.respond_to? :in_time_zone 237 | time.in_time_zone(config[:time_zone]) 238 | elsif time.is_a? Date 239 | config[:time_zone].local(time.year, time.month, time.day) 240 | else 241 | raise TypeError.new("Can't convert #{time.class} to a Time") 242 | end 243 | end 244 | 245 | def convert_to_original_format time, original 246 | case original 247 | when Date then time.to_date 248 | when DateTime then time.to_datetime 249 | else time 250 | end 251 | end 252 | 253 | def working_hours_for(time, config:) 254 | config[:holiday_hours][time.to_date] || config[:working_hours][time.wday] 255 | end 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /lib/working_hours/config.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | module WorkingHours 4 | class InvalidConfiguration < StandardError 5 | attr_reader :data, :error_code 6 | 7 | def initialize(error_code, data: nil) 8 | @data = data 9 | @error_code = error_code 10 | super compose_message(error_code) 11 | end 12 | 13 | def compose_message(error_code) 14 | case error_code 15 | when :empty then "No working hours given" 16 | when :empty_day then "No working hours given for day `#{@data[:day]}`" 17 | when :holidays_not_array then "Invalid type for holidays: #{@data[:holidays_class]} - must act like an array" 18 | when :holiday_not_date then "Invalid holiday: #{@data[:day]} - must be Date" 19 | when :invalid_day_keys then "Invalid day identifier(s): #{@data[:invalid_keys]} - must be 3 letter symbols" 20 | when :invalid_format then "Invalid time: #{@data[:time]} - must be 'HH:MM(:SS)'" 21 | when :invalid_holiday_keys then "Invalid day identifier(s): #{@data[:invalid_keys]} - must be a Date object" 22 | when :invalid_timezone then "Invalid time zone: #{@data[:zone]} - must be String or ActiveSupport::TimeZone" 23 | when :invalid_type then "Invalid type for `#{@data[:day]}`: #{@data[:hours_class]} - must be Hash" 24 | when :outside_of_day then "Invalid time: #{@data[:time]} - outside of day" 25 | when :overlap then "Invalid range: #{@data[:start]} => #{@data[:finish]} - overlaps previous range" 26 | when :unknown_timezone then "Unknown time zone: #{@data[:zone]}" 27 | when :wrong_order then "Invalid range: #{@data[:start]} => #{@data[:finish]} - ends before it starts" 28 | else "Invalid Configuration" 29 | end 30 | end 31 | end 32 | 33 | 34 | class Config 35 | TIME_FORMAT = /\A([0-2][0-9])\:([0-5][0-9])(?:\:([0-5][0-9]))?\z/ 36 | DAYS_OF_WEEK = [:sun, :mon, :tue, :wed, :thu, :fri, :sat] 37 | MIDNIGHT = Rational('86399.999999') 38 | 39 | class << self 40 | def working_hours 41 | config[:working_hours] 42 | end 43 | 44 | def working_hours=(val) 45 | validate_working_hours! val 46 | config[:working_hours] = val 47 | global_config[:working_hours] = val 48 | config.delete :precompiled 49 | end 50 | 51 | def holidays 52 | config[:holidays] 53 | end 54 | 55 | def holidays=(val) 56 | validate_holidays! val 57 | config[:holidays] = val 58 | global_config[:holidays] = val 59 | config.delete :precompiled 60 | end 61 | 62 | def holiday_hours 63 | config[:holiday_hours] 64 | end 65 | 66 | def holiday_hours=(val) 67 | validate_holiday_hours! val 68 | config[:holiday_hours] = val 69 | global_config[:holiday_hours] = val 70 | config.delete :precompiled 71 | end 72 | 73 | # Returns an optimized for computing version 74 | def precompiled 75 | config_hash = [ 76 | config[:working_hours], 77 | config[:holiday_hours], 78 | config[:holidays], 79 | config[:time_zone] 80 | ].hash 81 | 82 | if config_hash != config[:config_hash] 83 | config[:config_hash] = config_hash 84 | config.delete :precompiled 85 | end 86 | 87 | config[:precompiled] ||= begin 88 | validate_working_hours! config[:working_hours] 89 | validate_holiday_hours! config[:holiday_hours] 90 | validate_holidays! config[:holidays] 91 | validate_time_zone! config[:time_zone] 92 | compiled = { working_hours: Array.new(7) { Hash.new }, holiday_hours: {} } 93 | working_hours.each do |day, hours| 94 | hours.each do |start, finish| 95 | compiled[:working_hours][DAYS_OF_WEEK.index(day)][compile_time(start)] = compile_time(finish) 96 | end 97 | end 98 | holiday_hours.each do |day, hours| 99 | compiled[:holiday_hours][day] = {} 100 | hours.each do |start, finish| 101 | compiled[:holiday_hours][day][compile_time(start)] = compile_time(finish) 102 | end 103 | end 104 | compiled[:holidays] = Set.new(holidays) 105 | compiled[:time_zone] = time_zone 106 | compiled 107 | end 108 | end 109 | 110 | def time_zone 111 | config[:time_zone] 112 | end 113 | 114 | def time_zone=(val) 115 | zone = validate_time_zone! val 116 | config[:time_zone] = zone 117 | global_config[:time_zone] = val 118 | config.delete :precompiled 119 | end 120 | 121 | def reset! 122 | Thread.current[:working_hours] = default_config 123 | end 124 | 125 | def with_config(working_hours: nil, holiday_hours: nil, holidays: nil, time_zone: nil) 126 | original_working_hours = self.working_hours 127 | original_holiday_hours = self.holiday_hours 128 | original_holidays = self.holidays 129 | original_time_zone = self.time_zone 130 | self.working_hours = working_hours if working_hours 131 | self.holiday_hours = holiday_hours if holiday_hours 132 | self.holidays = holidays if holidays 133 | self.time_zone = time_zone if time_zone 134 | yield 135 | ensure 136 | self.working_hours = original_working_hours 137 | self.holiday_hours = original_holiday_hours 138 | self.holidays = original_holidays 139 | self.time_zone = original_time_zone 140 | end 141 | 142 | private 143 | 144 | def config 145 | Thread.current[:working_hours] ||= global_config.dup 146 | end 147 | 148 | def global_config 149 | @@global_config ||= default_config 150 | end 151 | 152 | def default_config 153 | { 154 | working_hours: { 155 | mon: {'09:00' => '17:00'}, 156 | tue: {'09:00' => '17:00'}, 157 | wed: {'09:00' => '17:00'}, 158 | thu: {'09:00' => '17:00'}, 159 | fri: {'09:00' => '17:00'} 160 | }, 161 | holiday_hours: {}, 162 | holidays: [], 163 | time_zone: ActiveSupport::TimeZone['UTC'] 164 | } 165 | end 166 | 167 | def compile_time time 168 | hour = time[TIME_FORMAT,1].to_i 169 | min = time[TIME_FORMAT,2].to_i 170 | sec = time[TIME_FORMAT,3].to_i 171 | time = hour * 3600 + min * 60 + sec 172 | # Converts 24:00 to 23:59:59.999999 173 | return MIDNIGHT if time == 86400 174 | time 175 | end 176 | 177 | def validate_hours! dates 178 | dates.each do |day, hours| 179 | if not hours.is_a? Hash 180 | raise InvalidConfiguration.new :invalid_type, data: { day: day, hours_class: hours.class } 181 | elsif hours.empty? 182 | raise InvalidConfiguration.new :empty_day, data: { day: day } 183 | end 184 | last_time = nil 185 | hours.sort.each do |start, finish| 186 | if not start =~ TIME_FORMAT 187 | raise InvalidConfiguration.new :invalid_format, data: { time: start } 188 | elsif not finish =~ TIME_FORMAT 189 | raise InvalidConfiguration.new :invalid_format, data: { time: finish } 190 | elsif compile_time(finish) >= 24 * 60 * 60 191 | raise InvalidConfiguration.new :outside_of_day, data: { time: finish } 192 | elsif start >= finish 193 | raise InvalidConfiguration.new :wrong_order, data: { start: start, finish: finish } 194 | elsif last_time and start < last_time 195 | raise InvalidConfiguration.new :overlap, data: { start: start, finish: finish } 196 | end 197 | last_time = finish 198 | end 199 | end 200 | end 201 | 202 | def validate_working_hours! week 203 | if week.empty? 204 | raise InvalidConfiguration.new :empty 205 | end 206 | if (invalid_keys = (week.keys - DAYS_OF_WEEK)).any? 207 | raise InvalidConfiguration.new :invalid_day_keys, data: { invalid_keys: invalid_keys.join(', ') } 208 | end 209 | validate_hours!(week) 210 | end 211 | 212 | def validate_holiday_hours! days 213 | if (invalid_keys = (days.keys.reject{ |day| day.is_a?(Date) })).any? 214 | raise InvalidConfiguration.new :invalid_holiday_keys, data: { invalid_keys: invalid_keys.join(', ') } 215 | end 216 | validate_hours!(days) 217 | end 218 | 219 | def validate_holidays! holidays 220 | if not holidays.respond_to?(:to_a) 221 | raise InvalidConfiguration.new :holidays_not_array, data: { holidays_class: holidays.class } 222 | end 223 | holidays.to_a.each do |day| 224 | if not day.is_a? Date 225 | raise InvalidConfiguration.new :holiday_not_date, data: { day: day } 226 | end 227 | end 228 | end 229 | 230 | def validate_time_zone! zone 231 | if zone.is_a? String 232 | res = ActiveSupport::TimeZone[zone] 233 | if res.nil? 234 | raise InvalidConfiguration.new :unknown_timezone, data: { zone: zone } 235 | end 236 | elsif zone.is_a? ActiveSupport::TimeZone 237 | res = zone 238 | else 239 | raise InvalidConfiguration.new :invalid_timezone, data: { zone: zone.inspect } 240 | end 241 | res 242 | end 243 | end 244 | 245 | private 246 | 247 | def initialize; end 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /lib/working_hours/core_ext/date_and_time.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/time_with_zone' 2 | require 'working_hours/module' 3 | 4 | module WorkingHours 5 | module CoreExt 6 | module DateAndTime 7 | 8 | def self.included base 9 | base.class_eval do 10 | alias_method :minus_without_working_hours, :- 11 | alias_method :-, :minus_with_working_hours 12 | alias_method :plus_without_working_hours, :+ 13 | alias_method :+, :plus_with_working_hours 14 | end 15 | end 16 | 17 | def plus_with_working_hours(other) 18 | if WorkingHours::Duration === other 19 | other.since(self) 20 | else 21 | plus_without_working_hours(other) 22 | end 23 | end 24 | 25 | def minus_with_working_hours(other) 26 | if WorkingHours::Duration === other 27 | other.until(self) 28 | else 29 | minus_without_working_hours(other) 30 | end 31 | end 32 | 33 | def working_days_until(other) 34 | WorkingHours.working_days_between(self, other) 35 | end 36 | 37 | def working_time_until(other) 38 | WorkingHours.working_time_between(self, other) 39 | end 40 | 41 | def working_day? 42 | WorkingHours.working_day?(self) 43 | end 44 | 45 | def in_working_hours? 46 | WorkingHours.in_working_hours?(self) 47 | end 48 | end 49 | end 50 | end 51 | 52 | class Date 53 | include WorkingHours::CoreExt::DateAndTime 54 | end 55 | 56 | class Time 57 | include WorkingHours::CoreExt::DateAndTime 58 | end 59 | 60 | class ActiveSupport::TimeWithZone 61 | include WorkingHours::CoreExt::DateAndTime 62 | end 63 | -------------------------------------------------------------------------------- /lib/working_hours/core_ext/integer.rb: -------------------------------------------------------------------------------- 1 | require "working_hours/duration_proxy" 2 | 3 | module WorkingHours 4 | module CoreExt 5 | module Integer 6 | 7 | def working 8 | WorkingHours::DurationProxy.new(self) 9 | end 10 | 11 | end 12 | end 13 | end 14 | 15 | Integer.send(:include, WorkingHours::CoreExt::Integer) 16 | -------------------------------------------------------------------------------- /lib/working_hours/duration.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | require 'working_hours/computation' 3 | 4 | module WorkingHours 5 | class Duration 6 | 7 | attr_accessor :value, :kind 8 | 9 | SUPPORTED_KINDS = [:days, :hours, :minutes, :seconds] 10 | 11 | def initialize(value, kind) 12 | raise ArgumentError.new("Invalid working time unit: #{kind}") unless SUPPORTED_KINDS.include?(kind) 13 | @value = value 14 | @kind = kind 15 | end 16 | 17 | # Computation methods 18 | def until(time = ::Time.current) 19 | ::WorkingHours.send("add_#{@kind}", time, -@value) 20 | end 21 | alias :ago :until 22 | 23 | def since(time = ::Time.current) 24 | ::WorkingHours.send("add_#{@kind}", time, @value) 25 | end 26 | alias :from_now :since 27 | 28 | # Value object methods 29 | def -@ 30 | Duration.new(-value, kind) 31 | end 32 | 33 | def ==(other) 34 | self.class == other.class and kind == other.kind and value == other.value 35 | end 36 | alias :eql? :== 37 | 38 | def hash 39 | [self.class, kind, value].hash 40 | end 41 | 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/working_hours/duration_proxy.rb: -------------------------------------------------------------------------------- 1 | require "working_hours/duration" 2 | 3 | module WorkingHours 4 | class DurationProxy 5 | 6 | attr_accessor :value 7 | 8 | def initialize(value) 9 | @value = value 10 | end 11 | 12 | Duration::SUPPORTED_KINDS.each do |kind| 13 | define_method kind do 14 | Duration.new(@value, kind) 15 | end 16 | 17 | # Singular version 18 | define_method kind[0..-2] do 19 | Duration.new(@value, kind) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/working_hours/module.rb: -------------------------------------------------------------------------------- 1 | require 'working_hours/version' 2 | require 'working_hours/computation' 3 | require 'working_hours/duration' 4 | 5 | module WorkingHours 6 | extend WorkingHours::Computation 7 | end 8 | -------------------------------------------------------------------------------- /lib/working_hours/version.rb: -------------------------------------------------------------------------------- 1 | module WorkingHours 2 | VERSION = "1.5.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # Require this file using `require "spec_helper"` to ensure that it is only 4 | # loaded once. 5 | # 6 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 7 | 8 | require 'working_hours' 9 | require 'timecop' 10 | 11 | RSpec.configure do |config| 12 | config.run_all_when_everything_filtered = true 13 | 14 | # Run specs in random order to surface order dependencies. If you find an 15 | # order dependency and want to debug it, you can fix the order by providing 16 | # the seed, which is printed after each run. 17 | # --seed 1234 18 | config.order = 'random' 19 | 20 | config.before :each do 21 | WorkingHours::Config.reset! 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/working_hours/computation_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe WorkingHours::Computation do 4 | include WorkingHours::Computation 5 | 6 | describe '#add_days' do 7 | it 'can add working days to date' do 8 | date = Date.new(1991, 11, 15) #Friday 9 | expect(add_days(date, 2)).to eq(Date.new(1991, 11, 19)) # Tuesday 10 | end 11 | 12 | it 'can substract working days from date' do 13 | date = Date.new(1991, 11, 15) #Friday 14 | expect(add_days(date, -7)).to eq(Date.new(1991, 11, 6)) # Wednesday 15 | end 16 | 17 | it 'can add working days to time' do 18 | time = Time.local(1991, 11, 15, 14, 00, 42) 19 | expect(add_days(time, 1)).to eq(Time.local(1991, 11, 18, 14, 00, 42)) # Monday 20 | end 21 | 22 | it 'can add working days to ActiveSupport::TimeWithZone' do 23 | time = Time.utc(1991, 11, 15, 14, 00, 42) 24 | time_monday = Time.utc(1991, 11, 18, 14, 00, 42) 25 | time_with_zone = ActiveSupport::TimeWithZone.new(time, 'Tokyo') 26 | expect(add_days(time_with_zone, 1)).to eq(ActiveSupport::TimeWithZone.new(time_monday, 'Tokyo')) 27 | end 28 | 29 | it 'skips non worked days' do 30 | time = Date.new(2014, 4, 7) # Monday 31 | WorkingHours::Config.working_hours = {mon: {'09:00' => '17:00'}, wed: {'09:00' => '17:00'}} 32 | expect(add_days(time, 1)).to eq(Date.new(2014, 4, 9)) # Wednesday 33 | end 34 | 35 | it 'skips non worked days when origin is not worked' do 36 | time = Date.new(2014, 4, 8) # Tuesday 37 | WorkingHours::Config.working_hours = {mon: {'09:00' => '17:00'}, wed: {'09:00' => '17:00'}, thu: {'09:00' => '17:00'}, sun: {'09:00' => '17:00'}} 38 | expect(add_days(time, 1)).to eq(Date.new(2014, 4, 10)) # Thursday 39 | expect(add_days(time, -1)).to eq(Date.new(2014, 4, 6)) # Sunday 40 | end 41 | 42 | it 'skips holidays' do 43 | time = Date.new(2014, 4, 7) # Monday 44 | WorkingHours::Config.holidays = [Date.new(2014, 4, 8)] # Tuesday 45 | expect(add_days(time, 1)).to eq(Date.new(2014, 4, 9)) # Wednesday 46 | end 47 | 48 | it 'skips holidays when origin is holiday' do 49 | time = Date.new(2014, 4, 9) # Wednesday 50 | WorkingHours::Config.holidays = [time] # Wednesday 51 | expect(add_days(time, 1)).to eq(Date.new(2014, 4, 11)) # Friday 52 | expect(add_days(time, -1)).to eq(Date.new(2014, 4, 7)) # Monday 53 | end 54 | 55 | it 'skips holidays and non worked days' do 56 | time = Date.new(2014, 4, 7) # Monday 57 | WorkingHours::Config.holidays = [Date.new(2014, 4, 9)] # Wednesday 58 | WorkingHours::Config.working_hours = {mon: {'09:00' => '17:00'}, wed: {'09:00' => '17:00'}} 59 | expect(add_days(time, 3)).to eq(Date.new(2014, 4, 21)) 60 | end 61 | 62 | it 'returns the original value when adding 0 days' do 63 | time = Date.new(2014, 4, 7) 64 | WorkingHours::Config.holidays = [time] 65 | expect(add_days(time, 0)).to eq(time) 66 | end 67 | 68 | it 'accepts time given from any time zone' do 69 | time = Time.utc(1991, 11, 14, 21, 0, 0) # Thursday 21 pm UTC 70 | WorkingHours::Config.time_zone = 'Tokyo' # But we are at tokyo, so it's already Friday 6 am 71 | monday = Time.new(1991, 11, 18, 6, 0, 0, "+09:00") # so one working day later, we are monday (Tokyo) 72 | expect(add_days(time, 1)).to eq(monday) 73 | end 74 | end 75 | 76 | describe '#add_hours' do 77 | it 'adds working hours' do 78 | time = Time.utc(1991, 11, 15, 14, 00, 42) # Friday 79 | expect(add_hours(time, 2)).to eq(Time.utc(1991, 11, 15, 16, 00, 42)) 80 | end 81 | 82 | it 'can substract working hours' do 83 | time = Time.utc(1991, 11, 18, 14, 00, 42) # Monday 84 | expect(add_hours(time, -7)).to eq(Time.utc(1991, 11, 15, 15, 00, 42)) # Friday 85 | end 86 | 87 | it 'accepts time given from any time zone' do 88 | time = Time.utc(1991, 11, 15, 7, 0, 0) # Friday 7 am UTC 89 | WorkingHours::Config.time_zone = 'Tokyo' # But we are at tokyo, so it's already 4 pm 90 | monday = Time.new(1991, 11, 18, 11, 0, 0, "+09:00") # so 3 working hours later, we are monday (Tokyo) 91 | expect(add_hours(time, 3)).to eq(monday) 92 | end 93 | 94 | it 'moves correctly with multiple timespans' do 95 | WorkingHours::Config.working_hours = {mon: {'07:00' => '12:00', '13:00' => '18:00'}} 96 | time = Time.utc(1991, 11, 11, 5) # Monday 6 am UTC 97 | expect(add_hours(time, 6)).to eq(Time.utc(1991, 11, 11, 14)) 98 | end 99 | end 100 | 101 | describe '#add_minutes' do 102 | it 'adds working minutes' do 103 | time = Time.utc(1991, 11, 15, 16, 30, 42) # Friday 104 | expect(add_minutes(time, 45)).to eq(Time.utc(1991, 11, 18, 9, 15, 42)) 105 | end 106 | end 107 | 108 | describe '#add_seconds' do 109 | it 'adds working seconds' do 110 | time = Time.utc(1991, 11, 15, 16, 59, 42) # Friday 111 | expect(add_seconds(time, 120)).to eq(Time.utc(1991, 11, 18, 9, 1, 42)) 112 | end 113 | 114 | it 'calls precompiled only once' do 115 | precompiled = WorkingHours::Config.precompiled 116 | expect(WorkingHours::Config).to receive(:precompiled).once.and_return(precompiled) # in_config_zone and add_seconds 117 | time = Time.utc(1991, 11, 15, 16, 59, 42) # Friday 118 | add_seconds(time, 120) 119 | end 120 | 121 | it 'supports midnight when advancing time' do 122 | WorkingHours::Config.working_hours = {:mon => {'00:00' => '24:00'}} 123 | time = Time.utc(2014, 4, 7, 23, 59, 30) # Monday 124 | expect(add_seconds(time, 60)).to eq(Time.utc(2014, 4, 14, 0, 0, 30)) 125 | end 126 | 127 | it 'supports midnight when rewinding time' do 128 | WorkingHours::Config.working_hours = {:mon => {'00:00' => '24:00'}, :tue => {'08:00' => '18:00'}} 129 | time = Time.utc(2014, 4, 8, 0, 0, 30) # Tuesday 130 | expect(add_seconds(time, -60)).to eq(Time.utc(2014, 4, 7, 23, 59, 00)) 131 | end 132 | 133 | context 'with holiday hours' do 134 | before do 135 | WorkingHours::Config.working_hours = { thu: { '08:00' => '18:00' }, fri: { '08:00' => '18:00' } } 136 | end 137 | 138 | context 'with a later starting hour' do 139 | before do 140 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '10:00' => '18:00' } } 141 | end 142 | 143 | it 'adds working seconds' do 144 | time = Time.utc(2019, 12, 27, 9) 145 | expect(add_seconds(time, 120)).to eq(Time.utc(2019, 12, 27, 10, 2)) 146 | end 147 | 148 | it 'removes working seconds' do 149 | time = Time.utc(2019, 12, 27, 9) 150 | expect(add_seconds(time, -120)).to eq(Time.utc(2019, 12, 26, 17, 58)) 151 | end 152 | 153 | context 'working back from working hours' do 154 | it 'moves to the previous working day' do 155 | time = Time.utc(2019, 12, 27, 11) 156 | expect(add_seconds(time, -2.hours)).to eq(Time.utc(2019, 12, 26, 17)) 157 | end 158 | end 159 | end 160 | 161 | context 'with an earlier ending hour' do 162 | before do 163 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '08:00' => '17:00' } } 164 | end 165 | 166 | it 'adds working seconds' do 167 | time = Time.utc(2019, 12, 27, 17, 59) 168 | expect(add_seconds(time, 120)).to eq(Time.utc(2020, 1, 2, 8, 2)) 169 | end 170 | 171 | it 'removes working seconds' do 172 | time = Time.utc(2019, 12, 27, 18) 173 | expect(add_seconds(time, -120)).to eq(Time.utc(2019, 12, 27, 16, 58)) 174 | end 175 | 176 | context 'working forward from working hours' do 177 | it 'moves to the next working day' do 178 | time = Time.utc(2019, 12, 27, 16) 179 | expect(add_seconds(time, 2.hours)).to eq(Time.utc(2020, 1, 2, 9)) 180 | end 181 | end 182 | end 183 | 184 | context 'with an earlier starting time in the second set of hours within a day' do 185 | before do 186 | WorkingHours::Config.working_hours = { thu: { '08:00' => '18:00' }, fri: { '08:00' => '12:00', '13:00' => '18:00' } } 187 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '08:00' => '12:00', '14:00' => '18:00' } } 188 | end 189 | 190 | it 'adds working seconds' do 191 | time = Time.utc(2019, 12, 27, 12, 59) 192 | expect(add_seconds(time, 120)).to eq(Time.utc(2019, 12, 27, 14, 2)) 193 | end 194 | 195 | it 'removes working seconds' do 196 | time = Time.utc(2019, 12, 27, 14) 197 | expect(add_seconds(time, -120)).to eq(Time.utc(2019, 12, 27, 11, 58)) 198 | end 199 | 200 | context 'from morning to afternoon' do 201 | it 'takes into account the additional hour for lunch set in `holiday_hours`' do 202 | time = Time.utc(2019, 12, 27, 10) 203 | expect(add_seconds(time, 4.hours)).to eq(Time.utc(2019, 12, 27, 16)) 204 | end 205 | end 206 | 207 | context 'from afternoon to morning' do 208 | it 'takes into account the additional hour for lunch set in `holiday_hours`' do 209 | time = Time.utc(2019, 12, 27, 16) 210 | expect(add_seconds(time, -4.hours)).to eq(Time.utc(2019, 12, 27, 10)) 211 | end 212 | end 213 | end 214 | end 215 | 216 | it 'honors miliseconds in the base time and increment (but return rounded result)' do 217 | # Rounding the base time or increments before the end would yield a wrong result 218 | time = Time.utc(1991, 11, 15, 16, 59, 42.25) # +250ms 219 | expect(add_seconds(time, 120.4)).to eq(Time.utc(1991, 11, 18, 9, 1, 43)) 220 | end 221 | end 222 | 223 | describe '#advance_to_working_time' do 224 | it 'jumps non-working day' do 225 | WorkingHours::Config.holidays = [Date.new(2014, 5, 1)] 226 | expect(advance_to_working_time(Time.utc(2014, 5, 1, 12, 0))).to eq(Time.utc(2014, 5, 2, 9, 0)) 227 | expect(advance_to_working_time(Time.utc(2014, 6, 1, 12, 0))).to eq(Time.utc(2014, 6, 2, 9, 0)) 228 | end 229 | 230 | it 'returns self during working hours' do 231 | expect(advance_to_working_time(Time.utc(2014, 4, 7, 9, 0))).to eq(Time.utc(2014, 4, 7, 9, 0)) 232 | expect(advance_to_working_time(Time.utc(2014, 4, 7, 16, 59))).to eq(Time.utc(2014, 4, 7, 16, 59)) 233 | end 234 | 235 | it 'jumps outside working hours' do 236 | expect(advance_to_working_time(Time.utc(2014, 4, 7, 8, 59))).to eq(Time.utc(2014, 4, 7, 9, 0)) 237 | expect(advance_to_working_time(Time.utc(2014, 4, 7, 17, 0))).to eq(Time.utc(2014, 4, 8, 9, 0)) 238 | end 239 | 240 | it 'move between timespans' do 241 | WorkingHours::Config.working_hours = {mon: {'07:00' => '12:00', '13:00' => '18:00'}} 242 | expect(advance_to_working_time(Time.utc(2014, 4, 7, 11, 59))).to eq(Time.utc(2014, 4, 7, 11, 59)) 243 | expect(advance_to_working_time(Time.utc(2014, 4, 7, 12, 0))).to eq(Time.utc(2014, 4, 7, 13, 0)) 244 | expect(advance_to_working_time(Time.utc(2014, 4, 7, 12, 59))).to eq(Time.utc(2014, 4, 7, 13, 0)) 245 | expect(advance_to_working_time(Time.utc(2014, 4, 7, 13, 0))).to eq(Time.utc(2014, 4, 7, 13, 0)) 246 | end 247 | 248 | it 'works with any input timezone (converts to config)' do 249 | # Monday 0 am (-09:00) is 9am in UTC time, working time! 250 | expect(advance_to_working_time(Time.new(2014, 4, 7, 0, 0, 0 , "-09:00"))).to eq(Time.utc(2014, 4, 7, 9)) 251 | expect(advance_to_working_time(Time.new(2014, 4, 7, 22, 0, 0 , "+02:00"))).to eq(Time.utc(2014, 4, 8, 9)) 252 | end 253 | 254 | it 'returns time in config zone' do 255 | WorkingHours::Config.time_zone = 'Tokyo' 256 | expect(advance_to_working_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST') 257 | end 258 | 259 | it 'jumps outside holiday hours' do 260 | WorkingHours::Config.working_hours = { fri: { '08:00' => '18:00' } } 261 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '10:00' => '18:00' } } 262 | expect(advance_to_working_time(Time.utc(2019, 12, 27, 9))).to eq(Time.utc(2019, 12, 27, 10)) 263 | end 264 | 265 | it 'do not leak nanoseconds when advancing' do 266 | expect(advance_to_working_time(Time.utc(2014, 4, 7, 5, 0, 0, 123456.789))).to eq(Time.utc(2014, 4, 7, 9, 0, 0, 0)) 267 | end 268 | 269 | it 'returns correct hour during positive time shifts' do 270 | WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}} 271 | WorkingHours::Config.time_zone = 'Paris' 272 | from = Time.new(2020, 3, 29, 0, 0, 0, "+01:00") 273 | expect(from.utc_offset).to eq(3600) 274 | res = advance_to_working_time(from) 275 | expect(res).to eq(Time.new(2020, 3, 29, 9, 0, 0, "+02:00")) 276 | expect(res.utc_offset).to eq(7200) 277 | # starting from wrong time-zone 278 | expect(advance_to_working_time(Time.new(2020, 3, 29, 5, 0, 0, "+01:00"))).to eq(Time.new(2020, 3, 29, 9, 0, 0, "+02:00")) 279 | expect(advance_to_working_time(Time.new(2020, 3, 29, 1, 0, 0, "+02:00"))).to eq(Time.new(2020, 3, 29, 9, 0, 0, "+02:00")) 280 | end 281 | 282 | it 'returns correct hour during negative time shifts' do 283 | WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}} 284 | WorkingHours::Config.time_zone = 'Paris' 285 | from = Time.new(2020, 10, 25, 0, 0, 0, "+02:00") 286 | expect(from.utc_offset).to eq(7200) 287 | res = advance_to_working_time(from) 288 | expect(res).to eq(Time.new(2020, 10, 25, 9, 0, 0, "+01:00")) 289 | expect(res.utc_offset).to eq(3600) 290 | # starting from wrong time-zone 291 | expect(advance_to_working_time(Time.new(2020, 10, 25, 4, 0, 0, "+02:00"))).to eq(Time.new(2020, 10, 25, 9, 0, 0, "+01:00")) 292 | expect(advance_to_working_time(Time.new(2020, 10, 25, 1, 0, 0, "+01:00"))).to eq(Time.new(2020, 10, 25, 9, 0, 0, "+01:00")) 293 | end 294 | end 295 | 296 | describe '#advance_to_closing_time' do 297 | it 'jumps non-working day' do 298 | WorkingHours::Config.holidays = [Date.new(2014, 5, 1)] 299 | holiday = Time.utc(2014, 5, 1, 12, 0) 300 | friday_closing = Time.utc(2014, 5, 2, 17, 0) 301 | sunday = Time.utc(2014, 6, 1, 12, 0) 302 | monday_closing = Time.utc(2014, 6, 2, 17, 0) 303 | expect(advance_to_closing_time(holiday)).to eq(friday_closing) 304 | expect(advance_to_closing_time(sunday)).to eq(monday_closing) 305 | end 306 | 307 | it 'moves to the closing time during working hours' do 308 | in_open_time = Time.utc(2014, 4, 7, 12, 0) 309 | closing_time = Time.utc(2014, 4, 7, 17, 0) 310 | expect(advance_to_closing_time(in_open_time)).to eq(closing_time) 311 | end 312 | 313 | it 'jumps outside working hours' do 314 | monday_before_opening = Time.utc(2014, 4, 7, 8, 59) 315 | monday_closing = Time.utc(2014, 4, 7, 17, 0) 316 | tuesday_closing = Time.utc(2014, 4, 8, 17, 0) 317 | expect(advance_to_closing_time(monday_before_opening)).to eq(monday_closing) 318 | expect(advance_to_closing_time(monday_closing)).to eq(tuesday_closing) 319 | end 320 | 321 | context 'moving between timespans' do 322 | before do 323 | WorkingHours::Config.working_hours = { 324 | mon: {'07:00' => '12:00', '13:00' => '18:00'}, 325 | tue: {'09:00' => '17:00'}, 326 | wed: {'09:00' => '17:00'}, 327 | thu: {'09:00' => '17:00'}, 328 | fri: {'09:00' => '17:00'} 329 | } 330 | end 331 | 332 | let(:monday_morning) { Time.utc(2014, 4, 7, 10) } 333 | let(:morning_closing) { Time.utc(2014, 4, 7, 12) } 334 | let(:afternoon_closing) { Time.utc(2014, 4, 7, 18) } 335 | let(:monday_break) { Time.utc(2014, 4, 7, 12) } 336 | let(:tuesday_closing) { Time.utc(2014, 4, 8, 17) } 337 | 338 | it 'moves from morning to end of morning slot' do 339 | expect(advance_to_closing_time(monday_morning)).to eq(morning_closing) 340 | end 341 | 342 | it 'moves from break time to end of afternoon slot' do 343 | expect(advance_to_closing_time(monday_break)).to eq(afternoon_closing) 344 | end 345 | 346 | it 'moves from afternoon closing slot to next day' do 347 | expect(advance_to_closing_time(afternoon_closing)).to eq(tuesday_closing) 348 | end 349 | end 350 | 351 | context 'supporting midnight' do 352 | before do 353 | WorkingHours::Config.working_hours = { 354 | mon: {'00:00' => '24:00'}, 355 | tue: {'09:00' => '17:00'} 356 | } 357 | end 358 | 359 | let(:monday_morning) { Time.utc(2014, 4, 7, 0) } 360 | let(:monday_closing) { Time.utc(2014, 4, 7) + WorkingHours::Config::MIDNIGHT } 361 | let(:tuesday_closing) { Time.utc(2014, 4, 8, 17) } 362 | let(:sunday) { Time.utc(2014, 4, 6, 17) } 363 | 364 | it 'moves from morning to midnight' do 365 | expect(advance_to_closing_time(monday_morning)).to eq(monday_closing) 366 | end 367 | 368 | it 'moves from midnight to end of next slot' do 369 | expect(advance_to_closing_time(monday_closing)).to eq(tuesday_closing) 370 | end 371 | 372 | it 'moves over midnight' do 373 | expect(advance_to_closing_time(sunday)).to eq(monday_closing) 374 | end 375 | 376 | it 'give precise computation with nothing other than miliseconds' do 377 | pending "iso8601 is not precise enough on AS < 4" if ActiveSupport::VERSION::MAJOR <= 4 378 | expect(advance_to_closing_time(monday_morning).iso8601(25)).to eq("2014-04-07T23:59:59.9999990000000000000000000Z") 379 | end 380 | end 381 | 382 | it 'works with any input timezone (converts to config)' do 383 | # Monday 0 am (-09:00) is 9am in UTC time, working time! 384 | monday_morning = Time.new(2014, 4, 7, 0, 0, 0 , "-09:00") 385 | monday_closing = Time.new(2014, 4, 7, 12, 0, 0 , "-05:00") 386 | monday_night = Time.new(2014, 4, 7, 22, 0, 0, "+02:00") 387 | tuesday_evening = Time.utc(2014, 4, 8, 17) 388 | expect(advance_to_closing_time(monday_morning)).to eq(monday_closing) 389 | expect(advance_to_closing_time(monday_night)).to eq(tuesday_evening) 390 | end 391 | 392 | it 'returns time in config zone' do 393 | WorkingHours::Config.time_zone = 'Tokyo' 394 | expect(advance_to_closing_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST') 395 | end 396 | 397 | context 'with holiday hours' do 398 | before do 399 | WorkingHours::Config.working_hours = { thu: { '08:00' => '18:00' }, fri: { '08:00' => '18:00' } } 400 | end 401 | 402 | it 'takes into account reduced holiday closing' do 403 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '10:00' => '17:00' } } 404 | expect(advance_to_closing_time(Time.utc(2019, 12, 26, 20))).to eq(Time.utc(2019, 12, 27, 17)) 405 | end 406 | 407 | it 'takes into account extended holiday closing' do 408 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 26) => { '10:00' => '21:00' } } 409 | expect(advance_to_closing_time(Time.utc(2019, 12, 26, 20))).to eq(Time.utc(2019, 12, 26, 21)) 410 | end 411 | end 412 | 413 | it 'do not leak nanoseconds when advancing' do 414 | expect(advance_to_closing_time(Time.utc(2014, 4, 7, 5, 0, 0, 123456.789))).to eq(Time.utc(2014, 4, 7, 17, 0, 0, 0)) 415 | end 416 | 417 | it 'returns correct hour during positive time shifts' do 418 | WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}} 419 | WorkingHours::Config.time_zone = 'Paris' 420 | from = Time.new(2020, 3, 29, 0, 0, 0, "+01:00") 421 | expect(from.utc_offset).to eq(3600) 422 | res = advance_to_closing_time(from) 423 | expect(res).to eq(Time.new(2020, 3, 29, 17, 0, 0, "+02:00")) 424 | expect(res.utc_offset).to eq(7200) 425 | # starting from wrong time-zone 426 | expect(advance_to_closing_time(Time.new(2020, 3, 29, 5, 0, 0, "+01:00"))).to eq(Time.new(2020, 3, 29, 17, 0, 0, "+02:00")) 427 | expect(advance_to_closing_time(Time.new(2020, 3, 29, 1, 0, 0, "+02:00"))).to eq(Time.new(2020, 3, 29, 17, 0, 0, "+02:00")) 428 | end 429 | 430 | it 'returns correct hour during negative time shifts' do 431 | WorkingHours::Config.working_hours = {sun: {'09:00' => '17:00'}} 432 | WorkingHours::Config.time_zone = 'Paris' 433 | from = Time.new(2020, 10, 25, 0, 0, 0, "+02:00") 434 | expect(from.utc_offset).to eq(7200) 435 | res = advance_to_closing_time(from) 436 | expect(res).to eq(Time.new(2020, 10, 25, 17, 0, 0, "+01:00")) 437 | expect(res.utc_offset).to eq(3600) 438 | # starting from wrong time-zone 439 | expect(advance_to_closing_time(Time.new(2020, 10, 25, 4, 0, 0, "+02:00"))).to eq(Time.new(2020, 10, 25, 17, 0, 0, "+01:00")) 440 | expect(advance_to_closing_time(Time.new(2020, 10, 25, 1, 0, 0, "+01:00"))).to eq(Time.new(2020, 10, 25, 17, 0, 0, "+01:00")) 441 | end 442 | end 443 | 444 | describe '#next_working_time' do 445 | it 'jumps non-working day' do 446 | WorkingHours::Config.holidays = [Date.new(2014, 5, 1)] 447 | holiday = Time.utc(2014, 5, 1, 12, 0) 448 | sunday = Time.utc(2014, 6, 1, 12, 0) 449 | expect(next_working_time(holiday)).to eq(Time.utc(2014, 5, 2, 9, 0)) 450 | expect(next_working_time(sunday)).to eq(Time.utc(2014, 6, 2, 9, 0)) 451 | end 452 | 453 | it 'moves to the following timespan during working hours' do 454 | monday = Time.utc(2014, 4, 7, 12, 0) 455 | tuesday = Time.utc(2014, 4, 8, 9, 0) 456 | expect(next_working_time(monday)).to eq(tuesday) 457 | end 458 | 459 | it 'jumps outside working hours' do 460 | monday_before_opening = Time.utc(2014, 4, 7, 8, 59) 461 | monday_opening = Time.utc(2014, 4, 7, 9, 0) 462 | monday_closing = Time.utc(2014, 4, 7, 17, 0) 463 | tuesday_opening = Time.utc(2014, 4, 8, 9, 0) 464 | expect(next_working_time(monday_before_opening)).to eq(monday_opening) 465 | expect(next_working_time(monday_closing)).to eq(tuesday_opening) 466 | end 467 | 468 | context 'move between timespans' do 469 | before do 470 | WorkingHours::Config.working_hours = { 471 | mon: {'07:00' => '12:00', '13:00' => '18:00'}, 472 | tue: {'09:00' => '17:00'}, 473 | wed: {'09:00' => '17:00'}, 474 | thu: {'09:00' => '17:00'}, 475 | fri: {'09:00' => '17:00'} 476 | } 477 | end 478 | 479 | let(:monday_morning) { Time.utc(2014, 4, 7, 10) } 480 | let(:monday_afternoon) { Time.utc(2014, 4, 7, 13) } 481 | let(:monday_break) { Time.utc(2014, 4, 7, 12) } 482 | let(:tuesday_morning) { Time.utc(2014, 4, 8, 9) } 483 | 484 | it 'moves from morning to afternoon slot' do 485 | expect(next_working_time(monday_morning)).to eq(monday_afternoon) 486 | end 487 | 488 | it 'moves from break time to afternoon slot' do 489 | expect(next_working_time(monday_break)).to eq(monday_afternoon) 490 | end 491 | 492 | it 'moves from afternoon slot to next day' do 493 | expect(next_working_time(monday_afternoon)).to eq(tuesday_morning) 494 | end 495 | end 496 | 497 | it 'works with any input timezone (converts to config)' do 498 | # Monday 0 am (-09:00) is 9am in UTC time, working time! 499 | monday_morning = Time.new(2014, 4, 7, 0, 0, 0 , "-09:00") 500 | monday_night = Time.new(2014, 4, 7, 22, 0, 0, "+02:00") 501 | tuesday_morning = Time.utc(2014, 4, 8, 9) 502 | expect(next_working_time(monday_morning)).to eq(tuesday_morning) 503 | expect(next_working_time(monday_night)).to eq(tuesday_morning) 504 | end 505 | 506 | it 'returns time in config zone' do 507 | WorkingHours::Config.time_zone = 'Tokyo' 508 | expect(next_working_time(Time.new(2014, 4, 7, 0, 0, 0)).zone).to eq('JST') 509 | end 510 | end 511 | 512 | describe '#return_to_working_time' do 513 | it 'jumps non-working day' do 514 | WorkingHours::Config.holidays = [Date.new(2014, 5, 1)] 515 | expect(return_to_working_time(Time.utc(2014, 5, 1, 12, 0))).to eq(Time.utc(2014, 4, 30, 17)) 516 | expect(return_to_working_time(Time.utc(2014, 6, 1, 12, 0))).to eq(Time.utc(2014, 5, 30, 17)) 517 | end 518 | 519 | it 'returns self during working hours' do 520 | expect(return_to_working_time(Time.utc(2014, 4, 7, 9, 1))).to eq(Time.utc(2014, 4, 7, 9, 1)) 521 | expect(return_to_working_time(Time.utc(2014, 4, 7, 17, 0))).to eq(Time.utc(2014, 4, 7, 17, 0)) 522 | end 523 | 524 | it 'jumps outside working hours' do 525 | expect(return_to_working_time(Time.utc(2014, 4, 7, 17, 1))).to eq(Time.utc(2014, 4, 7, 17, 0)) 526 | expect(return_to_working_time(Time.utc(2014, 4, 8, 9, 0))).to eq(Time.utc(2014, 4, 7, 17, 0)) 527 | end 528 | 529 | it 'move between timespans' do 530 | WorkingHours::Config.working_hours = {mon: {'07:00' => '12:00', '13:00' => '18:00'}} 531 | expect(return_to_working_time(Time.utc(2014, 4, 7, 13, 1))).to eq(Time.utc(2014, 4, 7, 13, 1)) 532 | expect(return_to_working_time(Time.utc(2014, 4, 7, 13, 0))).to eq(Time.utc(2014, 4, 7, 12, 0)) 533 | expect(return_to_working_time(Time.utc(2014, 4, 7, 12, 1))).to eq(Time.utc(2014, 4, 7, 12, 0)) 534 | expect(return_to_working_time(Time.utc(2014, 4, 7, 12, 0))).to eq(Time.utc(2014, 4, 7, 12, 0)) 535 | end 536 | 537 | it 'works with any input timezone (converts to config)' do 538 | # Monday 1 am (-09:00) is 10am in UTC time, working time! 539 | expect(return_to_working_time(Time.new(2014, 4, 7, 1, 0, 0 , "-09:00"))).to eq(Time.utc(2014, 4, 7, 10)) 540 | expect(return_to_working_time(Time.new(2014, 4, 7, 22, 0, 0 , "+02:00"))).to eq(Time.utc(2014, 4, 7, 17)) 541 | end 542 | 543 | it 'returns time in config zone' do 544 | WorkingHours::Config.time_zone = 'Tokyo' 545 | expect(return_to_working_time(Time.new(2014, 4, 7, 1, 0, 0)).zone).to eq('JST') 546 | end 547 | 548 | it 'returns correct hour during positive time shifts' do 549 | WorkingHours::Config.working_hours = {sun: {'00:00' => '01:00'}} 550 | WorkingHours::Config.time_zone = 'Paris' 551 | from = Time.new(2020, 3, 29, 9, 0, 0, "+02:00") 552 | expect(from.utc_offset).to eq(7200) 553 | res = return_to_working_time(from) 554 | expect(res).to eq(Time.new(2020, 3, 29, 1, 0, 0, "+01:00")) 555 | expect(res.utc_offset).to eq(3600) 556 | # starting from wrong time-zone 557 | expect(return_to_working_time(Time.new(2020, 3, 29, 2, 0, 0, "+02:00"))).to eq(Time.new(2020, 3, 29, 1, 0, 0, "+01:00")) 558 | expect(return_to_working_time(Time.new(2020, 3, 29, 9, 0, 0, "+01:00"))).to eq(Time.new(2020, 3, 29, 1, 0, 0, "+01:00")) 559 | end 560 | 561 | it 'returns correct hour during negative time shifts' do 562 | WorkingHours::Config.working_hours = {sun: {'00:00' => '01:00'}} 563 | WorkingHours::Config.time_zone = 'Paris' 564 | from = Time.new(2020, 10, 25, 9, 0, 0, "+01:00") 565 | expect(from.utc_offset).to eq(3600) 566 | res = return_to_working_time(from) 567 | expect(res).to eq(Time.new(2020, 10, 25, 1, 0, 0, "+02:00")) 568 | expect(res.utc_offset).to eq(7200) 569 | # starting from wrong time-zone 570 | expect(return_to_working_time(Time.new(2020, 10, 25, 9, 0, 0, "+02:00"))).to eq(Time.new(2020, 10, 25, 1, 0, 0, "+02:00")) 571 | expect(return_to_working_time(Time.new(2020, 10, 25, 1, 0, 0, "+01:00"))).to eq(Time.new(2020, 10, 25, 1, 0, 0, "+02:00")) 572 | end 573 | end 574 | 575 | describe '#working_day?' do 576 | it 'returns true on working day' do 577 | expect(working_day?(Date.new(2014, 4, 7))).to be(true) 578 | end 579 | 580 | it 'skips holidays' do 581 | WorkingHours::Config.holidays = [Date.new(2014, 5, 1)] 582 | expect(working_day?(Date.new(2014, 5, 1))).to be(false) 583 | end 584 | 585 | it 'skips non working days' do 586 | expect(working_day?(Date.new(2014, 4, 6))).to be(false) 587 | end 588 | end 589 | 590 | describe '#in_working_hours?' do 591 | it 'returns false in non-working day' do 592 | WorkingHours::Config.holidays = [Date.new(2014, 5, 1)] 593 | expect(in_working_hours?(Time.utc(2014, 5, 1, 12, 0))).to be(false) 594 | expect(in_working_hours?(Time.utc(2014, 6, 1, 12, 0))).to be(false) 595 | end 596 | 597 | it 'returns true during working hours' do 598 | expect(in_working_hours?(Time.utc(2014, 4, 7, 9, 0))).to be(true) 599 | expect(in_working_hours?(Time.utc(2014, 4, 7, 16, 59))).to be(true) 600 | end 601 | 602 | it 'returns false outside working hours' do 603 | expect(in_working_hours?(Time.utc(2014, 4, 7, 8, 59))).to be(false) 604 | expect(in_working_hours?(Time.utc(2014, 4, 7, 17, 0))).to be(false) 605 | end 606 | 607 | it 'works with multiple timespan' do 608 | WorkingHours::Config.working_hours = {mon: {'07:00' => '12:00', '13:00' => '18:00'}} 609 | expect(in_working_hours?(Time.utc(2014, 4, 7, 11, 59))).to be(true) 610 | expect(in_working_hours?(Time.utc(2014, 4, 7, 12, 0))).to be(false) 611 | expect(in_working_hours?(Time.utc(2014, 4, 7, 12, 59))).to be(false) 612 | expect(in_working_hours?(Time.utc(2014, 4, 7, 13, 0))).to be(true) 613 | end 614 | 615 | it 'works with any timezone' do 616 | # Monday 00:00 am UTC is 09:00 am Tokyo, working time ! 617 | WorkingHours::Config.time_zone = 'Tokyo' 618 | expect(in_working_hours?(Time.utc(2014, 4, 7, 0, 0))).to be(true) 619 | end 620 | 621 | context 'with holiday hours' do 622 | before do 623 | WorkingHours::Config.working_hours = { thu: { '08:00' => '18:00' }, fri: { '08:00' => '18:00' } } 624 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 27) => { '10:00' => '20:00' } } 625 | end 626 | 627 | it 'returns true during working hours' do 628 | expect(in_working_hours?(Time.utc(2019, 12, 26, 9))).to be(true) 629 | expect(in_working_hours?(Time.utc(2019, 12, 27, 19))).to be(true) 630 | end 631 | 632 | it 'returns false outside working hours' do 633 | expect(in_working_hours?(Time.utc(2019, 12, 26, 7))).to be(false) 634 | expect(in_working_hours?(Time.utc(2019, 12, 27, 9))).to be(false) 635 | end 636 | end 637 | end 638 | 639 | describe '#working_days_between' do 640 | it 'returns 0 if same date' do 641 | expect(working_days_between( 642 | Date.new(1991, 11, 15), # friday 643 | Date.new(1991, 11, 15) 644 | )).to eq(0) 645 | end 646 | 647 | it 'returns 0 if time in same day' do 648 | expect(working_days_between( 649 | Time.utc(1991, 11, 15, 8), # friday 650 | Time.utc(1991, 11, 15, 4) 651 | )).to eq(0) 652 | end 653 | 654 | it 'counts working days' do 655 | expect(working_days_between( 656 | Date.new(1991, 11, 15), # friday to friday 657 | Date.new(1991, 11, 22) 658 | )).to eq(5) 659 | end 660 | 661 | it 'returns negative if params are reversed' do 662 | expect(working_days_between( 663 | Date.new(1991, 11, 22), # friday to friday 664 | Date.new(1991, 11, 15) 665 | )).to eq(-5) 666 | end 667 | 668 | context 'consider time at end of day' do 669 | it 'returns 0 from friday to saturday' do 670 | expect(working_days_between( 671 | Date.new(1991, 11, 15), # friday to saturday 672 | Date.new(1991, 11, 16) 673 | )).to eq(0) 674 | end 675 | 676 | it 'returns 1 from sunday to monday' do 677 | expect(working_days_between( 678 | Date.new(1991, 11, 17), # sunday to monday 679 | Date.new(1991, 11, 18) 680 | )).to eq(1) 681 | end 682 | end 683 | end 684 | 685 | describe '#working_time_between' do 686 | it 'returns 0 if same time' do 687 | expect(working_time_between( 688 | Time.utc(2014, 4, 7, 8), 689 | Time.utc(2014, 4, 7, 8) 690 | )).to eq(0) 691 | end 692 | 693 | it 'returns 0 during non working time' do 694 | expect(working_time_between( 695 | Time.utc(2014, 4, 11, 20), # Friday evening 696 | Time.utc(2014, 4, 14, 5) # Monday early 697 | )).to eq(0) 698 | end 699 | 700 | it 'ignores miliseconds' do 701 | expect(working_time_between( 702 | Time.utc(2014, 4, 13, 9, 10, 24.01), 703 | Time.utc(2014, 4, 14, 9, 10, 24.02), 704 | )).to eq(624) 705 | end 706 | 707 | it 'returns distance in same period' do 708 | expect(working_time_between( 709 | Time.utc(2014, 4, 7, 10), 710 | Time.utc(2014, 4, 7, 15) 711 | )).to eq(5.hours) 712 | end 713 | 714 | it 'returns negative if params are reversed' do 715 | expect(working_time_between( 716 | Time.utc(2014, 4, 7, 15), 717 | Time.utc(2014, 4, 7, 10) 718 | )).to eq(-5.hours) 719 | end 720 | 721 | it 'returns full day if outside period' do 722 | expect(working_time_between( 723 | Time.utc(2014, 4, 7, 7), 724 | Time.utc(2014, 4, 7, 20) 725 | )).to eq(8.hours) 726 | end 727 | 728 | it 'supports midnight' do 729 | WorkingHours::Config.working_hours = {:mon => {'00:00' => '24:00'}} 730 | expect(working_time_between( 731 | Time.utc(2014, 4, 6, 12), 732 | Time.utc(2016, 4, 6, 12) 733 | )).to eq(24.hours * 105) # 105 complete mondays in 2 years 734 | end 735 | 736 | it 'handles multiple timespans' do 737 | WorkingHours::Config.working_hours = { 738 | mon: {'07:00' => '12:00', '13:00' => '18:00'} 739 | } 740 | expect(working_time_between( 741 | Time.utc(2014, 4, 7, 11, 59), 742 | Time.utc(2014, 4, 7, 13, 1) 743 | )).to eq(2.minutes) 744 | expect(working_time_between( 745 | Time.utc(2014, 4, 7, 11), 746 | Time.utc(2014, 4, 14, 13) 747 | )).to eq(11.hours) 748 | end 749 | 750 | it 'works with any timezone (converts to config)' do 751 | expect(working_time_between( 752 | Time.new(2014, 4, 7, 1, 0, 0, "-09:00"), # Monday 10am in UTC 753 | Time.new(2014, 4, 7, 15, 0, 0, "-04:00"), # Monday 7pm in UTC 754 | )).to eq(7.hours) 755 | end 756 | 757 | it 'uses precise computation to avoid useless loops' do 758 | # +200 usec on each time, using floating point would cause 759 | # precision issues and require several iterations 760 | expect(self).to receive(:advance_to_working_time).twice.and_call_original 761 | expect(working_time_between( 762 | Time.utc(2014, 4, 7, 5, 0, 0, 200), 763 | Time.utc(2014, 4, 7, 15, 0, 0, 200), 764 | )).to eq(6.hours) 765 | end 766 | 767 | it 'works across positive time shifts' do 768 | WorkingHours::Config.working_hours = {sun: {'08:00' => '21:00'}} 769 | WorkingHours::Config.time_zone = 'Paris' 770 | expect(working_time_between( 771 | Time.utc(2020, 3, 29, 1, 0), 772 | Time.utc(2020, 3, 30, 0, 0), 773 | )).to eq(13.hours) 774 | end 775 | 776 | it 'works across negative time shifts' do 777 | WorkingHours::Config.working_hours = {sun: {'08:00' => '21:00'}} 778 | WorkingHours::Config.time_zone = 'Paris' 779 | expect(working_time_between( 780 | Time.utc(2019, 10, 27, 1, 0), 781 | Time.utc(2019, 10, 28, 0, 0), 782 | )).to eq(13.hours) 783 | end 784 | 785 | it 'works across time shifts + midnight' do 786 | WorkingHours::Config.working_hours = {sun: {'00:00' => '24:00'}} 787 | WorkingHours::Config.time_zone = 'Paris' 788 | expect(working_time_between( 789 | Time.utc(2020, 10, 24, 22, 0), 790 | Time.utc(2020, 10, 25, 23, 0), 791 | )).to eq(24.hours) 792 | end 793 | 794 | it 'works across multiple time shifts' do 795 | WorkingHours::Config.working_hours = {sun: {'08:00' => '21:00'}} 796 | WorkingHours::Config.time_zone = 'Paris' 797 | expect(working_time_between( 798 | Time.utc(2002, 10, 27, 6, 0), 799 | Time.utc(2021, 10, 30, 0, 0), 800 | )).to eq(12896.hours) 801 | end 802 | 803 | it 'do not cause infinite loop if the time is not advancing properly' do 804 | # simulate some computation/precision error 805 | expect(self).to receive(:advance_to_working_time).twice do |time| 806 | time.change(hour: 9) - 0.0001 807 | end 808 | expect { working_time_between( 809 | Time.utc(2014, 4, 7, 5, 0, 0), 810 | Time.utc(2014, 4, 7, 15, 0, 0), 811 | ) }.to raise_error(RuntimeError, /Invalid loop detected in working_time_between \(from=2014-04-07T08:59:59.999/) 812 | end 813 | 814 | # generates two times with +0ms, +250ms, +500ms, +750ms and +1s 815 | # then for each combination compare the result with a ruby diff 816 | context 'with precise miliseconds timings' do 817 | reference = Time.utc(2014, 4, 7, 10) 818 | 0.step(1.0, 0.25) do |offset1| 819 | 0.step(1.0, 0.25) do |offset2| 820 | from = reference + offset1 821 | to = reference + offset2 822 | it "returns expected value (#{(to - from).round}) for #{offset1} — #{offset2} interval" do 823 | expect(working_time_between(from, to)).to eq((to - from).round) 824 | end 825 | end 826 | end 827 | end 828 | 829 | context 'with holiday hours' do 830 | before do 831 | WorkingHours::Config.working_hours = { mon: { '08:00' => '18:00' }, tue: { '08:00' => '18:00' } } 832 | WorkingHours::Config.holiday_hours = { Date.new(2014, 4, 7) => { '10:00' => '12:00', '14:00' => '17:00' } } 833 | end 834 | 835 | context 'time is before the start of holiday hours' do 836 | it 'does not count holiday hours as working time' do 837 | expect(working_time_between( 838 | Time.utc(2014, 4, 7, 8), 839 | Time.utc(2014, 4, 7, 9) 840 | )).to eq(0) 841 | end 842 | end 843 | 844 | context 'time is between holiday hours' do 845 | it 'does not count holiday hours as working time' do 846 | expect(working_time_between( 847 | Time.utc(2014, 4, 7, 13), 848 | Time.utc(2014, 4, 7, 13, 30) 849 | )).to eq(0) 850 | end 851 | end 852 | 853 | context 'time is after the end of holiday hours' do 854 | it 'does not count holiday hours as working time' do 855 | expect(working_time_between( 856 | Time.utc(2014, 4, 7, 19), 857 | Time.utc(2014, 4, 7, 20) 858 | )).to eq(0) 859 | end 860 | end 861 | 862 | context 'time is before the start of the holiday hours' do 863 | it 'does not count holiday hours as working time' do 864 | expect(working_time_between( 865 | Time.utc(2014, 4, 7, 9), 866 | Time.utc(2014, 4, 7, 12) 867 | )).to eq(7200) 868 | end 869 | end 870 | 871 | context 'time crosses overridden holiday hours at midday' do 872 | it 'does not count holiday hours as working time' do 873 | expect(working_time_between( 874 | Time.utc(2014, 4, 7, 9), 875 | Time.utc(2014, 4, 7, 14) 876 | )).to eq(7200) 877 | end 878 | end 879 | 880 | context 'time crosses overridden holiday hours at midday' do 881 | it 'does not count holiday hours as working time' do 882 | expect(working_time_between( 883 | Time.utc(2014, 4, 7, 12), 884 | Time.utc(2014, 4, 7, 18) 885 | )).to eq(10800) 886 | end 887 | end 888 | end 889 | end 890 | end 891 | -------------------------------------------------------------------------------- /spec/working_hours/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe WorkingHours::Config do 4 | 5 | describe '.working_hours' do 6 | let(:config) { WorkingHours::Config.working_hours } 7 | let(:config2) { { :mon => { '08:00' => '14:00' } } } 8 | let(:config3) { { :tue => { '10:00' => '16:00' } } } 9 | 10 | it 'has a default config' do 11 | expect(config).to be_kind_of(Hash) 12 | end 13 | 14 | it 'is thread safe' do 15 | expect(WorkingHours::Config.working_hours).to eq(config) 16 | 17 | thread = Thread.new do 18 | WorkingHours::Config.working_hours = config2 19 | expect(WorkingHours::Config.working_hours).to eq(config2) 20 | Thread.stop 21 | expect(WorkingHours::Config.working_hours).to eq(config2) 22 | end 23 | 24 | expect { 25 | sleep 0.1 # let the thread begin its execution 26 | }.not_to change { WorkingHours::Config.working_hours }.from(config) 27 | 28 | expect { 29 | WorkingHours::Config.working_hours = config3 30 | }.to change { WorkingHours::Config.working_hours }.from(config).to(config3) 31 | 32 | expect { 33 | thread.run 34 | thread.join 35 | }.not_to change { WorkingHours::Config.working_hours }.from(config3) 36 | end 37 | 38 | it 'is fiber safe' do 39 | expect(WorkingHours::Config.working_hours).to eq(config) 40 | 41 | fiber = Fiber.new do 42 | WorkingHours::Config.working_hours = config2 43 | expect(WorkingHours::Config.working_hours).to eq(config2) 44 | Fiber.yield 45 | expect(WorkingHours::Config.working_hours).to eq(config2) 46 | end 47 | 48 | expect { 49 | fiber.resume 50 | }.not_to change { WorkingHours::Config.working_hours }.from(config) 51 | 52 | expect { 53 | WorkingHours::Config.working_hours = config3 54 | }.to change { WorkingHours::Config.working_hours }.from(config).to(config3) 55 | 56 | expect { 57 | fiber.resume 58 | }.not_to change { WorkingHours::Config.working_hours }.from(config3) 59 | end 60 | 61 | it 'is initialized from last known global config' do 62 | WorkingHours::Config.working_hours = {:mon => {'08:00' => '14:00'}} 63 | Thread.new { 64 | expect(WorkingHours::Config.working_hours).to match :mon => {'08:00' => '14:00'} 65 | }.join 66 | end 67 | 68 | it 'should have a key for each week day' do 69 | [:mon, :tue, :wed, :thu, :fri].each do |d| 70 | expect(config[d]).to be_kind_of(Hash) 71 | end 72 | end 73 | 74 | it 'should be changeable' do 75 | time_sheet = {:mon => {'08:00' => '14:00'}} 76 | WorkingHours::Config.working_hours = time_sheet 77 | expect(config).to eq(time_sheet) 78 | end 79 | 80 | it 'should support multiple timespan per day' do 81 | time_sheet = {:mon => {'08:00' => '12:00', '14:00' => '18:00'}} 82 | WorkingHours::Config.working_hours = time_sheet 83 | expect(config).to eq(time_sheet) 84 | end 85 | 86 | it "recomputes precompiled when modified" do 87 | time_sheet = {:mon => {'08:00' => '14:00'}} 88 | WorkingHours::Config.working_hours = time_sheet 89 | expect { 90 | WorkingHours::Config.working_hours[:tue] = {'08:00' => '14:00'} 91 | }.to change { WorkingHours::Config.precompiled[:working_hours][2] } 92 | expect { 93 | WorkingHours::Config.working_hours[:mon]['08:00'] = '15:00' 94 | }.to change { WorkingHours::Config.precompiled[:working_hours][1] } 95 | end 96 | 97 | describe 'validations' do 98 | it 'rejects empty hash' do 99 | expect { 100 | WorkingHours::Config.working_hours = {} 101 | }.to raise_error(WorkingHours::InvalidConfiguration, "No working hours given") 102 | end 103 | 104 | it 'rejects invalid day' do 105 | expect { 106 | WorkingHours::Config.working_hours = {:mon => 1, 'tuesday' => 2, 'wed' => 3} 107 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid day identifier(s): tuesday, wed - must be 3 letter symbols") 108 | end 109 | 110 | it 'rejects other type than hash' do 111 | expect { 112 | WorkingHours::Config.working_hours = {:mon => []} 113 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid type for `mon`: Array - must be Hash") 114 | end 115 | 116 | it 'rejects empty range' do 117 | expect { 118 | WorkingHours::Config.working_hours = {:mon => {}} 119 | }.to raise_error(WorkingHours::InvalidConfiguration, "No working hours given for day `mon`") 120 | end 121 | 122 | it 'rejects invalid time format' do 123 | expect { 124 | WorkingHours::Config.working_hours = {:mon => {'8:0' => '12:00'}} 125 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid time: 8:0 - must be 'HH:MM(:SS)'") 126 | 127 | expect { 128 | WorkingHours::Config.working_hours = {:mon => {'08:00' => '24:10'}} 129 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid time: 24:10 - outside of day") 130 | end 131 | 132 | it 'rejects invalid range' do 133 | expect { 134 | WorkingHours::Config.working_hours = {:mon => {'12:30' => '12:00'}} 135 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid range: 12:30 => 12:00 - ends before it starts") 136 | end 137 | 138 | it 'rejects overlapping range' do 139 | expect { 140 | WorkingHours::Config.working_hours = {:mon => {'08:00' => '13:00', '12:00' => '18:00'}} 141 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid range: 12:00 => 18:00 - overlaps previous range") 142 | end 143 | 144 | it 'does not reject out-of-order, non-overlapping ranges' do 145 | expect { 146 | WorkingHours::Config.working_hours = {:mon => {'10:00' => '11:00', '08:00' => '09:00'}} 147 | }.not_to raise_error 148 | end 149 | 150 | it 'raises an error when precompiling if working hours are invalid after assignment' do 151 | WorkingHours::Config.working_hours = {:mon => {'10:00' => '11:00', '08:00' => '09:00'}} 152 | WorkingHours::Config.working_hours[:mon] = 'Not correct' 153 | expect { 154 | 1.working.hour.ago 155 | }.to raise_error(WorkingHours::InvalidConfiguration, 'Invalid type for `mon`: String - must be Hash') 156 | end 157 | end 158 | end 159 | 160 | describe '.holiday_hours' do 161 | let(:config) { WorkingHours::Config.holiday_hours } 162 | let(:config2) { { Date.new(2019, 12, 1) => { '08:00' => '14:00' } } } 163 | let(:config3) { { Date.new(2019, 12, 2) => { '10:00' => '16:00' } } } 164 | 165 | it 'has a default config' do 166 | expect(config).to be_kind_of(Hash) 167 | end 168 | 169 | it 'is thread safe' do 170 | expect(WorkingHours::Config.holiday_hours).to eq(config) 171 | 172 | thread = Thread.new do 173 | WorkingHours::Config.holiday_hours = config2 174 | expect(WorkingHours::Config.holiday_hours).to eq(config2) 175 | Thread.stop 176 | expect(WorkingHours::Config.holiday_hours).to eq(config2) 177 | end 178 | 179 | expect { 180 | sleep 0.1 # let the thread begin its execution 181 | }.not_to change { WorkingHours::Config.holiday_hours }.from(config) 182 | 183 | expect { 184 | WorkingHours::Config.holiday_hours = config3 185 | }.to change { WorkingHours::Config.holiday_hours }.from(config).to(config3) 186 | 187 | expect { 188 | thread.run 189 | thread.join 190 | }.not_to change { WorkingHours::Config.holiday_hours }.from(config3) 191 | end 192 | 193 | it 'is fiber safe' do 194 | expect(WorkingHours::Config.holiday_hours).to eq(config) 195 | 196 | fiber = Fiber.new do 197 | WorkingHours::Config.holiday_hours = config2 198 | expect(WorkingHours::Config.holiday_hours).to eq(config2) 199 | Fiber.yield 200 | expect(WorkingHours::Config.holiday_hours).to eq(config2) 201 | end 202 | 203 | expect { 204 | fiber.resume 205 | }.not_to change { WorkingHours::Config.holiday_hours }.from(config) 206 | 207 | expect { 208 | WorkingHours::Config.holiday_hours = config3 209 | }.to change { WorkingHours::Config.holiday_hours }.from(config).to(config3) 210 | 211 | expect { 212 | fiber.resume 213 | }.not_to change { WorkingHours::Config.holiday_hours }.from(config3) 214 | end 215 | 216 | it 'is initialized from last known global config' do 217 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '08:00' => '14:00' } } 218 | Thread.new { 219 | expect(WorkingHours::Config.holiday_hours).to match Date.new(2019, 12, 1) => {'08:00' => '14:00'} 220 | }.join 221 | end 222 | 223 | it 'should support multiple timespan per day' do 224 | time_sheet = { Date.new(2019, 12, 1) => { '08:00' => '12:00', '14:00' => '18:00' } } 225 | WorkingHours::Config.holiday_hours = time_sheet 226 | expect(config).to eq(time_sheet) 227 | end 228 | 229 | describe 'validations' do 230 | it 'rejects invalid day' do 231 | expect { 232 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => 1, 'aaaaaa' => 2 } 233 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid day identifier(s): aaaaaa - must be a Date object") 234 | end 235 | 236 | it 'rejects other type than hash' do 237 | expect { 238 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => [] } 239 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid type for `2019-12-01`: Array - must be Hash") 240 | end 241 | 242 | it 'rejects empty range' do 243 | expect { 244 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => {} } 245 | }.to raise_error(WorkingHours::InvalidConfiguration, "No working hours given for day `2019-12-01`") 246 | end 247 | 248 | it 'rejects invalid time format' do 249 | expect { 250 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '8:0' => '12:00' } } 251 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid time: 8:0 - must be 'HH:MM(:SS)'") 252 | 253 | expect { 254 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '08:00' => '24:10' }} 255 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid time: 24:10 - outside of day") 256 | end 257 | 258 | it 'rejects invalid range' do 259 | expect { 260 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '12:30' => '12:00' } } 261 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid range: 12:30 => 12:00 - ends before it starts") 262 | end 263 | 264 | it 'rejects overlapping range' do 265 | expect { 266 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '08:00' => '13:00', '12:00' => '18:00' } } 267 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid range: 12:00 => 18:00 - overlaps previous range") 268 | end 269 | 270 | it 'does not reject out-of-order, non-overlapping ranges' do 271 | expect { 272 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '10:00' => '11:00', '08:00' => '09:00' } } 273 | }.not_to raise_error 274 | end 275 | 276 | it 'raises an error when precompiling if working hours are invalid after assignment' do 277 | WorkingHours::Config.holiday_hours = { Date.new(2019, 12, 1) => { '10:00' => '11:00', '08:00' => '09:00' } } 278 | WorkingHours::Config.holiday_hours[Date.new(2019, 12, 1)] = 'Not correct' 279 | expect { 280 | 1.working.hour.ago 281 | }.to raise_error(WorkingHours::InvalidConfiguration, 'Invalid type for `2019-12-01`: String - must be Hash') 282 | end 283 | end 284 | end 285 | 286 | describe '.holidays' do 287 | let (:config) { WorkingHours::Config.holidays } 288 | 289 | it 'has a default config' do 290 | expect(config).to eq([]) 291 | end 292 | 293 | it 'should be changeable' do 294 | WorkingHours::Config.holidays = [Date.today] 295 | expect(config).to eq([Date.today]) 296 | end 297 | 298 | it "recomputes precompiled when modified" do 299 | expect { 300 | WorkingHours::Config.holidays << Date.today 301 | }.to change { WorkingHours::Config.precompiled[:holidays] }.by(Set.new([Date.today])) 302 | end 303 | 304 | it 'is initialized from last known global config' do 305 | WorkingHours::Config.holidays = [Date.today] 306 | Thread.new { 307 | expect(WorkingHours::Config.holidays).to eq [Date.today] 308 | }.join 309 | end 310 | 311 | describe 'validation' do 312 | it 'rejects types that cannot be converted into an array' do 313 | expect { 314 | WorkingHours::Config.holidays = Object.new 315 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid type for holidays: Object - must act like an array") 316 | end 317 | 318 | it 'rejects invalid day' do 319 | expect { 320 | WorkingHours::Config.holidays = [Date.today, 42] 321 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid holiday: 42 - must be Date") 322 | end 323 | 324 | it 'raises an error when precompiling if holidays are invalid after assignment' do 325 | WorkingHours::Config.holidays = [Date.today] 326 | WorkingHours::Config.holidays << 42 327 | expect { 328 | 1.working.hour.ago 329 | }.to raise_error(WorkingHours::InvalidConfiguration, 'Invalid holiday: 42 - must be Date') 330 | end 331 | 332 | end 333 | end 334 | 335 | describe '.time_zone' do 336 | let (:config) { WorkingHours::Config.time_zone } 337 | 338 | it 'defaults to UTC' do 339 | expect(config).to eq(ActiveSupport::TimeZone['UTC']) 340 | end 341 | 342 | it 'should accept a String' do 343 | WorkingHours::Config.time_zone = 'Tokyo' 344 | expect(config).to eq(ActiveSupport::TimeZone['Tokyo']) 345 | end 346 | 347 | it 'should accept a TimeZone' do 348 | WorkingHours::Config.time_zone = ActiveSupport::TimeZone['Tokyo'] 349 | expect(config).to eq(ActiveSupport::TimeZone['Tokyo']) 350 | end 351 | 352 | it 'is initialized from last known global config' do 353 | WorkingHours::Config.time_zone = ActiveSupport::TimeZone['Tokyo'] 354 | Thread.new { 355 | expect(WorkingHours::Config.time_zone).to eq ActiveSupport::TimeZone['Tokyo'] 356 | }.join 357 | end 358 | 359 | it "recomputes precompiled when modified" do 360 | expect { 361 | WorkingHours::Config.time_zone.instance_variable_set(:@name, 'Bordeaux') 362 | }.to change { WorkingHours::Config.time_zone.name }.from('UTC').to('Bordeaux') 363 | end 364 | 365 | describe 'validation' do 366 | it 'rejects invalid types' do 367 | expect { 368 | WorkingHours::Config.time_zone = 02 369 | }.to raise_error(WorkingHours::InvalidConfiguration, "Invalid time zone: 2 - must be String or ActiveSupport::TimeZone") 370 | end 371 | 372 | it 'rejects unknown time zone' do 373 | expect { 374 | WorkingHours::Config.time_zone = 'Bordeaux' 375 | }.to raise_error(WorkingHours::InvalidConfiguration, "Unknown time zone: Bordeaux") 376 | end 377 | 378 | it 'raises an error when precompiling if timezone is invalid after assignment' do 379 | WorkingHours::Config.time_zone = 'Paris' 380 | WorkingHours::Config.holidays << ' NotACity' 381 | expect { 382 | 1.working.hour.ago 383 | }.to raise_error(WorkingHours::InvalidConfiguration, 'Invalid holiday: NotACity - must be Date') 384 | end 385 | end 386 | end 387 | 388 | describe '.precompiled' do 389 | subject { WorkingHours::Config.precompiled } 390 | 391 | it 'computes an optimized version' do 392 | expect(subject).to eq({ 393 | :working_hours => [{}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {}], 394 | :holiday_hours => {}, 395 | :holidays => Set.new([]), 396 | :time_zone => ActiveSupport::TimeZone['UTC'] 397 | }) 398 | end 399 | 400 | it 'includes default values for each days so computation does not fail' do 401 | WorkingHours::Config.working_hours = {:mon => {'08:00' => '14:00'}} 402 | expect(subject[:working_hours]).to eq([{}, {28800=>50400}, {}, {}, {}, {}, {}]) 403 | expect(WorkingHours.working_time_between(Time.utc(2014, 4, 14, 0), Time.utc(2014, 4, 21, 0))).to eq(3600*6) 404 | expect(WorkingHours.add_seconds(Time.utc(2014, 4, 14, 0), 3600*7)).to eq(Time.utc(2014, 4, 21, 9)) 405 | end 406 | 407 | it 'supports seconds' do 408 | WorkingHours::Config.working_hours = {:mon => {'20:32:59' => '22:59:59'}} 409 | expect(subject).to eq({ 410 | :working_hours => [{}, {73979 => 82799}, {}, {}, {}, {}, {}], 411 | :holiday_hours => {}, 412 | :holidays => Set.new([]), 413 | :time_zone => ActiveSupport::TimeZone['UTC'] 414 | }) 415 | end 416 | 417 | it 'supports 24:00 (converts to 23:59:59.999999)' do 418 | WorkingHours::Config.working_hours = {:mon => {'20:00' => '24:00'}} 419 | expect(subject).to eq({ 420 | :working_hours => [{}, {72000 => 86399.999999}, {}, {}, {}, {}, {}], 421 | :holiday_hours => {}, 422 | :holidays => Set.new([]), 423 | :time_zone => ActiveSupport::TimeZone['UTC'] 424 | }) 425 | end 426 | 427 | it 'changes if working_hours changes' do 428 | expect { 429 | WorkingHours::Config.working_hours = {:mon => {'08:00' => '14:00'}} 430 | }.to change { 431 | WorkingHours::Config.precompiled[:working_hours] 432 | }.from( 433 | [{}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {32400=>61200}, {}] 434 | ).to( 435 | [{}, {28800=>50400}, {}, {}, {}, {}, {}] 436 | ) 437 | end 438 | 439 | it 'changes if time_zone changes' do 440 | expect { 441 | WorkingHours::Config.time_zone = 'Tokyo' 442 | }.to change { 443 | WorkingHours::Config.precompiled[:time_zone] 444 | }.from(ActiveSupport::TimeZone['UTC']).to(ActiveSupport::TimeZone['Tokyo']) 445 | end 446 | 447 | it 'changes if holidays changes' do 448 | expect { 449 | WorkingHours::Config.holidays = [Date.new(2014, 8, 1), Date.new(2014, 7, 1)] 450 | }.to change { 451 | WorkingHours::Config.precompiled[:holidays] 452 | }.from(Set.new([])).to(Set.new([Date.new(2014, 8, 1), Date.new(2014, 7, 1)])) 453 | end 454 | 455 | it 'changes if config is reset' do 456 | WorkingHours::Config.time_zone = 'Tokyo' 457 | expect { 458 | WorkingHours::Config.reset! 459 | }.to change { 460 | WorkingHours::Config.precompiled[:time_zone] 461 | }.from(ActiveSupport::TimeZone['Tokyo']).to(ActiveSupport::TimeZone['UTC']) 462 | end 463 | 464 | it 'is computed only once' do 465 | precompiled = WorkingHours::Config.precompiled 466 | 3.times { WorkingHours::Config.precompiled } 467 | expect(WorkingHours::Config.precompiled).to be(precompiled) 468 | end 469 | end 470 | 471 | describe '.with_config' do 472 | 473 | let(:working_hours) { { mon: {'08:00' => '19:00'} } } 474 | let(:holidays) { [Date.new(2014, 11, 15)]} 475 | let(:time_zone) { ActiveSupport::TimeZone.new('Paris') } 476 | 477 | it 'sets the corresponding config inside the block' do 478 | WorkingHours::Config.with_config(working_hours: working_hours, holidays: holidays, time_zone: time_zone) do 479 | expect(WorkingHours::Config.working_hours).to eq(working_hours) 480 | expect(WorkingHours::Config.holidays).to eq(holidays) 481 | expect(WorkingHours::Config.time_zone).to eq(time_zone) 482 | end 483 | end 484 | 485 | it 'resets to old config after the block' do 486 | WorkingHours::Config.working_hours = {tue: {'09:00' => '16:00'} } 487 | WorkingHours::Config.holidays = [Date.new(2014, 01, 01)] 488 | WorkingHours::Config.time_zone = ActiveSupport::TimeZone.new('Tokyo') 489 | WorkingHours::Config.with_config(working_hours: working_hours, holidays: holidays, time_zone: time_zone) {} 490 | expect(WorkingHours::Config.working_hours).to eq({tue: {'09:00' => '16:00'} }) 491 | expect(WorkingHours::Config.holidays).to eq([Date.new(2014, 01, 01)]) 492 | expect(WorkingHours::Config.time_zone).to eq(ActiveSupport::TimeZone.new('Tokyo')) 493 | end 494 | 495 | it 'resets to old config after the block even if things go bad' do 496 | WorkingHours::Config.working_hours = {tue: {'09:00' => '16:00'} } 497 | WorkingHours::Config.holidays = [Date.new(2014, 01, 01)] 498 | WorkingHours::Config.time_zone = ActiveSupport::TimeZone.new('Tokyo') 499 | begin 500 | WorkingHours::Config.with_config(working_hours: working_hours, holidays: holidays, time_zone: time_zone) do 501 | raise 502 | end 503 | rescue 504 | end 505 | expect(WorkingHours::Config.working_hours).to eq({tue: {'09:00' => '16:00'} }) 506 | expect(WorkingHours::Config.holidays).to eq([Date.new(2014, 01, 01)]) 507 | expect(WorkingHours::Config.time_zone).to eq(ActiveSupport::TimeZone.new('Tokyo')) 508 | end 509 | 510 | it 'raises if working_hours are invalid' do 511 | expect { WorkingHours::Config.with_config(working_hours: {}) {}}.to raise_error(WorkingHours::InvalidConfiguration) 512 | end 513 | 514 | it 'raises if holidays are invalid' do 515 | expect { WorkingHours::Config.with_config(holidays: [1]) {}}.to raise_error(WorkingHours::InvalidConfiguration) 516 | end 517 | 518 | it 'raises if timezone is invalid' do 519 | expect { WorkingHours::Config.with_config(time_zone: '67P Comet') {}}.to raise_error(WorkingHours::InvalidConfiguration) 520 | end 521 | 522 | end 523 | end 524 | -------------------------------------------------------------------------------- /spec/working_hours/core_ext/date_and_time_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe WorkingHours::CoreExt::DateAndTime do 4 | let(:duration) { 5.working.days } 5 | 6 | describe 'operator +' do 7 | it 'works with Time objects' do 8 | time = Time.now 9 | expect(WorkingHours).to receive(:add_days).with(time, 5) 10 | time + duration 11 | end 12 | 13 | it 'works with Date objects' do 14 | date = Date.today 15 | expect(WorkingHours).to receive(:add_days).with(date, 5) 16 | date + duration 17 | end 18 | 19 | it 'works with DateTime objects' do 20 | date_time = DateTime.now 21 | expect(WorkingHours).to receive(:add_days).with(date_time, 5) 22 | date_time + duration 23 | end 24 | 25 | it 'works with ActiveSupport::TimeWithZone' do 26 | time = Time.now.in_time_zone('Tokyo') 27 | expect(WorkingHours).to receive(:add_days).with(time, 5) 28 | time + duration 29 | end 30 | 31 | it "doesn't break original Time operator" do 32 | time = Time.now 33 | expect(WorkingHours).not_to receive(:add_days) 34 | expect(time + 3600).to eq(time + 1.hour) 35 | end 36 | 37 | it "doesn't break original Date operator" do 38 | date = Date.today 39 | expect(WorkingHours).not_to receive(:add_days) 40 | expect(date + 1).to eq(date + 1.day) 41 | end 42 | 43 | it "doesn't break original DateTime operator" do 44 | datetime = DateTime.now.change(usec: 0) 45 | expect(WorkingHours).not_to receive(:add_days) 46 | expect(datetime + 1).to eq(datetime + 1.day) 47 | end 48 | 49 | it "doesn't break original TimeWithZone operator" do 50 | Time.zone = 'UTC' 51 | time = Time.zone.now 52 | expect(WorkingHours).not_to receive(:add_days) 53 | expect(time + 1).to eq(time + 1.second) 54 | end 55 | end 56 | 57 | describe 'operator -' do 58 | it 'works with Time objects' do 59 | time = Time.now 60 | expect(WorkingHours).to receive(:add_days).with(time, -5) 61 | time - duration 62 | end 63 | 64 | it 'works with Date objects' do 65 | date = Date.today 66 | expect(WorkingHours).to receive(:add_days).with(date, -5) 67 | date - duration 68 | end 69 | 70 | it 'works with DateTime objects' do 71 | date_time = DateTime.now 72 | expect(WorkingHours).to receive(:add_days).with(date_time, -5) 73 | date_time - duration 74 | end 75 | 76 | it 'works with ActiveSupport::TimeWithZone' do 77 | time = Time.now.in_time_zone('Tokyo') 78 | expect(WorkingHours).to receive(:add_days).with(time, -5) 79 | time - duration 80 | end 81 | 82 | it "doesn't break original Time operator" do 83 | time = Time.now 84 | expect(WorkingHours).not_to receive(:add_days) 85 | expect(time - 3600).to eq(time - 1.hour) 86 | end 87 | 88 | it "doesn't break original Date operator" do 89 | date = Date.today 90 | expect(WorkingHours).not_to receive(:add_days) 91 | expect(date - 1).to eq(date - 1.day) 92 | end 93 | 94 | it "doesn't break original DateTime operator" do 95 | datetime = DateTime.now.change(usec: 0) 96 | expect(WorkingHours).not_to receive(:add_days) 97 | expect(datetime - 1).to eq(datetime - 1.day) 98 | end 99 | 100 | it "doesn't break original TimeWithZone operator" do 101 | Time.zone = 'UTC' 102 | time = Time.zone.now 103 | expect(WorkingHours).not_to receive(:add_days) 104 | expect(time - 1).to eq(time - 1.second) 105 | end 106 | end 107 | 108 | describe '#working_days_until' do 109 | it 'works with Time objects' do 110 | from = Time.new(1991, 11, 15) 111 | to = Time.new(1991, 11, 22) 112 | expect(WorkingHours).to receive(:working_days_between).with(from, to) 113 | from.working_days_until(to) 114 | end 115 | 116 | it 'works with Date objects' do 117 | from = Date.new(1991, 11, 15) 118 | to = Date.new(1991, 11, 22) 119 | expect(WorkingHours).to receive(:working_days_between).with(from, to) 120 | from.working_days_until(to) 121 | end 122 | 123 | it 'works with DateTime objects' do 124 | from = DateTime.new(1991, 11, 15) 125 | to = DateTime.new(1991, 11, 22) 126 | expect(WorkingHours).to receive(:working_days_between).with(from, to) 127 | from.working_days_until(to) 128 | end 129 | 130 | it 'works with ActiveSupport::TimeWithZone' do 131 | from = Time.new(1991, 11, 15).in_time_zone('Tokyo') 132 | to = Time.new(1991, 11, 22).in_time_zone('Tokyo') 133 | expect(WorkingHours).to receive(:working_days_between).with(from, to) 134 | from.working_days_until(to) 135 | end 136 | end 137 | 138 | describe '#working_time_until' do 139 | it 'works with Time objects' do 140 | from = Time.new(1991, 11, 15) 141 | to = Time.new(1991, 11, 22) 142 | expect(WorkingHours).to receive(:working_time_between).with(from, to) 143 | from.working_time_until(to) 144 | end 145 | 146 | it 'works with Date objects' do 147 | from = Date.new(1991, 11, 15) 148 | to = Date.new(1991, 11, 22) 149 | expect(WorkingHours).to receive(:working_time_between).with(from, to) 150 | from.working_time_until(to) 151 | end 152 | 153 | it 'works with DateTime objects' do 154 | from = DateTime.new(1991, 11, 15) 155 | to = DateTime.new(1991, 11, 22) 156 | expect(WorkingHours).to receive(:working_time_between).with(from, to) 157 | from.working_time_until(to) 158 | end 159 | 160 | it 'works with ActiveSupport::TimeWithZone' do 161 | from = Time.new(1991, 11, 15).in_time_zone('Tokyo') 162 | to = Time.new(1991, 11, 22).in_time_zone('Tokyo') 163 | expect(WorkingHours).to receive(:working_time_between).with(from, to) 164 | from.working_time_until(to) 165 | end 166 | end 167 | 168 | describe '#working_day?' do 169 | it 'works with Time objects' do 170 | time = Time.new(1991, 11, 15) 171 | expect(WorkingHours).to receive(:working_day?).with(time) 172 | time.working_day? 173 | end 174 | 175 | it 'works with Date objects' do 176 | time = Date.new(1991, 11, 15) 177 | expect(WorkingHours).to receive(:working_day?).with(time) 178 | time.working_day? 179 | end 180 | 181 | it 'works with DateTime objects' do 182 | time = DateTime.new(1991, 11, 15) 183 | expect(WorkingHours).to receive(:working_day?).with(time) 184 | time.working_day? 185 | end 186 | 187 | it 'works with ActiveSupport::TimeWithZone' do 188 | time = Time.new(1991, 11, 15).in_time_zone('Tokyo') 189 | expect(WorkingHours).to receive(:working_day?).with(time) 190 | time.working_day? 191 | end 192 | end 193 | 194 | describe '#in_working_hours?' do 195 | it 'works with Time objects' do 196 | time = Time.new(1991, 11, 15) 197 | expect(WorkingHours).to receive(:in_working_hours?).with(time) 198 | time.in_working_hours? 199 | end 200 | 201 | it 'works with Date objects' do 202 | time = Date.new(1991, 11, 15) 203 | expect(WorkingHours).to receive(:in_working_hours?).with(time) 204 | time.in_working_hours? 205 | end 206 | 207 | it 'works with DateTime objects' do 208 | time = DateTime.new(1991, 11, 15) 209 | expect(WorkingHours).to receive(:in_working_hours?).with(time) 210 | time.in_working_hours? 211 | end 212 | 213 | it 'works with ActiveSupport::TimeWithZone' do 214 | time = Time.new(1991, 11, 15).in_time_zone('Tokyo') 215 | expect(WorkingHours).to receive(:in_working_hours?).with(time) 216 | time.in_working_hours? 217 | end 218 | end 219 | end 220 | -------------------------------------------------------------------------------- /spec/working_hours/core_ext/integer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe WorkingHours::CoreExt::Integer do 4 | 5 | describe '#working' do 6 | it 'returns a DurationProxy' do 7 | proxy = 42.working 8 | expect(proxy).to be_kind_of(WorkingHours::DurationProxy) 9 | expect(proxy.value).to eq(42) 10 | end 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /spec/working_hours/duration_proxy_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe WorkingHours::DurationProxy do 4 | describe '#initialize' do 5 | it 'is constructed with a value' do 6 | proxy = WorkingHours::DurationProxy.new(42) 7 | expect(proxy.value).to eq(42) 8 | end 9 | end 10 | 11 | context 'proxy methods' do 12 | 13 | let(:proxy) { WorkingHours::DurationProxy.new(42) } 14 | 15 | WorkingHours::Duration::SUPPORTED_KINDS.each do |kind| 16 | singular = kind[0..-2] 17 | 18 | it "##{kind} returns a duration object" do 19 | duration = proxy.send(kind) 20 | expect(duration.value).to eq(42) 21 | expect(duration.kind).to eq(kind) 22 | end 23 | 24 | it "##{singular} returns a duration object" do 25 | duration = proxy.send(singular) 26 | expect(duration.value).to eq(42) 27 | expect(duration.kind).to eq(kind) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/working_hours/duration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe WorkingHours::Duration do 4 | 5 | describe '#initialize' do 6 | it 'is initialized with a number and a type' do 7 | duration = WorkingHours::Duration.new(5, :days) 8 | expect(duration.value).to eq(5) 9 | expect(duration.kind).to eq(:days) 10 | end 11 | 12 | it 'should work with days' do 13 | duration = WorkingHours::Duration.new(42, :days) 14 | expect(duration.kind).to eq(:days) 15 | end 16 | 17 | it 'should work with hours' do 18 | duration = WorkingHours::Duration.new(42, :hours) 19 | expect(duration.kind).to eq(:hours) 20 | end 21 | 22 | it 'should work with minutes' do 23 | duration = WorkingHours::Duration.new(42, :minutes) 24 | expect(duration.kind).to eq(:minutes) 25 | end 26 | 27 | it 'should work with seconds' do 28 | duration = WorkingHours::Duration.new(42, :seconds) 29 | expect(duration.kind).to eq(:seconds) 30 | end 31 | 32 | it 'should not work with anything else' do 33 | expect { 34 | duration = WorkingHours::Duration.new(42, :foo) 35 | }.to raise_error ArgumentError, "Invalid working time unit: foo" 36 | end 37 | end 38 | 39 | describe '#-@' do 40 | it 'inverses value' do 41 | expect(-2.working.days).to eq(WorkingHours::Duration.new(-2, :days)) 42 | expect(-(-1.working.hour)).to eq(WorkingHours::Duration.new(1, :hours)) 43 | end 44 | end 45 | 46 | describe '#since' do 47 | it "performs addition with Time.now" do 48 | Timecop.freeze(Time.utc(1991, 11, 15, 21)) # we are Friday 21 pm UTC 49 | expect(1.working.day.since).to eq(Time.utc(1991, 11, 18, 21)) 50 | end 51 | 52 | it "is aliased to from_now" do 53 | Timecop.freeze(Time.utc(1991, 11, 15, 21)) # we are Friday 21 pm UTC 54 | expect(1.working.day.from_now).to eq(Time.utc(1991, 11, 18, 21)) 55 | end 56 | 57 | it "accepts reference time as argument" do 58 | expect(1.working.day.since(Time.utc(1991, 11, 15, 21))).to eq(Time.utc(1991, 11, 18, 21)) 59 | end 60 | 61 | it 'returns time in config zone' do 62 | WorkingHours::Config.time_zone = 'Tokyo' 63 | expect(7.working.days.from_now.zone).to eq('JST') 64 | end 65 | 66 | it 'should not hang with fractional hours' do 67 | WorkingHours::Duration.new(4.1, :hours).since(Time.utc(1991, 11, 15, 21)) 68 | end 69 | end 70 | 71 | describe '#until' do 72 | it "performs substraction with Time.now" do 73 | Timecop.freeze(Time.utc(1991, 11, 15, 21)) # we are Friday 21 pm UTC 74 | expect(7.working.day.until).to eq(Time.utc(1991, 11, 6, 21)) 75 | end 76 | 77 | it "is aliased to ago" do 78 | Timecop.freeze(Time.utc(1991, 11, 15, 21)) # we are Friday 21 pm UTC 79 | expect(7.working.day.ago).to eq(Time.utc(1991, 11, 6, 21)) 80 | end 81 | 82 | it "accepts reference time as argument" do 83 | expect(7.working.day.until(Time.utc(1991, 11, 15, 21))).to eq(Time.utc(1991, 11, 6, 21)) 84 | end 85 | 86 | it 'returns time in config zone' do 87 | WorkingHours::Config.time_zone = 'Tokyo' 88 | expect(7.working.days.ago.zone).to eq('JST') 89 | end 90 | end 91 | 92 | end 93 | -------------------------------------------------------------------------------- /spec/working_hours_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe WorkingHours do 4 | 5 | it 'can be used to call computation methods' do 6 | [ :advance_to_working_time, :return_to_working_time, 7 | :working_day?, :in_working_hours?, 8 | :working_days_between, :working_time_between 9 | ].each do |method| 10 | expect(WorkingHours).to respond_to(method) 11 | end 12 | end 13 | 14 | end -------------------------------------------------------------------------------- /working_hours.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'working_hours/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "working_hours" 8 | spec.version = WorkingHours::VERSION 9 | spec.authors = ["Adrien Jarthon", "Intrepidd"] 10 | spec.email = ["me@adrienjarthon.com", "adrien@siami.fr"] 11 | spec.summary = %q{time calculation with working hours} 12 | spec.description = %q{A modern ruby gem allowing to do time calculation with working hours.} 13 | spec.homepage = "https://github.com/intrepidd/working_hours" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency 'activesupport', '>= 7.0' 22 | spec.add_dependency 'tzinfo' 23 | 24 | spec.add_development_dependency 'bundler', '>= 1.5' 25 | spec.add_development_dependency 'rake' 26 | spec.add_development_dependency 'rspec', '~> 3.2' 27 | spec.add_development_dependency 'timecop' 28 | end 29 | --------------------------------------------------------------------------------