SharePoint: JQuery Full Calendar

In one of our recently concluded O365 SharePoint Online projects, we needed to aggregate events present in Calendar lists spanning multiple sub sites of a site collection and show them in a single Calendar view. All of this had to be done using client side programming. We decided to use JQuery Full Calendar plugin for showing the events and did some research about retrieving the events from calendars:
Our first approach was to use SharePoint search to get all calendar events as described by Scot Hillier, in this article: Using the FullCalendar Plugin in SharePoint 2013 Apps. However, one drawback of this approach is that the recurring calendar events are returned by SharePoint search as a single item rather than item series. 
Similarly JavaScript client object model code and REST api don't have the capability to give full information about recurring events. In fact there is already an idea about this gap in Office SP Dev User voice.
So, the only option left was to use either traditional lists.asmx web service or JQuery SPServicesDateRangesOverlap queries can be used with lists.asmx to get information about recurring events. Josh McCarty, describes the SPServices approach in his article SharePoint, jQuery, and FullCalendar—Now with SPServices.
Since SPServices is simply a wrapper around SharePoint web services, we decided to uses list.asmx service directly and not to introduce SPServices JQuery library. Moreover, the post shows how to get events from a single calendar list. Our requirement was to get events from several calendar lists, however, still many parts of code are borrowed from his wonderful post. Before we dive into the code, I would highly recommend you to have a look at my previous post about Recurring Calendar Events. It shows how different options (Year, Now, Month, Week, Today) work with DateRangesOverlap queries.

Download the latest Full Calendar files from here. Extract the files and upload them to a Document library. We can directly embed our script in a Content Editor web part aka CEWP or we can create a text file and reference this file by editing CEWP and setting the content link. 
Include references to full calendar js and css files on top:
<link rel="Stylesheet" type="text/css" href='/Style%20Library/CSS/jquery-ui.min.css' />
<link rel="Stylesheet" type="text/css" href="/Style%20Library/CSS/fullcalendar.min.css" />
<script type="text/javascript" src="/Style%20Library/JS/moment.min.js"></script>
<script type="text/javascript" src="/Style%20Library/JS/jquery-1.9.1.min.js"></script>
<script type="text/javascript" src="/Style%20Library/JS/fullcalendar.min.js"></script>
Create an empty div with ID tag. This is where Calendar will be rendered.
<div id='aggregatedCalendar'>

</div>
Now define the calendar:
jQuery("#aggregatedCalendar").fullCalendar({
    header: {
        left: 'prev,next today',
        center: 'title',
        right: 'month, agendaWeek, agendaDay'
    },                                
    displayEventEnd :true,  // shows end date
    nextDayThreshold: "00:00:00", // When an event's end time spans into another day, the minimum time it must be in order for it to render as if it were on that day
    timeFormat: 'h:mma ',
    theme: true, // Use JQuery UI theme
    fixedWeekCount: false,  // If true, the calendar will always be 6 weeks tall. If false, the calendar will have either 4, 5, or 6 weeks, depending on the month.
    eventLimit: true, // allow "more" link when too many events
    eventSources: eventSourceArray
});
Here fixedWeekCount: false ensures that Calendar weeks will be dynamic depending on the number of weeks. This is required because by default calendar will always be six weeks tall and this causes issues in some cases. The Month option in DateRangesOverlap query shows events of the given month along with events from last seven days of previous month and future seven days of next month. Now consider the month of April 2015, the future dates in it extend till 9th May and hence if there are events on dates 8th and 9th of May 15, they won't show up.
eventSources: Since we are retrieving events from multiple calendar lists, this means we can use eventSources to specify multiple sources. This is how eventSourceArray is populated:
aggregateEvents : function( calendarEntries )
{
    var eventSourceArray = []; 
    for (var entry = 0; entry < calendarEntries.length; entry++) {      
        eventSourceArray.push({         
            events: function( start, end, timezone, callback) {                 
                    entry--;
                    if (entry === -1) {
                        entry = calendarEntries.length -1;
                    }                                               
                    callback( NY.FullCalendar.generateEvents( calendarEntries[entry], start, end ) );                                                                             
                }          
        });
    }
}
Based upon the type of view selected in the calendar, a CAML query is generated and passed to GetListItems method of lists.asmx service:
var selectedView = jQuery( '#aggregatedCalendar' ).fullCalendar( 'getView' ).name;var rangeType = "";

