From feecbddd5d29370b37ca53ef3ce739d5dbcad7e4 Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Mon, 24 Aug 2026 15:55:28 -0400 Subject: [PATCH 1/9] Fix calendar-aligned elapsed durations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d8328fd-448c-41bc-8426-102150ede25f --- src/duration.ts | 17 ++++++++++ test/duration.ts | 4 +-- test/relative-time.js | 76 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 0195fc2..396c342 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -157,6 +157,23 @@ export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.n const month = Math.floor(day / 30) const year = Math.floor(month / 12) const i = unitNames.indexOf(precision) + + const nowDate = new Date(now) + const calendarMonths = + (date.getUTCFullYear() - nowDate.getUTCFullYear()) * 12 + date.getUTCMonth() - nowDate.getUTCMonth() + const sameTime = + i <= 3 || + (date.getUTCHours() === nowDate.getUTCHours() && + (i <= 4 || + (date.getUTCMinutes() === nowDate.getUTCMinutes() && + (i <= 5 || + (date.getUTCSeconds() === nowDate.getUTCSeconds() && + (i <= 6 || date.getUTCMilliseconds() === nowDate.getUTCMilliseconds())))))) + if (calendarMonths && date.getUTCDate() === nowDate.getUTCDate() && (sameTime || Math.abs(calendarMonths) >= 12)) { + const calendarYears = Math.trunc(calendarMonths / 12) + return new Duration(i >= 0 ? calendarYears : 0, i >= 1 ? calendarMonths - calendarYears * 12 : 0) + } + return new Duration( i >= 0 ? year * sign : 0, i >= 1 ? (month - year * 12) * sign : 0, diff --git a/test/duration.ts b/test/duration.ts index 87707de..e0a73bb 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -107,7 +107,7 @@ suite('duration', function () { { now: '2022-01-21T16:48:44.104Z', input: '2022-10-21T16:48:44.104Z', - expected: 'P9M3D', + expected: 'P9M', }, { now: '2022-01-21T16:48:44.104Z', @@ -118,7 +118,7 @@ suite('duration', function () { now: '2022-01-21T16:48:44.104Z', input: '2022-10-21T16:48:45.104Z', precision: 'day', - expected: 'P9M3D', + expected: 'P9M', }, { now: '2022-10-21T16:44:44.104Z', diff --git a/test/relative-time.js b/test/relative-time.js index 59b658b..39ae23e 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -1559,7 +1559,7 @@ suite('relative-time', function () { { datetime: '2024-10-24T14:46:00.000Z', format: 'duration', - expected: '2 years, 11 days', + expected: '2 years', }, { datetime: '2024-10-24T14:46:00.000Z', @@ -1571,19 +1571,19 @@ suite('relative-time', function () { datetime: '2024-10-24T14:46:00.000Z', format: 'duration', precision: 'minute', - expected: '2 years, 11 days', + expected: '2 years', }, { datetime: '2024-10-24T14:46:00.000Z', format: 'duration', precision: 'day', - expected: '2 years, 11 days', + expected: '2 years', }, { datetime: '2024-10-24T14:46:00.000Z', format: 'duration', tense: 'future', - expected: '2 years, 11 days', + expected: '2 years', }, { datetime: '2024-10-24T14:46:00.000Z', @@ -2015,19 +2015,19 @@ suite('relative-time', function () { { datetime: '2020-10-24T14:46:00.000Z', format: 'duration', - expected: '2 years, 10 days', + expected: '2 years', }, { datetime: '2020-10-24T14:46:00.000Z', format: 'duration', precision: 'minute', - expected: '2 years, 10 days', + expected: '2 years', }, { datetime: '2020-10-24T14:46:00.000Z', format: 'duration', precision: 'day', - expected: '2 years, 10 days', + expected: '2 years', }, { datetime: '2020-10-24T14:46:00.000Z', @@ -2039,7 +2039,7 @@ suite('relative-time', function () { datetime: '2020-10-24T14:46:00.000Z', format: 'duration', tense: 'past', - expected: '2 years, 10 days', + expected: '2 years', }, { reference: '2023-03-23T12:03:00.000Z', @@ -2048,6 +2048,66 @@ suite('relative-time', function () { tense: 'past', expected: '2 days ago', }, + + // Whole years shouldn't have extra days + { + reference: '2023-01-01T09:00:00.000Z', + datetime: '2022-01-01T10:00:00.000Z', + format: 'duration', + tense: 'past', + expected: '1 year', + }, + { + reference: '2023-01-01T00:00:00.000Z', + datetime: '2021-01-01T00:00:00.000Z', + format: 'duration', + tense: 'past', + expected: '2 years', + }, + { + reference: '2023-01-01T00:00:00.000Z', + datetime: '2003-01-01T00:00:00.000Z', + format: 'duration', + tense: 'past', + expected: '20 years', + }, + { + reference: '2023-02-01T00:00:00.000Z', + datetime: '2022-02-01T00:00:00.000Z', + format: 'duration', + tense: 'past', + expected: '1 year', + }, + { + reference: '2023-03-01T00:00:00.000Z', + datetime: '2022-03-01T00:00:00.000Z', + format: 'duration', + tense: 'past', + expected: '1 year', + }, + { + reference: '2023-04-01T00:00:00.000Z', + datetime: '2022-04-01T00:00:00.000Z', + format: 'duration', + tense: 'past', + expected: '1 year', + }, + + // Whole months shouldn't have extra days + { + reference: '2023-07-01T00:00:00.000Z', + datetime: '2023-05-01T00:00:00.000Z', + format: 'duration', + tense: 'past', + expected: '2 months', + }, + { + reference: '2023-06-01T00:00:00.000Z', + datetime: '2023-04-01T00:00:00.000Z', + format: 'duration', + tense: 'past', + expected: '2 months', + }, ]) for (const { From d6b5c94bf8f5adeadf13217aa7ef1487502b913f Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Mon, 24 Aug 2026 16:06:08 -0400 Subject: [PATCH 2/9] Refactor calendar duration calculation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d8328fd-448c-41bc-8426-102150ede25f --- src/duration.ts | 45 +++++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 396c342..aaa87ca 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -145,6 +145,35 @@ export function applyDuration(date: Date | number, duration: Duration): Date { return r } +function hasSameTimeAtPrecision(date: Date, reference: Date, precisionIndex: number): boolean { + if (precisionIndex <= unitNames.indexOf('day')) return true + if (date.getUTCHours() !== reference.getUTCHours()) return false + if (precisionIndex === unitNames.indexOf('hour')) return true + if (date.getUTCMinutes() !== reference.getUTCMinutes()) return false + if (precisionIndex === unitNames.indexOf('minute')) return true + if (date.getUTCSeconds() !== reference.getUTCSeconds()) return false + if (precisionIndex === unitNames.indexOf('second')) return true + return date.getUTCMilliseconds() === reference.getUTCMilliseconds() +} + +function calendarElapsedTime(date: Date, reference: Date, precisionIndex: number): Duration | undefined { + const calendarMonths = + (date.getUTCFullYear() - reference.getUTCFullYear()) * 12 + date.getUTCMonth() - reference.getUTCMonth() + if (!calendarMonths || date.getUTCDate() !== reference.getUTCDate()) return + + // Treat matching calendar days at least a year apart as anniversaries even + // when their times differ, rather than leaking fixed-month remainder days. + const isAnniversary = Math.abs(calendarMonths) >= 12 + if (!isAnniversary && !hasSameTimeAtPrecision(date, reference, precisionIndex)) return + + const calendarYears = Math.trunc(calendarMonths / 12) + let years = 0 + let months = 0 + if (precisionIndex >= unitNames.indexOf('year')) years = calendarYears + if (precisionIndex >= unitNames.indexOf('month')) months = calendarMonths - calendarYears * 12 + return new Duration(years, months) +} + export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.now()): Duration { const delta = date.getTime() - now if (delta === 0) return new Duration() @@ -159,20 +188,8 @@ export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.n const i = unitNames.indexOf(precision) const nowDate = new Date(now) - const calendarMonths = - (date.getUTCFullYear() - nowDate.getUTCFullYear()) * 12 + date.getUTCMonth() - nowDate.getUTCMonth() - const sameTime = - i <= 3 || - (date.getUTCHours() === nowDate.getUTCHours() && - (i <= 4 || - (date.getUTCMinutes() === nowDate.getUTCMinutes() && - (i <= 5 || - (date.getUTCSeconds() === nowDate.getUTCSeconds() && - (i <= 6 || date.getUTCMilliseconds() === nowDate.getUTCMilliseconds())))))) - if (calendarMonths && date.getUTCDate() === nowDate.getUTCDate() && (sameTime || Math.abs(calendarMonths) >= 12)) { - const calendarYears = Math.trunc(calendarMonths / 12) - return new Duration(i >= 0 ? calendarYears : 0, i >= 1 ? calendarMonths - calendarYears * 12 : 0) - } + const calendarDuration = calendarElapsedTime(date, nowDate, i) + if (calendarDuration) return calendarDuration return new Duration( i >= 0 ? year * sign : 0, From 6972406a634896a3e0b58011bb9e65affe04fdfc Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Wed, 26 Aug 2026 01:15:06 -0400 Subject: [PATCH 3/9] Correct premature calendar year estimates Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d8328fd-448c-41bc-8426-102150ede25f --- src/duration.ts | 64 +++++++++++++++++++++++++++++++++++-------- test/duration.ts | 8 +++++- test/relative-time.js | 4 +-- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index aaa87ca..55f8d35 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -145,6 +145,12 @@ export function applyDuration(date: Date | number, duration: Duration): Date { return r } +function applyCalendarMonths(reference: Date, months: number): Date { + const result = new Date(reference) + result.setUTCMonth(result.getUTCMonth() + months) + return result +} + function hasSameTimeAtPrecision(date: Date, reference: Date, precisionIndex: number): boolean { if (precisionIndex <= unitNames.indexOf('day')) return true if (date.getUTCHours() !== reference.getUTCHours()) return false @@ -156,22 +162,58 @@ function hasSameTimeAtPrecision(date: Date, reference: Date, precisionIndex: num return date.getUTCMilliseconds() === reference.getUTCMilliseconds() } -function calendarElapsedTime(date: Date, reference: Date, precisionIndex: number): Duration | undefined { +function calendarElapsedTime( + date: Date, + reference: Date, + precisionIndex: number, + estimatedYears: number, +): Duration | undefined { const calendarMonths = (date.getUTCFullYear() - reference.getUTCFullYear()) * 12 + date.getUTCMonth() - reference.getUTCMonth() - if (!calendarMonths || date.getUTCDate() !== reference.getUTCDate()) return - // Treat matching calendar days at least a year apart as anniversaries even - // when their times differ, rather than leaking fixed-month remainder days. - const isAnniversary = Math.abs(calendarMonths) >= 12 - if (!isAnniversary && !hasSameTimeAtPrecision(date, reference, precisionIndex)) return + // Anchor the candidate month count to the reference, then back it off if it + // crossed the target. This prevents 30-day estimates from inventing a year. + let wholeMonths = calendarMonths + let anchor = applyCalendarMonths(reference, calendarMonths) + const candidateOvershot = calendarMonths > 0 ? anchor > date : anchor < date + if (candidateOvershot) { + wholeMonths += calendarMonths > 0 ? -1 : 1 + anchor = applyCalendarMonths(reference, wholeMonths) + } + + const calendarYears = Math.trunc(wholeMonths / 12) + const estimatedFalseYear = calendarYears !== estimatedYears + const sameCalendarDay = date.getUTCDate() === reference.getUTCDate() + const isAnniversary = Math.abs(calendarMonths) >= 12 && sameCalendarDay + const isCalendarAligned = + sameCalendarDay && (isAnniversary || hasSameTimeAtPrecision(date, reference, precisionIndex)) + if (!estimatedFalseYear && !isCalendarAligned) return + + // Exact anniversaries intentionally ignore a sub-day difference. Other + // corrected durations retain the remainder after the calendar-month anchor. + const sign = Math.sign(date.getTime() - reference.getTime()) + const remainder = isCalendarAligned ? 0 : Math.abs(date.getTime() - anchor.getTime()) + const seconds = Math.floor(remainder / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) - const calendarYears = Math.trunc(calendarMonths / 12) let years = 0 let months = 0 - if (precisionIndex >= unitNames.indexOf('year')) years = calendarYears - if (precisionIndex >= unitNames.indexOf('month')) months = calendarMonths - calendarYears * 12 - return new Duration(years, months) + const durationMonths = isCalendarAligned ? calendarMonths : wholeMonths + const durationYears = Math.trunc(durationMonths / 12) + if (precisionIndex >= unitNames.indexOf('year')) years = durationYears + if (precisionIndex >= unitNames.indexOf('month')) months = durationMonths - durationYears * 12 + return new Duration( + years, + months, + 0, + precisionIndex >= unitNames.indexOf('day') ? days * sign : 0, + precisionIndex >= unitNames.indexOf('hour') ? (hours - days * 24) * sign : 0, + precisionIndex >= unitNames.indexOf('minute') ? (minutes - hours * 60) * sign : 0, + precisionIndex >= unitNames.indexOf('second') ? (seconds - minutes * 60) * sign : 0, + precisionIndex >= unitNames.indexOf('millisecond') ? (remainder - seconds * 1000) * sign : 0, + ) } export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.now()): Duration { @@ -188,7 +230,7 @@ export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.n const i = unitNames.indexOf(precision) const nowDate = new Date(now) - const calendarDuration = calendarElapsedTime(date, nowDate, i) + const calendarDuration = calendarElapsedTime(date, nowDate, i, year * sign) if (calendarDuration) return calendarDuration return new Duration( diff --git a/test/duration.ts b/test/duration.ts index e0a73bb..7edb641 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -218,7 +218,7 @@ suite('duration', function () { { now: '2022-10-24T14:46:00.000Z', input: '2021-10-29T14:46:00.000Z', - expected: '-P1Y', + expected: '-P11M26D', }, { now: '2023-03-23T12:03:00.000Z', @@ -237,6 +237,12 @@ suite('duration', function () { precision: 'year', expected: '-P2Y', }, + { + now: '2023-01-01T00:00:00.000Z', + input: '2023-12-31T00:00:00.000Z', + precision: 'year', + expected: 'PT0S', + }, { now: '2022-10-24T14:46:00.000Z', input: '2024-10-24T14:46:00.000Z', diff --git a/test/relative-time.js b/test/relative-time.js index 39ae23e..e92d4d8 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -2744,7 +2744,7 @@ suite('relative-time', function () { { datetime: '2021-10-29T14:46:00.000Z', format: 'elapsed', - expected: '1y', + expected: '11mo 26d', }, { datetime: '2020-10-24T14:46:00.000Z', @@ -3077,7 +3077,7 @@ suite('relative-time', function () { datetime: '2022-01-01T12:00:00.000Z', tense: 'past', format: 'micro', - expected: '1y ago', + expected: '11mo ago', }, { reference: '2022-12-31T12:00:00.000Z', From ca4886b1a923bf267a2e2d558038620b1ec1f5be Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Thu, 27 Aug 2026 01:32:04 -0400 Subject: [PATCH 4/9] Document calendar duration helpers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d8328fd-448c-41bc-8426-102150ede25f --- src/duration.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/duration.ts b/src/duration.ts index 55f8d35..6efda16 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -145,12 +145,26 @@ export function applyDuration(date: Date | number, duration: Duration): Date { return r } +/** + * Applies a number of calendar months to a copy of the reference date. + * + * @param reference - Date from which to count. + * @param months - Signed number of months to apply. + * @returns The resulting date without modifying the reference. + */ function applyCalendarMonths(reference: Date, months: number): Date { const result = new Date(reference) result.setUTCMonth(result.getUTCMonth() + months) return result } +/** + * Checks whether two UTC times match through the requested precision. + * + * @param date - Date being compared. + * @param reference - Reference date for the comparison. + * @param precisionIndex - Index of the requested unit in {@link unitNames}. + */ function hasSameTimeAtPrecision(date: Date, reference: Date, precisionIndex: number): boolean { if (precisionIndex <= unitNames.indexOf('day')) return true if (date.getUTCHours() !== reference.getUTCHours()) return false @@ -162,6 +176,16 @@ function hasSameTimeAtPrecision(date: Date, reference: Date, precisionIndex: num return date.getUTCMilliseconds() === reference.getUTCMilliseconds() } +/** + * Returns a calendar-based correction when fixed 30-day months produce an + * incorrect year or when the dates align on a month or year boundary. + * + * @param date - Target date. + * @param reference - Date from which elapsed time is measured. + * @param precisionIndex - Index of the requested unit in {@link unitNames}. + * @param estimatedYears - Year count produced by the fixed-duration estimate. + * @returns The corrected duration, or `undefined` when no correction is needed. + */ function calendarElapsedTime( date: Date, reference: Date, From 0fd6fb3ba6b6eeab1e3b9b8f69eb4e9cc245c6fe Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Thu, 27 Aug 2026 02:05:28 -0400 Subject: [PATCH 5/9] Respect precision for anniversary remainders Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d8328fd-448c-41bc-8426-102150ede25f --- src/duration.ts | 8 +++----- test/relative-time.js | 8 ++++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 6efda16..05876a3 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -208,13 +208,11 @@ function calendarElapsedTime( const calendarYears = Math.trunc(wholeMonths / 12) const estimatedFalseYear = calendarYears !== estimatedYears const sameCalendarDay = date.getUTCDate() === reference.getUTCDate() - const isAnniversary = Math.abs(calendarMonths) >= 12 && sameCalendarDay - const isCalendarAligned = - sameCalendarDay && (isAnniversary || hasSameTimeAtPrecision(date, reference, precisionIndex)) + const isCalendarAligned = sameCalendarDay && hasSameTimeAtPrecision(date, reference, precisionIndex) if (!estimatedFalseYear && !isCalendarAligned) return - // Exact anniversaries intentionally ignore a sub-day difference. Other - // corrected durations retain the remainder after the calendar-month anchor. + // Calendar-aligned durations omit only units below the requested precision. + // Other corrected durations retain the remainder after the month anchor. const sign = Math.sign(date.getTime() - reference.getTime()) const remainder = isCalendarAligned ? 0 : Math.abs(date.getTime() - anchor.getTime()) const seconds = Math.floor(remainder / 1000) diff --git a/test/relative-time.js b/test/relative-time.js index e92d4d8..db0864a 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -2055,6 +2055,14 @@ suite('relative-time', function () { datetime: '2022-01-01T10:00:00.000Z', format: 'duration', tense: 'past', + expected: '11 months, 30 days, 23 hours', + }, + { + reference: '2023-01-01T09:00:00.000Z', + datetime: '2022-01-01T10:00:00.000Z', + format: 'duration', + precision: 'day', + tense: 'past', expected: '1 year', }, { From ee5730e64b0a9197ba69a1348483e28f70e8960e Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Thu, 27 Aug 2026 02:12:39 -0400 Subject: [PATCH 6/9] Use calendar anchors for year durations Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d8328fd-448c-41bc-8426-102150ede25f --- src/duration.ts | 4 ++-- test/relative-time.js | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 05876a3..d5030b7 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -206,10 +206,10 @@ function calendarElapsedTime( } const calendarYears = Math.trunc(wholeMonths / 12) - const estimatedFalseYear = calendarYears !== estimatedYears + const hasYearScaleDuration = estimatedYears !== 0 || calendarYears !== 0 const sameCalendarDay = date.getUTCDate() === reference.getUTCDate() const isCalendarAligned = sameCalendarDay && hasSameTimeAtPrecision(date, reference, precisionIndex) - if (!estimatedFalseYear && !isCalendarAligned) return + if (!hasYearScaleDuration && !isCalendarAligned) return // Calendar-aligned durations omit only units below the requested precision. // Other corrected durations retain the remainder after the month anchor. diff --git a/test/relative-time.js b/test/relative-time.js index db0864a..d62e7d2 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -650,7 +650,7 @@ suite('relative-time', function () { time.setAttribute('tense', 'past') time.setAttribute('datetime', '2023-01-01T00:00:00Z') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '11 years ago') + assert.equal(time.shadowRoot.textContent, '10 years ago') }) test('rewrites from now past datetime to minutes ago', async () => { @@ -2065,6 +2065,20 @@ suite('relative-time', function () { tense: 'past', expected: '1 year', }, + { + reference: '2023-01-01T09:00:00.000Z', + datetime: '2022-01-02T10:00:00.000Z', + format: 'duration', + tense: 'past', + expected: '11 months, 29 days, 23 hours', + }, + { + reference: '2023-01-02T09:00:00.000Z', + datetime: '2022-01-01T10:00:00.000Z', + format: 'duration', + tense: 'past', + expected: '1 year, 23 hours', + }, { reference: '2023-01-01T00:00:00.000Z', datetime: '2021-01-01T00:00:00.000Z', From 549dc25e1e42a69f4b3ba8c0576027fa01dd2d3f Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Thu, 27 Aug 2026 02:21:55 -0400 Subject: [PATCH 7/9] Clamp calendar month anchors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d8328fd-448c-41bc-8426-102150ede25f --- src/duration.ts | 21 ++++++++++++++++++--- test/duration.ts | 10 ++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index d5030b7..b0f45c4 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -154,7 +154,14 @@ export function applyDuration(date: Date | number, duration: Duration): Date { */ function applyCalendarMonths(reference: Date, months: number): Date { const result = new Date(reference) + const referenceDay = result.getUTCDate() + + // Move from the first to avoid rolling an invalid date into the next month. + result.setUTCDate(1) result.setUTCMonth(result.getUTCMonth() + months) + const targetMonth = result.getUTCMonth() + result.setUTCMonth(targetMonth + 1, 0) + result.setUTCDate(Math.min(referenceDay, result.getUTCDate())) return result } @@ -199,7 +206,12 @@ function calendarElapsedTime( // crossed the target. This prevents 30-day estimates from inventing a year. let wholeMonths = calendarMonths let anchor = applyCalendarMonths(reference, calendarMonths) - const candidateOvershot = calendarMonths > 0 ? anchor > date : anchor < date + const candidateAligned = + anchor.getUTCFullYear() === date.getUTCFullYear() && + anchor.getUTCMonth() === date.getUTCMonth() && + anchor.getUTCDate() === date.getUTCDate() && + hasSameTimeAtPrecision(date, anchor, precisionIndex) + const candidateOvershot = !candidateAligned && (calendarMonths > 0 ? anchor > date : anchor < date) if (candidateOvershot) { wholeMonths += calendarMonths > 0 ? -1 : 1 anchor = applyCalendarMonths(reference, wholeMonths) @@ -207,8 +219,11 @@ function calendarElapsedTime( const calendarYears = Math.trunc(wholeMonths / 12) const hasYearScaleDuration = estimatedYears !== 0 || calendarYears !== 0 - const sameCalendarDay = date.getUTCDate() === reference.getUTCDate() - const isCalendarAligned = sameCalendarDay && hasSameTimeAtPrecision(date, reference, precisionIndex) + const isCalendarAligned = + anchor.getUTCFullYear() === date.getUTCFullYear() && + anchor.getUTCMonth() === date.getUTCMonth() && + anchor.getUTCDate() === date.getUTCDate() && + hasSameTimeAtPrecision(date, anchor, precisionIndex) if (!hasYearScaleDuration && !isCalendarAligned) return // Calendar-aligned durations omit only units below the requested precision. diff --git a/test/duration.ts b/test/duration.ts index 7edb641..248ba25 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -243,6 +243,16 @@ suite('duration', function () { precision: 'year', expected: 'PT0S', }, + { + now: '2020-02-29T00:00:00.000Z', + input: '2021-02-28T00:00:00.000Z', + expected: 'P1Y', + }, + { + now: '2023-01-31T00:00:00.000Z', + input: '2023-02-28T00:00:00.000Z', + expected: 'P1M', + }, { now: '2022-10-24T14:46:00.000Z', input: '2024-10-24T14:46:00.000Z', From 2edfb1c5b3de066b60abf7957bf945af13ee03ca Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Thu, 27 Aug 2026 02:38:37 -0400 Subject: [PATCH 8/9] Unify calendar duration semantics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6d8328fd-448c-41bc-8426-102150ede25f --- src/duration.ts | 26 +++++++++----------------- test/duration.ts | 6 ++++-- test/relative-time.js | 12 ++++++------ 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index b0f45c4..29a8b2c 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -126,17 +126,16 @@ export class Duration { } export function applyDuration(date: Date | number, duration: Duration): Date { - const r = new Date(date) + let r = new Date(date) + const calendarMonths = duration.years * 12 + duration.months if (duration.sign < 0) { r.setUTCSeconds(r.getUTCSeconds() + duration.seconds) r.setUTCMinutes(r.getUTCMinutes() + duration.minutes) r.setUTCHours(r.getUTCHours() + duration.hours) r.setUTCDate(r.getUTCDate() + duration.weeks * 7 + duration.days) - r.setUTCMonth(r.getUTCMonth() + duration.months) - r.setUTCFullYear(r.getUTCFullYear() + duration.years) + r = applyCalendarMonths(r, calendarMonths) } else { - r.setUTCFullYear(r.getUTCFullYear() + duration.years) - r.setUTCMonth(r.getUTCMonth() + duration.months) + r = applyCalendarMonths(r, calendarMonths) r.setUTCDate(r.getUTCDate() + duration.weeks * 7 + duration.days) r.setUTCHours(r.getUTCHours() + duration.hours) r.setUTCMinutes(r.getUTCMinutes() + duration.minutes) @@ -190,15 +189,9 @@ function hasSameTimeAtPrecision(date: Date, reference: Date, precisionIndex: num * @param date - Target date. * @param reference - Date from which elapsed time is measured. * @param precisionIndex - Index of the requested unit in {@link unitNames}. - * @param estimatedYears - Year count produced by the fixed-duration estimate. * @returns The corrected duration, or `undefined` when no correction is needed. */ -function calendarElapsedTime( - date: Date, - reference: Date, - precisionIndex: number, - estimatedYears: number, -): Duration | undefined { +function calendarElapsedTime(date: Date, reference: Date, precisionIndex: number): Duration | undefined { const calendarMonths = (date.getUTCFullYear() - reference.getUTCFullYear()) * 12 + date.getUTCMonth() - reference.getUTCMonth() @@ -211,20 +204,19 @@ function calendarElapsedTime( anchor.getUTCMonth() === date.getUTCMonth() && anchor.getUTCDate() === date.getUTCDate() && hasSameTimeAtPrecision(date, anchor, precisionIndex) - const candidateOvershot = !candidateAligned && (calendarMonths > 0 ? anchor > date : anchor < date) + const candidateOvershot = + calendarMonths !== 0 && !candidateAligned && (calendarMonths > 0 ? anchor > date : anchor < date) if (candidateOvershot) { wholeMonths += calendarMonths > 0 ? -1 : 1 anchor = applyCalendarMonths(reference, wholeMonths) } - const calendarYears = Math.trunc(wholeMonths / 12) - const hasYearScaleDuration = estimatedYears !== 0 || calendarYears !== 0 const isCalendarAligned = anchor.getUTCFullYear() === date.getUTCFullYear() && anchor.getUTCMonth() === date.getUTCMonth() && anchor.getUTCDate() === date.getUTCDate() && hasSameTimeAtPrecision(date, anchor, precisionIndex) - if (!hasYearScaleDuration && !isCalendarAligned) return + if (!wholeMonths && !isCalendarAligned) return // Calendar-aligned durations omit only units below the requested precision. // Other corrected durations retain the remainder after the month anchor. @@ -267,7 +259,7 @@ export function elapsedTime(date: Date, precision: Unit = 'second', now = Date.n const i = unitNames.indexOf(precision) const nowDate = new Date(now) - const calendarDuration = calendarElapsedTime(date, nowDate, i, year * sign) + const calendarDuration = calendarElapsedTime(date, nowDate, i) if (calendarDuration) return calendarDuration return new Duration( diff --git a/test/duration.ts b/test/duration.ts index 248ba25..80f94a3 100644 --- a/test/duration.ts +++ b/test/duration.ts @@ -94,6 +94,8 @@ suite('duration', function () { {referenceDate: '2023-12-24T20:53:50.104Z', input: '-P1Y2M3DT4H5M6S', expected: '2022-10-21T16:48:44.104Z'}, {referenceDate: '2023-08-15T00:00:00.000Z', input: 'P1Y3M25D', expected: '2024-12-10T00:00:00.000Z'}, {referenceDate: '2024-12-10T00:00:00.000Z', input: '-P1Y3M25D', expected: '2023-08-15T00:00:00.000Z'}, + {referenceDate: '2023-01-31T00:00:00.000Z', input: 'P1M', expected: '2023-02-28T00:00:00.000Z'}, + {referenceDate: '2020-02-29T00:00:00.000Z', input: 'P1Y', expected: '2021-02-28T00:00:00.000Z'}, ]) for (const {referenceDate, input, expected} of tests) { test(`${referenceDate} -> ${input} -> ${expected}`, () => { @@ -112,7 +114,7 @@ suite('duration', function () { { now: '2022-01-21T16:48:44.104Z', input: '2022-10-21T16:48:45.104Z', - expected: 'P9M3DT1S', + expected: 'P9MT1S', }, { now: '2022-01-21T16:48:44.104Z', @@ -207,7 +209,7 @@ suite('duration', function () { { now: '2022-10-24T14:46:00.000Z', input: '2021-10-30T14:46:00.000Z', - expected: '-P11M29D', + expected: '-P11M25D', }, { now: '2022-10-24T14:46:00.000Z', diff --git a/test/relative-time.js b/test/relative-time.js index d62e7d2..aa17699 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -697,7 +697,7 @@ suite('relative-time', function () { time.setAttribute('tense', 'past') time.setAttribute('datetime', '2023-06-01T00:00:00Z') await Promise.resolve() - assert.equal(time.shadowRoot.textContent, '4 months ago') + assert.equal(time.shadowRoot.textContent, '3 months ago') }) test('rewrites from last few days of month to smaller last month', async () => { @@ -1486,25 +1486,25 @@ suite('relative-time', function () { { datetime: '2022-12-03T15:46:00.000Z', format: 'duration', - expected: '1 month, 10 days, 1 hour', + expected: '1 month, 9 days, 1 hour', }, { datetime: '2022-12-03T15:46:00.000Z', format: 'duration', precision: 'minute', - expected: '1 month, 10 days, 1 hour', + expected: '1 month, 9 days, 1 hour', }, { datetime: '2022-12-03T15:46:00.000Z', format: 'duration', precision: 'day', - expected: '1 month, 10 days', + expected: '1 month, 9 days', }, { datetime: '2022-12-03T15:46:00.000Z', format: 'duration', tense: 'future', - expected: '1 month, 10 days, 1 hour', + expected: '1 month, 9 days, 1 hour', }, { datetime: '2022-12-03T15:46:00.000Z', @@ -2755,7 +2755,7 @@ suite('relative-time', function () { { datetime: '2021-10-30T14:46:00.000Z', format: 'elapsed', - expected: '11mo 29d', + expected: '11mo 25d', }, { datetime: '2021-10-30T14:46:00.000Z', From 9446af6c1103609214014a747d907cef47614d53 Mon Sep 17 00:00:00 2001 From: Marie Lucca <40550942+francinelucca@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:53:05 -0400 Subject: [PATCH 9/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/duration.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/duration.ts b/src/duration.ts index 29a8b2c..d968b66 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -183,8 +183,9 @@ function hasSameTimeAtPrecision(date: Date, reference: Date, precisionIndex: num } /** - * Returns a calendar-based correction when fixed 30-day months produce an - * incorrect year or when the dates align on a month or year boundary. + * Returns a calendar-based duration for completed calendar-month spans or + * endpoints aligned at the requested precision, leaving shorter intervals on + * the fixed-duration path. * * @param date - Target date. * @param reference - Date from which elapsed time is measured.