From 61fb7c2cd76311ca2d4492a86459b5228aa7e76c Mon Sep 17 00:00:00 2001 From: Yarchik Date: Thu, 9 Jul 2026 12:25:07 +0100 Subject: [PATCH] fix: clamp day-of-month when adding years/months to a start date end() (and thus toSeconds()) added months/years with the raw JS Date setters, so a day that does not exist in the target month overflowed into the next one: e.g. from Jan 31, P1M produced Mar 3 instead of Feb 28. This diverges from the @js-temporal/polyfill oracle the test suite validates against, which constrains the day to the target month. Add years/months on the 1st of the month, then clamp the day to the number of days in the resulting month before adding the day component. --- lib/index.js | 7 ++++++- src/index.ts | 11 ++++++++++- test/iso8601-tests.mjs | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/index.js b/lib/index.js index 411a233..611184b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -67,9 +67,14 @@ var end = function (durationInput, startDate) { // Create two equal timestamps, add duration to 'then' and return time difference var timestamp = startDate.getTime(); var then = new Date(timestamp); + // Add years/months on the 1st, then clamp the day to the resulting month so + // that e.g. Jan 31 + P1M is Feb 28, not an overflow into March + var dayOfMonth = then.getDate(); + then.setDate(1); then.setFullYear(then.getFullYear() + duration.years); then.setMonth(then.getMonth() + duration.months); - then.setDate(then.getDate() + duration.days); + var daysInMonth = new Date(then.getFullYear(), then.getMonth() + 1, 0).getDate(); + then.setDate(Math.min(dayOfMonth, daysInMonth) + duration.days); // set time as milliseconds to get fractions working for minutes/hours var hoursInMs = duration.hours * 3600 * 1000; var minutesInMs = duration.minutes * 60 * 1000; diff --git a/src/index.ts b/src/index.ts index e66b83b..4182f70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,9 +89,18 @@ export const end = ( const timestamp = startDate.getTime(); const then = new Date(timestamp); + // Add years/months on the 1st, then clamp the day to the resulting month so + // that e.g. Jan 31 + P1M is Feb 28, not an overflow into March + const dayOfMonth = then.getDate(); + then.setDate(1); then.setFullYear(then.getFullYear() + duration.years); then.setMonth(then.getMonth() + duration.months); - then.setDate(then.getDate() + duration.days); + const daysInMonth = new Date( + then.getFullYear(), + then.getMonth() + 1, + 0, + ).getDate(); + then.setDate(Math.min(dayOfMonth, daysInMonth) + duration.days); // set time as milliseconds to get fractions working for minutes/hours const hoursInMs = duration.hours * 3600 * 1000; const minutesInMs = duration.minutes * 60 * 1000; diff --git a/test/iso8601-tests.mjs b/test/iso8601-tests.mjs index 4a6db7b..567ec00 100644 --- a/test/iso8601-tests.mjs +++ b/test/iso8601-tests.mjs @@ -133,6 +133,22 @@ test("toSeconds: with supplied start date", () => { assert.equal(durFromFeb, expectedFebDuration); }); +[ + // Jan 31 + 1 month must clamp to Feb 28, not overflow into March + ["P1M", new Date(2021, 0, 31), new Date(2021, 1, 28)], + // Feb 29 + 1 year must clamp to Feb 28 in a non-leap year + ["P1Y", new Date(2020, 1, 29), new Date(2021, 1, 28)], + // overflow is clamped before days are added: Feb 28 + 1 day = Mar 1 + ["P1M1D", new Date(2021, 0, 31), new Date(2021, 2, 1)], +].forEach(([value, start, expectedEnd]) => { + test(`toSeconds: clamps calendar overflow (${value})`, () => { + assert.equal( + toSeconds(parse(value), start), + (expectedEnd.getTime() - start.getTime()) / 1000, + ); + }); +}); + test("usage example test", () => { // Arrange const jsonString = JSON.stringify({