lib/aixm/schedule/date.rb
using AIXM::Refinements
module AIXM
module Schedule
# Dates suitable for schedules
#
# This class implements the bare minimum of stdlib +Date+ and adds some
# extensions:
#
# * yearless dates
# * {covered_by?} to check whether (yearless) date falls within (yearless)
# date range
#
# @example
# date = AIXM.date('2022-04-20') # => 2022-04-20
# from = AIXM.date('03-20') # => XXXX-03-20
# date.covered_by?(from..AIXM.date('05-20')) # => true
class Date
include AIXM::Concerns::HashEquality
include Comparable
extend Forwardable
YEARLESS_YEAR = 0
# @api private
attr_accessor :date
# Parse the given representation of (yearless) date.
#
# @param date [Date, Time, String] either stdlib Date/Time, "YYYY-MM-DD",
# "XXXX-MM-DD" or "MM-DD" (yearless date)
def initialize(date)
@date = case date.to_s[0, 10]
when /\A\d{4}-\d{2}-\d{2}\z/
::Date.strptime(date.to_s)
when /\A(?:XXXX-)?(\d{2}-\d{2})\z/
::Date.strptime("#{YEARLESS_YEAR}-#{$1}")
else
fail ArgumentError
end
rescue ::Date::Error
raise ArgumentError
end
# Human readable representation such as "2002-05-19" or "XXXX-05-19"
#
# All formats from {strftime}[https://www.rubydoc.info/stdlib/date/Date#strftime-instance_method]
# are supported, however, +%Y+ is replaced with "XXXX" for yearless dates.
# Any other formats containing the year won't do so and should be avoided!
#
# @param format [String] see {strftime}[https://www.rubydoc.info/stdlib/date/Date#strftime-instance_method]
# @return [String]
def to_s(format='%Y-%m-%d')
@date.strftime(yearless? ? format.gsub('%Y', 'XXXX') : format)
end
def inspect
%Q(#<#{self.class} #{to_s}>)
end
# Creates a new date with the given parts altered.
#
# @example
# date = AIXM.date('2000-12-22')
# date.at(month: 4) # => 2000-04-22
# date.at(year: 2020, day: 5) # => 2020-12-05
# date.at(month: 1) # => 2020-01-22
# date.at(month: 1, wrap: true) # => 2021-01-22 (year incremented)
#
# @param year [Integer] new year
# @param month [Integer] new month
# @param day [Integer] new day
# @param wrap [Boolean] whether to increment month when crossing month
# boundary and year when crossing year boundary
# @return [AIXM::Schedule::Date]
def at(year: nil, month: nil, day: nil, wrap: false)
return self unless year || month || day
wrap_month, wrap_year = day&.<(date.day), month&.<(date.month)
date = ::Date.new(year || self.year || YEARLESS_YEAR, month || self.month, day || self.day)
date = date.next_month if wrap && wrap_month && !month
date = date.next_year if wrap && wrap_year && !year
self.class.new(date.strftime(yearless? ? '%m-%d' : '%F'))
end
# Create new date one day prior to this one.
#
# @return [AIXM::Schedule::Date]
def pred
self.class.new(date.prev_day.to_s.sub(/^-/, '')).at(year: (YEARLESS_YEAR if yearless?))
end
alias_method :prev, :pred
# Create new date one day after this one.
#
# @return [AIXM::Schedule::Date]
def succ
self.class.new(date.next_day).at(year: (YEARLESS_YEAR if yearless?))
end
alias_method :next, :succ
# Calculate difference in days between two dates.
#
# @return [Integer]
def -(date)
(self.date - date.date).to_i
end
# Convert date to day
#
# @raise [RuntimeError] if date is yearless
# @return [AIXM::Schedule::Day]
def to_day
fail "cannot convert yearless date" if yearless?
AIXM.day(date.wday)
end
# Stdlib Date equivalent using the value of {YEARLESS_YEAR} to represent a
# yearless date.
#
# @return [Date]
def to_date
@date
end
# Whether the other schedule date can be compared to this one.
#
# @param other [AIXM::Schedule::Date]
# @return [Boolean]
def comparable_to?(other)
other.instance_of?(self.class) && yearless? == other.yearless?
end
def <=>(other)
fail "not comparable" unless comparable_to? other
@date.jd <=> other.to_date.jd
end
# @see Object#hash
def hash
[self.class, @date.jd].hash
end
# Whether this schedule date is yearless or not.
#
# @return [Boolean]
def yearless?
@date.year == YEARLESS_YEAR
end
# Yearless duplicate of self
#
# @return [AIXM::Schedule::Date]
def to_yearless
yearless? ? self : self.class.new(to_s[5..])
end
# @return [Integer] year or +nil+ if yearless
def year
@date.year unless yearless?
end
# @!method month
# @return [Integer]
# @!method day
# @return [Integer] day of month
def_delegators :@date, :month, :day
# Whether this schedule date falls within the given range of schedule dates
#
# @note It is possible to compare dates as well as days.
#
# @param other [AIXM::Schedule::Date, Range<AIXM::Schedule::Date>,
# AIXM::Schedule::Day, Range<AIXM::Schedule::Day>] single schedule
# date/day or range of schedule dates/days
# @return [Boolean]
def covered_by?(other)
range = Range.from(other)
case
when range.first.instance_of?(AIXM::Schedule::Day)
range.first.any? || to_day.covered_by?(range)
when range.first.yearless?
yearless? ? covered_by_yearless_date?(range) : to_yearless.covered_by?(range)
else
yearless? ? covered_by_yearless_date?(range.first.to_yearless..range.last.to_yearless) : range.cover?(self)
end
end
private
def covered_by_yearless_date?(range)
range.min ? range.cover?(self) : range.first <= self || self <= range.last
end
end
end
end