Skip to content

Commit

Permalink
intervalToDuration fix for end of month calculations (date-fns#2616)
Browse files Browse the repository at this point in the history
Fixed `intervalToDuration` being off by 1 day sometimes.

Changed: introduced `RangeError` exception to `intervalToDuration` in case the start of the interval is after its end. This change makes `intervalToDuration`'s API consistent with other interval functions.
  • Loading branch information
fturmel authored Feb 7, 2022
1 parent 43c22c0 commit 928172c
Show file tree
Hide file tree
Showing 2 changed files with 205 additions and 52 deletions.
56 changes: 21 additions & 35 deletions src/intervalToDuration/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import compareAsc from '../compareAsc/index'
import add from '../add/index'
import differenceInDays from '../differenceInDays/index'
import differenceInHours from '../differenceInHours/index'
import differenceInMinutes from '../differenceInMinutes/index'
import differenceInMonths from '../differenceInMonths/index'
import differenceInSeconds from '../differenceInSeconds/index'
import differenceInYears from '../differenceInYears/index'
import isValid from '../isValid/index'
import sub from '../sub/index'
import toDate from '../toDate/index'
import type { Duration, Interval } from '../types'
import requiredArgs from '../_lib/requiredArgs/index'
Expand All @@ -25,6 +23,7 @@ import requiredArgs from '../_lib/requiredArgs/index'
* @throws {TypeError} Requires 2 arguments
* @throws {RangeError} `start` must not be Invalid Date
* @throws {RangeError} `end` must not be Invalid Date
* @throws {RangeError} The start of an interval cannot be after its end
*
* @example
* // Get the duration between January 15, 1929 and April 4, 1968.
Expand All @@ -34,49 +33,36 @@ import requiredArgs from '../_lib/requiredArgs/index'
* })
* // => { years: 39, months: 2, days: 20, hours: 7, minutes: 5, seconds: 0 }
*/

