Class: BusinessDay::Calendar

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

Constant Summary collapse

VALID_KEYS =
%w[holidays working_days extra_working_dates].freeze
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.



59
60
61
62
63
64
65
66
67
# File 'lib/business_day/calendar.rb', line 59

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

  unless (@holidays & @extra_working_dates).none?
    raise ArgumentError, "Holidays cannot be extra working dates"
  end
end

Class Attribute Details

.load_pathsObject

Returns the value of attribute load_paths.



11
12
13
# File 'lib/business_day/calendar.rb', line 11

def load_paths
  @load_paths
end

Instance Attribute Details

#extra_working_datesObject (readonly)

Returns the value of attribute extra_working_dates.



57
58
59
# File 'lib/business_day/calendar.rb', line 57

def extra_working_dates
  @extra_working_dates
end

#holidaysObject (readonly)

Returns the value of attribute holidays.



57
58
59
# File 'lib/business_day/calendar.rb', line 57

def holidays
  @holidays
end

#working_daysObject (readonly)

Returns the value of attribute working_days.



57
58
59
# File 'lib/business_day/calendar.rb', line 57

def working_days
  @working_days
end

Class Method Details

.find_calendar_data(calendar_name) ⇒ Object



34
35
36
37
38
39
40
41
42
43
44
# File 'lib/business_day/calendar.rb', line 34

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

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

.load(calendar_name) ⇒ Object



19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/business_day/calendar.rb', line 19

def self.load(calendar_name)
  data = find_calendar_data(calendar_name)
  raise "No such calendar '#{calendar_name}'" unless data

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

  new(
    holidays: data["holidays"],
    working_days: data["working_days"],
    extra_working_dates: data["extra_working_dates"],
  )
end

.load_cached(calendar) ⇒ Object



47
48
49
50
51
52
53
# File 'lib/business_day/calendar.rb', line 47

def self.load_cached(calendar)
  @lock.synchronize do
    @cache ||= {}
    @cache[calendar] = self.load(calendar) unless @cache.include?(calendar)
    @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


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

def add_business_days(date, delta)
  date = roll_forward(date)
  delta.times do
    loop do
      date += day_interval_for(date)
      break date if business_day?(date)
    end
  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)


71
72
73
74
# File 'lib/business_day/calendar.rb', line 71

def business_day?(date)
  date = date.to_date
  working_day?(date) && !holiday?(date)
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) rubocop:disable Metrics/AbcSize rubocop:disable Metrics/MethodLength



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/business_day/calendar.rb', line 157

def business_days_between(date1, date2)
  date1 = date1.to_date
  date2 = 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

rubocop:enable Metrics/AbcSize rubocop:enable Metrics/MethodLength



192
193
194
# File 'lib/business_day/calendar.rb', line 192

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.



225
226
227
# File 'lib/business_day/calendar.rb', line 225

def default_working_days
  %w[mon tue wed thu fri]
end

#holiday?(date) ⇒ Boolean

Returns:

  • (Boolean)


82
83
84
# File 'lib/business_day/calendar.rb', line 82

def holiday?(date)
  holidays.include?(date.to_date)
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.



104
105
106
107
108
109
# File 'lib/business_day/calendar.rb', line 104

def next_business_day(date)
  loop do
    date += day_interval_for(date)
    break date if business_day?(date)
  end
end

#parse_dates(dates) ⇒ Object



211
212
213
# File 'lib/business_day/calendar.rb', line 211

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.



113
114
115
116
117
118
# File 'lib/business_day/calendar.rb', line 113

def previous_business_day(date)
  loop do
    date -= day_interval_for(date)
    break date if business_day?(date)
  end
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.



97
98
99
100
# File 'lib/business_day/calendar.rb', line 97

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.



89
90
91
92
# File 'lib/business_day/calendar.rb', line 89

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

#set_extra_working_dates(extra_working_dates) ⇒ Object



220
221
222
# File 'lib/business_day/calendar.rb', line 220

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.



216
217
218
# File 'lib/business_day/calendar.rb', line 216

def set_holidays(holidays)
  @holidays = parse_dates(holidays)
end

#set_working_days(working_days) ⇒ Object

Internal method for assigning working days from a calendar config.

Raises:

  • (ArgumentError)


197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/business_day/calendar.rb', line 197

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 do |d|
    d.strftime("%a").downcase
  end
  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


141
142
143
144
145
146
147
148
149
150
# File 'lib/business_day/calendar.rb', line 141

def subtract_business_days(date, delta)
  date = roll_backward(date)
  delta.times do
    loop do
      date -= day_interval_for(date)
      break date if business_day?(date)
    end
  end
  date
end

#working_day?(date) ⇒ Boolean

Returns:

  • (Boolean)


76
77
78
79
80
# File 'lib/business_day/calendar.rb', line 76

def working_day?(date)
  date = date.to_date
  extra_working_dates.include?(date) ||
    working_days.include?(date.strftime("%a").downcase)
end