Skip to content

Instantly share code, notes, and snippets.

@IgnusG
Last active May 7, 2025 12:22
Show Gist options
  • Save IgnusG/1177e5242c0503abc2a1a4e4f8c88746 to your computer and use it in GitHub Desktop.
Save IgnusG/1177e5242c0503abc2a1a4e4f8c88746 to your computer and use it in GitHub Desktop.
date-fns additional helpers
import { differenceInDays } from 'date-fns';
/** Checks whether two days are bordering/adjacent to each other
*
* @example
*
* ```typescript
* areDaysBordering(new Date('2025-05-01'), new Date('2025-05-02')) === true
* areDaysBordering(new Date('2025-05-01'), new Date('2025-05-10')) === false
* ```
*/
export function areDaysBordering(
day1: number | Date,
day2: number | Date,
): boolean {
return Math.abs(differenceInDays(day1, day2)) === 1;
}
import { isWithinInterval, max, min } from 'date-fns';
import { areDaysBordering } from './areDaysBordering';
/**
* Given a list of intervals, it will combine overlapping and/or adjacent intervals into a single interval.
*
* @example
* ```typescript
* combineIntervals([
* { start: new Date('2025-01-01'), end: new Date('2025-01-03') },
* { start: new Date('2025-01-02'), end: new Date('2025-01-03') },
* { start: new Date('2025-01-04'), end: new Date('2025-01-05') },
* { start: new Date('2025-01-10'), end: new Date('2025-01-20') },
* ]) === [
* { start: new Date('2025-01-01'), end: new Date('2025-01-05') },
* { start: new Date('2025-01-10'), end: new Date('2025-01-20') }
* ]
* ```
*/
export function combineIntervals(intervals: Interval[]) {
const intervalSet = new Set(intervals);
const evaluate = Array.from(intervals);
for (const interval of evaluate) {
// intervals already looked at and combined are removed from the set - therefore we can skip these
// eslint-disable-next-line no-continue
if (!intervalSet.has(interval)) continue;
const overlappingIntervals = Array.from(intervalSet).filter(
otherInterval =>
// either other interval is partially or fully inside interval
isWithinInterval(otherInterval.start, interval) ||
isWithinInterval(otherInterval.end, interval) ||
// or it's bordering the interval
areDaysBordering(otherInterval.start, interval.end) ||
areDaysBordering(otherInterval.end, interval.start),
);
// if an interval only has one overlap (itself) we can skip
// eslint-disable-next-line no-continue
if (overlappingIntervals.length === 1) continue;
const combinedInterval = {
start: min(overlappingIntervals.map(i => i.start)),
end: max(overlappingIntervals.map(i => i.end)),
};
intervalSet.add(combinedInterval);
// check combined interval in next pass to see if it can be combined further
evaluate.push(combinedInterval);
// remove all overlapping intervals - including self (we created a new combined interval)
overlappingIntervals.forEach(i => intervalSet.delete(i));
}
return Array.from(intervalSet);
}
import { isAfter } from 'date-fns';
/** Find the first interval that immediately follows the given date (but is not contained within it) */
export function firstIntervalAfterDate(intervals: Interval[], date: Date) {
return intervals.find(interval => isAfter(interval.start, date));
}
import { isBefore } from 'date-fns';
/** Find the first interval that directly precedes the given date (but is not contained within it) */
export function firstIntervalBeforeDate(intervals: Interval[], date: Date) {
return intervals.reduce((result, interval) => {
return isBefore(interval.end, date) ? interval : result;
}, intervals?.[0]);
}
import { Interval, isAfter, isSameDay, max, min } from 'date-fns';
/**
* Given a list of lists of intervals, find all intersections between intervals from the different lists.
*
* @example
*
* ```typescript
* intersectIntervals([
* [{ start: new Date('2025-05-15'), end: new Date('2025-07-14') }],
* [{ start: new Date('2025-06-01'), end: new Date('2025-07-30') }],
* [{ start: new Date('2025-06-01'), end: new Date('2025-08-15') }],
* ]) === [
* { start: new Date('2025-06-01'), end: new Date('2025-07-14') }
* ]
* ```
*/
export function intersectIntervals(intervals: Interval[][]) {
const indices = Array(intervals.length).fill(0);
const result = [] as Interval[];
// if we reach beyond the end of any interval there cannot be more intersections
function allHaveNext() {
return !indices.some((index, i) => index > intervals[i].length - 1);
}
while (allHaveNext()) {
const currentIntervals = intervals.map(
(interval, i) => interval[indices[i] as number],
);
// an intersection will be the latest start date across all intervals
const intersectionStart = max(currentIntervals.map(({ start }) => start));
// combines with the earliest end date across all intervals
const intersectionEnd = min(currentIntervals.map(({ end }) => end));
// an intersection only occurs if the latest start date still comes before the earliest end date
if (!isAfter(intersectionStart, intersectionEnd)) {
result.push({ start: intersectionStart, end: intersectionEnd });
}
// to advance we find the earliest start date we were inspecting - this ensures we always advance at least one index
const earliestStartDate = min(currentIntervals.map(({ start }) => start));
// and its associated intervals
const earliestIndices = currentIntervals.flatMap(({ start }, index) =>
isSameDay(start, earliestStartDate) ? [index] : [],
);
// and advance each index of the earliest intervals by 1 - the rest still might intersect with other intervals
earliestIndices.forEach(index => {
indices[index] += 1;
});
}
return result;
}
import { addDays, isAfter, isBefore, subDays } from 'date-fns';
/**
* Calculates a set of negated intervals from a set of input intervals
*
* Negated intervals fill the gaps between the absolute start (defaults to beginning of time)
* and absolute end (defaults to end of time) in between the input intervals.
*
* @example
* ```typescript
* negateIntervals([
* { start: new Date('2025-01-01'), end: new Date('2025-01-10') },
* { start: new Date('2025-01-15'), end: new Date('2025-02-14') },
* { start: new Date('2025-02-20'), end: new Date('2025-03-31') },
* ]) === [
* { start: new Date('0001-01-01'), end: new Date('2024-12-31') },
* { start: new Date('2025-01-11'), end: new Date('2025-01-14') },
* { start: new Date('2025-02-15'), end: new Date('2025-02-19') },
* { start: new Date('2025-04-01'), end: new Date('9999-12-31') },
* ];
* ```
*/
export function negateIntervals(
intervals: Interval[],
{
absoluteStart = new Date('0001-01-01'),
absoluteEnd = new Date('9999-12-31'),
}: {
/** The start of the first negated interval (before first input interval) */
absoluteStart?: Date;
/** The end of the last negated interval (after last input interval) */
absoluteEnd?: Date;
} = {},
) {
const endOfFirstNegatedInterval =
intervals.length === 0 ? absoluteEnd : subDays(intervals[0].start, 1);
// check if we can insert a negated interval before the first one
// only if absolute start date is before the end of this new interval - otherwise we can't create it
const initialNegatedInterval = isBefore(
absoluteStart,
endOfFirstNegatedInterval,
)
? { start: absoluteStart, end: endOfFirstNegatedInterval }
: null;
return intervals.reduce(
(result, interval, currentIndex, list) => {
const isAtEnd = currentIndex === list.length - 1;
const nextInterval = list[currentIndex + 1];
// each new interval fits in the gap between the previous interval and the next interval
const nextNegatedInterval = {
start: addDays(interval.end, 1),
end: isAtEnd ? absoluteEnd : subDays(nextInterval.start, 1),
};
// but only if there is a gap (if the previous and next intervals overlap or are adjacent we skip)
return isAfter(nextNegatedInterval.end, nextNegatedInterval.start)
? [...result, nextNegatedInterval]
: result;
},
initialNegatedInterval ? [initialNegatedInterval] : [],
);
}

Comments are disabled for this gist.