├── .rspec ├── .gitignore ├── spec ├── support.rb ├── helper.rb ├── error_spec.rb ├── dates_spec.rb ├── core_ext │ ├── date_spec.rb │ ├── time_spec.rb │ └── integer_spec.rb ├── calculation │ ├── on_holiday_spec.rb │ ├── duration_within_spec.rb │ ├── on_break_spec.rb │ ├── active_spec.rb │ └── for_duration_spec.rb ├── week_time_spec.rb ├── validation_spec.rb ├── periods │ ├── proxy_spec.rb │ ├── before_spec.rb │ └── after_spec.rb ├── date_spec.rb ├── timeline │ ├── proxy_spec.rb │ ├── backward_spec.rb │ └── forward_spec.rb ├── support │ └── time.rb ├── week_time │ ├── abstract_spec.rb │ ├── start_spec.rb │ └── end_spec.rb ├── week_spec.rb ├── holiday_spec.rb ├── biz_spec.rb ├── day_of_week_spec.rb ├── time_spec.rb ├── duration_spec.rb ├── schedule_spec.rb ├── day_time_spec.rb └── time_segment_spec.rb ├── lib ├── biz │ ├── version.rb │ ├── error.rb │ ├── core_ext │ │ ├── date.rb │ │ ├── integer.rb │ │ └── time.rb │ ├── timeline.rb │ ├── periods.rb │ ├── calculation.rb │ ├── dates.rb │ ├── core_ext.rb │ ├── week_time │ │ ├── end.rb │ │ ├── start.rb │ │ └── abstract.rb │ ├── timeline │ │ ├── proxy.rb │ │ ├── backward.rb │ │ ├── forward.rb │ │ └── abstract.rb │ ├── date.rb │ ├── calculation │ │ ├── on_break.rb │ │ ├── on_holiday.rb │ │ ├── duration_within.rb │ │ ├── active.rb │ │ └── for_duration.rb │ ├── week_time.rb │ ├── periods │ │ ├── after.rb │ │ ├── proxy.rb │ │ ├── before.rb │ │ ├── linear.rb │ │ └── abstract.rb │ ├── holiday.rb │ ├── week.rb │ ├── validation.rb │ ├── duration.rb │ ├── day_of_week.rb │ ├── schedule.rb │ ├── time_segment.rb │ ├── interval.rb │ ├── time.rb │ ├── day_time.rb │ └── configuration.rb └── biz.rb ├── .github ├── CODEOWNERS └── workflows │ └── actions.yml ├── script ├── spec ├── bootstrap └── console ├── Rakefile ├── gems.rb ├── biz.gemspec ├── .rubocop.yml ├── CHANGELOG.md ├── benchmark └── performance ├── LICENSE └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --require helper 2 | --warnings 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | gems.locked 4 | pkg 5 | bin/ 6 | coverage/ 7 | -------------------------------------------------------------------------------- /spec/support.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'support/time' 4 | -------------------------------------------------------------------------------- /lib/biz/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | VERSION = '1.8.2' 5 | end 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # CODEOWNERS file 2 | # This file defines who should review code changes in this repository. 3 | 4 | * @zendesk/fang 5 | -------------------------------------------------------------------------------- /script/spec: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if ! bundle check > /dev/null 6 | then 7 | bundle install --quiet 2> /dev/null 8 | fi 9 | 10 | bundle exec rake 11 | -------------------------------------------------------------------------------- /lib/biz/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Error < StandardError 5 | 6 | Configuration = Class.new(self) 7 | 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/biz/core_ext/date.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module CoreExt 5 | module Date 6 | def business_day? 7 | Biz.dates.active?(self) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/biz/timeline.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Timeline 5 | end 6 | end 7 | 8 | require 'biz/timeline/abstract' 9 | 10 | require 'biz/timeline/forward' 11 | require 'biz/timeline/backward' 12 | require 'biz/timeline/proxy' 13 | -------------------------------------------------------------------------------- /lib/biz/periods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Periods 5 | end 6 | end 7 | 8 | require 'biz/periods/abstract' 9 | 10 | require 'biz/periods/after' 11 | require 'biz/periods/before' 12 | require 'biz/periods/linear' 13 | require 'biz/periods/proxy' 14 | -------------------------------------------------------------------------------- /lib/biz/core_ext/integer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module CoreExt 5 | module Integer 6 | Calculation::ForDuration.units.each do |unit| 7 | define_method("business_#{unit}") { Biz.time(self, unit) } 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | bold() { 6 | echo "\033[1m$1\033[0m" 7 | } 8 | 9 | echo 'Installing dependencies...' 10 | 11 | if ! bundle check > /dev/null 12 | then 13 | bundle install --quiet 2> /dev/null 14 | fi 15 | 16 | echo 17 | bold 'Bootstrapping complete!' 18 | -------------------------------------------------------------------------------- /lib/biz/calculation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Calculation 5 | end 6 | end 7 | 8 | require 'biz/calculation/active' 9 | require 'biz/calculation/duration_within' 10 | require 'biz/calculation/for_duration' 11 | require 'biz/calculation/on_break' 12 | require 'biz/calculation/on_holiday' 13 | -------------------------------------------------------------------------------- /spec/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV['CI'] 4 | require 'simplecov' 5 | 6 | SimpleCov.start 7 | end 8 | 9 | require 'biz' 10 | 11 | RSpec.configure do |config| 12 | config.color = true 13 | config.tty = true 14 | config.order = :random 15 | 16 | config.disable_monkey_patching! 17 | end 18 | 19 | require 'support' 20 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec/core/rake_task' 4 | require 'rubocop/rake_task' 5 | 6 | unless ENV['CI'] 7 | require 'bump/tasks' 8 | require 'bundler/gem_tasks' 9 | end 10 | 11 | RSpec::Core::RakeTask.new(:spec) do |task| 12 | task.verbose = false 13 | end 14 | 15 | RuboCop::RakeTask.new 16 | 17 | task default: %i[spec rubocop] 18 | -------------------------------------------------------------------------------- /lib/biz/dates.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Dates < SimpleDelegator 5 | 6 | def initialize(schedule) 7 | super( 8 | Clavius::Schedule.new do |c| 9 | c.weekdays = schedule.weekdays 10 | c.excluded = schedule.holidays.map(&:to_date) 11 | end 12 | ) 13 | end 14 | 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Error do 4 | it 'is a standard error' do 5 | expect(described_class.new.is_a?(StandardError)).to eq true 6 | end 7 | 8 | describe Biz::Error::Configuration do 9 | it "is a 'biz' error" do 10 | expect(described_class.new.is_a?(Biz::Error)).to eq true 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/biz/core_ext/time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module CoreExt 5 | module Time 6 | def business_hours? 7 | Biz.in_hours?(self) 8 | end 9 | 10 | def on_break? 11 | Biz.on_break?(self) 12 | end 13 | 14 | def on_holiday? 15 | Biz.on_holiday?(self) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/biz/core_ext.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module CoreExt 5 | end 6 | end 7 | 8 | require 'biz/core_ext/date' 9 | require 'biz/core_ext/integer' 10 | require 'biz/core_ext/time' 11 | 12 | Date.class_eval do include Biz::CoreExt::Date end 13 | Integer.class_eval do include Biz::CoreExt::Integer end 14 | Time.class_eval do include Biz::CoreExt::Time end 15 | -------------------------------------------------------------------------------- /lib/biz/week_time/end.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module WeekTime 5 | class End < Abstract 6 | 7 | def day_time 8 | @day_time ||= DayTime.from_minute(day_of_week.day_minute(week_minute)) 9 | end 10 | 11 | private 12 | 13 | def day_of_week 14 | @day_of_week ||= DayOfWeek.all.find { |day| day.contains?(week_minute) } 15 | end 16 | 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/biz/timeline/proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Timeline 5 | class Proxy 6 | 7 | def initialize(periods) 8 | @periods = periods 9 | end 10 | 11 | def forward 12 | Forward.new(periods) 13 | end 14 | 15 | def backward 16 | Backward.new(periods) 17 | end 18 | 19 | private 20 | 21 | attr_reader :periods 22 | 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/biz/date.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Date 5 | 6 | EPOCH = ::Date.new(2006, 1, 1).freeze 7 | 8 | private_constant :EPOCH 9 | 10 | def self.epoch 11 | EPOCH 12 | end 13 | 14 | def self.from_day(day) 15 | EPOCH + day 16 | end 17 | 18 | def self.for_dst(date, day_time) 19 | date + ((day_time.day_second + Time.hour_seconds) / Time.day_seconds) 20 | end 21 | 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/biz/calculation/on_break.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Calculation 5 | class OnBreak 6 | 7 | def initialize(schedule, time) 8 | @schedule = schedule 9 | @time = time 10 | end 11 | 12 | def result 13 | schedule.breaks.any? { |brake| brake.contains?(time) } 14 | end 15 | 16 | private 17 | 18 | attr_reader :schedule, 19 | :time 20 | 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/biz/calculation/on_holiday.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Calculation 5 | class OnHoliday 6 | 7 | def initialize(schedule, time) 8 | @schedule = schedule 9 | @time = time 10 | end 11 | 12 | def result 13 | schedule.holidays.any? { |holiday| holiday.contains?(time) } 14 | end 15 | 16 | private 17 | 18 | attr_reader :schedule, 19 | :time 20 | 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/dates_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Dates do 4 | subject(:dates) { 5 | described_class.new( 6 | schedule( 7 | hours: {mon: {'01:00' => '02:00'}}, 8 | holidays: [Date.new(2006, 1, 9)] 9 | ) 10 | ) 11 | } 12 | 13 | it 'returns dates based on the configured schedule' do 14 | expect(dates.after(Date.new(2006, 1, 1)).take(2).to_a).to eq [ 15 | Date.new(2006, 1, 2), 16 | Date.new(2006, 1, 16) 17 | ] 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/biz/week_time/start.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module WeekTime 5 | class Start < Abstract 6 | 7 | def day_time 8 | @day_time ||= DayTime.from_minute(week_minute % Time.day_minutes) 9 | end 10 | 11 | private 12 | 13 | def day_of_week 14 | @day_of_week ||= 15 | DayOfWeek.all.find { |day_of_week| 16 | day_of_week.wday?(week_minute / Time.day_minutes % Time.week_days) 17 | } 18 | end 19 | 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/biz/week_time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module WeekTime 5 | class << self 6 | 7 | def from_time(time) 8 | Start.from_time(time) 9 | end 10 | 11 | def start(week_minute) 12 | Start.new(week_minute) 13 | end 14 | 15 | def end(week_minute) 16 | End.new(week_minute) 17 | end 18 | 19 | alias build start 20 | 21 | end 22 | end 23 | end 24 | 25 | require 'biz/week_time/abstract' 26 | 27 | require 'biz/week_time/end' 28 | require 'biz/week_time/start' 29 | -------------------------------------------------------------------------------- /lib/biz/calculation/duration_within.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Calculation 5 | class DurationWithin < SimpleDelegator 6 | 7 | def initialize(schedule, calculation_period) 8 | super( 9 | schedule 10 | .periods 11 | .after(calculation_period.start_time) 12 | .timeline 13 | .until(calculation_period.end_time) 14 | .map(&:duration) 15 | .reduce(Duration.new(0), :+) 16 | ) 17 | end 18 | 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'biz' 6 | require 'irb' 7 | 8 | TEST_SCHEDULE = Biz::Schedule.new do |config| 9 | config.hours = { 10 | mon: {'09:00' => '12:00', '13:00' => '17:00'}, 11 | tue: {'09:00' => '12:00', '13:00' => '17:00'}, 12 | wed: {'09:00' => '12:00', '13:00' => '17:00'}, 13 | thu: {'09:00' => '12:00', '13:00' => '17:00'}, 14 | fri: {'09:00' => '12:00', '13:00' => '17:00'}, 15 | sat: {'10:00' => '14:00'} 16 | } 17 | 18 | config.holidays = [Date.new(2014, 1, 1), Date.new(2014, 12, 25)] 19 | 20 | config.time_zone = 'Etc/UTC' 21 | end 22 | 23 | IRB.start 24 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | group :benchmark do 8 | gem 'benchmark-ipsa', '~> 0.2.0', require: false 9 | gem 'business_time', '~> 0.9.0', require: false 10 | gem 'working_hours', '~> 1.0', require: false 11 | end 12 | 13 | group :ci do 14 | gem 'simplecov', '~> 0.16.0', require: false 15 | end 16 | 17 | group :development do 18 | gem 'bump', '~> 0.7.0', require: false 19 | gem 'bundler', '~> 2.0', require: false 20 | end 21 | 22 | group :ci, :development do 23 | gem 'rake', '~> 12.0', require: false 24 | gem 'rspec', '~> 3.0', require: false 25 | gem 'rubocop', '~> 1.45.0', require: false 26 | end 27 | -------------------------------------------------------------------------------- /lib/biz/periods/after.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Periods 5 | class After < Abstract 6 | 7 | def initialize(schedule, origin) 8 | @boundary = TimeSegment.after(origin) 9 | @intervals = schedule.intervals 10 | @shifts = schedule.shifts 11 | 12 | super 13 | end 14 | 15 | def timeline 16 | super.forward 17 | end 18 | 19 | private 20 | 21 | def selector 22 | :min_by 23 | end 24 | 25 | def weeks 26 | Range.new( 27 | Week.since_epoch(schedule.in_zone.local(origin)), 28 | Week.since_epoch(Time.heat_death) 29 | ) 30 | end 31 | 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/biz/periods/proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Periods 5 | class Proxy 6 | 7 | def initialize(schedule) 8 | @schedule = schedule 9 | end 10 | 11 | def after(origin) 12 | After.new(schedule, origin) 13 | end 14 | 15 | def before(origin) 16 | Before.new(schedule, origin) 17 | end 18 | 19 | def on(date) 20 | schedule 21 | .periods 22 | .after(schedule.in_zone.on_date(date, DayTime.midnight)) 23 | .timeline 24 | .until(schedule.in_zone.on_date(date, DayTime.endnight)) 25 | end 26 | 27 | private 28 | 29 | attr_reader :schedule 30 | 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/biz/timeline/backward.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Timeline 5 | class Backward < Abstract 6 | 7 | def backward 8 | self 9 | end 10 | 11 | private 12 | 13 | def occurred?(period, terminus) 14 | period.end_time <= terminus 15 | end 16 | 17 | def overflow?(*) 18 | false 19 | end 20 | 21 | def comparison_period(period, terminus) 22 | TimeSegment.new(terminus, period.end_time) 23 | end 24 | 25 | def duration_period(period, duration) 26 | TimeSegment.new( 27 | period.end_time - duration.in_seconds, 28 | period.end_time 29 | ) 30 | end 31 | 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/biz/timeline/forward.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Timeline 5 | class Forward < Abstract 6 | 7 | def forward 8 | self 9 | end 10 | 11 | private 12 | 13 | def occurred?(period, terminus) 14 | period.start_time > terminus 15 | end 16 | 17 | def overflow?(period, remaining) 18 | period.duration == remaining 19 | end 20 | 21 | def comparison_period(period, terminus) 22 | TimeSegment.new(period.start_time, terminus) 23 | end 24 | 25 | def duration_period(period, duration) 26 | TimeSegment.new( 27 | period.start_time, 28 | period.start_time + duration.in_seconds 29 | ) 30 | end 31 | 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /biz.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path('lib/biz/version', __dir__) 4 | 5 | Gem::Specification.new do |gem| 6 | gem.name = 'biz' 7 | gem.version = Biz::VERSION 8 | gem.authors = ['Craig Little', 'Alex Stone'] 9 | gem.email = %w[clittle@zendesk.com astone@zendesk.com] 10 | gem.summary = 'Business hours calculations' 11 | gem.description = 'Time calculations using business hours.' 12 | gem.homepage = 'https://github.com/zendesk/biz' 13 | gem.license = 'Apache 2.0' 14 | gem.files = Dir['lib/**/*', 'README.md'] 15 | gem.metadata = {'rubygems_mfa_required' => 'true'} 16 | 17 | gem.required_ruby_version = '>= 2.7' 18 | 19 | gem.add_dependency 'clavius', '~> 1.0' 20 | gem.add_dependency 'tzinfo' 21 | end 22 | -------------------------------------------------------------------------------- /lib/biz/periods/before.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Periods 5 | class Before < Abstract 6 | 7 | def initialize(schedule, origin) 8 | @boundary = TimeSegment.before(origin) 9 | @intervals = schedule.intervals.reverse 10 | @shifts = schedule.shifts.reverse 11 | 12 | super 13 | end 14 | 15 | def timeline 16 | super.backward 17 | end 18 | 19 | private 20 | 21 | def selector 22 | :max_by 23 | end 24 | 25 | def weeks 26 | Week 27 | .since_epoch(schedule.in_zone.local(origin)) 28 | .downto(Week.since_epoch(Time.big_bang)) 29 | end 30 | 31 | def active_periods(*) 32 | super.reverse 33 | end 34 | 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/biz/calculation/active.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Calculation 5 | class Active 6 | 7 | def initialize(schedule, time) 8 | @schedule = schedule 9 | @time = time 10 | end 11 | 12 | def result 13 | return in_hours? && active? if schedule.shifts.none? 14 | 15 | schedule.periods.after(time).first.contains?(time) 16 | end 17 | 18 | private 19 | 20 | attr_reader :schedule, 21 | :time 22 | 23 | def in_hours? 24 | schedule.intervals.any? { |interval| interval.contains?(time) } 25 | end 26 | 27 | def active? 28 | schedule.holidays.none? { |holiday| holiday.contains?(time) } && 29 | schedule.breaks.none? { |brake| brake.contains?(time) } 30 | end 31 | 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/biz/holiday.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Holiday 5 | 6 | extend Forwardable 7 | 8 | include Comparable 9 | 10 | def initialize(date, time_zone) 11 | @date = date 12 | @time_zone = time_zone 13 | end 14 | 15 | delegate contains?: :to_time_segment 16 | 17 | def to_time_segment 18 | @to_time_segment ||= 19 | TimeSegment.new( 20 | Time.new(time_zone).on_date(date, DayTime.midnight), 21 | Time.new(time_zone).on_date(date, DayTime.endnight) 22 | ) 23 | end 24 | 25 | protected 26 | 27 | attr_reader :date, 28 | :time_zone 29 | 30 | public 31 | 32 | def to_date 33 | date 34 | end 35 | 36 | private 37 | 38 | def <=>(other) 39 | return unless other.is_a?(self.class) 40 | 41 | [date, time_zone] <=> [other.date, other.time_zone] 42 | end 43 | 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | SuggestExtensions: false 4 | TargetRubyVersion: 2.7 5 | 6 | Bundler/GemFilename: 7 | EnforcedStyle: gems.rb 8 | 9 | Layout/HashAlignment: 10 | EnforcedHashRocketStyle: table 11 | EnforcedColonStyle: table 12 | EnforcedLastArgumentHashStyle: ignore_implicit 13 | 14 | Layout/EmptyLinesAroundClassBody: 15 | EnforcedStyle: empty_lines 16 | 17 | Layout/MultilineMethodCallIndentation: 18 | EnforcedStyle: indented 19 | 20 | Layout/SpaceInsideHashLiteralBraces: 21 | EnforcedStyle: no_space 22 | 23 | Metrics/AbcSize: 24 | Enabled: false 25 | 26 | Metrics/BlockLength: 27 | Enabled: false 28 | 29 | Metrics/ClassLength: 30 | Enabled: false 31 | 32 | Metrics/MethodLength: 33 | Enabled: false 34 | 35 | Style/AndOr: 36 | EnforcedStyle: conditionals 37 | 38 | Style/BlockDelimiters: 39 | EnforcedStyle: semantic 40 | 41 | Style/Documentation: 42 | Enabled: false 43 | 44 | Style/SignalException: 45 | EnforcedStyle: semantic 46 | -------------------------------------------------------------------------------- /spec/core_ext/date_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'biz/core_ext/date' 4 | 5 | RSpec.describe Biz::CoreExt::Date do 6 | let(:date_class) { Class.new(Date) do include Biz::CoreExt::Date end } 7 | 8 | before do 9 | Biz.configure do |config| 10 | config.hours = {mon: {'09:00' => '17:00'}} 11 | config.holidays = [] 12 | config.time_zone = 'Etc/UTC' 13 | end 14 | end 15 | 16 | after do Thread.current[:biz_schedule] = nil end 17 | 18 | describe '#business_day?' do 19 | context 'when the date contains at least one business period' do 20 | let(:date) { date_class.new(2006, 1, 2) } 21 | 22 | it 'returns true' do 23 | expect(date.business_day?).to eq true 24 | end 25 | end 26 | 27 | context 'when the date does not contain any business periods' do 28 | let(:date) { date_class.new(2006, 1, 3) } 29 | 30 | it 'returns false' do 31 | expect(date.business_day?).to eq false 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/biz/periods/linear.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Periods 5 | class Linear < SimpleDelegator 6 | 7 | def initialize(periods, shifts, selector) 8 | @periods = periods.to_enum 9 | @shifts = shifts.to_enum 10 | @selector = selector 11 | @sequences = [@periods, @shifts] 12 | 13 | super(linear_periods) 14 | end 15 | 16 | private 17 | 18 | attr_reader :periods, 19 | :shifts, 20 | :selector, 21 | :sequences 22 | 23 | def linear_periods 24 | Enumerator.new do |yielder| 25 | loop do 26 | periods.next and next if periods.peek.date == shifts.peek.date 27 | 28 | yielder << begin 29 | sequences 30 | .public_send(selector) { |sequence| sequence.peek.date } 31 | .next 32 | end 33 | end 34 | 35 | loop do yielder << periods.next end 36 | end 37 | end 38 | 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/biz/week_time/abstract.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module WeekTime 5 | class Abstract 6 | 7 | extend Forwardable 8 | 9 | include Comparable 10 | 11 | def self.from_time(time) 12 | new( 13 | (time.wday * Time.day_minutes) + 14 | (time.hour * Time.hour_minutes) + 15 | time.min 16 | ) 17 | end 18 | 19 | def initialize(week_minute) 20 | @week_minute = Integer(week_minute) 21 | end 22 | 23 | def wday_symbol 24 | day_of_week.symbol 25 | end 26 | 27 | delegate wday: :day_of_week 28 | 29 | delegate %i[ 30 | hour 31 | minute 32 | second 33 | day_minute 34 | day_second 35 | timestamp 36 | ] => :day_time 37 | 38 | protected 39 | 40 | attr_reader :week_minute 41 | 42 | private 43 | 44 | def <=>(other) 45 | return unless other.is_a?(WeekTime::Abstract) 46 | 47 | week_minute <=> other.week_minute 48 | end 49 | 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/biz/week.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Week 5 | 6 | include Comparable 7 | 8 | def self.from_date(date) 9 | new((date - Date.epoch).to_i / Time.week_days) 10 | end 11 | 12 | def self.from_time(time) 13 | from_date(time.to_date) 14 | end 15 | 16 | class << self 17 | 18 | alias since_epoch from_time 19 | 20 | end 21 | 22 | def initialize(week) 23 | @week = Integer(week) 24 | end 25 | 26 | def start_date 27 | Date.from_day(week * Time.week_days) 28 | end 29 | 30 | def succ 31 | self.class.new(week.succ) 32 | end 33 | 34 | def downto(final_week) 35 | return enum_for(:downto, final_week) unless block_given? 36 | 37 | week.downto(final_week.week).each do |raw_week| 38 | yield self.class.new(raw_week) 39 | end 40 | end 41 | 42 | def +(other) 43 | self.class.new(week + other.week) 44 | end 45 | 46 | protected 47 | 48 | attr_reader :week 49 | 50 | private 51 | 52 | def <=>(other) 53 | return unless other.is_a?(self.class) 54 | 55 | week <=> other.week 56 | end 57 | 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/calculation/on_holiday_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Calculation::OnHoliday do 4 | subject(:calculation) { 5 | described_class.new(schedule(holidays: [Date.new(2006, 1, 4)]), time) 6 | } 7 | 8 | describe '#result' do 9 | context 'when the time is at the beginning of a holiday' do 10 | let(:time) { Time.utc(2006, 1, 4, 0) } 11 | 12 | it 'returns true' do 13 | expect(calculation.result).to eq true 14 | end 15 | end 16 | 17 | context 'when the time is in the middle of a holiday' do 18 | let(:time) { Time.utc(2006, 1, 4, 12) } 19 | 20 | it 'returns true' do 21 | expect(calculation.result).to eq true 22 | end 23 | end 24 | 25 | context 'when the time is at the end of a holiday' do 26 | let(:time) { Time.utc(2006, 1, 4, 24) } 27 | 28 | it 'returns false' do 29 | expect(calculation.result).to eq false 30 | end 31 | end 32 | 33 | context 'when the time is not during a holiday' do 34 | let(:time) { Time.utc(2006, 1, 5, 12) } 35 | 36 | it 'returns false' do 37 | expect(calculation.result).to eq false 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/calculation/duration_within_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Calculation::DurationWithin do 4 | subject(:calculation) { 5 | described_class.new(schedule, calculation_period) 6 | } 7 | 8 | context 'when the calculation start time is after the end time' do 9 | let(:calculation_period) { 10 | Biz::TimeSegment.new(Time.utc(2006, 1, 8), Time.utc(2006, 1, 1)) 11 | } 12 | 13 | it 'returns a zero duration' do 14 | expect(calculation).to eq Biz::Duration.new(0) 15 | end 16 | end 17 | 18 | context 'when the calculation start time is equal to the end time' do 19 | let(:calculation_period) { 20 | Biz::TimeSegment.new(Time.utc(2006, 1, 1), Time.utc(2006, 1, 1)) 21 | } 22 | 23 | it 'returns a zero duration' do 24 | expect(calculation).to eq Biz::Duration.new(0) 25 | end 26 | end 27 | 28 | context 'when the calculation start time is before the end time' do 29 | let(:calculation_period) { 30 | Biz::TimeSegment.new(Time.utc(2006, 1, 1), Time.utc(2006, 1, 8)) 31 | } 32 | 33 | it 'returns the elapsed duration' do 34 | expect(calculation).to eq Biz::Duration.hours(39.5) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/week_time_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::WeekTime do 4 | describe '.from_time' do 5 | let(:time) { Time.utc(2006, 1, 9, 9, 30) } 6 | 7 | it 'creates the proper week time' do 8 | expect(described_class.from_time(time)).to eq( 9 | described_class.start(week_minute(wday: 1, hour: 9, min: 30)) 10 | ) 11 | end 12 | end 13 | 14 | describe '.start' do 15 | let(:week_time) { 16 | described_class.start(Biz::DayOfWeek.all.first.start_minute) 17 | } 18 | 19 | it 'creates a week time that acts as a start time' do 20 | expect(week_time.timestamp).to eq '00:00' 21 | end 22 | end 23 | 24 | describe '.end' do 25 | let(:week_time) { described_class.end(Biz::DayOfWeek.all.first.end_minute) } 26 | 27 | it 'creates a week time that acts as an end time' do 28 | expect(week_time.timestamp).to eq '24:00' 29 | end 30 | end 31 | 32 | describe '.build' do 33 | let(:week_time) { 34 | described_class.build(Biz::DayOfWeek.all.first.start_minute) 35 | } 36 | 37 | it 'creates a week time that acts as a start time' do 38 | expect(week_time.timestamp).to eq '00:00' 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/biz/validation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Validation 5 | 6 | def self.perform(configuration) 7 | new(configuration).perform 8 | end 9 | 10 | def initialize(configuration) 11 | @configuration = configuration 12 | end 13 | 14 | def perform 15 | RULES.each do |rule| rule.check(configuration) end 16 | 17 | self 18 | end 19 | 20 | private 21 | 22 | attr_reader :configuration 23 | 24 | class Rule 25 | 26 | def initialize(message, &condition) 27 | @message = message 28 | @condition = condition 29 | end 30 | 31 | def check(configuration) 32 | fail Error::Configuration, message if condition.call(configuration) 33 | end 34 | 35 | private 36 | 37 | attr_reader :message, 38 | :condition 39 | 40 | end 41 | 42 | RULES = [ 43 | Rule.new('hours not provided') { |configuration| 44 | configuration.intervals.none? 45 | }, 46 | Rule.new('nonsensical hours provided') { |configuration| 47 | configuration.intervals.any?(&:empty?) 48 | } 49 | ].freeze 50 | 51 | private_constant :RULES 52 | 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/calculation/on_break_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Calculation::OnBreak do 4 | subject(:calculation) { 5 | described_class.new( 6 | schedule(breaks: {Date.new(2006, 1, 4) => {'11:00' => '13:00'}}), 7 | time 8 | ) 9 | } 10 | 11 | describe '#result' do 12 | context 'when the time is at the beginning of a break' do 13 | let(:time) { Time.utc(2006, 1, 4, 11) } 14 | 15 | it 'returns true' do 16 | expect(calculation.result).to eq true 17 | end 18 | end 19 | 20 | context 'when the time is in the middle of a break' do 21 | let(:time) { Time.utc(2006, 1, 4, 12) } 22 | 23 | it 'returns true' do 24 | expect(calculation.result).to eq true 25 | end 26 | end 27 | 28 | context 'when the time is at the end of a break' do 29 | let(:time) { Time.utc(2006, 1, 4, 13) } 30 | 31 | it 'returns false' do 32 | expect(calculation.result).to eq false 33 | end 34 | end 35 | 36 | context 'when the time is not during a break' do 37 | let(:time) { Time.utc(2006, 1, 5, 12) } 38 | 39 | it 'returns false' do 40 | expect(calculation.result).to eq false 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | name: repo-checks 2 | on: 3 | push: 4 | branches: [master] 5 | pull_request: 6 | jobs: 7 | main: 8 | name: test 9 | runs-on: ubuntu-latest 10 | env: 11 | RAILS_ENV: test 12 | BUNDLE_GEMFILE: gems.rb 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | ruby: 17 | - "2.7" 18 | - "3.0" 19 | - "3.1" 20 | - "3.2" 21 | - "3.3" 22 | - jruby-9.4 23 | - jruby-head 24 | steps: 25 | - uses: zendesk/checkout@v2 26 | - uses: zendesk/setup-ruby@v1 27 | with: 28 | ruby-version: ${{ matrix.ruby }} 29 | - name: gem_cache 30 | id: cache 31 | uses: zendesk/cache@v2 32 | with: 33 | path: vendor/bundle 34 | key: cache-${{ runner.os }}-ruby-${{ matrix.ruby }}-${{ hashFiles('Gemfile.lock') }} 35 | - name: install 36 | run: | 37 | bundle config path vendor/bundle 38 | bundle install --without benchmark development 39 | curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 40 | chmod +x ./cc-test-reporter 41 | ./cc-test-reporter before-build 42 | - name: rake ${{ matrix.ruby }} 43 | run: | 44 | bundle exec rake 45 | -------------------------------------------------------------------------------- /lib/biz.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | require 'delegate' 5 | require 'forwardable' 6 | 7 | require 'clavius' 8 | require 'tzinfo' 9 | 10 | module Biz 11 | class << self 12 | 13 | extend Forwardable 14 | 15 | def configure(&config) 16 | Thread.current[:biz_schedule] = Schedule.new(&config) 17 | end 18 | 19 | delegate %i[ 20 | intervals 21 | holidays 22 | time_zone 23 | periods 24 | date 25 | dates 26 | time 27 | within 28 | in_hours? 29 | business_hours? 30 | on_break? 31 | on_holiday? 32 | ] => :schedule 33 | 34 | private 35 | 36 | def schedule 37 | Thread.current[:biz_schedule] or 38 | fail Error::Configuration, "#{name} not configured" 39 | end 40 | 41 | end 42 | end 43 | 44 | require 'biz/date' 45 | require 'biz/time' 46 | 47 | require 'biz/calculation' 48 | require 'biz/configuration' 49 | require 'biz/dates' 50 | require 'biz/day_of_week' 51 | require 'biz/day_time' 52 | require 'biz/duration' 53 | require 'biz/error' 54 | require 'biz/holiday' 55 | require 'biz/interval' 56 | require 'biz/periods' 57 | require 'biz/schedule' 58 | require 'biz/timeline' 59 | require 'biz/time_segment' 60 | require 'biz/week' 61 | require 'biz/week_time' 62 | require 'biz/validation' 63 | require 'biz/version' 64 | -------------------------------------------------------------------------------- /lib/biz/timeline/abstract.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Timeline 5 | class Abstract 6 | 7 | def initialize(periods) 8 | @periods = periods.lazy 9 | end 10 | 11 | def until(terminus) 12 | return enum_for(:until, terminus) unless block_given? 13 | 14 | periods 15 | .map { |period| period & comparison_period(period, terminus) } 16 | .each do |period| 17 | break if occurred?(period, terminus) 18 | 19 | yield period unless period.disjoint? 20 | end 21 | end 22 | 23 | def for(duration) 24 | return enum_for(:for, duration) unless block_given? 25 | 26 | remaining = duration 27 | 28 | periods 29 | .each do |period| 30 | if overflow?(period, remaining) 31 | remaining = Duration.new(0) 32 | 33 | yield period 34 | else 35 | scoped_period = period & duration_period(period, remaining) 36 | 37 | remaining -= scoped_period.duration 38 | 39 | yield scoped_period unless scoped_period.disjoint? 40 | 41 | break unless remaining.positive? 42 | end 43 | end 44 | end 45 | 46 | private 47 | 48 | attr_reader :periods 49 | 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/biz/duration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Duration 5 | 6 | include Comparable 7 | 8 | class << self 9 | 10 | def seconds(seconds) 11 | new(seconds) 12 | end 13 | 14 | alias second seconds 15 | 16 | def minutes(minutes) 17 | new(minutes * Time.minute_seconds) 18 | end 19 | 20 | alias minute minutes 21 | 22 | def hours(hours) 23 | new(hours * Time.hour_seconds) 24 | end 25 | 26 | alias hour hours 27 | 28 | end 29 | 30 | def initialize(seconds) 31 | @seconds = Integer(seconds) 32 | end 33 | 34 | def in_seconds 35 | seconds 36 | end 37 | 38 | def in_minutes 39 | seconds / Time.minute_seconds 40 | end 41 | 42 | def in_hours 43 | seconds / Time.hour_seconds 44 | end 45 | 46 | def +(other) 47 | self.class.new(seconds + other.seconds) 48 | end 49 | 50 | def -(other) 51 | self.class.new(seconds - other.seconds) 52 | end 53 | 54 | def positive? 55 | seconds.positive? 56 | end 57 | 58 | def abs 59 | self.class.new(seconds.abs) 60 | end 61 | 62 | protected 63 | 64 | attr_reader :seconds 65 | 66 | private 67 | 68 | def <=>(other) 69 | return unless other.is_a?(self.class) 70 | 71 | seconds <=> other.seconds 72 | end 73 | 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/biz/day_of_week.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class DayOfWeek 5 | 6 | SYMBOLS = %i[sun mon tue wed thu fri sat].freeze 7 | 8 | include Comparable 9 | 10 | def self.all 11 | ALL 12 | end 13 | 14 | def self.from_symbol(symbol) 15 | ALL.fetch(SYMBOLS.index(symbol)) 16 | end 17 | 18 | attr_reader :wday 19 | 20 | def initialize(wday) 21 | @wday = Integer(wday) 22 | end 23 | 24 | def contains?(week_minute) 25 | minutes.cover?(week_minute) 26 | end 27 | 28 | def start_minute 29 | wday * Time.day_minutes 30 | end 31 | 32 | def end_minute 33 | start_minute + Time.day_minutes 34 | end 35 | 36 | def minutes 37 | start_minute..end_minute 38 | end 39 | 40 | def week_minute(day_minute) 41 | start_minute + day_minute 42 | end 43 | 44 | def day_minute(week_minute) 45 | ((week_minute - 1) % Time.day_minutes) + 1 46 | end 47 | 48 | def symbol 49 | SYMBOLS.fetch(wday) 50 | end 51 | 52 | def wday?(other_wday) 53 | wday == other_wday 54 | end 55 | 56 | private 57 | 58 | def <=>(other) 59 | return unless other.is_a?(self.class) 60 | 61 | wday <=> other.wday 62 | end 63 | 64 | ALL = (0..6).map(&method(:new)).freeze 65 | 66 | private_constant :SYMBOLS, 67 | :ALL 68 | 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/biz/schedule.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Schedule 5 | 6 | extend Forwardable 7 | 8 | def initialize(&config) 9 | @configuration = Configuration.new(&config) 10 | end 11 | 12 | delegate %i[ 13 | intervals 14 | shifts 15 | breaks 16 | holidays 17 | time_zone 18 | weekdays 19 | ] => :configuration 20 | 21 | def periods 22 | Periods::Proxy.new(self) 23 | end 24 | 25 | def dates 26 | Dates.new(self) 27 | end 28 | 29 | alias date dates 30 | 31 | def time(scalar, unit) 32 | Calculation::ForDuration.with_unit(self, scalar, unit) 33 | end 34 | 35 | def within(origin, terminus) 36 | Calculation::DurationWithin.new(self, TimeSegment.new(origin, terminus)) 37 | end 38 | 39 | def in_hours?(time) 40 | Calculation::Active.new(self, time).result 41 | end 42 | 43 | alias business_hours? in_hours? 44 | 45 | def on_break?(time) 46 | Calculation::OnBreak.new(self, time).result 47 | end 48 | 49 | def on_holiday?(time) 50 | Calculation::OnHoliday.new(self, time).result 51 | end 52 | 53 | def in_zone 54 | Time.new(time_zone) 55 | end 56 | 57 | def &(other) 58 | self.class.new(&(configuration & other.configuration)) 59 | end 60 | 61 | protected 62 | 63 | attr_reader :configuration 64 | 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/biz/periods/abstract.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Periods 5 | class Abstract < SimpleDelegator 6 | 7 | def initialize(schedule, origin) 8 | @schedule = schedule 9 | @origin = origin 10 | 11 | super(periods) 12 | end 13 | 14 | def timeline 15 | Timeline::Proxy.new(self) 16 | end 17 | 18 | private 19 | 20 | attr_reader :schedule, 21 | :origin, 22 | :boundary, 23 | :intervals, 24 | :shifts 25 | 26 | def periods 27 | Linear.new(week_periods, shifts, selector) 28 | .lazy 29 | .map { |period| period & boundary } 30 | .reject(&:disjoint?) 31 | .flat_map { |period| active_periods(period) } 32 | .reject { |period| on_holiday?(period) } 33 | end 34 | 35 | def week_periods 36 | weeks 37 | .lazy 38 | .flat_map { |week| 39 | intervals.map { |interval| interval.to_time_segment(week) } 40 | } 41 | end 42 | 43 | def active_periods(period) 44 | schedule 45 | .breaks 46 | .reduce([period]) { |periods, break_period| 47 | periods.flat_map { |active_period| active_period / break_period } 48 | } 49 | end 50 | 51 | def on_holiday?(period) 52 | schedule 53 | .holidays 54 | .any? { |holiday| holiday.contains?(period.start_time) } 55 | end 56 | 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/biz/time_segment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class TimeSegment 5 | 6 | include Comparable 7 | 8 | def self.before(time) 9 | new(Time.big_bang, time) 10 | end 11 | 12 | def self.after(time) 13 | new(time, Time.heat_death) 14 | end 15 | 16 | def initialize(start_time, end_time) 17 | @start_time = start_time 18 | @end_time = end_time 19 | @date = start_time.to_date 20 | end 21 | 22 | attr_reader :start_time, 23 | :end_time, 24 | :date 25 | 26 | def duration 27 | Duration.new([end_time - start_time, 0].max) 28 | end 29 | 30 | def endpoints 31 | [start_time, end_time] 32 | end 33 | 34 | def empty? 35 | start_time == end_time 36 | end 37 | 38 | def disjoint? 39 | start_time > end_time 40 | end 41 | 42 | def contains?(time) 43 | (start_time...end_time).cover?(time) 44 | end 45 | 46 | def &(other) 47 | self.class.new( 48 | [self, other].map(&:start_time).max, 49 | [self, other].map(&:end_time).min 50 | ) 51 | end 52 | 53 | def /(other) 54 | [ 55 | self.class.new(start_time, other.start_time), 56 | self.class.new(other.end_time, end_time) 57 | ].reject(&:disjoint?).map { |potential| self & potential } 58 | end 59 | 60 | private 61 | 62 | def <=>(other) 63 | return unless other.is_a?(self.class) 64 | 65 | [start_time, end_time] <=> [other.start_time, other.end_time] 66 | end 67 | 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/validation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Validation do 4 | let(:configuration) { 5 | Biz::Configuration.new do |config| config.hours = hours end 6 | } 7 | 8 | subject(:validation) { described_class.new(configuration) } 9 | 10 | describe '.perform' do 11 | let(:hours) { {} } 12 | 13 | it 'performs the validation on the provided configuration' do 14 | expect { 15 | described_class.perform(configuration) 16 | }.to raise_error Biz::Error::Configuration 17 | end 18 | end 19 | 20 | describe '#perform' do 21 | context 'when hours are provided' do 22 | let(:hours) { {mon: {'09:00' => '17:00'}} } 23 | 24 | it 'does not raise an error' do 25 | expect { validation.perform }.not_to raise_error 26 | end 27 | end 28 | 29 | context 'when hours are not provided' do 30 | let(:hours) { {} } 31 | 32 | it 'raises a configuration error' do 33 | expect { validation.perform }.to raise_error Biz::Error::Configuration 34 | end 35 | end 36 | 37 | context 'when nonsensical hours are provided' do 38 | let(:hours) { {mon: {'09:00' => '09:00'}} } 39 | 40 | it 'raises a configuration error' do 41 | expect { validation.perform }.to raise_error Biz::Error::Configuration 42 | end 43 | end 44 | 45 | context 'when a day with no hours is provided' do 46 | let(:hours) { {mon: {}} } 47 | 48 | it 'raises a configuration error' do 49 | expect { validation.perform }.to raise_error Biz::Error::Configuration 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/biz/interval.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Interval 5 | 6 | extend Forwardable 7 | 8 | include Comparable 9 | 10 | def self.to_hours(intervals) 11 | intervals.each_with_object( 12 | Hash.new do |hours, wday| hours.store(wday, {}) end 13 | ) do |interval, hours| 14 | hours[interval.wday_symbol].store(*interval.endpoints.map(&:timestamp)) 15 | end 16 | end 17 | 18 | def initialize(start_time, end_time, time_zone) 19 | @start_time = start_time 20 | @end_time = end_time 21 | @time_zone = time_zone 22 | end 23 | 24 | attr_reader :start_time, 25 | :end_time, 26 | :time_zone 27 | 28 | delegate wday_symbol: :start_time 29 | 30 | def endpoints 31 | [start_time, end_time] 32 | end 33 | 34 | def empty? 35 | start_time >= end_time 36 | end 37 | 38 | def contains?(time) 39 | (start_time...end_time).cover?( 40 | WeekTime.from_time(Time.new(time_zone).local(time)) 41 | ) 42 | end 43 | 44 | def to_time_segment(week) 45 | TimeSegment.new( 46 | *endpoints.map { |endpoint| 47 | Time.new(time_zone).during_week(week, endpoint) 48 | } 49 | ) 50 | end 51 | 52 | def &(other) 53 | lower_bound = [self, other].map(&:start_time).max 54 | upper_bound = [self, other].map(&:end_time).min 55 | 56 | self.class.new(lower_bound, [lower_bound, upper_bound].max, time_zone) 57 | end 58 | 59 | private 60 | 61 | def <=>(other) 62 | return unless other.is_a?(self.class) 63 | 64 | [start_time, end_time, time_zone] <=> 65 | [other.start_time, other.end_time, other.time_zone] 66 | end 67 | 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/periods/proxy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Periods::Proxy do 4 | let(:hours) { 5 | { 6 | mon: {'09:00' => '17:00'}, 7 | tue: {'10:00' => '16:00'}, 8 | wed: {'09:00' => '17:00'}, 9 | thu: {'10:00' => '12:00', '13:00' => '17:00'}, 10 | fri: {'09:00' => '17:00'}, 11 | sat: {'11:00' => '14:30'} 12 | } 13 | } 14 | 15 | subject(:periods) { described_class.new(schedule(hours: hours)) } 16 | 17 | describe '#after' do 18 | let(:origin) { Time.utc(2006, 1, 3) } 19 | 20 | it 'generates periods after the provided origin' do 21 | expect(periods.after(origin).take(2).to_a).to eq [ 22 | Biz::TimeSegment.new( 23 | Time.utc(2006, 1, 3, 10), 24 | Time.utc(2006, 1, 3, 16) 25 | ), 26 | Biz::TimeSegment.new( 27 | Time.utc(2006, 1, 4, 9), 28 | Time.utc(2006, 1, 4, 17) 29 | ) 30 | ] 31 | end 32 | end 33 | 34 | describe '#before' do 35 | let(:origin) { Time.utc(2006, 1, 5) } 36 | 37 | it 'generates periods before the provided origin' do 38 | expect(periods.before(origin).take(2).to_a).to eq [ 39 | Biz::TimeSegment.new( 40 | Time.utc(2006, 1, 4, 9), 41 | Time.utc(2006, 1, 4, 17) 42 | ), 43 | Biz::TimeSegment.new( 44 | Time.utc(2006, 1, 3, 10), 45 | Time.utc(2006, 1, 3, 16) 46 | ) 47 | ] 48 | end 49 | end 50 | 51 | describe '#on' do 52 | let(:date) { Date.new(2006, 1, 5) } 53 | 54 | it 'generates periods on the provided date' do 55 | expect(periods.on(date).to_a).to eq [ 56 | Biz::TimeSegment.new( 57 | Time.utc(2006, 1, 5, 10), 58 | Time.utc(2006, 1, 5, 12) 59 | ), 60 | Biz::TimeSegment.new( 61 | Time.utc(2006, 1, 5, 13), 62 | Time.utc(2006, 1, 5, 17) 63 | ) 64 | ] 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/date_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Date do 4 | describe '.epoch' do 5 | it 'returns the epoch date' do 6 | expect(described_class.epoch).to eq Date.new(2006, 1, 1) 7 | end 8 | end 9 | 10 | describe '.from_day' do 11 | let(:built_date) { described_class.from_day(960) } 12 | 13 | it 'builds a date with the correct year' do 14 | expect(built_date.year).to eq 2008 15 | end 16 | 17 | it 'builds a date with the correct month' do 18 | expect(built_date.month).to eq 8 19 | end 20 | 21 | it 'builds a date with the correct day' do 22 | expect(built_date.mday).to eq 18 23 | end 24 | 25 | it 'returns a Date object' do 26 | expect(built_date).to be_instance_of Date 27 | end 28 | end 29 | 30 | describe '.for_dst' do 31 | let(:date) { Date.new(2006, 1, 1) } 32 | 33 | context 'when the day time is midnight' do 34 | let(:day_time) { Biz::DayTime.midnight } 35 | 36 | it 'returns the same date' do 37 | expect(described_class.for_dst(date, day_time)).to eq date 38 | end 39 | end 40 | 41 | context 'when the day time is noon' do 42 | let(:day_time) { Biz::DayTime.new(day_second(hour: 12)) } 43 | 44 | it 'returns the same date' do 45 | expect(described_class.for_dst(date, day_time)).to eq date 46 | end 47 | end 48 | 49 | context 'when the day time is one hour before endnight' do 50 | let(:day_time) { Biz::DayTime.new(day_second(hour: 23)) } 51 | 52 | it 'returns the next date' do 53 | expect(described_class.for_dst(date, day_time)).to eq date.next_day 54 | end 55 | end 56 | 57 | context 'when the day time is endnight' do 58 | let(:day_time) { Biz::DayTime.endnight } 59 | 60 | it 'returns the next date' do 61 | expect(described_class.for_dst(date, day_time)).to eq date.next_day 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/timeline/proxy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Timeline::Proxy do 4 | subject(:timeline) { described_class.new(periods) } 5 | 6 | describe '#forward' do 7 | let(:periods) { 8 | [ 9 | Biz::TimeSegment.new( 10 | Time.utc(2006, 1, 2, 9), 11 | Time.utc(2006, 1, 2, 17) 12 | ), 13 | Biz::TimeSegment.new( 14 | Time.utc(2006, 1, 3, 10), 15 | Time.utc(2006, 1, 3, 16) 16 | ), 17 | Biz::TimeSegment.new( 18 | Time.utc(2006, 1, 4, 9), 19 | Time.utc(2006, 1, 4, 17) 20 | ) 21 | ].lazy 22 | } 23 | 24 | it 'generates a forward-moving timeline' do 25 | expect( 26 | timeline.forward.until(Time.utc(2006, 1, 4)).to_a 27 | ).to eq [ 28 | Biz::TimeSegment.new( 29 | Time.utc(2006, 1, 2, 9), 30 | Time.utc(2006, 1, 2, 17) 31 | ), 32 | Biz::TimeSegment.new( 33 | Time.utc(2006, 1, 3, 10), 34 | Time.utc(2006, 1, 3, 16) 35 | ) 36 | ] 37 | end 38 | end 39 | 40 | describe '#before' do 41 | let(:periods) { 42 | [ 43 | Biz::TimeSegment.new( 44 | Time.utc(2006, 1, 4, 9), 45 | Time.utc(2006, 1, 4, 17) 46 | ), 47 | Biz::TimeSegment.new( 48 | Time.utc(2006, 1, 3, 10), 49 | Time.utc(2006, 1, 3, 16) 50 | ), 51 | Biz::TimeSegment.new( 52 | Time.utc(2006, 1, 2, 9), 53 | Time.utc(2006, 1, 2, 17) 54 | ) 55 | ].lazy 56 | } 57 | 58 | it 'generates a backward-moving timeline' do 59 | expect( 60 | timeline.backward.until(Time.utc(2006, 1, 3)).to_a 61 | ).to eq [ 62 | Biz::TimeSegment.new( 63 | Time.utc(2006, 1, 4, 9), 64 | Time.utc(2006, 1, 4, 17) 65 | ), 66 | Biz::TimeSegment.new( 67 | Time.utc(2006, 1, 3, 10), 68 | Time.utc(2006, 1, 3, 16) 69 | ) 70 | ] 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/core_ext/time_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'biz/core_ext/time' 4 | 5 | RSpec.describe Biz::CoreExt::Time do 6 | let(:time_class) { Class.new(Time) do include Biz::CoreExt::Time end } 7 | 8 | before do 9 | Biz.configure do |config| 10 | config.hours = {mon: {'09:00' => '17:00'}} 11 | config.breaks = {Date.new(2006, 1, 2) => {'10:00' => '12:00'}} 12 | config.holidays = [Date.new(2006, 1, 1)] 13 | config.time_zone = 'Etc/UTC' 14 | end 15 | end 16 | 17 | after do Thread.current[:biz_schedule] = nil end 18 | 19 | describe '#business_hours?' do 20 | context 'when the time is within a business period' do 21 | let(:time) { time_class.utc(2006, 1, 2, 14) } 22 | 23 | it 'returns true' do 24 | expect(time.business_hours?).to eq true 25 | end 26 | end 27 | 28 | context 'when the time is not within a business period' do 29 | let(:time) { time_class.utc(2006, 1, 2, 8) } 30 | 31 | it 'returns false' do 32 | expect(time.business_hours?).to eq false 33 | end 34 | end 35 | end 36 | 37 | describe '#on_break?' do 38 | context 'when the time is during a break' do 39 | let(:time) { time_class.utc(2006, 1, 2, 11) } 40 | 41 | it 'returns true' do 42 | expect(time.on_break?).to eq true 43 | end 44 | end 45 | 46 | context 'when the time is not during a break' do 47 | let(:time) { time_class.utc(2006, 1, 2, 13) } 48 | 49 | it 'returns false' do 50 | expect(time.on_break?).to eq false 51 | end 52 | end 53 | end 54 | 55 | describe '#on_holiday?' do 56 | context 'when the time is during a holiday' do 57 | let(:time) { time_class.utc(2006, 1, 1, 12) } 58 | 59 | it 'returns true' do 60 | expect(time.on_holiday?).to eq true 61 | end 62 | end 63 | 64 | context 'when the time is not during a holiday' do 65 | let(:time) { time_class.utc(2006, 1, 2, 12) } 66 | 67 | it 'returns false' do 68 | expect(time.on_holiday?).to eq false 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/core_ext/integer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'biz/core_ext/integer' 4 | 5 | RSpec.describe Biz::CoreExt::Integer do 6 | let(:integer_class) { 7 | Class.new(SimpleDelegator) do include Biz::CoreExt::Integer end 8 | } 9 | 10 | before do 11 | Biz.configure do |config| 12 | config.hours = {mon: {'09:00' => '17:00'}} 13 | config.holidays = [] 14 | config.time_zone = 'Etc/UTC' 15 | end 16 | end 17 | 18 | after do Thread.current[:biz_schedule] = nil end 19 | 20 | describe '#business_second' do 21 | it 'performs a for-duration calculation for the given number of seconds' do 22 | expect( 23 | integer_class.new(1).business_second.after(Time.utc(2006, 1, 2, 10)) 24 | ).to eq Time.utc(2006, 1, 2, 10, 0, 1) 25 | end 26 | end 27 | 28 | describe '#business_seconds' do 29 | it 'performs a for-duration calculation for the given number of seconds' do 30 | expect( 31 | integer_class.new(10).business_seconds.after(Time.utc(2006, 1, 2, 10)) 32 | ).to eq Time.utc(2006, 1, 2, 10, 0, 10) 33 | end 34 | end 35 | 36 | describe '#business_minute' do 37 | it 'performs a for-duration calculation for the given number of minutes' do 38 | expect( 39 | integer_class.new(1).business_minute.after(Time.utc(2006, 1, 2, 10)) 40 | ).to eq Time.utc(2006, 1, 2, 10, 1) 41 | end 42 | end 43 | 44 | describe '#business_minutes' do 45 | it 'performs a for-duration calculation for the given number of minutes' do 46 | expect( 47 | integer_class.new(10).business_minutes.after(Time.utc(2006, 1, 2, 10)) 48 | ).to eq Time.utc(2006, 1, 2, 10, 10) 49 | end 50 | end 51 | 52 | describe '#business_hour' do 53 | it 'performs a for-duration calculation for the given number of hours' do 54 | expect( 55 | integer_class.new(1).business_hour.after(Time.utc(2006, 1, 2, 10)) 56 | ).to eq Time.utc(2006, 1, 2, 11) 57 | end 58 | end 59 | 60 | describe '#business_hours' do 61 | it 'performs a for-duration calculation for the given number of hours' do 62 | expect( 63 | integer_class.new(10).business_hours.after(Time.utc(2006, 1, 2, 10)) 64 | ).to eq Time.utc(2006, 1, 9, 12) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/support/time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Spec 5 | module Support 6 | module Time 7 | def in_zone(zone) 8 | TZInfo::Timezone.get(zone).local_to_utc(yield) 9 | end 10 | 11 | def week_minute(args = {}) 12 | (args.fetch(:wday) * Biz::Time.day_minutes) + day_minute(args) 13 | end 14 | 15 | def day_minute(args = {}) 16 | (args.fetch(:hour) * Biz::Time.hour_minutes) + args.fetch(:min, 0) 17 | end 18 | 19 | def day_second(args = {}) 20 | (day_minute(args) * Biz::Time.minute_seconds) + args.fetch(:sec, 0) 21 | end 22 | 23 | def in_seconds(args = {}) 24 | (args.fetch(:days, 0) * Biz::Time.day_seconds) + 25 | (args.fetch(:hours, 0) * Biz::Time.hour_seconds) + 26 | (args.fetch(:minutes, 0) * Biz::Time.minute_seconds) + 27 | args.fetch(:seconds, 0) 28 | end 29 | 30 | def schedule(args = {}) 31 | Biz::Schedule.new do |config| 32 | config.hours = args.fetch( 33 | :hours, 34 | mon: {'09:00' => '17:00'}, 35 | tue: {'10:00' => '16:00'}, 36 | wed: {'09:00' => '17:00'}, 37 | thu: {'10:00' => '16:00'}, 38 | fri: {'09:00' => '17:00'}, 39 | sat: {'11:00' => '14:30'} 40 | ) 41 | 42 | config.shifts = args.fetch(:shifts, {}) 43 | config.breaks = args.fetch(:breaks, {}) 44 | config.holidays = args.fetch(:holidays, []) 45 | config.time_zone = args.fetch(:time_zone, 'Etc/UTC') 46 | end 47 | end 48 | 49 | def forward_periods 50 | Enumerator.new do |yielder| 51 | (2006..Float::INFINITY).each do |year| 52 | yielder.yield( 53 | Biz::TimeSegment.new(::Time.utc(year), ::Time.utc(year, 2)) 54 | ) 55 | end 56 | end 57 | end 58 | 59 | def backward_periods 60 | Enumerator.new do |yielder| 61 | 2006.downto(-Float::INFINITY).each do |year| 62 | yielder.yield( 63 | Biz::TimeSegment.new(::Time.utc(year), ::Time.utc(year, 2)) 64 | ) 65 | end 66 | end 67 | end 68 | end 69 | end 70 | end 71 | end 72 | 73 | RSpec.configure do |c| c.include Biz::Spec::Support::Time end 74 | -------------------------------------------------------------------------------- /lib/biz/time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Time 5 | 6 | MINUTE_SECONDS = 60 7 | HOUR_MINUTES = 60 8 | DAY_HOURS = 24 9 | WEEK_DAYS = 7 10 | 11 | HOUR_SECONDS = HOUR_MINUTES * MINUTE_SECONDS 12 | DAY_SECONDS = DAY_HOURS * HOUR_SECONDS 13 | 14 | DAY_MINUTES = DAY_HOURS * HOUR_MINUTES 15 | WEEK_MINUTES = WEEK_DAYS * DAY_MINUTES 16 | 17 | BIG_BANG = ::Time.new(-100_000_000).freeze 18 | HEAT_DEATH = ::Time.new(100_000_000).freeze 19 | 20 | private_constant :MINUTE_SECONDS, 21 | :HOUR_MINUTES, 22 | :DAY_HOURS, 23 | :WEEK_DAYS, 24 | :HOUR_SECONDS, 25 | :DAY_SECONDS, 26 | :DAY_MINUTES, 27 | :WEEK_MINUTES, 28 | :BIG_BANG, 29 | :HEAT_DEATH 30 | 31 | def self.minute_seconds 32 | MINUTE_SECONDS 33 | end 34 | 35 | def self.hour_minutes 36 | HOUR_MINUTES 37 | end 38 | 39 | def self.day_hours 40 | DAY_HOURS 41 | end 42 | 43 | def self.week_days 44 | WEEK_DAYS 45 | end 46 | 47 | def self.hour_seconds 48 | HOUR_SECONDS 49 | end 50 | 51 | def self.day_seconds 52 | DAY_SECONDS 53 | end 54 | 55 | def self.day_minutes 56 | DAY_MINUTES 57 | end 58 | 59 | def self.week_minutes 60 | WEEK_MINUTES 61 | end 62 | 63 | def self.big_bang 64 | BIG_BANG 65 | end 66 | 67 | def self.heat_death 68 | HEAT_DEATH 69 | end 70 | 71 | def initialize(time_zone) 72 | @time_zone = time_zone 73 | end 74 | 75 | def local(time) 76 | time_zone.utc_to_local(time.utc) 77 | end 78 | 79 | def on_date(date, day_time) 80 | time_zone.local_to_utc( 81 | ::Time.new( 82 | date.year, 83 | date.month, 84 | date.mday, 85 | day_time.hour, 86 | day_time.minute, 87 | day_time.second 88 | ), 89 | true 90 | ) 91 | rescue TZInfo::PeriodNotFound 92 | on_date(Date.for_dst(date, day_time), day_time.for_dst) 93 | end 94 | 95 | def during_week(week, week_time) 96 | on_date(week.start_date + week_time.wday, week_time.day_time) 97 | end 98 | 99 | private 100 | 101 | attr_reader :time_zone 102 | 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/week_time/abstract_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::WeekTime::Abstract do 4 | let(:week_time_class) { Class.new(described_class) } 5 | 6 | subject(:week_time) { 7 | week_time_class.new(week_minute(wday: 0, hour: 9, min: 30)) 8 | } 9 | 10 | context 'when initializing' do 11 | context 'with a valid integer-like value' do 12 | it 'is successful' do 13 | expect { week_time_class.new('1') }.not_to raise_error 14 | end 15 | end 16 | 17 | context 'with an invalid integer-like value' do 18 | it 'fails hard' do 19 | expect { week_time_class.new('1one') }.to raise_error ArgumentError 20 | end 21 | end 22 | 23 | context 'with a non-integer value' do 24 | it 'fails hard' do 25 | expect { week_time_class.new([]) }.to raise_error TypeError 26 | end 27 | end 28 | end 29 | 30 | describe '.from_time' do 31 | let(:time) { Time.utc(2006, 1, 9, 9, 30) } 32 | 33 | it 'creates the proper week time' do 34 | expect(week_time_class.from_time(time)).to eq( 35 | week_time_class.new(week_minute(wday: 1, hour: 9, min: 30)) 36 | ) 37 | end 38 | end 39 | 40 | context 'when performing comparison' do 41 | context 'and the compared object is an earlier week time' do 42 | let(:other) { 43 | week_time_class.new(week_minute(wday: 0, hour: 9, min: 29)) 44 | } 45 | 46 | it 'compares as expected' do 47 | expect(week_time > other).to eq true 48 | end 49 | end 50 | 51 | context 'and the compared object is the same week time' do 52 | let(:other) { 53 | week_time_class.new(week_minute(wday: 0, hour: 9, min: 30)) 54 | } 55 | 56 | it 'compares as expected' do 57 | expect(week_time == other).to eq true 58 | end 59 | end 60 | 61 | context 'and the other object is a later week time' do 62 | let(:other) { 63 | week_time_class.new(week_minute(wday: 0, hour: 9, min: 31)) 64 | } 65 | 66 | it 'compares as expected' do 67 | expect(week_time < other).to eq true 68 | end 69 | end 70 | 71 | context 'and the other object is a week-time-like object' do 72 | let(:other) { 73 | Class.new(described_class).new(week_minute(wday: 0, hour: 9, min: 30)) 74 | } 75 | 76 | it 'compares as expected' do 77 | expect(week_time == other).to eq true 78 | end 79 | end 80 | 81 | context 'and the compared object is not a week time' do 82 | let(:other) { 1 } 83 | 84 | it 'is not comparable' do 85 | expect { week_time < other }.to raise_error ArgumentError 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/biz/day_time.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class DayTime 5 | 6 | VALID_SECONDS = (0..Time.day_seconds).freeze 7 | 8 | module Timestamp 9 | FORMAT = '%02d:%02d' 10 | PATTERN = 11 | /\A(?\d{2}):(?\d{2}):?(?\d{2})?\Z/.freeze 12 | end 13 | 14 | include Comparable 15 | 16 | class << self 17 | 18 | def from_time(time) 19 | new( 20 | (time.hour * Time.hour_seconds) + 21 | (time.min * Time.minute_seconds) + 22 | time.sec 23 | ) 24 | end 25 | 26 | def from_minute(minute) 27 | new(minute * Time.minute_seconds) 28 | end 29 | 30 | def from_hour(hour) 31 | new(hour * Time.hour_seconds) 32 | end 33 | 34 | def from_timestamp(timestamp) 35 | timestamp.match(Timestamp::PATTERN) { |match| 36 | new( 37 | (match[:hour].to_i * Time.hour_seconds) + 38 | (match[:minute].to_i * Time.minute_seconds) + 39 | match[:second].to_i 40 | ) 41 | } or fail( 42 | Error::Configuration, 43 | 'invalid timestamp: must be in `HH:MM` or `HH:MM:SS` format' 44 | ) 45 | end 46 | 47 | def midnight 48 | MIDNIGHT 49 | end 50 | 51 | def endnight 52 | ENDNIGHT 53 | end 54 | 55 | end 56 | 57 | def initialize(day_second) 58 | @day_second = Integer(day_second) 59 | 60 | fail ArgumentError, 'second not within a day' unless valid_second? 61 | end 62 | 63 | attr_reader :day_second 64 | 65 | def hour 66 | day_second / Time.hour_seconds 67 | end 68 | 69 | def minute 70 | day_second % Time.hour_seconds / Time.minute_seconds 71 | end 72 | 73 | def second 74 | day_second % Time.minute_seconds 75 | end 76 | 77 | def day_minute 78 | (hour * Time.hour_minutes) + minute 79 | end 80 | 81 | def for_dst 82 | self.class.new((day_second + Time.hour_seconds) % Time.day_seconds) 83 | end 84 | 85 | def timestamp 86 | format(Timestamp::FORMAT, hour, minute) 87 | end 88 | 89 | private 90 | 91 | def valid_second? 92 | VALID_SECONDS.cover?(day_second) 93 | end 94 | 95 | def <=>(other) 96 | return unless other.is_a?(self.class) 97 | 98 | day_second <=> other.day_second 99 | end 100 | 101 | MIDNIGHT = from_hour(0) 102 | ENDNIGHT = from_hour(24) 103 | 104 | private_constant :VALID_SECONDS, 105 | :Timestamp, 106 | :MIDNIGHT, 107 | :ENDNIGHT 108 | 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/biz/calculation/for_duration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | module Calculation 5 | class ForDuration 6 | 7 | UNITS = %i[second seconds minute minutes hour hours day days].freeze 8 | 9 | private_constant :UNITS 10 | 11 | def self.units 12 | UNITS 13 | end 14 | 15 | def self.with_unit(schedule, scalar, unit) 16 | fail ArgumentError, 'unsupported unit' unless UNITS.include?(unit) 17 | 18 | public_send(unit, schedule, scalar) 19 | end 20 | 21 | def self.unit 22 | name.split('::').last.downcase.to_sym 23 | end 24 | 25 | def self.time_class 26 | Class.new(self) do 27 | def before(time) 28 | advanced_periods(:before, time).last.start_time 29 | end 30 | 31 | def after(time) 32 | advanced_periods(:after, time).last.end_time 33 | end 34 | 35 | private 36 | 37 | def advanced_periods(direction, time) 38 | schedule 39 | .periods 40 | .public_send(direction, time) 41 | .timeline 42 | .for(Duration.public_send(unit, scalar)) 43 | .to_a 44 | end 45 | end 46 | end 47 | 48 | def self.day_class 49 | Class.new(self) do 50 | def before(time) 51 | periods(:before, time).first.end_time 52 | end 53 | 54 | def after(time) 55 | periods(:after, time).first.start_time 56 | end 57 | 58 | private 59 | 60 | def periods(direction, time) 61 | schedule 62 | .periods 63 | .public_send( 64 | direction, 65 | advanced_time(direction, schedule.in_zone.local(time)) 66 | ) 67 | end 68 | 69 | def advanced_time(direction, time) 70 | schedule 71 | .in_zone 72 | .on_date( 73 | schedule 74 | .dates 75 | .days(scalar) 76 | .public_send(direction, time.to_date), 77 | DayTime.from_time(time) 78 | ) 79 | end 80 | end 81 | end 82 | 83 | private_class_method :time_class, 84 | :day_class 85 | 86 | def initialize(schedule, scalar) 87 | @schedule = schedule 88 | @scalar = Integer(scalar) 89 | 90 | fail ArgumentError, 'negative scalar' if @scalar.negative? 91 | end 92 | 93 | private 94 | 95 | attr_reader :schedule, 96 | :scalar 97 | 98 | def unit 99 | self.class.unit 100 | end 101 | 102 | [ 103 | *%i[second seconds minute minutes hour hours].map { |unit| 104 | const_set(unit.to_s.capitalize, time_class) 105 | }, 106 | *%i[day days].map { |unit| const_set(unit.to_s.capitalize, day_class) } 107 | ].each do |unit_class| 108 | define_singleton_method(unit_class.unit) { |schedule, scalar| 109 | unit_class.new(schedule, scalar) 110 | } 111 | end 112 | 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /spec/calculation/active_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Calculation::Active do 4 | subject(:calculation) { 5 | described_class.new( 6 | schedule( 7 | shifts: { 8 | Date.new(2006, 1, 7) => {'09:00' => '11:00'}, 9 | Date.new(2006, 1, 10) => {'09:00' => '11:00'}, 10 | Date.new(2006, 1, 11) => {'09:00' => '11:00'} 11 | }, 12 | breaks: { 13 | Date.new(2006, 1, 3) => {'05:30' => '11:00'}, 14 | Date.new(2006, 1, 10) => {'05:30' => '11:00'} 15 | }, 16 | holidays: [Date.new(2006, 1, 4), Date.new(2006, 1, 11)] 17 | ), 18 | time 19 | ) 20 | } 21 | 22 | describe '#result' do 23 | context 'when the time is contained neither by an interval nor a shift' do 24 | context 'and during neither a break nor a holiday' do 25 | let(:time) { Time.utc(2006, 1, 1, 6) } 26 | 27 | it 'returns false' do 28 | expect(calculation.result).to eq false 29 | end 30 | end 31 | 32 | context 'and during a break' do 33 | let(:time) { Time.utc(2006, 1, 3, 6) } 34 | 35 | it 'returns false' do 36 | expect(calculation.result).to eq false 37 | end 38 | end 39 | 40 | context 'and during a holiday' do 41 | let(:time) { Time.utc(2006, 1, 4, 6) } 42 | 43 | it 'returns false' do 44 | expect(calculation.result).to eq false 45 | end 46 | end 47 | end 48 | 49 | context 'when the time is contained by an interval' do 50 | context 'and during neither a break nor a holiday' do 51 | let(:time) { Time.utc(2006, 1, 2, 12) } 52 | 53 | it 'returns true' do 54 | expect(calculation.result).to eq true 55 | end 56 | end 57 | 58 | context 'and during a break' do 59 | let(:time) { Time.utc(2006, 1, 3, 10) } 60 | 61 | it 'returns false' do 62 | expect(calculation.result).to eq false 63 | end 64 | end 65 | 66 | context 'and during a holiday' do 67 | let(:time) { Time.utc(2006, 1, 4, 12) } 68 | 69 | it 'returns false' do 70 | expect(calculation.result).to eq false 71 | end 72 | end 73 | 74 | context 'on a date with a shift' do 75 | let(:time) { Time.utc(2006, 1, 7, 12) } 76 | 77 | it 'returns false' do 78 | expect(calculation.result).to eq false 79 | end 80 | end 81 | end 82 | 83 | context 'when the time is contained by a shift' do 84 | context 'and during neither a break nor a holiday' do 85 | let(:time) { Time.utc(2006, 1, 7, 10) } 86 | 87 | it 'returns true' do 88 | expect(calculation.result).to eq true 89 | end 90 | end 91 | 92 | context 'and during a break' do 93 | let(:time) { Time.utc(2006, 1, 10, 10) } 94 | 95 | it 'returns false' do 96 | expect(calculation.result).to eq false 97 | end 98 | end 99 | 100 | context 'and during a holiday' do 101 | let(:time) { Time.utc(2006, 1, 11, 12) } 102 | 103 | it 'returns false' do 104 | expect(calculation.result).to eq false 105 | end 106 | end 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /spec/week_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Week do 4 | subject(:week) { described_class.new(2) } 5 | 6 | context 'when initializing' do 7 | context 'with a valid integer-like value' do 8 | it 'is successful' do 9 | expect { described_class.new('1') }.not_to raise_error 10 | end 11 | end 12 | 13 | context 'with an invalid integer-like value' do 14 | it 'fails hard' do 15 | expect { described_class.new('1one') }.to raise_error ArgumentError 16 | end 17 | end 18 | 19 | context 'with a non-integer value' do 20 | it 'fails hard' do 21 | expect { described_class.new([]) }.to raise_error TypeError 22 | end 23 | end 24 | end 25 | 26 | describe '.from_date' do 27 | let(:epoch_time) { Date.new(2006, 1, 10) } 28 | 29 | it 'creates the proper week' do 30 | expect(described_class.from_date(epoch_time)).to eq described_class.new(1) 31 | end 32 | end 33 | 34 | describe '.from_time' do 35 | let(:epoch_time) { Time.new(2006, 1, 10) } 36 | 37 | it 'creates the proper week' do 38 | expect(described_class.from_time(epoch_time)).to eq described_class.new(1) 39 | end 40 | end 41 | 42 | describe '.since_epoch' do 43 | let(:epoch_time) { Time.new(2006, 1, 10) } 44 | 45 | it 'creates the proper week' do 46 | expect(described_class.since_epoch(epoch_time)).to eq( 47 | described_class.new(1) 48 | ) 49 | end 50 | end 51 | 52 | describe '#start_date' do 53 | it 'returns the corresponding date since epoch of the first day' do 54 | expect(week.start_date).to eq Date.new(2006, 1, 15) 55 | end 56 | end 57 | 58 | describe '#succ' do 59 | it 'returns the next week' do 60 | expect(week.succ).to eq described_class.new(3) 61 | end 62 | end 63 | 64 | describe '#downto' do 65 | it 'iterates down to the provided week' do 66 | expect(week.downto(described_class.new(0)).to_a).to eq [ 67 | described_class.new(2), 68 | described_class.new(1), 69 | described_class.new(0) 70 | ] 71 | end 72 | end 73 | 74 | describe '#+' do 75 | let(:week1) { described_class.new(1) } 76 | let(:week2) { described_class.new(2) } 77 | 78 | it 'adds the weeks' do 79 | expect(week1 + week2).to eq described_class.new(3) 80 | end 81 | end 82 | 83 | context 'when performing comparison' do 84 | context 'and the compared object is an earlier week' do 85 | let(:other) { described_class.new(1) } 86 | 87 | it 'compares as expected' do 88 | expect(week > other).to eq true 89 | end 90 | end 91 | 92 | context 'and the compared object is the same week' do 93 | let(:other) { described_class.new(2) } 94 | 95 | it 'compares as expected' do 96 | expect(week == other).to eq true 97 | end 98 | end 99 | 100 | context 'and the other object is a later week' do 101 | let(:other) { described_class.new(3) } 102 | 103 | it 'compares as expected' do 104 | expect(week < other).to eq true 105 | end 106 | end 107 | 108 | context 'and the compared object is not a week' do 109 | let(:other) { 1 } 110 | 111 | it 'is not comparable' do 112 | expect { week < other }.to raise_error ArgumentError 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/holiday_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Holiday do 4 | let(:date) { Date.new(2010, 7, 15) } 5 | let(:time_zone) { TZInfo::Timezone.get('America/Los_Angeles') } 6 | 7 | subject(:holiday) { described_class.new(date, time_zone) } 8 | 9 | describe '#contains?' do 10 | context 'when the time is before the holiday' do 11 | let(:time) { in_zone('America/New_York') { Time.utc(2010, 7, 15, 2) } } 12 | 13 | it 'returns false' do 14 | expect(holiday.contains?(time)).to eq false 15 | end 16 | end 17 | 18 | context 'when the time is at the beginning of the holiday' do 19 | let(:time) { in_zone('America/New_York') { Time.utc(2010, 7, 15, 3) } } 20 | 21 | it 'returns true' do 22 | expect(holiday.contains?(time)).to eq true 23 | end 24 | end 25 | 26 | context 'when the time is contained by the holiday' do 27 | let(:time) { in_zone('America/New_York') { Time.utc(2010, 7, 15, 15) } } 28 | 29 | it 'returns true' do 30 | expect(holiday.contains?(time)).to eq true 31 | end 32 | end 33 | 34 | context 'when the time is at the end of the holiday' do 35 | let(:time) { in_zone('America/New_York') { Time.utc(2010, 7, 16, 3) } } 36 | 37 | it 'returns false' do 38 | expect(holiday.contains?(time)).to eq false 39 | end 40 | end 41 | 42 | context 'when the time is after the holiday' do 43 | let(:time) { in_zone('America/New_York') { Time.utc(2010, 7, 16, 4) } } 44 | 45 | it 'returns false' do 46 | expect(holiday.contains?(time)).to eq false 47 | end 48 | end 49 | end 50 | 51 | describe '#to_time_segment' do 52 | it 'returns the appropriate time segment' do 53 | expect(holiday.to_time_segment).to eq( 54 | Biz::TimeSegment.new( 55 | in_zone('America/Los_Angeles') { Time.utc(2010, 7, 15) }, 56 | in_zone('America/Los_Angeles') { Time.utc(2010, 7, 16) } 57 | ) 58 | ) 59 | end 60 | end 61 | 62 | describe '#to_date' do 63 | it 'returns the appropriate date' do 64 | expect(holiday.to_date).to eq Date.new(2010, 7, 15) 65 | end 66 | end 67 | 68 | context 'when performing comparison' do 69 | context 'and the compared object has an earlier date' do 70 | let(:other) { described_class.new(date.prev_day, time_zone) } 71 | 72 | it 'compares as expected' do 73 | expect(holiday > other).to eq true 74 | end 75 | end 76 | 77 | context 'and the compared object has a later date' do 78 | let(:other) { described_class.new(date.next_day, time_zone) } 79 | 80 | it 'compares as expected' do 81 | expect(holiday > other).to eq false 82 | end 83 | end 84 | 85 | context 'and the compared object has a different time zone' do 86 | let(:other) { 87 | described_class.new(date, TZInfo::Timezone.get('America/New_York')) 88 | } 89 | 90 | it 'compares as expected' do 91 | expect(holiday == other).to eq false 92 | end 93 | end 94 | 95 | context 'and the compared object has the same date and time zone' do 96 | let(:other) { described_class.new(date, time_zone) } 97 | 98 | it 'compares as expected' do 99 | expect(holiday == other).to eq true 100 | end 101 | end 102 | 103 | context 'and the compared object is not a holiday' do 104 | let(:other) { 1 } 105 | 106 | it 'is not comparable' do 107 | expect { holiday < other }.to raise_error ArgumentError 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/week_time/start_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::WeekTime::Start do 4 | subject(:week_time) { 5 | described_class.new(week_minute(wday: 0, hour: 9, min: 30)) 6 | } 7 | 8 | describe '#wday' do 9 | context 'when the time is contained within a day' do 10 | subject(:week_time) { 11 | described_class.new(week_minute(wday: 0, hour: 12)) 12 | } 13 | 14 | it 'returns the weekday integer for that day' do 15 | expect(week_time.wday).to eq 0 16 | end 17 | end 18 | 19 | context 'when the time is on a day boundary' do 20 | subject(:week_time) { 21 | described_class.new(week_minute(wday: 1, hour: 0)) 22 | } 23 | 24 | it 'returns the weekday integer for the midnight day' do 25 | expect(week_time.wday).to eq 1 26 | end 27 | end 28 | 29 | context 'when the time is the last minute of the week' do 30 | subject(:week_time) { 31 | described_class.new(week_minute(wday: 7, hour: 0)) 32 | } 33 | 34 | it 'returns the weekday integer for Sunday' do 35 | expect(week_time.wday).to eq 0 36 | end 37 | end 38 | end 39 | 40 | describe '#wday_symbol' do 41 | context 'when the time is contained within a day' do 42 | subject(:week_time) { 43 | described_class.new(week_minute(wday: 0, hour: 12)) 44 | } 45 | 46 | it 'returns the weekday symbol for that day' do 47 | expect(week_time.wday_symbol).to eq :sun 48 | end 49 | end 50 | 51 | context 'when the time is on a day boundary' do 52 | subject(:week_time) { 53 | described_class.new(week_minute(wday: 1, hour: 0)) 54 | } 55 | 56 | it 'returns the weekday symbol for the midnight day' do 57 | expect(week_time.wday_symbol).to eq :mon 58 | end 59 | end 60 | end 61 | 62 | describe '#day_time' do 63 | it 'returns the corresponding day time' do 64 | expect(week_time.day_time).to eq( 65 | Biz::DayTime.new(day_second(hour: 9, min: 30)) 66 | ) 67 | end 68 | end 69 | 70 | describe '#day_minute' do 71 | it 'returns the corresponding day minute' do 72 | expect(week_time.day_minute).to eq day_minute(hour: 9, min: 30) 73 | end 74 | end 75 | 76 | describe '#day_second' do 77 | it 'returns the corresponding day second' do 78 | expect(week_time.day_second).to eq day_second(hour: 9, min: 30) 79 | end 80 | end 81 | 82 | describe '#hour' do 83 | it 'returns the corresponding hour' do 84 | expect(week_time.hour).to eq 9 85 | end 86 | end 87 | 88 | describe '#minute' do 89 | it 'returns the corresponding minute' do 90 | expect(week_time.minute).to eq 30 91 | end 92 | end 93 | 94 | describe '#timestamp' do 95 | it 'returns the corresponding timestamp' do 96 | expect(week_time.timestamp).to eq '09:30' 97 | end 98 | end 99 | 100 | context 'when the week minute is on a day boundary' do 101 | subject(:week_time) { 102 | described_class.new(Biz::DayOfWeek.all.first.start_minute) 103 | } 104 | 105 | describe '#day_time' do 106 | it 'returns the midnight day time' do 107 | expect(week_time.day_time).to eq Biz::DayTime.new(0) 108 | end 109 | end 110 | 111 | describe '#day_minute' do 112 | it 'returns zero' do 113 | expect(week_time.day_minute).to eq 0 114 | end 115 | end 116 | 117 | describe '#hour' do 118 | it 'returns zero' do 119 | expect(week_time.hour).to eq 0 120 | end 121 | end 122 | 123 | describe '#minute' do 124 | it 'returns zero' do 125 | expect(week_time.minute).to eq 0 126 | end 127 | end 128 | 129 | describe '#timestamp' do 130 | it "returns '00:00'" do 131 | expect(week_time.timestamp).to eq '00:00' 132 | end 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /spec/week_time/end_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::WeekTime::End do 4 | subject(:week_time) { 5 | described_class.new(week_minute(wday: 0, hour: 9, min: 30)) 6 | } 7 | 8 | describe '#wday' do 9 | context 'when the time is contained within a day' do 10 | subject(:week_time) { 11 | described_class.new(week_minute(wday: 0, hour: 12)) 12 | } 13 | 14 | it 'returns the weekday integer for that day' do 15 | expect(week_time.wday).to eq 0 16 | end 17 | end 18 | 19 | context 'when the time is on a day boundary' do 20 | subject(:week_time) { 21 | described_class.new(week_minute(wday: 1, hour: 0)) 22 | } 23 | 24 | it 'returns the weekday integer for the endnight day' do 25 | expect(week_time.wday).to eq 0 26 | end 27 | end 28 | 29 | context 'when the time is the last minute of the week' do 30 | subject(:week_time) { 31 | described_class.new(week_minute(wday: 7, hour: 0)) 32 | } 33 | 34 | it 'returns the weekday integer for Saturday' do 35 | expect(week_time.wday).to eq 6 36 | end 37 | end 38 | end 39 | 40 | describe '#wday_symbol' do 41 | context 'when the time is contained within a day' do 42 | subject(:week_time) { 43 | described_class.new(week_minute(wday: 0, hour: 12)) 44 | } 45 | 46 | it 'returns the weekday symbol for that day' do 47 | expect(week_time.wday_symbol).to eq :sun 48 | end 49 | end 50 | 51 | context 'when the time is on a day boundary' do 52 | subject(:week_time) { 53 | described_class.new(week_minute(wday: 1, hour: 0)) 54 | } 55 | 56 | it 'returns the weekday symbol for the endnight day' do 57 | expect(week_time.wday_symbol).to eq :sun 58 | end 59 | end 60 | end 61 | 62 | describe '#day_time' do 63 | it 'returns the corresponding day time' do 64 | expect(week_time.day_time).to eq( 65 | Biz::DayTime.new(day_second(hour: 9, min: 30)) 66 | ) 67 | end 68 | end 69 | 70 | describe '#day_minute' do 71 | it 'returns the corresponding day minute' do 72 | expect(week_time.day_minute).to eq day_minute(hour: 9, min: 30) 73 | end 74 | end 75 | 76 | describe '#day_second' do 77 | it 'returns the corresponding day second' do 78 | expect(week_time.day_second).to eq day_second(hour: 9, min: 30) 79 | end 80 | end 81 | 82 | describe '#hour' do 83 | it 'returns the corresponding hour' do 84 | expect(week_time.hour).to eq 9 85 | end 86 | end 87 | 88 | describe '#minute' do 89 | it 'returns the corresponding minute' do 90 | expect(week_time.minute).to eq 30 91 | end 92 | end 93 | 94 | describe '#timestamp' do 95 | it 'returns the corresponding timestamp' do 96 | expect(week_time.timestamp).to eq '09:30' 97 | end 98 | end 99 | 100 | context 'when the week minute is on a day boundary' do 101 | subject(:week_time) { 102 | described_class.new(Biz::DayOfWeek.all.first.end_minute) 103 | } 104 | 105 | describe '#day_time' do 106 | it 'returns the endnight day time' do 107 | expect(week_time.day_time).to eq Biz::DayTime.new(Biz::Time.day_seconds) 108 | end 109 | end 110 | 111 | describe '#day_minute' do 112 | it 'returns the number of minutes in a day' do 113 | expect(week_time.day_minute).to eq Biz::Time.day_minutes 114 | end 115 | end 116 | 117 | describe '#hour' do 118 | it 'returns 24' do 119 | expect(week_time.hour).to eq 24 120 | end 121 | end 122 | 123 | describe '#minute' do 124 | it 'returns zero' do 125 | expect(week_time.minute).to eq 0 126 | end 127 | end 128 | 129 | describe '#timestamp' do 130 | it "returns '24:00'" do 131 | expect(week_time.timestamp).to eq '24:00' 132 | end 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /spec/biz_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz do 4 | context 'when configured' do 5 | before do 6 | described_class.configure do |config| 7 | config.hours = {sun: {'11:00' => '12:00'}} 8 | config.breaks = {Date.new(2015, 6, 1) => {'10:00' => '12:00'}} 9 | config.holidays = [Date.new(2015, 12, 25)] 10 | config.time_zone = 'Africa/Abidjan' 11 | end 12 | end 13 | 14 | describe '.intervals' do 15 | it 'delegates to the top-level schedule' do 16 | expect(described_class.intervals).to eq( 17 | [ 18 | Biz::Interval.new( 19 | Biz::WeekTime.start(week_minute(wday: 0, hour: 11)), 20 | Biz::WeekTime.end(week_minute(wday: 0, hour: 12)), 21 | TZInfo::Timezone.get('Africa/Abidjan') 22 | ) 23 | ] 24 | ) 25 | end 26 | end 27 | 28 | describe '.holidays' do 29 | it 'delegates to the top-level schedule' do 30 | expect(described_class.holidays).to eq( 31 | [ 32 | Biz::Holiday.new( 33 | Date.new(2015, 12, 25), 34 | TZInfo::Timezone.get('Africa/Abidjan') 35 | ) 36 | ] 37 | ) 38 | end 39 | end 40 | 41 | describe '.time_zone' do 42 | it 'delegates to the top-level schedule' do 43 | expect(described_class.time_zone).to eq( 44 | TZInfo::Timezone.get('Africa/Abidjan') 45 | ) 46 | end 47 | end 48 | 49 | describe '.periods' do 50 | it 'delegates to the top-level schedule' do 51 | expect( 52 | described_class.periods.after(Time.utc(2006, 1, 1)).first 53 | ).to eq( 54 | Biz::TimeSegment.new( 55 | Time.utc(2006, 1, 1, 11), 56 | Time.utc(2006, 1, 1, 12) 57 | ) 58 | ) 59 | end 60 | end 61 | 62 | %i[date dates].each do |method| 63 | describe ".#{method}" do 64 | it 'delegates to the top-level schedule' do 65 | expect(described_class.dates.after(Date.new(2006, 1, 1)).first).to eq( 66 | Date.new(2006, 1, 8) 67 | ) 68 | end 69 | end 70 | end 71 | 72 | describe '.time' do 73 | it 'delegates to the top-level schedule' do 74 | expect( 75 | described_class.time(2, :hours).after(Time.utc(2006, 1, 1)) 76 | ).to eq Time.utc(2006, 1, 15, 11) 77 | end 78 | end 79 | 80 | describe '.within' do 81 | it 'delegates to the top-level schedule' do 82 | expect( 83 | described_class.within( 84 | Time.utc(2006, 1, 1, 11, 30), 85 | Time.utc(2006, 1, 8, 11, 30) 86 | ) 87 | ).to eq Biz::Duration.hour(1) 88 | end 89 | end 90 | 91 | describe '.in_hours?' do 92 | it 'delegates to the top-level schedule' do 93 | expect(described_class.in_hours?(Time.utc(2006, 1, 1, 11))).to eq( 94 | true 95 | ) 96 | end 97 | end 98 | 99 | describe '.business_hours?' do 100 | it 'delegates to the top-level schedule' do 101 | expect(described_class.business_hours?(Time.utc(2006, 1, 1, 11))).to eq( 102 | true 103 | ) 104 | end 105 | end 106 | 107 | describe '.on_break?' do 108 | it 'delegates to the top-level schedule' do 109 | expect(described_class.on_break?(Time.utc(2015, 6, 1, 11))).to eq( 110 | true 111 | ) 112 | end 113 | end 114 | 115 | describe '.on_holiday?' do 116 | it 'delegates to the top-level schedule' do 117 | expect(described_class.on_holiday?(Time.utc(2015, 12, 25, 12))).to eq( 118 | true 119 | ) 120 | end 121 | end 122 | end 123 | 124 | context 'when not configured' do 125 | before do Thread.current[:biz_schedule] = nil end 126 | 127 | it 'fails hard' do 128 | expect { 129 | described_class.intervals 130 | }.to raise_error Biz::Error::Configuration 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/biz/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Biz 4 | class Configuration 5 | 6 | module Default 7 | HOURS = { 8 | mon: {'09:00' => '17:00'}, 9 | tue: {'09:00' => '17:00'}, 10 | wed: {'09:00' => '17:00'}, 11 | thu: {'09:00' => '17:00'}, 12 | fri: {'09:00' => '17:00'} 13 | }.freeze 14 | 15 | SHIFTS = [].freeze 16 | BREAKS = [].freeze 17 | HOLIDAYS = [].freeze 18 | TIME_ZONE = 'Etc/UTC' 19 | end 20 | 21 | def initialize 22 | @raw = Raw.new 23 | 24 | yield raw if block_given? 25 | 26 | raw.freeze 27 | 28 | Validation.perform(self) 29 | end 30 | 31 | def intervals 32 | @intervals ||= 33 | raw 34 | .hours 35 | .flat_map { |weekday, hours| weekday_intervals(weekday, hours) } 36 | .sort 37 | .freeze 38 | end 39 | 40 | def shifts 41 | @shifts ||= 42 | raw 43 | .shifts 44 | .flat_map { |date, hours| 45 | hours.map { |timestamps| date_period(date, timestamps) } 46 | } 47 | .sort 48 | .freeze 49 | end 50 | 51 | def breaks 52 | @breaks ||= 53 | raw 54 | .breaks 55 | .flat_map { |date, hours| 56 | hours.map { |timestamps| date_period(date, timestamps) } 57 | } 58 | .sort 59 | .freeze 60 | end 61 | 62 | def holidays 63 | @holidays ||= 64 | raw 65 | .holidays 66 | .to_a 67 | .uniq 68 | .map { |date| Holiday.new(date, time_zone) } 69 | .sort 70 | .freeze 71 | end 72 | 73 | def time_zone 74 | @time_zone ||= TZInfo::TimezoneProxy.new(raw.time_zone) 75 | end 76 | 77 | def weekdays 78 | @weekdays ||= raw.hours.keys.uniq.freeze 79 | end 80 | 81 | def &(other) 82 | self.class.new do |config| 83 | config.hours = Interval.to_hours(intersected_intervals(other)) 84 | config.breaks = combined_breaks(other) 85 | config.holidays = [*raw.holidays, *other.raw.holidays].map(&:to_date) 86 | config.time_zone = raw.time_zone 87 | end 88 | end 89 | 90 | protected 91 | 92 | attr_reader :raw 93 | 94 | private 95 | 96 | def to_proc 97 | proc do |config| 98 | config.hours = raw.hours 99 | config.shifts = raw.shifts 100 | config.breaks = raw.breaks 101 | config.holidays = raw.holidays 102 | config.time_zone = raw.time_zone 103 | end 104 | end 105 | 106 | def time 107 | @time ||= Time.new(time_zone) 108 | end 109 | 110 | def weekday_intervals(weekday, hours) 111 | hours.map { |start_timestamp, end_timestamp| 112 | Interval.new( 113 | WeekTime.start( 114 | DayOfWeek.from_symbol(weekday).start_minute + 115 | DayTime.from_timestamp(start_timestamp).day_minute 116 | ), 117 | WeekTime.end( 118 | DayOfWeek.from_symbol(weekday).start_minute + 119 | DayTime.from_timestamp(end_timestamp).day_minute 120 | ), 121 | time_zone 122 | ) 123 | } 124 | end 125 | 126 | def date_period(date, timestamps) 127 | TimeSegment.new( 128 | time.on_date(date, DayTime.from_timestamp(timestamps.first)), 129 | time.on_date(date, DayTime.from_timestamp(timestamps.last)) 130 | ) 131 | end 132 | 133 | def intersected_intervals(other) 134 | intervals.flat_map { |interval| 135 | other 136 | .intervals 137 | .map { |other_interval| interval & other_interval } 138 | .reject(&:empty?) 139 | } 140 | end 141 | 142 | def combined_breaks(other) 143 | Hash.new do |config, date| config.store(date, {}) end.tap do |combined| 144 | [raw.breaks, other.raw.breaks].each do |configured| 145 | configured.each do |date, breaks| combined[date].merge!(breaks) end 146 | end 147 | end 148 | end 149 | 150 | Raw = Struct.new(:hours, :shifts, :breaks, :holidays, :time_zone) do 151 | def initialize(*) 152 | super 153 | 154 | self.hours ||= Default::HOURS 155 | self.shifts ||= Default::SHIFTS 156 | self.breaks ||= Default::BREAKS 157 | self.holidays ||= Default::HOLIDAYS 158 | self.time_zone ||= Default::TIME_ZONE 159 | end 160 | 161 | alias_method :business_hours=, :hours= 162 | end 163 | 164 | private_constant :Raw, 165 | :Default 166 | 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /spec/day_of_week_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::DayOfWeek do 4 | subject(:day) { described_class.new(1) } 5 | 6 | context 'when initializing' do 7 | context 'with a valid integer-like value' do 8 | it 'is successful' do 9 | expect { described_class.new('1') }.not_to raise_error 10 | end 11 | end 12 | 13 | context 'with an invalid integer-like value' do 14 | it 'fails hard' do 15 | expect { described_class.new('1one') }.to raise_error ArgumentError 16 | end 17 | end 18 | 19 | context 'with a non-integer value' do 20 | it 'fails hard' do 21 | expect { described_class.new([]) }.to raise_error TypeError 22 | end 23 | end 24 | end 25 | 26 | describe '.all' do 27 | it 'returns all the days of the week' do 28 | expect(described_class.all).to eq [ 29 | described_class.new(0), 30 | described_class.new(1), 31 | described_class.new(2), 32 | described_class.new(3), 33 | described_class.new(4), 34 | described_class.new(5), 35 | described_class.new(6) 36 | ] 37 | end 38 | end 39 | 40 | describe '.from_symbol' do 41 | it 'creates the proper day of the week' do 42 | expect(described_class.from_symbol(:wed)).to eq described_class.new(3) 43 | end 44 | end 45 | 46 | describe '#contains?' do 47 | context 'when the week minute is at the beginning of the day of the week' do 48 | let(:minute) { week_minute(wday: 1, hour: 0) } 49 | 50 | it 'returns true' do 51 | expect(day.contains?(minute)).to eq true 52 | end 53 | end 54 | 55 | context 'when the week minute is in the middle of the day of the week' do 56 | let(:minute) { week_minute(wday: 1, hour: 12) } 57 | 58 | it 'returns true' do 59 | expect(day.contains?(minute)).to eq true 60 | end 61 | end 62 | 63 | context 'when the week minute is at the end of the day of the week' do 64 | let(:minute) { week_minute(wday: 2, hour: 0) } 65 | 66 | it 'returns true' do 67 | expect(day.contains?(minute)).to eq true 68 | end 69 | end 70 | 71 | context 'when the week time is not within the day of the week' do 72 | let(:minute) { week_minute(wday: 3, hour: 12) } 73 | 74 | it 'returns false' do 75 | expect(day.contains?(minute)).to eq false 76 | end 77 | end 78 | end 79 | 80 | describe '#start_minute' do 81 | it 'returns the first minute of the day of the week' do 82 | expect(day.start_minute).to eq week_minute(wday: 1, hour: 0) 83 | end 84 | end 85 | 86 | describe '#end_minute' do 87 | it 'returns the last minute of the day of the week' do 88 | expect(day.end_minute).to eq week_minute(wday: 2, hour: 0) 89 | end 90 | end 91 | 92 | describe '#week_minute' do 93 | it 'returns the corresponding week minute' do 94 | expect(day.week_minute(day_minute(hour: 9, min: 30))).to eq( 95 | week_minute(wday: 1, hour: 9, min: 30) 96 | ) 97 | end 98 | end 99 | 100 | describe '#day_minute' do 101 | context 'when the week minute occurs in the middle of a day' do 102 | it 'returns the corresponding day minute' do 103 | expect(day.day_minute(week_minute(wday: 2, hour: 9, min: 30))).to eq( 104 | day_minute(hour: 9, min: 30) 105 | ) 106 | end 107 | end 108 | 109 | context 'when the week minute is on the day boundary' do 110 | it 'returns the last minute of the day' do 111 | expect(day.day_minute(week_minute(wday: 2, hour: 24))).to eq( 112 | day_minute(hour: 24) 113 | ) 114 | end 115 | end 116 | end 117 | 118 | describe '#symbol' do 119 | it 'returns the corresponding symbol for the day of the week' do 120 | expect(day.symbol).to eq :mon 121 | end 122 | end 123 | 124 | describe '#wday?' do 125 | context 'when the other `wday` is the same' do 126 | it 'returns true' do 127 | expect(day.wday?(1)).to eq true 128 | end 129 | end 130 | 131 | context 'when the other `wday` is different' do 132 | it 'returns false' do 133 | expect(day.wday?(2)).to eq false 134 | end 135 | end 136 | end 137 | 138 | context 'when performing comparison' do 139 | context 'and the compared object is an earlier day of the week' do 140 | let(:other) { described_class.new(0) } 141 | 142 | it 'compares as expected' do 143 | expect(day > other).to eq true 144 | end 145 | end 146 | 147 | context 'and the compared object is the same day of the week' do 148 | let(:other) { described_class.new(1) } 149 | 150 | it 'compares as expected' do 151 | expect(day == other).to eq true 152 | end 153 | end 154 | 155 | context 'and the other object is a later day of the week' do 156 | let(:other) { described_class.new(2) } 157 | 158 | it 'compares as expected' do 159 | expect(day < other).to eq true 160 | end 161 | end 162 | 163 | context 'and the compared object is not a day of the week' do 164 | let(:other) { 1 } 165 | 166 | it 'is not comparable' do 167 | expect { day < other }.to raise_error ArgumentError 168 | end 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /spec/timeline/backward_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Timeline::Backward do 4 | subject(:timeline) { described_class.new(backward_periods) } 5 | 6 | describe '#backward' do 7 | it 'returns itself' do 8 | expect(timeline.backward).to eq timeline 9 | end 10 | end 11 | 12 | describe '#until' do 13 | context 'when the terminus has second precision' do 14 | let(:terminus) { Time.utc(2006, 1, 1, 0, 1) } 15 | 16 | it 'returns a period with second precision' do 17 | expect(timeline.until(terminus).to_a).to eq [ 18 | Biz::TimeSegment.new(Time.utc(2006, 1, 1, 0, 1), Time.utc(2006, 2)) 19 | ] 20 | end 21 | end 22 | 23 | context 'when the terminus is after the first period' do 24 | let(:terminus) { Time.utc(2007) } 25 | 26 | it 'returns no periods' do 27 | expect(timeline.until(terminus).to_a).to eq [] 28 | end 29 | end 30 | 31 | context 'when the terminus is between periods' do 32 | let(:terminus) { Time.utc(2004, 3) } 33 | 34 | it 'returns the proper periods' do 35 | expect(timeline.until(terminus).to_a).to eq [ 36 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 37 | Biz::TimeSegment.new(Time.utc(2005), Time.utc(2005, 2)) 38 | ] 39 | end 40 | end 41 | 42 | context 'when the terminus is at the end of a period' do 43 | let(:terminus) { Time.utc(2004, 2) } 44 | 45 | it 'returns the proper periods' do 46 | expect(timeline.until(terminus).to_a).to eq [ 47 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 48 | Biz::TimeSegment.new(Time.utc(2005), Time.utc(2005, 2)) 49 | ] 50 | end 51 | end 52 | 53 | context 'when the terminus is at the end of the first period' do 54 | let(:terminus) { Time.utc(2006, 2) } 55 | 56 | it 'returns no periods' do 57 | expect(timeline.until(terminus).to_a).to eq [] 58 | end 59 | end 60 | 61 | context 'when the terminus is in the middle of a period' do 62 | let(:terminus) { Time.utc(2005, 1, 15) } 63 | 64 | it 'returns the proper periods' do 65 | expect(timeline.until(terminus).to_a).to eq [ 66 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 67 | Biz::TimeSegment.new(Time.utc(2005, 1, 15), Time.utc(2005, 2)) 68 | ] 69 | end 70 | end 71 | 72 | context 'when the terminus is at the beginning of a period' do 73 | let(:terminus) { Time.utc(2005) } 74 | 75 | it 'returns the proper periods' do 76 | expect(timeline.until(terminus).to_a).to eq [ 77 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 78 | Biz::TimeSegment.new(Time.utc(2005), Time.utc(2005, 2)) 79 | ] 80 | end 81 | end 82 | end 83 | 84 | describe '#for' do 85 | context 'when the duration has second precision' do 86 | let(:duration) { Biz::Duration.seconds(2) } 87 | 88 | it 'returns a period with second precision' do 89 | expect(timeline.for(duration).to_a).to eq [ 90 | Biz::TimeSegment.new( 91 | Time.utc(2006, 1, 31, 23, 59, 58), 92 | Time.utc(2006, 2) 93 | ) 94 | ] 95 | end 96 | end 97 | 98 | context 'when the duration is negative' do 99 | let(:duration) { Biz::Duration.new(-1) } 100 | 101 | it 'returns no periods' do 102 | expect(timeline.for(duration).to_a).to eq [] 103 | end 104 | end 105 | 106 | context 'when the duration is zero' do 107 | let(:duration) { Biz::Duration.new(0) } 108 | 109 | it 'returns the first active moment backward in time' do 110 | expect(timeline.for(duration).to_a).to eq [ 111 | Biz::TimeSegment.new(Time.utc(2006, 2), Time.utc(2006, 2)) 112 | ] 113 | end 114 | end 115 | 116 | context 'when the duration is contained by the first period' do 117 | let(:duration) { Biz::Duration.seconds(in_seconds(days: 15)) } 118 | 119 | it 'returns part of the first period' do 120 | expect(timeline.for(duration).to_a).to eq [ 121 | Biz::TimeSegment.new(Time.utc(2006, 1, 17), Time.utc(2006, 2)) 122 | ] 123 | end 124 | end 125 | 126 | context 'when the duration is the length of the first period' do 127 | let(:duration) { Biz::Duration.seconds(in_seconds(days: 31)) } 128 | 129 | it 'returns the first period' do 130 | expect(timeline.for(duration).to_a).to eq [ 131 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)) 132 | ] 133 | end 134 | end 135 | 136 | context 'when the duration is contained by a set of full periods' do 137 | let(:duration) { Biz::Duration.seconds(in_seconds(days: 62)) } 138 | 139 | it 'returns the proper periods' do 140 | expect(timeline.for(duration).to_a).to eq [ 141 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 142 | Biz::TimeSegment.new(Time.utc(2005), Time.utc(2005, 2)) 143 | ] 144 | end 145 | end 146 | 147 | context 'when the duration ends in the middle of a period' do 148 | let(:duration) { Biz::Duration.seconds(in_seconds(days: 46)) } 149 | 150 | it 'returns the proper periods' do 151 | expect(timeline.for(duration).to_a).to eq [ 152 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 153 | Biz::TimeSegment.new(Time.utc(2005, 1, 17), Time.utc(2005, 2)) 154 | ] 155 | end 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /spec/time_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Time do 4 | let(:time_zone) { TZInfo::Timezone.get('America/Los_Angeles') } 5 | 6 | subject(:time) { described_class.new(time_zone) } 7 | 8 | describe '.minute_seconds' do 9 | it 'returns the number of seconds in a minute' do 10 | expect(described_class.minute_seconds).to eq 60 11 | end 12 | end 13 | 14 | describe '.hour_minutes' do 15 | it 'returns the number of minutes in an hour' do 16 | expect(described_class.hour_minutes).to eq 60 17 | end 18 | end 19 | 20 | describe '.day_hours' do 21 | it 'returns the number of hours in a day' do 22 | expect(described_class.day_hours).to eq 24 23 | end 24 | end 25 | 26 | describe '.week_days' do 27 | it 'returns the number of days in a week' do 28 | expect(described_class.week_days).to eq 7 29 | end 30 | end 31 | 32 | describe '.hour_seconds' do 33 | it 'returns the number of seconds in an hour' do 34 | expect(described_class.hour_seconds).to eq( 35 | Biz::Time.hour_minutes * Biz::Time.minute_seconds 36 | ) 37 | end 38 | end 39 | 40 | describe '.day_seconds' do 41 | it 'returns the number of seconds in a day' do 42 | expect(described_class.day_seconds).to eq( 43 | Biz::Time.day_minutes * Biz::Time.minute_seconds 44 | ) 45 | end 46 | end 47 | 48 | describe '.day_minutes' do 49 | it 'returns the number of minutes in a day' do 50 | expect(described_class.day_minutes).to eq( 51 | Biz::Time.day_hours * Biz::Time.hour_minutes 52 | ) 53 | end 54 | end 55 | 56 | describe '.week_minutes' do 57 | it 'returns the number of minutes in a week' do 58 | expect(described_class.week_minutes).to eq( 59 | Biz::Time.week_days * Biz::Time.day_minutes 60 | ) 61 | end 62 | end 63 | 64 | describe '.big_bang' do 65 | it 'returns the beginning of time' do 66 | expect(described_class.big_bang).to eq Time.new(-100_000_000) 67 | end 68 | end 69 | 70 | describe '.heat_death' do 71 | it 'returns the end of time' do 72 | expect(described_class.heat_death).to eq Time.new(100_000_000) 73 | end 74 | end 75 | 76 | describe '#local' do 77 | let(:provided_time) { Time.utc(2006, 1, 1, 12, 30, 15) } 78 | 79 | it 'returns an equivalent time' do 80 | expect(time.local(provided_time)).to eq provided_time 81 | end 82 | 83 | it 'returns a time with the appropriate time zone' do 84 | expect(time.local(provided_time).zone).to eq 'PST' 85 | end 86 | end 87 | 88 | describe '#on_date' do 89 | context 'when a normal time is targeted' do 90 | let(:date) { Date.new(2006, 1, 4) } 91 | let(:day_time) { Biz::DayTime.new(day_second(hour: 12, min: 30, sec: 9)) } 92 | 93 | it 'returns the corresponding UTC time' do 94 | expect(time.on_date(date, day_time)).to eq( 95 | time_zone.local_to_utc(Time.utc(2006, 1, 4, 12, 30, 9)) 96 | ) 97 | end 98 | end 99 | 100 | context 'when a non-existent (spring-forward) time is targeted' do 101 | let(:date) { Date.new(2014, 3, 9) } 102 | let(:day_time) { Biz::DayTime.new(day_second(hour: 2, min: 30)) } 103 | 104 | it 'returns the corresponding time an hour later' do 105 | expect(time.on_date(date, day_time)).to eq( 106 | time_zone.local_to_utc(Time.utc(2014, 3, 9, 3, 30)) 107 | ) 108 | end 109 | end 110 | 111 | context 'when a non-existent (spring-forward) endnight time is targeted' do 112 | let(:time_zone) { TZInfo::Timezone.get('America/Sao_Paulo') } 113 | let(:date) { Date.new(2015, 10, 17) } 114 | let(:day_time) { Biz::DayTime.new(day_second(hour: 24)) } 115 | 116 | it 'returns the corresponding time an hour later' do 117 | expect(time.on_date(date, day_time)).to eq( 118 | time_zone.local_to_utc(Time.new(2015, 10, 18, 1)) 119 | ) 120 | end 121 | end 122 | 123 | context 'when an ambiguous time is targeted' do 124 | let(:date) { Date.new(2014, 11, 2) } 125 | let(:day_time) { Biz::DayTime.new(day_second(hour: 1, min: 30)) } 126 | 127 | it 'returns the DST occurrence of the time' do 128 | expect(time.on_date(date, day_time)).to eq( 129 | time_zone.local_to_utc(Time.utc(2014, 11, 2, 1, 30), true) 130 | ) 131 | end 132 | end 133 | end 134 | 135 | describe '#during_week' do 136 | let(:week) { Biz::Week.new(1) } 137 | let(:week_time) { 138 | Biz::WeekTime.build(week_minute(wday: 0, hour: 1, min: 5)) 139 | } 140 | 141 | it 'returns the target time' do 142 | expect(time.during_week(week, week_time)).to eq( 143 | time_zone.local_to_utc(Time.utc(2006, 1, 8, 1, 5)) 144 | ) 145 | end 146 | end 147 | 148 | context 'when a non-existent (spring-forward) time is targeted' do 149 | let(:week) { Biz::Week.new(427) } 150 | let(:week_time) { 151 | Biz::WeekTime.build(week_minute(wday: 0, hour: 2, min: 30)) 152 | } 153 | 154 | it 'returns the target time an hour later' do 155 | expect(time.during_week(week, week_time)).to eq( 156 | time_zone.local_to_utc(Time.utc(2014, 3, 9, 3, 30)) 157 | ) 158 | end 159 | end 160 | 161 | context 'when an ambiguous time is targeted' do 162 | let(:week) { Biz::Week.new(461) } 163 | let(:week_time) { 164 | Biz::WeekTime.build(week_minute(wday: 0, hour: 1, min: 30)) 165 | } 166 | 167 | it 'returns the DST occurrence of the target time' do 168 | expect(time.during_week(week, week_time)).to eq( 169 | time_zone.local_to_utc(Time.utc(2014, 11, 2, 1, 30), true) 170 | ) 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /spec/timeline/forward_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Timeline::Forward do 4 | subject(:timeline) { described_class.new(forward_periods) } 5 | 6 | describe '#forward' do 7 | it 'returns itself' do 8 | expect(timeline.forward).to eq timeline 9 | end 10 | end 11 | 12 | describe '#until' do 13 | context 'when the terminus has second precision' do 14 | let(:terminus) { Time.utc(2006, 1, 1, 0, 1) } 15 | 16 | it 'returns a period with second precision' do 17 | expect(timeline.until(terminus).to_a).to eq [ 18 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 1, 1, 0, 1)) 19 | ] 20 | end 21 | end 22 | 23 | context 'when the terminus is before the first period' do 24 | let(:terminus) { Time.utc(2005) } 25 | 26 | it 'returns no periods' do 27 | expect(timeline.until(terminus).to_a).to eq [] 28 | end 29 | end 30 | 31 | context 'when the terminus is between periods' do 32 | let(:terminus) { Time.utc(2007, 3) } 33 | 34 | it 'returns the proper periods' do 35 | expect(timeline.until(terminus).to_a).to eq [ 36 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 37 | Biz::TimeSegment.new(Time.utc(2007), Time.utc(2007, 2)) 38 | ] 39 | end 40 | end 41 | 42 | context 'when the terminus is at the beginning of a period' do 43 | let(:terminus) { Time.utc(2008) } 44 | 45 | it 'returns the proper periods' do 46 | expect(timeline.until(terminus).to_a).to eq [ 47 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 48 | Biz::TimeSegment.new(Time.utc(2007), Time.utc(2007, 2)), 49 | Biz::TimeSegment.new(Time.utc(2008), Time.utc(2008)) 50 | ] 51 | end 52 | end 53 | 54 | context 'when the terminus at the beginning of the first period' do 55 | let(:terminus) { Time.utc(2006) } 56 | 57 | it 'returns the first active moment forward in time' do 58 | expect(timeline.until(terminus).to_a).to eq [ 59 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006)) 60 | ] 61 | end 62 | end 63 | 64 | context 'when the terminus is in the middle of a period' do 65 | let(:terminus) { Time.utc(2007, 1, 15) } 66 | 67 | it 'returns the proper periods' do 68 | expect(timeline.until(terminus).to_a).to eq [ 69 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 70 | Biz::TimeSegment.new(Time.utc(2007), Time.utc(2007, 1, 15)) 71 | ] 72 | end 73 | end 74 | 75 | context 'when the terminus is at the end of a period' do 76 | let(:terminus) { Time.utc(2007, 2) } 77 | 78 | it 'returns the proper periods' do 79 | expect(timeline.until(terminus).to_a).to eq [ 80 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 81 | Biz::TimeSegment.new(Time.utc(2007), Time.utc(2007, 2)) 82 | ] 83 | end 84 | end 85 | end 86 | 87 | describe '#for' do 88 | context 'when the duration has second precision' do 89 | let(:duration) { Biz::Duration.seconds(2) } 90 | 91 | it 'returns a period with second precision' do 92 | expect(timeline.for(duration).to_a).to eq [ 93 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 1, 1, 0, 0, 2)) 94 | ] 95 | end 96 | end 97 | 98 | context 'when the duration is negative' do 99 | let(:duration) { Biz::Duration.new(-1) } 100 | 101 | it 'returns no periods' do 102 | expect(timeline.for(duration).to_a).to eq [] 103 | end 104 | end 105 | 106 | context 'when the duration is zero' do 107 | let(:duration) { Biz::Duration.new(0) } 108 | 109 | it 'returns the first active moment forward in time' do 110 | expect(timeline.for(duration).to_a).to eq [ 111 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006)) 112 | ] 113 | end 114 | end 115 | 116 | context 'when the duration is contained by the first period' do 117 | let(:duration) { Biz::Duration.seconds(in_seconds(days: 15)) } 118 | 119 | it 'returns part of the first period' do 120 | expect(timeline.for(duration).to_a).to eq [ 121 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 1, 16)) 122 | ] 123 | end 124 | end 125 | 126 | context 'when the duration is the length of the first period' do 127 | let(:duration) { Biz::Duration.seconds(in_seconds(days: 31)) } 128 | 129 | it 'returns the proper periods' do 130 | expect(timeline.for(duration).to_a).to eq [ 131 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 132 | Biz::TimeSegment.new(Time.utc(2007), Time.utc(2007)) 133 | ] 134 | end 135 | end 136 | 137 | context 'when the duration is contained by a set of full periods' do 138 | let(:duration) { Biz::Duration.seconds(in_seconds(days: 62)) } 139 | 140 | it 'returns the proper periods' do 141 | expect(timeline.for(duration).to_a).to eq [ 142 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 143 | Biz::TimeSegment.new(Time.utc(2007), Time.utc(2007, 2)), 144 | Biz::TimeSegment.new(Time.utc(2008), Time.utc(2008)) 145 | ] 146 | end 147 | end 148 | 149 | context 'when the duration ends in the middle of a period' do 150 | let(:duration) { Biz::Duration.seconds(in_seconds(days: 46)) } 151 | 152 | it 'returns the proper periods' do 153 | expect(timeline.for(duration).to_a).to eq [ 154 | Biz::TimeSegment.new(Time.utc(2006), Time.utc(2006, 2)), 155 | Biz::TimeSegment.new(Time.utc(2007), Time.utc(2007, 1, 16)) 156 | ] 157 | end 158 | end 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /spec/duration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Duration do 4 | subject(:duration) { 5 | described_class.new(in_seconds(days: 2, hours: 5, minutes: 9, seconds: 30)) 6 | } 7 | 8 | context 'when initializing' do 9 | context 'with a valid integer-like value' do 10 | it 'is successful' do 11 | expect { described_class.new('1') }.not_to raise_error 12 | end 13 | end 14 | 15 | context 'with an invalid integer-like value' do 16 | it 'fails hard' do 17 | expect { described_class.new('1one') }.to raise_error ArgumentError 18 | end 19 | end 20 | 21 | context 'with a non-integer value' do 22 | it 'fails hard' do 23 | expect { described_class.new([]) }.to raise_error TypeError 24 | end 25 | end 26 | end 27 | 28 | describe '.seconds' do 29 | it 'returns the proper duration' do 30 | expect(described_class.seconds(60)).to eq( 31 | described_class.new(in_seconds(seconds: 60)) 32 | ) 33 | end 34 | end 35 | 36 | describe '.second' do 37 | it 'returns the proper duration' do 38 | expect(described_class.second(60)).to eq( 39 | described_class.new(in_seconds(seconds: 60)) 40 | ) 41 | end 42 | end 43 | 44 | describe '.minutes' do 45 | it 'returns the proper duration' do 46 | expect(described_class.minutes(60)).to eq( 47 | described_class.new(in_seconds(minutes: 60)) 48 | ) 49 | end 50 | end 51 | 52 | describe '.minute' do 53 | it 'returns the proper duration' do 54 | expect(described_class.minute(60)).to eq( 55 | described_class.new(in_seconds(minutes: 60)) 56 | ) 57 | end 58 | end 59 | 60 | describe '.hours' do 61 | it 'returns the proper duration' do 62 | expect(described_class.hours(1)).to eq( 63 | described_class.new(in_seconds(hours: 1)) 64 | ) 65 | end 66 | end 67 | 68 | describe '.hour' do 69 | it 'returns the proper duration' do 70 | expect(described_class.hour(1)).to eq( 71 | described_class.new(in_seconds(hours: 1)) 72 | ) 73 | end 74 | end 75 | 76 | describe '#in_seconds' do 77 | it 'returns the number of seconds' do 78 | expect(duration.in_seconds).to eq( 79 | in_seconds(days: 2, hours: 5, minutes: 9, seconds: 30) 80 | ) 81 | end 82 | end 83 | 84 | describe '#in_minutes' do 85 | it 'returns the number of whole minutes' do 86 | expect(duration.in_minutes).to eq (((2 * 24) + 5) * 60) + 9 87 | end 88 | end 89 | 90 | describe '#in_hours' do 91 | it 'returns the number of whole hours' do 92 | expect(duration.in_hours).to eq (2 * 24) + 5 93 | end 94 | end 95 | 96 | describe '#+' do 97 | let(:duration1) { described_class.hours(1) } 98 | let(:duration2) { described_class.minutes(30) } 99 | 100 | it 'adds the durations' do 101 | expect(duration1 + duration2).to eq described_class.minutes(90) 102 | end 103 | end 104 | 105 | describe '#-' do 106 | let(:duration1) { described_class.hours(1) } 107 | let(:duration2) { described_class.minutes(30) } 108 | 109 | it 'subtracts the durations' do 110 | expect(duration1 - duration2).to eq described_class.minutes(30) 111 | end 112 | end 113 | 114 | describe '#positive?' do 115 | context 'when the duration is zero' do 116 | let(:duration) { described_class.new(0) } 117 | 118 | it 'returns false' do 119 | expect(duration.positive?).to eq false 120 | end 121 | end 122 | 123 | context 'when the duration is negative' do 124 | let(:duration) { described_class.new(-1) } 125 | 126 | it 'returns false' do 127 | expect(duration.positive?).to eq false 128 | end 129 | end 130 | 131 | context 'when the duration is positive' do 132 | let(:duration) { described_class.new(1) } 133 | 134 | it 'returns true' do 135 | expect(duration.positive?).to eq true 136 | end 137 | end 138 | end 139 | 140 | describe '#abs' do 141 | context 'when the duration is zero' do 142 | let(:duration) { described_class.new(0) } 143 | 144 | it 'returns an equivalent duration' do 145 | expect(duration.abs).to eq duration 146 | end 147 | end 148 | 149 | context 'when the duration is negative' do 150 | let(:duration) { described_class.new(-1) } 151 | 152 | it 'returns a positive duration of the same magnitude' do 153 | expect(duration.abs).to eq described_class.new(1) 154 | end 155 | end 156 | 157 | context 'when the duration is positive' do 158 | let(:duration) { described_class.new(1) } 159 | 160 | it 'returns an equivalent duration' do 161 | expect(duration.abs).to eq duration 162 | end 163 | end 164 | end 165 | 166 | context 'when performing comparison' do 167 | context 'and the compared object is a shorter duration' do 168 | let(:other) { 169 | described_class.new( 170 | in_seconds(days: 2, hours: 5, minutes: 9, seconds: 29) 171 | ) 172 | } 173 | 174 | it 'compares as expected' do 175 | expect(duration > other).to eq true 176 | end 177 | end 178 | 179 | context 'and the compared object is the same duration' do 180 | let(:other) { 181 | described_class.new( 182 | in_seconds(days: 2, hours: 5, minutes: 9, seconds: 30) 183 | ) 184 | } 185 | 186 | it 'compares as expected' do 187 | expect(duration == other).to eq true 188 | end 189 | end 190 | 191 | context 'and the other object is a longer duration' do 192 | let(:other) { 193 | described_class.new( 194 | in_seconds(days: 2, hours: 5, minutes: 9, seconds: 31) 195 | ) 196 | } 197 | 198 | it 'compares as expected' do 199 | expect(duration < other).to eq true 200 | end 201 | end 202 | 203 | context 'and the compared object is not a duration' do 204 | let(:other) { 1 } 205 | 206 | it 'is not comparable' do 207 | expect { duration < other }.to raise_error ArgumentError 208 | end 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [v1.8.2] (January 14, 2019) 9 | 10 | ### Added 11 | 12 | - [#137](https://github.com/zendesk/biz/pull/137): Add support for Ruby 2.6 13 | 14 | ### Fixed 15 | 16 | - [#139](https://github.com/zendesk/biz/pull/139): Treat endpoints consistently in calculations 17 | 18 | ## [v1.8.1] (October 30, 2018) 19 | 20 | ### Added 21 | 22 | - [#131](https://github.com/zendesk/biz/pull/131): Add support for JRuby 23 | - [#132](https://github.com/zendesk/biz/pull/132): Enrich the hours validation experience 24 | 25 | ## [v1.8.0] (September 14, 2018) 26 | 27 | ### Added 28 | 29 | - [#109](https://github.com/zendesk/biz/pull/109): Implement shifts (date-based intervals) feature 30 | - [#114](https://github.com/zendesk/biz/pull/114): Add support for Ruby 2.5 31 | 32 | ### Changed 33 | 34 | - [#120](https://github.com/zendesk/biz/pull/120): Calibrate method privacy 35 | - [#121](https://github.com/zendesk/biz/pull/121): Rename `Gemfile` to `gems.rb` 36 | 37 | ### Removed 38 | 39 | - [#119](https://github.com/zendesk/biz/pull/119): Drop support for Ruby 2.2 40 | 41 | ## [v1.7.0] (June 13, 2017) 42 | 43 | ### Added 44 | 45 | - [#105](https://github.com/zendesk/biz/pull/105): Add helper for generating periods on a date 46 | 47 | ### Removed 48 | 49 | - [#100](https://github.com/zendesk/biz/pull/100): Drop support for Ruby 2.1 50 | 51 | ## [v1.6.1] (January 5, 2017) 52 | 53 | ### Added 54 | 55 | - [#89](https://github.com/zendesk/biz/pull/89): Add support for Ruby 2.4 56 | 57 | ### Removed 58 | 59 | - [#77](https://github.com/zendesk/biz/pull/77): Drop support for Ruby 2.0 60 | 61 | ## [v1.6.0] (June 13, 2016) 62 | 63 | ### Added 64 | 65 | - [#67](https://github.com/zendesk/biz/pull/67): Implement breaks (time-segment holidays) feature 66 | - [#71](https://github.com/zendesk/biz/pull/71): Include breaks when intersecting schedules 67 | - [#72](https://github.com/zendesk/biz/pull/72): Add `on_break?` schedule method 68 | 69 | ### Fixed 70 | 71 | - [#66](https://github.com/zendesk/biz/pull/66): Filter out empty intervals 72 | - [#70](https://github.com/zendesk/biz/pull/70): Consider breaks in `in_hours?` calculation 73 | - [#72](https://github.com/zendesk/biz/pull/72): Be consistent when excluding endpoints 74 | 75 | ## [v1.5.2] (April 12, 2016) 76 | 77 | ### Fixed 78 | 79 | - [#60](https://github.com/zendesk/biz/pull/60): Reject negative scalars in for-duration calculations 80 | - [#61](https://github.com/zendesk/biz/pull/61): Support zero scalar for-duration calculations 81 | 82 | ## [v1.5.1] (March 30, 2016) 83 | 84 | ### Changed 85 | 86 | - [#53](https://github.com/zendesk/biz/pull/53): Allow configuration with array-like objects 87 | 88 | ## [v1.5.0] (March 29, 2016) 89 | 90 | ### Added 91 | 92 | - [#51](https://github.com/zendesk/biz/pull/51): Add ability to intersect schedules 93 | 94 | ## [v1.4.0] (March 11, 2016) 95 | 96 | ### Changed 97 | 98 | - [#46](https://github.com/zendesk/biz/pull/46): Standardize value object equality logic 99 | - [#47](https://github.com/zendesk/biz/pull/47): Clean up remaining post-extraction clutter 100 | 101 | ## [v1.3.4] (February 13, 2016) 102 | 103 | ### Added 104 | 105 | - [#41](https://github.com/zendesk/biz/pull/41): Add support for Ruby 2.3 106 | 107 | ### Removed 108 | 109 | - [#44](https://github.com/zendesk/biz/pull/44): Remove unwarranted gem dependencies 110 | 111 | ## [v1.3.3] (October 19, 2015) 112 | 113 | ### Changed 114 | 115 | - [#37](https://github.com/zendesk/biz/pull/37): Refactor "endnight" DST handling 116 | 117 | ## [v1.3.2] (October 17, 2015) 118 | 119 | ### Fixed 120 | 121 | - [#36](https://github.com/zendesk/biz/pull/36): Add "endnight" DST handling 122 | 123 | ## [v1.3.1] (October 1, 2015) 124 | 125 | ### Fixed 126 | 127 | - [#34](https://github.com/zendesk/biz/pull/34): Add basic hours validation 128 | 129 | ## [v1.3.0] (July 29, 2015) 130 | 131 | ### Added 132 | 133 | - [#29](https://github.com/zendesk/biz/pull/29): Add `on_holiday?` schedule method 134 | 135 | ## [v1.2.2] (April 15, 2015) 136 | 137 | ### Fixed 138 | 139 | - [#26](https://github.com/zendesk/biz/pull/26): Fix DST handling 140 | 141 | ## [v1.2.1] (March 23, 2015) 142 | 143 | ### Fixed 144 | 145 | - [#22](https://github.com/zendesk/biz/pull/22): Allow second-level precision on day calculations 146 | 147 | ## [v1.2.0] (March 20, 2015) 148 | 149 | ### Added 150 | 151 | - [#17](https://github.com/zendesk/biz/pull/17): Implement day-increment duration calculations 152 | 153 | ### Removed 154 | 155 | - [#15](https://github.com/zendesk/biz/pull/15): Remove "day" as a unit of duration 156 | 157 | ## [v1.1.0] (February 26, 2015) 158 | 159 | ### Changed 160 | 161 | - [#10](https://github.com/zendesk/biz/pull/10): Update license 162 | - [#10](https://github.com/zendesk/biz/pull/10): Specify minimum support for Ruby 2.0 163 | - [#11](https://github.com/zendesk/biz/pull/11): Tweak public method names 164 | 165 | ## v1.0.0 (February 17, 2015) 166 | 167 | Initial public release. 168 | 169 | [v1.8.2]: https://github.com/zendesk/biz/compare/v1.8.1...v1.8.2 170 | [v1.8.1]: https://github.com/zendesk/biz/compare/v1.8.0...v1.8.1 171 | [v1.8.0]: https://github.com/zendesk/biz/compare/v1.7.0...v1.8.0 172 | [v1.7.0]: https://github.com/zendesk/biz/compare/v1.6.1...v1.7.0 173 | [v1.6.1]: https://github.com/zendesk/biz/compare/v1.6.0...v1.6.1 174 | [v1.6.0]: https://github.com/zendesk/biz/compare/v1.5.2...v1.6.0 175 | [v1.5.2]: https://github.com/zendesk/biz/compare/v1.5.1...v1.5.2 176 | [v1.5.1]: https://github.com/zendesk/biz/compare/v1.5.0...v1.5.1 177 | [v1.5.0]: https://github.com/zendesk/biz/compare/v1.4.0...v1.5.0 178 | [v1.4.0]: https://github.com/zendesk/biz/compare/v1.3.4...v1.4.0 179 | [v1.3.4]: https://github.com/zendesk/biz/compare/v1.3.3...v1.3.4 180 | [v1.3.3]: https://github.com/zendesk/biz/compare/v1.3.2...v1.3.3 181 | [v1.3.2]: https://github.com/zendesk/biz/compare/v1.3.1...v1.3.2 182 | [v1.3.1]: https://github.com/zendesk/biz/compare/v1.3.0...v1.3.1 183 | [v1.3.0]: https://github.com/zendesk/biz/compare/v1.2.2...v1.3.0 184 | [v1.2.2]: https://github.com/zendesk/biz/compare/v1.2.1...v1.2.2 185 | [v1.2.1]: https://github.com/zendesk/biz/compare/v1.2.0...v1.2.1 186 | [v1.2.0]: https://github.com/zendesk/biz/compare/v1.1.0...v1.2.0 187 | [v1.1.0]: https://github.com/zendesk/biz/compare/v1.0.0...v1.1.0 188 | -------------------------------------------------------------------------------- /spec/schedule_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Schedule do 4 | let(:hours) { 5 | { 6 | mon: {'09:00' => '17:00'}, 7 | tue: {'10:00' => '16:00'}, 8 | wed: {'09:00' => '17:00'}, 9 | thu: {'10:00' => '16:00'}, 10 | fri: {'09:00' => '17:00'}, 11 | sat: {'11:00' => '14:30'} 12 | } 13 | } 14 | 15 | let(:breaks) { 16 | { 17 | Date.new(2006, 1, 2) => {'10:00' => '11:30'}, 18 | Date.new(2006, 1, 3) => {'14:15' => '14:30', '15:40' => '15:50'} 19 | } 20 | } 21 | 22 | let(:holidays) { [Date.new(2006, 1, 1), Date.new(2006, 12, 25)] } 23 | let(:time_zone) { 'Etc/UTC' } 24 | 25 | let(:config) { 26 | proc do |c| 27 | c.hours = hours 28 | c.breaks = breaks 29 | c.holidays = holidays 30 | c.time_zone = time_zone 31 | end 32 | } 33 | 34 | subject(:schedule) { Biz::Schedule.new(&config) } 35 | 36 | describe '#intervals' do 37 | it 'delegates to the configuration' do 38 | expect(schedule.intervals).to eq Biz::Configuration.new(&config).intervals 39 | end 40 | end 41 | 42 | describe '#shifts' do 43 | it 'delegates to the configuration' do 44 | expect(schedule.shifts).to eq Biz::Configuration.new(&config).shifts 45 | end 46 | end 47 | 48 | describe '#breaks' do 49 | it 'delegates to the configuration' do 50 | expect(schedule.breaks).to eq Biz::Configuration.new(&config).breaks 51 | end 52 | end 53 | 54 | describe '#holidays' do 55 | it 'delegates to the configuration' do 56 | expect(schedule.holidays).to eq Biz::Configuration.new(&config).holidays 57 | end 58 | end 59 | 60 | describe '#time_zone' do 61 | it 'delegates to the configuration' do 62 | expect(schedule.time_zone).to eq Biz::Configuration.new(&config).time_zone 63 | end 64 | end 65 | 66 | describe '#periods' do 67 | let(:hours) { {mon: {'01:00' => '02:00'}} } 68 | 69 | it 'returns periods for the schedule' do 70 | expect( 71 | schedule.periods.after(Time.utc(2006, 1, 1)).first 72 | ).to eq( 73 | Biz::TimeSegment.new(Time.utc(2006, 1, 2, 1), Time.utc(2006, 1, 2, 2)) 74 | ) 75 | end 76 | end 77 | 78 | describe '#dates' do 79 | let(:hours) { {mon: {'09:00' => '17:00'}, fri: {'09:00' => '17:00'}} } 80 | 81 | it 'returns dates for the schedule' do 82 | expect(schedule.dates.after(Date.new(2006, 1, 1)).take(2).to_a).to eq [ 83 | Date.new(2006, 1, 2), 84 | Date.new(2006, 1, 6) 85 | ] 86 | end 87 | end 88 | 89 | describe '#time' do 90 | it 'returns the time after an amount of elapsed business time' do 91 | expect(schedule.time(30, :minutes).after(Time.utc(2006, 1, 2, 9))).to eq( 92 | Time.utc(2006, 1, 2, 9, 30) 93 | ) 94 | end 95 | end 96 | 97 | describe '#within' do 98 | it 'returns the amount of elapsed business time between two times' do 99 | expect( 100 | schedule.within(Time.utc(2006, 1, 5, 12), Time.utc(2006, 1, 6, 12)) 101 | ).to eq Biz::Duration.hours(7) 102 | end 103 | end 104 | 105 | describe '#in_hours?' do 106 | context 'when the time is not in business hours' do 107 | let(:time) { Time.utc(2006, 1, 2, 8) } 108 | 109 | it 'returns false' do 110 | expect(schedule.in_hours?(time)).to eq false 111 | end 112 | end 113 | 114 | context 'when the time is in business hours' do 115 | let(:time) { Time.utc(2006, 1, 2, 12) } 116 | 117 | it 'returns true' do 118 | expect(schedule.in_hours?(time)).to eq true 119 | end 120 | end 121 | end 122 | 123 | describe '#business_hours?' do 124 | context 'when the time is not in business hours' do 125 | let(:time) { Time.utc(2006, 1, 2, 8) } 126 | 127 | it 'returns false' do 128 | expect(schedule.business_hours?(time)).to eq false 129 | end 130 | end 131 | 132 | context 'when the time is in business hours' do 133 | let(:time) { Time.utc(2006, 1, 2, 12) } 134 | 135 | it 'returns true' do 136 | expect(schedule.business_hours?(time)).to eq true 137 | end 138 | end 139 | end 140 | 141 | describe '#on_break?' do 142 | context 'when the time is during a break' do 143 | let(:time) { Time.utc(2006, 1, 2, 11) } 144 | 145 | it 'returns true' do 146 | expect(schedule.on_break?(time)).to eq true 147 | end 148 | end 149 | 150 | context 'when the time is not during a break' do 151 | let(:time) { Time.utc(2006, 1, 2, 13) } 152 | 153 | it 'returns false' do 154 | expect(schedule.on_break?(time)).to eq false 155 | end 156 | end 157 | end 158 | 159 | describe '#on_holiday?' do 160 | context 'when the time is during a holiday' do 161 | let(:time) { Time.utc(2006, 12, 25, 12) } 162 | 163 | it 'returns true' do 164 | expect(schedule.on_holiday?(time)).to eq true 165 | end 166 | end 167 | 168 | context 'when the time is not during a holiday' do 169 | let(:time) { Time.utc(2006, 12, 26, 12) } 170 | 171 | it 'returns false' do 172 | expect(schedule.on_holiday?(time)).to eq false 173 | end 174 | end 175 | end 176 | 177 | describe '#in_zone' do 178 | let(:time_zone) { 'America/Los_Angeles' } 179 | let(:time) { Time.utc(2006, 1, 1, 10) } 180 | 181 | it 'returns an equivalent time' do 182 | expect(schedule.in_zone.local(time)).to eq time 183 | end 184 | 185 | it 'returns a time with the specified time zone' do 186 | expect(schedule.in_zone.local(time).zone).to eq 'PST' 187 | end 188 | end 189 | 190 | describe '#&' do 191 | let(:other) { 192 | described_class.new do |config| 193 | config.hours = { 194 | sun: {'10:00' => '12:00'}, 195 | mon: {'08:00' => '10:00'}, 196 | tue: {'11:00' => '15:00'}, 197 | wed: {'16:00' => '18:00'}, 198 | thu: {'11:00' => '12:00', '13:00' => '14:00'} 199 | } 200 | 201 | config.breaks = {Date.new(2006, 1, 3) => {'11:15' => '11:45'}} 202 | 203 | config.holidays = [ 204 | Date.new(2006, 1, 1), 205 | Date.new(2006, 7, 4), 206 | Date.new(2006, 11, 24) 207 | ] 208 | 209 | config.time_zone = 'America/Los_Angeles' 210 | end 211 | } 212 | 213 | it 'returns an intersected schedule' do 214 | expect( 215 | (schedule & other).periods.after(Time.utc(2006, 1, 1)).take(3).to_a 216 | ).to eq [ 217 | Biz::TimeSegment.new( 218 | Time.utc(2006, 1, 2, 9), 219 | Time.utc(2006, 1, 2, 10) 220 | ), 221 | Biz::TimeSegment.new( 222 | Time.utc(2006, 1, 3, 11), 223 | Time.utc(2006, 1, 3, 11, 15) 224 | ), 225 | Biz::TimeSegment.new( 226 | Time.utc(2006, 1, 3, 11, 45), 227 | Time.utc(2006, 1, 3, 14, 15) 228 | ) 229 | ] 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /benchmark/performance: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # frozen_string_literal: true 4 | 5 | require 'bundler/setup' 6 | 7 | require 'benchmark/ipsa' 8 | 9 | require 'biz' 10 | require 'business_time' 11 | require 'working_hours/module' 12 | 13 | def benchmark(description) 14 | puts "Benchmark: #{description}" 15 | end 16 | 17 | holidays = [ 18 | Date.new(2015, 1, 1), 19 | *(Date.new(2015, 3, 22)..Date.new(2015, 3, 28)), 20 | Date.new(2015, 12, 25) 21 | ] 22 | 23 | Biz.configure do |config| 24 | config.hours = { 25 | mon: {'09:00' => '17:00'}, 26 | tue: {'09:00' => '17:00'}, 27 | wed: {'09:00' => '17:00'}, 28 | thu: {'09:00' => '17:00'}, 29 | fri: {'09:00' => '17:00'}, 30 | sat: {'10:00' => '14:00'} 31 | } 32 | 33 | config.holidays = holidays 34 | 35 | config.time_zone = 'Etc/UTC' 36 | end 37 | 38 | BusinessTime::Config.tap do |config| 39 | config.work_hours = { 40 | mon: ['09:00', '17:00'], 41 | tue: ['09:00', '17:00'], 42 | wed: ['09:00', '17:00'], 43 | thu: ['09:00', '17:00'], 44 | fri: ['09:00', '17:00'], 45 | sat: ['10:00', '14:00'] 46 | } 47 | 48 | holidays.each do |holiday| config.holidays << holiday end 49 | end 50 | 51 | WorkingHours::Config.tap do |config| 52 | config.working_hours = { 53 | mon: {'09:00' => '17:00'}, 54 | tue: {'09:00' => '17:00'}, 55 | wed: {'09:00' => '17:00'}, 56 | thu: {'09:00' => '17:00'}, 57 | fri: {'09:00' => '17:00'}, 58 | sat: {'10:00' => '14:00'} 59 | } 60 | 61 | config.holidays = holidays 62 | 63 | config.time_zone = 'UTC' 64 | end 65 | 66 | benchmark 'Return time one business hour after origin' 67 | Benchmark.ipsa do |bm| 68 | origin = Time.utc(2006, 1, 2, 9, 30) 69 | 70 | bm.report 'biz' do 71 | Biz.time(1, :hour).after(origin) 72 | end 73 | 74 | bm.report 'business_time' do 75 | 1.business_hour.after(origin) 76 | end 77 | 78 | bm.report 'working_hours' do 79 | WorkingHours::Duration.new(1, :hours).since(origin) 80 | end 81 | 82 | bm.compare! 83 | end 84 | 85 | benchmark 'Return time 100 business hours after origin' 86 | Benchmark.ipsa do |bm| 87 | origin = Time.utc(2006, 1, 2, 9, 30) 88 | 89 | bm.report 'biz' do 90 | Biz.time(100, :hours).after(origin) 91 | end 92 | 93 | bm.report 'business_time' do 94 | 100.business_hour.after(origin) 95 | end 96 | 97 | bm.report 'working_hours' do 98 | WorkingHours::Duration.new(100, :hours).since(origin) 99 | end 100 | 101 | bm.compare! 102 | end 103 | 104 | benchmark 'Return time 10,000 business hours after origin' 105 | Benchmark.ipsa do |bm| 106 | origin = Time.utc(2006, 1, 2, 9, 30) 107 | 108 | bm.report 'biz' do 109 | Biz.time(10_000, :hours).after(origin) 110 | end 111 | 112 | bm.report 'business_time' do 113 | 10_000.business_hour.after(origin) 114 | end 115 | 116 | bm.report 'working_hours' do 117 | WorkingHours::Duration.new(10_000, :hours).since(origin) 118 | end 119 | 120 | bm.compare! 121 | end 122 | 123 | benchmark 'Return time one business hour before origin' 124 | Benchmark.ipsa do |bm| 125 | origin = Time.utc(2006, 1, 2, 9, 30) 126 | 127 | bm.report 'biz' do 128 | Biz.time(1, :hour).before(origin) 129 | end 130 | 131 | bm.report 'business_time' do 132 | 1.business_hour.before(origin) 133 | end 134 | 135 | bm.report 'working_hours' do 136 | WorkingHours::Duration.new(1, :hours).until(origin) 137 | end 138 | 139 | bm.compare! 140 | end 141 | 142 | benchmark 'Return time 100 business hours before origin' 143 | Benchmark.ipsa do |bm| 144 | origin = Time.utc(2006, 1, 2, 9, 30) 145 | 146 | bm.report 'biz' do 147 | Biz.time(100, :hours).before(origin) 148 | end 149 | 150 | bm.report 'business_time' do 151 | 100.business_hour.before(origin) 152 | end 153 | 154 | bm.report 'working_hours' do 155 | WorkingHours::Duration.new(100, :hours).until(origin) 156 | end 157 | 158 | bm.compare! 159 | end 160 | 161 | benchmark 'Return time 10,000 business hours before origin' 162 | Benchmark.ipsa do |bm| 163 | origin = Time.utc(2006, 1, 2, 9, 30) 164 | 165 | bm.report 'biz' do 166 | Biz.time(10_000, :hours).before(origin) 167 | end 168 | 169 | bm.report 'business_time' do 170 | 10_000.business_hour.before(origin) 171 | end 172 | 173 | bm.report 'working_hours' do 174 | WorkingHours::Duration.new(10_000, :hours).until(origin) 175 | end 176 | 177 | bm.compare! 178 | end 179 | 180 | benchmark 'Return amount of business time between two times one hour apart' 181 | Benchmark.ipsa do |bm| 182 | origin = Time.utc(2006, 1, 2, 9) 183 | terminus = Time.utc(2006, 1, 2, 10) 184 | 185 | bm.report 'biz' do 186 | Biz.within(origin, terminus).in_seconds 187 | end 188 | 189 | bm.report 'business_time' do 190 | origin.business_time_until(terminus).to_i 191 | end 192 | 193 | bm.report 'working_hours' do 194 | WorkingHours.working_time_between(origin, terminus).to_i 195 | end 196 | 197 | bm.compare! 198 | end 199 | 200 | benchmark 'Return amount of business time between two times one week apart' 201 | Benchmark.ipsa do |bm| 202 | origin = Time.utc(2006, 1, 2, 9) 203 | terminus = Time.utc(2006, 1, 9, 9) 204 | 205 | bm.report 'biz' do 206 | Biz.within(origin, terminus).in_seconds 207 | end 208 | 209 | bm.report 'business_time' do 210 | origin.business_time_until(terminus).to_i 211 | end 212 | 213 | bm.report 'working_hours' do 214 | WorkingHours.working_time_between(origin, terminus).to_i 215 | end 216 | 217 | bm.compare! 218 | end 219 | 220 | benchmark 'Return amount of business time between two times one year apart' 221 | Benchmark.ipsa do |bm| 222 | origin = Time.utc(2006, 1, 2, 9) 223 | terminus = Time.utc(2006, 7, 2, 9) 224 | 225 | bm.report 'biz' do 226 | Biz.within(origin, terminus).in_seconds 227 | end 228 | 229 | bm.report 'business_time' do 230 | origin.business_time_until(terminus).to_i 231 | end 232 | 233 | bm.report 'working_hours' do 234 | WorkingHours.working_time_between(origin, terminus).to_i 235 | end 236 | 237 | bm.compare! 238 | end 239 | 240 | benchmark 'Check if an in-business-hours time is in business hours' 241 | Benchmark.ipsa do |bm| 242 | time = Time.utc(2006, 1, 4, 12) 243 | 244 | bm.report 'biz' do 245 | Biz.in_hours?(time) 246 | end 247 | 248 | bm.report 'business_time' do 249 | time.workday? \ 250 | && !Time.before_business_hours?(time) \ 251 | && !Time.after_business_hours?(time) 252 | end 253 | 254 | bm.report 'working_hours' do 255 | WorkingHours.in_working_hours?(time) 256 | end 257 | 258 | bm.compare! 259 | end 260 | 261 | benchmark 'Check if an out-of-business-hours time is in business hours' 262 | Benchmark.ipsa do |bm| 263 | time = Time.utc(2006, 1, 4, 6) 264 | 265 | bm.report 'biz' do 266 | Biz.in_hours?(time) 267 | end 268 | 269 | bm.report 'business_time' do 270 | time.workday? \ 271 | && !Time.before_business_hours?(time) \ 272 | && !Time.after_business_hours?(time) 273 | end 274 | 275 | bm.report 'working_hours' do 276 | WorkingHours.in_working_hours?(time) 277 | end 278 | 279 | bm.compare! 280 | end 281 | -------------------------------------------------------------------------------- /spec/day_time_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::DayTime do 4 | subject(:day_time) { 5 | described_class.new(day_second(hour: 9, min: 53, sec: 27)) 6 | } 7 | 8 | context 'when initializing' do 9 | context 'with a valid integer-like value' do 10 | it 'is successful' do 11 | expect { described_class.new('1') }.not_to raise_error 12 | end 13 | end 14 | 15 | context 'with an invalid integer-like value' do 16 | it 'fails hard' do 17 | expect { described_class.new('1one') }.to raise_error ArgumentError 18 | end 19 | end 20 | 21 | context 'with a non-integer value' do 22 | it 'fails hard' do 23 | expect { described_class.new([]) }.to raise_error TypeError 24 | end 25 | end 26 | 27 | context 'with a negative value' do 28 | it 'fails hard' do 29 | expect { described_class.new(-1) }.to raise_error ArgumentError 30 | end 31 | end 32 | 33 | context 'when a zero value' do 34 | it 'is successful' do 35 | expect { described_class.new(0).day_second }.not_to raise_error 36 | end 37 | end 38 | 39 | context 'when the value is the number of seconds in a day' do 40 | it 'is successful' do 41 | expect { described_class.new(Biz::Time.day_seconds) }.not_to raise_error 42 | end 43 | end 44 | 45 | context 'when the value is more than the number of seconds in a day' do 46 | it 'fails hard' do 47 | expect { 48 | described_class.new(Biz::Time.day_seconds + 1) 49 | }.to raise_error ArgumentError 50 | end 51 | end 52 | end 53 | 54 | describe '.from_time' do 55 | let(:time) { Time.utc(2006, 1, 1, 9, 38, 47) } 56 | 57 | it 'creates a day time from the given time' do 58 | expect(described_class.from_time(time)).to eq( 59 | described_class.new(day_second(hour: 9, min: 38, sec: 47)) 60 | ) 61 | end 62 | end 63 | 64 | describe '.from_hour' do 65 | it 'creates a day time from the given hour' do 66 | expect(described_class.from_hour(9)).to eq( 67 | described_class.new(day_second(hour: 9)) 68 | ) 69 | end 70 | end 71 | 72 | describe '.from_minute' do 73 | it 'creates a day time from the given from' do 74 | expect(described_class.from_minute(day_minute(hour: 9, min: 10))).to eq( 75 | described_class.new(day_second(hour: 9, min: 10)) 76 | ) 77 | end 78 | end 79 | 80 | describe '.from_timestamp' do 81 | context 'when the timestamp is not a timestamp' do 82 | let(:timestamp) { 'timestamp' } 83 | 84 | it 'raises a configuration error' do 85 | expect { 86 | described_class.from_timestamp(timestamp) 87 | }.to raise_error Biz::Error::Configuration 88 | end 89 | end 90 | 91 | context 'when the timestamp is in `H:MM` format' do 92 | let(:timestamp) { '5:35' } 93 | 94 | it 'raises a configuration error' do 95 | expect { 96 | described_class.from_timestamp(timestamp) 97 | }.to raise_error Biz::Error::Configuration 98 | end 99 | end 100 | 101 | context 'when the timestamp is in `HH:M` format' do 102 | let(:timestamp) { '12:3' } 103 | 104 | it 'raises a configuration error' do 105 | expect { 106 | described_class.from_timestamp(timestamp) 107 | }.to raise_error Biz::Error::Configuration 108 | end 109 | end 110 | 111 | context 'when the timestamp is in `HH:MM:S` format' do 112 | let(:timestamp) { '11:35:3' } 113 | 114 | it 'raises a configuration error' do 115 | expect { 116 | described_class.from_timestamp(timestamp) 117 | }.to raise_error Biz::Error::Configuration 118 | end 119 | end 120 | 121 | context 'when the timestamp is in `HH:MM` format' do 122 | let(:timestamp) { '21:43' } 123 | 124 | it 'returns the appropriate day time' do 125 | expect(described_class.from_timestamp(timestamp)).to eq( 126 | described_class.new(day_second(hour: 21, min: 43)) 127 | ) 128 | end 129 | end 130 | 131 | context 'when the timestamp is in `HH:MM:SS` format' do 132 | let(:timestamp) { '10:55:23' } 133 | 134 | it 'returns the appropriate day time' do 135 | expect(described_class.from_timestamp(timestamp)).to eq( 136 | described_class.new(day_second(hour: 10, min: 55, sec: 23)) 137 | ) 138 | end 139 | end 140 | end 141 | 142 | describe '.midnight' do 143 | it 'creates a day time that represents midnight' do 144 | expect(described_class.midnight).to eq( 145 | described_class.new(day_second(hour: 0)) 146 | ) 147 | end 148 | end 149 | 150 | describe '.endnight' do 151 | it 'creates a day time that represents the end-of-day midnight' do 152 | expect(described_class.endnight).to eq( 153 | described_class.new(day_second(hour: 24)) 154 | ) 155 | end 156 | end 157 | 158 | describe '#day_second' do 159 | it 'returns the number of seconds into the day' do 160 | expect(day_time.day_second).to eq day_second(hour: 9, min: 53, sec: 27) 161 | end 162 | end 163 | 164 | describe '#hour' do 165 | it 'returns the hour' do 166 | expect(day_time.hour).to eq 9 167 | end 168 | end 169 | 170 | describe '#minute' do 171 | it 'returns the minute' do 172 | expect(day_time.minute).to eq 53 173 | end 174 | end 175 | 176 | describe '#second' do 177 | it 'returns the second' do 178 | expect(day_time.second).to eq 27 179 | end 180 | end 181 | 182 | describe '#day_minute' do 183 | it 'returns the number of minutes into the day' do 184 | expect(day_time.day_minute).to eq 593 185 | end 186 | end 187 | 188 | describe '#for_dst' do 189 | context 'when the day time is midnight' do 190 | let(:day_time) { Biz::DayTime.midnight } 191 | 192 | it 'returns a day time one hour later' do 193 | expect(day_time.for_dst).to eq described_class.new(day_second(hour: 1)) 194 | end 195 | end 196 | 197 | context 'when the day time is noon' do 198 | let(:day_time) { Biz::DayTime.new(day_second(hour: 12)) } 199 | 200 | it 'returns a day time one hour later' do 201 | expect(day_time.for_dst).to eq described_class.new(day_second(hour: 13)) 202 | end 203 | end 204 | 205 | context 'when the day time is one hour before endnight' do 206 | let(:day_time) { Biz::DayTime.new(day_second(hour: 23)) } 207 | 208 | it 'returns a midnight day time' do 209 | expect(day_time.for_dst).to eq described_class.midnight 210 | end 211 | end 212 | 213 | context 'when the day time is less than one hour before endnight' do 214 | let(:day_time) { Biz::DayTime.new(day_second(hour: 23, min: 40)) } 215 | 216 | it 'returns a day time just after midnight' do 217 | expect(day_time.for_dst).to eq( 218 | described_class.new(day_second(hour: 0, min: 40)) 219 | ) 220 | end 221 | end 222 | 223 | context 'when the day time is endnight' do 224 | let(:day_time) { Biz::DayTime.endnight } 225 | 226 | it 'returns a day time one hour after midnight' do 227 | expect(day_time.for_dst).to eq described_class.new(day_second(hour: 1)) 228 | end 229 | end 230 | end 231 | 232 | describe '#timestamp' do 233 | context 'when the hour and minute are single-digit values' do 234 | subject(:day_time) { described_class.new(day_second(hour: 4, min: 3)) } 235 | 236 | it 'returns a zero-padded timestamp' do 237 | expect(day_time.timestamp).to eq '04:03' 238 | end 239 | end 240 | 241 | context 'when the hour and minute are double-digit values' do 242 | subject(:day_time) { described_class.new(day_second(hour: 15, min: 27)) } 243 | 244 | it 'returns a correctly formatted timestamp' do 245 | expect(day_time.timestamp).to eq '15:27' 246 | end 247 | end 248 | end 249 | 250 | context 'when performing comparison' do 251 | context 'and the compared object is an earlier day time' do 252 | let(:other) { described_class.new(day_second(hour: 9, min: 53, sec: 26)) } 253 | 254 | it 'compares as expected' do 255 | expect(day_time > other).to eq true 256 | end 257 | end 258 | 259 | context 'and the compared object is the same day time' do 260 | let(:other) { described_class.new(day_second(hour: 9, min: 53, sec: 27)) } 261 | 262 | it 'compares as expected' do 263 | expect(day_time == other).to eq true 264 | end 265 | end 266 | 267 | context 'and the other object is a later day time' do 268 | let(:other) { described_class.new(day_second(hour: 9, min: 53, sec: 28)) } 269 | 270 | it 'compares as expected' do 271 | expect(day_time < other).to eq true 272 | end 273 | end 274 | 275 | context 'and the compared object is not a day time' do 276 | let(:other) { 1 } 277 | 278 | it 'is not comparable' do 279 | expect { day_time < other }.to raise_error ArgumentError 280 | end 281 | end 282 | end 283 | end 284 | -------------------------------------------------------------------------------- /spec/calculation/for_duration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Calculation::ForDuration do 4 | context 'when initializing' do 5 | context 'with a valid integer-like scalar' do 6 | it 'is successful' do 7 | expect { described_class.new(schedule, '1') }.not_to raise_error 8 | end 9 | end 10 | 11 | context 'with an invalid integer-like scalar' do 12 | it 'fails hard' do 13 | expect { 14 | described_class.new(schedule, '1one') 15 | }.to raise_error ArgumentError 16 | end 17 | end 18 | 19 | context 'with a non-integer scalar' do 20 | it 'fails hard' do 21 | expect { described_class.new(schedule, []) }.to raise_error TypeError 22 | end 23 | end 24 | 25 | context 'with a negative scalar' do 26 | it 'fails hard' do 27 | expect { 28 | described_class.new(schedule, -1) 29 | }.to raise_error ArgumentError 30 | end 31 | end 32 | 33 | context 'with a zero scalar' do 34 | it 'is successful' do 35 | expect { described_class.new(schedule, 0) }.not_to raise_error 36 | end 37 | end 38 | 39 | context 'with a positive scalar' do 40 | it 'is successful' do 41 | expect { described_class.new(schedule, 1) }.not_to raise_error 42 | end 43 | end 44 | end 45 | 46 | describe '.units' do 47 | it 'returns the supported units' do 48 | expect(described_class.units).to eq( 49 | %i[second seconds minute minutes hour hours day days] 50 | ) 51 | end 52 | end 53 | 54 | describe '.with_unit' do 55 | context 'when called with a supported unit' do 56 | it 'returns a calculation with the unit' do 57 | expect( 58 | described_class 59 | .with_unit(schedule, 1, :hour) 60 | .after(Time.utc(2006, 1, 2, 10)) 61 | ).to eq( 62 | described_class.hours(schedule, 1).after(Time.utc(2006, 1, 2, 10)) 63 | ) 64 | end 65 | end 66 | 67 | context 'when called with an unsupported unit' do 68 | it 'fails hard' do 69 | expect { 70 | described_class.with_unit(schedule, 1, :parsec) 71 | }.to raise_error ArgumentError 72 | end 73 | end 74 | end 75 | 76 | %i[second seconds].each do |unit| 77 | describe ".#{unit}" do 78 | let(:scalar) { 90 } 79 | 80 | subject(:calculation) { described_class.send(unit, schedule, scalar) } 81 | 82 | describe '#before' do 83 | let(:time) { Time.utc(2006, 1, 4, 16, 1, 30) } 84 | 85 | it 'returns the backward time after the elapsed duration' do 86 | expect(calculation.before(time)).to eq Time.utc(2006, 1, 4, 16) 87 | end 88 | 89 | context 'when the scalar is zero' do 90 | let(:scalar) { 0 } 91 | let(:time) { Time.utc(2006, 1, 3) } 92 | 93 | it 'returns the first active moment backward in time' do 94 | expect(calculation.before(time)).to eq Time.utc(2006, 1, 2, 17) 95 | end 96 | end 97 | end 98 | 99 | describe '#after' do 100 | let(:time) { Time.utc(2006, 1, 4, 15, 58, 30) } 101 | 102 | it 'returns the forward time after the elapsed duration' do 103 | expect(calculation.after(time)).to eq Time.utc(2006, 1, 4, 16) 104 | end 105 | 106 | context 'when the scalar is zero' do 107 | let(:scalar) { 0 } 108 | let(:time) { Time.utc(2006, 1, 3) } 109 | 110 | it 'returns the first active moment forward in time' do 111 | expect(calculation.after(time)).to eq Time.utc(2006, 1, 3, 10) 112 | end 113 | end 114 | end 115 | end 116 | end 117 | 118 | %i[minute minutes].each do |unit| 119 | describe ".#{unit}" do 120 | let(:scalar) { 90 } 121 | 122 | subject(:calculation) { described_class.send(unit, schedule, scalar) } 123 | 124 | describe '#before' do 125 | let(:time) { Time.utc(2006, 1, 4, 16, 30) } 126 | 127 | it 'returns the backward time after the elapsed duration' do 128 | expect(calculation.before(time)).to eq Time.utc(2006, 1, 4, 15) 129 | end 130 | 131 | context 'when the scalar is zero' do 132 | let(:scalar) { 0 } 133 | let(:time) { Time.utc(2006, 1, 3) } 134 | 135 | it 'returns the first active moment backward in time' do 136 | expect(calculation.before(time)).to eq Time.utc(2006, 1, 2, 17) 137 | end 138 | end 139 | end 140 | 141 | describe '#after' do 142 | let(:time) { Time.utc(2006, 1, 4, 14, 30) } 143 | 144 | it 'returns the forward time after the elapsed duration' do 145 | expect(calculation.after(time)).to eq Time.utc(2006, 1, 4, 16) 146 | end 147 | 148 | context 'when the scalar is zero' do 149 | let(:scalar) { 0 } 150 | let(:time) { Time.utc(2006, 1, 3) } 151 | 152 | it 'returns the first active moment forward in time' do 153 | expect(calculation.after(time)).to eq Time.utc(2006, 1, 3, 10) 154 | end 155 | end 156 | end 157 | end 158 | end 159 | 160 | %i[hour hours].each do |unit| 161 | describe ".#{unit}" do 162 | let(:scalar) { 3 } 163 | 164 | subject(:calculation) { described_class.send(unit, schedule, scalar) } 165 | 166 | describe '#before' do 167 | let(:time) { Time.utc(2006, 1, 4, 17) } 168 | 169 | it 'returns the backward time after the elapsed duration' do 170 | expect(calculation.before(time)).to eq Time.utc(2006, 1, 4, 14) 171 | end 172 | 173 | context 'when the scalar is zero' do 174 | let(:scalar) { 0 } 175 | let(:time) { Time.utc(2006, 1, 3) } 176 | 177 | it 'returns the first active moment backward in time' do 178 | expect(calculation.before(time)).to eq Time.utc(2006, 1, 2, 17) 179 | end 180 | end 181 | end 182 | 183 | describe '#after' do 184 | let(:time) { Time.utc(2006, 1, 4, 13) } 185 | 186 | it 'returns the forward time after the elapsed duration' do 187 | expect(calculation.after(time)).to eq Time.utc(2006, 1, 4, 16) 188 | end 189 | 190 | context 'when the scalar is zero' do 191 | let(:scalar) { 0 } 192 | let(:time) { Time.utc(2006, 1, 3) } 193 | 194 | it 'returns the first active moment forward in time' do 195 | expect(calculation.after(time)).to eq Time.utc(2006, 1, 3, 10) 196 | end 197 | end 198 | end 199 | end 200 | end 201 | 202 | %i[day days].each do |unit| 203 | describe ".#{unit}" do 204 | let(:scalar) { 2 } 205 | 206 | subject(:calculation) { described_class.send(unit, schedule, scalar) } 207 | 208 | describe '#before' do 209 | context 'when the advanced time is within a period' do 210 | let(:time) { Time.utc(2006, 1, 9, 12) } 211 | 212 | it 'returns the time advanced by the number of business days' do 213 | expect(calculation.before(time)).to eq Time.utc(2006, 1, 6, 12) 214 | end 215 | end 216 | 217 | context 'when the advanced time is before the first day period' do 218 | let(:time) { Time.utc(2006, 1, 5, 8) } 219 | 220 | it 'returns the next time before the advanced time' do 221 | expect(calculation.before(time)).to eq Time.utc(2006, 1, 2, 17) 222 | end 223 | end 224 | 225 | context 'when the advanced time is after the last day period' do 226 | let(:time) { Time.utc(2006, 1, 5, 18) } 227 | 228 | it 'returns the next time before the advanced time' do 229 | expect(calculation.before(time)).to eq Time.utc(2006, 1, 3, 16) 230 | end 231 | end 232 | 233 | context 'when the time has second precision' do 234 | let(:time) { Time.utc(2006, 1, 9, 12, 30, 52) } 235 | 236 | it 'retains second precision' do 237 | expect(calculation.before(time)).to eq( 238 | Time.utc(2006, 1, 6, 12, 30, 52) 239 | ) 240 | end 241 | end 242 | 243 | context 'when the scalar is zero' do 244 | let(:scalar) { 0 } 245 | let(:time) { Time.utc(2006, 1, 2, 14) } 246 | 247 | it 'returns the first active moment backward in time' do 248 | expect(calculation.before(time)).to eq Time.utc(2006, 1, 2, 14) 249 | end 250 | end 251 | end 252 | 253 | describe '#after' do 254 | context 'when the advanced time is within a period' do 255 | let(:time) { Time.utc(2006, 1, 6, 12) } 256 | 257 | it 'returns the time advanced by the number of business days' do 258 | expect(calculation.after(time)).to eq Time.utc(2006, 1, 9, 12) 259 | end 260 | end 261 | 262 | context 'when the advanced time is before the first day period' do 263 | let(:time) { Time.utc(2006, 1, 3, 8) } 264 | 265 | it 'returns the next time after the advanced time' do 266 | expect(calculation.after(time)).to eq Time.utc(2006, 1, 5, 10) 267 | end 268 | end 269 | 270 | context 'when the advanced time is after the last day period' do 271 | let(:time) { Time.utc(2006, 1, 3, 18) } 272 | 273 | it 'returns the next time after the advanced time' do 274 | expect(calculation.after(time)).to eq Time.utc(2006, 1, 6, 9) 275 | end 276 | end 277 | 278 | context 'when the time has second precision' do 279 | let(:time) { Time.utc(2006, 1, 6, 12, 30, 52) } 280 | 281 | it 'retains second precision' do 282 | expect(calculation.after(time)).to eq( 283 | Time.utc(2006, 1, 9, 12, 30, 52) 284 | ) 285 | end 286 | end 287 | 288 | context 'when the scalar is zero' do 289 | let(:scalar) { 0 } 290 | let(:time) { Time.utc(2006, 1, 2, 13) } 291 | 292 | it 'returns the first active moment forward in time' do 293 | expect(calculation.after(time)).to eq Time.utc(2006, 1, 2, 13) 294 | end 295 | end 296 | end 297 | end 298 | end 299 | end 300 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /spec/periods/before_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Periods::Before do 4 | let(:hours) { 5 | { 6 | mon: {'09:00' => '17:00'}, 7 | tue: {'10:00' => '16:00'}, 8 | wed: {'09:00' => '17:00'}, 9 | thu: {'10:00' => '16:00'}, 10 | fri: {'09:00' => '17:00'}, 11 | sat: {'11:00' => '14:30'} 12 | } 13 | } 14 | 15 | let(:shifts) { 16 | { 17 | Date.new(2006, 1, 4) => {'09:00' => '10:00'}, 18 | Date.new(2006, 1, 5) => {'08:00' => '12:00'}, 19 | Date.new(2006, 1, 31) => {'06:00' => '20:00'} 20 | } 21 | } 22 | 23 | let(:breaks) { {} } 24 | let(:holidays) { [Date.new(2006, 1, 16), Date.new(2006, 1, 18)] } 25 | let(:time_zone) { 'Etc/UTC' } 26 | let(:origin) { Time.utc(2006, 1, 8) } 27 | 28 | subject(:periods) { 29 | described_class.new( 30 | schedule( 31 | hours: hours, 32 | shifts: shifts, 33 | breaks: breaks, 34 | holidays: holidays, 35 | time_zone: time_zone 36 | ), 37 | origin 38 | ) 39 | } 40 | 41 | context 'when one week of periods is requested' do 42 | let(:origin) { Time.utc(2006, 1, 8) } 43 | 44 | it 'returns the proper intervals' do 45 | expect(periods.take(6).to_a).to eq [ 46 | Biz::TimeSegment.new( 47 | Time.utc(2006, 1, 7, 11), 48 | Time.utc(2006, 1, 7, 14, 30) 49 | ), 50 | Biz::TimeSegment.new( 51 | Time.utc(2006, 1, 6, 9), 52 | Time.utc(2006, 1, 6, 17) 53 | ), 54 | Biz::TimeSegment.new( 55 | Time.utc(2006, 1, 5, 8), 56 | Time.utc(2006, 1, 5, 12) 57 | ), 58 | Biz::TimeSegment.new( 59 | Time.utc(2006, 1, 4, 9), 60 | Time.utc(2006, 1, 4, 10) 61 | ), 62 | Biz::TimeSegment.new( 63 | Time.utc(2006, 1, 3, 10), 64 | Time.utc(2006, 1, 3, 16) 65 | ), 66 | Biz::TimeSegment.new( 67 | Time.utc(2006, 1, 2, 9), 68 | Time.utc(2006, 1, 2, 17) 69 | ) 70 | ] 71 | end 72 | end 73 | 74 | context 'when multiple weeks of periods are requested' do 75 | let(:origin) { Time.utc(2006, 1, 15) } 76 | 77 | it 'returns the proper intervals' do 78 | expect(periods.take(12).to_a).to eq [ 79 | Biz::TimeSegment.new( 80 | Time.utc(2006, 1, 14, 11), 81 | Time.utc(2006, 1, 14, 14, 30) 82 | ), 83 | Biz::TimeSegment.new( 84 | Time.utc(2006, 1, 13, 9), 85 | Time.utc(2006, 1, 13, 17) 86 | ), 87 | Biz::TimeSegment.new( 88 | Time.utc(2006, 1, 12, 10), 89 | Time.utc(2006, 1, 12, 16) 90 | ), 91 | Biz::TimeSegment.new( 92 | Time.utc(2006, 1, 11, 9), 93 | Time.utc(2006, 1, 11, 17) 94 | ), 95 | Biz::TimeSegment.new( 96 | Time.utc(2006, 1, 10, 10), 97 | Time.utc(2006, 1, 10, 16) 98 | ), 99 | Biz::TimeSegment.new( 100 | Time.utc(2006, 1, 9, 9), 101 | Time.utc(2006, 1, 9, 17) 102 | ), 103 | Biz::TimeSegment.new( 104 | Time.utc(2006, 1, 7, 11), 105 | Time.utc(2006, 1, 7, 14, 30) 106 | ), 107 | Biz::TimeSegment.new( 108 | Time.utc(2006, 1, 6, 9), 109 | Time.utc(2006, 1, 6, 17) 110 | ), 111 | Biz::TimeSegment.new( 112 | Time.utc(2006, 1, 5, 8), 113 | Time.utc(2006, 1, 5, 12) 114 | ), 115 | Biz::TimeSegment.new( 116 | Time.utc(2006, 1, 4, 9), 117 | Time.utc(2006, 1, 4, 10) 118 | ), 119 | Biz::TimeSegment.new( 120 | Time.utc(2006, 1, 3, 10), 121 | Time.utc(2006, 1, 3, 16) 122 | ), 123 | Biz::TimeSegment.new( 124 | Time.utc(2006, 1, 2, 9), 125 | Time.utc(2006, 1, 2, 17) 126 | ) 127 | ] 128 | end 129 | end 130 | 131 | context 'when the origin is outside a period' do 132 | let(:origin) { Time.utc(2006, 1, 3) } 133 | 134 | it 'returns a full period first' do 135 | expect(periods.first).to eq( 136 | Biz::TimeSegment.new(Time.utc(2006, 1, 2, 9), Time.utc(2006, 1, 2, 17)) 137 | ) 138 | end 139 | end 140 | 141 | context 'when the origin is inside a period' do 142 | let(:origin) { Time.utc(2006, 1, 2, 12) } 143 | 144 | it 'returns a partial period first' do 145 | expect(periods.first).to eq( 146 | Biz::TimeSegment.new(Time.utc(2006, 1, 2, 9), Time.utc(2006, 1, 2, 12)) 147 | ) 148 | end 149 | end 150 | 151 | context 'when a break overlaps with the beginning of a period' do 152 | let(:breaks) { {Date.new(2006, 1, 7) => {'14:00' => '15:00'}} } 153 | 154 | it 'excludes the overlapping time' do 155 | expect(periods.first).to eq( 156 | Biz::TimeSegment.new( 157 | Time.utc(2006, 1, 7, 11), 158 | Time.utc(2006, 1, 7, 14) 159 | ) 160 | ) 161 | end 162 | end 163 | 164 | context 'when a break overlaps with the end of a period' do 165 | let(:breaks) { {Date.new(2006, 1, 7) => {'10:00' => '12:00'}} } 166 | 167 | it 'excludes the overlapping time' do 168 | expect(periods.first).to eq( 169 | Biz::TimeSegment.new( 170 | Time.utc(2006, 1, 7, 12), 171 | Time.utc(2006, 1, 7, 14, 30) 172 | ) 173 | ) 174 | end 175 | end 176 | 177 | context 'when a break overlaps an entire period' do 178 | let(:breaks) { {Date.new(2006, 1, 7) => {'10:00' => '16:00'}} } 179 | 180 | it 'excludes that period' do 181 | expect(periods.first).to eq( 182 | Biz::TimeSegment.new( 183 | Time.utc(2006, 1, 6, 9), 184 | Time.utc(2006, 1, 6, 17) 185 | ) 186 | ) 187 | end 188 | end 189 | 190 | context 'when a break is in the middle of a period' do 191 | let(:breaks) { {Date.new(2006, 1, 7) => {'12:00' => '13:00'}} } 192 | 193 | it 'excludes the overlapping time' do 194 | expect(periods.take(2).to_a).to eq [ 195 | Biz::TimeSegment.new( 196 | Time.utc(2006, 1, 7, 13), 197 | Time.utc(2006, 1, 7, 14, 30) 198 | ), 199 | Biz::TimeSegment.new( 200 | Time.utc(2006, 1, 7, 11), 201 | Time.utc(2006, 1, 7, 12) 202 | ) 203 | ] 204 | end 205 | end 206 | 207 | context 'when multiple breaks are in the middle of a period' do 208 | let(:breaks) { 209 | {Date.new(2006, 1, 7) => {'11:30' => '12:00', '12:30' => '13:00'}} 210 | } 211 | 212 | it 'excludes the overlapping time' do 213 | expect(periods.take(3).to_a).to eq [ 214 | Biz::TimeSegment.new( 215 | Time.utc(2006, 1, 7, 13), 216 | Time.utc(2006, 1, 7, 14, 30) 217 | ), 218 | Biz::TimeSegment.new( 219 | Time.utc(2006, 1, 7, 12), 220 | Time.utc(2006, 1, 7, 12, 30) 221 | ), 222 | Biz::TimeSegment.new( 223 | Time.utc(2006, 1, 7, 11), 224 | Time.utc(2006, 1, 7, 11, 30) 225 | ) 226 | ] 227 | end 228 | end 229 | 230 | context 'when a break overlaps multiple periods' do 231 | let(:hours) { {sat: {'17:00' => '19:00', '20:00' => '22:00'}} } 232 | let(:breaks) { {Date.new(2006, 1, 7) => {'18:00' => '21:00'}} } 233 | 234 | it 'excludes the overlapping time' do 235 | expect(periods.take(2).to_a).to eq [ 236 | Biz::TimeSegment.new( 237 | Time.utc(2006, 1, 7, 21), 238 | Time.utc(2006, 1, 7, 22) 239 | ), 240 | Biz::TimeSegment.new( 241 | Time.utc(2006, 1, 7, 17), 242 | Time.utc(2006, 1, 7, 18) 243 | ) 244 | ] 245 | end 246 | end 247 | 248 | context 'when multiple breaks overlap multiple periods' do 249 | let(:breaks) { 250 | { 251 | Date.new(2006, 1, 6) => {'09:30' => '10:15', '11:30' => '12:30'}, 252 | Date.new(2006, 1, 7) => {'12:00' => '13:00', '14:25' => '14:40'} 253 | } 254 | } 255 | 256 | it 'excludes the overlapping time' do 257 | expect(periods.take(5).to_a).to eq [ 258 | Biz::TimeSegment.new( 259 | Time.utc(2006, 1, 7, 13), 260 | Time.utc(2006, 1, 7, 14, 25) 261 | ), 262 | Biz::TimeSegment.new( 263 | Time.utc(2006, 1, 7, 11), 264 | Time.utc(2006, 1, 7, 12) 265 | ), 266 | Biz::TimeSegment.new( 267 | Time.utc(2006, 1, 6, 12, 30), 268 | Time.utc(2006, 1, 6, 17) 269 | ), 270 | Biz::TimeSegment.new( 271 | Time.utc(2006, 1, 6, 10, 15), 272 | Time.utc(2006, 1, 6, 11, 30) 273 | ), 274 | Biz::TimeSegment.new( 275 | Time.utc(2006, 1, 6, 9), 276 | Time.utc(2006, 1, 6, 9, 30) 277 | ) 278 | ] 279 | end 280 | end 281 | 282 | context 'when a period during a holiday is encountered' do 283 | let(:origin) { Time.utc(2006, 1, 18) } 284 | 285 | it 'does not include that period' do 286 | expect(periods.take(2).to_a).to eq [ 287 | Biz::TimeSegment.new( 288 | Time.utc(2006, 1, 17, 10), 289 | Time.utc(2006, 1, 17, 16) 290 | ), 291 | Biz::TimeSegment.new( 292 | Time.utc(2006, 1, 14, 11), 293 | Time.utc(2006, 1, 14, 14, 30) 294 | ) 295 | ] 296 | end 297 | end 298 | 299 | context 'when multiple periods during holidays are encountered' do 300 | let(:origin) { Time.utc(2006, 1, 20) } 301 | 302 | it 'does not include any of those periods' do 303 | expect(periods.take(3).to_a).to eq [ 304 | Biz::TimeSegment.new( 305 | Time.utc(2006, 1, 19, 10), 306 | Time.utc(2006, 1, 19, 16) 307 | ), 308 | Biz::TimeSegment.new( 309 | Time.utc(2006, 1, 17, 10), 310 | Time.utc(2006, 1, 17, 16) 311 | ), 312 | Biz::TimeSegment.new( 313 | Time.utc(2006, 1, 14, 11), 314 | Time.utc(2006, 1, 14, 14, 30) 315 | ) 316 | ] 317 | end 318 | end 319 | 320 | context 'when the origin is near the end of the week' do 321 | let(:hours) { {sun: {'06:00' => '18:00'}} } 322 | let(:time_zone) { 'Asia/Brunei' } 323 | 324 | let(:origin) { in_zone('Asia/Brunei') { Time.utc(2006, 1, 8, 7) } } 325 | 326 | it 'includes the relevant interval from the prior week' do 327 | expect(periods.first).to eq( 328 | Biz::TimeSegment.new( 329 | in_zone('Asia/Brunei') { Time.utc(2006, 1, 8, 6) }, 330 | in_zone('Asia/Brunei') { Time.utc(2006, 1, 8, 7) } 331 | ) 332 | ) 333 | end 334 | end 335 | 336 | describe '#timeline' do 337 | let(:origin) { Time.utc(2006, 1, 4) } 338 | 339 | it 'creates a timeline using its periods' do 340 | expect( 341 | periods.timeline.until(Time.utc(2006, 1, 1)).to_a 342 | ).to eq [ 343 | Biz::TimeSegment.new( 344 | Time.utc(2006, 1, 3, 10), 345 | Time.utc(2006, 1, 3, 16) 346 | ), 347 | Biz::TimeSegment.new( 348 | Time.utc(2006, 1, 2, 9), 349 | Time.utc(2006, 1, 2, 17) 350 | ) 351 | ] 352 | end 353 | end 354 | end 355 | -------------------------------------------------------------------------------- /spec/time_segment_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::TimeSegment do 4 | let(:start_time) { Time.utc(2006, 1, 8, 9, 30) } 5 | let(:end_time) { Time.utc(2006, 1, 22, 17) } 6 | 7 | subject(:time_segment) { described_class.new(start_time, end_time) } 8 | 9 | describe '.before' do 10 | it 'returns the time segment before the provided time' do 11 | expect(described_class.before(start_time)).to eq( 12 | described_class.new(Biz::Time.big_bang, start_time) 13 | ) 14 | end 15 | end 16 | 17 | describe '.after' do 18 | it 'returns the time segment after the provided time' do 19 | expect(described_class.after(start_time)).to eq( 20 | described_class.new(start_time, Biz::Time.heat_death) 21 | ) 22 | end 23 | end 24 | 25 | describe '#duration' do 26 | it 'returns the duration of the time segment in seconds' do 27 | expect(time_segment.duration).to eq( 28 | Biz::Duration.new(end_time - start_time) 29 | ) 30 | end 31 | 32 | context 'when the time segment is disjoint' do 33 | let(:time_segment) { described_class.new(end_time, start_time) } 34 | 35 | it 'returns a zero duration' do 36 | expect(time_segment.duration).to eq Biz::Duration.new(0) 37 | end 38 | end 39 | end 40 | 41 | describe '#start_time' do 42 | it 'returns the start time' do 43 | expect(time_segment.start_time).to eq start_time 44 | end 45 | end 46 | 47 | describe '#end_time' do 48 | it 'returns the end time' do 49 | expect(time_segment.end_time).to eq end_time 50 | end 51 | end 52 | 53 | describe '#date' do 54 | it 'returns the date corresponding with the start time' do 55 | expect(time_segment.date).to eq Date.new(2006, 1, 8) 56 | end 57 | end 58 | 59 | describe '#endpoints' do 60 | it 'returns the endpoints' do 61 | expect(time_segment.endpoints).to eq [ 62 | time_segment.start_time, 63 | time_segment.end_time 64 | ] 65 | end 66 | end 67 | 68 | describe '#empty?' do 69 | context 'when the start time is before the end time' do 70 | let(:time_segment) { described_class.new(start_time, start_time + 1) } 71 | 72 | it 'returns false' do 73 | expect(time_segment.empty?).to eq false 74 | end 75 | end 76 | 77 | context 'when the start time is equal to the end time' do 78 | let(:time_segment) { described_class.new(start_time, start_time) } 79 | 80 | it 'returns true' do 81 | expect(time_segment.empty?).to eq true 82 | end 83 | end 84 | 85 | context 'when the start time is after the end time' do 86 | let(:time_segment) { described_class.new(end_time + 1, end_time) } 87 | 88 | it 'returns false' do 89 | expect(time_segment.empty?).to eq false 90 | end 91 | end 92 | end 93 | 94 | describe '#disjoint?' do 95 | context 'when the start time is before the end time' do 96 | let(:time_segment) { described_class.new(start_time, start_time + 1) } 97 | 98 | it 'returns false' do 99 | expect(time_segment.disjoint?).to eq false 100 | end 101 | end 102 | 103 | context 'when the start time is equal to the end time' do 104 | let(:time_segment) { described_class.new(start_time, start_time) } 105 | 106 | it 'returns false' do 107 | expect(time_segment.disjoint?).to eq false 108 | end 109 | end 110 | 111 | context 'when the start time is after the end time' do 112 | let(:time_segment) { described_class.new(end_time + 1, end_time) } 113 | 114 | it 'returns true' do 115 | expect(time_segment.disjoint?).to eq true 116 | end 117 | end 118 | end 119 | 120 | describe '#contains?' do 121 | context 'when the time is before the start time' do 122 | let(:time) { start_time - 1 } 123 | 124 | it 'returns false' do 125 | expect(time_segment.contains?(time)).to eq false 126 | end 127 | end 128 | 129 | context 'when the time equals the start time' do 130 | let(:time) { start_time } 131 | 132 | it 'returns true' do 133 | expect(time_segment.contains?(time)).to eq true 134 | end 135 | end 136 | 137 | context 'when the time is between the start and end times' do 138 | let(:time) { start_time + 1 } 139 | 140 | it 'returns true' do 141 | expect(time_segment.contains?(time)).to eq true 142 | end 143 | end 144 | 145 | context 'when the time equals the end time' do 146 | let(:time) { end_time } 147 | 148 | it 'returns false' do 149 | expect(time_segment.contains?(time)).to eq false 150 | end 151 | end 152 | 153 | context 'when the time is after the end time' do 154 | let(:time) { end_time + 1 } 155 | 156 | it 'returns false' do 157 | expect(time_segment.contains?(time)).to eq false 158 | end 159 | end 160 | end 161 | 162 | describe '#&' do 163 | let(:other) { described_class.new(other_start_time, other_end_time) } 164 | 165 | context 'when the other segment occurs before the time segment' do 166 | let(:other_start_time) { Time.utc(2006, 1, 1) } 167 | let(:other_end_time) { Time.utc(2006, 1, 2) } 168 | 169 | it 'returns a disjoint time segment' do 170 | expect(time_segment & other).to be_disjoint 171 | end 172 | end 173 | 174 | context 'when the other segment starts before the time segment' do 175 | let(:other_start_time) { Time.utc(2006, 1, 7) } 176 | 177 | context 'and ends before the time segment' do 178 | let(:other_end_time) { Time.utc(2006, 1, 8, 11, 45) } 179 | 180 | it 'returns the correct time segment' do 181 | expect(time_segment & other).to eq( 182 | described_class.new(start_time, other_end_time) 183 | ) 184 | end 185 | end 186 | 187 | context 'and ends after the time segment' do 188 | let(:other_end_time) { Time.utc(2006, 1, 23) } 189 | 190 | it 'returns the correct time segment' do 191 | expect(time_segment & other).to eq( 192 | described_class.new(start_time, end_time) 193 | ) 194 | end 195 | end 196 | end 197 | 198 | context 'when the other segment starts after the time segment' do 199 | let(:other_start_time) { Time.utc(2006, 1, 8, 11, 30) } 200 | 201 | context 'and ends before the time segment' do 202 | let(:other_end_time) { Time.utc(2006, 1, 9, 12, 30) } 203 | 204 | it 'returns the correct time segment' do 205 | expect(time_segment & other).to eq( 206 | described_class.new(other_start_time, other_end_time) 207 | ) 208 | end 209 | end 210 | 211 | context 'and ends after the time segment' do 212 | let(:other_end_time) { Time.utc(2006, 1, 23) } 213 | 214 | it 'returns the correct time segment' do 215 | expect(time_segment & other).to eq( 216 | described_class.new(other_start_time, end_time) 217 | ) 218 | end 219 | end 220 | end 221 | 222 | context 'when the other segment occurs after the time segment' do 223 | let(:other_start_time) { Time.utc(2006, 2, 1) } 224 | let(:other_end_time) { Time.utc(2006, 2, 7) } 225 | 226 | it 'returns a disjoint time segment' do 227 | expect(time_segment & other).to be_disjoint 228 | end 229 | end 230 | end 231 | 232 | describe '#/' do 233 | let(:other) { described_class.new(other_start_time, other_end_time) } 234 | 235 | context 'when the other segment occurs before the time segment' do 236 | let(:other_start_time) { Time.utc(2006, 1, 1) } 237 | let(:other_end_time) { Time.utc(2006, 1, 2) } 238 | 239 | it 'returns the original time segment' do 240 | expect(time_segment / other).to eq [time_segment] 241 | end 242 | end 243 | 244 | context 'when the other segment starts before the time segment' do 245 | let(:other_start_time) { Time.utc(2006, 1, 7) } 246 | 247 | context 'and ends before the time segment' do 248 | let(:other_end_time) { Time.utc(2006, 1, 8, 11, 45) } 249 | 250 | it 'returns the correct time segment' do 251 | expect(time_segment / other).to eq [ 252 | described_class.new(other.end_time, time_segment.end_time) 253 | ] 254 | end 255 | end 256 | 257 | context 'and ends after the time segment' do 258 | let(:other_end_time) { Time.utc(2006, 1, 23) } 259 | 260 | it 'returns an empty array' do 261 | expect(time_segment / other).to eq [] 262 | end 263 | end 264 | end 265 | 266 | context 'when the other segment starts after the time segment' do 267 | let(:other_start_time) { Time.utc(2006, 1, 8, 11, 30) } 268 | 269 | context 'and ends before the time segment' do 270 | let(:other_end_time) { Time.utc(2006, 1, 9, 12, 30) } 271 | 272 | it 'returns the correct time segments' do 273 | expect(time_segment / other).to eq [ 274 | described_class.new(time_segment.start_time, other.start_time), 275 | described_class.new(other.end_time, time_segment.end_time) 276 | ] 277 | end 278 | end 279 | 280 | context 'and ends after the time segment' do 281 | let(:other_end_time) { Time.utc(2006, 1, 23) } 282 | 283 | it 'returns the correct time segment' do 284 | expect(time_segment / other).to eq [ 285 | described_class.new(time_segment.start_time, other.start_time) 286 | ] 287 | end 288 | end 289 | end 290 | 291 | context 'when the other segment occurs after the time segment' do 292 | let(:other_start_time) { Time.utc(2006, 2, 1) } 293 | let(:other_end_time) { Time.utc(2006, 2, 7) } 294 | 295 | it 'returns the original time segment' do 296 | expect(time_segment / other).to eq [time_segment] 297 | end 298 | end 299 | end 300 | 301 | context 'when performing comparison' do 302 | context 'and the compared object has an earlier start time' do 303 | let(:other) { described_class.new(start_time - 1, end_time) } 304 | 305 | it 'compares as expected' do 306 | expect(time_segment > other).to eq true 307 | end 308 | end 309 | 310 | context 'and the compared object has a later start time' do 311 | let(:other) { described_class.new(start_time + 1, end_time) } 312 | 313 | it 'compares as expected' do 314 | expect(time_segment > other).to eq false 315 | end 316 | end 317 | 318 | context 'and the compared object has an earlier end time' do 319 | let(:other) { described_class.new(start_time, end_time - 1) } 320 | 321 | it 'compares as expected' do 322 | expect(time_segment > other).to eq true 323 | end 324 | end 325 | 326 | context 'and the compared object has a later end time' do 327 | let(:other) { described_class.new(start_time, end_time + 1) } 328 | 329 | it 'compares as expected' do 330 | expect(time_segment > other).to eq false 331 | end 332 | end 333 | 334 | context 'and the compared object has the same start and end times' do 335 | let(:other) { described_class.new(start_time, end_time) } 336 | 337 | it 'compares as expected' do 338 | expect(time_segment == other).to eq true 339 | end 340 | end 341 | 342 | context 'and the compared object is not a time segment' do 343 | let(:other) { 1 } 344 | 345 | it 'is not comparable' do 346 | expect { time_segment < other }.to raise_error ArgumentError 347 | end 348 | end 349 | end 350 | end 351 | -------------------------------------------------------------------------------- /spec/periods/after_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Biz::Periods::After do 4 | let(:hours) { 5 | { 6 | mon: {'09:00' => '17:00'}, 7 | tue: {'10:00' => '16:00'}, 8 | wed: {'09:00' => '17:00'}, 9 | thu: {'10:00' => '16:00'}, 10 | fri: {'09:00' => '17:00'}, 11 | sat: {'11:00' => '14:30'} 12 | } 13 | } 14 | 15 | let(:shifts) { 16 | { 17 | Date.new(2005, 12, 31) => {'10:00' => '14:00'}, 18 | Date.new(2006, 1, 4) => {'08:00' => '12:00'}, 19 | Date.new(2006, 1, 6) => {'09:00' => '10:00'} 20 | } 21 | } 22 | 23 | let(:breaks) { {} } 24 | let(:holidays) { [Date.new(2006, 1, 16), Date.new(2006, 1, 18)] } 25 | let(:time_zone) { 'Etc/UTC' } 26 | let(:origin) { Time.utc(2006, 1, 1) } 27 | 28 | subject(:periods) { 29 | described_class.new( 30 | schedule( 31 | hours: hours, 32 | shifts: shifts, 33 | breaks: breaks, 34 | holidays: holidays, 35 | time_zone: time_zone 36 | ), 37 | origin 38 | ) 39 | } 40 | 41 | context 'when one week of periods is requested' do 42 | let(:origin) { Time.utc(2006, 1, 1) } 43 | 44 | it 'returns the proper intervals' do 45 | expect(periods.take(6).to_a).to eq [ 46 | Biz::TimeSegment.new( 47 | Time.utc(2006, 1, 2, 9), 48 | Time.utc(2006, 1, 2, 17) 49 | ), 50 | Biz::TimeSegment.new( 51 | Time.utc(2006, 1, 3, 10), 52 | Time.utc(2006, 1, 3, 16) 53 | ), 54 | Biz::TimeSegment.new( 55 | Time.utc(2006, 1, 4, 8), 56 | Time.utc(2006, 1, 4, 12) 57 | ), 58 | Biz::TimeSegment.new( 59 | Time.utc(2006, 1, 5, 10), 60 | Time.utc(2006, 1, 5, 16) 61 | ), 62 | Biz::TimeSegment.new( 63 | Time.utc(2006, 1, 6, 9), 64 | Time.utc(2006, 1, 6, 10) 65 | ), 66 | Biz::TimeSegment.new( 67 | Time.utc(2006, 1, 7, 11), 68 | Time.utc(2006, 1, 7, 14, 30) 69 | ) 70 | ] 71 | end 72 | end 73 | 74 | context 'when multiple weeks of periods are requested' do 75 | let(:origin) { Time.utc(2006, 1, 1) } 76 | 77 | it 'returns the proper intervals' do 78 | expect(periods.take(12).to_a).to eq [ 79 | Biz::TimeSegment.new( 80 | Time.utc(2006, 1, 2, 9), 81 | Time.utc(2006, 1, 2, 17) 82 | ), 83 | Biz::TimeSegment.new( 84 | Time.utc(2006, 1, 3, 10), 85 | Time.utc(2006, 1, 3, 16) 86 | ), 87 | Biz::TimeSegment.new( 88 | Time.utc(2006, 1, 4, 8), 89 | Time.utc(2006, 1, 4, 12) 90 | ), Biz::TimeSegment.new( 91 | Time.utc(2006, 1, 5, 10), 92 | Time.utc(2006, 1, 5, 16) 93 | ), 94 | Biz::TimeSegment.new( 95 | Time.utc(2006, 1, 6, 9), 96 | Time.utc(2006, 1, 6, 10) 97 | ), 98 | Biz::TimeSegment.new( 99 | Time.utc(2006, 1, 7, 11), 100 | Time.utc(2006, 1, 7, 14, 30) 101 | ), 102 | Biz::TimeSegment.new( 103 | Time.utc(2006, 1, 9, 9), 104 | Time.utc(2006, 1, 9, 17) 105 | ), 106 | Biz::TimeSegment.new( 107 | Time.utc(2006, 1, 10, 10), 108 | Time.utc(2006, 1, 10, 16) 109 | ), 110 | Biz::TimeSegment.new( 111 | Time.utc(2006, 1, 11, 9), 112 | Time.utc(2006, 1, 11, 17) 113 | ), 114 | Biz::TimeSegment.new( 115 | Time.utc(2006, 1, 12, 10), 116 | Time.utc(2006, 1, 12, 16) 117 | ), 118 | Biz::TimeSegment.new( 119 | Time.utc(2006, 1, 13, 9), 120 | Time.utc(2006, 1, 13, 17) 121 | ), 122 | Biz::TimeSegment.new( 123 | Time.utc(2006, 1, 14, 11), 124 | Time.utc(2006, 1, 14, 14, 30) 125 | ) 126 | ] 127 | end 128 | end 129 | 130 | context 'when the origin is outside a period' do 131 | let(:origin) { Time.utc(2006, 1, 1) } 132 | 133 | it 'returns a full period first' do 134 | expect(periods.first).to eq( 135 | Biz::TimeSegment.new(Time.utc(2006, 1, 2, 9), Time.utc(2006, 1, 2, 17)) 136 | ) 137 | end 138 | end 139 | 140 | context 'when the origin is inside a period' do 141 | let(:origin) { Time.utc(2006, 1, 2, 12) } 142 | 143 | it 'returns a partial period first' do 144 | expect(periods.first).to eq( 145 | Biz::TimeSegment.new(Time.utc(2006, 1, 2, 12), Time.utc(2006, 1, 2, 17)) 146 | ) 147 | end 148 | end 149 | 150 | context 'when a break overlaps with the beginning of a period' do 151 | let(:breaks) { {Date.new(2006, 1, 2) => {'08:00' => '10:00'}} } 152 | 153 | it 'excludes the overlapping time' do 154 | expect(periods.first).to eq( 155 | Biz::TimeSegment.new( 156 | Time.utc(2006, 1, 2, 10), 157 | Time.utc(2006, 1, 2, 17) 158 | ) 159 | ) 160 | end 161 | end 162 | 163 | context 'when a break overlaps with the end of a period' do 164 | let(:breaks) { {Date.new(2006, 1, 2) => {'16:00' => '18:00'}} } 165 | 166 | it 'excludes the overlapping time' do 167 | expect(periods.first).to eq( 168 | Biz::TimeSegment.new( 169 | Time.utc(2006, 1, 2, 9), 170 | Time.utc(2006, 1, 2, 16) 171 | ) 172 | ) 173 | end 174 | end 175 | 176 | context 'when a break overlaps an entire period' do 177 | let(:breaks) { {Date.new(2006, 1, 2) => {'08:00' => '18:00'}} } 178 | 179 | it 'excludes that period' do 180 | expect(periods.first).to eq( 181 | Biz::TimeSegment.new( 182 | Time.utc(2006, 1, 3, 10), 183 | Time.utc(2006, 1, 3, 16) 184 | ) 185 | ) 186 | end 187 | end 188 | 189 | context 'when a break is in the middle of a period' do 190 | let(:breaks) { {Date.new(2006, 1, 2) => {'13:30' => '15:30'}} } 191 | 192 | it 'excludes the overlapping time' do 193 | expect(periods.take(2).to_a).to eq [ 194 | Biz::TimeSegment.new( 195 | Time.utc(2006, 1, 2, 9), 196 | Time.utc(2006, 1, 2, 13, 30) 197 | ), 198 | Biz::TimeSegment.new( 199 | Time.utc(2006, 1, 2, 15, 30), 200 | Time.utc(2006, 1, 2, 17) 201 | ) 202 | ] 203 | end 204 | end 205 | 206 | context 'when multiple breaks are in the middle of a period' do 207 | let(:breaks) { 208 | {Date.new(2006, 1, 2) => {'10:00' => '11:30', '13:00' => '14:00'}} 209 | } 210 | 211 | it 'excludes the overlapping time' do 212 | expect(periods.take(3).to_a).to eq [ 213 | Biz::TimeSegment.new( 214 | Time.utc(2006, 1, 2, 9), 215 | Time.utc(2006, 1, 2, 10) 216 | ), 217 | Biz::TimeSegment.new( 218 | Time.utc(2006, 1, 2, 11, 30), 219 | Time.utc(2006, 1, 2, 13) 220 | ), 221 | Biz::TimeSegment.new( 222 | Time.utc(2006, 1, 2, 14), 223 | Time.utc(2006, 1, 2, 17) 224 | ) 225 | ] 226 | end 227 | end 228 | 229 | context 'when a break overlaps multiple periods' do 230 | let(:hours) { {mon: {'17:00' => '19:00', '20:00' => '22:00'}} } 231 | let(:breaks) { {Date.new(2006, 1, 2) => {'18:00' => '21:00'}} } 232 | 233 | it 'excludes the overlapping time' do 234 | expect(periods.take(2).to_a).to eq [ 235 | Biz::TimeSegment.new( 236 | Time.utc(2006, 1, 2, 17), 237 | Time.utc(2006, 1, 2, 18) 238 | ), 239 | Biz::TimeSegment.new( 240 | Time.utc(2006, 1, 2, 21), 241 | Time.utc(2006, 1, 2, 22) 242 | ) 243 | ] 244 | end 245 | end 246 | 247 | context 'when multiple breaks overlap multiple periods' do 248 | let(:breaks) { 249 | { 250 | Date.new(2006, 1, 2) => {'08:00' => '10:15', '11:30' => '12:30'}, 251 | Date.new(2006, 1, 3) => {'12:00' => '13:00', '14:25' => '14:40'} 252 | } 253 | } 254 | 255 | it 'excludes the overlapping time' do 256 | expect(periods.take(5).to_a).to eq [ 257 | Biz::TimeSegment.new( 258 | Time.utc(2006, 1, 2, 10, 15), 259 | Time.utc(2006, 1, 2, 11, 30) 260 | ), 261 | Biz::TimeSegment.new( 262 | Time.utc(2006, 1, 2, 12, 30), 263 | Time.utc(2006, 1, 2, 17) 264 | ), 265 | Biz::TimeSegment.new( 266 | Time.utc(2006, 1, 3, 10), 267 | Time.utc(2006, 1, 3, 12) 268 | ), 269 | Biz::TimeSegment.new( 270 | Time.utc(2006, 1, 3, 13), 271 | Time.utc(2006, 1, 3, 14, 25) 272 | ), 273 | Biz::TimeSegment.new( 274 | Time.utc(2006, 1, 3, 14, 40), 275 | Time.utc(2006, 1, 3, 16) 276 | ) 277 | ] 278 | end 279 | end 280 | 281 | context 'when a period during a holiday is encountered' do 282 | let(:origin) { Time.utc(2006, 1, 14) } 283 | 284 | it 'does not include that period' do 285 | expect(periods.take(2).to_a).to eq [ 286 | Biz::TimeSegment.new( 287 | Time.utc(2006, 1, 14, 11), 288 | Time.utc(2006, 1, 14, 14, 30) 289 | ), 290 | Biz::TimeSegment.new( 291 | Time.utc(2006, 1, 17, 10), 292 | Time.utc(2006, 1, 17, 16) 293 | ) 294 | ] 295 | end 296 | end 297 | 298 | context 'when multiple periods during holidays are encountered' do 299 | let(:origin) { Time.utc(2006, 1, 14) } 300 | 301 | it 'does not include any of those periods' do 302 | expect(periods.take(3).to_a).to eq [ 303 | Biz::TimeSegment.new( 304 | Time.utc(2006, 1, 14, 11), 305 | Time.utc(2006, 1, 14, 14, 30) 306 | ), 307 | Biz::TimeSegment.new( 308 | Time.utc(2006, 1, 17, 10), 309 | Time.utc(2006, 1, 17, 16) 310 | ), 311 | Biz::TimeSegment.new( 312 | Time.utc(2006, 1, 19, 10), 313 | Time.utc(2006, 1, 19, 16) 314 | ) 315 | ] 316 | end 317 | end 318 | 319 | context 'when the origin is near the beginning of the week' do 320 | let(:hours) { {sat: {'06:00' => '18:00'}} } 321 | let(:time_zone) { 'America/Los_Angeles' } 322 | 323 | let(:origin) { in_zone('America/Los_Angeles') { Time.utc(2006, 1, 7, 17) } } 324 | 325 | it 'includes the relevant interval from the prior week' do 326 | expect(periods.first).to eq( 327 | Biz::TimeSegment.new( 328 | in_zone('America/Los_Angeles') { Time.utc(2006, 1, 7, 17) }, 329 | in_zone('America/Los_Angeles') { Time.utc(2006, 1, 7, 18) } 330 | ) 331 | ) 332 | end 333 | end 334 | 335 | context 'when an empty interval is generated' do 336 | let(:hours) { {sun: {'02:30' => '03:15'}, mon: {'09:00' => '17:00'}} } 337 | let(:time_zone) { 'America/Los_Angeles' } 338 | 339 | let(:origin) { in_zone('America/Los_Angeles') { Time.utc(2006, 4, 2) } } 340 | 341 | it 'is filtered out' do 342 | expect(periods.first).to eq( 343 | Biz::TimeSegment.new( 344 | in_zone('America/Los_Angeles') { Time.utc(2006, 4, 3, 9) }, 345 | in_zone('America/Los_Angeles') { Time.utc(2006, 4, 3, 17) } 346 | ) 347 | ) 348 | end 349 | end 350 | 351 | describe '#timeline' do 352 | let(:origin) { Time.utc(2006, 1, 1) } 353 | 354 | it 'creates a timeline using its periods' do 355 | expect( 356 | periods.timeline.until(Time.utc(2006, 1, 4)).to_a 357 | ).to eq [ 358 | Biz::TimeSegment.new( 359 | Time.utc(2006, 1, 2, 9), 360 | Time.utc(2006, 1, 2, 17) 361 | ), 362 | Biz::TimeSegment.new( 363 | Time.utc(2006, 1, 3, 10), 364 | Time.utc(2006, 1, 3, 16) 365 | ) 366 | ] 367 | end 368 | end 369 | end 370 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![biz](http://d26a57ydsghvgx.cloudfront.net/www/public/assets/images/bizlogo.png) 2 | 3 | [![Gem Version](https://badge.fury.io/rb/biz.svg)](http://badge.fury.io/rb/biz) 4 | ![repo-checks](https://github.com/zendesk/biz/workflows/repo-checks/badge.svg) 5 | [![Code Climate](https://codeclimate.com/github/zendesk/biz/badges/gpa.svg)](https://codeclimate.com/github/zendesk/biz) 6 | [![Test Coverage](https://codeclimate.com/github/zendesk/biz/badges/coverage.svg)](https://codeclimate.com/github/zendesk/biz) 7 | 8 | Time calculations using business hours. 9 | 10 | ## Features 11 | 12 | * Support for: 13 | - Multiple intervals per day. 14 | - Multiple schedule configurations. 15 | - Intervals spanning the entire day. 16 | - Holidays. 17 | - Breaks (time-segment holidays). 18 | - Shifts (date-based intervals). 19 | * Second-level calculation precision. 20 | * Seamless Daylight Saving Time handling. 21 | * Schedule intersection. 22 | * Thread safety. 23 | 24 | ## Anti-Features 25 | 26 | * No dependency on ActiveSupport. 27 | * No monkey patching by default. 28 | 29 | ## Installation 30 | 31 | Add this line to your application's Gemfile: 32 | 33 | gem 'biz' 34 | 35 | And then execute: 36 | 37 | $ bundle 38 | 39 | Or install it yourself as: 40 | 41 | $ gem install biz 42 | 43 | ## Configuration 44 | 45 | ```ruby 46 | Biz.configure do |config| 47 | config.hours = { 48 | mon: {'09:00' => '17:00'}, 49 | tue: {'00:00' => '24:00'}, 50 | wed: {'09:00' => '17:00'}, 51 | thu: {'09:00' => '12:00', '13:00' => '17:00'}, 52 | sat: {'10:00' => '14:00'} 53 | } 54 | 55 | config.shifts = { 56 | Date.new(2006, 1, 1) => {'09:00' => '12:00'}, 57 | Date.new(2006, 1, 7) => {'08:00' => '10:00', '12:00' => '14:00'} 58 | } 59 | 60 | config.breaks = { 61 | Date.new(2006, 1, 2) => {'10:00' => '11:30'}, 62 | Date.new(2006, 1, 3) => {'14:15' => '14:30', '15:40' => '15:50'} 63 | } 64 | 65 | config.holidays = [Date.new(2016, 1, 1), Date.new(2016, 12, 25)] 66 | 67 | config.time_zone = 'America/Los_Angeles' 68 | end 69 | ``` 70 | 71 | Configured timestamps must be in either `HH:MM` or `HH:MM:SS` format. 72 | 73 | Shifts act as exceptions to the hours configured for a particular date; that is, 74 | if a date is configured with both hours-based intervals and shifts, the shifts 75 | are in force and the intervals are disregarded. 76 | 77 | Periods occurring on holidays are disregarded. Similarly, any segment of a 78 | period that overlaps with a break is treated as inactive. 79 | 80 | If global configuration isn't your thing, configure an instance instead: 81 | 82 | ```ruby 83 | Biz::Schedule.new do |config| 84 | # ... 85 | end 86 | ``` 87 | 88 | Note that times must be specified in 24-hour clock format and time zones 89 | must be [IANA identifiers](http://en.wikipedia.org/wiki/List_of_tz_database_time_zones). 90 | 91 | If you're operating in a threaded environment and want to use the same 92 | configuration across threads, save the configured schedule as a global variable: 93 | 94 | ```ruby 95 | $biz = Biz::Schedule.new 96 | ``` 97 | 98 | ## Usage 99 | 100 | ```ruby 101 | # Find the time an amount of business time *before* a specified starting time 102 | Biz.time(30, :minutes).before(Time.utc(2015, 1, 1, 11, 45)) 103 | 104 | # Find the time an amount of business time *after* a specified starting time 105 | Biz.time(2, :hours).after(Time.utc(2015, 12, 25, 9, 30)) 106 | 107 | # Calculations can be performed in seconds, minutes, hours, or days 108 | Biz.time(1, :day).after(Time.utc(2015, 1, 8, 10)) 109 | 110 | # Find the previous business time 111 | Biz.time(0, :hours).before(Time.utc(2016, 1, 8, 6)) 112 | 113 | # Find the next business time 114 | Biz.time(0, :hours).after(Time.utc(2016, 1, 8, 20)) 115 | 116 | # Find the amount of business time between two times 117 | Biz.within(Time.utc(2015, 3, 7), Time.utc(2015, 3, 14)).in_seconds 118 | 119 | # Find the start of the business day 120 | Biz.periods.on(Date.today).first.start_time 121 | 122 | # Find the end of the business day 123 | Biz.periods.on(Date.today).to_a.last.end_time 124 | 125 | # Determine if a time is in business hours 126 | Biz.in_hours?(Time.utc(2015, 1, 10, 9)) 127 | 128 | # Determine if a time is during a break 129 | Biz.on_break?(Time.utc(2016, 6, 3)) 130 | 131 | # Determine if a time is during a holiday 132 | Biz.on_holiday?(Time.utc(2014, 1, 1)) 133 | ``` 134 | 135 | The same methods can be called on a configured instance: 136 | 137 | ```ruby 138 | schedule = Biz::Schedule.new 139 | 140 | schedule.in_hours?(Time.utc(2015, 1, 1, 10)) 141 | ``` 142 | 143 | All returned times are in UTC. 144 | 145 | If a schedule will be configured with a large number of holidays and performance 146 | is a particular concern, it's recommended that holidays are filtered down to 147 | those relevant to the calculation(s) at hand before configuration to improve 148 | performance. 149 | 150 | By dropping down a level, you can get access to the underlying time segments, 151 | which you can use to do your own custom calculations or just get a better idea 152 | of what's happening under the hood: 153 | 154 | ```ruby 155 | Biz.periods.after(Time.utc(2015, 1, 10, 10)).timeline 156 | .until(Time.utc(2015, 1, 17, 10)).to_a 157 | 158 | #=> [#, 159 | # #, 160 | # #, 161 | # #, 162 | # #, 163 | # #] 164 | 165 | Biz.periods 166 | .before(Time.utc(2015, 5, 5, 12, 34, 57)).timeline 167 | .for(Biz::Duration.minutes(3_598)).to_a 168 | 169 | #=> [#, 170 | # #, 171 | # #, 172 | # #, 173 | # #, 174 | # #, 175 | # #, 176 | # #] 177 | ``` 178 | 179 | #### Day calculation semantics 180 | 181 | Unlike seconds, minutes, or hours, a "day" is an ambiguous concept, particularly 182 | in relation to the vast number of potential schedule configurations. Because of 183 | that, day calculations are implemented with the principle of making the logic as 184 | straightforward as possible while knowing not all use cases will be satisfied 185 | out of the box. 186 | 187 | Here's the logic that's followed: 188 | 189 | > Find the next day that contains business hours. Starting from the same minute 190 | > on that day as the specified time, look forward (or back) to find the next 191 | > moment in time that is in business hours. 192 | 193 | ## Schedule intersection 194 | 195 | An intersection of two schedules can be found using `&`: 196 | 197 | ```ruby 198 | schedule_1 = Biz::Schedule.new do |config| 199 | config.hours = { 200 | mon: {'09:00' => '17:00'}, 201 | tue: {'10:00' => '16:00'}, 202 | wed: {'09:00' => '17:00'}, 203 | thu: {'10:00' => '16:00'}, 204 | fri: {'09:00' => '17:00'}, 205 | sat: {'11:00' => '14:30'} 206 | } 207 | 208 | config.shifts = { 209 | Date.new(2016, 7, 1) => {'10:00' => '13:00', '15:00' => '16:00'}, 210 | Date.new(2016, 7, 2) => {'14:00' => '17:00'} 211 | } 212 | 213 | config.breaks = { 214 | Date.new(2016, 6, 2) => {'09:00' => '10:30', '16:00' => '16:30'}, 215 | Date.new(2016, 6, 3) => {'12:15' => '12:45'} 216 | } 217 | 218 | config.holidays = [Date.new(2016, 1, 1), Date.new(2016, 12, 25)] 219 | 220 | config.time_zone = 'Etc/UTC' 221 | end 222 | 223 | schedule_2 = Biz::Schedule.new do |config| 224 | config.hours = { 225 | sun: {'10:00' => '12:00'}, 226 | mon: {'08:00' => '10:00'}, 227 | tue: {'11:00' => '15:00'}, 228 | wed: {'16:00' => '18:00'}, 229 | thu: {'11:00' => '12:00', '13:00' => '14:00'} 230 | } 231 | 232 | config.shifts = { 233 | Date.new(2016, 7, 1) => {'15:30' => '16:30'}, 234 | Date.new(2016, 7, 5) => {'14:00' => '18:00'} 235 | } 236 | 237 | config.breaks = { 238 | Date.new(2016, 6, 3) => {'13:30' => '14:00'}, 239 | Date.new(2016, 6, 4) => {'11:00' => '12:00'} 240 | } 241 | 242 | config.holidays = [ 243 | Date.new(2016, 1, 1), 244 | Date.new(2016, 7, 4), 245 | Date.new(2016, 11, 24) 246 | ] 247 | 248 | config.time_zone = 'America/Los_Angeles' 249 | end 250 | 251 | schedule_1 & schedule_2 252 | ``` 253 | 254 | The resulting schedule will be a combination of the two schedules: an 255 | intersection of the intervals, a union of the breaks and holidays, 256 | and the time zone of the first schedule. Any configured shifts will be 257 | disregarded. 258 | 259 | For the above example, the resulting schedule would be equivalent to one with 260 | the following configuration: 261 | 262 | ```ruby 263 | Biz::Schedule.new do |config| 264 | config.hours = { 265 | mon: {'09:00' => '10:00'}, 266 | tue: {'11:00' => '15:00'}, 267 | wed: {'16:00' => '17:00'}, 268 | thu: {'11:00' => '12:00', '13:00' => '14:00'} 269 | } 270 | 271 | config.shifts = { 272 | Date.new(2016, 7, 1) => {'15:30' => '16:00'}, 273 | Date.new(2016, 7, 5) => {'14:00' => '16:00'} 274 | } 275 | 276 | config.breaks = { 277 | Date.new(2016, 6, 2) => {'09:00' => '10:30', '16:00' => '16:30'}, 278 | Date.new(2016, 6, 3) => {'12:15' => '12:45', '13:30' => '14:00'}, 279 | Date.new(2016, 6, 4) => {'11:00' => '12:00'} 280 | } 281 | 282 | config.holidays = [ 283 | Date.new(2016, 1, 1), 284 | Date.new(2016, 7, 4), 285 | Date.new(2016, 11, 24), 286 | Date.new(2016, 12, 25) 287 | ] 288 | 289 | config.time_zone = 'Etc/UTC' 290 | end 291 | ``` 292 | 293 | ## Core extensions 294 | 295 | Optional extensions to core classes (`Date`, `Integer`, and `Time`) are 296 | available for additional expressiveness: 297 | 298 | ```ruby 299 | require 'biz/core_ext' 300 | 301 | 75.business_seconds.after(Time.utc(2015, 3, 5, 12, 30)) 302 | 303 | 30.business_minutes.before(Time.utc(2015, 1, 1, 11, 45)) 304 | 305 | 5.business_hours.after(Time.utc(2015, 4, 7, 8, 20)) 306 | 307 | 3.business_days.before(Time.utc(2015, 5, 9, 4, 12)) 308 | 309 | Time.utc(2015, 8, 20, 9, 30).business_hours? 310 | 311 | Time.utc(2016, 6, 3, 12).on_break? 312 | 313 | Time.utc(2014, 1, 1, 12).on_holiday? 314 | 315 | Date.new(2015, 12, 10).business_day? 316 | ``` 317 | 318 | ## Contributing 319 | 320 | Pull requests are welcome, but consider asking for a feature or bug fix first 321 | through the issue tracker. When contributing code, please squash sloppy commits 322 | aggressively and follow [Tim Pope's guidelines](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) 323 | for commit messages. 324 | 325 | There are a number of ways to get started after cloning the repository. 326 | 327 | To set up your environment: 328 | 329 | script/bootstrap 330 | 331 | To run the spec suite: 332 | 333 | script/spec 334 | 335 | To open a console with the gem and sample schedule loaded: 336 | 337 | script/console 338 | 339 | ## Alternatives 340 | 341 | * [`business_time`](https://github.com/bokmann/business_time) 342 | * [`working_hours`](https://github.com/Intrepidd/working_hours) 343 | 344 | ## Copyright and license 345 | 346 | Copyright 2015-19 Zendesk 347 | 348 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 349 | this gem except in compliance with the License. 350 | 351 | You may obtain a copy of the License at 352 | http://www.apache.org/licenses/LICENSE-2.0. 353 | 354 | Unless required by applicable law or agreed to in writing, software distributed 355 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 356 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 357 | specific language governing permissions and limitations under the License. 358 | --------------------------------------------------------------------------------