ShogunPanda/lazier

View on GitHub
lib/lazier/timezone.rb

Summary

Maintainability
B
4 hrs
Test Coverage
#
# This file is part of the lazier gem. Copyright (C) 2013 and above Shogun <shogun@cowtech.it>.
# Licensed under the MIT license, which can be found at https://choosealicense.com/licenses/mit.
#

module Lazier
  # Extensions for `TimeZone` objects.
  module TimeZone
    extend ::ActiveSupport::Concern

    # Pattern for a parameterized timezone.
    ALREADY_PARAMETERIZED = /^[+-]\d{4}@[a-z-]+/

    # Pattern for a unparameterized timezone.
    PARAMETERIZER = /^(
      \(
        [a-z]+ # UTC Label
        (?<offset>([+-])(\d{2})(:?)(\d{2}))
      \)
      \s(?<label>.+)
    )$/xi

    # General methods.
    module ClassMethods
      # Expression to parameterize a zone
      # Returns an offset in rational value.
      #
      # @param offset [Fixnum] The offset to convert.
      # @return [Rational] The converted offset.
      def rationalize_offset(offset)
        ::TZInfo::OffsetRationals.rational_for_offset(offset)
      end

      # Returns a +HH:MM formatted representation of the offset.
      #
      # @param offset [Rational|Fixnum] The offset to represent, in seconds or as a rational.
      # @param colon [Boolean] Whether to put the colon in the output string.
      # @return [String] The formatted offset.
      def format_offset(offset, colon = true)
        seconds_to_utc_offset(offset.is_a?(::Rational) ? (offset * 86_400).to_i : offset, colon)
      end

      # Find a zone by its name.
      #
      # @param name [String] The zone name.
      # @param dst_label [String] Label for the DST indication. Defaults to ` (DST)`.
      # @return [TimeZone] A timezone or `nil` if no zone was found.
      def find(name, dst_label = " (DST)")
        rv = list(true, dst_label: dst_label, as_hash: true)[name]
        rv.current_alias = name.gsub(/\(GMT(.{6})\) (.+)(#{Regexp.quote(dst_label)})$/, "\\2") if rv
        rv
      end

      # Returns a list of names of all timezones.
      #
      # @param with_dst [Boolean] If include DST version of the zones.
      # @param parameterized [Boolean] If parameterize zones.
      # @param dst_label [String] Label for the DST indication. Defaults to ` (DST)`.
      # @param as_hash [Hash] If return an hash.
      # @return [Array|Hash] A list of names of timezones or a hash with labels and timezones as keys.
      def list(with_dst = true, dst_label: " (DST)", parameterized: false, sort_by_name: true, as_hash: false)
        dst_label = nil unless with_dst
        key = [dst_label, sort_by_name, as_hash, parameterized].join(":")
        @zones_names ||= {}

        unless @zones_names[key]
          all = ::ActiveSupport::TimeZone.all
          @zones_names[key] = send("finalize_list_as_#{as_hash ? "hash" : "list"}", all, dst_label, parameterized, sort_by_name)
        end

        @zones_names[key]
      end

      # Returns a string representation of a timezone.
      #
      # ```ruby
      # DateTime.parameterize_zone(ActiveSupport::TimeZone["Pacific Time (US & Canada)"])
      # # => "-0800@pacific-time-us-canada"
      # ```
      # @param tz [TimeZone|String] The zone to represent.
      # @param with_offset [Boolean] Whether to include offset into the representation.
      # @return [String] A string representation which can be used for searches.
      def parameterize(tz, with_offset = true)
        tz = tz.to_str unless tz.is_a?(::String)

        if tz =~ ::Lazier::TimeZone::ALREADY_PARAMETERIZED
          tz
        elsif tz =~ ::Lazier::TimeZone::PARAMETERIZER
          mo = $LAST_MATCH_INFO
          [(with_offset ? mo[:offset].gsub(":", "") : nil), mo[:label].parameterize].compact.join("@")
        else
          tz.parameterize
        end
      end

      # Finds a parameterized timezone.
      # @see DateTime#parameterize_zone
      #
      # @param tz [String] The zone to unparameterize.
      # @param dst_label [String] Label for the DST indication. Defaults to `(DST)`.
      # @return [TimeZone] The found timezone or `nil` if the zone is not valid.
      def unparameterize(tz, dst_label = " (DST)")
        tz = parameterize(tz)
        list(true, dst_label: dst_label, parameterized: true, as_hash: true)[tz]
      end

      # Compares two timezones. They are sorted by the location name.
      #
      # @param left [String|TimeZone] The first zone name to compare.
      # @param right [String|TimeZone] The second zone name to compare.
      # @return [Fixnum] The result of comparison, like Ruby's operator `<=>`.
      def compare(left, right)
        left = left.to_str if left.is_a?(::ActiveSupport::TimeZone)
        right = right.to_str if right.is_a?(::ActiveSupport::TimeZone)
        left.ensure_string.split(" ", 2)[1] <=> right.ensure_string.split(" ", 2)[1]
      end

      private

      # :nodoc:
      def fetch_aliases(zone, dst_label = "(DST)", parameterized = false)
        rv = zone.aliases.map do |zone_alias|
          [
            zone.to_str(false, label: zone_alias, parameterized: parameterized),
            (zone.uses_dst? && dst_label) ? zone.to_str(true, label: zone_alias, dst_label: dst_label, parameterized: parameterized) : nil
          ]
        end

        rv.flatten.uniq.compact
      end

      # :nodoc:
      def finalize_list_as_list(all, dst_label, parameterized, sort_by_name)
        rv = all.map { |zone| fetch_aliases(zone, dst_label, parameterized) }.flatten.uniq

        sort_by_name ? rv.sort { |a, b| ::ActiveSupport::TimeZone.compare(a, b) } : rv
      end

      # :nodoc:
      def finalize_list_as_hash(all, dst_label, parameterized, sort_by_name)
        rv = all.reduce({}) do |accu, zone|
          accu.merge(fetch_aliases(zone, dst_label, parameterized).reduce({}) do |a, e|
            a[e] = zone
            a
          end)
        end

        sort_by_name ? ::Hash[rv.sort { |a, b| ::ActiveSupport::TimeZone.compare(a[0], b[0]) }] : rv
      end
    end

    # Returns a list of valid aliases (city names) for this timezone (basing on the offset).
    # @return [Array] A list of aliases for this timezone
    def aliases
      reference = self.class::MAPPING.fetch(name, name).gsub("_", " ")
      @aliases ||= ([reference] + self.class::MAPPING.map { |name, zone| format_alias(name, zone, reference) }).uniq.compact.sort
    end

    # Returns the current offset for this timezone, taking care of Daylight Saving Time (DST).
    #
    # @param rational [Boolean] Whether to return the offset as a Rational.
    # @param date [DateTime|NilClass] The date to consider. Defaults to now.
    # @return [Fixnum|Rational] The offset of this timezone.
    def current_offset(rational = false, date = nil)
      date = (date || ::DateTime.current).in_time_zone
      offset(rational: rational, dst: date.dst?, year: date.year)
    end

    # Returns the current alias for this timezone.
    #
    # @return [String] The current alias or the first alias of the current timezone.
    def current_alias
      if @current_alias
        @current_alias
      else
        identifier = name
        aliases.find { |a| a == identifier } || aliases.first
      end
    end

    # Sets the current alias.
    #
    # @param new_alias [String] The new current alias.
    def current_alias=(new_alias)
      @current_alias = new_alias.ensure_string
    end

    # Returns the current name.
    #
    # @param dst [Boolean] Whether to return the name with DST indication.
    # @param dst_label [String] Label for the DST indication. Defaults to ` (DST)`.
    # @param year [Fixnum] The year to which refer to. Defaults to the current year. *Only required when `dst` is true*.
    # @return [String] The name for the zone.
    def current_name(dst = false, dst_label: " (DST)", year: nil)
      year ||= Date.current.year
      rv = current_alias
      rv += dst_label if dst && uses_dst?(year)
      rv
    end

    # Returns the standard offset for this timezone.
    #
    # @param rational [Boolean] Whether to return he offset as a `Rational`.
    # @param dst [Boolean] Whether to return the offset when the DST is active.
    # @param year [Fixnum|NilClass] The year to which refer to. Defaults to the current year.
    # @return [Fixnum|Rational] The offset of this timezone.
    def offset(rational: false, dst: false, year: nil)
      rv =
        if dst
          period = dst_period(year)
          period ? period.utc_total_offset : 0
        else
          utc_offset
        end

      rational ? self.class.rationalize_offset(rv) : rv
    end

    # Checks if the timezone uses Daylight Saving Time (DST) for that date or year.
    #
    # @param reference [Date|DateTime|NilClass] The date or year to check. Defaults to the current year.
    # @return [Boolean] `true` if the zone uses DST for that date or year, `false` otherwise.
    def uses_dst?(reference = nil)
      if reference.is_a?(Date) || reference.is_a?(DateTime) || reference.is_a?(Time)
        period_for_utc(reference).dst?
      else
        dst_period(reference)
      end
    end

    # Gets a period for this timezone when the Daylight Saving Time (DST) is active (it takes care of different hemispheres).
    #
    # @param year [Fixnum|NilClass] The year to which refer to. Defaults to the current year.
    # @return [TimezonePeriod] A period when the Daylight Saving Time (DST) is active or `nil` if the timezone doesn't use DST for that year.
    def dst_period(year = nil)
      year ||= ::Date.current.year

      period = period_for_utc(::DateTime.civil(year, 7, 15, 12).utc) # Summer for the northern hemisphere
      period = period_for_utc(::DateTime.civil(year, 1, 15, 12).utc) unless period.dst? # Summer for the southern hemisphere
      period.dst? ? period : nil
    rescue
      nil
    end

    # Return the correction applied to the standard offset the timezone when the Daylight Saving Time (DST) is active.
    #
    # @param rational [Boolean] Whether to return the offset as a Rational.
    # @param year [Fixnum|NilClass] The year to which refer to. Defaults to the current year.
    # @return [Fixnum|Rational] The correction for dst.
    def dst_correction(rational = false, year = nil)
      rv = dst_offset(year, :std_offset)
      rational ? self.class.rationalize_offset(rv) : rv
    end

    # Formats this zone as a string.
    #
    # @param dst [Boolean] Whether to represent with (DST) active.
    # @param args [Hash] Parameters for the formatting:
    # @option args label [String]: The label to use. Default to the current alias.
    # @option args dst_label [String]: Label for the DST indication. Defaults to ` (DST)`.
    # @option args utc_label [String]: Label for the UTC name. Defaults to `GMT`. *Only used when `parameterized` is `false`.
    # @option args year [Fixnum]: The year to which refer to. Defaults to the current year.
    # @option args parameterized [Boolean]: Whether to represent as parameterized.
    # @option args with_offset [Boolean]: Whether to include offset into the representation. *Only used when `parameterized` is `true`.
    # @option args offset_position [Symbol]: Where to put the offset. Valid values are `:begin` or `:end`. *Only used when `parameterized` is `false`.
    # @option args colon [Boolean]: If include a colon in the offset. *Only used when `parameterized` is `false`.
    # @return [String] The string representation for this zone.
    def to_str(dst = false, **args)
      # PI: Ignore reek on this.
      label, dst_label, utc_label, year, parameterized, with_offset, colon, offset_position = prepare_to_str_arguments(args)

      if parameterized
        self.class.parameterize(to_str(dst, label: label, dst_label: dst_label, utc_label: utc_label, year: year, parameterized: false), with_offset)
      else
        offset_label = self.class.seconds_to_utc_offset(offset(rational: false, dst: dst, year: year), colon)
        to_str_unparameterized(dst ? dst_label : "", label, offset_label, offset_position, utc_label, with_offset)
      end
    end

    private

    # :nodoc
    def format_alias(name, zone, reference)
      if zone.gsub("_", " ") == reference
        ["International Date Line West", "UTC"].include?(name) || name.include?("(US & Canada)") ? name : reference.gsub(/\/.*/, "/#{name}")
      end
    end

    # :nodoc:
    def dst_offset(year, method)
      period = dst_period(year)
      period ? period.send(method) : 0
    end

    # :nodoc:
    def prepare_to_str_arguments(args)
      args = args.reverse_merge(
        label: current_alias, dst_label: " (DST)", utc_label: "GMT", year: nil, parameterized: false,
        with_offset: true, colon: true, offset_position: :begin
      ).symbolize_keys

      [:label, :dst_label, :utc_label, :year, :parameterized, :with_offset, :colon, :offset_position].map { |e| args[e] }
    end

    # :nodoc:
    def to_str_unparameterized(dst_label, label, offset_label, offset_position, utc_label, with_offset)
      if !with_offset
        format("%s%s", label, dst_label)
      elsif offset_position != :end
        format("(%s%s) %s%s", utc_label, offset_label, label, dst_label)
      else
        format("%s%s (%s%s)", label, dst_label, utc_label, offset_label)
      end
    end
  end
end