aldesantis/artic

View on GitHub
lib/artic/time_range.rb

Summary

Maintainability
A
35 mins
Test Coverage
# frozen_string_literal: true

module Artic
  # Represents a range of two times in the same day (e.g. 09:00-17:00).
  #
  # @author Alessandro Desantis
  class TimeRange < Range
    TIME_REGEX = /\A^(0\d|1\d|2[0-3]):[0-5]\d$\z/.freeze

    class << self
      # Builds a time range from the provided value.
      #
      # @param range [Range|TimeRange] a range of times (e.g. +'09:00'..'18:00'+ or
      #   a +TimeRange+ object)
      #
      # @return [TimeRange] the passed time range or a new time range
      def build(range)
        range.is_a?(TimeRange) ? range : TimeRange.new(range.begin, range.end)
      end
    end

    # Initializes a new range.
    #
    # @param start_time [String] the start time (in HH:MM format)
    # @param end_time [String] the end time (in HH:MM format)
    #
    # @raise [ArgumentError] if the start time or the end time is invalid
    # @raise [ArgumentError] if the start time is after the end time
    def initialize(start_time, end_time)
      super
      validate_range
    end

    # Returns whether the two ranges overlap (i.e. whether there's at least a moment in time that
    # belongs to both ranges).
    #
    # @param other [TimeRange]
    #
    # @return [Boolean]
    def overlaps?(other)
      self.begin <= other.end && self.end >= other.begin
    end

    # Returns whether this range completely covers the one given.
    #
    # @param other [TimeRange]
    #
    # @return [Boolean]
    def covers?(other)
      self.begin <= other.begin && self.end >= other.end
    end

    # Returns a range of +Time+ objects for this time range.
    #
    # @param date [Date] the date to use for the range
    #
    # @return [Range]
    def with_date(date)
      Range.new(
        Time.parse("#{date} #{self.begin}"),
        Time.parse("#{date} #{self.end}")
      )
    end

    # Uses this range to bisect another.
    #
    # If this range does not overlap the other, returns an array with the original range.
    #
    # If this range completely covers the other, returns an empty array.
    #
    # @param other [TimeRange]
    #
    # @return [Array<TimeRange>]
    #
    # @example
    #   range1 = TimeRange.new('10:00'..'12:00')
    #   range2 = TimeRange.new('09:00'..'18:00')
    #   range3 = TimeRange.new('08:00'..'11:00')
    #
    #   range1.bisect(range2)
    #   # => [
    #   #   #<TimeRange 09:00..10:00>,
    #   #   #<TimeRange 12:00..18:00>
    #   # ]
    #
    #   range2.bisect(range1)
    #   # => []
    #
    #   range3.bisect(range2)
    #   # => [
    #   #   #<TimeRange 11:00..18:00>
    #   # ]
    #
    #   range2.bisect(range3)
    #   # => [
    #   #   #<TimeRange 08:00..09:00>
    #   # ]
    def bisect(other)
      return [other] unless overlaps?(other)
      return [] if covers?(other)

      if self.begin <= other.begin && self.end <= other.end
        [self.end..other.end]
      elsif self.begin >= other.begin && self.end <= other.end
        [
          (other.begin..self.begin),
          (self.end..other.end)
        ]
      elsif self.begin >= other.begin && self.end >= other.end
        [other.begin..self.begin]
      end
    end

    private

    def validate_range
      unless TIME_REGEX.match?(self.begin)
        fail(
          ArgumentError,
          "#{self.begin} is not a valid time"
        )
      end

      unless TIME_REGEX.match?(self.end)
        fail(
          ArgumentError,
          "#{self.end} is not a valid time"
        )
      end

      if self.begin > self.end
        fail(
          ArgumentError,
          "#{self.begin} is greater than #{self.end}"
        )
      end
    end
  end
end