– we create awesome web applications

I recently had an interesting bug that I want to share.

We had an age method in the User model, that was implemented like this:

def age
  return unless birthday
  now = Time.now.utc.to_date
  now.year - birthday.year - (birthday.to_date.change(year: now.year) > now ? 1 : 0)
end

And the following test for it:

describe :age do
  it 'should calculate age on exact date' do
    user = record(birthday: '2000-10-10')
    Timecop.freeze(Date.parse('2010-10-10')) do
      user.age.should == 10
    end
  end

  it 'should calculate age on next date' do
    user = record(birthday: '2000-10-10')
    Timecop.freeze(Date.parse('2010-10-11')) do
      user.age.should == 10
    end
  end

  it 'should calculate age on prev date' do
    user = record(birthday: '2000-10-10')
    Timecop.freeze(Date.parse('2010-10-9')) do
      user.age.should == 9
    end
  end
end

Suddenly, on 2013-07-14, I received a Circleci email that my last commit broke the specs. While investigating I found out that it failed in a place completely unrelated to my latest changes. It failed with an ArgumentError: invalid date. WTF?!

Investigating I found that we had a typo in one of our fixtures, that went like this:

triton:
  name: Triton
  birthday: <%= 501.days.ago %>
  ...

Notice the days instead of years that were ment to be used. And 501 days before 2013-07-14 is 2012-02-29, a leap year extra day, oops ;)

The age implementation tried to do .change(year: 1) to 2012-02-29 which produced an invalid date 2013-02-29. Apparently Date#change wasn’t smart enough to take care of that:

> d = Date.parse('2012-02-29')
=> Wed, 29 Feb 2012
> d.change(year: 1)
ArgumentError: invalid date

I changed tirton’s age to 501 years, and added the following test:

it 'should not fail when birthdate is on feb-29' do
  user = record(birthday: '2012-02-29')
  Timecop.freeze(Date.parse('2013-05-01')) do
    user.age.should == 1
  end
end

and fixed the implementation to be like so:

def age
  return unless birthday
  now = Time.now.utc.to_date
  diff = now.year - birthday.year
  diff - (diff.years.since(birthday) > now ? 1 : 0)
end

ActiveSupport’s Date#since is smarter then #change and handles invalid dates properly:

> d = Date.parse('2012-02-29')
=> Wed, 29 Feb 2012
> 1.year.since(d)
=> Thu, 28 Feb 2013

Also note that there will be no problem with user fixtures on Feb-29 of the next leap year 2016-02-29. The x.years.ago that is used for birthdays will work just fine.