Walks LLC, a provider of walking tours in Europe, are relaunching their web site. I built a calendar widget for clients to manage their itineraries visually.
Due to the complexity of the client side interactions required, I decided to use Backbone.js as an MVC Framework.
/* global namespace */ var walks = { builder: {}, collections: { bookings_details: [], events: [], events_domains_groups: [ // maps events to domain groups, aka locations // also embedded by cake within events ], locations: [ // DomainsGroup ], stages: [ // EventsStagePaxRemaining ], tags: [] }, constructors: { backbone: {}, templates: {}, views: {} }, data: { // variable to pass CakePHP webroot across context // set in View/Layouts/main webroot: walksllc_webroot, display_filters: { week: { first_date: function() {return new Date($(walks.selectors.calendar_range).attr('data-range-start'))}, last_date: function() {return new Date($(walks.selectors.calendar_range).attr('data-range-end'))} }, events: {} }, }, init_data: {}, selectors: { locations: '.location-info tbody', stages: '.stages', events: '.events', events_filters: '.events-filters', calendar_range: '.calendar-range' }, settings: { builder_height: 480, location_duration: 3, test_client_id: 118, test_mode: false, url: { ajax_url: function(this_url) { return walks.settings.url.base + walks.settings.url.app + this_url; }, app: 'elias/', base: 'http://dev.walks.org/', } }, views: { locations: [], events: [], stages: [] } } walks.builder = (function(){ /* Convenience functions */ Date.prototype.yyyymmdd = function() { var yyyy = this.getFullYear().toString(); var mm = (this.getMonth()+1).toString(); // getMonth() is zero-based var dd = this.getDate().toString(); return Number(yyyy + (mm[1]?mm:"0"+mm[0]) + (dd[1]?dd:"0"+dd[0])); // padding }; Date.prototype.prettyDate = function() { var prettyDate =(this.getMonth()+1) + '/' + this.getDate() + '/' + this.getFullYear(); return prettyDate; } function toType (obj) { return ({}).toString.call(obj).match(/\s([a-zA-Z]+)/)[1].toLowerCase(); } function capitaliseFirstLetter (string) { return string.charAt(0).toUpperCase() + string.slice(1); } function logg (message) { if (walks.settings.test_mode) { console.log(message); } } function initItineraryBuilder () { initMVCTypes(); loadData(); initUI(); } function initMVCTypes() { initMVCLocations(); initMVCEvents(); initMVCStages(); initMVCBookingsDetails(); initMVCGeneric(); initTemplating(); } function initTemplating () { // TODO: pre-compile Handlebars walks.constructors.templates walks.constructors.templates['events'] = Handlebars.compile($('#handlebars-event-template').html()); walks.constructors.templates['location-ui'] = Handlebars.compile($('#handlebars-location-ui-template').html()); walks.constructors.templates['stages'] = Handlebars.compile($('#handlebars-stages-template').html()); walks.constructors.templates['calendar-grid'] = Handlebars.compile($('#handlebars-calendar-grid-template').html()); walks.constructors.templates['calendar-hours-legend'] = Handlebars.compile($('#handlebars-calendar-hours-legend-template').html()); } // custom MVC types function initMVCLocations() { walks.constructors.backbone.LocationModel = Backbone.Model.extend({ getCallbackStage: function () { return walks.collections.stages.getByCid(this.get('callback_stage_id')); } }); walks.constructors.backbone.LocationsCollection = Backbone.Collection.extend({ model: walks.constructors.backbone.LocationModel, initialize: function() { this.on('add', function(location_model) { mvcAddView(location_model, 'Location', 'locations'); }); this.on('change:start_date', function(m) { if (m.has('start_date')) { m.set({start_date_pretty: m.get('start_date').prettyDate()}); } else { m.set({start_date_pretty: null}); } }); this.on('change:end_date', function(m) { if (m.has('end_date')) { m.set({end_date_pretty: m.get('end_date').prettyDate()}); } else { m.set({end_date_pretty: null}); } }); }, listForSelect: function (target_location_id) { var temp_collection = walks.collections.locations.map(function(c) { var temp_id = c.get('id'); var temp_object; temp_object = { name: c.get('name'), id: temp_id } if (target_location_id === temp_id) { temp_object['selected'] = true; } return temp_object; }); return temp_collection; } }); walks.collections.locations = new walks.constructors.backbone.LocationsCollection(); walks.constructors.backbone.LocationView = Backbone.View.extend({ tagName: 'tr', className: 'Cant', id: 'Stand', attributes: 'It', collection: walks.collections.locations, events: { 'click .add-location': 'modifyLocationUI', 'click .location-cancel': 'cancelLocationUI', 'click .location-edit': 'editLocationUI' }, initialize: function() { this.model.on('change', this.render, this); }, model: walks.constructors.backbone.LocationModel, render: function(location_model, options) { var temp_stage; var temp_location = location_model; var rendered_template; temp_location['collection'] = this.collection.listForSelect(this.model.get('id')); this.$el.addClass('location-ui'); if (this.model.get('edit_mode') === true) { rendered_template = walks.constructors.templates['location-ui'](temp_location); } else { rendered_template = walks.constructors.templates['location-ui'](this.model); } this.$el.html(rendered_template); $(walks.selectors.locations).append(this.$el); if (temp_stage = this.getCallbackStage()) { this.bindLocationUI(temp_stage); } toggleDrawer(); $('a[href="#Locations"]').click(); return this; }, bindLocationUI: function(callback_stage) { var to_date; var from_date; from_date = new Date(callback_stage.get('datetime')); to_date = new Date(from_date); to_date.setDate(to_date.getDate() + walks.settings.location_duration); $( ".location-ui .from" ).datepicker({ defaultDate: from_date, changeMonth: true, numberOfMonths: 1, onClose: function( selectedDate ) { $( "#to" ).datepicker( "option", "minDate", selectedDate ); } }).val(from_date.prettyDate()); $( ".location-ui .to" ).datepicker({ defaultDate: to_date, changeMonth: true, numberOfMonths: 1, onClose: function( selectedDate ) { $( "#from" ).datepicker( "option", "maxDate", selectedDate ); } }).val(to_date.prettyDate()); }, cancelLocationUI: function() { this.model.set({ callback_stage_id: null, edit_mode: null, end_date: null, start_date: null }); toggleDrawer(this.el, 'closed'); }, editLocationUI: function() { this.model.set({edit_mode: true}); }, getCallbackStage: function() { var temp_stage = this.model.getCallbackStage(); if (temp_stage) { return temp_stage; } else { return false; } }, modifyLocationUI: function() { var temp_stage; var target_location_id = $('.location-select').val(); var target_location = walks.collections.locations.get(target_location_id); target_location.set({ edit_mode: null, end_date: new Date($('.location-ui .to').val()), start_date: new Date($('.location-ui .from').val()) }); if (temp_stage = this.getCallbackStage()) { temp_stage.addToItinerary(); } this.paintBuilder(); }, paintBuilder: function() { // on initial launch, model will only have default start_date $('.column.location').removeClass('location'); walks.collections.locations.forEach(function(m) { var temp_start_date = m.get('start_date'); var date_diff = (m.get('end_date') - temp_start_date)/86400000; for (var i=0; i < date_diff; i++) { $('[data-date="' + (temp_start_date.yyyymmdd() + i) + '"]').addClass('location'); } }); } }); } function initMVCEvents () { walks.constructors.backbone.EventModel = Backbone.Model.extend(); walks.constructors.backbone.EventsCollection = Backbone.Collection.extend({ model: walks.constructors.backbone.EventModel, initialize: function() { this.on('add', function(event_model) { mvcAddView(event_model, 'Event', 'events'); }); } }); walks.collections.events = new walks.constructors.backbone.EventsCollection; walks.constructors.backbone.EventView = Backbone.View.extend({ events: { 'click a.show-availability': 'showAvailability' }, initialize: function() { this.model.on('change', this.render, this); }, model: walks.constructors.backbone.EventModel, render: function () { var rendered_template = walks.constructors.templates['events'](this.model); this.$el.addClass('span6'); this.$el.html(rendered_template); $(walks.selectors.events).append(this.$el); return this; }, showAvailability: function(e) { builderOpen(); $('.hide-availability').show(); walks.data.display_filters.events.id = this.model.get('id'); repaintBuilder(); } }); } function initMVCStages () { walks.constructors.backbone.StageModel = Backbone.Model.extend({ initialize: function() { this.on('change:itinerary', this.updateBookings, this), this.set({js_date: new Date(this.get('datetime').replace(/-/g, '/'))}); }, addToItinerary: function() { var temp_location = this.getLocation(); if (this.bookedLocation()) { this.set({itinerary: true}); hideAvailability(); walks.collections.stages.suggestAnotherBooking(this); return true; } else { temp_location.set({ callback_stage_id: this.cid, edit_mode: true }); return false; } }, // convenience function to set attributes dynamically alterAttribute: function(this_attribute, attribute_value) { this.attributes[this_attribute] = attribute_value; }, bookedLocation: function() { var this_location = this.getLocation(); if (!this_location.get('start_date') || !this_location.get('end_date')) { return false; } else { return true; } }, initForDisplay: function() { var this_date = this.get('js_date'); var this_event; var this_duration; var this_hours; var this_minutes; var this_end_date; if (!this.has('js_end_date')) { this_event = walks.collections.events.get(this.get('events_id')); this_duration = this_event.get('duration'); this_hours = Math.floor(this_duration/60); this_minutes = this_duration%60; this_end_date = new Date(this_date); this_end_date.setHours(this_date.getHours() + this_hours); this_end_date.setMinutes(this_date.getMinutes() + this_minutes); this.set({ event_name: this_event.get('name_long').replace(/-.*/, ''), duration: this_duration, js_end_date: this_end_date }); } }, getLocation: function() { var this_location; var temp_location_id; var this_event_id = this.get('events_id'); temp_location_id = walks.collections.events_domains_groups.where({ event_id: this_event_id, primary: true })[0].get('group_id'); this_location = walks.collections.locations.get(temp_location_id); return this_location; }, updateBookings: function() { if (walks.collections.bookings.length === 0) { // create new booking $.ajax({ url: walks.settings.url.ajax_url('Bookings/Add/new/ajax/'), success: function (data) { walks.data['current_booking'] = JSON.parse(data)['booking_id']; }, error: function (data) { // TODO: better error handling this.model.set({itinerary: false}); } }); } else { // retrieve existing booking } if (this.get('itinerary') === true) { walks.collections.bookings_details.add(this, {merge: true}); } else { walks.collections.bookings_details.remove(this); } } }); walks.constructors.backbone.StagesCollection = Backbone.Collection.extend({ comparator: function(stage_model) { return stage_model.get('js_date'); }, initialize: function() { this.on('add', function(stage_obj) { mvcAddView(event_model, 'Stage', 'stages'); }) }, model: walks.constructors.backbone.StageModel, isStageAvailable: function(test_stage) { var overlapping_itinerary_p = true; _.forEach( walks.collections.stages.where({itinerary: true}), function(itinerary_stage) { // exclude stages that overlap with anything in itinerary if (itinerary_stage.get('js_date') < test_stage.get('js_end_date') && itinerary_stage.get('js_end_date') > test_stage.get('js_date')) { overlapping_itinerary_p = false; } } ); return overlapping_itinerary_p; }, nextAvailableStage: function(search_stage) { return _.find( walks.collections.stages.availableStages(search_stage), function() { // get first result return true; } ) }, availableStages: function(start_stage) { return _.filter( walks.collections.stages.where({in_date_range: true}), function(each_stage) { if (each_stage.get('js_date') > start_stage.get('js_end_date')) { return walks.collections.stages.isStageAvailable(each_stage); } } ); }, suggestAnotherBooking: function(this_stage) { // TODO: handle when nextAvailable returns null this.nextAvailableStage(this_stage).set({'suggested': true}); } }); walks.collections.stages = new walks.constructors.backbone.StagesCollection; walks.constructors.backbone.StageView = Backbone.View.extend({ events: { 'click a.toggle-itinerary': 'toggleItinerary', 'click .stage-inner': 'stageClick' }, initialize: function() { this.model.on('change', function() { this.render(); }, this); }, model: walks.constructors.backbone.StageModel, render: function () { var this_date = this.model.get('js_date'); var this_events_id = this.model.get('events_id'); this.$el.addClass('stage'); var display_state_classes = ['itinerary', 'availability', 'suggested', 'in_date_range']; for (var i=0; i<4; i++) { if (this.model.get(display_state_classes[i])) { this.$el.addClass(display_state_classes[i]); } else { this.$el.removeClass(display_state_classes[i]); } } this.$el.attr('data-events-id', this_events_id); this.$el.html(walks.constructors.templates['stages'](this.model)); var this_selector = [ walks.selectors.stages, ' [data-date="', this_date.yyyymmdd(), '"] ', ' [data-hour-id="', this_date.getHours() + '"]' ].join(''); $(this_selector).append(this.$el); return this; }, addToItinerary: function() { this.model.addToItinerary(); }, removeFromItinerary: function () { this.model.set('itinerary', false); repaintBuilder(); }, stageClick: function () { if (this.model.get('suggested') == true) { filterEvents( 'id', _.map( walks.collections.stages.availableStages(this.model), function(stage_model) { return stage_model.get('events_id'); } ) ); builderClose(); } }, toggleItinerary: function (e) { e.preventDefault(); e.stopPropagation(); if (this.model.get('itinerary') != true) { this.addToItinerary(); } else { this.removeFromItinerary(); } } }); } function initMVCBookingsDetails () { walks.constructors.backbone.BookingsDetailsModel = Backbone.Model.extend({ initialize: function() { this.on('add', this.syncStage, this); }, findStage: function() { this.set({ stage_id: walks.collections.stages.where({ datetime: this.get('events_datetimes') })[0].get('id') }); }, markStage: function() { walks.collections.stages.get( this.get('stage_id') ).set({ itinerary: true }); }, syncStage: function() { this.findStage(); this.markStage(); } }); walks.constructors.backbone.BookingsDetailsCollection = Backbone.Collection.extend({ initialize: function() { this.on('reset', function() { this.forEach(function(b) { b.trigger('add'); }); }, this); }, model: walks.constructors.backbone.BookingsDetailsModel }); walks.collections.bookings_details = new walks.constructors.backbone.BookingsDetailsCollection; } // abstracted MVC functions function mvcAddView (model_object, view_type, view_type_plural) { if (!view_type_plural) { view_type_plural = view_type.toLowerCase() + 's'; } var temp_view = new walks.constructors.backbone[view_type + 'View']({model: model_object}); walks.views[view_type_plural].push(temp_view); } function mvcAddGenericDataType () { var model_constructor_name = arguments[0][0]; var collection_constructor_name = arguments[0][1]; var collection_instance_name = arguments[0][2]; walks.constructors.backbone[model_constructor_name + 'Model'] = Backbone.Model.extend({}); walks.constructors.backbone[collection_constructor_name + 'Collection'] = Backbone.Collection.extend({ model: walks.constructors.backbone[model_constructor_name + 'Model'] }); walks.collections[collection_instance_name] = new walks.constructors.backbone[collection_constructor_name + 'Collection']; } function initMVCGeneric () { var generic_mvcs = [ ['Tag', 'Tags', 'tags'], ['DomainsGroup', 'DomainsGroups', 'domains_groups'], ['EventsDomainsGroup', 'EventsDomainsGroups', 'events_domains_groups'], ['Bookings', 'Booking', 'bookings'] ] for (var g=0; g < generic_mvcs.length; g++) { mvcAddGenericDataType(generic_mvcs[g]); } } // controls and presentation behavior function builderToggle () { if ($('.builder-content').hasClass('open')) { builderClose(); } else { builderOpen(); } } function builderOpen () { if (!$('.builder-content').hasClass('open')) { $('.builder-content').addClass('open').show(); builderAnimate('open'); } } function builderClose () { $('.builder-content').removeClass('open').hide(); builderAnimate('close'); hideAvailability(); } function builderAnimate (direction, height) { var opacity; var width; if (direction === 'open') { if (!height) { height = walks.settings.builder_height; } width = 790; } else if (direction === 'close') { if (!height) { height = 0; width = 0; } } $('.builder').animate({ width: width, height: height, opacity: opacity }); } function toggleDrawer (element, direction) { if (!direction) { direction = 'open'; } if (direction === 'open') { builderAnimate('open', 650); } else { builderAnimate('close', walks.settings.builder_height); } } function bindControls () { $('body').click(function () { var $target_element = $(event.target); if ($('.builder-content').hasClass('open')) { if ($target_element.parents().index($('.builder')) == -1) { if (!$target_element.hasClass('stop') && !$target_element.hasClass('prevent')) { builderClose(); } } } }); $('body').on('click', '.prevent', function(e) { e.preventDefault(); }).on('click', '.stop', function(e) { e.stopPropagation(); }).on('click', '.test', function(e) { logg(e); debugger; }); $('body').on('click', 'a.repaint', function(e) { repaintContent($(this).attr('href')); }); $('.calendar-controls').on('click', '.prev', function() { var date = new Date($(walks.selectors.calendar_range).attr('data-range-start')); date.setDate(date.getDate() - 7); setCalendarRange(date); repaintBuilder(); }).on('click', '.next', function() { var date = new Date($(walks.selectors.calendar_range).attr('data-range-start')); date.setDate(date.getDate() + 7); setCalendarRange(date); repaintBuilder(); }).on('click', '.calendar-scale a.btn', function() { $(this).button('toggle'); }); $('.events-filters').on('click', 'a', function() { $('.events-filters a').removeClass('selected'); var temp_type = $(this).attr('data-filter-type'); var temp_value = $(this).attr('data-filter-value'); filterEvents( temp_type, temp_value ); repaintBuilder(); $(this).addClass('selected'); }); $('.builder').on('click', '.builder-toggle', function() { builderToggle(); }).on('click', '.hide-availability', function() { hideAvailability(); }).on('click', '.book-all-test', function() { walks.collections.stages.where({in_date_range: true}).forEach(function(a) {a.set({'itinerary': true})}); }); $('.itinerary-drawer .nav-tabs a').click(function (e) { e.preventDefault(); $(this).tab('show'); toggleDrawer(this); }); } function loadData () { var temp_object; var temp_collection; var internal_object_name; var internal_collection_name; for (var i in walks.init_data) { logg(i); switch (i) { case 'BookingsDetail': internal_object_name = 'BookingsDetail'; internal_collection_name = 'bookings_details'; break; case 'EventsDomainsGroup': internal_object_name = 'EventsDomainsGroup'; internal_collection_name = 'events_domains_groups'; break; case 'EventsStagePaxRemaining': internal_object_name = 'Stage'; internal_collection_name = 'stages'; break; case 'DomainsGroup': internal_object_name = 'Location'; internal_collection_name = 'locations'; break; default: internal_object_name = i; internal_collection_name = i.toLowerCase() + 's'; } // cake wraps json objects with the same name // this section discards it for syntactical convenience // the else statement below protects against it disappearing, but, // TODO: if we ever alter the cake feed, this section could be removed entirely temp_collection = _.map(walks.init_data[i], function (each_model) { if (each_model[i]) { temp_object = each_model[i]; for (var j in each_model) { if (i != j) { temp_object[j] = each_model[j]; } } } else { temp_object = each_model[j]; } return temp_object; }); if (temp_collection[0] != undefined) { walks.collections[internal_collection_name].reset(temp_collection).forEach(function (m) { if ($.inArray(internal_object_name, ['Event', 'Stage', 'Location']) > -1) { mvcAddView(m, internal_object_name, internal_collection_name); } }); } } filterEvents(); $('.events .spin-wheel').hide(); repaintBuilder(); } function initUI () { setCalendarRange(); initCalendarGrid(); bindControls(); addLocationFormToDrawer(); } function addLocationFormToDrawer () { var rendered_template = walks.constructors.templates['location-ui']({ edit_mode: true, collection: function() { return walks.collections.locations.listForSelect(); } }); $(walks.selectors.locations).append(rendered_template); } function initCalendarGrid () { var rendered_template; var grid_hours = new Array; var grid_day; var first_date_of_week = new Date($(walks.selectors.calendar_range).attr('data-range-start')); var grid_date = new Date; for (var j=0; j<24; j++) { grid_hours.push(j); } rendered_template = walks.constructors.templates['calendar-hours-legend']({hours: grid_hours}); $(walks.selectors.stages).append(rendered_template); for (var i=0; i<7; i++) { grid_date.setDate(first_date_of_week.getDate() + i); grid_day = { grid_date: grid_date.yyyymmdd(), day_of_week: i, hours: grid_hours }; rendered_template = walks.constructors.templates['calendar-grid'](grid_day); $(walks.selectors.stages).append(rendered_template); } setCalendarHeaders(); } function setCalendarRange(date) { var day_of_week; var first_date_of_week = new Date(); var last_date_of_week; var current_week_string; if (!date) { var date = new Date(); } day_of_week = date.getDay(); if (day_of_week == 0) { first_date_of_week = date; } else { first_date_of_week.setDate(date.getDate() - day_of_week); } $(walks.selectors.calendar_range).attr('data-range-start', first_date_of_week); last_date_of_week = new Date(first_date_of_week); last_date_of_week.setDate(first_date_of_week.getDate() + 7); $(walks.selectors.calendar_range).attr('data-range-end', last_date_of_week); current_week_string = (first_date_of_week.getMonth() + 1) + '/' + first_date_of_week.getDate() + ' - ' + (last_date_of_week.getMonth() + 1) + '/' + last_date_of_week.getDate(); $(walks.selectors.calendar_range).html(current_week_string); setCalendarHeaders(); } function setCalendarHeaders () { for (var i=0; i<7; i++) { var column_selector = '.day[data-day-id="' + i + '"]'; var first_date_of_week = new Date($(walks.selectors.calendar_range).attr('data-range-start')); var each_date = new Date(first_date_of_week); each_date.setDate(first_date_of_week.getDate() + i); var date_string = (each_date.getMonth() + 1) + '/' + (each_date.getDate()); $(column_selector).attr('data-date', each_date.yyyymmdd()); $(column_selector + ' .date-header').html(date_string); } } function filterEvents (filter_type, filter_value) { $('.events .spin-wheel').show(); if (filter_value && toType(filter_value) != 'array') { filter_value = [filter_value]; } walks.collections.events.forEach(function(event_model, i) { if (!filter_value) { event_model.set({displayed: true}); } else { event_model.set({displayed: false}); for (var i=0; iwalks.data.display_filters.week.first_date() && stage_model.get('js_date') < walks.data.display_filters.week.last_date()) { stage_model.initForDisplay(); stage_model.set({in_date_range: true}); // TODO: accept mulitple filters if (stage_model.get('events_id') == walks.data.display_filters.events.id) { stage_model.set({availability: walks.collections.stages.isStageAvailable(stage_model)}); } } }); } function positionCalendarGrid () { var filtered_stages = walks.collections.stages.where({availability: true}); var earliest_stage; var offset_hours = 8; var offset_px; if (filtered_stages.length > 0) { earliest_stage = _.min( filtered_stages, function(stage) {return stage.get('js_date').getHours()} ); offset_hours = earliest_stage.get('js_date').getHours(); } offset_px = offset_hours * -20; if (offset_px < -140) { offset_px = -140; } $('.hours').css({ top: offset_px + 'px', }); } function hideAvailability () { walks.data.display_filters.events.id = null; repaintBuilder(); $('.hide-availability').hide(); } return { initItineraryBuilder: initItineraryBuilder } })(); $(document).ready(function() { var opts = { lines: 12, // The number of lines to draw length: 7, // The length of each line width: 4, // The line thickness radius: 10, // The radius of the inner circle corners: 1, // Corner roundness (0..1) rotate: 0, // The rotation offset color: '#000', // #rgb or #rrggbb speed: 1.8, // Rounds per second trail: 60, // Afterglow percentage shadow: false, // Whether to render a shadow hwaccel: false, // Whether to use hardware acceleration className: 'spinner', // The CSS class to assign to the spinner zIndex: 2e9, // The z-index (defaults to 2000000000) top: 'auto', // Top position relative to parent in px left: 'auto' // Left position relative to parent in px }; var target = $('.events .spin-wheel'); var spinner = new Spinner(opts).spin(target[0]); });