Calendar Technical Implemintation

Last Updated

22/3/2024 15:10

The calendar has been built to function entirely within the constraints of the Webflow platform in order to avoid having to rely on external services as was done previously, while at the same time allowing for far greater flexibility.

Event Schedule Database

Schedules/time slots for all events are centralised in the 'Event Schedules' CMS Collection. Each individual time slot, is an independent entry in the collection.

Each Schedule item, relies on the following fields;

Schedule Event Title

Does not impact anything on a technical level, but helps finding relevant schedule items easier.

Slug

Unique identifier of the Schedule item, can not be a copy of any other event, for simplicity typically is a string of the event name + unique datetime.

For Event

Identifies the specific event that the schedule is attached to and alongside which event it will appear.

Event Title

The name of the event exactly as it will appear within the calendar. Can be anything, but suggested to keep same as the original event name in order to not confuse visitors by referring to single event in different ways.

Audience Category

Identifies which calendars this event will appear in - Adults, Kids or Family. At this time Kids and Family events are combined into a single calendar.

Event DateTime Start

Indicates the exact time when the specific Schedule item starts. This field is used to place the event item into the correct day in the calendar as well as to determine if an event has passed or not

Booking URL

Direct scheduling url of the event. If an event is recurring, all instances of the that Event within the schedule would have the same booking URL. If an event such as a Camp or Workshop where there may be multiple intake batches of attendees, each individual Schedule item would have a unique booking URL.

Duration

How long the event is in minutes. At this time data not being used, but in the future the intent is to adjust the size of the calendar blocks relative to the duration.

Sold Out Toggle

If an event time slot has been fully booked, the 'Sold Out' item can be toggled in order to remove the 'Book' button from the event page and indicate that people can no longer register.

Waitlist Toggle

If a specific time slot for an event is full, ability to toggle the 'Join Waitlist' button which then replaces either the 'Book' button or 'Sold Out' indicator and links the visitor to WhatsApp. Read more on Event Schedule Waitlist

Event Schedule on individual event pages

Calendar Pages

As Webflow does not have native capability to display CMS items within a calendar grid, a custom implementation had to be created.

CMS Collection Item Limitation

CMS Collections are limited to be able to display only 100 items at a time without pagination. This is limiting as a single month can have more than 100 schedule items and 3 months need to be loaded at once.

In order to get around this limitation the schedule is broken up into 8 independently loaded collections, each being limited to a rolling 2 week window.

  • 4weeks ago to 2 weeks ago
  • 2 weeks ago to now
  • now to 2 weeks in future
  • 2 weeks in future to 4 weeks in future
  • etc ...

This allows each time range to be under the 100 item limitation.

After the collection items have been loaded, a custom script is responsible for creating the 3 months of the calendar. This is done in multiple steps.

  1. Determine the current date and time and make sure that everything is relative to the SGT timezone.
  2. Make sure that all schedule items have the datetime in the correct format and the rest of the script can run using this datetime data.
  3. Determine what is the current month, and the 2 months that are following the current month. Based on this determination, change the name of the calendar tabs to be the names of the months.
  4. Based on the determined months, create individual calendar grids accounting for the mid-week month changes (e.g. one month ending on a Wednesday, new one starting on a Thursday)
  5. Create date numbers throughout the grid and grey out the parts of the grid that belong to the previous/upcoming month.
  6. Check if there are any duplicate events which may sometimes be generated by the overlaps in collections (e.g. 2 week to now, now to 2 weeks in future may generate duplicates for current day)
  7. Remove the duplicates
  8. Based on the timedate, place the schedule items into the correct date on the grid
  9. Remove any schedule items which were outside of the start of the current month and are outside of the end of month which is 2 months from current month.
