HorusKol

Date mathematics at the end of the month

April 7, 2022

A user of Cross It Off came to me with a bug last week. It happened to be the 31st of March (which is important to this story).

She was trying to use the date picker in the app, and wanted to advance one month. However, the calendar was jumping from March right on into May.

As soon as I heard the problem I knew exactly what was happening. When increasing the month displayed in the calendar, I was simply doing this:

const incMonth = (date) => {
  return date.setMonth(date.getMonth() + 1);
}

The Javascript Date API handles overflow pretty neatly. Adding a month to a date in December 2022 will put you into January of 2023.

What I hadn't anticipated (and what hadn't come up before because the app was only launch in the middle of March) was how this overflow comes into effect when, for example, you set the date to the 31st of April. Because that is pretty much what that incMonth did.

Of course, there are only 30 days in April, so the 31st of April is invalid. The Date API knows this is invalid, and kicks in with its overflow behaviour. We've set a date one day beyond the 30th of April, so we must be wanting the 1st of May. And so the calendar jumps into May.

The same problem happens the other - there's no 31st of February, so we end up with the 3rd of March (except in leap years).

I fixed the problem by putting a guard around where I change the month:

const setMonth = (date, newMonth) => {
  date.setMonth(newMonth);

  if (date.getMonth() > newMonth) {
    date.setMonth(date.getMonth() - 1);

    switch(date.getMonth()) {
      case 1:
        // February
        isLeapYear(date) ? date.setDate(29) : date.setDate(28);
        break;

      default:
        // April, June, September, November
        date.setDate(30);
    }
  }

  return date;
}

An imperfect solution

Honestly, this is probably good enough for most things, but it does have one problem.

I also use this function whenever a user crosses a repeating to-do off their list - if they have set up the to-do to repeat every so many months, I copy the to-do and increase the month value in the due date accordingly.

So, the user starts on the 31st March and crosses the to-do off their list. The new to-do appears on the 30th April, and the user will cross it off their list when that date rolls around. The next to-do will then come along on the 30th of May.

Why? Since we're only setting a new month value on the due date, the Date API will be happy since the 30th of May is perfectly valid. It has no idea that the user originally set the 31st as their day to do this.

It gets worse - eventually, the to-do will repeat on the 28th of February. Then for every month after that it will always be the 28th of February.

This is not very good if you really want something to happen on the 31st.

Can we do better?

Not really - and I don't think even the proposed Temporal API will resolve this kind of thing. This is the biggest problem with computers - they can only really do what we can tell them to do, not what we want them do. However, most of the time we're lucky, and these things intersect.

In fact this has long been a problem - over the years, I've noticed that a lot of businesses try and avoid doing monthly things after the 28th of the month. Some will even set the 25th (or the next week day after that if it's the weekend) as their monthly run day.

Actually, maybe we can

Okay, I fibbed a bit. There are solutions to this - but they involve working above the Date API (or whatever API is available in your language of choice), and also changing the user interface to help us know what the user wants.

In much the same way we can allow the user to specify "this repeats every Monday", we could also allow "this repeats on the last day of the month", if it's important to the capture that. It's then up to us to store that information, and do the work ourselves in properly setting the date.