iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐴

Bugs that only occur on leap day

に公開

Yesterday, I encountered a bug that only occurs on a leap day. I think this is a well-known story for those who write JavaScript, so it might not be particularly interesting, but I'll leave a note just in case.

I can't go into detail, but the specifications of the function where the bug occurred were roughly as follows:

  • Returns true if the target date is between one year before and one year after the reference date, and false otherwise.
  • A string in the format 2020-12-24 is passed as an argument (the date to be evaluated).
  • A string in the format 2021-01-01 is passed as an argument (the reference date for ±1 year).
  • Written in JavaScript.

(Example)
Target date: 2024/10/10, Reference date: 2024/01/28
In this case, the range that returns true is 2023/01/28 ~ 2025/01/28. Therefore, 2024/10/10 is true. Both 2023/01/28 and 2025/01/28 will also be true. It is a closed interval.

The logic that was implemented in the code was as follows:

const isWithinOneYear = (input: string, base: string) => {
  const baseDate = new Date(`${base}T00:00:00.000Z`);
  const inputDate = new Date(`${input}T00:00:00.000Z`);

  const prevYear = baseDate.getFullYear() - 1;
  const nextYear = baseDate.getFullYear() + 1;
  const lower = baseDate.setFullYear(prevYear);
  const upper = baseDate.setFullYear(nextYear);

  return inputDate >= new Date(lower) && inputDate <= new Date(upper);
};

When this is executed on days other than a leap day, it behaves as follows, for example:

// Example: I want it to be true if the date is included in 2023-01-29 ~ 2025-01-29
const notLeapDate = "2024-01-29";
isWithinOneYear("2025-01-29", notLeapDate) // true as expected
isWithinOneYear("2025-01-30", notLeapDate) // false as expected
isWithinOneYear("2023-01-29", notLeapDate) // true as expected
isWithinOneYear("2023-01-28", notLeapDate) // false as expected

On the other hand, when executed on a leap day, it behaves as follows:

// I want it to be true if the date is included in 2023-03-01 ~ 2025-02-28
const leapDate = "2024-02-29";
isWithinOneYear("2025-02-28", leapDate) // true as expected
isWithinOneYear("2025-03-01", leapDate) // true wrong. I want it to be false.
isWithinOneYear("2023-03-01", leapDate) // true as expected
isWithinOneYear("2023-02-28", leapDate) // false as expected

For dates within ±1 year of a leap day, I want 2025-03-01 to return false, but it returns true.

This is because JavaScript returns March 1st as the following year of a leap day (this is also true when using methods like setUTCFullYear).

const date = new Date(`2024-02-29T00:00:00.000Z`)
date.setFullYear(date.getFullYear() + 1)
console.log(date) //=> Sat Mar 01 2025 09:00:00 GMT+0900 (Japan Standard Time)

This is why isWithinOneYear("2025-03-01", leapDate) returns true, as 2025-03-01 becomes the upper limit.

What a language returns for one year after a leap day seems to vary depending on the language. For example, in Ruby, the year following February 29th is February 28th, so this issue shouldn't occur (I'm not intending to criticize JavaScript).

So, how should the previous implementation have been improved? The simplest way is to move the upper limit back by one day only for leap days.

const isWithinOneYear = (input: string, base: string) => {
  const baseDate = new Date(`${base}T00:00:00.000Z`);
  const inputDate = new Date(`${input}T00:00:00.000Z`);

  // Set the number of seconds for one day to shift back only for Feb 29th of a leap year
  let forLeapSec = 0;
  if (
    ((baseDate.getFullYear() % 4 === 0 && baseDate.getFullYear() % 100 !== 0) ||
      baseDate.getFullYear() % 400 === 0) &&
    baseDate.getMonth() === 1 &&
    baseDate.getDate() === 29
  ) {
    forLeapSec = 24 * 60 * 60 * 1000;
  }

  const prevYear = baseDate.getFullYear() - 1;
  const nextYear = baseDate.getFullYear() + 1;
  const lower = baseDate.setFullYear(prevYear);
  const upper = baseDate.setFullYear(nextYear);

  return inputDate >= new Date(lower) && inputDate <= new Date(upper - forLeapSec);
};

There are probably various other more elegant ways to do this, but for now, this works as expected.

Even though we say "within ±1 year," there are cases where including February 28th as one year before February 29th is the correct requirement, and others where including March 1st as one year after February 29th is correct. It is not simply a matter of being safe because you are using a library like dayjs. It was the fault of the implementer for not confirming the details of the "one year" requirement.

Incidentally, the code generated by ChatGPT when I provided the same requirements also failed to account for leap years and required manual fixes. It seems we still need the skill to decipher whether AI-generated code actually meets the requirements.

Anyway, that's the story. Not just with leap days, but JavaScript's Date object is prone to various pitfalls, such as months being 0-indexed or the inability to change time zones...

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date

Discussion