From feecbddd5d29370b37ca53ef3ce739d5dbcad7e4 Mon Sep 17 00:00:00 2001 From: Marie Lucca Date: Mon, 24 Aug 2026 15:55:28 -0400 Subject: [PATCH 1/3] 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/3] 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/3] 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',