javascriptarraysperformancedatetimefullcalendar

How can I get an array of all overlapping date ranges across a given set?


I am using fullcalendar.io JS library to visualize the bookings on my rental company. I store the motorbikes as arrays of categories (engine sizes) with a children array containing all the motrobikes belonging to each, as in the example below:

// The resources array contains information about the motorbikes in a rental store.
// The parent array contains the information about the motorbike engine, 
// while the children array contains the information about the unique motorbikes belonging to each engine category.
resources = [{
    id: "scooter50",
    title: "Scooter 50",
    children: [{
            id: "aa123ab",
            title: "AA 123 AB"
        },
        {
            id: "aa456cd",
            title: "AA 456 CD"
        },
        {
            id: "aa789ef",
            title: "AA 789 EF"
        },
        {
            id: "aa101gh",
            title: "AA 101 GH"
        }
    ]
},
{
    id: "scooter125",
    title: "Scooter 125",
    children: [{
            id: "bb123ab",
            title: "BB 123 AB"
        },
        {
            id: "bb456cd",
            title: "BB 456 CD"
        },
        {
            id: "bb789ef",
            title: "BB 789 EF"
        },
        {
            id: "bb101gh",
            title: "BB 101 GH"
        }
    ]
},
{
    id: "scooter250",
    title: "Scooter 250",
    children: [{
            id: "cc123ab",
            title: "CC 123 AB"
        },
        {
            id: "cc456cd",
            title: "CC 456 CD"
        },
        {
            id: "cc789ef",
            title: "CC 789 EF"
        },
        {
            id: "cc101gh",
            title: "CC 101 GH"
        }
    ]
}
];

Then, I have some events (bookings) belonging to each resource:

// The events array contains information about the motorbikes that are rented, including the start and end date of the rental.
events = [{
    start: new Date(2025, 0, 6, 0, 0),
    end: new Date(2025, 0, 9, 23, 59, 59),
    resourceId: "bb101gh",
    title: "Event 0"
},
{
    start: new Date(2025, 0, 11, 0, 0),
    end: new Date(2025, 0, 14, 23, 59, 59),
    resourceId: "bb101gh",
    title: "Event 1"
},
{
    start: new Date(2025, 0, 7, 0, 0),
    end: new Date(2025, 0, 11, 23, 59, 59),
    resourceId: "bb123ab",
    title: "Event 2"
},
{
    start: new Date(2025, 0, 9, 0, 0),
    end: new Date(2025, 0, 14, 23, 59, 59),
    resourceId: "bb456cd",
    title: "Event 3"
},
{
    start: new Date(2025, 0, 7, 0, 0),
    end: new Date(2025, 0, 10, 23, 59, 59),
    resourceId: "bb789ef",
    title: "Event 4"
},
{
    start: new Date(2025, 0, 11, 0, 0),
    end: new Date(2025, 0, 15, 23, 59, 59),
    resourceId: "bb789ef",
    title: "Event 5"
},
{
    start: new Date(2025, 0, 6, 0, 0),
    end: new Date(2025, 0, 15, 23, 59, 59),
    resourceId: "cc101gh",
    title: "Event 6"
},
{
    start: new Date(2025, 0, 8, 0, 0),
    end: new Date(2025, 0, 11, 23, 59, 59),
    resourceId: "cc123ab",
    title: "Event 7"
}
]

The events can be visualized in this screenshot of my fullcalendar.io timeline chart.

Whenever all motorbikes in a category are fully booked for a certain slot, I'd like to have some red background on the resource level (example here.) That can be easily achieved through background events (https://fullcalendar.io/docs/background-events). My problem is that I haven't found any way to store the date ranges where the bookings overlap across "n" resources (where "n" is the number of motorbikes in a engine size category) in a reliable and efficient manner.

So far, I have managed to find a semi-working solution by taking the minimum and maximum dates across all events, iterating over them using 30 minutes time frames and storing a background event for the engine category when all its resources are booked at a certain timeslot. Here is the code:

