Readable Dates in Rails

Posted by on June 12, 2023

I was playing around in Swift recently, and wanted a reference to ‘one day ago’. This is simple enough in human terms: if it’s 9:30 on the 18th of September, ‘one day ago’ means 9:30 on the 17th of September.

The Swift code to do this looks like this:

Calendar.current.date(
  byAdding: .day,
  value: -1,
  to: Date.now
)

…and that’s just a bit much, isn’t it?

First we need to access Calendar.current, a singleton on Calendar which returns whatever calendar is in use. (Gregorian, Islamic, Chinese…)

Then we need to tell the calendar to calculate a date by adding a DateComponents object to Date.now. Good luck parsing this initialiser if you’ve never done that before.

And after all that, we don’t have a Date object! We have an optional Date?, and like, come on man. Yesterday definitely exists, but now I feel bad about force unwrapping it.

The equivalent code in Rails is this:

1.day.ago

…yeah.

Knowing this existed, I started extending Swift’s Int and DateComponents classes to support this beautiful, detail-abstracting, human-readable syntax. (Here’s the result, for any interested Swift people!)

In the process of re-implementing this, I started wondering. In Rails I am liberal when calling ago or since, but I really don’t know what goes on under the hood when I do so. Despite knowing date logic to be the eternal folly of the curious programmer, I decided I wanted to understand exactly how these Date calculations were being done. Or at least, I wanted to understand to the best of my abilities.

If you too are so cursed, read on!

Active Support

Here is the relevant code that ActiveSupport adds to Ruby’s Numeric, but it isn’t very interesting:

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

alias :day :days

All this tells us is that the real work for days is done in a dedicated class called Duration. Looking around, we find ago and since living here as well!

module ActiveSupport
  class Duration
    ...
    
    def ago(time = ::Time.current)
      sum(-1, time)
    end

    def since(time = ::Time.current)
      sum(1, time)
    end

    ...

    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
  end
end

There’s a lot here, so let’s begin with ago and since. (We’ll come back to days, I promise!) We notice immediately that these methods are just syntactic sugar around sum – let’s drill down.

<sidenote>

If you’ve ever wondered, ago and since are the OG names of these functions; untilbeforefrom_now, and after are all aliases. Personally I would prefer hence to since , but to each their own! ¯\_(ツ)_/¯

</sidenote>

The driving force of sum is a call to @parts.injectinject is an alias for a method you may know as reduce, which takes an enumerable and acts on each of its elements in turn to produce a single value; a one-dimensional array is reduced to a zero-dimensional scalar value.

inject is also given a Time object, which is included and updated in each iteration. Time starts with a default value of ::Time.current, and each element of @parts updates it, until we have reduced our @parts array into a single time value.

So a Duration has some @parts, which all get sum‘ed up when we want to calculate anything. But what, exactly, is a Time, and what kind of thing is contained in @parts?

Telling the Time

Time objects are stored internally as a UNIX timestamp, the number of seconds that have passed since January 1, 1970.

In ActiveSupport::Duration#sum above, we are making use of Time‘s since method, which is added by ActiveSupport as a Core Extension. This is a different method with a different signature to Duration‘s since.

Actually, this is a much simpler version of since, accepting an integer number of seconds. Since a Time is also fundamentally a number of seconds, this method is just addition.

So that’s nice and straightforward – now what about the @parts array?

<sidenote>

If we wanted to go further, we could go down the rabbit hole of trying to explain exactly how we get the timestamp for Time.current – that is, how is it that computers can tell the current time?

Turns out this reduces from a computer problem to a physics problem. If like me you weren’t aware, there’s a crystal inside your computer that vibrates at an incredibly consistent frequency when you apply electricity to it. All computers are doing this even when turned off, and they can use this consistent oscillation to accurately track the passage of time.

</sidenote>

Date Components

Let’s now return to how days is defined on ActiveSupport::Duration, which we skipped over earlier:

SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR   = 3600
SECONDS_PER_DAY    = 86400
SECONDS_PER_WEEK   = 604800
SECONDS_PER_MONTH  = 2629746  # 1/12 of a gregorian year
SECONDS_PER_YEAR   = 31556952 # length of a gregorian year (365.2425 days)

class << self
  ...
  def days(value) # :nodoc:
    new(value * SECONDS_PER_DAY, { days: value }, true)
  end
  ...
end

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

We can see here that the days method is just a fancy wrapper over Duration‘s initialiser. This initialiser reveals to use what our @parts array looks like: a hash keyed by symbols that represent different human date measurements. The definitions of the other date components (week, month, etc.) are much the same, using the corresponding number of seconds at the top of this code snippet.

If we check the number of seconds given for a month, we have a concern: it is defined as 1/12th of a Gregorian year. This is fine for when we want to talk about durations in the abstract (think about what the result of 3.months.to_i should be!) but real months are obviously not equal length. When we anchor ourselves in time (i.e. when asking for a duration from or since a given date), we care which month we’re talking about.

