Przeglądaj źródła

Merge branch 'master' into weeknr-in-daycell

Peter Nowee 10 lat temu
rodzic
commit
21bd5b222e

+ 10 - 0
CHANGELOG.md

@@ -1,4 +1,14 @@
 
+v2.6.0 (2016-01-07)
+-------------------
+
+- current time indicator (#414)
+- bundled with most recent version of moment (2.11.0)
+- UMD wrapper around lang files now handles commonjs (#2918)
+- fix bug where external event dragging would not respect eventOverlap
+- fix bug where external event dropping would not render the whole-day highlight
+
+
 v2.5.0 (2015-11-30)
 -------------------
 

+ 2 - 0
build/karma.conf.js

@@ -34,6 +34,8 @@ module.exports = function(config) {
 			'../tests/lib/dom-utils.js',
 			'../tests/lib/dnd-resize-utils.js',
 
+			'../tests/lib/time-grid.js',
+
 			'../dist/fullcalendar.js',
 			'../dist/gcal.js',
 			'../dist/lang-all.js',

+ 11 - 6
build/tasks/generateLanguages.js

@@ -98,6 +98,9 @@ module.exports = function(grunt) {
 			'    if (typeof define === "function" && define.amd) {',
 			'        define([ "jquery", "moment" ], factory);',
 			'    }',
+			'    else if (typeof exports === "object") {',
+			'        module.exports = factory(require("jquery"), require("moment"));',
+			'    }',
 			'    else {',
 			'        factory(jQuery, moment);',
 			'    }',
@@ -125,19 +128,21 @@ module.exports = function(grunt) {
 
 		var js = grunt.file.read(path);
 
-		js = js.replace( // remove the UMD wrap
+		// remove the UMD wrap
+		js = js.replace(
 			/\(\s*function[\S\s]*?function\s*\(\s*moment\s*\)\s*\{([\S\s]*)\}\)\);?/,
 			function(m0, body) {
-				body = body.replace(/^    /mg, ''); // remove 1 level of indentation
 				return body;
 			}
 		);
 
-		// replace the `return` statement so execution continues
-		// compatible with moment-pre-2.8
+		// the JS will return a value. wrap in a closure to avoid haulting execution
+		js = '(function() {\n' + js + '})();\n';
+
+		// make the defineLocale statement compatible with moment-pre-2.8
 		js = js.replace(
-			/^(\s*)return moment\.(defineLocale|lang)\(/m,
-			'$1(moment.defineLocale || moment.lang).call(moment, '
+			/moment\.(defineLocale|lang)\(/m,
+			'(moment.defineLocale || moment.lang).call(moment, '
 		);
 
 		return js;

+ 16 - 16
demos/agenda-views.html

@@ -17,63 +17,63 @@
 				center: 'title',
 				right: 'month,agendaWeek,agendaDay'
 			},
-			defaultDate: '2015-12-12',
+			defaultDate: '2016-01-12',
 			editable: true,
 			eventLimit: true, // allow "more" link when too many events
 			events: [
 				{
 					title: 'All Day Event',
-					start: '2015-12-01'
+					start: '2016-01-01'
 				},
 				{
 					title: 'Long Event',
-					start: '2015-12-07',
-					end: '2015-12-10'
+					start: '2016-01-07',
+					end: '2016-01-10'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2015-12-09T16:00:00'
+					start: '2016-01-09T16:00:00'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2015-12-16T16:00:00'
+					start: '2016-01-16T16:00:00'
 				},
 				{
 					title: 'Conference',
-					start: '2015-12-11',
-					end: '2015-12-13'
+					start: '2016-01-11',
+					end: '2016-01-13'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-12T10:30:00',
-					end: '2015-12-12T12:30:00'
+					start: '2016-01-12T10:30:00',
+					end: '2016-01-12T12:30:00'
 				},
 				{
 					title: 'Lunch',
-					start: '2015-12-12T12:00:00'
+					start: '2016-01-12T12:00:00'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-12T14:30:00'
+					start: '2016-01-12T14:30:00'
 				},
 				{
 					title: 'Happy Hour',
-					start: '2015-12-12T17:30:00'
+					start: '2016-01-12T17:30:00'
 				},
 				{
 					title: 'Dinner',
-					start: '2015-12-12T20:00:00'
+					start: '2016-01-12T20:00:00'
 				},
 				{
 					title: 'Birthday Party',
-					start: '2015-12-13T07:00:00'
+					start: '2016-01-13T07:00:00'
 				},
 				{
 					title: 'Click for Google',
 					url: 'http://google.com/',
-					start: '2015-12-28'
+					start: '2016-01-28'
 				}
 			]
 		});

+ 14 - 14
demos/background-events.html

@@ -17,56 +17,56 @@
 				center: 'title',
 				right: 'month,agendaWeek,agendaDay'
 			},
-			defaultDate: '2015-12-12',
+			defaultDate: '2016-01-12',
 			businessHours: true, // display business hours
 			editable: true,
 			events: [
 				{
 					title: 'Business Lunch',
-					start: '2015-12-03T13:00:00',
+					start: '2016-01-03T13:00:00',
 					constraint: 'businessHours'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-13T11:00:00',
+					start: '2016-01-13T11:00:00',
 					constraint: 'availableForMeeting', // defined below
 					color: '#257e4a'
 				},
 				{
 					title: 'Conference',
-					start: '2015-12-18',
-					end: '2015-12-20'
+					start: '2016-01-18',
+					end: '2016-01-20'
 				},
 				{
 					title: 'Party',
-					start: '2015-12-29T20:00:00'
+					start: '2016-01-29T20:00:00'
 				},
 
 				// areas where "Meeting" must be dropped
 				{
 					id: 'availableForMeeting',
-					start: '2015-12-11T10:00:00',
-					end: '2015-12-11T16:00:00',
+					start: '2016-01-11T10:00:00',
+					end: '2016-01-11T16:00:00',
 					rendering: 'background'
 				},
 				{
 					id: 'availableForMeeting',
-					start: '2015-12-13T10:00:00',
-					end: '2015-12-13T16:00:00',
+					start: '2016-01-13T10:00:00',
+					end: '2016-01-13T16:00:00',
 					rendering: 'background'
 				},
 
 				// red areas where no events can be dropped
 				{
-					start: '2015-12-24',
-					end: '2015-12-28',
+					start: '2016-01-24',
+					end: '2016-01-28',
 					overlap: false,
 					rendering: 'background',
 					color: '#ff9f89'
 				},
 				{
-					start: '2015-12-06',
-					end: '2015-12-08',
+					start: '2016-01-06',
+					end: '2016-01-08',
 					overlap: false,
 					rendering: 'background',
 					color: '#ff9f89'

+ 16 - 16
demos/basic-views.html

@@ -17,63 +17,63 @@
 				center: 'title',
 				right: 'month,basicWeek,basicDay'
 			},
-			defaultDate: '2015-12-12',
+			defaultDate: '2016-01-12',
 			editable: true,
 			eventLimit: true, // allow "more" link when too many events
 			events: [
 				{
 					title: 'All Day Event',
-					start: '2015-12-01'
+					start: '2016-01-01'
 				},
 				{
 					title: 'Long Event',
-					start: '2015-12-07',
-					end: '2015-12-10'
+					start: '2016-01-07',
+					end: '2016-01-10'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2015-12-09T16:00:00'
+					start: '2016-01-09T16:00:00'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2015-12-16T16:00:00'
+					start: '2016-01-16T16:00:00'
 				},
 				{
 					title: 'Conference',
-					start: '2015-12-11',
-					end: '2015-12-13'
+					start: '2016-01-11',
+					end: '2016-01-13'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-12T10:30:00',
-					end: '2015-12-12T12:30:00'
+					start: '2016-01-12T10:30:00',
+					end: '2016-01-12T12:30:00'
 				},
 				{
 					title: 'Lunch',
-					start: '2015-12-12T12:00:00'
+					start: '2016-01-12T12:00:00'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-12T14:30:00'
+					start: '2016-01-12T14:30:00'
 				},
 				{
 					title: 'Happy Hour',
-					start: '2015-12-12T17:30:00'
+					start: '2016-01-12T17:30:00'
 				},
 				{
 					title: 'Dinner',
-					start: '2015-12-12T20:00:00'
+					start: '2016-01-12T20:00:00'
 				},
 				{
 					title: 'Birthday Party',
-					start: '2015-12-13T07:00:00'
+					start: '2016-01-13T07:00:00'
 				},
 				{
 					title: 'Click for Google',
 					url: 'http://google.com/',
-					start: '2015-12-28'
+					start: '2016-01-28'
 				}
 			]
 		});

+ 16 - 16
demos/default.html

@@ -12,63 +12,63 @@
 	$(document).ready(function() {
 
 		$('#calendar').fullCalendar({
-			defaultDate: '2015-12-12',
+			defaultDate: '2016-01-12',
 			editable: true,
 			eventLimit: true, // allow "more" link when too many events
 			events: [
 				{
 					title: 'All Day Event',
-					start: '2015-12-01'
+					start: '2016-01-01'
 				},
 				{
 					title: 'Long Event',
-					start: '2015-12-07',
-					end: '2015-12-10'
+					start: '2016-01-07',
+					end: '2016-01-10'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2015-12-09T16:00:00'
+					start: '2016-01-09T16:00:00'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2015-12-16T16:00:00'
+					start: '2016-01-16T16:00:00'
 				},
 				{
 					title: 'Conference',
-					start: '2015-12-11',
-					end: '2015-12-13'
+					start: '2016-01-11',
+					end: '2016-01-13'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-12T10:30:00',
-					end: '2015-12-12T12:30:00'
+					start: '2016-01-12T10:30:00',
+					end: '2016-01-12T12:30:00'
 				},
 				{
 					title: 'Lunch',
-					start: '2015-12-12T12:00:00'
+					start: '2016-01-12T12:00:00'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-12T14:30:00'
+					start: '2016-01-12T14:30:00'
 				},
 				{
 					title: 'Happy Hour',
-					start: '2015-12-12T17:30:00'
+					start: '2016-01-12T17:30:00'
 				},
 				{
 					title: 'Dinner',
-					start: '2015-12-12T20:00:00'
+					start: '2016-01-12T20:00:00'
 				},
 				{
 					title: 'Birthday Party',
-					start: '2015-12-13T07:00:00'
+					start: '2016-01-13T07:00:00'
 				},
 				{
 					title: 'Click for Google',
 					url: 'http://google.com/',
-					start: '2015-12-28'
+					start: '2016-01-28'
 				}
 			]
 		});

+ 1 - 1
demos/json.html

@@ -17,7 +17,7 @@
 				center: 'title',
 				right: 'month,agendaWeek,agendaDay'
 			},
-			defaultDate: '2015-12-12',
+			defaultDate: '2016-01-12',
 			editable: true,
 			eventLimit: true, // allow "more" link when too many events
 			events: {

+ 15 - 15
demos/json/events.json

@@ -1,56 +1,56 @@
 [
   {
     "title": "All Day Event",
-    "start": "2015-12-01"
+    "start": "2016-01-01"
   },
   {
     "title": "Long Event",
-    "start": "2015-12-07",
-    "end": "2015-12-10"
+    "start": "2016-01-07",
+    "end": "2016-01-10"
   },
   {
     "id": "999",
     "title": "Repeating Event",
-    "start": "2015-12-09T16:00:00-05:00"
+    "start": "2016-01-09T16:00:00-05:00"
   },
   {
     "id": "999",
     "title": "Repeating Event",
-    "start": "2015-12-16T16:00:00-05:00"
+    "start": "2016-01-16T16:00:00-05:00"
   },
   {
     "title": "Conference",
-    "start": "2015-12-11",
-    "end": "2015-12-13"
+    "start": "2016-01-11",
+    "end": "2016-01-13"
   },
   {
     "title": "Meeting",
-    "start": "2015-12-12T10:30:00-05:00",
-    "end": "2015-12-12T12:30:00-05:00"
+    "start": "2016-01-12T10:30:00-05:00",
+    "end": "2016-01-12T12:30:00-05:00"
   },
   {
     "title": "Lunch",
-    "start": "2015-12-12T12:00:00-05:00"
+    "start": "2016-01-12T12:00:00-05:00"
   },
   {
     "title": "Meeting",
-    "start": "2015-12-12T14:30:00-05:00"
+    "start": "2016-01-12T14:30:00-05:00"
   },
   {
     "title": "Happy Hour",
-    "start": "2015-12-12T17:30:00-05:00"
+    "start": "2016-01-12T17:30:00-05:00"
   },
   {
     "title": "Dinner",
-    "start": "2015-12-12T20:00:00"
+    "start": "2016-01-12T20:00:00"
   },
   {
     "title": "Birthday Party",
-    "start": "2015-12-13T07:00:00-05:00"
+    "start": "2016-01-13T07:00:00-05:00"
   },
   {
     "title": "Click for Google",
     "url": "http://google.com/",
-    "start": "2015-12-28"
+    "start": "2016-01-28"
   }
 ]

+ 16 - 16
demos/languages.html

@@ -40,7 +40,7 @@
 					center: 'title',
 					right: 'month,agendaWeek,agendaDay'
 				},
-				defaultDate: '2015-12-12',
+				defaultDate: '2016-01-12',
 				lang: currentLangCode,
 				buttonIcons: false, // show the prev/next text
 				weekNumbers: true,
@@ -49,57 +49,57 @@
 				events: [
 					{
 						title: 'All Day Event',
-						start: '2015-12-01'
+						start: '2016-01-01'
 					},
 					{
 						title: 'Long Event',
-						start: '2015-12-07',
-						end: '2015-12-10'
+						start: '2016-01-07',
+						end: '2016-01-10'
 					},
 					{
 						id: 999,
 						title: 'Repeating Event',
-						start: '2015-12-09T16:00:00'
+						start: '2016-01-09T16:00:00'
 					},
 					{
 						id: 999,
 						title: 'Repeating Event',
-						start: '2015-12-16T16:00:00'
+						start: '2016-01-16T16:00:00'
 					},
 					{
 						title: 'Conference',
-						start: '2015-12-11',
-						end: '2015-12-13'
+						start: '2016-01-11',
+						end: '2016-01-13'
 					},
 					{
 						title: 'Meeting',
-						start: '2015-12-12T10:30:00',
-						end: '2015-12-12T12:30:00'
+						start: '2016-01-12T10:30:00',
+						end: '2016-01-12T12:30:00'
 					},
 					{
 						title: 'Lunch',
-						start: '2015-12-12T12:00:00'
+						start: '2016-01-12T12:00:00'
 					},
 					{
 						title: 'Meeting',
-						start: '2015-12-12T14:30:00'
+						start: '2016-01-12T14:30:00'
 					},
 					{
 						title: 'Happy Hour',
-						start: '2015-12-12T17:30:00'
+						start: '2016-01-12T17:30:00'
 					},
 					{
 						title: 'Dinner',
-						start: '2015-12-12T20:00:00'
+						start: '2016-01-12T20:00:00'
 					},
 					{
 						title: 'Birthday Party',
-						start: '2015-12-13T07:00:00'
+						start: '2016-01-13T07:00:00'
 					},
 					{
 						title: 'Click for Google',
 						url: 'http://google.com/',
-						start: '2015-12-28'
+						start: '2016-01-28'
 					}
 				]
 			});

+ 16 - 16
demos/selectable.html

@@ -17,7 +17,7 @@
 				center: 'title',
 				right: 'month,agendaWeek,agendaDay'
 			},
