diff --git a/src/duration.ts b/src/duration.ts index 0195fc2..55f8d35 100644 --- a/src/duration.ts +++ b/src/duration.ts @@ -145,6 +145,77 @@ 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 + 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, + estimatedYears: number, +): Duration | undefined { + const calendarMonths = + (date.getUTCFullYear() - reference.getUTCFullYear()) * 12 + date.getUTCMonth() - reference.getUTCMonth() + + // 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) + + let years = 0 + let months = 0 + 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 { const delta = date.getTime() - now if (delta === 0) return new Duration() @@ -157,6 +228,11 @@ 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 calendarDuration = calendarElapsedTime(date, nowDate, i, year * sign) + if (calendarDuration) return calendarDuration + 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..7edb641 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', @@ -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 59b658b..e92d4d8 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 { @@ -2684,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', @@ -3017,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',