diff --git a/src/duration.ts b/src/duration.ts index 0195fc2..d968b66 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) @@ -145,6 +144,108 @@ 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) + 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 +} + +/** + * 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 + 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() +} + +/** + * 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. + * @param precisionIndex - Index of the requested unit in {@link unitNames}. + * @returns The corrected duration, or `undefined` when no correction is needed. + */ +function calendarElapsedTime(date: Date, reference: Date, precisionIndex: 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 candidateAligned = + anchor.getUTCFullYear() === date.getUTCFullYear() && + anchor.getUTCMonth() === date.getUTCMonth() && + anchor.getUTCDate() === date.getUTCDate() && + hasSameTimeAtPrecision(date, anchor, precisionIndex) + const candidateOvershot = + calendarMonths !== 0 && !candidateAligned && (calendarMonths > 0 ? anchor > date : anchor < date) + if (candidateOvershot) { + wholeMonths += calendarMonths > 0 ? -1 : 1 + anchor = applyCalendarMonths(reference, wholeMonths) + } + + const isCalendarAligned = + anchor.getUTCFullYear() === date.getUTCFullYear() && + anchor.getUTCMonth() === date.getUTCMonth() && + anchor.getUTCDate() === date.getUTCDate() && + hasSameTimeAtPrecision(date, anchor, precisionIndex) + if (!wholeMonths && !isCalendarAligned) return + + // 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) + 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 +258,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) + 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..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}`, () => { @@ -107,18 +109,18 @@ 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', input: '2022-10-21T16:48:45.104Z', - expected: 'P9M3DT1S', + expected: 'P9MT1S', }, { 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', @@ -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', @@ -218,7 +220,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 +239,22 @@ 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: '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', diff --git a/test/relative-time.js b/test/relative-time.js index 59b658b..aa17699 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 () => { @@ -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', @@ -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,88 @@ 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: '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', + }, + { + 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', + 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 { @@ -2673,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', @@ -2684,7 +2766,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 +3099,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',