# encoding: utf-8 $:.unshift File.dirname(__FILE__) require 'digest/md5' require 'date' # == Region options # Holidays can be defined as belonging to one or more regions and sub regions. # The Holidays#on, Holidays#between, Date#holidays and Date#holiday? methods # each allow you to specify a specific region. # # There are several different ways that you can specify a region: # # [:region] # By region. For example, return holidays in the Canada with :ca. # [:region_] # By region and sub regions. For example, return holidays in Germany # and all its sub regions with :de_. # [:region_sub] # By sub region. Return national holidays in Spain plus holidays in Spain's # Valencia region with :es_v. # [:any] # Any region. Return holidays from any loaded region. # # == Other options # [:observed] Return holidays on the day they are observed (e.g. on a Monday if they fall on a Sunday). # [:informal] Include informal holidays (e.g. Valentine's Day) # # == Examples # Return all holidays in the :ca and :us regions on the day that they are # observed. # # Holidays.between(from, to, :ca, :us, :observed) # # Return all holidays in :ca and any :ca sub-region. # # Holidays.between(from, to, :ca_) # # Return all holidays in :ca_bc sub-region (which includes the :ca), including informal holidays. # # Holidays.between(from, to, :ca_bc, :informal) module Holidays # Exception thrown when an unknown region is requested. class UnknownRegionError < ArgumentError; end VERSION = '1.0.0' @@regions = [] @@holidays_by_month = {} @@proc_cache = {} WEEKS = {:first => 1, :second => 2, :third => 3, :fourth => 4, :fifth => 5, :last => -1, :second_last => -2, :third_last => -3} MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] DAY_SYMBOLS = Date::DAYNAMES.collect { |n| n.downcase.intern } # Get all holidays on a given date. # # [date] A Date object. # [:options] One or more region symbols, :informal and/or :observed. # # Returns an array of hashes or nil. See Holidays#between for the output # format. # # Also available via Date#holidays. def self.on(date, *options) self.between(date, date, options) end # Get all holidays occuring between two dates, inclusively. # # Returns an array of hashes or nil. # # Each holiday is returned as a hash with the following fields: # [start_date] Ruby Date object. # [end_date] Ruby Date object. # [options] One or more region symbols, :informal and/or :observed. # # ==== Example # from = Date.civil(2008,7,1) # to = Date.civil(2008,7,31) # # Holidays.between(from, to, :ca, :us) # => [{:name => 'Canada Day', :regions => [:ca]...} # {:name => 'Independence Day'', :regions => [:us], ...}] def self.between(start_date, end_date, *options) # remove the timezone start_date = start_date.new_offset(0) if start_date.respond_to?(:new_offset) end_date = end_date.new_offset(0) if end_date.respond_to?(:new_offset) # get simple dates if start_date.respond_to?(:to_date) start_date = start_date.to_date else start_date = Date.civil(start_date.year, start_date.mon, start_date.mday) end if end_date.respond_to?(:to_date) end_date = end_date.to_date else end_date = Date.civil(end_date.year, end_date.mon, end_date.mday) end regions, observed, informal = parse_options(options) holidays = [] dates = {} (start_date..end_date).each do |date| # Always include month '0' for variable-month holidays dates[date.year] = [0] unless dates[date.year] # TODO: test this, maybe should push then flatten dates[date.year] << date.month unless dates[date.year].include?(date.month) end dates.each do |year, months| months.each do |month| next unless hbm = @@holidays_by_month[month] hbm.each do |h| next unless in_region?(regions, h[:regions]) # Skip informal holidays unless they have been requested next if h[:type] == :informal and not informal if h[:function] # Holiday definition requires a calculation result = call_proc(h[:function], year) # Procs may return either Date or an integer representing mday if result.kind_of?(Date) month = result.month mday = result.mday else mday = result end else # Calculate the mday mday = h[:mday] || Date.calculate_mday(year, month, h[:week], h[:wday]) end # Silently skip bad mdays begin date = Date.civil(year, month, mday) rescue; next; end # If the :observed option is set, calculate the date when the holiday # is observed. if observed and h[:observed] date = call_proc(h[:observed], date) end if date.between?(start_date, end_date) holidays << {:date => date, :name => h[:name], :regions => h[:regions]} end end end end holidays.sort{|a, b| a[:date] <=> b[:date] } end # Merge a new set of definitions into the Holidays module. # # This method is automatically called when including holiday definition # files. def self.merge_defs(regions, holidays) # :nodoc: @@regions = @@regions | regions @@regions.uniq! holidays.each do |month, holiday_defs| @@holidays_by_month[month] = [] unless @@holidays_by_month[month] holiday_defs.each do |holiday_def| exists = false @@holidays_by_month[month].each do |ex| # TODO: gross. if ex[:name] == holiday_def[:name] and ex[:wday] == holiday_def[:wday] and ex[:mday] == holiday_def[:mday] and ex[:week] == holiday_def[:week] and ex[:function_id] == holiday_def[:function_id] and ex[:type] == holiday_def[:type] and ex[:observed_id] == holiday_def[:observed_id] # append regions ex[:regions] << holiday_def[:regions] # Should do this once we're done ex[:regions].flatten! ex[:regions].uniq! exists = true end end @@holidays_by_month[month] << holiday_def unless exists end end end # Get the date of Easter Sunday in a given year. From Easter Sunday, it is # possible to calculate many traditional holidays in Western countries. # Returns a Date object. def self.easter(year) y = year a = y % 19 b = y / 100 c = y % 100 d = b / 4 e = b % 4 f = (b + 8) / 25 g = (b - f + 1) / 3 h = (19 * a + b - d - g + 15) % 30 i = c / 4 k = c % 4 l = (32 + 2 * e + 2 * i - h - k) % 7 m = (a + 11 * h + 22 * l) / 451 month = (h + l - 7 * m + 114) / 31 day = ((h + l - 7 * m + 114) % 31) + 1 Date.civil(year, month, day) end # Move date to Monday if it occurs on a Sunday. # Used as a callback function. def self.to_monday_if_sunday(date) date += 1 if date.wday == 0 date end # Move date to Monday if it occurs on a Saturday on Sunday. # Used as a callback function. def self.to_monday_if_weekend(date) date += 1 if date.wday == 0 date += 2 if date.wday == 6 date end # Move Boxing Day if it falls on a weekend, leaving room for Christmas. # Used as a callback function. def self.to_weekday_if_boxing_weekend(date) date += 2 if date.wday == 6 or date.wday == 0 date end # Move date to Monday if it occurs on a Sunday or to Friday if it occurs on a # Saturday. # Used as a callback function. def self.to_weekday_if_weekend(date) date += 1 if date.wday == 0 date -= 1 if date.wday == 6 date end private # Returns [(arr)regions, (bool)observed, (bool)informal] def self.parse_options(*options) # :nodoc: options.flatten! observed = options.delete(:observed) ? true : false informal = options.delete(:informal) ? true : false regions = parse_regions(options) return regions, observed, informal end # Check regions against list of supported regions and return an array of # symbols. # # If a wildcard region is found (e.g. :ca_) it is expanded into all # of its available sub regions. def self.parse_regions(regions) # :nodoc: regions = [regions] unless regions.kind_of?(Array) return [:any] if regions.empty? regions = regions.collect { |r| r.to_sym } # Found sub region wild-card regions.delete_if do |reg| if reg.to_s =~ /_$/ prefix = reg.to_s.split('_').first raise UnknownRegionError unless @@regions.include?(prefix.to_sym) or begin require "holidays/#{prefix}"; rescue LoadError; false; end regions << @@regions.select { |dr| dr.to_s =~ Regexp.new("^#{reg}") } true end end regions.flatten! require "holidays/north_america" if regions.include?(:us) # special case for north_america/US cross-linking raise UnknownRegionError unless regions.all? { |r| r == :any or @@regions.include?(r) or begin require "holidays/#{r.to_s}"; rescue LoadError; false; end } regions end # Check sub regions. # # When request :any, all holidays should be returned. # When requesting :ca_bc, holidays in :ca or :ca_bc should be returned. # When requesting :ca, holidays in :ca but not its subregions should be returned. def self.in_region?(requested, available) # :nodoc: return true if requested.include?(:any) # When an underscore is encountered, derive the parent regions # symbol and include both in the requested array. requested = requested.collect do |r| r.to_s =~ /_/ ? [r, r.to_s.gsub(/_[\w]*$/, '').to_sym] : r end requested = requested.flatten.uniq available.any? { |avail| requested.include?(avail) } end # Call a proc function defined in a holiday definition file. # # Procs are cached. # # ==== Benchmarks # # Lookup Easter Sunday, with caching, by number of iterations: # # user system total real # 0001 0.000000 0.000000 0.000000 ( 0.000000) # 0010 0.000000 0.000000 0.000000 ( 0.000000) # 0100 0.078000 0.000000 0.078000 ( 0.078000) # 1000 0.641000 0.000000 0.641000 ( 0.641000) # 5000 3.172000 0.015000 3.187000 ( 3.219000) # # Lookup Easter Sunday, without caching, by number of iterations: # # user system total real # 0001 0.000000 0.000000 0.000000 ( 0.000000) # 0010 0.016000 0.000000 0.016000 ( 0.016000) # 0100 0.125000 0.000000 0.125000 ( 0.125000) # 1000 1.234000 0.000000 1.234000 ( 1.234000) # 5000 6.094000 0.031000 6.125000 ( 6.141000) def self.call_proc(function, year) # :nodoc: proc_key = Digest::MD5.hexdigest("#{function.to_s}_#{year.to_s}") @@proc_cache[proc_key] = function.call(year) unless @@proc_cache[proc_key] @@proc_cache[proc_key] end end # === Extending Ruby's Date class with the Holidays gem # The Holidays gem automatically extends Ruby's Date class and gives you access # to three new methods: holiday?, #holidays and #calculate_mday. # # ==== Examples # Lookup Canada Day in the :ca region # Date.civil(2008,7,1).holiday?(:ca) # => true # # Lookup Canada Day in the :fr region # Date.civil(2008,7,1).holiday?(:fr) # => false # # Lookup holidays on North America in January 1. # Date.civil(2008,1,1).holidays(:ca, :mx, :us, :informal, :observed) # => [{:name => 'New Year\'s Day'...}] class Date include Holidays # Get holidays on the current date. # # Returns an array of hashes or nil. See Holidays#between for options # and the output format. # # Date.civil('2008-01-01').holidays(:ca_) # => [{:name => 'New Year\'s Day',...}] # # Also available via Holidays#on. def holidays(*options) Holidays.on(self, options) end # Check if the current date is a holiday. # # Returns true or false. # # Date.civil('2008-01-01').holiday?(:ca) # => true def holiday?(*options) holidays = self.holidays(options) holidays && !holidays.empty? end # Calculate day of the month based on the week number and the day of the # week. # # ==== Parameters # [year] Integer. # [month] Integer from 1-12. # [week] One of :first, :second, :third, # :fourth, :fifth or :last. # [wday] Day of the week as an integer from 0 (Sunday) to 6 # (Saturday) or as a symbol (e.g. :monday). # # Returns an integer. # # ===== Examples # First Monday of January, 2008: # Date.calculate_mday(2008, 1, :first, :monday) # => 7 # # Third Thursday of December, 2008: # Date.calculate_mday(2008, 12, :third, :thursday) # => 18 # # Last Monday of January, 2008: # Date.calculate_mday(2008, 1, :last, 1) # => 28 #-- # see http://www.irt.org/articles/js050/index.htm def self.calculate_mday(year, month, week, wday) raise ArgumentError, "Week parameter must be one of Holidays::WEEKS (provided #{week})." unless WEEKS.include?(week) or WEEKS.has_value?(week) unless wday.kind_of?(Numeric) and wday.between?(0,6) or DAY_SYMBOLS.index(wday) raise ArgumentError, "Wday parameter must be an integer between 0 and 6 or one of Date::DAY_SYMBOLS." end week = WEEKS[week] if week.kind_of?(Symbol) wday = DAY_SYMBOLS.index(wday) if wday.kind_of?(Symbol) # :first, :second, :third, :fourth or :fifth if week > 0 return ((week - 1) * 7) + 1 + ((wday - Date.civil(year, month,(week-1)*7 + 1).wday) % 7) end days = MONTH_LENGTHS[month-1] days = 29 if month == 2 and Date.leap?(year) return days - ((Date.civil(year, month, days).wday - wday + 7) % 7) - (7 * (week.abs - 1)) end end