Class: Business::Calendar

Inherits:
Object
  • Object
show all
Defined in:
lib/business/calendar.rb

Constant Summary collapse

DAY_NAMES =
%( mon tue wed thu fri sat sun )

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config) ⇒ Calendar

Returns a new instance of Calendar.



56
57
58
59
60
# File 'lib/business/calendar.rb', line 56

def initialize(config)
  set_extra_working_dates(config[:extra_working_dates])
  set_working_days(config[:working_days])
  set_holidays(config[:holidays])
end

Class Attribute Details

.load_pathsObject

Returns the value of attribute load_paths.



7
8
9
# File 'lib/business/calendar.rb', line 7

def load_paths
  @load_paths
end

Instance Attribute Details

#extra_working_datesObject (readonly)

Returns the value of attribute extra_working_dates.



54
55
56
# File 'lib/business/calendar.rb', line 54

def extra_working_dates
  @extra_working_dates
end

#holidaysObject (readonly)

Returns the value of attribute holidays.



54
55
56
# File 'lib/business/calendar.rb', line 54

def holidays
  @holidays
end

#working_daysObject (readonly)

Returns the value of attribute working_days.



54
55
56
# File 'lib/business/calendar.rb', line 54

def working_days
  @working_days
end

Class Method Details

.load(calendar_name) ⇒ Object



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/business/calendar.rb', line 15

def self.load(calendar_name)
  data = calendar_directories.detect do |path|
    if path.is_a?(Hash)
      break path[calendar_name] if path[calendar_name]
    else
      next unless File.exists?(File.join(path, "#{calendar_name}.yml"))

      break YAML.load_file(File.join(path, "#{calendar_name}.yml"))
    end
  end

  raise "No such calendar '#{calendar_name}'" unless data

  valid_keys = %w(holidays working_days extra_working_dates)

  unless (data.keys - valid_keys).empty?
    raise "Only valid keys are: #{valid_keys.join(', ')}"
  end

  self.new(
    holidays: data['holidays'],
    working_days: data['working_days'],
    extra_working_dates: data['extra_working_dates'],
  )
end

.load_cached(calendar) ⇒ Object



42
43
44
45
46
47
48
49
50
# File 'lib/business/calendar.rb', line 42

def self.load_cached(calendar)
  @lock.synchronize do
    @cache ||= { }
    unless @cache.include?(calendar)
      @cache[calendar] = self.load(calendar)
    end
    @cache[calendar]
  end
end

Instance Method Details

#add_business_days(date, delta) ⇒ Object

Add a number of business days to a date. If a non-business day is given, counting will start from the next business day. So,

monday + 1 = tuesday
friday + 1 = monday
sunday + 1 = tuesday


111
112
113
114
115
116
117
118
119
# File 'lib/business/calendar.rb', line 111

def add_business_days(date, delta)
  date = roll_forward(date)
  delta.times do
    begin
      date += day_interval_for(date)
    end until business_day?(date)
  end
  date
end

#business_day?(date) ⇒ Boolean

Return true if the date given is a business day (typically that means a non-weekend day) and not a holiday.

Returns:

  • (Boolean)


64
65
66
67
68
69
70
# File 'lib/business/calendar.rb', line 64

def business_day?(date)
  date = date.to_date
  return true if extra_working_dates.include?(date)
  return false unless working_days.include?(date.strftime('%a').downcase)
  return false if holidays.include?(date)
  true
end

#business_days_between(date1, date2) ⇒ Object

Count the number of business days between two dates. This method counts from start of date1 to start of date2. So, business_days_between(mon, weds) = 2 (assuming no holidays)



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/business/calendar.rb', line 139

def business_days_between(date1, date2)
  date1, date2 = date1.to_date, date2.to_date

  # To optimise this method we split the range into full weeks and a
  # remaining period.
  #
  # We then calculate business days in the full weeks period by
  # multiplying number of weeks by number of working days in a week and
  # removing holidays one by one.

  # For the remaining period, we just loop through each day and check
  # whether it is a business day.

  # Calculate number of full weeks and remaining days
  num_full_weeks, remaining_days = (date2 - date1).to_i.divmod(7)

  # First estimate for full week range based on # biz days in a week
  num_biz_days = num_full_weeks * working_days.length

  full_weeks_range = (date1...(date2 - remaining_days))
  num_biz_days -= holidays.count do |holiday|
    in_range = full_weeks_range.cover?(holiday)
    # Only pick a holiday if its on a working day (e.g., not a weekend)
    on_biz_day = working_days.include?(holiday.strftime('%a').downcase)
    in_range && on_biz_day
  end

  remaining_range = (date2-remaining_days...date2)
  # Loop through each day in remaining_range and count if a business day
  num_biz_days + remaining_range.count { |a| business_day?(a) }
