class BusinessDay::Calendar

Constants

DAY_NAMES
VALID_KEYS

Attributes

load_paths[RW]
extra_working_dates[R]
holidays[R]
working_days[R]

Public Class Methods

find_calendar_data(calendar_name) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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
new(config) click to toggle source
# 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

Private Class Methods

calendar_directories() click to toggle source
# File lib/business_day/calendar.rb, line 14
def self.calendar_directories
  @load_paths
end

Public Instance Methods

add_business_days(date, delta) click to toggle source

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
# 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) click to toggle source

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

# 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) click to toggle source

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

# 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) click to toggle source

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

# File lib/business_day/calendar.rb, line 192
def day_interval_for(date)
  date.is_a?(Date) ? 1 : 3600 * 24
end
default_working_days() click to toggle source

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

# File lib/business_day/calendar.rb, line 225
def default_working_days
  %w[mon tue wed thu fri]
end
holiday?(date) click to toggle source
# File lib/business_day/calendar.rb, line 82
def holiday?(date)
  holidays.include?(date.to_date)
end
next_business_day(date) click to toggle source

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

# 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) click to toggle source
# 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) click to toggle source

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

# 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) click to toggle source

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.

# 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) click to toggle source

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.

# 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) click to toggle source
# 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) click to toggle source

Internal method for assigning holidays from a calendar config.

# File lib/business_day/calendar.rb, line 216
def set_holidays(holidays)
  @holidays = parse_dates(holidays)
end
set_working_days(working_days) click to toggle source

Internal method for assigning working days from a calendar config.

# 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) click to toggle source

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
# 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) click to toggle source
# 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