Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 6 min read

How Old Are You, Exactly? The Month-End Rule Behind a Correct Age Calculator

We are a small Saudi team in Riyadh, and one of our free browser tools is an age calculator. It answers a question that sounds trivial: "how old am I, in years, months and days?" People use it for school forms, visa appl

How Old Are You, Exactly? The Month-End Rule Behind a Correct Age Calculator

We are a small Saudi team in Riyadh, and one of our free browser tools is an age calculator. It answers a question that sounds trivial: "how old am I, in years, months and days?" People use it for school forms, visa applications and official papers, so the answer has to match what other careful software says, to the day.

It turned out that "years, months and days" is not a single obvious calculation. The hard part is the end of the month. This post shows the short function we ship, the bug in the version most people write first, and the test we used to prove ours matches Python's dateutil.

The question has more than one answer

Take someone born on 31 January 2023. How old are they on 1 March 2023?

There is no "31 February", so "one month later" has to land somewhere. JavaScript's Date lets the day overflow into the next month:

const x = new Date(Date.UTC(2023, 0, 31));
x.setUTCMonth(x.getUTCMonth() + 1);
x.toISOString().slice(0, 10); // "2023-03-03"

So in JavaScript, 31 January plus one month is 3 March. By that logic the person on 1 March is not even one month old yet.

Python's dateutil makes the opposite choice: it clips the day to the end of the month. 31 January plus one month is 28 February. On 1 March the person is one month and one day old. Excel's EDATE, Java's plusMonths and moment.js's add clip the same way, and it is the rule people expect: someone born on the last day of a month has their "month birthday" on the last day of each shorter month.

We picked dateutil's relativedelta as our reference. It is widely used, well tested, and gives an answer you can defend to a user.

The first version everyone writes

The textbook approach subtracts year from year, month from month and day from day, then borrows when the days go negative:

function naive(b, t) {
  let y = t.y - b.y, m = t.m - b.m, d = t.d - b.d;
  if (d < 0) {
    m--;
    const pm = t.m === 1 ? 12 : t.m - 1, py = t.m === 1 ? t.y - 1 : t.y;
    d += daysIn(py, pm); // borrow the previous month's length
  }
  if (m < 0) { y--; m += 12; }
  return { y, m, d };
}

It looks right and works for most dates. Then the month ends get in:

Born Today Naive dateutil
31 Jan 2023 1 Mar 2023 1 month, โˆ’2 days 1 month, 1 day
31 Jan 2023 30 Apr 2023 2 months, 30 days 3 months
29 Feb 2020 28 Feb 2021 11 months, 30 days 1 year

The first row is the worst: a negative day count, because February's 28 days are not enough to cover a borrow of 31. The other two are "off by one month" in a way a user will notice, because they disagree with every other tool they try.

Three month-end cases: the naive borrow against our function and dateutil

The fix: two small rules

Here is the function we ship, unchanged from our shared helper file:

function ymdDiff(b, t, monthLen) {
  var y = t.y - b.y, m = t.m - b.m, d = t.d - b.d;
  /* born on the 31st and t is the 30th, last of its month: the month is complete */
  if (d < 0 && t.d === monthLen(t.y, t.m)) d = 0;
  if (d < 0) {
    m--;
    var pm = t.m === 1 ? 12 : t.m - 1, py = t.m === 1 ? t.y - 1 : t.y;
    d += Math.max(monthLen(py, pm), b.d);
  }
  if (m < 0) { y--; m += 12; }
  return { y: y, m: m, d: d };
}

Two lines differ from the naive version, and each one encodes the clipping rule.

Rule 1: the last day of a month completes the month. If today is the last day of its month and the birth day is larger (born on the 31st, today is 30 April), there is no later day in this month to wait for. The month is complete, so the day count is zero. This fixes the 30 April case and the 29 February case: in 2021, 28 February is the last day of the month, so the year is complete.

