Last active
May 7, 2025 12:22
-
-
Save IgnusG/1177e5242c0503abc2a1a4e4f8c88746 to your computer and use it in GitHub Desktop.
date-fns additional helpers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.