switch( selectedView ) {
    case "agendaWeek":      
        // Note: If localization of dates isn't done, then uncomment the first two code lines and comment third and fourth line             
        // calendarDate = moment(end, 'YYYY-MM-DD').add('days', -1).format('YYYY-MM-DD');   
        // rangeType = "<Week />";  
        calendarDate = jQuery('#aggregatedCalendar').fullCalendar( 'getDate' ).format().slice( 0,10 );        rangeType = "<Month />";
        break;
    case "agendaDay":   
        // In case no localization is done, we can use <Today /> along with calendarDate to get the events of a particular day.
        // var today = new Date();
        // calendarDate = today.toISOString().slice(0,10);
        // rangeType = "<Today />";         
        calendarDate = moment(start, 'YYYY-MM-DD').add('days', -1).format('YYYY-MM-DD');
        rangeType = "<Week />";         
        break;
    default: // Default to month view                       
        calendarDate = jQuery('#aggregatedCalendar').fullCalendar( 'getDate' ).format().slice( 0,10 );
        rangeType = "<Month />";            
}
The Week option shows events of the given week (Sunday to Saturday) and the events of Sunday of next week. The Today option shows the events of the given day. 
If the requirement is not to show events in local time zone based upon user's browser, then the things are fairly simple. Use Week option in agendaWeek, Today option in agendaDay and Month option as default. However, if events are localized, we need to understand few things. 
The Week rangeType has a potential issue in case of agendaWeek View. Consider an event happening on Saturday, 8pm (UTC-05:00) Eastern Time (US and Canada) on first week of a month. Since we are using localized timings based on user's browser, this means the above event should be seen on Sunday, 5:30am in India (UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi on second week of a month. Now if CalendarDate is set to any of the dates of first week and rangeType is set to <Week />, this event will be retrieved, however, localization date manipulation will  push it to Sunday of second week and hence it won't show up in first week. And if CalendarDate is set to any of the dates of second week and rangeType is set to <Week />, this event will be not be retrieved at all.
In either case the event will not show up. Hence, we need to either query the list twice, once using first week date and then using second week date and then merge the events. Or we can use <Month /> rangeType to get all events of that month. 
In case of angendDay view, If we use <Today /> and set the calendarDate to date of first week Saturday, we will see the same behavior as seen in agendaWeek. However, the <Week /> rangeType will not be an issue in case of "agendaDay" if used properly. Notice in the code we are setting the calendarDate to a day less than the start date. So, if we are in Sunday, the calendarDate will be Saturday. So, we get all events of previous week and current day i.e. Sunday. Hence the event will show up.
Finally, the code to read the events:
readEventsFromCalendar : function (webUrl, calendarName, calendarDate, camlQuery, orderByClause, rowLimit)
{
    var listServiceUrl = webUrl + "/_vti_bin/Lists.asmx";       
    var viewFields = NY.FullCalendar.getViewFields();
    var events = [];
    if (calendarName) {
        var xmlCall = "<soap:Envelope xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance' xmlns:xsd='http://www.w3.org/2001/XMLSchema' xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'> <soap:Body>" + 
        "<GetListItems xmlns='http://schemas.microsoft.com/sharepoint/soap/'>" +
        "<listName>" + calendarName + "</listName>" +
        "<rowLimit>" + rowLimit + "</rowLimit>" +
        "<viewFields><ViewFields>" + viewFields + "</ViewFields></viewFields>" +
        "<query><Query><Where>" + camlQuery + "</Where>" + orderByClause + "</Query></query>" +
        "<queryOptions><QueryOptions>" +
            "<ExpandRecurrence>TRUE</ExpandRecurrence>" +
            "<DateInUtc>TRUE</DateInUtc>" +
            "<CalendarDate>"+ calendarDate +"</CalendarDate>" +             
        "</QueryOptions></queryOptions>" +
        "</GetListItems>" +
        "</soap:Body></soap:Envelope>";

        jQuery.ajax({
            url: listServiceUrl,
            type: "POST",
            dataType: "xml",
            async: false,
            data: xmlCall,
            contentType: "text/xml; charset=\"utf-8\"",
            complete: function (xData, status) {                                
                if (status === "success") {
                    var root = jQuery(xData.responseText);
                    root.find("listitems").children().children().each(function () {                                                 
                        var itemID = jQuery( this ).attr( 'ows_ID' ).split( ';#' ).join( '.' );                 
                        var listUrl = jQuery( this ).attr("ows_FileRef").split( '/Lists/' )[1].split( '/' )[0];
                        var start;
                        var end;
                        var allDay = false; 

                        // Don't perform localization conversion in case of allday events, as in some cases the date gets set to previous or future date
                        if ( jQuery( this ).attr("ows_fAllDayEvent") == 1 ) {
                            start = jQuery( this ).attr("ows_EventDate").slice(0,10);
                            end = jQuery( this ).attr("ows_EndDate").slice(0,10);
                            allDay = true;
                        }
                        else {
                            start = NY.FullCalendar.formatDateToLocal( jQuery( this ).attr("ows_EventDate") );
                            end = NY.FullCalendar.formatDateToLocal ( jQuery( this ).attr("ows_EndDate") );
                        }   

                        events.push({
                                "start": start,
                                "end": end,
                                "title": jQuery( this ).attr("ows_Title"),
                                "location": jQuery( this ).attr("ows_Location"),
                                "allDay": allDay,                                   
                                "url" : webUrl + "/Lists/" + listUrl + "/DispForm.aspx?ID=" + itemID + '&Source=' + window.location,
                                "isRecurrence" : jQuery( this ).attr("ows_fRecurrence") == 1 ? true : false
                        });                     
                    });                     
                }                                        
            }               
        });
    }

    return events;  
}
Note that DateInUtc is set to true. This returns all dates in UTC format and then there is a helper method which converts dates to local timezone. If no localization is needed, this option can be removed. 
Those who have been working with CAML queries will be thinking about list view threshold. I have introduced a limiting query into the solution. It makes sure that the first field which is used in a CAML query is an indexed field. Luckily, the OOB EndDate field is an indexed field and this is what we need to set the limiting query:
getLimitingQuery : function ( eventStartLimitingDate )
{
    return "<Geq><FieldRef Name=\"EndDate\"/><Value Type=\"DateTime\">" + eventStartLimitingDate + "</Value></Geq>";
}
eventStartLimitingDate is defined like this:
var eventStartLimitingDate = moment(start, 'YYYY-MM-DD').add('days', -1).format('YYYY-MM-DD'); 
This is the first day shown on the calendar. This can either be the first day of the month or among the last few days back in the previous month. In case of week, this is the first day of the week. Note that we are subtracting a day from it. This is needed for localization. Recall the previous example of an event which is actually on some day but appears next day on users calendar because of timezone difference.
Moreover, having this as the first condition in the CAML query will at least ensure that we have a good chance of avoiding the list view threshold issue. But as we start moving into previous months by using calendar previous button arrow, the risk increases.
Finally, the full code:
<link rel="Stylesheet" type="text/css" href='/Style%20Library/CSS/jquery-ui.min.css' />
<link rel="Stylesheet" type="text/css" href="/Style%20Library/CSS/fullcalendar.min.css" />
<script type="text/javascript" src="/Style%20Library/JS/moment.min.js"></script>
<script type="text/javascript" src="/Style%20Library/JS/jquery-1.9.1.min.js"></script>
<script type="text/javascript" src="/Style%20Library/JS/fullcalendar.min.js"></script>
<div id='aggregatedCalendar'>

