Does the expression 50.days.ago respect the Time Zone?

Does the expression 50.days.ago (rails syntactic sugar for a time 50 days ago before the current moment in time) respect the user’s timezone?

50.days returns an ActiveSupport::Duration as we see in the class Numeric in time.rb.

def days
  ActiveSupport::Duration.days(self)
end

50.days.ago then calls the ago method in ActiveSupport::Duration. Let’s take a look at this method in duration.rb.

# Calculates a new Time or Date that is as far in the past
# as this Duration represents.
def ago(time = ::Time.current)
  sum(-1, time)
end
alias :until :ago
alias :before :ago

We see that the method ActiveSupport::Duration#ago calls a private method called sum.

private
  def sum(sign, time = ::Time.current)
    unless time.acts_like?(:time) || time.acts_like?(:date)
      raise ::ArgumentError, "expected a time or date, got #{time.inspect}"
    end

    if parts.empty?
      time.since(sign * value)
    else
      parts.inject(time) do |t, (type, number)|
        if type == :seconds
          t.since(sign * number)
        elsif type == :minutes
          t.since(sign * number * 60)
        elsif type == :hours
          t.since(sign * number * 3600)
        else
          t.advance(type => sign * number)
        end
      end
    end
  end

And this method references a variable called parts. Where’d that come from? parts must be an attribute of ActiveSupport::Duration. So I figured it would be from the initializer.

def initialize(value, parts) #:nodoc:
  @value, @parts = value, parts.to_h
  @parts.default = 0
  @parts.reject! { |k, v| v.zero? } unless value == 0
end

But we only passed one argument to the initializer: ActiveSupport::Duration.days(self) through the expression 50.days. So parts must be an empty hash!

irb(main):001:0> nil.to_h
=> {}
irb(main):002:0> {}.empty?
=> true

Which means parts.empty? returns true. Taking a look at the private method sum again, I realized that the method returns the following expression:

time.since(sign * value)

We know that sign is -1 (see the ago method). We know that value is 50 (from 50.days). And we know that time is set to the default Time.current (also see the ago method). So the expression is equivalent to

Time.current.since(-50)

So the question of whether the expression 50.days.ago respects the time zone of the user simplifies to this question: does Time.current respect the time zone of the user? A quick look at the current method tells us the answer.

# Returns <tt>Time.zone.now</tt> when <tt>Time.zone</tt> or <tt>config.time_zone</tt> are set, otherwise just returns <tt>Time.now</tt>.
def current
  ::Time.zone ? ::Time.zone.now : ::Time.now
end

YES IT DOES! So after a long rabbit hole excursion, we can safely assume that 50.days.ago respects the time zone of the user! Thanks Rails team!

Written on May 31, 2020