-			defaultDate: '2015-12-12',
+			defaultDate: '2016-01-12',
 			selectable: true,
 			selectHelper: true,
 			select: function(start, end) {
@@ -38,57 +38,57 @@
 			events: [
 				{
 					title: 'All Day Event',
-					start: '2015-12-01'
+					start: '2016-01-01'
 				},
 				{
 					title: 'Long Event',
-					start: '2015-12-07',
-					end: '2015-12-10'
+					start: '2016-01-07',
+					end: '2016-01-10'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2015-12-09T16:00:00'
+					start: '2016-01-09T16:00:00'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2015-12-16T16:00:00'
+					start: '2016-01-16T16:00:00'
 				},
 				{
 					title: 'Conference',
-					start: '2015-12-11',
-					end: '2015-12-13'
+					start: '2016-01-11',
+					end: '2016-01-13'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-12T10:30:00',
-					end: '2015-12-12T12:30:00'
+					start: '2016-01-12T10:30:00',
+					end: '2016-01-12T12:30:00'
 				},
 				{
 					title: 'Lunch',
-					start: '2015-12-12T12:00:00'
+					start: '2016-01-12T12:00:00'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-12T14:30:00'
+					start: '2016-01-12T14:30:00'
 				},
 				{
 					title: 'Happy Hour',
-					start: '2015-12-12T17:30:00'
+					start: '2016-01-12T17:30:00'
 				},
 				{
 					title: 'Dinner',
-					start: '2015-12-12T20:00:00'
+					start: '2016-01-12T20:00:00'
 				},
 				{
 					title: 'Birthday Party',
-					start: '2015-12-13T07:00:00'
+					start: '2016-01-13T07:00:00'
 				},
 				{
 					title: 'Click for Google',
 					url: 'http://google.com/',
-					start: '2015-12-28'
+					start: '2016-01-28'
 				}
 			]
 		});

+ 16 - 16
demos/theme.html

@@ -19,63 +19,63 @@
 				center: 'title',
 				right: 'month,agendaWeek,agendaDay'
 			},
-			defaultDate: '2015-12-12',
+			defaultDate: '2016-01-12',
 			editable: true,
 			eventLimit: true, // allow "more" link when too many events
 			events: [
 				{
 					title: 'All Day Event',
-					start: '2015-12-01'
+					start: '2016-01-01'
 				},
 				{
 					title: 'Long Event',
-					start: '2015-12-07',
-					end: '2015-12-10'
+					start: '2016-01-07',
+					end: '2016-01-10'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2015-12-09T16:00:00'
+					start: '2016-01-09T16:00:00'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2015-12-16T16:00:00'
+					start: '2016-01-16T16:00:00'
 				},
 				{
 					title: 'Conference',
-					start: '2015-12-11',
-					end: '2015-12-13'
+					start: '2016-01-11',
+					end: '2016-01-13'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-12T10:30:00',
-					end: '2015-12-12T12:30:00'
+					start: '2016-01-12T10:30:00',
+					end: '2016-01-12T12:30:00'
 				},
 				{
 					title: 'Lunch',
-					start: '2015-12-12T12:00:00'
+					start: '2016-01-12T12:00:00'
 				},
 				{
 					title: 'Meeting',
-					start: '2015-12-12T14:30:00'
+					start: '2016-01-12T14:30:00'
 				},
 				{
 					title: 'Happy Hour',
-					start: '2015-12-12T17:30:00'
+					start: '2016-01-12T17:30:00'
 				},
 				{
 					title: 'Dinner',
-					start: '2015-12-12T20:00:00'
+					start: '2016-01-12T20:00:00'
 				},
 				{
 					title: 'Birthday Party',
-					start: '2015-12-13T07:00:00'
+					start: '2016-01-13T07:00:00'
 				},
 				{
 					title: 'Click for Google',
 					url: 'http://google.com/',
-					start: '2015-12-28'
+					start: '2016-01-28'
 				}
 			]
 		});

+ 1 - 1
demos/timezones.html

@@ -37,7 +37,7 @@
 					center: 'title',
 					right: 'month,agendaWeek,agendaDay'
 				},
-				defaultDate: '2015-12-12',
+				defaultDate: '2016-01-12',
 				timezone: currentTimezone,
 				editable: true,
 				selectable: true,

+ 42 - 9
src/agenda/AgendaView.js

