panthomakos/timezone

View on GitHub
lib/timezone/zone.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

require 'json'
require 'date'
require 'time'

require 'timezone/loader'
require 'timezone/error'

module Timezone
  # This object represents a real-world timezone. Each instance provides
  # methods for converting UTC times to the local timezone and local
  # times to UTC for any historical, present or future times.
  class Zone
    include Comparable

    # @return [String] the timezone name
    attr_reader :name

    alias to_s name

    # @return [String] a developer friendly representation of the object
    def inspect
      "#<Timezone::Zone name: \"#{name}\">"
    end

    # If this is a valid timezone.
    #
    # @return [true] if this is a valid timezone
    def valid?
      true
    end

    SOURCE_BIT = 0
    private_constant :SOURCE_BIT
    NAME_BIT = 1
    private_constant :NAME_BIT
    DST_BIT = 2
    private_constant :DST_BIT
    OFFSET_BIT = 3
    private_constant :OFFSET_BIT

    # Create a new timezone object using the timezone name.
    #
    # @param name [String] the timezone name
    # @return [Timezone::Zone]
    def initialize(name)
      @name = name
    end

    # Converts the given time to the local timezone and does not include
    # a UTC offset in the result.
    #
    # @param time [#to_time] the source time
    # @return [Time] the time in the local timezone
    #
    # @note The resulting time is always a UTC time. If you would  like
    #       a time with the appropriate offset, use `#time_with_offset`
    #       instead.
    def utc_to_local(time)
      time = sanitize(time)

      (time + utc_offset(time)).utc
    end

    alias time utc_to_local

    # Converts the given local time to the UTC equivalent.
    #
    # @param time [#to_time] the local time
    # @return [Time] the time in UTC
    #
    # @note The UTC equivalent is a "best guess". There are cases where
    #   local times do not map to UTC at all (during a time skip forward).
    #   There are also cases where local times map to two distinct UTC
    #   times (during a fall back). All of these cases are approximated
    #   in this method and the first possible result is used instead.
    #
    # @note A note about the handling of time arguments.
    #
    #   Because the UTC offset of a `Time` object in Ruby is not
    #   equivalent to a single timezone, the `time` argument in this
    #   method is first converted to a UTC equivalent before being
    #   used as a local time.
    #
    #   This prevents confusion between historical UTC offsets and the UTC
    #   offset that the `Time` object provides. For instance, if I pass
    #   a "local" time with offset `+8` but the timezone actually had
    #   an offset of `+9` at the given historical time, there is an
    #   inconsistency that must be resolved.
    #
    #   Did the user make a mistake; or is the offset intentional?
    #
    #   One approach to solving this problem would be to raise an error,
    #   but this means that the user then needs to calculate the
    #   appropriate local offset and append that to a UTC time to satisfy
    #   the function. This is impractical because the offset can already
    #   be calculated by this library. The user should only need to
    #   provide a time without an offset!
    #
    #   To resolve this inconsistency, the solution I chose was to scrub
    #   the offset. In the case where an offset is provided, the time is
    #   just converted to the UTC equivalent (without an offset). The
    #   resulting time is used as the local reference time.
    #
    #   For example, if the time `08:00 +2` is passed to this function,
    #   the local time is assumed to be `06:00`.
    def local_to_utc(time)
      time = sanitize(time)

      (time - rule_for_local(time).rules.first[OFFSET_BIT]).utc
    end

    # Converts the given time to the local timezone and includes the UTC
    # offset in the result.
    #
    # @param time [#to_time] the source time
    # @return [Time] the time in the local timezone with the UTC offset
    def time_with_offset(time)
      time = sanitize(time)

      utc = utc_to_local(time)
      offset = utc_offset(time)

      Time.new(
        utc.year,
        utc.month,
        utc.day,
        utc.hour,
        utc.min,
        utc.sec + utc.subsec,
        offset
      )
    end

    # The timezone abbreviation, at the given time.
    #
    # @param time [#to_time] the source time
    # @return [String] the timezone abbreviation, at the given time
    def abbr(time)
      time = sanitize(time)

      rule_for_utc(time)[NAME_BIT]
    end

    # If, at the given time, the timezone was observing Daylight Savings.
    #
    # @param time [#to_time] the source time
    # @return [Boolean] whether the timezone, at the given time, was
    #                   observing Daylight Savings Time
    def dst?(time)
      time = sanitize(time)

      rule_for_utc(time)[DST_BIT]
    end

    # Return the UTC offset (in seconds) for the given time.
    #
    # @param time [#to_time] (Time.now) the source time
    # @return [Integer] the UTC offset (in seconds) in the local timezone
    def utc_offset(time = nil)
      time ||= Time.now
      time = sanitize(time)

      rule_for_utc(time)[OFFSET_BIT]
    end

    # Compare one timezone with another based on current UTC offset.
    #
    # @param other [Timezone::Zone] the other timezone
    #
    # @return [-1, 0, 1, nil] comparison based on current `utc_offset`.
    def <=>(other)
      return nil unless other.respond_to?(:utc_offset)

      utc_offset <=> other.utc_offset
    end

    private

    def sanitize(time)
      time.to_time
    end

    # Does the given time (in seconds) match this rule?
    #
    # Each rule has a SOURCE bit which is the number of seconds, since the
    # Epoch, up to which the rule is valid.
    def match?(seconds, rule) #:nodoc:
      seconds <= rule[SOURCE_BIT]
    end

    RuleSet = Struct.new(:type, :rules)
    private_constant :RuleSet

    def rule_for_local(local)
      local = local.to_i
      rules = Loader.load(name)

      # For each rule, convert the local time into the UTC equivalent for
      # that rule offset, and then check if the UTC time matches the rule.
      index =
        binary_search(rules, local) do |t, r|
          match?(t - r[OFFSET_BIT], r)
        end
      match = rules[index]

      utc = local - match[OFFSET_BIT]

      # If the UTC rule for the calculated UTC time does not map back to the
      # same rule, then we have a skip in time and there is no applicable rule.
      return RuleSet.new(:missing, [match]) if rule_for_utc(utc) != match

      # If the match is the last rule, then return it.
      return RuleSet.new(:single, [match]) if index == rules.length - 1

      # If the UTC equivalent time falls within the last hour(s) of the time
      # change which were replayed during a fall-back in time, then return
      # the matched rule and the next one.
      #
      # Example:
      #
      #     rules = [
      #       [ 8:00 UTC, -1 ], # UTC-1 up to and including 8:00 UTC
      #       [ 14:00 UTC, -2 ], # UTC-2 up to and including 14:00 UTC
      #     ]
      #
      #     6:50 local (7:50 UTC) by the first rule
      #     6:50 local (8:50 UTC) by the second rule
      #
      #     Since both rules provide valid mappings for the local time,
      #     we need to return both values.
      last_hour =
        match[SOURCE_BIT] -
        match[OFFSET_BIT] +
        rules[index + 1][OFFSET_BIT]

      if utc > last_hour
        RuleSet.new(:double, rules[index..(index + 1)])
      else
        RuleSet.new(:single, [match])
      end
    end

    def rule_for_utc(time)
      time = time.to_i
      rules = Loader.load(name)

      rules[binary_search(rules, time) { |t, r| match?(t, r) }]
    end

    # Find the first rule that matches using binary search.
    def binary_search(rules, time, from = 0, to = nil, &block)
      to = rules.length - 1 if to.nil?

      return from if from == to

      mid = (from + to).div(2)

      unless yield(time, rules[mid])
        return binary_search(rules, time, mid + 1, to, &block)
      end

      return mid if mid.zero?

      return mid unless yield(time, rules[mid - 1])

      binary_search(rules, time, from, mid - 1, &block)
    end
  end
end