From aa264e394b66af6c2c4d8d4e2774d0b5bb8f9285 Mon Sep 17 00:00:00 2001 From: Sasha Koss Date: Mon, 22 Jan 2024 14:19:55 +0800 Subject: [PATCH] Fix DST issue in getOverlappingDaysInIntervals Fixed DST issue in `getOverlappingDaysInIntervals`, resulting in an inconsistent number of days returned for intervals starting and ending in different DST periods. --- scripts/test/tz.sh | 2 + .../getTimezoneOffsetInMilliseconds/index.ts | 25 +++++--- src/getOverlappingDaysInIntervals/index.ts | 36 ++++++----- src/getOverlappingDaysInIntervals/test.ts | 64 ++++++++++++------- .../getOverlappingDaysInIntervals/basic.ts | 16 +++++ 5 files changed, 93 insertions(+), 50 deletions(-) create mode 100644 test/dst/getOverlappingDaysInIntervals/basic.ts diff --git a/scripts/test/tz.sh b/scripts/test/tz.sh index 52f2218503..6ce9f8405c 100755 --- a/scripts/test/tz.sh +++ b/scripts/test/tz.sh @@ -16,6 +16,8 @@ env TZ=Asia/Damascus npx tsx ./test/dst/eachDayOfInterval/basic.ts env TZ=America/Santiago npx tsx ./test/dst/addBusinessDays/basic.ts env TZ=Australia/Melbourne npx tsx ./test/dst/formatDistanceStrict/melbourne.ts env TZ=Africa/Cairo npx tsx ./test/dst/formatDistanceStrict/cairo.ts +env TZ=Asia/Singapore npx tsx ./test/dst/getOverlappingDaysInIntervals/basic.ts +env TZ=Asia/Chita npx tsx ./test/dst/getOverlappingDaysInIntervals/basic.ts echo "✅ DST tests passed" echo "Running formatISO tests" diff --git a/src/_lib/getTimezoneOffsetInMilliseconds/index.ts b/src/_lib/getTimezoneOffsetInMilliseconds/index.ts index a8ff692cb2..99fd8604de 100644 --- a/src/_lib/getTimezoneOffsetInMilliseconds/index.ts +++ b/src/_lib/getTimezoneOffsetInMilliseconds/index.ts @@ -1,3 +1,5 @@ +import { toDate } from "../../toDate/index.js"; + /** * Google Chrome as of 67.0.3396.87 introduced timezones with offset that includes seconds. * They usually appear for dates that denote time before the timezones were introduced @@ -9,18 +11,21 @@ * * This function returns the timezone offset in milliseconds that takes seconds in account. */ -export function getTimezoneOffsetInMilliseconds(date: Date): number { +export function getTimezoneOffsetInMilliseconds( + date: Date | number | string, +): number { + const _date = toDate(date); const utcDate = new Date( Date.UTC( - date.getFullYear(), - date.getMonth(), - date.getDate(), - date.getHours(), - date.getMinutes(), - date.getSeconds(), - date.getMilliseconds(), + _date.getFullYear(), + _date.getMonth(), + _date.getDate(), + _date.getHours(), + _date.getMinutes(), + _date.getSeconds(), + _date.getMilliseconds(), ), ); - utcDate.setUTCFullYear(date.getFullYear()); - return date.getTime() - utcDate.getTime(); + utcDate.setUTCFullYear(_date.getFullYear()); + return +date - +utcDate; } diff --git a/src/getOverlappingDaysInIntervals/index.ts b/src/getOverlappingDaysInIntervals/index.ts index 22e3ceccc6..b0fef9c084 100644 --- a/src/getOverlappingDaysInIntervals/index.ts +++ b/src/getOverlappingDaysInIntervals/index.ts @@ -1,3 +1,4 @@ +import { getTimezoneOffsetInMilliseconds } from "../_lib/getTimezoneOffsetInMilliseconds/index.js"; import { millisecondsInDay } from "../constants/index.js"; import { toDate } from "../toDate/index.js"; import type { Interval } from "../types.js"; @@ -8,7 +9,12 @@ import type { Interval } from "../types.js"; * @summary Get the number of days that overlap in two time intervals * * @description - * Get the number of days that overlap in two time intervals + * Get the number of days that overlap in two time intervals. It uses the time + * between dates to calculate the number of days, rounding it up to include + * partial days. + * + * Two equal 0-length intervals will result in 0. Two equal 1ms intervals will + * result in 1. * * @typeParam DateType - The `Date` type, the function operates on. Gets inferred from passed arguments. Allows to use extensions like [`UTCDate`](https://github.com/date-fns/utc). * @@ -38,29 +44,25 @@ export function getOverlappingDaysInIntervals( intervalLeft: Interval, intervalRight: Interval, ): number { - const [leftStartTime, leftEndTime] = [ + const [leftStart, leftEnd] = [ +toDate(intervalLeft.start), +toDate(intervalLeft.end), ].sort((a, b) => a - b); - const [rightStartTime, rightEndTime] = [ + const [rightStart, rightEnd] = [ +toDate(intervalRight.start), +toDate(intervalRight.end), ].sort((a, b) => a - b); - const isOverlapping = - leftStartTime < rightEndTime && rightStartTime < leftEndTime; - - if (!isOverlapping) { - return 0; - } - - const overlapStartDate = - rightStartTime < leftStartTime ? leftStartTime : rightStartTime; - - const overlapEndDate = - rightEndTime > leftEndTime ? leftEndTime : rightEndTime; + // Prevent NaN result if intervals don't overlap at all. + const isOverlapping = leftStart < rightEnd && rightStart < leftEnd; + if (!isOverlapping) return 0; - const differenceInMs = overlapEndDate - overlapStartDate; + // Remove the timezone offset to negate the DST effect on calculations. + const overlapLeft = rightStart < leftStart ? leftStart : rightStart; + const left = overlapLeft - getTimezoneOffsetInMilliseconds(overlapLeft); + const overlapRight = rightEnd > leftEnd ? leftEnd : rightEnd; + const right = overlapRight - getTimezoneOffsetInMilliseconds(overlapRight); - return Math.ceil(differenceInMs / millisecondsInDay); + // Ceil the number to include partial days too. + return Math.ceil((right - left) / millisecondsInDay); } diff --git a/src/getOverlappingDaysInIntervals/test.ts b/src/getOverlappingDaysInIntervals/test.ts index 1dad0b91ba..532e4ddfd0 100644 --- a/src/getOverlappingDaysInIntervals/test.ts +++ b/src/getOverlappingDaysInIntervals/test.ts @@ -1,6 +1,5 @@ /* eslint-env mocha */ -import assert from "assert"; import { describe, expect, it } from "vitest"; import { getOverlappingDaysInIntervals } from "./index.js"; @@ -17,7 +16,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: earlierIntervalStart, end: earlierIntervalEnd }, ); - assert(numOverlappingDays === 0); + expect(numOverlappingDays).toBe(0); }); it("returns 0 for a valid non overlapping interval after another interval", () => { @@ -28,7 +27,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: laterIntervalStart, end: laterIntervalEnd }, ); - assert(numOverlappingDays === 0); + expect(numOverlappingDays).toBe(0); }); it("returns 0 for a non overlapping same-day interval", () => { @@ -39,7 +38,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: sameDayIntervalStart, end: sameDayIntervalEnd }, ); - assert(numOverlappingDays === 0); + expect(numOverlappingDays).toBe(0); }); it("returns 0 for an interval differing by a few hours", () => { @@ -53,7 +52,7 @@ describe("getOverlappingDaysInIntervals", () => { end: oneDayOverlappingIntervalEnd, }, ); - assert(numOverlappingDays === 0); + expect(numOverlappingDays).toBe(0); }); it("returns 0 for an interval with the same startDateTime as the initial time intervals's endDateTime", () => { @@ -64,7 +63,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: oneDayOverlapIntervalStart, end: oneDayOverlapIntervalEnd }, ); - assert(numOverlappingDays === 0); + expect(numOverlappingDays).toBe(0); }); it("returns 0 for an interval with the same endDateTime as the initial time interval's startDateTime", () => { @@ -75,7 +74,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: oneDayOverlapIntervalStart, end: oneDayOverlapIntervalEnd }, ); - assert(numOverlappingDays === 0); + expect(numOverlappingDays).toBe(0); }); }); @@ -88,7 +87,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: includedIntervalStart, end: includedIntervalEnd }, ); - assert(numOverlappingDays === 2); + expect(numOverlappingDays).toBe(2); }); it("returns the correct value for an interval included within another interval", () => { @@ -99,7 +98,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: includedIntervalStart, end: includedIntervalEnd }, ); - assert(numOverlappingDays === 1); + expect(numOverlappingDays).toBe(1); }); it("returns the correct value for an interval overlapping at the end", () => { @@ -110,7 +109,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: endOverlappingIntervalStart, end: endOverlappingIntervalEnd }, ); - assert(numOverlappingDays === 4); + expect(numOverlappingDays).toBe(4); }); it("returns the correct value for an interval overlapping at the beginning", () => { @@ -124,7 +123,7 @@ describe("getOverlappingDaysInIntervals", () => { end: startOverlappingIntervalEnd, }, ); - assert(numOverlappingDays === 14); + expect(numOverlappingDays).toBe(14); }); it("returns the correct value for an interval including another interval", () => { @@ -135,7 +134,26 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: includingIntervalStart, end: includingIntervalEnd }, ); - assert(numOverlappingDays === 24); + expect(numOverlappingDays).toBe(24); + }); + + it("considers equal 0-lenght intervals not overlapping", () => { + const date = new Date(2016, 10, 15); + const numOverlappingDays = getOverlappingDaysInIntervals( + { start: date, end: date }, + { start: date, end: date }, + ); + expect(numOverlappingDays).toBe(0); + }); + + it("considers equal 1ms-length intervals overlapping", () => { + const start = new Date(2016, 10, 15); + const end = new Date(2016, 10, 15, 0, 0, 0, 1); + const numOverlappingDays = getOverlappingDaysInIntervals( + { start, end }, + { start, end }, + ); + expect(numOverlappingDays).toBe(1); }); }); @@ -150,7 +168,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: endOverlappingIntervalStart, end: endOverlappingIntervalEnd }, ); - assert(numOverlappingDays === 4); + expect(numOverlappingDays).toBe(4); }); it("normalizes the left interval if its start date is after the end date", () => { @@ -164,7 +182,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalEnd, end: initialIntervalStart }, { start: endOverlappingIntervalStart, end: endOverlappingIntervalEnd }, ); - assert(numOverlappingDays === 4); + expect(numOverlappingDays).toBe(4); }); it("normalizes the right interval if its start date is after the end date", () => { @@ -178,7 +196,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: initialIntervalStart, end: initialIntervalEnd }, { start: endOverlappingIntervalEnd, end: endOverlappingIntervalStart }, ); - assert(numOverlappingDays === 4); + expect(numOverlappingDays).toBe(4); }); describe("one of the dates is `Invalid Date`", () => { @@ -187,7 +205,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: new Date(NaN), end: new Date(2016, 10, 3) }, { start: new Date(2016, 10, 5), end: new Date(2016, 10, 15) }, ); - assert(numOverlappingDays === 0); + expect(numOverlappingDays).toBe(0); }); it("throws an exception if the end date of the initial time interval is `Invalid Date`", () => { @@ -195,7 +213,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: new Date(2016, 10, 3), end: new Date(NaN) }, { start: new Date(2016, 10, 5), end: new Date(2016, 10, 15) }, ); - assert(numOverlappingDays === 0); + expect(numOverlappingDays).toBe(0); }); it("returns 0 if the start date of the compared time interval is `Invalid Date`", () => { @@ -203,7 +221,7 @@ describe("getOverlappingDaysInIntervals", () => { { start: new Date(2016, 10, 3), end: new Date(2016, 10, 7) }, { start: new Date(NaN), end: new Date(2016, 10, 5) }, ); - assert(numOverlappingDays === 0); + expect(numOverlappingDays).toBe(0); }); it("returns 0 if the end date of the compared time interval is `Invalid Date`", () => { @@ -211,19 +229,19 @@ describe("getOverlappingDaysInIntervals", () => { { start: new Date(2016, 10, 3), end: new Date(2016, 10, 7) }, { start: new Date(2016, 10, 5), end: new Date(NaN) }, ); - assert(numOverlappingDays === 0); + expect(numOverlappingDays).toBe(0); }); }); it("properly sorts the dates", () => { const result = getOverlappingDaysInIntervals( { - start: new Date(2001, 8 /* Sep */, 1), - end: new Date(2023, 11 /* Dec */, 20), + start: new Date(2001, 8 /* Sep */, 1, 16), + end: new Date(2023, 11 /* Dec */, 20, 16), }, { - start: new Date(2023, 11 /* Dec */, 21), - end: new Date(2001, 8 /* Sep */, 9), + start: new Date(2023, 11 /* Dec */, 21, 16), + end: new Date(2001, 8 /* Sep */, 9, 16), }, ); expect(result).toBe(8137); diff --git a/test/dst/getOverlappingDaysInIntervals/basic.ts b/test/dst/getOverlappingDaysInIntervals/basic.ts new file mode 100644 index 0000000000..2e62e17b08 --- /dev/null +++ b/test/dst/getOverlappingDaysInIntervals/basic.ts @@ -0,0 +1,16 @@ +import assert from "assert"; +import { getOverlappingDaysInIntervals } from "../../../src/getOverlappingDaysInIntervals/index.js"; + +assert.strictEqual( + getOverlappingDaysInIntervals( + { + start: new Date(2001, 8 /* Sep */, 1, 16), + end: new Date(2023, 11 /* Dec */, 20, 16), + }, + { + start: new Date(2023, 11 /* Dec */, 21, 16), + end: new Date(2001, 8 /* Sep */, 9, 16), + }, + ), + 8137, +);