sealink/timely

View on GitHub
lib/timely/temporal_patterns/pattern.rb

Summary

Maintainability
A
3 hrs
Test Coverage
require 'active_support/core_ext/integer/inflections' # ordinalize

module Timely
  module TemporalPatterns
    class Pattern
      attr_reader :intervals, :frequency

      def initialize(ranges, frequency)
        @intervals = Array.wrap(ranges).map { |r| Interval.new(r.first, r.last) }.sort_by(&:first_datetime)
        @frequency = Frequency.new(frequency)
        fix_frequency
      end

      # Convert each interval to a list of datetimes
      def datetimes
        intervals.map do |interval|
          datetimes = []
          datetime = interval.first_datetime
          while datetime <= interval.last_datetime
            datetimes << datetime
            datetime = datetime + frequency.duration
          end
          datetimes
        end
      end

      def ranges
        intervals.map { |i| (i.first_datetime..i.last_datetime) }
      end

      def first_datetime
        surrounding_interval.first_datetime
      end

      def last_datetime
        surrounding_interval.last_datetime
      end

      def surrounding_interval
        Interval.surrounding(intervals)
      end
      alias_method :interval, :surrounding_interval # backwards compatibility

      def match?(datetimes)
        datetimes = Array.wrap(datetimes).map(&:to_datetime)
        intervals.each do |interval|
          current_datetime = interval.first_datetime
          while current_datetime <= interval.last_datetime
            datetimes.delete_if { |datetime| datetime == current_datetime }
            return true if datetimes.empty?
            current_datetime = current_datetime + frequency.duration
          end
        end
        false
      end

      def <=>(other)
        self.intervals.count <=> other.intervals.count
      end

      # Join with other IF same frequency AND same number of intervals
      def join(other)
        return nil unless self.frequency == other.frequency

        expanded_datetimes = self.datetimes.map { |datetimes_within_an_interval|
          back_one    = datetimes_within_an_interval.first - frequency.duration
          forward_one = datetimes_within_an_interval.last + frequency.duration

          [back_one] + datetimes_within_an_interval + [forward_one]
        }

        joint_ranges = []

        # Look for overlaps, where an overlap may be 'off by 1' -- hence the 'expanded_datetimes'
        # ...but start with other and join to each of it's intervals.
        #
        # Remember that 'pattern.datetimes' returns a list of datetimes per interval
        other.datetimes.each do |other_datetimes_within_an_interval|

          joinable_datetimes = expanded_datetimes.find { |expanded_datetimes_within_an_interval|
            other_datetimes_within_an_interval.any? { |d|
              expanded_datetimes_within_an_interval.include?(d)
            }
          }
          break unless joinable_datetimes

          # Joint ranges should be those that overlap
          #
          # This is buggy, because joinable_datetimes is a list of datetimes per interval that overlap
          # Excluding the first doesn't make sense
          #
          # Instead, we should exclude the first AND last for each element within joinable_datetimes 
          joint_datetimes = (other_datetimes_within_an_interval + joinable_datetimes[1...-1]).sort
          joint_ranges << (joint_datetimes.first..joint_datetimes.last)
        end

        # This seems to be trying to say "Only join when we got one for each interval of self"
        # ...it also seems too restrictive...
        #
        # What if other includes multiple intervals of self?
        # Then we don't need same number of intervals
        #
        # Also might be wrong in other ways, it's tricky to tell
        if joint_ranges.size == self.intervals.size
          Pattern.new(joint_ranges, frequency.duration)
        end
      end

      def to_s
        single_date_intervals, multiple_dates_intervals = intervals.partition { |i| i.first_datetime == i.last_datetime}
        patterns_strings = if multiple_dates_intervals.empty?
          single_date_intervals.map(&:to_s)
        else
          interval_surrounding_multiple_dates = Interval.surrounding(multiple_dates_intervals)

          multiple_dates_intervals_string = case frequency.unit
          when :years
            "every #{multiple_dates_intervals.map { |i| "#{i.first_datetime.day.ordinalize} of #{i.first_datetime.strftime('%B')}" }.uniq.to_sentence} #{interval_surrounding_multiple_dates}"
          when :months
            "every #{multiple_dates_intervals.map { |i| i.first_datetime.day.ordinalize }.uniq.to_sentence} of the month #{interval_surrounding_multiple_dates}"
          when :weeks
            weekdays = multiple_dates_intervals.map { |i| i.first_datetime.strftime('%A') }.uniq
            if weekdays.count == 7
              "every day #{interval_surrounding_multiple_dates}"
            else
              "every #{weekdays.to_sentence} #{interval_surrounding_multiple_dates}"
            end
          when :days
            if multiple_dates_intervals.any? { |i| i.first_datetime != i.first_datetime.beginning_of_day }
              "every day at #{multiple_dates_intervals.map { |i| i.first_datetime.strftime("%I:%M %p") }.to_sentence} #{interval_surrounding_multiple_dates}"
            else
              "every day #{interval_surrounding_multiple_dates}"
            end
          else
            "#{frequency} #{multiple_dates_intervals.map(&:to_s).to_sentence}"
          end
          [multiple_dates_intervals_string] + single_date_intervals.map(&:to_s)
        end
        patterns_strings.to_sentence
      end

      private

      # Fix the time units inconsistency problem
      # e.g.: a year isn't exactly 12 months, it's a little bit more, but it is commonly considered to be equal to 12 months
      def fix_frequency
        return unless frequency.duration > 1.month
        if frequency.duration < 12.months
          if intervals.all? { |i| i.first_datetime.day == i.last_datetime.day }
            frequency.duration = frequency.units[:months].months
          end
        else
          if intervals.all? { |i| i.first_datetime.month == i.last_datetime.month && i.first_datetime.day == i.last_datetime.day }
            frequency.duration = (frequency.duration / 12.months).floor.years
          end
        end
      end
    end
  end
end