Class: Business::Calendar

Inherits:
Object
  • Object
show all
Defined in:
lib/business/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(name: nil, extra_working_dates: nil, working_days: nil, holidays: nil) ⇒ Calendar

Returns a new instance of Calendar.



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

def initialize(name: nil, extra_working_dates: nil, working_days: nil, holidays: nil)
  @name = name
  set_extra_working_dates(extra_working_dates)
  set_working_days(working_days)
  set_holidays(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.



12
13
14
# File 'lib/business/calendar.rb', line 12

def load_paths
  @load_paths
end

Instance Attribute Details

#extra_working_datesObject (readonly)

Returns the value of attribute extra_working_dates.



60
61
62
# File 'lib/business/calendar.rb', line 60

def extra_working_dates
  @extra_working_dates
end

#holidaysObject (readonly)

Returns the value of attribute holidays.



60
61
62
# File 'lib/business/calendar.rb', line 60

def holidays
  @holidays
end

#nameObject (readonly)

Returns the value of attribute name.



60
61
62
# File 'lib/business/calendar.rb', line 60

def name
  @name
end

#working_daysObject (readonly)

Returns the value of attribute working_days.



60
61
62
# File 'lib/business/calendar.rb', line 60

def working_days
  @working_days
end

Class Method Details

.find_calendar_data(calendar_name) ⇒ Object



36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/business/calendar.rb', line 36

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
      calendar_path = Pathname.new(path).join("#{calendar_name}.yml")
      next unless calendar_path.exist?

      break YAML.safe_load(calendar_path.read, permitted_classes: [Date])
    end
  end
end

.load(calendar_name) ⇒ Object



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

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(
    name: calendar_name,
    holidays: data["holidays"],
    working_days: data["working_days"],
    extra_working_dates: data["extra_working_dates"],
  )
end

.load_cached(calendar) ⇒ Object



50
51
52
53
54
55
56
# File 'lib/business/calendar.rb', line 50

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


129
130
131
132
133
134
135
136
137
138
# File 'lib/business/calendar.rb', line 129

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)


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

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)



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
189
190
# File 'lib/business/calendar.rb', line 159

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



192
193
194
# File 'lib/business/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/calendar.rb', line 225

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

#holiday?(date) ⇒ Boolean

Returns:

  • (Boolean)


86
87
88
# File 'lib/business/calendar.rb', line 86

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.



108
109
110
111
112
113
# File 'lib/business/calendar.rb', line 108

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/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.



117
118
119
120
121
122
# File 'lib/business/calendar.rb', line 117

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.



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

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.



93
94
95
96
# File 'lib/business/calendar.rb', line 93

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/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/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/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


145
146
147
148
149
150
151
152
153
154
# File 'lib/business/calendar.rb', line 145

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)


80
81
82
83
84
# File 'lib/business/calendar.rb', line 80

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