document.addEventListener('DOMContentLoaded', function() {
    const today = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Singapore' }));
    const currentMonth = today.getMonth(); // 0-11 for Jan-Dec
    const currentYear = today.getFullYear();

    function parseSGTDate(localDateString) {
        const [datePart, timePart] = localDateString.split(' ');
        const paddedTimePart = timePart.padStart(5, '0'); // Ensure HH:MM format
        const date = new Date(`${datePart}T${paddedTimePart}:00+08:00`);
        if (isNaN(date)) {
            console.error('Invalid date encountered:', localDateString);
            return null;
        }
        return date;
    }

    function generateCalendarGridsAndUpdateTitles(startMonth, startYear) {
        const monthNames = ["January", "February", "March", "April", "May", "June",
            "July", "August", "September", "October", "November", "December"];
        for (let i = 0; i < 3; i++) {
            const monthOffset = (startMonth + i) % 12;
            const yearOffset = startYear + Math.floor((startMonth + i) / 12);
            const daysInMonth = new Date(yearOffset, monthOffset + 1, 0).getDate();
            const firstDay = new Date(yearOffset, monthOffset, 1).getDay();
            const gridId = ['calMonthCurrent', 'calMonthFuture1', 'calMonthFuture2'][i];
            const tabId = ['tabMonthCurrent', 'tabMonthFuture1', 'tabMonthFuture2'][i];

            document.getElementById(tabId).textContent = `${monthNames[monthOffset]} ${yearOffset}`;

            createCalendarGrid(gridId, yearOffset, monthOffset, daysInMonth, firstDay);
        }
    }

    function createCalendarGrid(gridId, year, month, daysInMonth, firstDay) {
        const grid = document.getElementById(gridId);
        grid.innerHTML = ''; // Clear existing grid contents
        let dayOfWeekCounter = firstDay === 0 ? 6 : firstDay - 1; // Adjust for Monday start
        // Add preceding month's days if necessary
        for (let i = dayOfWeekCounter; i > 0; i--) {
            const date = new Date(year, month, -i + 1);
            grid.appendChild(createDayCell('day-outofrange', date));
        }
        // Add current month's days
        for (let day = 1; day <= daysInMonth; day++) {
            const date = new Date(year, month, day);
            grid.appendChild(createDayCell('day-currentmonth', date, month));
        }
        // Add following month's days to complete the last week
        const remainingDays = (7 - (dayOfWeekCounter + daysInMonth) % 7) % 7;
        for (let i = 1; i <= remainingDays; i++) {
            const date = new Date(year, month + 1, i);
            grid.appendChild(createDayCell('day-outofrange', date));
        }
    }

    function createDayCell(className, date, month) {
        const cell = document.createElement('div');
        cell.className = `calendar-gridday ${className}`;
        const dateAttr = month !== undefined ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : null;
        if (dateAttr) {
            cell.setAttribute('data-date', dateAttr); // Set a data attribute to match events strictly for current month days
        }

        const dateWrap = document.createElement('div');
        dateWrap.className = 'calendar-gridday-datewrap';
        const dow = document.createElement('p');
        dow.className = 'calendar-gridday-dow';
        dow.textContent = date.toLocaleString('en-US', { weekday: 'long', timeZone: 'Asia/Singapore' });
        const day = document.createElement('p');
        day.className = 'calendar-gridday-date';
        day.textContent = date.getDate();
        dateWrap.appendChild(dow);
        dateWrap.appendChild(day);

        const eventWrap = document.createElement('div');
        eventWrap.className = 'calendar-gridday-eventwrap';
        cell.appendChild(dateWrap);
        cell.appendChild(eventWrap);

        return cell;
    }

    function removeDuplicatesAndOutOfScopeEvents() {
        const eventSet = new Set();
        document.querySelectorAll('#calendarcollection .calendarevent-item').forEach(event => {
            const startTimeFull = event.querySelector('.calendarevent-starttimefull').textContent;
            const eventDate = parseSGTDate(startTimeFull);

            if (!eventDate || eventSet.has(startTimeFull) || eventDate.getMonth() < currentMonth || eventDate.getMonth() > currentMonth + 2 || eventDate.getFullYear() != currentYear) {
                event.remove(); // Remove duplicates or out-of-scope events
            } else {
                eventSet.add(startTimeFull);
            }
        });
    }

    function addSlugClassToLinkBlock() {
        document.querySelectorAll('#calendarcollection .calendarevent-item .calendarevent-linkblock').forEach(linkBlock => {
            const url = linkBlock.href;
            const slug = url.split('/event/')[1];
            if (slug) {
                linkBlock.classList.add(slug);
            }
        });
    }

    function organizeAndPlaceEvents() {
        document.querySelectorAll('#calendarcollection .calendarevent-item').forEach(event => {
            const startTimeFull = event.querySelector('.calendarevent-starttimefull').textContent;
            const eventDate = parseSGTDate(startTimeFull);
            if (!eventDate) return; // Skip if date is invalid
            // Ensure event placement matches exactly to the month and year to prevent spillover
            const dateString = `${eventDate.getFullYear()}-${eventDate.getMonth()}-${eventDate.getDate()}`;
            const targetDay = document.querySelector(`.calendar-gridday[data-date="${dateString}"] .calendar-gridday-eventwrap`);
            if (targetDay) {
                targetDay.appendChild(event);
            }
        });
    }

    // Execute functions in the correct order
    generateCalendarGridsAndUpdateTitles(currentMonth, currentYear);
    removeDuplicatesAndOutOfScopeEvents();
    addSlugClassToLinkBlock();
    organizeAndPlaceEvents();
});