// Get the minimum and maximum date in the events array
minDate = events.map(event => event.start).reduce((a, b) => a < b ? a : b);
maxDate = events.map(event => event.end).reduce((a, b) => a > b ? a : b);

// Iterate between the dates using 30 minutes intervals.
unavailableDateRanges = [];
for (let i = minDate; i <= maxDate; i.setMinutes(i.getMinutes() + 30)) {
    // Filter the events that are happening at this time and get the resources that are being used.
    let usedResources = events.filter(event => event.start <= i && event.end >= i).map(event => event.resourceId);

    // If all resources from a certain category are used, store a 30 minutes unavailable range for that category.
    let unavailableCategories = resources.filter(resource => resource.children.every(child => usedResources.includes(child.id))).map(resource => resource.id);
    for (category of unavailableCategories) {
        let start = new Date(i);
        let end = new Date(i);
        end.setMinutes(end.getMinutes() + 30);
        unavailableDateRanges.push({
            start: start,
            end: end,
            resourceId: category,
            title: "Unavailable",
            display: "background"
        });
    }
}

This does not reliably achieve the desired result since it only checks 30 minutes intervals while a booking could start at a different minutage. Say that a booking starts at 9:45, this code would not mark the period from 9:45 to 10:00 as unavailable. Since the red background would be visible at differnt levels of detail, that will be an issue.

Since dates represent a continuous variable (UNIX), I guess that it could be achievable to filter the events by the parent engine size category and then check what date ranges have an overlap across "n" number of motorbikes, assuming that bookings for a certain motorbike do not overlap (which I've already made sure of). I tried to code this solution with no results.

I know that there are some libraries to work with date ranges, like date-fns, but it does not look like it can help.

Can anyone more experienced please help me?


Solution

  • Instead of walking through your full time range by hops of 30 mn, you just have to hop from event to event (an event being "the start or end of a booking").

    Thus you'll have a timeline of all your bookings starts and ends sorted together, with a counter that makes +1 when it encounters a start date and -1 for an end date. When the counter reaches n, go for the red flag!

    // Prepare our item id -> category mapping table.
    let id2Cat = {};
    let byCat = {};
    for(let i = resources.length; --i >= 0;)
    {
        let catId = resources[i].id;
        byCat[catId] = [];
        for(let j = byCat[catId].nMax = resources[i].children.length; --j >= 0;)
            id2Cat[resources[i].children[j].id] = catId;
    }
    // Booking starts and ends are placed on the same timeline.
    for(let i = events.length; --i >= 0;)
    {
        let catId = id2Cat[events[i].resourceId];
        byCat[catId].push({ d: events[i].start, inc: 1 });
        byCat[catId].push({ d: events[i].end, inc: -1 });
    }
    // For each category, sort events, compute the number of booked items after each change event, and signal when all items have started a booking with no one having ended.
    unavailableDateRanges = [];
    for(let catId in byCat)
    {
        let lastChange;
        byCat[catId].sort((a, b) => a.d == b.d ? 0 : (a.d < b.d ? -1 : 1));
        // @todo Collapse when multiple items are restituted at 23:59:59 then rebooked at the next second, to avoid introducing 1-second gaps between two red ranges.
        byCat[catId].reduce
        (
            (s, ev) =>
            {
                // If the last state ("still available" or "fully booked") is uncoherent with the newly computed number of booked items, trigger the max' crossing.
                if(((s.n += ev.inc) >= byCat[catId].nMax) ^ s.st)
                {
                    s.st = -1 - s.st;
                    // If going up, just remember we've entered red zone.
                    if(ev.inc > 0) lastChange = ev.d;
                    // If going down, now log the red range.
                    else unavailableDateRanges.push({ start: lastChange, end: ev.d, resourceId: catId, title: "Unavailable", display: "background" });
                }
                return s;
            },
            { n: 0, st: 0 } // Start with 0 booked item, in a "green" state.
        );
        // @todo Handle unbounded bookings (having a start without an end, and letting our end state at 1).
    }