Skip to content

Commit

Permalink
Fix DST issue in getOverlappingDaysInIntervals
Browse files Browse the repository at this point in the history
Fixed DST issue in `getOverlappingDaysInIntervals`, resulting in an inconsistent number of days returned for intervals starting and ending in different DST periods.
  • Loading branch information
kossnocorp committed Jan 22, 2024
1 parent a960fc2 commit aa264e3
Show file tree
Hide file tree
Showing 5 changed files with 93 additions and 50 deletions.
2 changes: 2 additions & 0 deletions scripts/test/tz.sh
Expand Up @@ -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"
Expand Down
25 changes: 15 additions & 10 deletions 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
Expand All @@ -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;
}
36 changes: 19 additions & 17 deletions 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";
Expand All @@ -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).
*
Expand Down Expand Up @@ -38,29 +44,25 @@ export function getOverlappingDaysInIntervals<DateType extends Date>(
intervalLeft: Interval<DateType>,
intervalRight: Interval<DateType>,
): 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);
}
64 changes: 41 additions & 23 deletions 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";

Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -75,7 +74,7 @@ describe("getOverlappingDaysInIntervals", () => {
{ start: initialIntervalStart, end: initialIntervalEnd },
{ start: oneDayOverlapIntervalStart, end: oneDayOverlapIntervalEnd },
);
assert(numOverlappingDays === 0);
expect(numOverlappingDays).toBe(0);
});
});

Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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);
});
});

Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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`", () => {
Expand All @@ -187,43 +205,43 @@ 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`", () => {
const numOverlappingDays = 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`", () => {
const numOverlappingDays = 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`", () => {
const numOverlappingDays = 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);
Expand Down
16 changes: 16 additions & 0 deletions 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,
);

0 comments on commit aa264e3

Please sign in to comment.