lib/et-orbi/time.rb



module EtOrbi

  # Our EoTime class (which quacks like a ::Time).
  #
  # An EoTime instance should respond to most of the methods ::Time instances
  # respond to. If a method is missing, feel free to open an issue to
  # ask (politely) for it. If it makes sense, it'll get added, else
  # a workaround will get suggested.
  # The immediate workaround is to call #to_t on the EoTime instance to get
  # equivalent ::Time instance in the local, current, timezone.
  #
  class EoTime

    #
    # class methods

    class << self

      def now(zone=nil)

        EtOrbi.now(zone)
      end

      def parse(str, opts={})

        EtOrbi.parse(str, opts)
      end

      def get_tzone(o)

        EtOrbi.get_tzone(o)
      end

      def local_tzone

        EtOrbi.determine_local_tzone
      end

      def platform_info

        EtOrbi.platform_info
      end

      def make(o)

        EtOrbi.make_time(o)
      end

      def utc(*a)

        EtOrbi.send(:make_from_array, a, EtOrbi.get_tzone('UTC'))
      end

      def local(*a)

        EtOrbi.send(:make_from_array, a, local_tzone)
      end
    end

    #
    # instance methods

    attr_reader :seconds
    attr_reader :zone

    def initialize(s, zone)

      z = zone
      z = nil if zone.is_a?(String) && zone.strip == ''
        #
        # happens with JRuby (and offset tzones like +04:00)
        #
        # $ jruby -r time -e "p Time.parse('2012-1-1 12:00 +04:00').zone"
        # # => ""
        # ruby -r time -e "p Time.parse('2012-1-1 12:00 +04:00').zone"
        # # => nil

      @seconds = s.to_f
      @zone = self.class.get_tzone(z || :local)

      fail ArgumentError.new(
        "Cannot determine timezone from #{zone.inspect}" +
        "\n#{EtOrbi.render_nozone_time(@seconds)}" +
        "\n#{EtOrbi.platform_info.sub(',debian:', ",\ndebian:")}" +
        "\nTry setting `ENV['TZ'] = 'Continent/City'` in your script " +
        "(see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)" +
        (defined?(TZInfo::Data) ? '' : "\nand adding gem 'tzinfo-data'")
      ) unless @zone

      touch
    end

    # Nullify the "caches" used by #to_time, #rweek, and others
    #
    def touch

      @time = nil
      @rday = nil
      @rweek = nil
    end

    def seconds=(f)

      @seconds = f

      touch

      f
    end

    def zone=(z)


      @zone = self.class.get_tzone(zone || :current)

      touch

      @zone
    end

    # Returns true if this EoTime instance corresponds to 2 different UTC
    # times.
    # It happens when transitioning from DST to winter time.
    #
    # https://www.timeanddate.com/time/change/usa/new-york?year=2018
    #
    def ambiguous?

      @zone.local_to_utc(@zone.utc_to_local(utc))

      false

    rescue TZInfo::AmbiguousTime

      true
    end

    # Returns this ::EtOrbi::EoTime as a ::Time instance
    # in the current UTC timezone.
    #
    def utc

      Time.utc(1970) + @seconds
    end

    # Returns true if this ::EtOrbi::EoTime instance timezone is UTC.
    # Returns false else.
    #
    def utc?

      %w[ gmt utc zulu etc/gmt etc/utc ].include?(
        @zone.canonical_identifier.downcase)
    end

    alias getutc utc
    alias getgm utc
    alias to_utc_time utc

    def to_f

      @seconds
    end

    def to_i

      @seconds.to_i
    end

    def strftime(format)

      format = format.gsub(/%(\/?Z|:{0,2}z)/) { |f| strfz(f) }

      to_time.strftime(format)
    end

    # Returns this ::EtOrbi::EoTime as a ::Time instance
    # in the current timezone.
    #
    # Has a #to_t alias.
    #
    def to_local_time

      Time.at(@seconds)
    end

    alias to_t to_local_time

    def is_dst?

      @zone.period_for_utc(utc).std_offset != 0
    end
    alias isdst is_dst?

    def to_debug_s

      uo = self.utc_offset
      uos = uo < 0 ? '-' : '+'
      uo = uo.abs
      uoh, uom = [ uo / 3600, uo % 3600 ]

      [
        'ot',
        self.strftime('%Y-%m-%d %H:%M:%S'),
        "%s%02d:%02d" % [ uos, uoh, uom ],
        "dst:#{self.isdst}"
      ].join(' ')
    end

    def utc_offset

      @zone.period_for_utc(utc).utc_total_offset
    end

    %w[
      year month day wday yday hour min sec usec asctime
    ].each do |m|
      define_method(m) { to_time.send(m) }
    end

    def ==(o)

      if o.is_a?(EoTime)
        o.seconds == @seconds &&
        (o.zone == @zone || o.zone.current_period == @zone.current_period)
      elsif o.is_a?(::Time)
        (to_f * 1000).to_i == (o.to_f * 1000).to_i
      else
        false
      end
    end

    # Nota Bene:
    #
    # Unlike ==, the equal? method should never be overridden by subclasses
    # as it is used to determine object identity (that is, a.equal?(b) if and
    # only if a is the same object as b)
    #
    # The eql? method returns true if obj and other refer to the same hash key.
    # This is used by Hash to test members for equality.

    def >(o); @seconds > _to_f(o); end
    def >=(o); @seconds >= _to_f(o); end
    def <(o); @seconds < _to_f(o); end
    def <=(o); @seconds <= _to_f(o); end
    def <=>(o); @seconds <=> _to_f(o); end

    def add(t); @seconds += t.to_f; touch; self; end
    def subtract(t); @seconds -= t.to_f; touch; self; end

    def +(t); inc(t, 1); end
    def -(t); inc(t, -1); end

    DAY_S = 24 * 3600
    WEEK_S = 7 * DAY_S

    def monthdays

      date = to_time

      pos = 1
      d = self.dup

      loop do
        d.add(-WEEK_S)
        break if d.month != date.month
        pos = pos + 1
      end

      neg = -1
      d = self.dup

      loop do
        d.add(WEEK_S)
        break if d.month != date.month
        neg = neg - 1
      end

      [ "#{date.wday}##{pos}", "#{date.wday}##{neg}" ]
    end

    def to_s

      strftime('%Y-%m-%d %H:%M:%S %z')
    end

    def to_zs

      strftime('%Y-%m-%d %H:%M:%S %/Z')
    end

    def iso8601(fraction_digits=0)

      s = (fraction_digits || 0) > 0 ? ".%#{fraction_digits}N" : ''
      strftime("%Y-%m-%dT%H:%M:%S#{s}%:z")
    end

    # Debug current time by showing local time / delta / utc time
    # for example: "0120-7(0820)"
    #
    def to_utc_comparison_s

      per = @zone.period_for_utc(utc)
      off = per.utc_total_offset

      off = off / 3600
      off = off >= 0 ? "+#{off}" : off.to_s

      strftime('%H%M') + off + utc.strftime('(%H%M)')
    end

    def to_time_s

      strftime('%H:%M:%S.%6N')
    end

    def inc(t, dir=1)

      r =
        case t
        when Numeric
          nt = self.dup
          nt.seconds += dir * t.to_f
          nt
        when ::Time, ::EtOrbi::EoTime
          fail ArgumentError.new(
            "Cannot add #{t.class} to EoTime") if dir > 0
          @seconds + dir * t.to_f
        else
          fail ArgumentError.new(
            "Cannot call add or subtract #{t.class} to EoTime instance")
        end

      touch

      r
    end

    def localtime(zone=nil)

      EoTime.new(self.to_f, zone)
    end

    alias translate localtime
    alias in_time_zone localtime

    def wday_in_month

      [ count_weeks(EtOrbi.make_time(strftime('%F 12:00:00 %/Z')), -1),
        - count_weeks(EtOrbi.make_time(strftime('%F 12:00:00 %/Z')) , 1) ]
    end

    # "reference week", used in fugit for cron modulo notation
    #
    def rweek

      @rweek ||=
        begin
          ref = EtOrbi.make_time('2019-01-01 12:00:00', @zone)
          noon = EtOrbi.make_time(strftime('%F 12:00:00'), @zone)
          ((noon - ref) / WEEK_S).floor + 1
        end
    end

    # "reference week", used in fugit for cron modulo notation
    #
    def rday

      @rday ||=
        begin
          ref = EtOrbi.make_time('2019-01-01 12:00:00', @zone)
          noon = EtOrbi.make_time(strftime('%F 12:00:00'), @zone)
          ((noon - ref) / DAY_S).floor + 1
        end
    end

    def reach(points)

      t = EoTime.new(self.to_f, @zone)
      step = 1

      s = points[:second] || points[:sec] || points[:s]
      m = points[:minute] || points[:min] || points[:m]
      h = points[:hour] || points[:hou] || points[:h]

      fail ArgumentError.new("missing :second, :minute, and :hour") \
        unless s || m || h

      if !s && !m
        step = 60 * 60
        t -= t.sec
        t -= t.min * 60
      elsif !s
        step = 60
        t -= t.sec
      end

      loop do
        t += step
        next if s && t.sec != s
        next if m && t.min != m
        next if h && t.hour != h
        break
      end

      t
    end

    def clone

      EtOrbi::EoTime.new(@seconds, @zone)
    end

    protected

    # Returns a Ruby Time instance.
    #
    # Warning: the timezone of that Time instance will be UTC when used with
    # TZInfo < 2.0.0.
    #
    def to_time

      @time ||= @zone.utc_to_local(utc)
    end

    def count_weeks(start, dir)

      c = 0
      t = start

      until t.month != self.month
        c += 1
        t += dir * (7 * 24 * 3600)
      end

      c
    end

    def strfz(code)

      return @zone.name if code == '%/Z'

      per = @zone.period_for_utc(utc)

      return per.abbreviation.to_s if code == '%Z'

      off = per.utc_total_offset
        #
      sn = off < 0 ? '-' : '+'; off = off.abs
      hr = off / 3600
      mn = (off % 3600) / 60
      sc = 0

      if @zone.name == 'UTC'
        'Z' # align on Ruby ::Time#iso8601
      elsif code == '%z'
        '%s%02d%02d' % [ sn, hr, mn ]
      elsif code == '%:z'
        '%s%02d:%02d' % [ sn, hr, mn ]
      else
        '%s%02d:%02d:%02d' % [ sn, hr, mn, sc ]
      end
    end

    def _to_f(o)

      fail ArgumentError(
        "Comparison of EoTime with #{o.inspect} failed"
      ) unless o.respond_to?(:to_f)

      o.to_f
    end
  end
end