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.
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;
Does not impact anything on a technical level, but helps finding relevant schedule items easier.
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.
Identifies the specific event that the schedule is attached to and alongside which event it will appear.
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.
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.
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
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.
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.
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.
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
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.
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.
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();
});