@@ -111,15 +111,6 @@ var AgendaView = FC.AgendaView = View.extend({
 	},
 
 
-	renderBusinessHours: function() {
-		this.timeGrid.renderBusinessHours();
-
-		if (this.dayGrid) {
-			this.dayGrid.renderBusinessHours();
-		}
-	},
-
-
 	// Builds the HTML skeleton for the view.
 	// The day-grid and time-grid components will render inside containers defined by this HTML.
 	renderSkeletonHtml: function() {
@@ -157,6 +148,47 @@ var AgendaView = FC.AgendaView = View.extend({
 	},
 
 
+	/* Business Hours
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	renderBusinessHours: function() {
+		this.timeGrid.renderBusinessHours();
+
+		if (this.dayGrid) {
+			this.dayGrid.renderBusinessHours();
+		}
+	},
+
+
+	unrenderBusinessHours: function() {
+		this.timeGrid.unrenderBusinessHours();
+
+		if (this.dayGrid) {
+			this.dayGrid.unrenderBusinessHours();
+		}
+	},
+
+
+	/* Now Indicator
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	getNowIndicatorUnit: function() {
+		return this.timeGrid.getNowIndicatorUnit();
+	},
+
+
+	renderNowIndicator: function(date) {
+		this.timeGrid.renderNowIndicator(date);
+	},
+
+
+	unrenderNowIndicator: function() {
+		this.timeGrid.unrenderNowIndicator();
+	},
+
+
 	/* Dimensions
 	------------------------------------------------------------------------------------------------------------------*/
 
@@ -392,6 +424,7 @@ var AgendaView = FC.AgendaView = View.extend({
 
 
 // Methods that will customize the rendering behavior of the AgendaView's timeGrid
+// TODO: move into TimeGrid
 var agendaTimeGridMethods = {
 
 

+ 56 - 10
src/agenda/agenda.css

@@ -66,27 +66,46 @@
 	z-index: 2;
 }
 
-.fc-time-grid .fc-bgevent-skeleton,
+.fc-time-grid .fc-content-col {
+	position: relative; /* because now-indicator lives directly inside */
+}
+
 .fc-time-grid .fc-content-skeleton {
 	position: absolute;
+	z-index: 3;
 	top: 0;
 	left: 0;
 	right: 0;
 }
 
-.fc-time-grid .fc-bgevent-skeleton {
+/* divs within a cell within the fc-content-skeleton */
+
+.fc-time-grid .fc-business-container {
+	position: relative;
+	z-index: 1;
+}
+
+.fc-time-grid .fc-bgevent-container {
+	position: relative;
+	z-index: 2;
+}
+
+.fc-time-grid .fc-highlight-container {
+	position: relative;
 	z-index: 3;
 }
 
-.fc-time-grid .fc-highlight-skeleton {
+.fc-time-grid .fc-event-container {
+	position: relative;
 	z-index: 4;
 }
 
-.fc-time-grid .fc-content-skeleton {
+.fc-time-grid .fc-now-indicator-line {
 	z-index: 5;
 }
 
-.fc-time-grid .fc-helper-skeleton {
+.fc-time-grid .fc-helper-container { /* also is fc-event-container */
+	position: relative;
 	z-index: 6;
 }
 
@@ -126,11 +145,6 @@
 /* TimeGrid Event Containment
 --------------------------------------------------------------------------------------------------*/
 
-.fc-time-grid .fc-event-container, /* a div within a cell within the fc-content-skeleton */
-.fc-time-grid .fc-bgevent-container { /* a div within a cell within the fc-bgevent-skeleton */
-	position: relative;
-}
-
 .fc-ltr .fc-time-grid .fc-event-container { /* space on the sides of events for LTR (default) */
 	margin: 0 2.5% 0 2px;
 }
@@ -245,3 +259,35 @@ be a descendant of the grid when it is being dragged.
 .fc-time-grid-event .fc-resizer:after {
 	content: "=";
 }
+
+
+/* Now Indicator
+--------------------------------------------------------------------------------------------------*/
+
+.fc-time-grid .fc-now-indicator-line {
+	border-top-width: 1px;
+	left: 0;
+	right: 0;
+}
+
+/* arrow on axis */
+
+.fc-time-grid .fc-now-indicator-arrow {
+	margin-top: -5px; /* vertically center on top coordinate */
+}
+
+.fc-ltr .fc-time-grid .fc-now-indicator-arrow {
+	left: 0;
+	/* triangle pointing right... */
+	border-width: 5px 0 5px 6px;
+	border-top-color: transparent;
+	border-bottom-color: transparent;
+}
+
+.fc-rtl .fc-time-grid .fc-now-indicator-arrow {
+	right: 0;
+	/* triangle pointing left... */
+	border-width: 5px 6px 5px 0;
+	border-top-color: transparent;
+	border-bottom-color: transparent;
+}

+ 75 - 42
src/common/Grid.events.js

@@ -8,7 +8,7 @@ Grid.mixin({
 	isDraggingSeg: false, // is a segment being dragged? boolean
 	isResizingSeg: false, // is a segment being resized? boolean
 	isDraggingExternal: false, // jqui-dragging an external element? boolean
-	segs: null, // the event segments currently rendered in the grid
+	segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs`
 
 
 	// Renders the given events onto the grid
@@ -297,7 +297,7 @@ Grid.mixin({
 					event
 				);
 
-				if (dropLocation &&!calendar.isEventSpanAllowed(_this.eventToSpan(dropLocation), event)) {
+				if (dropLocation && !calendar.isEventSpanAllowed(_this.eventToSpan(dropLocation), event)) {
 					disableCursor();
 					dropLocation = null;
 				}
@@ -360,6 +360,7 @@ Grid.mixin({
 	// Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay
 	// values for the event. Subclasses may override and set additional properties to be used by renderDrag.
 	// A falsy returned value indicates an invalid drop.
+	// DOES NOT consider overlap/constraint.
 	computeEventDrop: function(startSpan, endSpan, event) {
 		var calendar = this.view.calendar;
 		var dragStart = startSpan.start;
@@ -449,6 +450,7 @@ Grid.mixin({
 	// Called when a jQuery UI drag starts and it needs to be monitored for dropping
 	listenToExternalDrag: function(el, ev, ui) {
 		var _this = this;
+		var calendar = this.view.calendar;
 		var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create
 		var dropLocation; // a null value signals an unsuccessful drag
 
@@ -462,22 +464,27 @@ Grid.mixin({
 					hit.component.getHitSpan(hit), // since we are querying the parent view, might not belong to this grid
 					meta
 				);
+
+				if ( // invalid hit?
+					dropLocation &&
+					!calendar.isExternalSpanAllowed(_this.eventToSpan(dropLocation), dropLocation, meta.eventProps)
+				) {
+					disableCursor();
+					dropLocation = null;
+				}
+
 				if (dropLocation) {
 					_this.renderDrag(dropLocation); // called without a seg parameter
 				}
-				else { // invalid hit
-					disableCursor();
-				}
 			},
 			hitOut: function() {
 				dropLocation = null; // signal unsuccessful
-				_this.unrenderDrag();
+			},
+			hitDone: function() { // Called after a hitOut OR before a dragStop
 				enableCursor();
+				_this.unrenderDrag();
 			},
 			dragStop: function() {
-				_this.unrenderDrag();
-				enableCursor();
-
 				if (dropLocation) { // element was dropped on a valid hit
 					_this.view.reportExternalDrop(meta, dropLocation, el, ev, ui);
 				}
@@ -494,6 +501,7 @@ Grid.mixin({
 	// Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object),
 	// returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null.
 	// Returning a null value signals an invalid drop hit.
+	// DOES NOT consider overlap/constraint.
 	computeExternalDrop: function(span, meta) {
 		var calendar = this.view.calendar;
 		var dropLocation = {
@@ -510,10 +518,6 @@ Grid.mixin({
 			dropLocation.end = dropLocation.start.clone().add(meta.duration);
 		}
 
-		if (!calendar.isExternalSpanAllowed(this.eventToSpan(dropLocation), dropLocation, meta.eventProps)) {
-			return null;
-		}
-
 		return dropLocation;
 	},
 
@@ -634,7 +638,8 @@ Grid.mixin({
 
 
 	// Returns new zoned date information for an event segment being resized from its start OR end
-	// `type` is either 'start' or 'end'
+	// `type` is either 'start' or 'end'.
+	// DOES NOT consider overlap/constraint.
 	computeEventResize: function(type, startSpan, endSpan, event) {
 		var calendar = this.view.calendar;
 		var delta = this.diffDates(endSpan[type], startSpan[type]);
@@ -781,20 +786,27 @@ Grid.mixin({
 
 
 	// Generates an array of segments for the given single event
+	// Can accept an event "location" as well (which only has start/end and no allDay)
 	eventToSegs: function(event) {
 		return this.eventsToSegs([ event ]);
 	},
 
 
-	// Generates a single span (always unzoned) by using the given event's dates.
-	// Does not do any inverting for inverse-background events.
 	eventToSpan: function(event) {
+		return this.eventToSpans(event)[0];
+	},
+
+
+	// Generates spans (always unzoned) for the given event.
+	// Does not do any inverting for inverse-background events.
+	// Can accept an event "location" as well (which only has start/end and no allDay)
+	eventToSpans: function(event) {
 		var range = this.eventToRange(event);
-		this.transformEventSpan(range, event); // convert it to a span, in-place
-		return range;
+		return this.eventRangeToSpans(range, event);
 	},
 
 
+
 	// Converts an array of event objects into an array of event segment objects.
 	// A custom `segSliceFunc` may be given for arbitrarily slicing up events.
 	// Doesn't guarantee an order for the resulting array.
@@ -816,13 +828,15 @@ Grid.mixin({
 				ranges = _this.invertRanges(ranges);
 
 				for (i = 0; i < ranges.length; i++) {
-					_this.generateEventSegs(ranges[i], events[0], segSliceFunc, segs);
+					segs.push.apply(segs, // append to
+						_this.eventRangeToSegs(ranges[i], events[0], segSliceFunc));
 				}
 			}
 			// normal event ranges
 			else {
 				for (i = 0; i < ranges.length; i++) {
-					_this.generateEventSegs(ranges[i], events[i], segSliceFunc, segs);
+					segs.push.apply(segs, // append to
+						_this.eventRangeToSegs(ranges[i], events[i], segSliceFunc));
 				}
 			}
 		});
@@ -832,44 +846,62 @@ Grid.mixin({
 
 
 	// Generates the unzoned start/end dates an event appears to occupy
+	// Can accept an event "location" as well (which only has start/end and no allDay)
 	eventToRange: function(event) {
 		return {
 			start: event.start.clone().stripZone(),
-			end: this.view.calendar.getEventEnd(event).stripZone()
+			end: (
+				event.end ?
+					event.end.clone() :
+					// derive the end from the start and allDay. compute allDay if necessary
+					this.view.calendar.getDefaultEventEnd(
+						event.allDay != null ?
+							event.allDay :
+							!event.start.hasTime(),
+						event.start
+					)
+			).stripZone()
 		};
 	},
 
 
-	// Given an event's span (unzoned start/end and other misc data), and the event itself,
-	// slice into segments (using the segSliceFunc function if specified) and append to the `out` array.
-	// SIDE EFFECT: will mutate the given `range`.
-	generateEventSegs: function(range, event, segSliceFunc, out) {
-		var segs;
+	// Given an event's range (unzoned start/end), and the event itself,
+	// slice into segments (using the segSliceFunc function if specified)
+	eventRangeToSegs: function(range, event, segSliceFunc) {
+		var spans = this.eventRangeToSpans(range, event);
+		var segs = [];
 		var i;
 
-		this.transformEventSpan(range, event); // converts the range to a span
-
-		segs = segSliceFunc ? segSliceFunc(range) : this.spanToSegs(range);
-
-		for (i = 0; i < segs.length; i++) {
-			this.transformEventSeg(segs[i], range, event);
-			out.push(segs[i]);
+		for (i = 0; i < spans.length; i++) {
+			segs.push.apply(segs, // append to
+				this.eventSpanToSegs(spans[i], event, segSliceFunc));
 		}
+
+		return segs;
 	},
 
 
-	// Given a range (unzoned start/end) that is about to become a span,
-	// attach any event-derived properties to it.
-	transformEventSpan: function(range, event) {
-		// subclasses can implement
+	// Given an event's unzoned date range, return an array of "span" objects.
+	// Subclasses can override.
+	eventRangeToSpans: function(range, event) {
+		return [ $.extend({}, range) ]; // copy into a single-item array
 	},
 
 
-	// Given a segment object, attach any extra properties, based off of its source span and event.
-	transformEventSeg: function(seg, span, event) {
-		seg.event = event;
-		seg.eventStartMS = +span.start; // TODO: not the best name after making spans unzoned
-		seg.eventDurationMS = span.end - span.start;
+	// Given an event's span (unzoned start/end and other misc data), and the event itself,
+	// slices into segments and attaches event-derived properties to them.
+	eventSpanToSegs: function(span, event, segSliceFunc) {
+		var segs = segSliceFunc ? segSliceFunc(span) : this.spanToSegs(span);
+		var i, seg;
+
+		for (i = 0; i < segs.length; i++) {
+			seg = segs[i];
+			seg.event = event;
+			seg.eventStartMS = +span.start; // TODO: not the best name after making spans unzoned
+			seg.eventDurationMS = span.end - span.start;
+		}
+
+		return segs;
 	},
 
 
@@ -936,6 +968,7 @@ function isBgEvent(event) { // returns true if background OR inverse-background
 	var rendering = getEventRendering(event);
 	return rendering === 'background' || rendering === 'inverse-background';
 }
+FC.isBgEvent = isBgEvent; // export
 
 
 function isInverseBgEvent(event) {

+ 32 - 2
src/common/Grid.js

@@ -408,10 +408,40 @@ var Grid = FC.Grid = Class.extend({
 	},
 
 
-	/* Fill System (highlight, background events, business hours)
+	/* Business Hours
 	------------------------------------------------------------------------------------------------------------------*/
 
 
+	renderBusinessHours: function() {
+	},
+
+
+	unrenderBusinessHours: function() {
+	},
+
+
+	/* Now Indicator
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	getNowIndicatorUnit: function() {
+	},
+
+
+	renderNowIndicator: function(date) {
+	},
+
+
+	unrenderNowIndicator: function() {
+	},
+
+
+	/* Fill System (highlight, background events, business hours)
+	--------------------------------------------------------------------------------------------------------------------
+	TODO: remove this system. like we did in TimeGrid
+	*/
+
+
 	// Renders a set of rectangles over the given segments of time.
 	// MUST RETURN a subset of segs, the segs that were actually rendered.
 	// Responsible for populating this.elsByFill. TODO: better API for expressing this requirement
@@ -504,7 +534,7 @@ var Grid = FC.Grid = Class.extend({
 	// Computes HTML classNames for a single-day element
 	getDayClasses: function(date) {
 		var view = this.view;
-		var today = view.calendar.getNow().stripTime();
+		var today = view.calendar.getNow();
 		var classes = [ 'fc-' + dayIDs[date.day()] ];
 
 		if (

+ 338 - 148
src/common/TimeGrid.events.js

@@ -1,169 +1,241 @@
 
-/* Event-rendering methods for the TimeGrid class
+/* Methods for rendering SEGMENTS, pieces of content that live on the view
+ ( this file is no longer just for events )
 ----------------------------------------------------------------------------------------------------------------------*/
 
 TimeGrid.mixin({
 
-	eventSkeletonEl: null, // has cells with event-containers, which contain absolutely positioned event elements
+	colContainerEls: null, // containers for each column
 
+	// inner-containers for each column where different types of segs live
+	fgContainerEls: null,
+	bgContainerEls: null,
+	helperContainerEls: null,
+	highlightContainerEls: null,
+	businessContainerEls: null,
 
-	// Renders the given foreground event segments onto the grid
-	renderFgSegs: function(segs) {
-		segs = this.renderFgSegEls(segs); // returns a subset of the segs. segs that were actually rendered
+	// arrays of different types of displayed segments
+	fgSegs: null,
+	bgSegs: null,
+	helperSegs: null,
+	highlightSegs: null,
+	businessSegs: null,
+
+
+	// Renders the DOM that the view's content will live in
+	renderContentSkeleton: function() {
+		var cellHtml = '';
+		var i;
+		var skeletonEl;
 
-		this.el.append(
-			this.eventSkeletonEl = $('<div class="fc-content-skeleton"/>')
-				.append(this.renderSegTable(segs))
+		for (i = 0; i < this.colCnt; i++) {
+			cellHtml +=
+				'<td>' +
+					'<div class="fc-content-col">' +
+						'<div class="fc-event-container fc-helper-container"></div>' +
+						'<div class="fc-event-container"></div>' +
+						'<div class="fc-highlight-container"></div>' +
+						'<div class="fc-bgevent-container"></div>' +
+						'<div class="fc-business-container"></div>' +
+					'</div>' +
+				'</td>';
+		}
+
+		skeletonEl = $(
+			'<div class="fc-content-skeleton">' +
+				'<table>' +
+					'<tr>' + cellHtml + '</tr>' +
+				'</table>' +
+			'</div>'
 		);
 
-		return segs; // return only the segs that were actually rendered
+		this.colContainerEls = skeletonEl.find('.fc-content-col');
+		this.helperContainerEls = skeletonEl.find('.fc-helper-container');
+		this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)');
+		this.bgContainerEls = skeletonEl.find('.fc-bgevent-container');
+		this.highlightContainerEls = skeletonEl.find('.fc-highlight-container');
+		this.businessContainerEls = skeletonEl.find('.fc-business-container');
+
+		this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level
+		this.el.append(skeletonEl);
 	},
 
 
-	// Unrenders all currently rendered foreground event segments
-	unrenderFgSegs: function(segs) {
-		if (this.eventSkeletonEl) {
-			this.eventSkeletonEl.remove();
-			this.eventSkeletonEl = null;
-		}
+	/* Foreground Events
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	renderFgSegs: function(segs) {
+		segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls);
+		this.fgSegs = segs;
+		return segs; // needed for Grid::renderEvents
 	},
 
 
-	// Renders and returns the <table> portion of the event-skeleton.
-	// Returns an object with properties 'tbodyEl' and 'segs'.
-	renderSegTable: function(segs) {
-		var tableEl = $('<table><tr/></table>');
-		var trEl = tableEl.find('tr');
-		var segCols;
+	unrenderFgSegs: function() {
+		this.unrenderNamedSegs('fgSegs');
+	},
+
+
+	/* Foreground Helper Events
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	renderHelperSegs: function(segs, sourceSeg) {
 		var i, seg;
-		var col, colSegs;
-		var containerEl;
+		var sourceEl;
 
-		segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg
+		segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls);
 
-		this.computeSegVerticals(segs); // compute and assign top/bottom
+		// Try to make the segment that is in the same row as sourceSeg look the same
+		for (i = 0; i < segs.length; i++) {
+			seg = segs[i];
+			if (sourceSeg && sourceSeg.col === seg.col) {
+				sourceEl = sourceSeg.el;
+				seg.el.css({
+					left: sourceEl.css('left'),
+					right: sourceEl.css('right'),
+					'margin-left': sourceEl.css('margin-left'),
+					'margin-right': sourceEl.css('margin-right')
+				});
+			}
+		}
 
-		for (col = 0; col < segCols.length; col++) { // iterate each column grouping
-			colSegs = segCols[col];
-			this.placeSlotSegs(colSegs); // compute horizontal coordinates, z-index's, and reorder the array
+		this.helperSegs = segs;
+	},
 
-			containerEl = $('<div class="fc-event-container"/>');
 
-			// assign positioning CSS and insert into container
-			for (i = 0; i < colSegs.length; i++) {
-				seg = colSegs[i];
-				seg.el.css(this.generateSegPositionCss(seg));
+	unrenderHelperSegs: function() {
+		this.unrenderNamedSegs('helperSegs');
+	},
 
-				// if the height is short, add a className for alternate styling
-				if (seg.bottom - seg.top < 30) {
-					seg.el.addClass('fc-short');
-				}
 
-				containerEl.append(seg.el);
-			}
+	/* Background Events
+	------------------------------------------------------------------------------------------------------------------*/
 
-			trEl.append($('<td/>').append(containerEl));
-		}
 
-		this.bookendCells(trEl);
+	renderBgSegs: function(segs) {
+		segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system
+		this.updateSegVerticals(segs);
+		this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls);
+		this.bgSegs = segs;
+		return segs; // needed for Grid::renderEvents
+	},
+
 
-		return tableEl;
+	unrenderBgSegs: function() {
+		this.unrenderNamedSegs('bgSegs');
 	},
 
 
-	// Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
-	// NOTE: Also reorders the given array by date!
-	placeSlotSegs: function(segs) {
-		var levels;
-		var level0;
-		var i;
+	/* Highlight
+	------------------------------------------------------------------------------------------------------------------*/
 
-		this.sortEventSegs(segs); // order by certain criteria
-		levels = buildSlotSegLevels(segs);
-		computeForwardSlotSegs(levels);
 
-		if ((level0 = levels[0])) {
+	renderHighlightSegs: function(segs) {
+		segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system
+		this.updateSegVerticals(segs);
+		this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls);
+		this.highlightSegs = segs;
+	},
 
-			for (i = 0; i < level0.length; i++) {
-				computeSlotSegPressures(level0[i]);
-			}
 
-			for (i = 0; i < level0.length; i++) {
-				this.computeSlotSegCoords(level0[i], 0, 0);
-			}
-		}
+	unrenderHighlightSegs: function() {
+		this.unrenderNamedSegs('highlightSegs');
 	},
 
 
-	// Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
-	// from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
-	// seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
-	//
-	// The segment might be part of a "series", which means consecutive segments with the same pressure
-	// who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
-	// segments behind this one in the current series, and `seriesBackwardCoord` is the starting
-	// coordinate of the first segment in the series.
-	computeSlotSegCoords: function(seg, seriesBackwardPressure, seriesBackwardCoord) {
-		var forwardSegs = seg.forwardSegs;
+	/* Business Hours
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	renderBusinessSegs: function(segs) {
+		segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system
+		this.updateSegVerticals(segs);
+		this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls);
+		this.businessSegs = segs;
+	},
+
+
+	unrenderBusinessSegs: function() {
+		this.unrenderNamedSegs('businessSegs');
+	},
+
+
+	/* Seg Rendering Utils
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	// Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col
+	groupSegsByCol: function(segs) {
+		var segsByCol = [];
 		var i;
 
-		if (seg.forwardCoord === undefined) { // not already computed
+		for (i = 0; i < this.colCnt; i++) {
+			segsByCol.push([]);
+		}
 
-			if (!forwardSegs.length) {
+		for (i = 0; i < segs.length; i++) {
+			segsByCol[segs[i].col].push(segs[i]);
+		}
 
-				// if there are no forward segments, this segment should butt up against the edge
-				seg.forwardCoord = 1;
-			}
-			else {
+		return segsByCol;
+	},
 
-				// sort highest pressure first
-				this.sortForwardSlotSegs(forwardSegs);
 
-				// this segment's forwardCoord will be calculated from the backwardCoord of the
-				// highest-pressure forward segment.
-				this.computeSlotSegCoords(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord);
-				seg.forwardCoord = forwardSegs[0].backwardCoord;
-			}
+	// Given segments grouped by column, insert the segments' elements into a parallel array of container
+	// elements, each living within a column.
+	attachSegsByCol: function(segsByCol, containerEls) {
+		var col;
+		var segs;
+		var i;
 
-			// calculate the backwardCoord from the forwardCoord. consider the series
-			seg.backwardCoord = seg.forwardCoord -
-				(seg.forwardCoord - seriesBackwardCoord) / // available width for series
-				(seriesBackwardPressure + 1); // # of segments in the series
+		for (col = 0; col < this.colCnt; col++) { // iterate each column grouping
+			segs = segsByCol[col];
 
-			// use this segment's coordinates to computed the coordinates of the less-pressurized
-			// forward segments
-			for (i=0; i<forwardSegs.length; i++) {
-				this.computeSlotSegCoords(forwardSegs[i], 0, seg.forwardCoord);
+			for (i = 0; i < segs.length; i++) {
+				containerEls.eq(col).append(segs[i].el);
 			}
 		}
 	},
 
 
-	// Refreshes the CSS top/bottom coordinates for each segment element. Probably after a window resize/zoom.
-	// Repositions business hours segs too, so not just for events. Maybe shouldn't be here.
-	updateSegVerticals: function() {
-		var allSegs = (this.segs || []).concat(this.businessHourSegs || []);
+	// Given the name of a property of `this` object, assumed to be an array of segments,
+	// loops through each segment and removes from DOM. Will null-out the property afterwards.
+	unrenderNamedSegs: function(propName) {
+		var segs = this[propName];
 		var i;
 
-		this.computeSegVerticals(allSegs);
-
-		for (i = 0; i < allSegs.length; i++) {
-			allSegs[i].el.css(
-				this.generateSegVerticalCss(allSegs[i])
-			);
+		if (segs) {
+			for (i = 0; i < segs.length; i++) {
+				segs[i].el.remove();
+			}
+			this[propName] = null;
 		}
 	},
 
 
-	// For each segment in an array, computes and assigns its top and bottom properties
-	computeSegVerticals: function(segs) {
-		var i, seg;
 
-		for (i = 0; i < segs.length; i++) {
-			seg = segs[i];
-			seg.top = this.computeDateTop(seg.start, seg.start);
-			seg.bottom = this.computeDateTop(seg.end, seg.start);
+	/* Foreground Event Rendering Utils
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	// Given an array of foreground segments, render a DOM element for each, computes position,
+	// and attaches to the column inner-container elements.
+	renderFgSegsIntoContainers: function(segs, containerEls) {
+		var segsByCol;
+		var col;
+
+		segs = this.renderFgSegEls(segs); // will call fgSegHtml
+		segsByCol = this.groupSegsByCol(segs);
+
+		for (col = 0; col < this.colCnt; col++) {
+			this.updateFgSegCoords(segsByCol[col]);
 		}
+
+		this.attachSegsByCol(segsByCol, containerEls);
+
+		return segs;
 	},
 
 
@@ -240,40 +312,39 @@ TimeGrid.mixin({
 	},
 
 
-	// Generates an object with CSS properties/values that should be applied to an event segment element.
-	// Contains important positioning-related properties that should be applied to any event element, customized or not.
-	generateSegPositionCss: function(seg) {
-		var shouldOverlap = this.view.opt('slotEventOverlap');
-		var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point
-		var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point
-		var props = this.generateSegVerticalCss(seg); // get top/bottom first
-		var left; // amount of space from left edge, a fraction of the total width
-		var right; // amount of space from right edge, a fraction of the total width
+	/* Seg Position Utils
+	------------------------------------------------------------------------------------------------------------------*/
 
-		if (shouldOverlap) {
-			// double the width, but don't go beyond the maximum forward coordinate (1.0)
-			forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2);
-		}
 
-		if (this.isRTL) {
-			left = 1 - forwardCoord;
-			right = backwardCoord;
-		}
-		else {
-			left = backwardCoord;
-			right = 1 - forwardCoord;
-		}
+	// Refreshes the CSS top/bottom coordinates for each segment element.
+	// Works when called after initial render, after a window resize/zoom for example.
+	updateSegVerticals: function(segs) {
+		this.computeSegVerticals(segs);
+		this.assignSegVerticals(segs);
+	},
 
-		props.zIndex = seg.level + 1; // convert from 0-base to 1-based
-		props.left = left * 100 + '%';
-		props.right = right * 100 + '%';
 
-		if (shouldOverlap && seg.forwardPressure) {
-			// add padding to the edge so that forward stacked events don't cover the resizer's icon
-			props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width
+	// For each segment in an array, computes and assigns its top and bottom properties
+	computeSegVerticals: function(segs) {
+		var i, seg;
+
+		for (i = 0; i < segs.length; i++) {
+			seg = segs[i];
+			seg.top = this.computeDateTop(seg.start, seg.start);
+			seg.bottom = this.computeDateTop(seg.end, seg.start);
 		}
+	},
 
-		return props;
+
+	// Given segments that already have their top/bottom properties computed, applies those values to
+	// the segments' elements.
+	assignSegVerticals: function(segs) {
+		var i, seg;
+
+		for (i = 0; i < segs.length; i++) {
+			seg = segs[i];
+			seg.el.css(this.generateSegVerticalCss(seg));
+		}
 	},
 
 
@@ -286,36 +357,155 @@ TimeGrid.mixin({
 	},
 
 
-	// Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col
-	groupSegCols: function(segs) {
-		var segCols = [];
+	/* Foreground Event Positioning Utils
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	// Given segments that are assumed to all live in the *same column*,
+	// compute their verical/horizontal coordinates and assign to their elements.
+	updateFgSegCoords: function(segs) {
+		this.computeSegVerticals(segs); // horizontals relies on this
+		this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array
+		this.assignSegVerticals(segs);
+		this.assignFgSegHorizontals(segs);
+	},
+
+
+	// Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
+	// NOTE: Also reorders the given array by date!
+	computeFgSegHorizontals: function(segs) {
+		var levels;
+		var level0;
 		var i;
 
-		for (i = 0; i < this.colCnt; i++) {
-			segCols.push([]);
-		}
+		this.sortEventSegs(segs); // order by certain criteria
+		levels = buildSlotSegLevels(segs);
+		computeForwardSlotSegs(levels);
 
-		for (i = 0; i < segs.length; i++) {
-			segCols[segs[i].col].push(segs[i]);
+		if ((level0 = levels[0])) {
+
+			for (i = 0; i < level0.length; i++) {
+				computeSlotSegPressures(level0[i]);
+			}
+
+			for (i = 0; i < level0.length; i++) {
+				this.computeFgSegForwardBack(level0[i], 0, 0);
+			}
 		}
+	},
+
+
+	// Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
+	// from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
+	// seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
+	//
+	// The segment might be part of a "series", which means consecutive segments with the same pressure
+	// who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
+	// segments behind this one in the current series, and `seriesBackwardCoord` is the starting
+	// coordinate of the first segment in the series.
+	computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) {
+		var forwardSegs = seg.forwardSegs;
+		var i;
+
+		if (seg.forwardCoord === undefined) { // not already computed
+
+			if (!forwardSegs.length) {
+
+				// if there are no forward segments, this segment should butt up against the edge
+				seg.forwardCoord = 1;
+			}
+			else {
 
-		return segCols;
+				// sort highest pressure first
+				this.sortForwardSegs(forwardSegs);
+
+				// this segment's forwardCoord will be calculated from the backwardCoord of the
+				// highest-pressure forward segment.
+				this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord);
+				seg.forwardCoord = forwardSegs[0].backwardCoord;
+			}
+
+			// calculate the backwardCoord from the forwardCoord. consider the series
+			seg.backwardCoord = seg.forwardCoord -
+				(seg.forwardCoord - seriesBackwardCoord) / // available width for series
+				(seriesBackwardPressure + 1); // # of segments in the series
+
+			// use this segment's coordinates to computed the coordinates of the less-pressurized
+			// forward segments
+			for (i=0; i<forwardSegs.length; i++) {
+				this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord);
+			}
+		}
 	},
 
 
-	sortForwardSlotSegs: function(forwardSegs) {
-		forwardSegs.sort(proxy(this, 'compareForwardSlotSegs'));
+	sortForwardSegs: function(forwardSegs) {
+		forwardSegs.sort(proxy(this, 'compareForwardSegs'));
 	},
 
 
 	// A cmp function for determining which forward segment to rely on more when computing coordinates.
-	compareForwardSlotSegs: function(seg1, seg2) {
+	compareForwardSegs: function(seg1, seg2) {
 		// put higher-pressure first
 		return seg2.forwardPressure - seg1.forwardPressure ||
 			// put segments that are closer to initial edge first (and favor ones with no coords yet)
 			(seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) ||
 			// do normal sorting...
 			this.compareEventSegs(seg1, seg2);
+	},
+
+
+	// Given foreground event segments that have already had their position coordinates computed,
+	// assigns position-related CSS values to their elements.
+	assignFgSegHorizontals: function(segs) {
+		var i, seg;
+
+		for (i = 0; i < segs.length; i++) {
+			seg = segs[i];
+			seg.el.css(this.generateFgSegHorizontalCss(seg));
+
+			// if the height is short, add a className for alternate styling
+			if (seg.bottom - seg.top < 30) {
+				seg.el.addClass('fc-short');
+			}
+		}
+	},
+
+
+	// Generates an object with CSS properties/values that should be applied to an event segment element.
+	// Contains important positioning-related properties that should be applied to any event element, customized or not.
+	generateFgSegHorizontalCss: function(seg) {
+		var shouldOverlap = this.view.opt('slotEventOverlap');
+		var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point
+		var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point
+		var props = this.generateSegVerticalCss(seg); // get top/bottom first
+		var left; // amount of space from left edge, a fraction of the total width
+		var right; // amount of space from right edge, a fraction of the total width
+
+		if (shouldOverlap) {
+			// double the width, but don't go beyond the maximum forward coordinate (1.0)
+			forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2);
+		}
+
+		if (this.isRTL) {
+			left = 1 - forwardCoord;
+			right = backwardCoord;
+		}
+		else {
+			left = backwardCoord;
+			right = 1 - forwardCoord;
+		}
+
+		props.zIndex = seg.level + 1; // convert from 0-base to 1-based
+		props.left = left * 100 + '%';
+		props.right = right * 100 + '%';
+
+		if (shouldOverlap && seg.forwardPressure) {
+			// add padding to the edge so that forward stacked events don't cover the resizer's icon
+			props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width
+		}
+
+		return props;
 	}
 
 });

+ 73 - 86
src/common/TimeGrid.js

@@ -15,13 +15,11 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 
 	colEls: null, // cells elements in the day-row background
 	slatEls: null, // elements running horizontally across all columns
-	helperEl: null, // cell skeleton element for rendering the mock event "helper"
+	nowIndicatorEls: null,
 
 	colCoordCache: null,
 	slatCoordCache: null,
 
-	businessHourSegs: null,
-
 
 	constructor: function() {
 		Grid.apply(this, arguments); // call the super-constructor
@@ -45,12 +43,8 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 			els: this.slatEls,
 			isVertical: true
 		});
-	},
 
-
-	renderBusinessHours: function() {
-		var events = this.view.calendar.getBusinessHoursEvents();
-		this.businessHourSegs = this.renderFill('businessHours', this.eventsToSegs(events), 'bgevent');
+		this.renderContentSkeleton();
 	},
 
 
@@ -311,7 +305,9 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 		this.slatCoordCache.build();
 
 		if (isResize) {
-			this.updateSegVerticals();
+			this.updateSegVerticals(
+				[].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || [])
+			);
 		}
 	},
 
@@ -365,7 +361,10 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 
 		if (seg) { // if there is event information for this drag, render a helper event
 			this.renderEventLocationHelper(eventLocation, seg);
-			this.applyDragOpacity(this.helperEl);
+
+			for (var i = 0; i < this.helperSegs.length; i++) {
+				this.applyDragOpacity(this.helperSegs[i].el);
+			}
 
 			return true; // signal that a helper has been rendered
 		}
@@ -405,39 +404,72 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 
 	// Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag)
 	renderHelper: function(event, sourceSeg) {
-		var segs = this.eventToSegs(event);
-		var tableEl;
-		var i, seg;
-		var sourceEl;
+		this.renderHelperSegs(this.eventToSegs(event), sourceSeg);
+	},
+
 
-		segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered
-		tableEl = this.renderSegTable(segs);
+	// Unrenders any mock helper event
+	unrenderHelper: function() {
+		this.unrenderHelperSegs();
+	},
+
+
+	/* Business Hours
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	renderBusinessHours: function() {
+		var events = this.view.calendar.getBusinessHoursEvents();
+		var segs = this.eventsToSegs(events);
+
+		this.renderBusinessSegs(segs);
+	},
+
+
+	unrenderBusinessHours: function() {
+		this.unrenderBusinessSegs();
+	},
 
-		// Try to make the segment that is in the same row as sourceSeg look the same
+
+	/* Now Indicator
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	getNowIndicatorUnit: function() {
+		return 'minute'; // will refresh on the minute
+	},
+
+
+	renderNowIndicator: function(date) {
+		// seg system might be overkill, but it handles scenario where line needs to be rendered
+		//  more than once because of columns with the same date (resources columns for example)
+		var segs = this.spanToSegs({ start: date, end: date });
+		var top = this.computeDateTop(date, date);
+		var nodes = [];
+		var i;
+
+		// render lines within the columns
 		for (i = 0; i < segs.length; i++) {
-			seg = segs[i];
-			if (sourceSeg && sourceSeg.col === seg.col) {
-				sourceEl = sourceSeg.el;
-				seg.el.css({
-					left: sourceEl.css('left'),
-					right: sourceEl.css('right'),
-					'margin-left': sourceEl.css('margin-left'),
-					'margin-right': sourceEl.css('margin-right')
-				});
-			}
+			nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>')
+				.css('top', top)
+				.appendTo(this.colContainerEls.eq(segs[i].col))[0]);
+		}
+
+		// render an arrow over the axis
+		if (segs.length > 0) { // is the current time in view?
+			nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>')
+				.css('top', top)
+				.appendTo(this.el.find('.fc-content-skeleton'))[0]);
 		}
 
-		this.helperEl = $('<div class="fc-helper-skeleton"/>')
-			.append(tableEl)
-				.appendTo(this.el);
+		this.nowIndicatorEls = $(nodes);
 	},
 
 
-	// Unrenders any mock helper event
-	unrenderHelper: function() {
-		if (this.helperEl) {
-			this.helperEl.remove();
-			this.helperEl = null;
+	unrenderNowIndicator: function() {
+		if (this.nowIndicatorEls) {
+			this.nowIndicatorEls.remove();
+			this.nowIndicatorEls = null;
 		}
 	},
 
@@ -466,62 +498,17 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 	},
 
 
-	/* Fill System (highlight, background events, business hours)
+	/* Highlight
 	------------------------------------------------------------------------------------------------------------------*/
 
 
-	// Renders a set of rectangles over the given time segments.
-	// Only returns segments that successfully rendered.
-	renderFill: function(type, segs, className) {
-		var segCols;
-		var skeletonEl;
-		var trEl;
-		var col, colSegs;
-		var tdEl;
-		var containerEl;
-		var dayDate;
-		var i, seg;
-
-		if (segs.length) {
-
-			segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs
-			segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg
-
-			className = className || type.toLowerCase();
-			skeletonEl = $(
-				'<div class="fc-' + className + '-skeleton">' +
-					'<table><tr/></table>' +
-				'</div>'
-			);
-			trEl = skeletonEl.find('tr');
-
-			for (col = 0; col < segCols.length; col++) {
-				colSegs = segCols[col];
-				tdEl = $('<td/>').appendTo(trEl);
-
-				if (colSegs.length) {
-					containerEl = $('<div class="fc-' + className + '-container"/>').appendTo(tdEl);
-					dayDate = this.getCellDate(0, col); // row=0
-
-					for (i = 0; i < colSegs.length; i++) {
-						seg = colSegs[i];
-						containerEl.append(
-							seg.el.css({
-								top: this.computeDateTop(seg.start, dayDate),
-								bottom: -this.computeDateTop(seg.end, dayDate) // the y position of the bottom edge
-							})
-						);
-					}
-				}
-			}
-
-			this.bookendCells(trEl);
+	renderHighlight: function(span) {
+		this.renderHighlightSegs(this.spanToSegs(span));
+	},
 
-			this.el.append(skeletonEl);
-			this.elsByFill[type] = skeletonEl;
-		}
 
-		return segs;
+	unrenderHighlight: function() {
+		this.unrenderHighlightSegs();
 	}
 
 });

+ 113 - 13
src/common/View.js

@@ -48,6 +48,10 @@ var View = FC.View = Class.extend({
 	// document handlers, bound to `this` object
 	documentMousedownProxy: null, // TODO: doesn't work with touch
 
+	// for refresh timing of now indicator
+	nowIndicatorTimeoutID: null,
+	nowIndicatorIntervalID: null,
+
 
 	constructor: function(calendar, type, options, intervalDuration) {
 
@@ -326,7 +330,7 @@ var View = FC.View = Class.extend({
 			this.clearView();
 			this.displayView();
 			if (wasEventsRendered) { // only render and trigger handlers if events previously rendered
-				this.displayEvents();
+				this.displayEvents(this.calendar.getEventCache());
 			}
 		}
 	},
@@ -349,6 +353,10 @@ var View = FC.View = Class.extend({
 		this.renderDates();
 		this.updateSize();
 		this.renderBusinessHours(); // might need coordinates, so should go after updateSize()
+
+		if (this.opt('nowIndicator')) {
+			this.startNowIndicator();
+		}
 	},
 
 
@@ -356,6 +364,7 @@ var View = FC.View = Class.extend({
 	// Can be asynchronous and return a promise.
 	clearView: function() {
 		this.unselect();
+		this.stopNowIndicator();
 		this.triggerUnrender();
 		this.unrenderBusinessHours();
 		this.unrenderDates();
@@ -390,18 +399,6 @@ var View = FC.View = Class.extend({
 	},
 
 
-	// Renders business-hours onto the view. Assumes updateSize has already been called.
-	renderBusinessHours: function() {
-		// subclasses should implement
-	},
-
-
-	// Unrenders previously-rendered business-hours
-	unrenderBusinessHours: function() {
-		// subclasses should implement
-	},
-
-
 	// Signals that the view's content has been rendered
 	triggerRender: function() {
 		this.trigger('viewRender', this, this, this.el);
@@ -436,6 +433,102 @@ var View = FC.View = Class.extend({
 	},
 
 
+	/* Business Hours
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	// Renders business-hours onto the view. Assumes updateSize has already been called.
+	renderBusinessHours: function() {
+		// subclasses should implement
+	},
+
+
+	// Unrenders previously-rendered business-hours
+	unrenderBusinessHours: function() {
+		// subclasses should implement
+	},
+
+
+	/* Now Indicator
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	// Immediately render the current time indicator and begins re-rendering it at an interval,
+	// which is defined by this.getNowIndicatorUnit().
+	// TODO: somehow do this for the current whole day's background too
+	startNowIndicator: function() {
+		var _this = this;
+		var unit = this.getNowIndicatorUnit();
+		var initialNow; // result first getNow call
+		var initialNowQueried; // ms time of then getNow was called
+		var delay; // ms wait value
+
+		// rerenders the now indicator, computing the new current time from the amount of time that has passed
+		// since the initial getNow call.
+		function update() {
+			_this.unrenderNowIndicator();
+			_this.renderNowIndicator(
+				initialNow.clone().add(new Date() - initialNowQueried) // add ms
+			);
+		}
+
+		if (unit) {
+			initialNow = this.calendar.getNow();
+			initialNowQueried = +new Date();
+			this.renderNowIndicator(initialNow);
+
+			// wait until the beginning of the next interval
+			delay = initialNow.clone().startOf(unit).add(1, unit) - initialNow;
+			this.nowIndicatorTimeoutID = setTimeout(function() {
+				this.nowIndicatorTimeoutID = null;
+				update();
+				delay = +moment.duration(1, unit);
+				delay = Math.max(100, delay); // prevent too frequent
+				this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval
+			}, delay);
+		}
+	},
+
+
+	// Immediately unrenders the view's current time indicator and stops any re-rendering timers.
+	// Won't cause side effects if indicator isn't rendered.
+	stopNowIndicator: function() {
+		var cleared = false;
+
+		if (this.nowIndicatorTimeoutID) {
+			clearTimeout(this.nowIndicatorTimeoutID);
+			cleared = true;
+		}
+		if (this.nowIndicatorIntervalID) {
+			clearTimeout(this.nowIndicatorIntervalID);
+			cleared = true;
+		}
+
+		if (cleared) { // is the indicator currently display?
+			this.unrenderNowIndicator();
+		}
+	},
+
+
+	// Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator
+	// should be refreshed. If something falsy is returned, no time indicator is rendered at all.
+	getNowIndicatorUnit: function() {
+		// subclasses should implement
+	},
+
+
+	// Renders a current time indicator at the given datetime
+	renderNowIndicator: function(date) {
+		// subclasses should implement
+	},
+
+
+	// Undoes the rendering actions from renderNowIndicator
+	unrenderNowIndicator: function() {
+		// subclasses should implement
+	},
+
+
 	/* Dimensions
 	------------------------------------------------------------------------------------------------------------------*/
 
@@ -558,12 +651,19 @@ var View = FC.View = Class.extend({
 
 	// Does everything necessary to clear the view's currently-rendered events
 	clearEvents: function() {
+		var scrollState;
+
 		if (this.isEventsRendered) {
+
+			// TODO: optimize: if we know this is part of a displayEvents call, don't queryScroll/setScroll
+			scrollState = this.queryScroll();
+
 			this.triggerEventUnrender();
 			if (this.destroyEvents) {
 				this.destroyEvents(); // TODO: deprecate
 			}
 			this.unrenderEvents();
+			this.setScroll(scrollState);
 			this.isEventsRendered = false;
 		}
 	},

+ 9 - 0
src/common/common.css

@@ -671,3 +671,12 @@ a.fc-more:hover {
 .fc-more-popover .fc-event-container {
 	padding: 10px;
 }
+
+
+/* Now Indicator
+--------------------------------------------------------------------------------------------------*/
+
+.fc-now-indicator {
+	position: absolute;
+	border: 0 solid red;
+}

+ 8 - 2
src/common/print.css

@@ -42,11 +42,17 @@ tbody,
 	background: #fff !important;
 }
 
-/* kill the overlaid, absolutely-positioned common components */
+/* kill the overlaid, absolutely-positioned components */
+/* common... */
 .fc-bg,
 .fc-bgevent-skeleton,
 .fc-highlight-skeleton,
-.fc-helper-skeleton {
+.fc-helper-skeleton,
+/* for timegrid. within cells within table skeletons... */
+.fc-bgevent-container,
+.fc-business-container,
+.fc-highlight-container,
+.fc-helper-container {
 	display: none;
 }
 

+ 2 - 0
src/defaults.js

@@ -25,6 +25,8 @@ Calendar.defaults = {
 	
 	//editable: false,
 
+	//nowIndicator: false,
+
 	scrollTime: '06:00:00',
 	
 	// event ajax

+ 1 - 1
src/main.js

@@ -1,7 +1,7 @@
 
 var FC = $.fullCalendar = {
 	version: "<%= meta.version %>",
-	internalApiVersion: 1
+	internalApiVersion: 2
 };
 var fcViews = FC.views = {};
 

+ 1 - 1
src/util.js

@@ -123,7 +123,7 @@ function undistributeHeight(els) {
 function matchCellWidths(els) {
 	var maxInnerWidth = 0;
 
-	els.find('> *').each(function(i, innerEl) {
+	els.find('> span').each(function(i, innerEl) {
 		var innerWidth = $(innerEl).outerWidth();
 		if (innerWidth > maxInnerWidth) {
 			maxInnerWidth = innerWidth;

+ 70 - 62
tests/automated/background-events.js

@@ -378,7 +378,7 @@ describe('background events', function() {
 				} ];
 				options.eventAfterAllRender = function() {
 					expect($('.fc-bgevent').length).toBe(1);
-					expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(2) .fc-bgevent').length).toBe(1); // column 2
+					expect(queryBgEventsInCol(2).length).toBe(1); // column 2
 					expect($('.fc-bgevent')).toBeBelow('.fc-slats tr:eq(0)'); // should be 1am (eq(1)) but FF cmplaning
 					expect($('.fc-bgevent')).toBeAbove('.fc-slats tr:eq(10)'); // 5am
 					expect($('.fc-event').length).toBe(0);
@@ -395,8 +395,8 @@ describe('background events', function() {
 				} ];
 				options.eventAfterAllRender = function() {
 					expect($('.fc-bgevent').length).toBe(2);
-					expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(2) .fc-bgevent').length).toBe(1); // column 2
-					expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(3) .fc-bgevent').length).toBe(1); // column 3
+					expect(queryBgEventsInCol(2).length).toBe(1);
+					expect(queryBgEventsInCol(3).length).toBe(1);
 					// TODO: maybe check y coords
 					done();
 				};
@@ -417,8 +417,8 @@ describe('background events', function() {
 				];
 				options.eventAfterAllRender = function() {
 					expect($('.fc-bgevent').length).toBe(4);
-					expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(2) .fc-bgevent').length).toBe(2); // column 2
-					expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(3) .fc-bgevent').length).toBe(2); // column 3
+					expect(queryBgEventsInCol(2).length).toBe(2);
+					expect(queryBgEventsInCol(3).length).toBe(2);
 					// TODO: maybe check y coords
 					done();
 				};
@@ -444,14 +444,13 @@ describe('background events', function() {
 
 					// time area
 					expect($('.fc-time-grid .fc-nonbusiness').length).toBe(11);
-					var containerEls = $('.fc-time-grid .fc-bgevent-skeleton td:not(.fc-axis)'); // background columns
-					expect(containerEls.eq(0).find('.fc-nonbusiness').length).toBe(1);
-					expect(containerEls.eq(1).find('.fc-nonbusiness').length).toBe(2);
-					expect(containerEls.eq(2).find('.fc-nonbusiness').length).toBe(2);
-					expect(containerEls.eq(3).find('.fc-nonbusiness').length).toBe(2);
-					expect(containerEls.eq(4).find('.fc-nonbusiness').length).toBe(2);
-					expect(containerEls.eq(5).find('.fc-nonbusiness').length).toBe(1);
-					expect(containerEls.eq(6).find('.fc-nonbusiness').length).toBe(1);
+					expect(queryNonBusinessSegsInCol(0).length).toBe(1);
+					expect(queryNonBusinessSegsInCol(1).length).toBe(2);
+					expect(queryNonBusinessSegsInCol(2).length).toBe(2);
+					expect(queryNonBusinessSegsInCol(3).length).toBe(2);
+					expect(queryNonBusinessSegsInCol(4).length).toBe(2);
+					expect(queryNonBusinessSegsInCol(5).length).toBe(1);
+					expect(queryNonBusinessSegsInCol(6).length).toBe(1);
 				});
 			});
 		});
@@ -467,7 +466,7 @@ describe('background events', function() {
 				} ];
 				options.eventAfterAllRender = function() {
 					expect($('.fc-bgevent').length).toBe(1);
-					expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(4) .fc-bgevent').length).toBe(1); // column 4
+					expect(queryBgEventsInCol(4).length).toBe(1);
 					expect($('.fc-bgevent')).toBeBelow('.fc-slats tr:eq(0)'); // should be 1am (eq(1)) but FF cmplaining
 					expect($('.fc-bgevent')).toBeAbove('.fc-slats tr:eq(10)'); // 5am
 					done();
@@ -482,8 +481,8 @@ describe('background events', function() {
 				} ];
 				options.eventAfterAllRender = function() {
 					expect($('.fc-bgevent').length).toBe(2);
-					expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(3) .fc-bgevent').length).toBe(1); // column 3
-					expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(4) .fc-bgevent').length).toBe(1); // column 4
+					expect(queryBgEventsInCol(3).length).toBe(1);
+					expect(queryBgEventsInCol(4).length).toBe(1);
 					done();
 				};
 				$('#cal').fullCalendar(options);
@@ -502,14 +501,13 @@ describe('background events', function() {
 
 					// time area
 					expect($('.fc-time-grid .fc-nonbusiness').length).toBe(11);
-					var containerEls = $('.fc-time-grid .fc-bgevent-skeleton td:not(.fc-axis)'); // background columns
-					expect(containerEls.eq(0).find('.fc-nonbusiness').length).toBe(1);
-					expect(containerEls.eq(1).find('.fc-nonbusiness').length).toBe(1);
-					expect(containerEls.eq(2).find('.fc-nonbusiness').length).toBe(2);
-					expect(containerEls.eq(3).find('.fc-nonbusiness').length).toBe(2);
-					expect(containerEls.eq(4).find('.fc-nonbusiness').length).toBe(2);
-					expect(containerEls.eq(5).find('.fc-nonbusiness').length).toBe(2);
-					expect(containerEls.eq(6).find('.fc-nonbusiness').length).toBe(1);
+					expect(queryNonBusinessSegsInCol(0).length).toBe(1);
+					expect(queryNonBusinessSegsInCol(1).length).toBe(1);
+					expect(queryNonBusinessSegsInCol(2).length).toBe(2);
+					expect(queryNonBusinessSegsInCol(3).length).toBe(2);
+					expect(queryNonBusinessSegsInCol(4).length).toBe(2);
+					expect(queryNonBusinessSegsInCol(5).length).toBe(2);
+					expect(queryNonBusinessSegsInCol(6).length).toBe(1);
 				});
 			});
 		});
@@ -526,13 +524,13 @@ describe('background events', function() {
 					} ];
 					options.eventAfterAllRender = function() {
 						expect($('.fc-bgevent').length).toBe(8);
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(0) .fc-bgevent').length).toBe(1); // column 0
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(1) .fc-bgevent').length).toBe(1); // column 1
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(2) .fc-bgevent').length).toBe(2); // column 2
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(3) .fc-bgevent').length).toBe(1); // column 3
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(4) .fc-bgevent').length).toBe(1); // column 4
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(5) .fc-bgevent').length).toBe(1); // column 5
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(6) .fc-bgevent').length).toBe(1); // column 6
+						expect(queryBgEventsInCol(0).length).toBe(1);
+						expect(queryBgEventsInCol(1).length).toBe(1);
+						expect(queryBgEventsInCol(2).length).toBe(2);
+						expect(queryBgEventsInCol(3).length).toBe(1);
+						expect(queryBgEventsInCol(4).length).toBe(1);
+						expect(queryBgEventsInCol(5).length).toBe(1);
+						expect(queryBgEventsInCol(6).length).toBe(1);
 						// TODO: maybe check y coords
 						done();
 					};
@@ -547,13 +545,13 @@ describe('background events', function() {
 					} ];
 					options.eventAfterAllRender = function() {
 						expect($('.fc-bgevent').length).toBe(7);
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(0) .fc-bgevent').length).toBe(1); // column 0
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(1) .fc-bgevent').length).toBe(1); // column 1
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(2) .fc-bgevent').length).toBe(1); // column 2
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(3) .fc-bgevent').length).toBe(1); // column 3
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(4) .fc-bgevent').length).toBe(1); // column 4
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(5) .fc-bgevent').length).toBe(1); // column 5
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(6) .fc-bgevent').length).toBe(1); // column 6
+						expect(queryBgEventsInCol(0).length).toBe(1);
+						expect(queryBgEventsInCol(1).length).toBe(1);
+						expect(queryBgEventsInCol(2).length).toBe(1);
+						expect(queryBgEventsInCol(3).length).toBe(1);
+						expect(queryBgEventsInCol(4).length).toBe(1);
+						expect(queryBgEventsInCol(5).length).toBe(1);
+						expect(queryBgEventsInCol(6).length).toBe(1);
 						// TODO: maybe check y coords
 						done();
 					};
@@ -568,13 +566,13 @@ describe('background events', function() {
 					} ];
 					options.eventAfterAllRender = function() {
 						expect($('.fc-bgevent').length).toBe(5);
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(0) .fc-bgevent').length).toBe(0); // column 0
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(1) .fc-bgevent').length).toBe(0); // column 1
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(2) .fc-bgevent').length).toBe(1); // column 2
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(3) .fc-bgevent').length).toBe(1); // column 3
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(4) .fc-bgevent').length).toBe(1); // column 4
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(5) .fc-bgevent').length).toBe(1); // column 5
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(6) .fc-bgevent').length).toBe(1); // column 6
+						expect(queryBgEventsInCol(0).length).toBe(0);
+						expect(queryBgEventsInCol(1).length).toBe(0);
+						expect(queryBgEventsInCol(2).length).toBe(1);
+						expect(queryBgEventsInCol(3).length).toBe(1);
+						expect(queryBgEventsInCol(4).length).toBe(1);
+						expect(queryBgEventsInCol(5).length).toBe(1);
+						expect(queryBgEventsInCol(6).length).toBe(1);
 						// TODO: maybe check y coords
 						done();
 					};
@@ -589,9 +587,9 @@ describe('background events', function() {
 					} ];
 					options.eventAfterAllRender = function() {
 						expect($('.fc-bgevent').length).toBe(3);
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(0) .fc-bgevent').length).toBe(1); // column 0
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(1) .fc-bgevent').length).toBe(1); // column 1
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(2) .fc-bgevent').length).toBe(1); // column 2
+						expect(queryBgEventsInCol(0).length).toBe(1);
+						expect(queryBgEventsInCol(1).length).toBe(1);
+						expect(queryBgEventsInCol(2).length).toBe(1);
 						// TODO: maybe check y coords
 						done();
 					};
@@ -615,13 +613,13 @@ describe('background events', function() {
 					];
 					options.eventAfterAllRender = function() {
 						expect($('.fc-bgevent').length).toBe(9);
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(0) .fc-bgevent').length).toBe(1); // column 0
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(1) .fc-bgevent').length).toBe(2); // column 1
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(2) .fc-bgevent').length).toBe(1); // column 2
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(3) .fc-bgevent').length).toBe(2); // column 3
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(4) .fc-bgevent').length).toBe(1); // column 4
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(5) .fc-bgevent').length).toBe(1); // column 5
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(6) .fc-bgevent').length).toBe(1); // column 6
+						expect(queryBgEventsInCol(0).length).toBe(1);
+						expect(queryBgEventsInCol(1).length).toBe(2);
+						expect(queryBgEventsInCol(2).length).toBe(1);
+						expect(queryBgEventsInCol(3).length).toBe(2);
+						expect(queryBgEventsInCol(4).length).toBe(1);
+						expect(queryBgEventsInCol(5).length).toBe(1);
+						expect(queryBgEventsInCol(6).length).toBe(1);
 						// TODO: maybe check y coords
 						done();
 					};
@@ -641,13 +639,13 @@ describe('background events', function() {
 					} ];
 					options.eventAfterAllRender = function() {
 						expect($('.fc-bgevent').length).toBe(8);
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(0) .fc-bgevent').length).toBe(1); // column 0
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(1) .fc-bgevent').length).toBe(1); // column 1
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(2) .fc-bgevent').length).toBe(1); // column 2
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(3) .fc-bgevent').length).toBe(1); // column 3
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(4) .fc-bgevent').length).toBe(2); // column 4
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(5) .fc-bgevent').length).toBe(1); // column 5
-						expect($('.fc-bgevent-skeleton td:not(.fc-axis):eq(6) .fc-bgevent').length).toBe(1); // column 6
+						expect(queryBgEventsInCol(0).length).toBe(1);
+						expect(queryBgEventsInCol(1).length).toBe(1);
+						expect(queryBgEventsInCol(2).length).toBe(1);
+						expect(queryBgEventsInCol(3).length).toBe(1);
+						expect(queryBgEventsInCol(4).length).toBe(2);
+						expect(queryBgEventsInCol(5).length).toBe(1);
+						expect(queryBgEventsInCol(6).length).toBe(1);
 						// TODO: maybe check y coords
 						done();
 					};
@@ -742,4 +740,14 @@ describe('background events', function() {
 			$('#cal').fullCalendar(options);
 		});
 	});
+
+
+	function queryBgEventsInCol(col) {
+		return $('.fc-time-grid .fc-content-skeleton td:not(.fc-axis):eq(' + col + ') .fc-bgevent');
+	}
+
+	function queryNonBusinessSegsInCol(col) {
+		return $('.fc-time-grid .fc-content-skeleton td:not(.fc-axis):eq(' + col + ') .fc-nonbusiness');
+	}
+
 });

+ 13 - 8
tests/automated/businessHours.js

@@ -23,13 +23,18 @@ describe('businessHours', function() {
 
 		// timed area
 		expect($('.fc-time-grid .fc-nonbusiness').length).toBe(12);
-		var containerEls = $('.fc-time-grid .fc-bgevent-skeleton td:not(.fc-axis)'); // background columns
-		expect(containerEls.eq(0).find('.fc-nonbusiness').length).toBe(1);
-		expect(containerEls.eq(1).find('.fc-nonbusiness').length).toBe(2);
-		expect(containerEls.eq(2).find('.fc-nonbusiness').length).toBe(2);
-		expect(containerEls.eq(3).find('.fc-nonbusiness').length).toBe(2);
-		expect(containerEls.eq(4).find('.fc-nonbusiness').length).toBe(2);
-		expect(containerEls.eq(5).find('.fc-nonbusiness').length).toBe(2);
-		expect(containerEls.eq(6).find('.fc-nonbusiness').length).toBe(1);
+		expect(queryNonBusinessSegsInCol(0).length).toBe(1);
+		expect(queryNonBusinessSegsInCol(1).length).toBe(2);
+		expect(queryNonBusinessSegsInCol(2).length).toBe(2);
+		expect(queryNonBusinessSegsInCol(3).length).toBe(2);
+		expect(queryNonBusinessSegsInCol(4).length).toBe(2);
+		expect(queryNonBusinessSegsInCol(5).length).toBe(2);
+		expect(queryNonBusinessSegsInCol(6).length).toBe(1);
 	});
+
+
+	function queryNonBusinessSegsInCol(col) {
+		return $('.fc-time-grid .fc-content-skeleton td:not(.fc-axis):eq(' + col + ') .fc-nonbusiness');
+	}
+
 });

+ 3 - 3
tests/automated/event-dnd.js

@@ -225,8 +225,8 @@ describe('eventDrop', function() {
 						});
 					},
 					function(event, delta, revertFunc) {
-						expect(delta.days()).toBe(-1);
-						expect(delta.hours()).toBe(1);
+						expect(delta.days()).toBe(0);
+						expect(delta.hours()).toBe(-23);
 						expect(delta.minutes()).toBe(0);
 						expect(delta.seconds()).toBe(0);
 						expect(delta.milliseconds()).toBe(0);
@@ -413,4 +413,4 @@ describe('eventDrop', function() {
 			$('#cal').fullCalendar(options);
 		}, 0);
 	}
-});
+});

+ 3 - 1
tests/automated/events-gcal.js

@@ -1,5 +1,7 @@
 
-describe('Google Calendar plugin', function() {
+// TODO: revive
+// Google removes holidays that are old, and returns no results, breaking these tests
+xdescribe('Google Calendar plugin', function() {
 
 	var API_KEY = 'AIzaSyDcnW6WejpTOCffshGDDb4neIrXVUA1EAE';
 	var options;

+ 4 - 1
tests/automated/lang.js

@@ -39,7 +39,10 @@ describe('lang', function() {
 		expect(moment.lang()).toEqual('fr');
 	});
 
-	it('defaults to English when configured to language that isn\'t loaded', function() {
+	// the most recent version of moment will actually throw a cryptic exception,
+	// and instead of papering over this, just let it be thrown. will indicate that something
+	// needs to be fixed to the developer.
+	xit('defaults to English when configured to language that isn\'t loaded', function() {
 		affix('#cal');
 		$('#cal').fullCalendar({
 			lang: 'zz'

+ 102 - 0
tests/automated/nowIndicator.js

@@ -0,0 +1,102 @@
+describe('now indicator', function() {
+	var options;
+
+	beforeEach(function() {
+		affix('#cal');
+		options = {
+			now: '2015-12-26T06:00:00',
+			scrollTime: '00:00'
+		};
+	});
+
+	describe('when in month view', function() {
+		beforeEach(function() {
+			options.defaultView = 'month';
+		});
+
+		it('doesn\'t render even when activated', function() {
+			$('#cal').fullCalendar(options);
+			expect(isNowIndicatorRendered()).toBe(false);
+		});
+	});
+
+	describe('when in agendaWeek view', function() {
+		beforeEach(function() {
+			options.defaultView = 'agendaWeek';
+		});
+
+		it('doesn\'t render by default', function() {
+			$('#cal').fullCalendar(options);
+			expect(isNowIndicatorRendered()).toBe(false);
+		});
+
+		describe('when activated', function() {
+			beforeEach(function() {
+				options.nowIndicator = true;
+			});
+
+			[ false, true ].forEach(function(isRTL) {
+
+				describe('when ' + (isRTL ? 'RTL' : 'LTR'), function() {
+					beforeEach(function() {
+						options.isRTL = isRTL;
+					});
+
+					it('doesn\'t render when out of view', function() {
+						options.defaultDate = '2015-12-27'; // sun of next week
+						$('#cal').fullCalendar(options);
+						expect(isNowIndicatorRendered()).toBe(false);
+					});
+
+					it('renders on correct time', function() {
+						$('#cal').fullCalendar(options);
+						isNowIndicatorRenderedAt('2015-12-26T06:00:00');
+					});
+
+					it('renders on correct time2', function() {
+						options.now = '2015-12-20T02:30:00';
+						$('#cal').fullCalendar(options);
+						isNowIndicatorRenderedAt('2015-12-20T02:30:00');
+					});
+				});
+			});
+		});
+	});
+
+	function isNowIndicatorRendered() {
+		return $('.fc-now-indicator').length > 0;
+	}
+
+	function isNowIndicatorRenderedAt(date) {
+		var line = getTimeGridLine(date);
+		var lineEl = $('.fc-now-indicator-line');
+		var arrowEl = $('.fc-now-indicator-arrow');
+
+		expect(lineEl.length).toBe(1);
+		expect(arrowEl.length).toBe(1);
+
+		var lineElRect = getBoundingRect(lineEl);
+		var arrowElRect = getBoundingRect(arrowEl);
+
+		expect(Math.abs(
+			(lineElRect.top + lineElRect.bottom) / 2 -
+			line.top
+		)).toBeLessThan(2);
+		expect(Math.abs(
+			(arrowElRect.top + arrowElRect.bottom) / 2 -
+			line.top
+		)).toBeLessThan(2);
+
+		var timeGridRect = getBoundingRect('.fc-time-grid');
+		if (isElWithinRtl(arrowEl)) {
+			expect(Math.abs(
+				arrowElRect.right - timeGridRect.right
+			)).toBeLessThan(2);
+		}
+		else {
+			expect(Math.abs(
+				arrowElRect.left - timeGridRect.left
+			)).toBeLessThan(2);
+		}
+	}
+});

+ 45 - 0
tests/automated/refetchEvents.js

@@ -0,0 +1,45 @@
+
+describe('when agenda events are rerendered', function() {
+	beforeEach(function() {
+		affix('#cal');
+	});
+
+	it('keeps scroll after refetchEvents', function(done) {
+		var renderCalls = 0;
+
+		$('#cal').fullCalendar({
+			now: '2015-08-07',
+			scrollTime: '00:00',
+			height: 400, // makes this test more consistent across viewports
+			defaultView: 'agendaDay',
+			events: function(start, end, timezone, callback) {
+				setTimeout(function() {
+					callback([
+						{ id: '1', resourceId: 'b', start: '2015-08-07T02:00:00', end: '2015-08-07T07:00:00', title: 'event 1' },
+						{ id: '2', resourceId: 'c', start: '2015-08-07T05:00:00', end: '2015-08-07T22:00:00', title: 'event 2' },
+						{ id: '3', resourceId: 'd', start: '2015-08-06', end: '2015-08-08', title: 'event 3' },
+						{ id: '4', resourceId: 'e', start: '2015-08-07T03:00:00', end: '2015-08-07T08:00:00', title: 'event 4' },
+						{ id: '5', resourceId: 'f', start: '2015-08-07T00:30:00', end: '2015-08-07T02:30:00', title: 'event 5' }
+					]);
+				}, 100);
+			},
+			eventAfterAllRender: function() {
+				var scrollEl = $('.fc-time-grid-container.fc-scroller');
+				renderCalls++;
+				if (renderCalls == 1) {
+					setTimeout(function() {
+						scrollEl.scrollTop(100);
+						setTimeout(function() {
+							$('#cal').fullCalendar('refetchEvents');
+						}, 100);
+					}, 100);
+				}
+				else if (renderCalls == 2) {
+					expect(scrollEl.scrollTop()).toBe(100);
+					done();
+				}
+			}
+		});
+	});
+
+});

+ 77 - 0
tests/lib/time-grid.js

@@ -0,0 +1,77 @@
+
+// TODO: consolidate with scheduler
+
+
+function getTimeGridPoint(date) {
+	var date = $.fullCalendar.moment.parseZone(date);
+	var top = getTimeGridTop(date.time());
+	var dayEls = getTimeGridDayEls(date);
+	var dayRect;
+
+	expect(dayEls.length).toBe(1);
+	dayRect = getBoundingRect(dayEls.eq(0));
+
+	return {
+		left: (dayRect.left + dayRect.right) / 2,
+		top: top
+	};
+}
+
+
+function getTimeGridLine(date) { // not in Scheduler
+	var date = $.fullCalendar.moment.parseZone(date);
+	var top = getTimeGridTop(date.time());
+	var dayEls = getTimeGridDayEls(date);
+	var dayRect;
+
+	expect(dayEls.length).toBe(1);
+	dayRect = getBoundingRect(dayEls.eq(0));
+
+	return {
+		left: dayRect.left,
+		right: dayRect.right,
+		top: top,
+		bottom: top
+	};
+}
+
+
+function getTimeGridTop(time) {
+	var time = moment.duration(time);
+	var slotEls = getTimeGridSlotEls(time);
+
+	expect(slotEls.length).toBe(1);
+	
+	return slotEls.offset().top + 1; // +1 make sure after border
+}
+
+
+function getTimeGridDayEls(date) {
+	var date = $.fullCalendar.moment.parseZone(date);
+
+	return $('.fc-time-grid .fc-day[data-date="' + date.format('YYYY-MM-DD') + '"]');
+}
+
+
+function getTimeGridSlotEls(timeDuration) {
+	var timeDuration = moment.duration(timeDuration);
+	var date = $.fullCalendar.moment.utc('2016-01-01').time(timeDuration);
+
+	return $('.fc-time-grid .fc-slats tr[data-time="' + date.format('HH:mm:ss') + '"]');
+}
+
+
+function isElWithinRtl(el) {
+	return el.closest('.fc').hasClass('fc-rtl');
+}
+
+
+function getBoundingRect(el) {
+	var el = $(el);
+	expect(el.length).toBe(1);
+	var rect = el.offset();
+	rect.right = rect.left + el.outerWidth();
+	rect.bottom = rect.top + el.outerHeight();
+	rect.node = el[0]; // very useful for debugging
+	return rect;
+}