export default function intervalToDuration({ start, end }: Interval): Duration {
export default function intervalToDuration(interval: Interval): Duration {
requiredArgs(1, arguments)

const dateLeft = toDate(start)
const dateRight = toDate(end)
const start = toDate(interval.start)
const end = toDate(interval.end)

if (!isValid(dateLeft)) {
throw new RangeError('Start Date is invalid')
}
if (!isValid(dateRight)) {
throw new RangeError('End Date is invalid')
if (isNaN(start.getTime())) throw new RangeError('Start Date is invalid')
if (isNaN(end.getTime())) throw new RangeError('End Date is invalid')
if (start > end) {
throw new RangeError('The start of an interval cannot be after its end')
}

const duration = {
years: 0,
months: 0,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
const duration: Duration = {
years: differenceInYears(end, start),
}

const sign = compareAsc(dateLeft, dateRight)

duration.years = Math.abs(differenceInYears(dateLeft, dateRight))

const remainingMonths = sub(dateLeft, { years: sign * duration.years })
duration.months = Math.abs(differenceInMonths(remainingMonths, dateRight))
const remainingMonths = add(start, { years: duration.years })
duration.months = differenceInMonths(end, remainingMonths)

const remainingDays = sub(remainingMonths, { months: sign * duration.months })
duration.days = Math.abs(differenceInDays(remainingDays, dateRight))
const remainingDays = add(remainingMonths, { months: duration.months })
duration.days = differenceInDays(end, remainingDays)

const remainingHours = sub(remainingDays, { days: sign * duration.days })
duration.hours = Math.abs(differenceInHours(remainingHours, dateRight))
const remainingHours = add(remainingDays, { days: duration.days })
duration.hours = differenceInHours(end, remainingHours)

const remainingMinutes = sub(remainingHours, { hours: sign * duration.hours })
duration.minutes = Math.abs(differenceInMinutes(remainingMinutes, dateRight))
const remainingMinutes = add(remainingHours, { hours: duration.hours })
duration.minutes = differenceInMinutes(end, remainingMinutes)

const remainingSeconds = sub(remainingMinutes, {
minutes: sign * duration.minutes,
})
duration.seconds = Math.abs(differenceInSeconds(remainingSeconds, dateRight))
const remainingSeconds = add(remainingMinutes, { minutes: duration.minutes })
duration.seconds = differenceInSeconds(end, remainingSeconds)

return duration
}
201 changes: 184 additions & 17 deletions src/intervalToDuration/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import assert from 'assert'
import intervalToDuration from '.'
import addMonths from '../addMonths/index'

describe('intervalToDuration', () => {
it('returns correct duration for arbitrary dates', () => {
Expand Down Expand Up @@ -49,24 +50,31 @@ describe('intervalToDuration', () => {
})
})

describe('throws RangeError', () => {
it('throws error if start date is invalid', () => {
const block = () => {
const invalidStart = new Date(NaN)
const end = new Date(2020, 2, 1, 12, 0, 0)
intervalToDuration({ start: invalidStart, end })
}
assert.throws(block, RangeError, 'Start Date is invalid')
})
it("throws a RangeError if interval's start date is greater than its end date", () => {
const interval = {
start: new Date(2020, 3, 1),
end: new Date(2020, 2, 1),
}

it('throws error if end date is invalid', () => {
const block = () => {
const start = new Date(2020, 2, 1, 12, 0, 0)
const invalidEnd = new Date(NaN)
intervalToDuration({ start, end: invalidEnd })
}
assert.throws(block, RangeError, 'End Date is invalid')
})
assert.throws(intervalToDuration.bind(null, interval), RangeError)
})

it("throws a RangeError if interval's start date invalid", () => {
const interval = {
start: new Date(NaN),
end: new Date(2020, 0, 1),
}

assert.throws(intervalToDuration.bind(null, interval), RangeError)
})

it("throws a RangeError if interval's end date invalid", () => {
const interval = {
start: new Date(2020, 0, 1),
end: new Date(NaN),
}

assert.throws(intervalToDuration.bind(null, interval), RangeError)
})

describe('edge cases', () => {
Expand Down Expand Up @@ -132,5 +140,164 @@ describe('intervalToDuration', () => {
}
)
})

it('returns correct duration for end of month start dates - issue 2611', () => {
const start = new Date(2021, 7, 31)
const end = addMonths(start, 1)

assert.deepStrictEqual(end, new Date(2021, 8, 30))

const duration = intervalToDuration({ start, end })
const expectedDuration = {
years: 0,
months: 1,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
}

assert.deepStrictEqual(duration, expectedDuration)
})

it('returns correct duration for Feb 29 on leap year + 1 month - issue 1778', () => {
const duration = intervalToDuration({
start: new Date(2020, 1, 29),
end: new Date(2020, 2, 29),
})
const expectedDuration = {
years: 0,
months: 1,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
}

assert.deepStrictEqual(duration, expectedDuration)
})

it('returns correct duration for Feb 28 to Apr 30 interval - issue 2910', () => {
const duration = intervalToDuration({
start: new Date(2022, 1, 28),
end: new Date(2022, 3, 30),
})
const expectedDuration = {
years: 0,
months: 2,
days: 2,
hours: 0,
minutes: 0,
seconds: 0,
}

assert.deepStrictEqual(duration, expectedDuration)
})

describe('issue 2470', () => {
it('returns correct duration for Feb 28 to Aug 31 interval', () => {
const duration = intervalToDuration({
start: new Date(2021, 1, 28),
end: new Date(2021, 7, 31),
})
const expectedDuration = {
years: 0,
months: 6,
days: 3,
hours: 0,
minutes: 0,
seconds: 0,
}

assert.deepStrictEqual(duration, expectedDuration)
})

it('returns correct duration for Feb 28 to Aug 30 interval', () => {
const duration = intervalToDuration({
start: new Date(2021, 1, 28),
end: new Date(2021, 7, 30),
})
const expectedDuration = {
years: 0,
months: 6,
days: 2,
hours: 0,
minutes: 0,
seconds: 0,
}

assert.deepStrictEqual(duration, expectedDuration)
})

it('returns correct duration for Feb 28 to Aug 29 interval', () => {
const duration = intervalToDuration({
start: new Date(2021, 1, 28),
end: new Date(2021, 7, 29),
})
const expectedDuration = {
years: 0,
months: 6,
days: 1,
hours: 0,
minutes: 0,
seconds: 0,
}

assert.deepStrictEqual(duration, expectedDuration)
})

it('returns correct duration for Feb 28 to Aug 28 interval', () => {
const duration = intervalToDuration({
start: new Date(2021, 1, 28),
end: new Date(2021, 7, 28),
})
const expectedDuration = {
years: 0,
months: 6,
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
}

assert.deepStrictEqual(duration, expectedDuration)
})

it('returns correct duration for Feb 28 to Aug 27 interval', () => {
// Feb 28 to July 28 is 5 months, July 28 to Aug 27 is 30 days

const duration = intervalToDuration({
start: new Date(2021, 1, 28),
end: new Date(2021, 7, 27),
})
const expectedDuration = {
years: 0,
months: 5,
days: 30,
hours: 0,
minutes: 0,
seconds: 0,
}

assert.deepStrictEqual(duration, expectedDuration)
})

it('returns correct duration for Apr 30 to May 31 interval', () => {
const duration = intervalToDuration({
start: new Date(2021, 3, 30),
end: new Date(2021, 4, 31),
})
const expectedDuration = {
years: 0,
months: 1,
days: 1,
hours: 0,
minutes: 0,
seconds: 0,
}

assert.deepStrictEqual(duration, expectedDuration)
})
})
})
})

0 comments on commit 928172c

Please sign in to comment.