iainbeeston/nickel

View on GitHub
lib/nickel/zdate.rb

Summary

Maintainability
D
2 days
Test Coverage
require 'date'

module Nickel
  # TODO: get methods should accept dayname or dayindex
  class ZDate
    include Comparable

    @days_of_week               = %w(mon tue wed thu fri sat sun)
    @full_days_of_week          = %w(monday tuesday wednesday thursday friday saturday sunday)
    @months_of_year             = %w(jan feb mar apr may jun jul aug sep oct nov dec)
    @full_months_of_year        = %w(january february march april may june july august september october november december)
    @days_in_common_year_months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    @days_in_leap_year_months   = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

    MON = 0
    TUE = 1
    WED = 2
    THU = 3
    FRI = 4
    SAT = 5
    SUN = 6

    class << self
      attr_reader :days_of_week, :months_of_year, :days_in_common_year_months, :days_in_leap_year_months, :full_days_of_week, :full_months_of_year
    end

    # Don't use attr_accessor for date, year, month, day; we want to validate on change.
    def initialize(yyyymmdd = nil)
      d = yyyymmdd ? yyyymmdd.dup : ::Time.new.strftime('%Y%m%d')
      d.gsub!(/-/, '') # remove any hyphens, so a user can initialize with something like "2008-10-23"
      self.date = d
    end

    def date
      @date
    end

    def date=(yyyymmdd)
      @date = yyyymmdd
      validate
    end

    def year_str
      @date[0..3]
    end

    def month_str
      @date[4..5]
    end

    def day_str
      @date[6..7]
    end

    def year
      year_str.to_i
    end

    def month
      month_str.to_i
    end

    def day
      day_str.to_i
    end

    def readable
      month_str + '/' + day_str + '/' + year_str
    end

    def fmt(txt)
      txt.gsub!(/%Y/, year_str)
      txt.gsub!(/%m/, month_str)
      txt.gsub!(/%d/, day_str)
    end

    def <=>(other)
      return nil unless [:year, :month, :day].all? { |m| other.respond_to?(m) }

      if before?(other)
        -1
      elsif after?(other)
        1
      else
        0
      end
    end

    def is_today?
      warn '[DEPRECATION] `is_today?` is deprecated.  Please use `today?` instead.'
      today?
    end

    # returns true if self is today
    def today?
      self == ZDate.new
    end

    # for example, "1st friday", uses self as the reference month
    def ordinal_dayindex(num, day_index)
      # create a date object at the first occurrence of day_index
      first_occ_date = ZDate.new(ZDate.format_date(year_str, month_str)).this(day_index)
      # if num is 1 through 4, we can just add (num-1) weeks
      if num <= 4
        d = first_occ_date.add_weeks(num - 1)
      else
        # we want the last occurrence of this month
        # add 4 weeks to first occurrence, see if we are in the same month, subtract 1 week if we are not
        d = first_occ_date.add_weeks(4)
        if d.month != month
          d = d.sub_weeks(1)
        end
      end
      d
    end

    # for example, "this friday"
    def this(day)
      x_weeks_from_day(0, day)
    end

    # for example, "next friday"
    def next(day)
      x_weeks_from_day(1, day)
    end

    # for example, "previous friday"
    def prev(day)
      (dayindex == day) ? dup : x_weeks_from_day(-1, day)
    end

    # returns a new date object
    def x_weeks_from_day(weeks_away, day2index)
      day1index = dayindex
      if day1index > day2index
        days_away = 7 * (weeks_away + 1) - (day1index - day2index)
      elsif day1index < day2index
        days_away = (weeks_away * 7) + (day2index - day1index)
      elsif day1index == day2index
        days_away = 7 * weeks_away
      end
      add_days(days_away)  # returns a new date object
    end

    # add_ methods return new ZDate object, they DO NOT modify self
    def add_days(number)
      if number < 0
        return sub_days(number.abs)
      end
      o = dup  # new ZDate object
      # Let's see what month we are going to end in
      while number > 0
        if o.days_left_in_month >= number
          o.date = ZDate.format_date(o.year_str, o.month_str, o.day + number)
          number = 0
        else
          number = number - 1 - o.days_left_in_month  # it costs 1 day to increment the month
          o.increment_month!
        end
      end
      o
    end

    def add_weeks(number)
      add_days(7 * number)
    end

    def add_months(number)
      new_month = 1 + ((month - 1 + number) % 12)
      if number > months_left_in_year            # are we going to change year?
        years_to_increment = 1 + ((number - months_left_in_year) / 12)    # second term adds years if user entered a large number of months (e.g. date.add_months(50))
      else
        years_to_increment = 0
      end
      new_year = year + years_to_increment
      new_day = get_day_or_max_day_in_month(day, new_month, new_year)
      ZDate.new(ZDate.format_date(new_year, new_month, new_day))
    end

    def add_years(number)
      new_year = year + number
      new_day = get_day_or_max_day_in_month(day, month, new_year)
      ZDate.new(ZDate.format_date(new_year, month_str, new_day))
    end

    # DEPRECATED, change_ methods in ZTime modify self, this was confusing,
    # change_ methods return new ZDate object, they DO NOT modify self
    # def change_year_to(y)
    #   o = ZDate.new(ZDate.format_date(y, self.month_str, self.day_str))
    #   o
    # end
    # def change_day_to(d)
    #   o = ZDate.new(ZDate.format_date(self.year_str, self.month_str, d))
    #   o
    # end

    # returns new ZDate object, note this is the MONTH NUMBER, not MONTH INDEX from ZDate.months_of_year
    # returns the first day of the month
    def jump_to_month(month_number)
      # find difference in months
      if month_number >= month
        ZDate.new(ZDate.format_date(year_str, month_number))
      else
        ZDate.new(ZDate.format_date(year + 1, month_number))
      end
    end

    # beginning and end of month both return new ZDate objects
    def beginning_of_month
      ZDate.new(ZDate.format_date(year_str, month_str))
    end

    def end_of_month
      ZDate.new(ZDate.format_date(year_str, month_str, days_in_month))
    end

    def beginning_of_next_month
      o = dup
      o.increment_month!
      o
    end

    # sub_ methods return new ZDate object, they do not modify self.
    def sub_days(number)
      o = dup
      while number > 0
        if (o.day - 1) >= number
          o.date = ZDate.format_date(o.year_str, o.month_str, o.day - number)
          number = 0
        else
          number -= o.day
          o.decrement_month!
        end
      end
      o
    end

    def sub_weeks(number)
      sub_days(7 * number)
    end

    def sub_months(number)
      o = dup
      number.times do
        o.decrement_month!
      end
      o
    end

    # Gets the absolute difference in days between self and date_to_compare, order is not important.
    def diff_in_days(date_to_compare)
      # d1 will be the earlier date, d2 the later
      if date_to_compare > self
        d1, d2 = dup, date_to_compare.dup
      elsif self > date_to_compare
        d1, d2 = date_to_compare.dup, dup
      else
        return 0  # same date
      end

      total = 0
      while d1.year != d2.year
        total += d1.days_left_in_year + 1 # need one extra day to push us to jan 1
        d1 = ZDate.new(ZDate.format_date(d1.year + 1))
      end
      total += d2.day_of_year - d1.day_of_year
      total
    end

    def diff_in_days_to_this(closest_day_index)
      if closest_day_index >= dayindex
        closest_day_index - dayindex  # could be 0
      else   # day_num < self.dayindex
        7 - (dayindex - closest_day_index)
      end
    end

    # We need days_in_months and diff_in_months to be available at the class level as well.
    class << self
      def new_first_day_in_month(month, year)
        ZDate.new(ZDate.format_date(year, month))
      end

      def new_last_day_in_month(month, year)
        day = days_in_month(month, year)
        ZDate.new(ZDate.format_date(year, month, day))
      end

      def days_in_month(month, year)
        if year % 400 == 0 || year % 4 == 0 && year % 100 != 0
          ZDate.days_in_leap_year_months[month - 1]
        else
          ZDate.days_in_common_year_months[month - 1]
        end
      end

      # Gets the difference FROM month1, year1 TO month2, year2
      # don't use it the other way around, it won't work
      def diff_in_months(month1, year1, month2, year2)
        # first get the difference in months
        if month2 >= month1
          diff_in_months = month2 - month1
        else
          diff_in_months = 12 - (month1 - month2)
          year2 -= 1  # this makes the next line nice
        end
        diff_in_months + (year2 - year1) * 12
      end

      def format_year(y)
        # if there were only two digits, prepend 20 (e.g. "08" should be "2008")
        y.to_s.rjust(4, '20')
      end

      def format_month(m)
        m.to_s.rjust(2, '0')
      end

      def format_day(d)
        d.to_s.rjust(2, '0')
      end

      # formats the year, month, day into the format expected by the ZDate constructor
      def format_date(year, month = 1, day = 1)
        format_year(year) + format_month(month) + format_day(day)
      end

      # Interpret Date is equally as important, our goals:
      # First off, convention of the NLP is to not allow month names to the construct finder (unless it is implying date span), so we will not be interpreting
      # anything such as january 2nd, 2008.  Instead all dates will be represented in this form month/day/year.  However it may not
      # be as nice as that.  We need to match things like '5', if someone just typed in "the 5th."  Because of this, there will be
      # overlap between interpret_date and interpret_time in matching; interpret_date should ALWAYS be found after interpret_time in
      # the construct finder.  If the construct finder happens upon a digit on it's own, e.g. "5", it will not run interpret_time
      # because there is no "at" preceeding it.  Therefore it will fall through to the finder with interpret_date and we will assume
      # the user meant the 5th.  If interpret_date is before interpret_time, then .... wait... does the order actually matter?  Even if
      # this is before interpret_time, it shouldn't get hit because the time should be picked up at the "at" construct.  This may be a bunch
      # of useless rambling.
      #
      # 2/08      <------ This is not A date
      # 2/2008    <------ Neither is this, but I can see people using these as wrappers, must support this in next version
      # 11/08     <------ same
      # 11/2008   <------ same
      # 2/1/08,   2/12/08,  2/1/2008,   2/12/2008
      # 11/1/08,  11/12/08, 11/1/2008, 11/12/2008
      # 2/1     feb first
      # 2/12    feb twelfth
      # 11/1    nov first
      # 11/12   nov twelfth
      # 11      the 11th
      # 2       the 2nd
      #
      #
      # Match all of the following:
      #   a.) 1   10
      #   b.) 1/1  1/12  10/1  10/12
      #   c.) 1/1/08 1/12/08 1/1/2008 1/12/2008 10/1/08 10/12/08 10/12/2008 10/12/2008
      #   d.) 1st 10th
      def interpret(str, current_date)
        day_str, month_str, year_str = nil, nil, nil
        ambiguous = { month: false, year: false }   # assume false, we use this flag if we aren't certain about the year

        # appropriate matches
        a_d = /^(\d{1,2})(rd|st|nd|th)?$/     # handles cases a and d
        b = /^(\d{1,2})\/(\d{1,2})$/          # handles case b
        c = /^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/   # handles case c

        if mdata = str.match(a_d)
          ambiguous[:month] = true
          day_str = mdata[1]
        elsif mdata = str.match(b)
          ambiguous[:year] = true
          month_str = mdata[1]
          day_str = mdata[2]
        elsif mdata = str.match(c)
          month_str = mdata[1]
          day_str = mdata[2]
          year_str = mdata[3]
        else
          return nil
        end

        inst_str = ZDate.format_date(year_str || current_date.year_str, month_str || current_date.month_str, day_str || current_date.day_str)
        # in this case we do not care if date fails validation, if it does, it just means we haven't found a valid date, return nil
        date = ZDate.new(inst_str) rescue nil
        if date
          if ambiguous[:year]
            # say the date is 11/1 and someone enters 2/1, they probably mean next year, I pick 4 months as a threshold but that is totally arbitrary
            current_date.diff_in_months(date) < -4 && date = date.add_years(1)
          elsif ambiguous[:month]
            current_date.day > date.day && date = date.add_months(1)
          end
        end
        date
      end
    end

    # difference in months FROM self TO date2, for instance, if self is oct 1 and date2 is nov 14, will return 1
    # if self is nov 14 and date2 is oct 1, will return -1
    def diff_in_months(date2)
      if date2 > self
        ZDate.diff_in_months(month, year, date2.month, date2.year)
      else
        ZDate.diff_in_months(date2.month, date2.year, month, year) * -1
      end
    end

    def days_in_month
      if leap_year?
        ZDate.days_in_leap_year_months[month - 1]
      else
        ZDate.days_in_common_year_months[month - 1]
      end
    end

    def days_left_in_month
      days_in_month - day
    end

    def months_left_in_year
      12 - month
    end

    def dayname
      # well this is going to be a hack, I need an algo for finding the day
      # Ruby's Time.local is the fastest way to create a Ruby Time object
      t = ::Time.local(year, ZDate.months_of_year[month - 1], day)
      t.strftime('%a').downcase
    end

    def dayindex
      ZDate.days_of_week.index(dayname)
    end

    def full_dayname
      ZDate.full_days_of_week[dayindex]
    end

    def full_monthname
      Z.full_months_of_year[month - 1]
    end

    def leap_year?
      year % 400 == 0 || year % 4 == 0 && year % 100 != 0
    end

    def day_of_year
      doy = day
      # iterate through days in months arrays, summing up the days
      if leap_year?
        doy = (1...month).to_a.reduce(doy) { |sum, n| sum + ZDate.days_in_leap_year_months[n - 1] }
      else
        doy = (1...month).to_a.reduce(doy) { |sum, n| sum + ZDate.days_in_common_year_months[n - 1] }
      end
      doy
    end

    def days_left_in_year
      leap_year? ? 366 - day_of_year : 365 - day_of_year
    end

    def get_date_from_day_and_week_of_month(day_num, week_num)
      # This method is extremely sloppy, clean it up
      # Get the index of the first day of this month
      first_day_of_month = beginning_of_month
      first_day_index = first_day_of_month.dayindex

      diff_in_days_to_first_occ = first_day_of_month.diff_in_days_to_this(day_num)

      # now find the number of days to the correct occurrence; REMEMBER TO CHECK FOR LAST MONTH
      if week_num == -1
        total_diff_in_days = diff_in_days_to_first_occ + 21      # 7 * 3 weeks; are already at the first ocurrence, so this is total diff in days to 4th occurrence; may not be the last!!
      else
        total_diff_in_days = diff_in_days_to_first_occ + 7 * (week_num - 1)
      end

      # there is a chance that the last occurrence is not the 4th week of the month; if that is the case, add an extra 7 days
      if (week_num == -1) && (month == beginning_of_month.add_days(total_diff_in_days + 7).month)
        total_diff_in_days += 7
      end

      # Now we have the number of days FROM THE START OF THE CURRENT MONTH; if we are not past that date, then we have found the first occurrence
      if (total_diff_in_days + 1) >= day
        return beginning_of_month.add_days(total_diff_in_days)
      else # We have already past the date; calculate the occurrence next month!
        # Get the index of the first day next month
        first_day_index = add_months(1).beginning_of_month.dayindex

        # Find the number of days away to the day of interest (NOT the week)
        if day_num > first_day_index
          diff_in_days_to_first_occ = day_num - first_day_index
        elsif day_num < first_day_index
          diff_in_days_to_first_occ = 7 - (first_day_index - day_num)
        else # first_day_index == day_num
          diff_in_days_to_first_occ = 0
        end

        # now find the number of days to the correct occurrence; REMEMBER TO CHECK FOR LAST MONTH
        if week_num == -1
          total_diff_in_days = diff_in_days_to_first_occ + 21      # 7 * 3 weeks
        else
          total_diff_in_days = diff_in_days_to_first_occ + 7 * (week_num - 1)
        end

        # there is a chance that the last occurrence is not the 4th week of the month; if that is the case, add an extra 7 days
        if (week_num == -1) && (add_months(1).month == add_months(1).beginning_of_month.add_days(total_diff_in_days + 7).month)
          total_diff_in_days += 7
        end

        return add_months(1).beginning_of_month.add_days(total_diff_in_days)
      end # END if (total_diff_in_days + 1) ...
    end

    # returns a new ZDate object, NOTE! this returns nil if that date does not exist (sept 31st)
    def get_next_date_from_date_of_month(date_of_month)
      o = dup
      if day == date_of_month
        o
      else
        if day > date_of_month
          o.increment_month!
        end
        ZDate.new(ZDate.format_date(o.year_str, o.month_str, date_of_month)) rescue nil
      end
    end

    def to_date
      Date.new(year, month, day)
    end

    def to_s
      date
    end

    protected

    # Modifies self.
    # bumps self to first day of next month
    def increment_month!
      if month != 12
        # just bump up a number
        self.date = ZDate.format_date(year_str, month + 1)
      else
        self.date = ZDate.format_date(year + 1)
      end
    end

    def decrement_month!
      if month != 1
        # just bump down a number and set days to the last day in the month
        self.date = ZDate.format_date(year_str, month - 1, ZDate.days_in_month(month - 1, year))
      else
        self.date = ZDate.format_date(year - 1, 12, 31)    # dec has 31 days
      end
    end

    private

    def before?(other)
      (year < other.year) || (year == other.year && (month < other.month || (month == other.month && day < other.day)))
    end

    def after?(other)
      (year > other.year) || (year == other.year && (month > other.month || (month == other.month && day > other.day)))
    end

    def validate
      fail 'ZDate says: invalid date' unless valid
    end

    def valid
      # It is important that valid_day is last because we have to do the days_in_month calculation!!!
      @date.length == 8 && @date !~ /\D/ && valid_year && valid_month && valid_day
    end

    def valid_year
      year >= 1900
    end

    def valid_month
      month >= 1 && month <= 12
    end

    def valid_day
      day >= 1 && day <= days_in_month
    end

    def get_day_or_max_day_in_month(day, month, year)
      # if day exists in month/year then use it, if it is not then use the last day of the month
      dm = ZDate.days_in_month(month, year)
      dm >= day ? day : dm
    end
  end
end