lib/aixm/schedule/time.rb

Summary

Maintainability
A
0 mins
Test Coverage
using AIXM::Refinements

module AIXM
  module Schedule

    # Times suitable for schedules
    #
    # This class implements the bare minimum of stdlib +Time+ and adds some
    # extensions:
    #
    # * converts to UTC
    # * date, seconds and milliseconds are ignored
    # * {covered_by?} to check whether schedule time falls within range of times
    #
    # @note The {DATELESS_DATE} is used to mark the date of the internal +Time"
    #   object irrelevant. However, Ruby does not persist end of days as 24:00,
    #   therefore {DATELESS_DATE} + 1 marks this case.
    #
    # @example
    #   time = AIXM.time('21:30')                                  # => 21:30
    #   time.covered_by?(AIXM.time('20:00')..AIXM.time('02:00'))   # => true
    #
    # ===Shortcuts:
    # * +AIXM::BEGINNING_OF_DAY+ - midnight expressed as "00:00"
    # * +AIXM::END_OF_DAY+ - midnight expressed as "24:00"
    class Time
      include AIXM::Concerns::HashEquality
      extend Forwardable

      EVENTS = { sunrise: :up, sunset: :down }.freeze
      PRECEDENCES = { first: :min, last: :max }.freeze
      DATELESS_DATE = ::Date.parse('0001-01-01').freeze

      # @api private
      attr_accessor :time

      # Event or alternative to time
      #
      # @return [Symbol, nil] any key from {EVENTS}
      attr_reader :event

      # Minutes added or subtracted from event
      #
      # @return [Integer, nil]
      attr_reader :delta

      # Precedence of time vs. event
      #
      # @return [Symbol, nil] any key of {PRECEDENCES}
      attr_reader :precedence

      # Parse the given representation of time.
      #
      # @note Unlike its twin from the stdlib, this class differs between
      #   +AIXM.time('00:00')+ (beginning of day) and +AIXM.time('24:00')+
      #   (end of day).
      #
      # @example
      #   AIXM.time('08:00')
      #   AIXM.time(:sunrise)
      #   AIXM.time(:sunrise, plus: 30)
      #   AIXM.time('08:00', or: :sunrise)
      #   AIXM.time('08:00', or: :sunrise, plus: 30)
      #   AIXM.time('08:00', or: :sunrise, minus: 15)
      #   AIXM.time('08:00', or: :sunrise, whichever_comes: :last)
      #
      # @param time_or_event [Time, DateTime, String, Symbol] either time as
      #   stdlib Time or DateTime, "HH:MM" (implicitly UTC), "HH:MM [+-]00:00",
      #   "HH:MM UTC" or any key from {EVENTS}
      # @param or [Symbol] alternative event, any key from {EVENTS}
      # @param plus [Integer] minutes added to event
      # @param minus [Integer] minutes subtracted from event
      # @param whichever_comes [Symbol] any key from {PRECEDENCES}
      def initialize(time_or_event, or: nil, plus: 0, minus: 0, whichever_comes: :first)
        alternative_event = binding.local_variable_get(:or)   # necessary since "or" is a keyword
        @time = @event = @precedence = nil
        case time_or_event
        when Symbol
          self.event = time_or_event
        when ::Time, ::DateTime
          time_or_event = time_or_event.to_time
          set_time(time_or_event.hour, time_or_event.min, time_or_event.utc_offset)
        when /\A(\d{2}):?(\d{2}) ?([+-]\d{2}:?\d{2}|UTC)?\z/
          set_time($1, $2, $3)
        else
          fail(ArgumentError, "time or event not recognized")
        end
        fail(ArgumentError, "only one event allowed") if event && alternative_event
        self.event ||= alternative_event
        @delta = event ? plus - minus : 0
        if @time && event
          self.precedence = whichever_comes
          fail(ArgumentError, "mandatory precedence missing") unless precedence
        end
      end

      # Human readable representation
      #
      # The format recognises does the following interpolations:
      # * +%R+ - "HH:MM" in UTC if time is present, "" otherwise
      # * +%z+ - "UTC" if time is present, "" otherwise
      # * +%o+ - "or" if both time and event are present, "" otherwise
      # * +%E+ - "sunrise-15min" if no event is present, "" otherwise
      # * +%P+ - "whichever comes first" if precedence is present, "" otherwise
      #
      # @param format [String]
      # @return [String]
      def to_s(format='%R %z %o %E %P')
        format.gsub(/%[RzoEP]/,
          '%R' => (sprintf("%02d:%02d", hour, min) if @time),
          '%z' => ('UTC' if @time),
          '%o' => ('or' if @time && event),
          '%E' => "#{event}#{sprintf("%+dmin", delta) unless delta.zero?}",
          '%P' => ("whichever comes #{precedence}" if precedence)
        ).compact
      end

      def inspect
        %Q(#<#{self.class} #{to_s}>)
      end

      # Creates a new time with the given parts altered.
      #
      # @example
      #   time = AIXM.time('22:12')
      #   time.at(min: 0)               # => 22:00
      #   time.at(min: 0 wrap: true)   # => 2021-01-22 (year incremented)
      #
      # @param hour [Integer] new hour
      # @param min [Integer] new minutes
      # @param wrap [Boolean] whether to increment hour when crossing minutes
      #   boundary
      # @return [AIXM::Schedule::Date]
      def at(hour: nil, min: nil, wrap: false)
        return self unless hour || min
        min ||= time.min
        hour ||= time.hour
        hour = hour + 1 if wrap && min < time.min
        hour = hour % 24 unless min.zero?
        self.class.new("%02d:%02d" % [hour, min])
      end

      # Resolve event to simple time
      #
      # * If +self+ doesn't have any event, +self+ is returned.
      # * Otherwise a new time is created with the event resolved for the
      #   given date and geographical location.
      #
      # @example
      #   time = AIXM.time('21:00', or: :sunset, minus: 30, whichever_cones: first)
      #   time.resolve(on: AIXM.date('2000-08-01'), at: AIXM.xy(lat: 48.8584, long: 2.2945))
      #   # => 20:50
      #
      # @param on [AIXM::Date] defaults to today
      # @param xy [AIXM::XY]
      # @param round [Integer, nil] round up (sunrise) or down (sunset) to the
      #   given minutes or +nil+ in order not to round round
      # @return [AIXM::Schedule::Time, self]
      def resolve(on:, xy:, round: nil)
        if resolved?
          self
        else
          sun_time = self.class.new(Sun.send(event, on.to_date, xy.lat, xy.long).utc + (delta * 60))
          sun_time = self.class.new([sun_time.time, self.time].send(PRECEDENCES.fetch(precedence))) if time
          sun_time = sun_time.round(EVENTS.fetch(event) => round) if round
          sun_time
        end
      end

      # Whether this time is resolved and doesn't contain an event (anymore).
      #
      # @return [Boolean]
      def resolved?
        !event
      end

      # Round this time up or down.
      #
      # @param up [Integer, nil] round up to the next given minutes
      # @param down [Integer, nil] round down to the next given minutes
      # @return [AIXM::Schedule::Time, self]
      def round(up: nil, down: nil)
        step = up || down || fail(ArgumentError, "either up or down is mandatory")
        rounded_min = min / step * step
        if rounded_min == min
          self
        else
          rounded_min = (rounded_min + step) % 60 if up
          at(min: rounded_min, wrap: !!up)
        end
      end

      # Stdlib Time equivalent using the value of {DATELESS_DATE} to represent a
      # time only.
      #
      # @return [Time]
      def to_time
        @time
      end

      # Hour from 0 (beginning of day) to 24 (end of day)
      #
      # @return [Integer]
      def hour
        @time.hour + (end_of_day? ? 24 : 0)
      end

      # @!method min
      #   @return [Integer]
      def_delegators :@time, :min

      # Whether two times are equal.
      #
      # @return [Boolean]
      def ==(other)
        to_s == other.to_s
      end

      # Whether this schedule time is sortable.
      #
      # @return [Boolean]
      def sortable?
        !event
      end

      # Whether this schedule time falls within the given range of schedule
      # times.
      #
      # @param other [AIXM::Schedule::Time, Range<AIXM::Schedule::Time>] single
      #   schedule time or range of schedule times
      # @raise RuntimeError if either self is or the range contains an
      #   unsortable time with event
      # @return [Boolean]
      def covered_by?(other)
        range = Range.from(other)
        case
        when !sortable? || !range.first.sortable? || !range.last.sortable?
          fail "includes unsortables"
        when range.min
          range.first.to_s <= self.to_s && self.to_s <= range.last.to_s
        else
          range.first.to_s <= self.to_s || self.to_s <= range.last.to_s
        end
      end

      private

      # Set the +@time+ instance variable.
      #
      # @param hour [Integer, String]
      # @param min [Integer, String]
      # @param offset [Integer, String] either UTC offset in seconds
      #   (default: 0), as '+01:00', '-0300' or 'UTC'
      # @return [Time]
      def set_time(hour, min, offset)
# TODO: Colon-workaround can be removed when support of Ruby 3.0 has ended
        coloned_offset = offset.is_a?(String) ? offset.sub(/([+-]\d{2})(\d{2})/, '\1:\2') : offset
        utc = ::Time.new(1, 1, 1, hour, min, 0, coloned_offset || 0).utc
#       utc = ::Time.new(1, 1, 1, hour, min, 0, offset || 0).utc
        day_shift = utc.hour.zero? && utc.min.zero? && hour.to_i >= 12 ? 1 : 0
        @time = ::Time.utc(DATELESS_DATE.year, DATELESS_DATE.month, DATELESS_DATE.day + day_shift, utc.hour, utc.min, 0)
      end

      def event=(value)
        fail ArgumentError if value && !EVENTS.has_key?(value)
        @event = value
      end

      def precedence=(value)
        fail ArgumentError if value && !PRECEDENCES.has_key?(value)
        @precedence = value
      end

      def end_of_day?
        @time.day != DATELESS_DATE.day
      end

      # @note Necessary to use this class in Range.
      def <=>(other)
        to_time <=> other.to_time || to_s <=> other.to_s
      end
    end

  end
end