Skip to content

Commit bd47809

Browse files
fix: prevent mixed-sign diff durations
1 parent 30838aa commit bd47809

2 files changed

Lines changed: 55 additions & 5 deletions

File tree

src/__tests__/diff.spec.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { afterEach, describe, expect, it, vi } from "vitest"
2-
import { addDay, addSecond, date, diff } from "../index"
2+
import { add, addDay, addSecond, date, diff } from "../index"
33

44
afterEach(() => {
55
vi.useRealTimers()
66
})
77

8+
function expectAllPositive(duration: Record<string, number>) {
9+
expect(Object.values(duration).every((value) => value > 0)).toBe(true)
10+
}
11+
812
describe("diff", () => {
913
it("returns a duration with calendar and time units", () => {
1014
expect(diff("2025-04-01 12:00:50", "2024-01-01 12:00:00")).toEqual({
@@ -63,4 +67,35 @@ describe("diff", () => {
6367
minutes: 245,
6468
})
6569
})
70+
71+
it("does not emit mixed signs when calendar units clamp", () => {
72+
const duration = diff("2025-02-28", "2024-02-29", { abs: true })
73+
74+
expect(duration).toEqual({
75+
months: 11,
76+
weeks: 3,
77+
days: 7,
78+
})
79+
expectAllPositive(duration)
80+
})
81+
82+
it("keeps leap-day calendar boundary durations round-trippable", () => {
83+
const start = "2024-02-29"
84+
const end = "2025-02-28"
85+
86+
expect(add(start, diff(end, start))).toEqual(date(end))
87+
expect(add(end, diff(start, end))).toEqual(date(start))
88+
})
89+
90+
it("uses days instead of negative fields when weeks are skipped", () => {
91+
const duration = diff("2025-02-28", "2024-02-29", { skip: ["weeks"] })
92+
93+
expect(duration).toEqual({
94+
months: 11,
95+
days: 27,
96+
hours: 24,
97+
})
98+
expectAllPositive(duration)
99+
expect(add("2024-02-29", duration)).toEqual(date("2025-02-28"))
100+
})
66101
})

src/diff.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,21 @@ function negateDuration(duration: Duration): Duration {
2525
return negated
2626
}
2727

28+
function calendarDiff(
29+
current: Date,
30+
target: Date,
31+
diffUnit: (dateA: Date, dateB: Date) => number,
32+
addUnit: (date: Date, count: number) => Date
33+
): [number, Date] {
34+
let amount = diffUnit(current, target)
35+
let next = addUnit(current, -amount)
36+
while (amount > 0 && next < target) {
37+
amount--
38+
next = addUnit(current, -amount)
39+
}
40+
return [amount, next]
41+
}
42+
2843
/**
2944
* Options for `diff` function
3045
*/
@@ -86,14 +101,14 @@ export function diff(
86101
const duration: Duration = {}
87102

88103
if (!skip.has("years")) {
89-
const years = diffYears(a, b)
90-
a = addYear(a, -years)
104+
const [years, next] = calendarDiff(a, b, diffYears, addYear)
105+
a = next
91106
if (years) duration.years = years
92107
}
93108

94109
if (!skip.has("months")) {
95-
const months = diffMonths(a, b)
96-
a = addMonth(a, -months)
110+
const [months, next] = calendarDiff(a, b, diffMonths, addMonth)
111+
a = next
97112
if (months) duration.months = months
98113
}
99114

0 commit comments

Comments
 (0)