</div>

<script type="text/javascript">
var NY = window.NY || {};
NY.FullCalendar = NY.FullCalendar ||
{       
    initialize: function( )
    {
        // Read list of calendars from the query string.
        // Pass server relative url of the lists. But instead of actual list Url, the display name of the list should be passed
        // So, if there is a list with tile "Company Calendar" under a sub site named "xyz", then server relative url should be "/xyz/lists/Company Calendar"
        // Note that in above case the actual url of the list may be "CompanyCalendar". Observe, the absence of space in the url.
        // use comma as a separator in urls
        // Sample : ?cals=/hr/lists/hr calendar,/news/lists/all calendar

        var urls = GetUrlKeyValue("cals");
        var calendarEntries = urls.split(',');          

        NY.FullCalendar.aggregateEvents( calendarEntries );         
    },

    aggregateEvents : function( calendarEntries )
    {
        var eventSourceArray = []; 
        for (var entry = 0; entry < calendarEntries.length; entry++) {      
            eventSourceArray.push({         
                events: function( start, end, timezone, callback) {                 
                        entry--;
                        if (entry === -1) {
                            entry = calendarEntries.length -1;
                        }                                               
                        callback( NY.FullCalendar.generateEvents( calendarEntries[entry], start, end ) );                                                                             
                    }          
            });
        }   

        jQuery("#aggregatedCalendar").fullCalendar({
            header: {
                left: 'prev,next today',
                center: 'title',
                right: 'month, agendaWeek, agendaDay'
            },                                
            displayEventEnd :true,  // shows end date
            nextDayThreshold: "00:00:00", // When an event's end time spans into another day, the minimum time it must be in order for it to render as if it were on that day
            timeFormat: 'h:mma ',
            theme: true, // Use JQuery UI theme
            fixedWeekCount: false,  // If true, the calendar will always be 6 weeks tall. If false, the calendar will have either 4, 5, or 6 weeks, depending on the month.
            eventLimit: true, // allow "more" link when too many events
            eventSources: eventSourceArray
        });
    },

    generateEvents : function( calendarEntry, start, end)
    {
        var calendarDate;

        // start: This is the first day shown on the calendar. This can either be the first day of the month or among the last few days back in the previous month.
        // start: In case of week, this is the first day of the week.
        // However, we are subtracting a day from start date in case the localization is done.
        // See more about it in switch case comments section below.
        var eventStartLimitingDate = moment(start, 'YYYY-MM-DD').add('days', -1).format('YYYY-MM-DD');      

        // Get the current view of the calendar (agendaWeek, agendaDay, month, etc.). Then set the rangeType to the appropriate value to pass to the web service. 
        // http://www.sharepointnadeem.com/2015/05/sharepoint-recurring-calendar-events.html
        var selectedView = jQuery( '#aggregatedCalendar' ).fullCalendar( 'getView' ).name;
        var rangeType = "";

        switch( selectedView ) {
            case "agendaWeek":
                // The <Week /> rangeType gives the events belonging to a certain week and the events of coming Sunday. The events are from Sunday to Saturday of the given week plus events 
                // occurring in Sunday of coming week. 
                // The Week rangeType has a potential issue in case of agendaWeek View. Consider an event happening on Saturday, 8pm (UTC-05:00) Eastern Time (US and Canada) on first week of a month.
                // Since we are using localized timings based on user's browser, this means the above event should be seen at Sunday, 5:30am in India (UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi on second week of a month.
                // Now if CalendarDate is set to any of the dates of first week and rangeType is set to <Week />, this event will be retrieved, however; localization date manipulation will 
                // push it to Sunday of second week and hence it won't show up in first week. And if CalendarDate is set to any of the dates of second week and rangeType is set to <Week />, this event will be not be retrieved at all.
                // In either case the event will not show up. Hence, we need to either query the list twice, once using first week date and then using second week date and then merge the events.
                // Or we can use <Month /> rangeType to get all events of that month.
                // Note: If localization of dates isn't done, then uncomment the first two code lines and comment third and fourth line             
                // calendarDate = moment(end, 'YYYY-MM-DD').add('days', -1).format('YYYY-MM-DD');   
                // rangeType = "<Week />";  
                calendarDate = jQuery('#aggregatedCalendar').fullCalendar( 'getDate' ).format().slice( 0,10 );
                rangeType = "<Month />";
                break;
            case "agendaDay":   
                // In case no localization is done, we can use <Today /> along with calendarDate to get the events of a particular day.
                // var today = new Date();
                // calendarDate = today.toISOString().slice(0,10);
                // rangeType = "<Today />"; 
                // Again consider the above example of "agendaWeek". If we use <Today /> and set the calendarDate to date of first week Saturday, we will see the same behavior.
                // However, the <Week /> rangeType will not be an issue in case of "agendaDay" if used properly. Notice here we are setting the calendarDate to a day less than the start date.
                // So, if we are in Sunday, the calendarDate will be Saturday. So, we get all events of previous week and current day i.e. Sunday. Hence the event will show up.
                calendarDate = moment(start, 'YYYY-MM-DD').add('days', -1).format('YYYY-MM-DD');
                rangeType = "<Week />";         
                break;
            default: // Default to month view               
                // This is either current day of current month or fist day of the month when a month view is selected using previous and forward arrows in fullcalendar
                calendarDate = jQuery('#aggregatedCalendar').fullCalendar( 'getDate' ).format().slice( 0,10 );
                rangeType = "<Month />";    // The month view shows recurrence events of current month plus 7 days of recurrence data from past and future month. 
                // The fixedWeekCount needs to be set to false in full calendar so that it doesn't show 6 weeks by default. Consider the month of April 15, the future dates
                // in it extend till 9th May and hence if there are events on dates 8th and 9th of May 15, they won't show up.          
        }                           

        var webUrl = calendarEntry.split('/lists/')[0]; 
        var listName = calendarEntry.split('/lists/')[1];

        // Ensure that the starting conditions are based on indexed column to avoid threshold limit. Luckily the EndDate calendar field is an indexed column.
        var limitingQuery = NY.FullCalendar.getLimitingQuery( eventStartLimitingDate );
        var camlQuery = "<And>" + limitingQuery + NY.FullCalendar.getDateRangeOverlapQuery(rangeType) + "</And>";
        var orderByClause = "<OrderBy><FieldRef Name='EventDate' Ascending='True' /></OrderBy>";        
        var events = NY.FullCalendar.readEventsFromCalendar(_spPageContextInfo.siteAbsoluteUrl + webUrl, listName, calendarDate, camlQuery, orderByClause);
        for (var eventCount = 0; eventCount < events.length; eventCount++) {
            var event = events[eventCount];
            if ( event.allDay && !event.isRecurrence) {

                // All day non recurring events spanning multiple days show end date one less than the actual end date in full calendar
                // nextDayThreshold property only affects timed events
                // http://fullcalendar.io/docs/event_rendering/nextDayThreshold/
                event.end = moment(event.end, 'YYYY-MM-DD').add('days', 1).format('YYYY-MM-DD'); 
            }
        }
        return events;
    },

    getDateRangeOverlapQuery : function( rangeType )
    {
        return  "<DateRangesOverlap><FieldRef Name=\"EventDate\" /><FieldRef Name=\"EndDate\" /><FieldRef Name=\"RecurrenceID\" /><Value Type=\"DateTime\">" + rangeType + "</Value></DateRangesOverlap>";
    },

    getViewFields : function()
    {
        return "<FieldRef Name=\"Title\" /><FieldRef Name=\"EventDate\" /><FieldRef Name=\"EndDate\" /><FieldRef Name=\"fAllDayEvent\" /><FieldRef Name=\"ID\" /><FieldRef Name=\"FileRef\" /><FieldRef Name=\"RecurrenceID\" /><FieldRef Name=\"Location\" /><FieldRef Name=\"fRecurrence\" />";       
    },

    getLimitingQuery : function ( eventStartLimitingDate )
    {
        return "<Geq><FieldRef Name=\"EndDate\"/><Value Type=\"DateTime\">" + eventStartLimitingDate + "</Value></Geq>";
    },

    formatDateToLocal : function ( date ) 
    {
        var dateUTC;
        if ( typeof date === "string" ) {

            // Convert UTC string to date object
            var d = new Date();
            var year = date.split('-')[0];
            var month = date.split('-')[1] - 1;
            var day;
            var hour;
            var minute;
            var second;
            day = date.split('-')[2].split('T')[0];
            hour = date.split('T')[1].split(':')[0];
            minute = date.split('T')[1].split(':')[1].split(':')[0];
            second = date.split('T')[1].split(':')[2].split('Z')[0];
            dateUTC = new Date( Date.UTC( year, month, day, hour, minute, second ) );
        }
        else if ( typeof date === "object" ) {          
            dateUTC = date;
        }

        // Create local date strings from UTC date object
        var year = "" + dateUTC.getFullYear();
        var month = "" + ( dateUTC.getMonth() + 1 ); // Add 1 to month because months are zero-indexed.
        var day = "" + dateUTC.getDate();
        var hour = "" + dateUTC.getHours();
        var minute = "" + dateUTC.getMinutes();
        var second = "" + dateUTC.getSeconds();

        // Add leading zeros to single-digit months, days, hours, minutes, and seconds
        if ( month.length < 2 ) {
            month = "0" + month;
        }
        if ( day.length < 2 ) {
            day = "0" + day;
        }
        if ( hour.length < 2 ) {
            hour = "0" + hour;
        }
        if ( minute.length < 2 ) {
            minute = "0" + minute;
        }
        if ( second.length < 2 ) {
            second = "0" + second;
        }

        //return date in format YYYY-MM-DD hh:mm:ss     
        var localDateString = year + "-" + month + "-" + day + " " + hour + ":" + minute + ":" + second;
        return localDateString;
    },

    readEventsFromCalendar : function (webUrl, calendarName, calendarDate, camlQuery, orderByClause)
    {
        var listServiceUrl = webUrl + "/_vti_bin/Lists.asmx";       
        var viewFields = NY.FullCalendar.getViewFields();
        var events = [];
        if (calendarName) {
            var xmlCall = "<soap:Envelope xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance' xmlns:xsd='http://www.w3.org/2001/XMLSchema' xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/'> <soap:Body>" + 
            "<GetListItems xmlns='http://schemas.microsoft.com/sharepoint/soap/'>" +
            "<listName>" + calendarName + "</listName>" +           
            "<viewFields><ViewFields>" + viewFields + "</ViewFields></viewFields>" +
            "<query><Query><Where>" + camlQuery + "</Where>" + orderByClause + "</Query></query>" +
            "<queryOptions><QueryOptions>" +
                "<ExpandRecurrence>TRUE</ExpandRecurrence>" +
                "<DateInUtc>TRUE</DateInUtc>" +
                "<CalendarDate>"+ calendarDate +"</CalendarDate>" +             
            "</QueryOptions></queryOptions>" +
            "</GetListItems>" +
            "</soap:Body></soap:Envelope>";

            jQuery.ajax({
                url: listServiceUrl,
                type: "POST",
                dataType: "xml",
                async: false,
                data: xmlCall,
                contentType: "text/xml; charset=\"utf-8\"",
                complete: function (xData, status) {                                
                    if (status === "success") {
                        var root = jQuery(xData.responseText);
                        root.find("listitems").children().children().each(function () {                                                 
                            var itemID = jQuery( this ).attr( 'ows_ID' ).split( ';#' ).join( '.' );                 
                            var listUrl = jQuery( this ).attr("ows_FileRef").split( '/Lists/' )[1].split( '/' )[0];
                            var start;
                            var end;
                            var allDay = false; 

                            // Don't perform localization conversion in case of allday events, as in some cases the date gets set to previous or future date
                            if ( jQuery( this ).attr("ows_fAllDayEvent") == 1 ) {
                                start = jQuery( this ).attr("ows_EventDate").slice(0,10);
                                end = jQuery( this ).attr("ows_EndDate").slice(0,10);
                                allDay = true;
                            }
                            else {
                                start = NY.FullCalendar.formatDateToLocal( jQuery( this ).attr("ows_EventDate") );
                                end = NY.FullCalendar.formatDateToLocal ( jQuery( this ).attr("ows_EndDate") );
                            }   

                            events.push({
                                    "start": start,
                                    "end": end,
                                    "title": jQuery( this ).attr("ows_Title"),
                                    "location": jQuery( this ).attr("ows_Location"),
                                    "allDay": allDay,                                   
                                    "url" : webUrl + "/Lists/" + listUrl + "/DispForm.aspx?ID=" + itemID + '&Source=' + window.location,
                                    "isRecurrence" : jQuery( this ).attr("ows_fRecurrence") == 1 ? true : false
                            });                     
                        });                     
                    }                                        
                }               
            });
        }

        return events;  
    }
};

SP.SOD.executeFunc('sp.js', 'SP.ClientContext', NY.FullCalendar.initialize);
</script>
The code reads the query string and extracts the list names from it. Note that GetListItems method in lists.asmx expects either the Title of the list or GUID of the list. You can change the code if you plan to use GUIDs of the lists. 

Code in action:

I have two calendar lists one at root of the site collection and is named "HR Calendar" and another one named "Sales Calendar" in a sub site named "Sales". Suppose, I create a web part page named "FullCalendar.aspx" in Pages library and drop content editor web part on the page and reference the above script in it. Now I can refer this page like this:
http://aissp2013/sites/Discovery/Pages/FullCalendar.aspx?cals=/lists/HR Calendar,/sales/lists/Sales Calendar
Sample Events in HR Calendar:

Recurrence and all day event spanning two days

Sample events in Sales Calendar

Event on Saturday 8pm Eastern Time (US and Canada)

Aggregated events in Full Calendar:

Full Calendar showing events in local timings(India UTC+05:30)

profile for Nadeem Yousuf at SharePoint Stack Exchange, Q&A for SharePoint enthusiasts

+