Seconds, minutes, and hours are the only date components that don’t have this problem, and are of reliably equal length – you can always reliably factor in or out 60s to go between them. (No, we’re not talking about leap seconds here. Google it.)

We already saw this relationship earlier, in sum:

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

and we can also see from this that the interesting date components are handled elsewhere, in Time#advance. This is added by ActiveSupport in another extension – let’s keep following the rabbit hole down.

Advance, Advance, and… what?

Here‘s the Time#advance method:

def advance(options)
  ... #fractional date handling that we don't need to worry about!

  d = to_date.gregorian.advance(options)
  time_advanced_by_date = change(year: d.year, month: d.month, day: d.day)
  seconds_to_advance = \
    options.fetch(:seconds, 0) +
    options.fetch(:minutes, 0) * 60 +
    options.fetch(:hours, 0) * 3600
    
	if seconds_to_advance.zero?
    time_advanced_by_date
  else
    time_advanced_by_date.since(seconds_to_advance)
  end
end

We see that this function handles date and time separately: the easy date components (second, minute, hour) are converted to seconds and added afterwards as a flat number.

However, it doesn’t deal with the pure date components directly (noticing a pattern yet?). Instead, it computes some new date components with a call to_date.gregorian.advance and swaps its existing date components with these new ones by a call to change. (Yes, change does other stuff, but we’re not getting into it!)

By my count, the actual advancement logic we’re trying to pin down has been deferred four times now, but I’m happy to tell you that this is end of the rabbit hole. We’ve reached a function that is going to advance a date by a date component using pure Ruby. Surely this is where ActiveSupport reveals its secrets?

Here it is:

def advance(options)
  d = self

  d = d >> options[:years] * 12 if options[:years]
  d = d >> options[:months] if options[:months]
  d = d + options[:weeks] * 7 if options[:weeks]
  d = d + options[:days] if options[:days]

  d
end

…where’s my date logic? And what on Earth is the >> operator doing here??

Wait, what?

Most readers are probably familiar with << as it is defined on an array, to append a new element. This looks like it may be related, but there’s no array here.

In a moment of insanity I tried to convince myself we’re dealing with the bitwise right shift operator. (And that consequently, that Ruby was doing something wild with how it stored dates.)

This is neither of those things, however. As it happens, Ruby actually defines a unique meaning for << and >> on Date. They simply increment or decrement the date in months by the number passed on the right. Try it!

This is pretty cool. In terms of our investigation though, we’re back where we started. We’ve simply discovered a new lower level way of advancing by a month or a year: x.months.from_now is equivalent to Date.now >> x!

The Actual Implementation!

This does give us a thread to follow though. We’ve cut through all that sweet ActiveSupport treacle and found a native Ruby function that just has to deal with the fact that the length of a month is not consistent.

There’s a small problem, however. This is, apparently, the definition of >>:

static VALUE
d_lite_rshift(VALUE self, VALUE other)
{
    VALUE t, y, nth, rjd2;
    int m, d, rjd;
    double sg;

    get_d1(self);
    t = f_add3(f_mul(m_real_year(dat), INT2FIX(12)),
               INT2FIX(m_mon(dat) - 1),
               other);
    if (FIXNUM_P(t)) {
        long it = FIX2LONG(t);
        y = LONG2NUM(DIV(it, 12));
        it = MOD(it, 12);
        m = (int)it + 1;
    }
    else {
        y = f_idiv(t, INT2FIX(12));
        t = f_mod(t, INT2FIX(12));
        m = FIX2INT(t) + 1;
    }
    d = m_mday(dat);
    sg = m_sg(dat);

    while (1) {
        int ry, rm, rd, ns;

        if (valid_civil_p(y, m, d, sg,
                          &nth, &ry,
                          &rm, &rd, &rjd, &ns))
            break;
        if (--d < 1)
            rb_raise(eDateError, "invalid date");
    }
    encode_jd(nth, rjd, &rjd2);
    return d_lite_plus(self, f_sub(rjd2, m_real_local_jd(dat)));
}

…and for those of you that know C, I hope that’s helpful. But I sure don’t! ¯\_(ツ)_/¯

Conclusion

So, in short, how does 1.day.ago work?

  • ActiveSupport introduces a Duration class which stores date components (seconds, minutes, hours, etc) separately.
  • Duration supports arithmetic methods, and will perform those methods separately on each component
    • Second, minute, and hour are handled directly by ActiveSupport
    • Larger components are subject to the whims of human calendars and require special logic
  • Larger date components are deferred to ruby, with an underlying C implementation.

I hope some people have managed to take fraction of what I got from this investigation, which is an improved respect and understanding for ActiveSupport, what it provides, and how it works. But, if you were hoping for some straightforward takeaways on dates, you might be a tad disappointed.

(I supposed you could start using Date.current >> 1 instead of 1.month.from_now. When the review feedback comes in, just point your coworker to this article. You saved so many lines of ActiveSupport code!)