end

#day_interval_for(date) ⇒ Object



171
172
173
# File 'lib/business/calendar.rb', line 171

def day_interval_for(date)
  date.is_a?(Date) ? 1 : 3600 * 24
end

#default_working_daysObject

If no working days are provided in the calendar config, these are used.



203
204
205
# File 'lib/business/calendar.rb', line 203

def default_working_days
  %w( mon tue wed thu fri )
end

#next_business_day(date) ⇒ Object

Roll forward to the next business day regardless of whether the given date is a business day or not.



90
91
92
93
94
95
# File 'lib/business/calendar.rb', line 90

def next_business_day(date)
  begin
    date += day_interval_for(date)
  end until business_day?(date)
  date
end

#parse_dates(dates) ⇒ Object



187
188
189
# File 'lib/business/calendar.rb', line 187

def parse_dates(dates)
  (dates || []).map { |date| date.is_a?(Date) ? date : Date.parse(date) }
end

#previous_business_day(date) ⇒ Object

Roll backward to the previous business day regardless of whether the given date is a business day or not.



99
100
101
102
103
104
# File 'lib/business/calendar.rb', line 99

def previous_business_day(date)
  begin
    date -= day_interval_for(date)
  end until business_day?(date)
  date
end

#roll_backward(date) ⇒ Object

Roll backward to the previous business day. If the date given is a business day, that day will be returned. If the day given is a holiday or non-working day, the previous non-holiday working day will be returned.



83
84
85
86
# File 'lib/business/calendar.rb', line 83

def roll_backward(date)
  date -= day_interval_for(date) until business_day?(date)
  date
end

#roll_forward(date) ⇒ Object

Roll forward to the next business day. If the date given is a business day, that day will be returned. If the day given is a holiday or non-working day, the next non-holiday working day will be returned.



75
76
77
78
# File 'lib/business/calendar.rb', line 75

def roll_forward(date)
  date += day_interval_for(date) until business_day?(date)
  date
end

#set_extra_working_dates(extra_working_dates) ⇒ Object



198
199
200
# File 'lib/business/calendar.rb', line 198

def set_extra_working_dates(extra_working_dates)
  @extra_working_dates = parse_dates(extra_working_dates)
end

#set_holidays(holidays) ⇒ Object

Internal method for assigning holidays from a calendar config.

Raises:

  • (ArgumentError)


192
193
194
195
196
# File 'lib/business/calendar.rb', line 192

def set_holidays(holidays)
  @holidays = parse_dates(holidays)
  return if (@holidays & @extra_working_dates).none?
  raise ArgumentError, 'Holidays cannot be extra working dates'
end

#set_working_days(working_days) ⇒ Object

Internal method for assigning working days from a calendar config.

Raises:

  • (ArgumentError)


176
177
178
179
180
181
182
183
184
185
# File 'lib/business/calendar.rb', line 176

def set_working_days(working_days)
  @working_days = (working_days || default_working_days).map do |day|
    day.downcase.strip[0..2].tap do |normalised_day|
      raise "Invalid day #{day}" unless DAY_NAMES.include?(normalised_day)
    end
  end
  extra_working_dates_names = @extra_working_dates.map { |d| d.strftime("%a").downcase }
  return if (extra_working_dates_names & @working_days).none?
  raise ArgumentError, 'Extra working dates cannot be on working days'
end

#subtract_business_days(date, delta) ⇒ Object

Subtract a number of business days to a date. If a non-business day is given, counting will start from the previous business day. So,

friday - 1 = thursday
monday - 1 = friday
sunday - 1 = thursday


126
127
128
129
130
131
132
133
134
# File 'lib/business/calendar.rb', line 126

def subtract_business_days(date, delta)
  date = roll_backward(date)
  delta.times do
    begin
      date -= day_interval_for(date)
    end until business_day?(date)
  end
  date
end