Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 111 additions & 5 deletions src/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()))
Comment thread
francinelucca marked this conversation as resolved.
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.
*/
Comment thread
Copilot marked this conversation as resolved.
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())
Comment on lines +224 to +225
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()
Expand All @@ -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,
Expand Down
28 changes: 23 additions & 5 deletions test/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, () => {
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down
Loading
Loading