Rule 2: borrow at least the birth day. When we do borrow, we add max(previous month's length, birth day), not just the previous month's length. Think about what dateutil does: it steps forward from the birth date by whole months, clipping as it goes, and counts the leftover days. Born on the 31st, the last whole-month step before 1 March lands on 28 February (clipped). From there to 1 March is one day. Adding max(28, 31) = 31 to 1 โˆ’ 31 gives exactly that: one day. Borrowing only 28 gives โˆ’2.

That's it. No loops, no date objects, no time zones: just three integers in and three out.

One function, two calendars

Notice that monthLen is a parameter. Saudi users often want their age in the Hijri calendar too, and Hijri months are 29 or 30 days long, with the lengths set by the official Umm al-Qura table rather than a formula.

So the same function runs twice:

var ag = diff(bG, tG, gDays);                               // Gregorian
var ah = bH && tH ? diff(bH, tH, U.monthLen) : null;        // Hijri (Umm al-Qura)

gDays is the classic trick of asking for "day 0 of the next month":

function gDays(y, m) { return new Date(Date.UTC(y, m, 0)).getUTCDate(); }

U.monthLen reads month lengths from our embedded Umm al-Qura table. The month-end rules are the same in both calendars: someone born on 30 Ramadan has their month birthday on the 29th of any 29-day month. Because the function never touches a calendar itself, the Hijri side gets the same correctness for free.

Two smaller traps on the way

Years below 100. Date.UTC treats years 0 to 99 as 1900 to 1999. Date.UTC(50, 0, 1) gives 1950, not year 50. Our date helper builds the date in a safe year and then sets the real year:

function utc(y, m, d) { var t = new Date(Date.UTC(2000, m - 1, d, 12)); t.setUTCFullYear(y); return t; }

The age calculator only accepts years from 1925, but the helper is shared with our Hijri date converter, so we'd rather not leave the trap waiting for the next tool.

Noon UTC. Every date in the tool is created at 12:00 UTC. Total days are Math.round((to - birth) / DAY). At noon UTC, no time-zone or daylight-saving shift can push a date across midnight into the previous or next day, and rounding absorbs any leftover hours. Birthdays don't have a time of day, so we pick one that can't hurt us.

The test: 267,546 pairs against dateutil

Our QA suite already compares the tool with dateutil on 52,924 date pairs. For this post we re-ran a bigger sweep from scratch: every birth date from 1 January 2019 to 31 December 2020, against every day of 2024. That is 731 ร— 366 = 267,546 pairs, and it includes two leap years, every month-end combination and every year boundary.

Node computes both our function (copied verbatim from production) and the naive version into a CSV. Python checks each row:

import csv
from datetime import date
from dateutil.relativedelta import relativedelta as rd

ok = bad = nbad = 0
for r in csv.reader(open("pairs.csv")):
    v = list(map(int, r))
    x = rd(date(*v[3:6]), date(*v[0:3]))
    ref = (x.years, x.months, x.days)
    if tuple(v[6:9]) == ref: ok += 1
    else: bad += 1
    if tuple(v[9:12]) != ref: nbad += 1
print(ok, bad, nbad)

The result, with Node 24 and dateutil 2.9.0:

  • our function: 267,546 matches, 0 differences;
  • naive borrow: 2,830 wrong, about 1 in 95 pairs, and 14 of those with a negative day count.

One in 95 sounds small, but it is concentrated exactly where people check: birthdays at the end of a month, and anyone born on 29 February. Those users will compare the result with another tool, and a mismatch makes them distrust everything else on the page.

Takeaways

  • Pick a reference before you write date code. "Add a month" has at least two reasonable meanings. Choose one (we chose dateutil's clipping) and test against it, rather than against your intuition.
  • Test exhaustively, not with examples. Date logic has a small input space. All pairs over a few years run in seconds and find the corners you would never write by hand.
  • Keep the arithmetic calendar-free. Pass month lengths in as a function. The same few lines then work for Gregorian and Hijri dates, and any other calendar with a month table.
  • Pin the time of day. Calendar dates at noon UTC avoid a whole class of time-zone bugs.

You can try the result here: Age Calculator. Enter 31 January and pick 1 March as the date to calculate to.

We are Webnaa, a Saudi web design team based in Riyadh. We build websites and online stores for businesses in Saudi Arabia and the Gulf. This post was written by Claude, an AI assistant, from our production code and a validation run against Python's dateutil.

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.