Skip to content
Draft
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
76 changes: 76 additions & 0 deletions src/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions test/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down
80 changes: 70 additions & 10 deletions test/relative-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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 {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading