Adam Shaw пре 12 година
родитељ
комит
d0d0a97c64

+ 247 - 53
src/Calendar.js

@@ -1,10 +1,38 @@
 
  
-function Calendar(element, options, eventSources) {
+function Calendar(element, instanceOptions) {
 	var t = this;
+
+
+
+	// Build options object
+	// -----------------------------------------------------------------------------------
+	// Precedence (lowest to highest): defaults, rtlDefaults, langOptions, instanceOptions
+
+	var options = mergeOptions({}, defaults, instanceOptions);
+	var langOptions;
+
+	// determine language options
+	if (options.lang in langOptionHash) {
+		langOptions = langOptionHash[options.lang];
+	}
+	else {
+		langOptions = langOptionHash[defaults.lang];
+	}
+
+	if (langOptions) { // if language options exist, rebuild...
+		options = mergeOptions({}, defaults, langOptions, instanceOptions);
+	}
+
+	if (options.isRTL) { // is isRTL, rebuild...
+		options = mergeOptions({}, defaults, rtlDefaults, langOptions || {}, instanceOptions);
+	}
+
+
 	
-	
-	// exports
+	// Exports
+	// -----------------------------------------------------------------------------------
+
 	t.options = options;
 	t.render = render;
 	t.destroy = destroy;
@@ -22,21 +50,186 @@ function Calendar(element, options, eventSources) {
 	t.today = today;
 	t.gotoDate = gotoDate;
 	t.incrementDate = incrementDate;
-	t.formatDate = function(format, date) { return formatDate(format, date, options) };
-	t.formatDates = function(format, date1, date2) { return formatDates(format, date1, date2, options) };
 	t.getDate = getDate;
+	t.getCalendar = getCalendar;
 	t.getView = getView;
 	t.option = option;
 	t.trigger = trigger;
+
+
+
+	// Language-data Internals
+	// -----------------------------------------------------------------------------------
+	// Apply overrides to the current language's data
+
+	var langData = createObject( // make a cheap clone
+		moment.langData(options.lang)
+	);
+
+	if (options.monthNames) {
+		langData._months = options.monthNames;
+	}
+	if (options.monthNamesShort) {
+		langData._monthsShort = options.monthNamesShort;
+	}
+	if (options.dayNames) {
+		langData._weekdays = options.dayNames;
+	}
+	if (options.dayNamesShort) {
+		langData._weekdaysShort = options.dayNamesShort;
+	}
+	if (options.firstDay) {
+		var _week = createObject(langData._week); // _week: { dow: # }
+		_week.dow = options.firstDay;
+		langData._week = _week;
+	}
+
+
+
+	// Calendar-specific Date Utilities
+	// -----------------------------------------------------------------------------------
+
+
+	t.defaultAllDayEventDuration = moment.duration(options.defaultAllDayEventDuration);
+	t.defaultTimedEventDuration = moment.duration(options.defaultTimedEventDuration);
+
+
+	// Builds a moment using the settings of the current calendar: timezone and language.
+	// Accepts anything the vanilla moment() constructor accepts.
+	t.moment = function() {
+		var mom;
+
+		if (options.timezone === 'local') {
+			mom = fc.moment.apply(null, arguments);
+		}
+		else if (options.timezone === 'UTC') {
+			mom = fc.moment.utc.apply(null, arguments);
+		}
+		else {
+			mom = fc.moment.parseZone.apply(null, arguments);
+		}
+
+		mom._lang = langData;
+
+		return mom;
+	};
+
+
+	// Returns a boolean about whether or not the calendar knows how to calculate
+	// the timezone offset of arbitrary dates in the current timezone.
+	t.getIsAmbigTimezone = function() {
+		return options.timezone !== 'local' && options.timezone !== 'UTC';
+	};
+
+
+	// Returns a copy of the given date in the current timezone of it is ambiguously zoned.
+	// This will also give the date an unambiguous time.
+	t.rezoneDate = function(date) {
+		return t.moment(date.toArray());
+	};
+
+
+	// Returns a moment for the current date, as defined by the client's computer,
+	// or overridden by the `now` option.
+	t.getNow = function() {
+		var now = options.now;
+		if (typeof now === 'function') {
+			now = now();
+		}
+		return t.moment(now);
+	};
+
+
+	// Calculates the week number for a moment according to the calendar's
+	// `weekNumberCalculation` setting.
+	t.calculateWeekNumber = function(mom) {
+		var calc = options.weekNumberCalculation;
+		if (calc == 'local') {
+			return mom.week();
+		}
+		else if (calc.toUpperCase() == 'ISO') {
+			return mom.isoWeek();
+		}
+		else if (typeof calc === 'function') {
+			return calc(mom);
+		}
+	};
+
+
+	// Get an event's normalized end date. If not present, calculate it from the defaults.
+	t.getEventEnd = function(event) {
+		if (event.end) {
+			return event.end.clone();
+		}
+		else {
+			return t.getDefaultEventEnd(event.allDay, event.start);
+		}
+	};
+
+
+	// Given an event's allDay status and start date, return swhat its fallback end date should be.
+	t.getDefaultEventEnd = function(allDay, start) { // TODO: rename to computeDefaultEventEnd
+		var end = start.clone();
+
+		if (allDay) {
+			end.stripTime().add(t.defaultAllDayEventDuration);
+		}
+		else {
+			end.add(t.defaultTimedEventDuration);
+		}
+
+		if (t.getIsAmbigTimezone()) {
+			end.stripZone(); // we don't know what the tzo should be
+		}
+
+		return end;
+	};
+
+
+
+	// Date-formatting Utilities
+	// -----------------------------------------------------------------------------------
+
+
+	// Like the vanilla formatRange, but with calendar-specific settings applied.
+	t.formatRange = function(m1, m2, formatStr) {
+
+		// a function that returns a formatStr // TODO: in future, precompute this
+		if (typeof formatStr === 'function') {
+			formatStr = formatStr.call(t, options, langData);
+		}
+
+		return formatRange(m1, m2, formatStr, null, options.isRTL);
+	};
+
+
+	// Like the vanilla formatDate, but with calendar-specific settings applied.
+	t.formatDate = function(mom, formatStr) {
+
+		// a function that returns a formatStr // TODO: in future, precompute this
+		if (typeof formatStr === 'function') {
+			formatStr = formatStr.call(t, options, langData);
+		}
+
+		return formatDate(mom, formatStr);
+	};
+
+
 	
-	
-	// imports
-	EventManager.call(t, options, eventSources);
+	// Imports
+	// -----------------------------------------------------------------------------------
+
+
+	EventManager.call(t, options);
 	var isFetchNeeded = t.isFetchNeeded;
 	var fetchEvents = t.fetchEvents;
-	
-	
-	// locals
+
+
+
+	// Locals
+	// -----------------------------------------------------------------------------------
+
+
 	var _element = element[0];
 	var header;
 	var headerElement;
@@ -47,17 +240,22 @@ function Calendar(element, options, eventSources) {
 	var suggestedViewHeight;
 	var resizeUID = 0;
 	var ignoreWindowResize = 0;
-	var date = new Date();
+	var date;
 	var events = [];
 	var _dragElement;
 	
 	
 	
-	/* Main Rendering
-	-----------------------------------------------------------------------------*/
-	
-	
-	setYMD(date, options.year, options.month, options.date);
+	// Main Rendering
+	// -----------------------------------------------------------------------------------
+
+
+	if (options.defaultDate != null) {
+		date = t.moment(options.defaultDate);
+	}
+	else {
+		date = t.getNow();
+	}
 	
 	
 	function render(inc) {
@@ -85,7 +283,7 @@ function Calendar(element, options, eventSources) {
 			element.addClass('ui-widget');
 		}
 
-		content = $("<div class='fc-content' style='position:relative'/>")
+		content = $("<div class='fc-content' />")
 			.prependTo(element);
 
 		header = new Header(t, options);
@@ -143,9 +341,9 @@ function Calendar(element, options, eventSources) {
 	}
 	
 	
-	
-	/* View Rendering
-	-----------------------------------------------------------------------------*/
+
+	// View Rendering
+	// -----------------------------------------------------------------------------------
 	
 
 	function changeView(newViewName) {
@@ -170,7 +368,7 @@ function Calendar(element, options, eventSources) {
 		header.activateButton(newViewName);
 
 		currentView = new fcViews[newViewName](
-			$("<div class='fc-view fc-view-" + newViewName + "' style='position:relative'/>")
+			$("<div class='fc-view fc-view-" + newViewName + "' />")
 				.appendTo(content),
 			t // the calendar object
 		);
@@ -185,7 +383,8 @@ function Calendar(element, options, eventSources) {
 	function renderView(inc) {
 		if (
 			!currentView.start || // never rendered before
-			inc || date < currentView.start || date >= currentView.end // or new date range
+			inc || // explicit date window change
+			!date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change
 		) {
 			if (elementVisible()) {
 				_renderView(inc);
@@ -204,7 +403,10 @@ function Calendar(element, options, eventSources) {
 		}
 
 		freezeContentHeight();
-		currentView.render(date, inc || 0); // the view's render method ONLY renders the skeleton, nothing else
+		if (inc) {
+			date = currentView.incrementDate(date, inc);
+		}
+		currentView.render(date.clone()); // the view's render method ONLY renders the skeleton, nothing else
 		setSize();
 		unfreezeContentHeight();
 		(currentView.afterRender || noop)();
@@ -213,7 +415,6 @@ function Calendar(element, options, eventSources) {
 		updateTodayButton();
 
 		trigger('viewRender', currentView, currentView, currentView.element);
-		currentView.trigger('viewDisplay', _element); // deprecated
 
 		ignoreWindowResize--;
 
@@ -222,8 +423,8 @@ function Calendar(element, options, eventSources) {
 	
 	
 
-	/* Resizing
-	-----------------------------------------------------------------------------*/
+	// Resizing
+	// -----------------------------------------------------------------------------------
 	
 	
 	function updateSize() {
@@ -324,7 +525,7 @@ function Calendar(element, options, eventSources) {
 	
 
 	function getAndRenderEvents() {
-		if (!options.lazyFetching || isFetchNeeded(currentView.visStart, currentView.visEnd)) {
+		if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) {
 			fetchAndRenderEvents();
 		}
 		else {
@@ -334,7 +535,7 @@ function Calendar(element, options, eventSources) {
 
 
 	function fetchAndRenderEvents() {
-		fetchEvents(currentView.visStart, currentView.visEnd);
+		fetchEvents(currentView.start, currentView.end);
 			// ... will call reportEvents
 			// ... which will call renderEvents
 	}
@@ -364,8 +565,8 @@ function Calendar(element, options, eventSources) {
 
 
 	function updateTodayButton() {
-		var today = new Date();
-		if (today >= currentView.start && today < currentView.end) {
+		var now = t.getNow();
+		if (now.isWithin(currentView.intervalStart, currentView.intervalEnd)) {
 			header.disableButton('today');
 		}
 		else {
@@ -379,8 +580,8 @@ function Calendar(element, options, eventSources) {
 	-----------------------------------------------------------------------------*/
 	
 
-	function select(start, end, allDay) {
-		currentView.select(start, end, allDay===undefined ? true : allDay);
+	function select(start, end) {
+		currentView.select(start, end);
 	}
 	
 
@@ -407,49 +608,37 @@ function Calendar(element, options, eventSources) {
 	
 	
 	function prevYear() {
-		addYears(date, -1);
+		date.add('years', -1);
 		renderView();
 	}
 	
 	
 	function nextYear() {
-		addYears(date, 1);
+		date.add('years', 1);
 		renderView();
 	}
 	
 	
 	function today() {
-		date = new Date();
+		date = t.getNow();
 		renderView();
 	}
 	
 	
-	function gotoDate(year, month, dateOfMonth) {
-		if (year instanceof Date) {
-			date = cloneDate(year); // provided 1 argument, a Date
-		}else{
-			setYMD(date, year, month, dateOfMonth);
-		}
+	function gotoDate(dateInput) {
+		date = t.moment(dateInput);
 		renderView();
 	}
 	
 	
-	function incrementDate(years, months, days) {
-		if (years !== undefined) {
-			addYears(date, years);
-		}
-		if (months !== undefined) {
-			addMonths(date, months);
-		}
-		if (days !== undefined) {
-			addDays(date, days);
-		}
+	function incrementDate() {
+		date.add.apply(date, arguments);
 		renderView();
 	}
 	
 	
 	function getDate() {
-		return cloneDate(date);
+		return date.clone();
 	}
 
 
@@ -480,6 +669,11 @@ function Calendar(element, options, eventSources) {
 	/* Misc
 	-----------------------------------------------------------------------------*/
 	
+
+	function getCalendar() {
+		return t;
+	}
+
 	
 	function getView() {
 		return currentView;

+ 194 - 88
src/EventManager.js

@@ -10,7 +10,7 @@ var ajaxDefaults = {
 var eventGUID = 1;
 
 
-function EventManager(options, _sources) {
+function EventManager(options) { // assumed to be a calendar
 	var t = this;
 	
 	
@@ -23,13 +23,14 @@ function EventManager(options, _sources) {
 	t.renderEvent = renderEvent;
 	t.removeEvents = removeEvents;
 	t.clientEvents = clientEvents;
-	t.normalizeEvent = normalizeEvent;
 	
 	
 	// imports
 	var trigger = t.trigger;
 	var getView = t.getView;
 	var reportEvents = t.reportEvents;
+	var getEventEnd = t.getEventEnd;
+	var mutateEvent = t.mutateEvent;
 	
 	
 	// locals
@@ -40,7 +41,14 @@ function EventManager(options, _sources) {
 	var pendingSourceCnt = 0;
 	var loadingLevel = 0;
 	var cache = [];
-	
+
+
+
+	var _sources = options.eventSources || [];
+
+	if (options.events) {
+		_sources.push(options.events);
+	}
 	
 	for (var i=0; i<_sources.length; i++) {
 		_addEventSource(_sources[i]);
@@ -53,7 +61,10 @@ function EventManager(options, _sources) {
 	
 	
 	function isFetchNeeded(start, end) {
-		return !rangeStart || start < rangeStart || end > rangeEnd;
+		return !rangeStart || // nothing has been fetched yet?
+			// or, a part of the new range is outside of the old range? (after normalizing)
+			start.clone().stripZone() < rangeStart.clone().stripZone() ||
+			end.clone().stripZone() > rangeEnd.clone().stripZone();
 	}
 	
 	
@@ -73,23 +84,16 @@ function EventManager(options, _sources) {
 	function fetchEventSource(source, fetchID) {
 		_fetchEventSource(source, function(events) {
 			if (fetchID == currentFetchID) {
-				if (events) {
 
-					if (options.eventDataTransform) {
-						events = $.map(events, options.eventDataTransform);
-					}
-					if (source.eventDataTransform) {
-						events = $.map(events, source.eventDataTransform);
-					}
-					// TODO: this technique is not ideal for static array event sources.
-					//  For arrays, we'll want to process all events right in the beginning, then never again.
-				
+				if (events) {
 					for (var i=0; i<events.length; i++) {
-						events[i].source = source;
-						normalizeEvent(events[i]);
+						var event = buildEvent(events[i], source);
+						if (event) {
+							cache.push(event);
+						}
 					}
-					cache = cache.concat(events);
 				}
+
 				pendingSourceCnt--;
 				if (!pendingSourceCnt) {
 					reportEvents(cache);
@@ -103,8 +107,17 @@ function EventManager(options, _sources) {
 		var i;
 		var fetchers = fc.sourceFetchers;
 		var res;
+
 		for (i=0; i<fetchers.length; i++) {
-			res = fetchers[i](source, rangeStart, rangeEnd, callback);
+			res = fetchers[i].call(
+				t, // this, the Calendar object
+				source,
+				rangeStart.clone(),
+				rangeEnd.clone(),
+				options.timezone,
+				callback
+			);
+
 			if (res === true) {
 				// the fetcher is in charge. made its own async request
 				return;
@@ -115,14 +128,21 @@ function EventManager(options, _sources) {
 				return;
 			}
 		}
+
 		var events = source.events;
 		if (events) {
 			if ($.isFunction(events)) {
 				pushLoading();
-				events(cloneDate(rangeStart), cloneDate(rangeEnd), function(events) {
-					callback(events);
-					popLoading();
-				});
+				events.call(
+					t, // this, the Calendar object
+					rangeStart.clone(),
+					rangeEnd.clone(),
+					options.timezone,
+					function(events) {
+						callback(events);
+						popLoading();
+					}
+				);
 			}
 			else if ($.isArray(events)) {
 				callback(events);
@@ -154,11 +174,16 @@ function EventManager(options, _sources) {
 
 				var startParam = firstDefined(source.startParam, options.startParam);
 				var endParam = firstDefined(source.endParam, options.endParam);
+				var timezoneParam = firstDefined(source.timezoneParam, options.timezoneParam);
+
 				if (startParam) {
-					data[startParam] = Math.round(+rangeStart / 1000);
+					data[startParam] = rangeStart.format();
 				}
 				if (endParam) {
-					data[endParam] = Math.round(+rangeEnd / 1000);
+					data[endParam] = rangeEnd.format();
+				}
+				if (options.timezone && options.timezone != 'local') {
+					data[timezoneParam] = options.timezone;
 				}
 
 				pushLoading();
@@ -232,55 +257,60 @@ function EventManager(options, _sources) {
 	
 	/* Manipulation
 	-----------------------------------------------------------------------------*/
-	
-	
-	function updateEvent(event) { // update an existing event
-		var i, len = cache.length, e,
-			defaultEventEnd = getView().defaultEventEnd, // getView???
-			startDelta = event.start - event._start,
-			endDelta = event.end ?
-				(event.end - (event._end || defaultEventEnd(event))) // event._end would be null if event.end
-				: 0;                                                      // was null and event was just resized
-		for (i=0; i<len; i++) {
-			e = cache[i];
-			if (e._id == event._id && e != event) {
-				e.start = new Date(+e.start + startDelta);
-				if (event.end) {
-					if (e.end) {
-						e.end = new Date(+e.end + endDelta);
-					}else{
-						e.end = new Date(+defaultEventEnd(e) + endDelta);
+
+
+	function updateEvent(event) {
+		mutateEvent(event);
+		propagateMiscProperties(event);
+		reportEvents(cache); // reports event modifications (so we can redraw)
+	}
+
+
+	var miscCopyableProps = [
+		'title',
+		'url',
+		'allDay',
+		'className',
+		'editable',
+		'color',
+		'backgroundColor',
+		'borderColor',
+		'textColor'
+	];
+
+	function propagateMiscProperties(event) {
+		var i;
+		var cachedEvent;
+		var j;
+		var prop;
+
+		for (i=0; i<cache.length; i++) {
+			cachedEvent = cache[i];
+			if (cachedEvent._id == event._id && cachedEvent !== event) {
+				for (j=0; j<miscCopyableProps.length; j++) {
+					prop = miscCopyableProps[j];
+					if (event[prop] !== undefined) {
+						cachedEvent[prop] = event[prop];
 					}
-				}else{
-					e.end = null;
 				}
-				e.title = event.title;
-				e.url = event.url;
-				e.allDay = event.allDay;
-				e.className = event.className;
-				e.editable = event.editable;
-				e.color = event.color;
-				e.backgroundColor = event.backgroundColor;
-				e.borderColor = event.borderColor;
-				e.textColor = event.textColor;
-				normalizeEvent(e);
 			}
 		}
-		normalizeEvent(event);
-		reportEvents(cache);
 	}
+
 	
 	
-	function renderEvent(event, stick) {
-		normalizeEvent(event);
-		if (!event.source) {
-			if (stick) {
-				stickySource.events.push(event);
-				event.source = stickySource;
+	function renderEvent(eventData, stick) {
+		var event = buildEvent(eventData);
+		if (event) {
+			if (!event.source) {
+				if (stick) {
+					stickySource.events.push(event);
+					event.source = stickySource;
+				}
+				cache.push(event);
 			}
-			cache.push(event);
+			reportEvents(cache);
 		}
-		reportEvents(cache);
 	}
 	
 	
@@ -348,35 +378,103 @@ function EventManager(options, _sources) {
 	
 	/* Event Normalization
 	-----------------------------------------------------------------------------*/
-	
-	
-	function normalizeEvent(event) {
-		var source = event.source || {};
-		var ignoreTimezone = firstDefined(source.ignoreTimezone, options.ignoreTimezone);
-		event._id = event._id || (event.id === undefined ? '_fc' + eventGUID++ : event.id + '');
-		if (event.date) {
-			if (!event.start) {
-				event.start = event.date;
+
+	function buildEvent(data, source) { // source may be undefined!
+		var out = {};
+		var start;
+		var end;
+		var allDay;
+		var allDayDefault;
+
+		if (options.eventDataTransform) {
+			data = options.eventDataTransform(data);
+		}
+		if (source && source.eventDataTransform) {
+			data = source.eventDataTransform(data);
+		}
+
+		start = t.moment(data.start || data.date); // "date" is an alias for "start"
+		if (!start.isValid()) {
+			return;
+		}
+
+		end = null;
+		if (data.end) {
+			end = t.moment(data.end);
+			if (!end.isValid()) {
+				return;
 			}
-			delete event.date;
 		}
-		event._start = cloneDate(event.start = parseDate(event.start, ignoreTimezone));
-		event.end = parseDate(event.end, ignoreTimezone);
-		if (event.end && event.end <= event.start) {
-			event.end = null;
+
+		allDay = data.allDay;
+		if (allDay === undefined) {
+			allDayDefault = firstDefined(
+				source ? source.allDayDefault : undefined,
+				options.allDayDefault
+			);
+			if (allDayDefault !== undefined) {
+				// use the default
+				allDay = allDayDefault;
+			}
+			else {
+				// all dates need to have ambig time for the event to be considered allDay
+				allDay = !start.hasTime() && (!end || !end.hasTime());
+			}
 		}
-		event._end = event.end ? cloneDate(event.end) : null;
-		if (event.allDay === undefined) {
-			event.allDay = firstDefined(source.allDayDefault, options.allDayDefault);
+
+		// normalize the date based on allDay
+		if (allDay) {
+			// neither date should have a time
+			if (start.hasTime()) {
+				start.stripTime();
+			}
+			if (end && end.hasTime()) {
+				end.stripTime();
+			}
 		}
-		if (event.className) {
-			if (typeof event.className == 'string') {
-				event.className = event.className.split(/\s+/);
+		else {
+			// force a time/zone up the dates
+			if (!start.hasTime()) {
+				start = t.rezoneDate(start);
+			}
+			if (end && !end.hasTime()) {
+				end = t.rezoneDate(end);
 			}
-		}else{
-			event.className = [];
 		}
-		// TODO: if there is no start date, return false to indicate an invalid event
+
+		// Copy all properties over to the resulting object.
+		// The special-case properties will be copied over afterwards.
+		$.extend(out, data);
+
+		if (source) {
+			out.source = source;
+		}
+
+		out._id = data._id || (data.id === undefined ? '_fc' + eventGUID++ : data.id + '');
+
+		if (data.className) {
+			if (typeof data.className == 'string') {
+				out.className = data.className.split(/\s+/);
+			}
+			else { // assumed to be an array
+				out.className = data.className;
+			}
+		}
+		else {
+			out.className = [];
+		}
+
+		out.allDay = allDay;
+		out.start = start;
+		out.end = end;
+
+		if (options.forceEventDuration && !out.end) {
+			out.end = getEventEnd(out);
+		}
+
+		backupEventDates(out);
+
+		return out;
 	}
 	
 	
@@ -396,7 +494,7 @@ function EventManager(options, _sources) {
 		}
 		var normalizers = fc.sourceNormalizers;
 		for (var i=0; i<normalizers.length; i++) {
-			normalizers[i](source);
+			normalizers[i].call(t, source);
 		}
 	}
 	
@@ -412,3 +510,11 @@ function EventManager(options, _sources) {
 
 
 }
+
+
+// updates the "backup" properties, which are preserved in order to compute diffs later on.
+function backupEventDates(event) {
+	event._allDay = event.allDay;
+	event._start = event.start.clone();
+	event._end = event.end ? event.end.clone() : null;
+}

+ 18 - 8
src/Header.js

@@ -68,16 +68,26 @@ function Header(calendar, options) {
 							};
 						}
 						if (buttonClick) {
-							var icon = options.theme ? smartProperty(options.buttonIcons, buttonName) : null; // why are we using smartProperty here?
-							var text = smartProperty(options.buttonText, buttonName); // why are we using smartProperty here?
+
+							// smartProperty allows different text per view button (ex: "Agenda Week" vs "Basic Week")
+							var themeIcon = smartProperty(options.themeButtonIcons, buttonName);
+							var normalIcon = smartProperty(options.buttonIcons, buttonName);
+							var text = smartProperty(options.buttonText, buttonName);
+							var html;
+
+							if (themeIcon && options.theme) {
+								html = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>";
+							}
+							else if (normalIcon && !options.theme) {
+								html = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>";
+							}
+							else {
+								html = htmlEscape(text || buttonName);
+							}
+
 							var button = $(
 								"<span class='fc-button fc-button-" + buttonName + " " + tm + "-state-default'>" +
-									(icon ?
-										"<span class='fc-icon-wrap'>" +
-											"<span class='ui-icon ui-icon-" + icon + "'/>" +
-										"</span>" :
-										text
-										) +
+									html +
 								"</span>"
 								)
 								.click(function() {

+ 14 - 19
src/agenda/AgendaDayView.js

@@ -1,39 +1,34 @@
 
 fcViews.agendaDay = AgendaDayView;
 
-
-function AgendaDayView(element, calendar) {
+function AgendaDayView(element, calendar) { // TODO: make a DayView mixin
 	var t = this;
 	
 	
 	// exports
+	t.incrementDate = incrementDate;
 	t.render = render;
 	
 	
 	// imports
 	AgendaView.call(t, element, calendar, 'agendaDay');
-	var opt = t.opt;
-	var renderAgenda = t.renderAgenda;
-	var skipHiddenDays = t.skipHiddenDays;
-	var formatDate = calendar.formatDate;
-	
-	
-	function render(date, delta) {
 
-		if (delta) {
-			addDays(date, delta);
-		}
-		skipHiddenDays(date, delta < 0 ? -1 : 1);
 
-		var start = cloneDate(date, true);
-		var end = addDays(cloneDate(start), 1);
+	function incrementDate(date, delta) {
+		var out = date.clone().stripTime().add('days', delta);
+		out = t.skipHiddenDays(out, delta < 0 ? -1 : 1);
+		return out;
+	}
+
+
+	function render(date) {
 
-		t.title = formatDate(date, opt('titleFormat'));
+		t.start = t.intervalStart = date.clone().stripTime();
+		t.end = t.intervalEnd = t.start.clone().add('days', 1);
 
-		t.start = t.visStart = start;
-		t.end = t.visEnd = end;
+		t.title = calendar.formatDate(t.start, t.opt('titleFormat'));
 
-		renderAgenda(1);
+		t.renderAgenda(1);
 	}
 	
 

+ 169 - 99
src/agenda/AgendaEventRenderer.js

@@ -15,15 +15,12 @@ function AgendaEventRenderer() {
 	var trigger = t.trigger;
 	var isEventDraggable = t.isEventDraggable;
 	var isEventResizable = t.isEventResizable;
-	var eventEnd = t.eventEnd;
 	var eventElementHandlers = t.eventElementHandlers;
 	var setHeight = t.setHeight;
 	var getDaySegmentContainer = t.getDaySegmentContainer;
 	var getSlotSegmentContainer = t.getSlotSegmentContainer;
 	var getHoverListener = t.getHoverListener;
-	var getMaxMinute = t.getMaxMinute;
-	var getMinMinute = t.getMinMinute;
-	var timePosition = t.timePosition;
+	var computeDateTop = t.computeDateTop;
 	var getIsCellAllDay = t.getIsCellAllDay;
 	var colContentLeft = t.colContentLeft;
 	var colContentRight = t.colContentRight;
@@ -31,7 +28,9 @@ function AgendaEventRenderer() {
 	var getColCnt = t.getColCnt;
 	var getColWidth = t.getColWidth;
 	var getSnapHeight = t.getSnapHeight;
-	var getSnapMinutes = t.getSnapMinutes;
+	var getSnapDuration = t.getSnapDuration;
+	var getSlotHeight = t.getSlotHeight;
+	var getSlotDuration = t.getSlotDuration;
 	var getSlotContainer = t.getSlotContainer;
 	var reportEventElement = t.reportEventElement;
 	var showEvents = t.showEvents;
@@ -41,9 +40,12 @@ function AgendaEventRenderer() {
 	var renderDayOverlay = t.renderDayOverlay;
 	var clearOverlays = t.clearOverlays;
 	var renderDayEvents = t.renderDayEvents;
+	var getMinTime = t.getMinTime;
+	var getMaxTime = t.getMaxTime;
 	var calendar = t.calendar;
 	var formatDate = calendar.formatDate;
-	var formatDates = calendar.formatDates;
+	var formatRange = calendar.formatRange;
+	var getEventEnd = calendar.getEventEnd;
 
 
 	// overrides
@@ -84,25 +86,21 @@ function AgendaEventRenderer() {
 	
 	function compileSlotSegs(events) {
 		var colCnt = getColCnt(),
-			minMinute = getMinMinute(),
-			maxMinute = getMaxMinute(),
-			d,
-			visEventEnds = $.map(events, slotEventEnd),
+			minTime = getMinTime(),
+			maxTime = getMaxTime(),
+			cellDate,
 			i,
 			j, seg,
 			colSegs,
 			segs = [];
 
 		for (i=0; i<colCnt; i++) {
-
-			d = cellToDate(0, i);
-			addMinutes(d, minMinute);
+			cellDate = cellToDate(0, i);
 
 			colSegs = sliceSegs(
 				events,
-				visEventEnds,
-				d,
-				addMinutes(cloneDate(d), maxMinute-minMinute)
+				cellDate.clone().time(minTime),
+				cellDate.clone().time(maxTime)
 			);
 
 			colSegs = placeSlotSegs(colSegs); // returns a new order
@@ -118,31 +116,45 @@ function AgendaEventRenderer() {
 	}
 
 
-	function sliceSegs(events, visEventEnds, start, end) {
+	function sliceSegs(events, rangeStart, rangeEnd) {
+
+		// normalize, because all dates will be compared w/o zones
+		rangeStart = rangeStart.clone().stripZone();
+		rangeEnd = rangeEnd.clone().stripZone();
+
 		var segs = [],
 			i, len=events.length, event,
 			eventStart, eventEnd,
 			segStart, segEnd,
 			isStart, isEnd;
 		for (i=0; i<len; i++) {
+
 			event = events[i];
-			eventStart = event.start;
-			eventEnd = visEventEnds[i];
-			if (eventEnd > start && eventStart < end) {
-				if (eventStart < start) {
-					segStart = cloneDate(start);
+
+			// get dates, make copies, then strip zone to normalize
+			eventStart = event.start.clone().stripZone();
+			eventEnd = getEventEnd(event).stripZone();
+
+			if (eventEnd > rangeStart && eventStart < rangeEnd) {
+
+				if (eventStart < rangeStart) {
+					segStart = rangeStart.clone();
 					isStart = false;
-				}else{
+				}
+				else {
 					segStart = eventStart;
 					isStart = true;
 				}
-				if (eventEnd > end) {
-					segEnd = cloneDate(end);
+
+				if (eventEnd > rangeEnd) {
+					segEnd = rangeEnd.clone();
 					isEnd = false;
-				}else{
+				}
+				else {
 					segEnd = eventEnd;
 					isEnd = true;
 				}
+
 				segs.push({
 					event: event,
 					start: segStart,
@@ -152,16 +164,8 @@ function AgendaEventRenderer() {
 				});
 			}
 		}
-		return segs.sort(compareSlotSegs);
-	}
-
 
-	function slotEventEnd(event) {
-		if (event.end) {
-			return cloneDate(event.end);
-		}else{
-			return addMinutes(cloneDate(event.start), opt('defaultEventMinutes'));
-		}
+		return segs.sort(compareSlotSegs);
 	}
 	
 	
@@ -194,8 +198,8 @@ function AgendaEventRenderer() {
 		for (i=0; i<segCnt; i++) {
 			seg = segs[i];
 			event = seg.event;
-			top = timePosition(seg.start, seg.start);
-			bottom = timePosition(seg.start, seg.end);
+			top = computeDateTop(seg.start, seg.start);
+			bottom = computeDateTop(seg.end, seg.start);
 			columnLeft = colContentLeft(seg.col);
 			columnRight = colContentRight(seg.col);
 			columnWidth = columnRight - columnLeft;
@@ -296,7 +300,9 @@ function AgendaEventRenderer() {
 				if (seg.contentTop !== undefined && height - seg.contentTop < 10) {
 					// not enough room for title, put it in the time (TODO: maybe make both display:inline instead)
 					eventElement.find('div.fc-event-time')
-						.text(formatDate(event.start, opt('timeFormat')) + ' - ' + event.title);
+						.text(
+							formatDate(event.start, opt('timeFormat')) + ' - ' + event.title
+						);
 					eventElement.find('div.fc-event-title')
 						.remove();
 				}
@@ -330,6 +336,7 @@ function AgendaEventRenderer() {
 		}else{
 			html += "div";
 		}
+
 		html +=
 			" class='" + classes.join(' ') + "'" +
 			" style=" +
@@ -341,14 +348,22 @@ function AgendaEventRenderer() {
 				"'" +
 			">" +
 			"<div class='fc-event-inner'>" +
-			"<div class='fc-event-time'>" +
-			htmlEscape(formatDates(event.start, event.end, opt('timeFormat'))) +
+			"<div class='fc-event-time'>";
+
+		if (event.end) {
+			html += htmlEscape(formatRange(event.start, event.end, opt('timeFormat')));
+		}else{
+			html += htmlEscape(formatDate(event.start, opt('timeFormat')));
+		}
+
+		html +=
 			"</div>" +
 			"<div class='fc-event-title'>" +
 			htmlEscape(event.title || '') +
 			"</div>" +
 			"</div>" +
 			"<div class='fc-event-bg'></div>";
+
 		if (seg.isEnd && isEventResizable(event)) {
 			html +=
 				"<div class='ui-resizable-handle ui-resizable-s'>=</div>";
@@ -386,79 +401,98 @@ function AgendaEventRenderer() {
 		var revert;
 		var allDay = true;
 		var dayDelta;
+
 		var hoverListener = getHoverListener();
 		var colWidth = getColWidth();
+		var minTime = getMinTime();
+		var slotDuration = getSlotDuration();
+		var slotHeight = getSlotHeight();
+		var snapDuration = getSnapDuration();
 		var snapHeight = getSnapHeight();
-		var snapMinutes = getSnapMinutes();
-		var minMinute = getMinMinute();
+
 		eventElement.draggable({
 			opacity: opt('dragOpacity', 'month'), // use whatever the month view was using
 			revertDuration: opt('dragRevertDuration'),
 			start: function(ev, ui) {
+
 				trigger('eventDragStart', eventElement, event, ev, ui);
 				hideEvents(event, eventElement);
 				origWidth = eventElement.width();
+
 				hoverListener.start(function(cell, origCell) {
 					clearOverlays();
 					if (cell) {
 						revert = false;
+
 						var origDate = cellToDate(0, origCell.col);
 						var date = cellToDate(0, cell.col);
-						dayDelta = dayDiff(date, origDate);
-						if (!cell.row) {
-							// on full-days
+						dayDelta = date.diff(origDate, 'days');
+
+						if (!cell.row) { // on full-days
+							
 							renderDayOverlay(
-								addDays(cloneDate(event.start), dayDelta),
-								addDays(exclEndDay(event), dayDelta)
+								event.start.clone().add('days', dayDelta),
+								getEventEnd(event).add('days', dayDelta)
 							);
+
 							resetElement();
-						}else{
-							// mouse is over bottom slots
+						}
+						else { // mouse is over bottom slots
+
 							if (isStart) {
 								if (allDay) {
 									// convert event to temporary slot-event
 									eventElement.width(colWidth - 10); // don't use entire width
-									setOuterHeight(
-										eventElement,
-										snapHeight * Math.round(
-											(event.end ? ((event.end - event.start) / MINUTE_MS) : opt('defaultEventMinutes')) /
-												snapMinutes
-										)
-									);
-									eventElement.draggable('option', 'grid', [colWidth, 1]);
+									setOuterHeight(eventElement, calendar.defaultTimedEventDuration / slotDuration * slotHeight); // the default height
+									eventElement.draggable('option', 'grid', [ colWidth, 1 ]);
 									allDay = false;
 								}
-							}else{
+							}
+							else {
 								revert = true;
 							}
 						}
+
 						revert = revert || (allDay && !dayDelta);
-					}else{
+					}
+					else {
 						resetElement();
 						revert = true;
 					}
+
 					eventElement.draggable('option', 'revert', revert);
+
 				}, ev, 'drag');
 			},
 			stop: function(ev, ui) {
 				hoverListener.stop();
 				clearOverlays();
 				trigger('eventDragStop', eventElement, event, ev, ui);
-				if (revert) {
-					// hasn't moved or is out of bounds (draggable has already reverted)
+
+				if (revert) { // hasn't moved or is out of bounds (draggable has already reverted)
+					
 					resetElement();
 					eventElement.css('filter', ''); // clear IE opacity side-effects
 					showEvents(event, eventElement);
-				}else{
-					// changed!
-					var minuteDelta = 0;
+				}
+				else { // changed!
+
+					var eventStart = event.start.clone().add('days', dayDelta); // already assumed to have a stripped time
+					var snapTime;
+					var snapIndex;
 					if (!allDay) {
-						minuteDelta = Math.round((eventElement.offset().top - getSlotContainer().offset().top) / snapHeight)
-							* snapMinutes
-							+ minMinute
-							- (event.start.getHours() * 60 + event.start.getMinutes());
+						snapIndex = Math.round((eventElement.offset().top - getSlotContainer().offset().top) / snapHeight); // why not use ui.offset.top?
+						snapTime = moment.duration(minTime + snapIndex * snapDuration);
+						eventStart = calendar.rezoneDate(eventStart.clone().time(snapTime));
 					}
-					eventDrop(this, event, dayDelta, minuteDelta, allDay, ev, ui);
+
+					eventDrop(
+						this, // el
+						event,
+						eventStart,
+						ev,
+						ui
+					);
 				}
 			}
 		});
@@ -481,7 +515,7 @@ function AgendaEventRenderer() {
 		var colCnt = getColCnt();
 		var colWidth = getColWidth();
 		var snapHeight = getSnapHeight();
-		var snapMinutes = getSnapMinutes();
+		var snapDuration = getSnapDuration();
 
 		// states
 		var origPosition; // original position of the element, not the mouse
@@ -490,7 +524,10 @@ function AgendaEventRenderer() {
 		var isAllDay, prevIsAllDay;
 		var colDelta, prevColDelta;
 		var dayDelta; // derived from colDelta
-		var minuteDelta, prevMinuteDelta;
+		var snapDelta, prevSnapDelta; // the number of snaps away from the original position
+
+		// newly computed
+		var eventStart, eventEnd;
 
 		eventElement.draggable({
 			scroll: false,
@@ -512,8 +549,10 @@ function AgendaEventRenderer() {
 				isAllDay = prevIsAllDay = getIsCellAllDay(origCell);
 				colDelta = prevColDelta = 0;
 				dayDelta = 0;
-				minuteDelta = prevMinuteDelta = 0;
+				snapDelta = prevSnapDelta = 0;
 
+				eventStart = null;
+				eventEnd = null;
 			},
 			drag: function(ev, ui) {
 
@@ -539,12 +578,12 @@ function AgendaEventRenderer() {
 						col = Math.max(0, col);
 						col = Math.min(colCnt-1, col);
 						var date = cellToDate(0, col);
-						dayDelta = dayDiff(date, origDate);
+						dayDelta = date.diff(origDate, 'days');
 					}
 
 					// calculate minute delta (only if over slots)
 					if (!isAllDay) {
-						minuteDelta = Math.round((ui.position.top - origPosition.top) / snapHeight) * snapMinutes;
+						snapDelta = Math.round((ui.position.top - origPosition.top) / snapHeight);
 					}
 				}
 
@@ -553,16 +592,26 @@ function AgendaEventRenderer() {
 					isInBounds != prevIsInBounds ||
 					isAllDay != prevIsAllDay ||
 					colDelta != prevColDelta ||
-					minuteDelta != prevMinuteDelta
+					snapDelta != prevSnapDelta
 				) {
 
+					// compute new dates
+					if (isAllDay) {
+						eventStart = event.start.clone().stripTime().add('days', dayDelta);
+						eventEnd = eventStart.clone().add(calendar.defaultAllDayEventDuration);
+					}
+					else {
+						eventStart = event.start.clone().add(snapDelta * snapDuration).add('days', dayDelta);
+						eventEnd = getEventEnd(event).add(snapDelta * snapDuration).add('days', dayDelta);
+					}
+
 					updateUI();
 
 					// update previous states for next time
 					prevIsInBounds = isInBounds;
 					prevIsAllDay = isAllDay;
 					prevColDelta = colDelta;
-					prevMinuteDelta = minuteDelta;
+					prevSnapDelta = snapDelta;
 				}
 
 				// if out-of-bounds, revert when done, and vice versa.
@@ -574,8 +623,14 @@ function AgendaEventRenderer() {
 				clearOverlays();
 				trigger('eventDragStop', eventElement, event, ev, ui);
 
-				if (isInBounds && (isAllDay || dayDelta || minuteDelta)) { // changed!
-					eventDrop(this, event, dayDelta, isAllDay ? 0 : minuteDelta, isAllDay, ev, ui);
+				if (isInBounds && (isAllDay || dayDelta || snapDelta)) { // changed!
+					eventDrop(
+						this, // el
+						event,
+						eventStart,
+						ev,
+						ui
+					);
 				}
 				else { // either no change or out-of-bounds (draggable has already reverted)
 
@@ -584,7 +639,7 @@ function AgendaEventRenderer() {
 					isAllDay = false;
 					colDelta = 0;
 					dayDelta = 0;
-					minuteDelta = 0;
+					snapDelta = 0;
 
 					updateUI();
 					eventElement.css('filter', ''); // clear IE opacity side-effects
@@ -605,26 +660,27 @@ function AgendaEventRenderer() {
 				if (isAllDay) {
 					timeElement.hide();
 					eventElement.draggable('option', 'grid', null); // disable grid snapping
-					renderDayOverlay(
-						addDays(cloneDate(event.start), dayDelta),
-						addDays(exclEndDay(event), dayDelta)
-					);
+					renderDayOverlay(eventStart, eventEnd);
 				}
 				else {
-					updateTimeText(minuteDelta);
+					updateTimeText();
 					timeElement.css('display', ''); // show() was causing display=inline
 					eventElement.draggable('option', 'grid', [colWidth, snapHeight]); // re-enable grid snapping
 				}
 			}
 		}
 
-		function updateTimeText(minuteDelta) {
-			var newStart = addMinutes(cloneDate(event.start), minuteDelta);
-			var newEnd;
-			if (event.end) {
-				newEnd = addMinutes(cloneDate(event.end), minuteDelta);
+		function updateTimeText() {
+			var text;
+			if (eventStart) { // must of had a state change
+				if (event.end) {
+					text = formatRange(eventStart, eventEnd, opt('timeFormat'));
+				}
+				else {
+					text = formatDate(eventStart, opt('timeFormat'));
+				}
+				timeElement.text(text);
 			}
-			timeElement.text(formatDates(newStart, newEnd, opt('timeFormat')));
 		}
 
 	}
@@ -638,7 +694,9 @@ function AgendaEventRenderer() {
 	function resizableSlotEvent(event, eventElement, timeElement) {
 		var snapDelta, prevSnapDelta;
 		var snapHeight = getSnapHeight();
-		var snapMinutes = getSnapMinutes();
+		var snapDuration = getSnapDuration();
+		var eventEnd;
+
 		eventElement.resizable({
 			handles: {
 				s: '.ui-resizable-handle'
@@ -653,22 +711,34 @@ function AgendaEventRenderer() {
 				// don't rely on ui.size.height, doesn't take grid into account
 				snapDelta = Math.round((Math.max(snapHeight, eventElement.height()) - ui.originalSize.height) / snapHeight);
 				if (snapDelta != prevSnapDelta) {
-					timeElement.text(
-						formatDates(
+					eventEnd = getEventEnd(event).add(snapDuration * snapDelta);
+					var text;
+					if (snapDelta || event.end) {
+						text = formatRange(
 							event.start,
-							(!snapDelta && !event.end) ? null : // no change, so don't display time range
-								addMinutes(eventEnd(event), snapMinutes*snapDelta),
+							eventEnd,
 							opt('timeFormat')
-						)
-					);
+						);
+					}
+					else {
+						text = formatDate(event.start, opt('timeFormat'));
+					}
+					timeElement.text(text);
 					prevSnapDelta = snapDelta;
 				}
 			},
 			stop: function(ev, ui) {
 				trigger('eventResizeStop', this, event, ev, ui);
 				if (snapDelta) {
-					eventResize(this, event, 0, snapMinutes*snapDelta, ev, ui);
-				}else{
+					eventResize(
+						this,
+						event,
+						eventEnd,
+						ev,
+						ui
+					);
+				}
+				else {
 					showEvents(event, eventElement);
 					// BUG: if event was really short, need to put title back in span
 				}

+ 193 - 132
src/agenda/AgendaView.js

@@ -2,22 +2,39 @@
 setDefaults({
 	allDaySlot: true,
 	allDayText: 'all-day',
-	firstHour: 6,
-	slotMinutes: 30,
-	defaultEventMinutes: 120,
-	axisFormat: 'h(:mm)tt',
+
+	scrollTime: '06:00:00',
+
+	slotDuration: '00:30:00',
+
+	axisFormat: generateAgendaAxisFormat,
 	timeFormat: {
-		agenda: 'h:mm{ - h:mm}'
+		agenda: generateAgendaTimeFormat
 	},
+
 	dragOpacity: {
 		agenda: .5
 	},
-	minTime: 0,
-	maxTime: 24,
+	minTime: '00:00:00',
+	maxTime: '24:00:00',
 	slotEventOverlap: true
 });
 
 
+function generateAgendaAxisFormat(options, langData) {
+	return langData.longDateFormat('LT')
+		.replace(':mm', '(:mm)')
+		.replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs
+		.replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
+}
+
+
+function generateAgendaTimeFormat(options, langData) {
+	return langData.longDateFormat('LT')
+		.replace(/\s*a$/i, ''); // remove trailing AM/PM
+}
+
+
 // TODO: make it work in quirks mode (event corners, all-day height)
 // TODO: test liquid width, especially in IE6
 
@@ -31,10 +48,9 @@ function AgendaView(element, calendar, viewName) {
 	t.setWidth = setWidth;
 	t.setHeight = setHeight;
 	t.afterRender = afterRender;
-	t.defaultEventEnd = defaultEventEnd;
-	t.timePosition = timePosition;
+	t.computeDateTop = computeDateTop;
 	t.getIsCellAllDay = getIsCellAllDay;
-	t.allDayRow = getAllDayRow;
+	t.allDayRow = function() { return allDayRow }; // badly named
 	t.getCoordinateGrid = function() { return coordinateGrid }; // specifically for AgendaEventRenderer
 	t.getHoverListener = function() { return hoverListener };
 	t.colLeft = colLeft;
@@ -43,14 +59,16 @@ function AgendaView(element, calendar, viewName) {
 	t.colContentRight = colContentRight;
 	t.getDaySegmentContainer = function() { return daySegmentContainer };
 	t.getSlotSegmentContainer = function() { return slotSegmentContainer };
-	t.getMinMinute = function() { return minMinute };
-	t.getMaxMinute = function() { return maxMinute };
 	t.getSlotContainer = function() { return slotContainer };
 	t.getRowCnt = function() { return 1 };
 	t.getColCnt = function() { return colCnt };
 	t.getColWidth = function() { return colWidth };
 	t.getSnapHeight = function() { return snapHeight };
-	t.getSnapMinutes = function() { return snapMinutes };
+	t.getSnapDuration = function() { return snapDuration };
+	t.getSlotHeight = function() { return slotHeight };
+	t.getSlotDuration = function() { return slotDuration };
+	t.getMinTime = function() { return minTime };
+	t.getMaxTime = function() { return maxTime };
 	t.defaultSelectionEnd = defaultSelectionEnd;
 	t.renderDayOverlay = renderDayOverlay;
 	t.renderSelection = renderSelection;
@@ -76,7 +94,9 @@ function AgendaView(element, calendar, viewName) {
 	var cellToDate = t.cellToDate;
 	var dateToCell = t.dateToCell;
 	var rangeToSegments = t.rangeToSegments;
+	var calendar = t.calendar;
 	var formatDate = calendar.formatDate;
+	var calculateWeekNumber = calendar.calculateWeekNumber;
 	
 	
 	// locals
@@ -105,9 +125,11 @@ function AgendaView(element, calendar, viewName) {
 	var axisWidth;
 	var colWidth;
 	var gutterWidth;
+
+	var slotDuration;
 	var slotHeight; // TODO: what if slotHeight changes? (see issue 650)
 
-	var snapMinutes;
+	var snapDuration;
 	var snapRatio; // ratio of number of "selection" slots to normal slots. (ex: 1, 2, 4)
 	var snapHeight; // holds the pixel hight of a "selection" slot
 	
@@ -121,11 +143,9 @@ function AgendaView(element, calendar, viewName) {
 	
 	var tm;
 	var rtl;
-	var minMinute, maxMinute;
+	var minTime;
+	var maxTime;
 	var colFormat;
-	var showWeekNumbers;
-	var weekNumberTitle;
-	var weekNumberFormat;
 	
 
 	
@@ -153,21 +173,14 @@ function AgendaView(element, calendar, viewName) {
 
 		tm = opt('theme') ? 'ui' : 'fc';
 		rtl = opt('isRTL')
-		minMinute = parseTime(opt('minTime'));
-		maxMinute = parseTime(opt('maxTime'));
 		colFormat = opt('columnFormat');
 
-		// week # options. (TODO: bad, logic also in other views)
-		showWeekNumbers = opt('weekNumbers');
-		weekNumberTitle = opt('weekNumberTitle');
-		if (opt('weekNumberCalculation') != 'iso') {
-			weekNumberFormat = "w";
-		}
-		else {
-			weekNumberFormat = "W";
-		}
+		minTime = moment.duration(opt('minTime'));
+		maxTime = moment.duration(opt('maxTime'));
 
-		snapMinutes = opt('snapMinutes') || opt('slotMinutes');
+		slotDuration = moment.duration(opt('slotDuration'));
+		snapDuration = opt('snapDuration');
+		snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration;
 	}
 
 
@@ -177,14 +190,13 @@ function AgendaView(element, calendar, viewName) {
 
 
 	function buildSkeleton() {
+		var s;
 		var headerClass = tm + "-widget-header";
 		var contentClass = tm + "-widget-content";
-		var s;
-		var d;
-		var i;
-		var maxd;
+		var slotTime;
+		var slotDate;
 		var minutes;
-		var slotNormal = opt('slotMinutes') % 15 == 0;
+		var slotNormal = slotDuration.asMinutes() % 15 == 0;
 		
 		buildDayTable();
 		
@@ -201,7 +213,12 @@ function AgendaView(element, calendar, viewName) {
 			s =
 				"<table style='width:100%' class='fc-agenda-allday' cellspacing='0'>" +
 				"<tr>" +
-				"<th class='" + headerClass + " fc-agenda-axis'>" + opt('allDayText') + "</th>" +
+				"<th class='" + headerClass + " fc-agenda-axis'>" +
+				(
+					opt('allDayHTML') ||
+					htmlEscape(opt('allDayText'))
+				) +
+				"</th>" +
 				"<td>" +
 				"<div class='fc-day-content'><div style='position:relative'/></div>" +
 				"</td>" +
@@ -240,27 +257,32 @@ function AgendaView(element, calendar, viewName) {
 		s =
 			"<table class='fc-agenda-slots' style='width:100%' cellspacing='0'>" +
 			"<tbody>";
-		d = zeroDate();
-		maxd = addMinutes(cloneDate(d), maxMinute);
-		addMinutes(d, minMinute);
+
+		slotTime = moment.duration(+minTime); // i wish there was .clone() for durations
 		slotCnt = 0;
-		for (i=0; d < maxd; i++) {
-			minutes = d.getMinutes();
+		while (slotTime < maxTime) {
+			slotDate = t.start.clone().time(slotTime); // will be in UTC but that's good. to avoid DST issues
+			minutes = slotDate.minutes();
 			s +=
-				"<tr class='fc-slot" + i + ' ' + (!minutes ? '' : 'fc-minor') + "'>" +
+				"<tr class='fc-slot" + slotCnt + ' ' + (!minutes ? '' : 'fc-minor') + "'>" +
 				"<th class='fc-agenda-axis " + headerClass + "'>" +
-				((!slotNormal || !minutes) ? formatDate(d, opt('axisFormat')) : '&nbsp;') +
+				((!slotNormal || !minutes) ?
+					htmlEscape(formatDate(slotDate, opt('axisFormat'))) :
+					'&nbsp;'
+					) +
 				"</th>" +
 				"<td class='" + contentClass + "'>" +
 				"<div style='position:relative'>&nbsp;</div>" +
 				"</td>" +
 				"</tr>";
-			addMinutes(d, opt('slotMinutes'));
+			slotTime.add(slotDuration);
 			slotCnt++;
 		}
+
 		s +=
 			"</tbody>" +
 			"</table>";
+
 		slotTable = $(s).appendTo(slotContainer);
 		
 		slotBind(slotTable.find('td'));
@@ -319,14 +341,14 @@ function AgendaView(element, calendar, viewName) {
 			"<thead>" +
 			"<tr>";
 
-		if (showWeekNumbers) {
+		if (opt('weekNumbers')) {
 			date = cellToDate(0, 0);
-			weekText = formatDate(date, weekNumberFormat);
+			weekText = calculateWeekNumber(date);
 			if (rtl) {
-				weekText += weekNumberTitle;
+				weekText += opt('weekNumberTitle');
 			}
 			else {
-				weekText = weekNumberTitle + weekText;
+				weekText = opt('weekNumberTitle') + weekText;
 			}
 			html +=
 				"<th class='fc-agenda-axis fc-week-number " + headerClass + "'>" +
@@ -340,7 +362,7 @@ function AgendaView(element, calendar, viewName) {
 		for (col=0; col<colCnt; col++) {
 			date = cellToDate(0, col);
 			html +=
-				"<th class='fc-" + dayIDs[date.getDay()] + " fc-col" + col + ' ' + headerClass + "'>" +
+				"<th class='fc-" + dayIDs[date.day()] + " fc-col" + col + ' ' + headerClass + "'>" +
 				htmlEscape(formatDate(date, colFormat)) +
 				"</th>";
 		}
@@ -358,7 +380,7 @@ function AgendaView(element, calendar, viewName) {
 		var headerClass = tm + "-widget-header"; // TODO: make these when updateOptions() called
 		var contentClass = tm + "-widget-content";
 		var date;
-		var today = clearTime(new Date());
+		var today = calendar.getNow().stripTime();
 		var col;
 		var cellsHTML;
 		var cellHTML;
@@ -378,10 +400,10 @@ function AgendaView(element, calendar, viewName) {
 
 			classNames = [
 				'fc-col' + col,
-				'fc-' + dayIDs[date.getDay()],
+				'fc-' + dayIDs[date.day()],
 				contentClass
 			];
-			if (+date == +today) {
+			if (date.isSame(today, 'day')) {
 				classNames.push(
 					tm + '-state-highlight',
 					'fc-today'
@@ -447,9 +469,12 @@ function AgendaView(element, calendar, viewName) {
 		
 		// the stylesheet guarantees that the first row has no border.
 		// this allows .height() to work well cross-browser.
-		slotHeight = slotTable.find('tr:first').height() + 1; // +1 for bottom border
+		var slotHeight0 = slotTable.find('tr:first').height() + 1; // +1 for bottom border
+		var slotHeight1 = slotTable.find('tr:eq(1)').height();
+		// HACK: i forget why we do this, but i think a cross-browser issue
+		slotHeight = (slotHeight0 + slotHeight1) / 2;
 
-		snapRatio = opt('slotMinutes') / snapMinutes;
+		snapRatio = slotDuration / snapDuration;
 		snapHeight = slotHeight / snapRatio;
 	}
 	
@@ -507,13 +532,14 @@ function AgendaView(element, calendar, viewName) {
 
 
 	function resetScroll() {
-		var d0 = zeroDate();
-		var scrollDate = cloneDate(d0);
-		scrollDate.setHours(opt('firstHour'));
-		var top = timePosition(d0, scrollDate) + 1; // +1 for the border
+		var top = computeTimeTop(
+			moment.duration(opt('scrollTime'))
+		) + 1; // +1 for the border
+
 		function scroll() {
 			slotScroller.scrollTop(top);
 		}
+
 		scroll();
 		setTimeout(scroll, 0); // overrides any previous scroll state made by the browser
 	}
@@ -545,15 +571,24 @@ function AgendaView(element, calendar, viewName) {
 		if (!opt('selectable')) { // if selectable, SelectionManager will worry about dayClick
 			var col = Math.min(colCnt-1, Math.floor((ev.pageX - dayTable.offset().left - axisWidth) / colWidth));
 			var date = cellToDate(0, col);
-			var rowMatch = this.parentNode.className.match(/fc-slot(\d+)/); // TODO: maybe use data
-			if (rowMatch) {
-				var mins = parseInt(rowMatch[1]) * opt('slotMinutes');
-				var hours = Math.floor(mins/60);
-				date.setHours(hours);
-				date.setMinutes(mins%60 + minMinute);
-				trigger('dayClick', dayBodyCells[col], date, false, ev);
+			var match = this.parentNode.className.match(/fc-slot(\d+)/); // TODO: maybe use data
+			if (match) {
+				var slotIndex = parseInt(match[1]);
+				date.add(minTime + slotIndex * slotDuration);
+				date = calendar.rezoneDate(date);
+				trigger(
+					'dayClick',
+					dayBodyCells[col],
+					date,
+					ev
+				);
 			}else{
-				trigger('dayClick', dayBodyCells[col], date, true, ev);
+				trigger(
+					'dayClick',
+					dayBodyCells[col],
+					date,
+					ev
+				);
 			}
 		}
 	}
@@ -594,15 +629,24 @@ function AgendaView(element, calendar, viewName) {
 	
 
 	function renderSlotOverlay(overlayStart, overlayEnd) {
-		for (var i=0; i<colCnt; i++) {
+
+		// normalize, because dayStart/dayEnd have stripped time+zone
+		overlayStart = overlayStart.clone().stripZone();
+		overlayEnd = overlayEnd.clone().stripZone();
+
+		for (var i=0; i<colCnt; i++) { // loop through the day columns
+
 			var dayStart = cellToDate(0, i);
-			var dayEnd = addDays(cloneDate(dayStart), 1);
-			var stretchStart = new Date(Math.max(dayStart, overlayStart));
-			var stretchEnd = new Date(Math.min(dayEnd, overlayEnd));
+			var dayEnd = dayStart.clone().add('days', 1);
+
+			var stretchStart = dayStart < overlayStart ? overlayStart : dayStart; // the max of the two
+			var stretchEnd = dayEnd < overlayEnd ? dayEnd : overlayEnd; // the min of the two
+
 			if (stretchStart < stretchEnd) {
 				var rect = coordinateGrid.rect(0, i, 0, i, slotContainer); // only use it for horizontal coords
-				var top = timePosition(dayStart, stretchStart);
-				var bottom = timePosition(dayStart, stretchEnd);
+				var top = computeDateTop(stretchStart, dayStart);
+				var bottom = computeDateTop(stretchEnd, dayStart);
+				
 				rect.top = top;
 				rect.height = bottom - top;
 				slotBind(
@@ -681,84 +725,92 @@ function AgendaView(element, calendar, viewName) {
 	}
 
 
-	function getIsCellAllDay(cell) {
+	function getIsCellAllDay(cell) { // TODO: remove because mom.hasTime() from realCellToDate() is better
 		return opt('allDaySlot') && !cell.row;
 	}
 
 
 	function realCellToDate(cell) { // ugh "real" ... but blame it on our abuse of the "cell" system
-		var d = cellToDate(0, cell.col);
+		var date = cellToDate(0, cell.col);
 		var slotIndex = cell.row;
+
 		if (opt('allDaySlot')) {
 			slotIndex--;
 		}
+
 		if (slotIndex >= 0) {
-			addMinutes(d, minMinute + slotIndex * snapMinutes);
+			date.time(moment.duration(minTime + slotIndex * slotDuration));
+			date = calendar.rezoneDate(date);
 		}
-		return d;
+
+		return date;
 	}
-	
-	
-	// get the Y coordinate of the given time on the given day (both Date objects)
-	function timePosition(day, time) { // both date objects. day holds 00:00 of current day
-		day = cloneDate(day, true);
-		if (time < addMinutes(cloneDate(day), minMinute)) {
+
+
+	function computeDateTop(date, startOfDayDate) {
+		return computeTimeTop(
+			moment.duration(
+				date.clone().stripZone() - startOfDayDate.clone().stripTime()
+			)
+		);
+	}
+
+
+	function computeTimeTop(time) { // time is a duration
+
+		if (time < minTime) {
 			return 0;
 		}
-		if (time >= addMinutes(cloneDate(day), maxMinute)) {
+		if (time >= maxTime) {
 			return slotTable.height();
 		}
-		var slotMinutes = opt('slotMinutes'),
-			minutes = time.getHours()*60 + time.getMinutes() - minMinute,
-			slotI = Math.floor(minutes / slotMinutes),
-			slotTop = slotTopCache[slotI];
+
+		var slots = (time - minTime) / slotDuration;
+		var slotIndex = Math.floor(slots);
+		var slotPartial = slots - slotIndex;
+		var slotTop = slotTopCache[slotIndex];
+
+		// find the position of the corresponding <tr>
+		// need to use this tecnhique because not all rows are rendered at same height sometimes.
 		if (slotTop === undefined) {
-			slotTop = slotTopCache[slotI] =
-				slotTable.find('tr').eq(slotI).find('td div')[0].offsetTop;
+			slotTop = slotTopCache[slotIndex] =
+				slotTable.find('tr').eq(slotIndex).find('td div')[0].offsetTop;
 				// .eq() is faster than ":eq()" selector
 				// [0].offsetTop is faster than .position().top (do we really need this optimization?)
 				// a better optimization would be to cache all these divs
 		}
-		return Math.max(0, Math.round(
-			slotTop - 1 + slotHeight * ((minutes % slotMinutes) / slotMinutes)
-		));
-	}
-	
-	
-	function getAllDayRow(index) {
-		return allDayRow;
-	}
-	
-	
-	function defaultEventEnd(event) {
-		var start = cloneDate(event.start);
-		if (event.allDay) {
-			return start;
-		}
-		return addMinutes(start, opt('defaultEventMinutes'));
+
+		var top =
+			slotTop - 1 + // because first row doesn't have a top border
+			slotPartial * slotHeight; // part-way through the row
+
+		top = Math.max(top, 0);
+
+		return top;
 	}
 	
 	
 	
 	/* Selection
 	---------------------------------------------------------------------------------*/
+
 	
-	
-	function defaultSelectionEnd(startDate, allDay) {
-		if (allDay) {
-			return cloneDate(startDate);
+	function defaultSelectionEnd(start) {
+		if (start.hasTime()) {
+			return start.clone().add(slotDuration);
+		}
+		else {
+			return start.clone().add('days', 1);
 		}
-		return addMinutes(cloneDate(startDate), opt('slotMinutes'));
 	}
 	
 	
-	function renderSelection(startDate, endDate, allDay) { // only for all-day
-		if (allDay) {
-			if (opt('allDaySlot')) {
-				renderDayOverlay(startDate, addDays(cloneDate(endDate), 1), true);
-			}
-		}else{
-			renderSlotSelection(startDate, endDate);
+	function renderSelection(start, end) {
+		if (start.hasTime() || end.hasTime()) {
+			renderSlotSelection(start, end);
+		}
+		else if (opt('allDaySlot')) {
+			renderDayOverlay(start, end, true); // true for refreshing coordinate grid
 		}
 	}
 	
@@ -770,8 +822,8 @@ function AgendaView(element, calendar, viewName) {
 			var col = dateToCell(startDate).col;
 			if (col >= 0 && col < colCnt) { // only works when times are on same day
 				var rect = coordinateGrid.rect(0, col, 0, col, slotContainer); // only for horizontal coords
-				var top = timePosition(startDate, startDate);
-				var bottom = timePosition(startDate, endDate);
+				var top = computeDateTop(startDate, startDate);
+				var bottom = computeDateTop(endDate, startDate);
 				if (bottom > top) { // protect against selections that are entirely before or after visible range
 					rect.top = top;
 					rect.height = bottom - top;
@@ -834,9 +886,9 @@ function AgendaView(element, calendar, viewName) {
 					var d2 = realCellToDate(cell);
 					dates = [
 						d1,
-						addMinutes(cloneDate(d1), snapMinutes), // calculate minutes depending on selection slot minutes 
+						d1.clone().add(snapDuration), // calculate minutes depending on selection slot minutes
 						d2,
-						addMinutes(cloneDate(d2), snapMinutes)
+						d2.clone().add(snapDuration)
 					].sort(dateCompare);
 					renderSlotSelection(dates[0], dates[3]);
 				}else{
@@ -847,17 +899,17 @@ function AgendaView(element, calendar, viewName) {
 				hoverListener.stop();
 				if (dates) {
 					if (+dates[0] == +dates[1]) {
-						reportDayClick(dates[0], false, ev);
+						reportDayClick(dates[0], ev);
 					}
-					reportSelection(dates[0], dates[3], false, ev);
+					reportSelection(dates[0], dates[3], ev);
 				}
 			});
 		}
 	}
 
 
-	function reportDayClick(date, allDay, ev) {
-		trigger('dayClick', dayBodyCells[dateToCell(date).col], date, allDay, ev);
+	function reportDayClick(date, ev) {
+		trigger('dayClick', dayBodyCells[dateToCell(date).col], date, ev);
 	}
 	
 	
@@ -870,13 +922,16 @@ function AgendaView(element, calendar, viewName) {
 		hoverListener.start(function(cell) {
 			clearOverlays();
 			if (cell) {
-				if (getIsCellAllDay(cell)) {
-					renderCellOverlay(cell.row, cell.col, cell.row, cell.col);
-				}else{
-					var d1 = realCellToDate(cell);
-					var d2 = addMinutes(cloneDate(d1), opt('defaultEventMinutes'));
+				var d1 = realCellToDate(cell);
+				var d2 = d1.clone();
+				if (d1.hasTime()) {
+					d2.add(calendar.defaultTimedEventDuration);
 					renderSlotOverlay(d1, d2);
 				}
+				else {
+					d2.add(calendar.defaultAllDayEventDuration);
+					renderDayOverlay(d1, d2);
+				}
 			}
 		}, ev);
 	}
@@ -886,7 +941,13 @@ function AgendaView(element, calendar, viewName) {
 		var cell = hoverListener.stop();
 		clearOverlays();
 		if (cell) {
-			trigger('drop', _dragElement, realCellToDate(cell), getIsCellAllDay(cell), ev, ui);
+			trigger(
+				'drop',
+				_dragElement,
+				realCellToDate(cell),
+				ev,
+				ui
+			);
 		}
 	}
 	

+ 17 - 28
src/agenda/AgendaWeekView.js

@@ -1,52 +1,41 @@
 
 fcViews.agendaWeek = AgendaWeekView;
 
-function AgendaWeekView(element, calendar) {
+function AgendaWeekView(element, calendar) { // TODO: do a WeekView mixin
 	var t = this;
 	
 	
 	// exports
+	t.incrementDate = incrementDate;
 	t.render = render;
 	
 	
 	// imports
 	AgendaView.call(t, element, calendar, 'agendaWeek');
-	var opt = t.opt;
-	var renderAgenda = t.renderAgenda;
-	var skipHiddenDays = t.skipHiddenDays;
-	var getCellsPerWeek = t.getCellsPerWeek;
-	var formatDates = calendar.formatDates;
 
-	
-	function render(date, delta) {
 
-		if (delta) {
-			addDays(date, delta * 7);
-		}
+	function incrementDate(date, delta) {
+		return date.clone().stripTime().add('weeks', delta).startOf('week');
+	}
 
-		var start = addDays(cloneDate(date), -((date.getDay() - opt('firstDay') + 7) % 7));
-		var end = addDays(cloneDate(start), 7);
 
-		var visStart = cloneDate(start);
-		skipHiddenDays(visStart);
+	function render(date) {
 
-		var visEnd = cloneDate(end);
-		skipHiddenDays(visEnd, -1, true);
+		t.intervalStart = date.clone().stripTime().startOf('week');
+		t.intervalEnd = t.intervalStart.clone().add('weeks', 1);
 
-		var colCnt = getCellsPerWeek();
+		t.start = t.skipHiddenDays(t.intervalStart);
+		t.end = t.skipHiddenDays(t.intervalEnd, -1, true);
 
-		t.title = formatDates(
-			visStart,
-			addDays(cloneDate(visEnd), -1),
-			opt('titleFormat')
+		t.title = calendar.formatRange(
+			t.start,
+			t.end.clone().subtract(1), // make inclusive by subtracting 1 ms
+			t.opt('titleFormat'),
+			' \u2014 ' // emphasized dash
 		);
 
-		t.start = start;
-		t.end = end;
-		t.visStart = visStart;
-		t.visEnd = visEnd;
-
-		renderAgenda(colCnt);
+		t.renderAgenda(t.getCellsPerWeek());
 	}
 
+
 }

+ 4 - 1
src/agenda/agenda.css

@@ -15,10 +15,13 @@
 	padding: 0 4px;
 	vertical-align: middle;
 	text-align: right;
-	white-space: nowrap;
 	font-weight: normal;
 	}
 
+.fc-agenda-slots .fc-agenda-axis {
+	white-space: nowrap;
+	}
+
 .fc-agenda .fc-week-number {
 	font-weight: bold;
 	}

+ 14 - 19
src/basic/BasicDayView.js

@@ -1,39 +1,34 @@
 
 fcViews.basicDay = BasicDayView;
 
-
-function BasicDayView(element, calendar) {
+function BasicDayView(element, calendar) { // TODO: make a DayView mixin
 	var t = this;
 	
 	
 	// exports
+	t.incrementDate = incrementDate;
 	t.render = render;
 	
 	
 	// imports
 	BasicView.call(t, element, calendar, 'basicDay');
-	var opt = t.opt;
-	var renderBasic = t.renderBasic;
-	var skipHiddenDays = t.skipHiddenDays;
-	var formatDate = calendar.formatDate;
-	
-	
-	function render(date, delta) {
 
-		if (delta) {
-			addDays(date, delta);
-		}
-		skipHiddenDays(date, delta < 0 ? -1 : 1);
 
-		var start = cloneDate(date, true);
-		var end = addDays(cloneDate(start), 1);
+	function incrementDate(date, delta) {
+		var out = date.clone().stripTime().add('days', delta);
+		out = t.skipHiddenDays(out, delta < 0 ? -1 : 1);
+		return out;
+	}
+
+
+	function render(date) {
 
-		t.title = formatDate(date, opt('titleFormat'));
+		t.start = t.intervalStart = date.clone().stripTime();
+		t.end = t.intervalEnd = t.start.clone().add('days', 1);
 
-		t.start = t.visStart = start;
-		t.end = t.visEnd = end;
+		t.title = calendar.formatDate(t.start, t.opt('titleFormat'));
 
-		renderBasic(1, 1, false);
+		t.renderBasic(1, 1, false);
 	}
 	
 	

+ 32 - 40
src/basic/BasicView.js

@@ -19,7 +19,6 @@ function BasicView(element, calendar, viewName) {
 	t.reportDayClick = reportDayClick; // for selection (kinda hacky)
 	t.dragStart = dragStart;
 	t.dragStop = dragStop;
-	t.defaultEventEnd = defaultEventEnd;
 	t.getHoverListener = function() { return hoverListener };
 	t.colLeft = colLeft;
 	t.colRight = colRight;
@@ -46,7 +45,9 @@ function BasicView(element, calendar, viewName) {
 	var cellToDate = t.cellToDate;
 	var dateToCell = t.dateToCell;
 	var rangeToSegments = t.rangeToSegments;
+	var calendar = t.calendar;
 	var formatDate = calendar.formatDate;
+	var calculateWeekNumber = calendar.calculateWeekNumber;
 	
 	
 	// locals
@@ -77,8 +78,6 @@ function BasicView(element, calendar, viewName) {
 	var tm;
 	var colFormat;
 	var showWeekNumbers;
-	var weekNumberTitle;
-	var weekNumberFormat;
 	
 	
 	
@@ -106,16 +105,7 @@ function BasicView(element, calendar, viewName) {
 	function updateOptions() {
 		tm = opt('theme') ? 'ui' : 'fc';
 		colFormat = opt('columnFormat');
-
-		// week # options. (TODO: bad, logic also in other views)
 		showWeekNumbers = opt('weekNumbers');
-		weekNumberTitle = opt('weekNumberTitle');
-		if (opt('weekNumberCalculation') != 'iso') {
-			weekNumberFormat = "w";
-		}
-		else {
-			weekNumberFormat = "W";
-		}
 	}
 	
 	
@@ -188,14 +178,14 @@ function BasicView(element, calendar, viewName) {
 		if (showWeekNumbers) {
 			html +=
 				"<th class='fc-week-number " + headerClass + "'>" +
-				htmlEscape(weekNumberTitle) +
+				htmlEscape(opt('weekNumberTitle')) +
 				"</th>";
 		}
 
 		for (col=0; col<colCnt; col++) {
 			date = cellToDate(0, col);
 			html +=
-				"<th class='fc-day-header fc-" + dayIDs[date.getDay()] + " " + headerClass + "'>" +
+				"<th class='fc-day-header fc-" + dayIDs[date.day()] + " " + headerClass + "'>" +
 				htmlEscape(formatDate(date, colFormat)) +
 				"</th>";
 		}
@@ -224,7 +214,7 @@ function BasicView(element, calendar, viewName) {
 				html +=
 					"<td class='fc-week-number " + contentClass + "'>" +
 					"<div>" +
-					htmlEscape(formatDate(date, weekNumberFormat)) +
+					htmlEscape(calculateWeekNumber(date)) +
 					"</div>" +
 					"</td>";
 			}
@@ -243,21 +233,21 @@ function BasicView(element, calendar, viewName) {
 	}
 
 
-	function buildCellHTML(date) {
-		var contentClass = tm + "-widget-content";
-		var month = t.start.getMonth();
-		var today = clearTime(new Date());
+	function buildCellHTML(date) { // date assumed to have stripped time
+		var month = t.intervalStart.month();
+		var today = calendar.getNow().stripTime();
 		var html = '';
+		var contentClass = tm + "-widget-content";
 		var classNames = [
 			'fc-day',
-			'fc-' + dayIDs[date.getDay()],
+			'fc-' + dayIDs[date.day()],
 			contentClass
 		];
 
-		if (date.getMonth() != month) {
+		if (date.month() != month) {
 			classNames.push('fc-other-month');
 		}
-		if (+date == +today) {
+		if (date.isSame(today, 'day')) {
 			classNames.push(
 				'fc-today',
 				tm + '-state-highlight'
@@ -273,12 +263,12 @@ function BasicView(element, calendar, viewName) {
 		html +=
 			"<td" +
 			" class='" + classNames.join(' ') + "'" +
-			" data-date='" + formatDate(date, 'yyyy-MM-dd') + "'" +
+			" data-date='" + date.format() + "'" +
 			">" +
 			"<div>";
 
 		if (showNumbers) {
-			html += "<div class='fc-day-number'>" + date.getDate() + "</div>";
+			html += "<div class='fc-day-number'>" + date.date() + "</div>";
 		}
 
 		html +=
@@ -353,8 +343,8 @@ function BasicView(element, calendar, viewName) {
 	
 	function dayClick(ev) {
 		if (!opt('selectable')) { // if selectable, SelectionManager will worry about dayClick
-			var date = parseISO8601($(this).data('date'));
-			trigger('dayClick', this, date, true, ev);
+			var date = calendar.moment($(this).data('date'));
+			trigger('dayClick', this, date, ev);
 		}
 	}
 	
@@ -398,13 +388,13 @@ function BasicView(element, calendar, viewName) {
 	-----------------------------------------------------------------------*/
 	
 	
-	function defaultSelectionEnd(startDate, allDay) {
-		return cloneDate(startDate);
+	function defaultSelectionEnd(start) {
+		return start.clone().stripTime().add('days', 1);
 	}
 	
 	
-	function renderSelection(startDate, endDate, allDay) {
-		renderDayOverlay(startDate, addDays(cloneDate(endDate), 1), true); // rebuild every time???
+	function renderSelection(start, end) { // end is exclusive
+		renderDayOverlay(start, end, true); // true = rebuild every time
 	}
 	
 	
@@ -413,10 +403,10 @@ function BasicView(element, calendar, viewName) {
 	}
 	
 	
-	function reportDayClick(date, allDay, ev) {
+	function reportDayClick(date, ev) {
 		var cell = dateToCell(date);
 		var _element = bodyCells[cell.row*colCnt + cell.col];
-		trigger('dayClick', _element, date, allDay, ev);
+		trigger('dayClick', _element, date, ev);
 	}
 	
 	
@@ -429,7 +419,9 @@ function BasicView(element, calendar, viewName) {
 		hoverListener.start(function(cell) {
 			clearOverlays();
 			if (cell) {
-				renderCellOverlay(cell.row, cell.col, cell.row, cell.col);
+				var d1 = cellToDate(cell);
+				var d2 = d1.clone().add(calendar.defaultAllDayEventDuration);
+				renderDayOverlay(d1, d2);
 			}
 		}, ev);
 	}
@@ -439,8 +431,13 @@ function BasicView(element, calendar, viewName) {
 		var cell = hoverListener.stop();
 		clearOverlays();
 		if (cell) {
-			var d = cellToDate(cell);
-			trigger('drop', _dragElement, d, true, ev, ui);
+			trigger(
+				'drop',
+				_dragElement,
+				cellToDate(cell),
+				ev,
+				ui
+			);
 		}
 	}
 	
@@ -450,11 +447,6 @@ function BasicView(element, calendar, viewName) {
 	--------------------------------------------------------*/
 	
 	
-	function defaultEventEnd(event) {
-		return cloneDate(event.start);
-	}
-	
-	
 	coordinateGrid = new CoordinateGrid(function(rows, cols) {
 		var e, n, p;
 		headCells.each(function(i, _e) {

+ 16 - 28
src/basic/BasicWeekView.js

@@ -1,52 +1,40 @@
 
 fcViews.basicWeek = BasicWeekView;
 
-function BasicWeekView(element, calendar) {
+function BasicWeekView(element, calendar) { // TODO: do a WeekView mixin
 	var t = this;
 	
 	
 	// exports
+	t.incrementDate = incrementDate;
 	t.render = render;
 	
 	
 	// imports
 	BasicView.call(t, element, calendar, 'basicWeek');
-	var opt = t.opt;
-	var renderBasic = t.renderBasic;
-	var skipHiddenDays = t.skipHiddenDays;
-	var getCellsPerWeek = t.getCellsPerWeek;
-	var formatDates = calendar.formatDates;
-	
-	
-	function render(date, delta) {
 
-		if (delta) {
-			addDays(date, delta * 7);
-		}
 
-		var start = addDays(cloneDate(date), -((date.getDay() - opt('firstDay') + 7) % 7));
-		var end = addDays(cloneDate(start), 7);
+	function incrementDate(date, delta) {
+		return date.clone().stripTime().add('weeks', delta).startOf('week');
+	}
 
-		var visStart = cloneDate(start);
-		skipHiddenDays(visStart);
 
-		var visEnd = cloneDate(end);
-		skipHiddenDays(visEnd, -1, true);
+	function render(date) {
 
-		var colCnt = getCellsPerWeek();
+		t.intervalStart = date.clone().stripTime().startOf('week');
+		t.intervalEnd = t.intervalStart.clone().add('weeks', 1);
 
-		t.start = start;
-		t.end = end;
-		t.visStart = visStart;
-		t.visEnd = visEnd;
+		t.start = t.skipHiddenDays(t.intervalStart);
+		t.end = t.skipHiddenDays(t.intervalEnd, -1, true);
 
-		t.title = formatDates(
-			visStart,
-			addDays(cloneDate(visEnd), -1),
-			opt('titleFormat')
+		t.title = calendar.formatRange(
+			t.start,
+			t.end.clone().subtract(1), // make inclusive by subtracting 1 ms
+			t.opt('titleFormat'),
+			' \u2014 ' // emphasized dash
 		);
 
-		renderBasic(1, colCnt, false);
+		t.renderBasic(1, t.getCellsPerWeek(), false);
 	}
 	
 	

+ 18 - 33
src/basic/MonthView.js

@@ -6,56 +6,41 @@ function MonthView(element, calendar) {
 	
 	
 	// exports
+	t.incrementDate = incrementDate;
 	t.render = render;
 	
 	
 	// imports
 	BasicView.call(t, element, calendar, 'month');
-	var opt = t.opt;
-	var renderBasic = t.renderBasic;
-	var skipHiddenDays = t.skipHiddenDays;
-	var getCellsPerWeek = t.getCellsPerWeek;
-	var formatDate = calendar.formatDate;
-	
-	
-	function render(date, delta) {
 
-		if (delta) {
-			addMonths(date, delta);
-			date.setDate(1);
-		}
 
-		var firstDay = opt('firstDay');
+	function incrementDate(date, delta) {
+		return date.clone().stripTime().add('months', delta).startOf('month');
+	}
 
-		var start = cloneDate(date, true);
-		start.setDate(1);
 
-		var end = addMonths(cloneDate(start), 1);
+	function render(date) {
 
-		var visStart = cloneDate(start);
-		addDays(visStart, -((visStart.getDay() - firstDay + 7) % 7));
-		skipHiddenDays(visStart);
+		t.intervalStart = date.clone().stripTime().startOf('month');
+		t.intervalEnd = t.intervalStart.clone().add('months', 1);
 
-		var visEnd = cloneDate(end);
-		addDays(visEnd, (7 - visEnd.getDay() + firstDay) % 7);
-		skipHiddenDays(visEnd, -1, true);
+		t.start = t.intervalStart.clone().startOf('week');
+		t.start = t.skipHiddenDays(t.start);
 
-		var colCnt = getCellsPerWeek();
-		var rowCnt = Math.round(dayDiff(visEnd, visStart) / 7); // should be no need for Math.round
+		t.end = t.intervalEnd.clone().add('days', (7 - t.intervalEnd.weekday()) % 7);
+		t.end = t.skipHiddenDays(t.end, -1, true);
 
-		if (opt('weekMode') == 'fixed') {
-			addDays(visEnd, (6 - rowCnt) * 7); // add weeks to make up for it
+		var rowCnt = Math.ceil( // need to ceil in case there are hidden days
+			t.end.diff(t.start, 'weeks', true) // returnfloat=true
+		);
+		if (t.opt('weekMode') == 'fixed') {
+			t.end.add('weeks', 6 - rowCnt);
 			rowCnt = 6;
 		}
 
-		t.title = formatDate(start, opt('titleFormat'));
-
-		t.start = start;
-		t.end = end;
-		t.visStart = visStart;
-		t.visEnd = visEnd;
+		t.title = calendar.formatDate(t.intervalStart, t.opt('titleFormat'));
 
-		renderBasic(rowCnt, colCnt, true);
+		t.renderBasic(rowCnt, t.getCellsPerWeek(), true);
 	}
 	
 	

+ 40 - 23
src/common/DayEventRenderer.js

@@ -14,7 +14,6 @@ function DayEventRenderer() {
 	var trigger = t.trigger;
 	var isEventDraggable = t.isEventDraggable;
 	var isEventResizable = t.isEventResizable;
-	var eventEnd = t.eventEnd;
 	var reportEventElement = t.reportEventElement;
 	var eventElementHandlers = t.eventElementHandlers;
 	var showEvents = t.showEvents;
@@ -31,7 +30,6 @@ function DayEventRenderer() {
 	var colContentRight = t.colContentRight;
 	var dateToCell = t.dateToCell;
 	var getDaySegmentContainer = t.getDaySegmentContainer;
-	var formatDates = t.calendar.formatDates;
 	var renderDayOverlay = t.renderDayOverlay;
 	var clearOverlays = t.clearOverlays;
 	var clearSelection = t.clearSelection;
@@ -42,6 +40,9 @@ function DayEventRenderer() {
 	var cellOffsetToDayOffset = t.cellOffsetToDayOffset;
 	var dateToDayOffset = t.dateToDayOffset;
 	var dayOffsetToCellOffset = t.dayOffsetToCellOffset;
+	var calendar = t.calendar;
+	var getEventEnd = calendar.getEventEnd;
+	var formatDate = calendar.formatDate;
 
 
 	// Render `events` onto the calendar, attach mouse event handlers, and call the `eventAfterRender` callback for each.
@@ -181,9 +182,7 @@ function DayEventRenderer() {
 	// A "segment" is the same data structure that View.rangeToSegments produces,
 	// with the addition of the `event` property being set to reference the original event.
 	function buildSegmentsForEvent(event) {
-		var startDate = event.start;
-		var endDate = exclEndDay(event);
-		var segments = rangeToSegments(startDate, endDate);
+		var segments = rangeToSegments(event.start, getEventEnd(event));
 		for (var i=0; i<segments.length; i++) {
 			segments[i].event = event;
 		}
@@ -246,7 +245,7 @@ function DayEventRenderer() {
 			classNames.push('fc-event-end');
 		}
 		// use the event's configured classNames
-		// guaranteed to be an array via `normalizeEvent`
+		// guaranteed to be an array via `buildEvent`
 		classNames = classNames.concat(event.className);
 		if (event.source) {
 			// use the event's source's classNames, if specified
@@ -276,7 +275,7 @@ function DayEventRenderer() {
 			html +=
 				"<span class='fc-event-time'>" +
 				htmlEscape(
-					formatDates(event.start, event.end, opt('timeFormat'))
+					formatDate(event.start, opt('timeFormat'))
 				) +
 				"</span>";
 		}
@@ -285,7 +284,7 @@ function DayEventRenderer() {
 			htmlEscape(event.title || '') +
 			"</span>" +
 			"</div>";
-		if (segment.isEnd && isEventResizable(event)) {
+		if (event.allDay && segment.isEnd && isEventResizable(event)) {
 			html +=
 				"<div class='ui-resizable-handle ui-resizable-" + (isRTL ? 'w' : 'e') + "'>" +
 				"&nbsp;&nbsp;&nbsp;" + // makes hit area a lot better for IE6/7
@@ -554,6 +553,7 @@ function DayEventRenderer() {
 		}
 
 		if (
+			event.allDay &&
 			segment.isEnd && // only allow resizing on the final segment for an event
 			isEventResizable(event)
 		) {
@@ -565,10 +565,11 @@ function DayEventRenderer() {
 		eventElementHandlers(event, eventElement);
 	}
 
-	
+
 	function draggableDayEvent(event, eventElement) {
 		var hoverListener = getHoverListener();
 		var dayDelta;
+		var eventStart;
 		eventElement.draggable({
 			delay: 50,
 			opacity: opt('dragOpacity'),
@@ -580,14 +581,16 @@ function DayEventRenderer() {
 					eventElement.draggable('option', 'revert', !cell || !rowDelta && !colDelta);
 					clearOverlays();
 					if (cell) {
-						var origDate = cellToDate(origCell);
-						var date = cellToDate(cell);
-						dayDelta = dayDiff(date, origDate);
+						var origCellDate = cellToDate(origCell);
+						var cellDate = cellToDate(cell);
+						dayDelta = cellDate.diff(origCellDate, 'days');
+						eventStart = event.start.clone().add('days', dayDelta);
 						renderDayOverlay(
-							addDays(cloneDate(event.start), dayDelta),
-							addDays(exclEndDay(event), dayDelta)
+							eventStart,
+							getEventEnd(event).add('days', dayDelta)
 						);
-					}else{
+					}
+					else {
 						dayDelta = 0;
 					}
 				}, ev, 'drag');
@@ -597,8 +600,15 @@ function DayEventRenderer() {
 				clearOverlays();
 				trigger('eventDragStop', eventElement, event, ev, ui);
 				if (dayDelta) {
-					eventDrop(this, event, dayDelta, 0, event.allDay, ev, ui);
-				}else{
+					eventDrop(
+						this, // el
+						event,
+						eventStart,
+						ev,
+						ui
+					);
+				}
+				else {
 					eventElement.css('filter', ''); // clear IE opacity side-effects
 					showEvents(event, eventElement);
 				}
@@ -637,6 +647,7 @@ function DayEventRenderer() {
 			var colCnt = getColCnt();
 			var elementTop = element.css('top');
 			var dayDelta;
+			var eventEnd;
 			var helpers;
 			var eventCopy = $.extend({}, event);
 			var minCellOffset = dayOffsetToCellOffset( dateToDayOffset(event.start) );
@@ -658,18 +669,17 @@ function DayEventRenderer() {
 						cellOffsetToDayOffset(cellOffset) -
 						cellOffsetToDayOffset(origCellOffset);
 
+					eventEnd = getEventEnd(event).add('days', dayDelta); // assumed to already have a stripped time
+
 					if (dayDelta) {
-						eventCopy.end = addDays(eventEnd(event), dayDelta, true);
+						eventCopy.end = eventEnd;
 						var oldHelpers = helpers;
-
 						helpers = renderTempDayEvent(eventCopy, segment.row, elementTop);
 						helpers = $(helpers); // turn array into a jQuery object
-
 						helpers.find('*').css('cursor', direction + '-resize');
 						if (oldHelpers) {
 							oldHelpers.remove();
 						}
-
 						hideEvents(event);
 					}
 					else {
@@ -679,10 +689,11 @@ function DayEventRenderer() {
 							helpers = null;
 						}
 					}
+
 					clearOverlays();
 					renderDayOverlay( // coordinate grid already rebuilt with hoverListener.start()
 						event.start,
-						addDays( exclEndDay(event), dayDelta )
+						eventEnd
 						// TODO: instead of calling renderDayOverlay() with dates,
 						// call _renderDayOverlay (or whatever) with cell offsets.
 					);
@@ -694,8 +705,14 @@ function DayEventRenderer() {
 				$('body').css('cursor', '');
 				hoverListener.stop();
 				clearOverlays();
+
 				if (dayDelta) {
-					eventResize(this, event, dayDelta, 0, ev);
+					eventResize(
+						this, // el
+						event,
+						eventEnd,
+						ev
+					);
 					// event redraw will clear helpers
 				}
 				// otherwise, the drag handler already restored the old events

+ 25 - 10
src/common/SelectionManager.js

@@ -13,6 +13,7 @@ function SelectionManager() {
 	
 	
 	// imports
+	var calendar = t.calendar;
 	var opt = t.opt;
 	var trigger = t.trigger;
 	var defaultSelectionEnd = t.defaultSelectionEnd;
@@ -39,13 +40,19 @@ function SelectionManager() {
 	}
 	
 
-	function select(startDate, endDate, allDay) {
+	function select(start, end) {
 		unselect();
-		if (!endDate) {
-			endDate = defaultSelectionEnd(startDate, allDay);
+
+		start = calendar.moment(start);
+		if (end) {
+			end = calendar.moment(end);
+		}
+		else {
+			end = defaultSelectionEnd(start);
 		}
-		renderSelection(startDate, endDate, allDay);
-		reportSelection(startDate, endDate, allDay);
+
+		renderSelection(start, end);
+		reportSelection(start, end);
 	}
 	
 	
@@ -58,9 +65,9 @@ function SelectionManager() {
 	}
 	
 	
-	function reportSelection(startDate, endDate, allDay, ev) {
+	function reportSelection(start, end, ev) {
 		selected = true;
-		trigger('select', null, startDate, endDate, allDay, ev);
+		trigger('select', null, start, end, ev);
 	}
 	
 	
@@ -69,6 +76,7 @@ function SelectionManager() {
 		var getIsCellAllDay = t.getIsCellAllDay;
 		var hoverListener = t.getHoverListener();
 		var reportDayClick = t.reportDayClick; // this is hacky and sort of weird
+
 		if (ev.which == 1 && opt('selectable')) { // which==1 means left mouse button
 			unselect(ev);
 			var _mousedownElement = this;
@@ -77,7 +85,10 @@ function SelectionManager() {
 				clearSelection();
 				if (cell && getIsCellAllDay(cell)) {
 					dates = [ cellToDate(origCell), cellToDate(cell) ].sort(dateCompare);
-					renderSelection(dates[0], dates[1], true);
+					renderSelection(
+						dates[0],
+						dates[1].clone().add('days', 1) // make exclusive
+					);
 				}else{
 					dates = null;
 				}
@@ -86,9 +97,13 @@ function SelectionManager() {
 				hoverListener.stop();
 				if (dates) {
 					if (+dates[0] == +dates[1]) {
-						reportDayClick(dates[0], true, ev);
+						reportDayClick(dates[0], ev);
 					}
-					reportSelection(dates[0], dates[1], true, ev);
+					reportSelection(
+						dates[0],
+						dates[1].clone().add('days', 1), // make exclusive
+						ev
+					);
 				}
 			});
 		}

+ 201 - 81
src/common/View.js

@@ -14,7 +14,6 @@ function View(element, calendar, viewName) {
 	t.isEventResizable = isEventResizable;
 	t.setEventData = setEventData;
 	t.clearEventData = clearEventData;
-	t.eventEnd = eventEnd;
 	t.reportEventElement = reportEventElement;
 	t.triggerEventDestroy = triggerEventDestroy;
 	t.eventElementHandlers = eventElementHandlers;
@@ -22,15 +21,14 @@ function View(element, calendar, viewName) {
 	t.hideEvents = hideEvents;
 	t.eventDrop = eventDrop;
 	t.eventResize = eventResize;
-	// t.title
-	// t.start, t.end
-	// t.visStart, t.visEnd
+	t.mutateEvent = mutateEvent;
+	// t.start, t.end // moments with ambiguous-time
+	// t.intervalStart, t.intervalEnd // moments with ambiguous-time
 	
 	
 	// imports
-	var defaultEventEnd = t.defaultEventEnd;
-	var normalizeEvent = calendar.normalizeEvent; // in EventManager
 	var reportEventChange = calendar.reportEventChange;
+	var getEventEnd = calendar.getEventEnd;
 	
 	
 	// locals
@@ -38,12 +36,14 @@ function View(element, calendar, viewName) {
 	var eventElementsByID = {}; // eventID mapped to array of jQuery elements
 	var eventElementCouples = []; // array of objects, { event, element } // TODO: unify with segment system
 	var options = calendar.options;
+	var nextDayThreshold = moment.duration(options.nextDayThreshold);
+
 	
 	
 	
 	function opt(name, viewNameOverride) {
 		var v = options[name];
-		if ($.isPlainObject(v)) {
+		if ($.isPlainObject(v) && !isForcedAtomicOption(name)) {
 			return smartProperty(v, viewNameOverride || viewName);
 		}
 		return v;
@@ -72,8 +72,7 @@ function View(element, calendar, viewName) {
 				event.editable,
 				source.editable,
 				opt('editable')
-			)
-			&& !opt('disableDragging'); // deprecated
+			);
 	}
 	
 	
@@ -86,8 +85,7 @@ function View(element, calendar, viewName) {
 				event.editable,
 				source.editable,
 				opt('editable')
-			)
-			&& !opt('disableResizing'); // deprecated
+			);
 	}
 	
 	
@@ -117,12 +115,6 @@ function View(element, calendar, viewName) {
 	}
 	
 	
-	// returns a Date object for an event's end
-	function eventEnd(event) {
-		return event.end ? cloneDate(event.end) : defaultEventEnd(event);
-	}
-	
-	
 	
 	/* Event Elements
 	------------------------------------------------------------------------------*/
@@ -194,83 +186,206 @@ function View(element, calendar, viewName) {
 	
 	/* Event Modification Reporting
 	---------------------------------------------------------------------------------*/
+
 	
-	
-	function eventDrop(e, event, dayDelta, minuteDelta, allDay, ev, ui) {
-		var oldAllDay = event.allDay;
-		var eventId = event._id;
-		moveEvents(eventsByID[eventId], dayDelta, minuteDelta, allDay);
+	function eventDrop(el, event, newStart, ev, ui) {
+		var undoMutation = mutateEvent(event, newStart, null);
+
 		trigger(
 			'eventDrop',
-			e,
+			el,
 			event,
-			dayDelta,
-			minuteDelta,
-			allDay,
 			function() {
-				// TODO: investigate cases where this inverse technique might not work
-				moveEvents(eventsByID[eventId], -dayDelta, -minuteDelta, oldAllDay);
-				reportEventChange(eventId);
+				undoMutation();
+				reportEventChange(event._id);
 			},
 			ev,
 			ui
 		);
-		reportEventChange(eventId);
+
+		reportEventChange(event._id);
 	}
-	
-	
-	function eventResize(e, event, dayDelta, minuteDelta, ev, ui) {
-		var eventId = event._id;
-		elongateEvents(eventsByID[eventId], dayDelta, minuteDelta);
+
+
+	function eventResize(el, event, newEnd, ev, ui) {
+		var undoMutation = mutateEvent(event, null, newEnd);
+
 		trigger(
 			'eventResize',
-			e,
+			el,
 			event,
-			dayDelta,
-			minuteDelta,
 			function() {
-				// TODO: investigate cases where this inverse technique might not work
-				elongateEvents(eventsByID[eventId], -dayDelta, -minuteDelta);
-				reportEventChange(eventId);
+				undoMutation();
+				reportEventChange(event._id);
 			},
 			ev,
 			ui
 		);
-		reportEventChange(eventId);
+
+		reportEventChange(event._id);
 	}
 	
 	
 	
 	/* Event Modification Math
-	---------------------------------------------------------------------------------*/
-	
-	
-	function moveEvents(events, dayDelta, minuteDelta, allDay) {
-		minuteDelta = minuteDelta || 0;
-		for (var e, len=events.length, i=0; i<len; i++) {
-			e = events[i];
-			if (allDay !== undefined) {
-				e.allDay = allDay;
+	-----------------------------------------------------------------------------------------*/
+
+
+	// Modify the date(s) of an event and make this change propagate to all other events with
+	// the same ID (related repeating events).
+	//
+	// If `newStart`/`newEnd` are not specified, the "new" dates are assumed to be `event.start` and `event.end`.
+	// The "old" dates to be compare against are always `event._start` and `event._end` (set by EventManager).
+	//
+	// Returns a function that can be called to undo all the operations.
+	//
+	function mutateEvent(event, newStart, newEnd) {
+		var oldAllDay = event._allDay;
+		var oldStart = event._start;
+		var oldEnd = event._end;
+		var clearEnd = false;
+		var newAllDay;
+		var dateDelta;
+		var durationDelta;
+
+		// if no new dates were passed in, compare against the event's existing dates
+		if (!newStart && !newEnd) {
+			newStart = event.start;
+			newEnd = event.end;
+		}
+
+		// NOTE: throughout this function, the initial values of `newStart` and `newEnd` are
+		// preserved. These values may be undefined.
+
+		// detect new allDay
+		if (event.allDay != oldAllDay) { // if value has changed, use it
+			newAllDay = event.allDay;
+		}
+		else { // otherwise, see if any of the new dates are allDay
+			newAllDay = !(newStart || newEnd).hasTime();
+		}
+
+		// normalize the new dates based on allDay
+		if (newAllDay) {
+			if (newStart) {
+				newStart = newStart.clone().stripTime();
 			}
-			addMinutes(addDays(e.start, dayDelta, true), minuteDelta);
-			if (e.end) {
-				e.end = addMinutes(addDays(e.end, dayDelta, true), minuteDelta);
+			if (newEnd) {
+				newEnd = newEnd.clone().stripTime();
 			}
-			normalizeEvent(e, options);
 		}
-	}
-	
-	
-	function elongateEvents(events, dayDelta, minuteDelta) {
-		minuteDelta = minuteDelta || 0;
-		for (var e, len=events.length, i=0; i<len; i++) {
-			e = events[i];
-			e.end = addMinutes(addDays(eventEnd(e), dayDelta, true), minuteDelta);
-			normalizeEvent(e, options);
+
+		// compute dateDelta
+		if (newStart) {
+			if (newAllDay) {
+				dateDelta = dayishDiff(newStart, oldStart.clone().stripTime()); // treat oldStart as allDay
+			}
+			else {
+				dateDelta = dayishDiff(newStart, oldStart);
+			}
+		}
+
+		if (newAllDay != oldAllDay) {
+			// if allDay has changed, always throw away the end
+			clearEnd = true;
+		}
+		else if (newEnd) {
+			durationDelta = dayishDiff(
+				// new duration
+				newEnd || calendar.getDefaultEventEnd(newAllDay, newStart || oldStart),
+				newStart || oldStart
+			).subtract(dayishDiff(
+				// subtract old duration
+				oldEnd || calendar.getDefaultEventEnd(oldAllDay, oldStart),
+				oldStart
+			));
 		}
+
+		return mutateEvents(eventsByID[event._id], clearEnd, newAllDay, dateDelta, durationDelta);
 	}
 
 
+	// Modifies an array of events in the following ways (operations are in order):
+	// - clear the event's `end`
+	// - convert the event to allDay
+	// - add `dateDelta` to the start and end 
+	// - add `durationDelta` to the event's duration
+	//
+	// Returns a function that can be called to undo all the operations.
+	//
+	function mutateEvents(events, clearEnd, forceAllDay, dateDelta, durationDelta) {
+		var isAmbigTimezone = calendar.getIsAmbigTimezone();
+		var undoFunctions = [];
+
+		$.each(events, function(i, event) {
+			var oldAllDay = event._allDay;
+			var oldStart = event._start;
+			var oldEnd = event._end;
+			var newAllDay = forceAllDay != null ? forceAllDay : oldAllDay;
+			var newStart = oldStart.clone();
+			var newEnd = (!clearEnd && oldEnd) ? oldEnd.clone() : null;
+
+			// NOTE: this function is responsible for transforming `newStart` and `newEnd`,
+			// which were initialized to the OLD values first. `newEnd` may be null.
+
+			// normlize newStart/newEnd to be consistent with newAllDay
+			if (newAllDay) {
+				newStart.stripTime();
+				if (newEnd) {
+					newEnd.stripTime();
+				}
+			}
+			else {
+				if (!newStart.hasTime()) {
+					newStart = calendar.rezoneDate(newStart);
+				}
+				if (newEnd && !newEnd.hasTime()) {
+					newEnd = calendar.rezoneDate(newEnd);
+				}
+			}
+
+			// ensure we have an end date if necessary
+			if (!newEnd && (options.forceEventDuration || +durationDelta)) {
+				newEnd = calendar.getDefaultEventEnd(newAllDay, newStart);
+			}
+
+			// translate the dates
+			newStart.add(dateDelta);
+			if (newEnd) {
+				newEnd.add(dateDelta).add(durationDelta);
+			}
+
+			// if the dates have changed, and we know it is impossible to recompute the
+			// timezone offsets, strip the zone.
+			if (isAmbigTimezone) {
+				if (+dateDelta) {
+					newStart.stripZone();
+				}
+				if (newEnd && (+dateDelta || +durationDelta)) {
+					newEnd.stripZone();
+				}
+			}
+
+			event.allDay = newAllDay;
+			event.start = newStart;
+			event.end = newEnd;
+			backupEventDates(event);
+
+			undoFunctions.push(function() {
+				event.allDay = oldAllDay;
+				event.start = oldStart;
+				event.end = oldEnd;
+				backupEventDates(event);
+			});
+		});
+
+		return function() {
+			for (var i=0; i<undoFunctions.length; i++) {
+				undoFunctions[i]();
+			}
+		};
+	}
+
 
 	// ====================================================================================================
 	// Utilities for day "cells"
@@ -289,7 +404,7 @@ function View(element, calendar, viewName) {
 	//
 	// 2. Convert the "cell offset" to a "day offset" (the # of days since the first visible day in the view).
 	//
-	// 3. Convert the "day offset" into a "date" (a JavaScript Date object).
+	// 3. Convert the "day offset" into a "date" (a Moment).
 	//
 	// The reverse transformation happens when transforming a date into a cell.
 
@@ -345,10 +460,10 @@ function View(element, calendar, viewName) {
 
 
 	// Is the current day hidden?
-	// `day` is a day-of-week index (0-6), or a Date object
+	// `day` is a day-of-week index (0-6), or a Moment
 	function isHiddenDay(day) {
-		if (typeof day == 'object') {
-			day = day.getDay();
+		if (moment.isMoment(day)) {
+			day = day.day();
 		}
 		return isHiddenDayHash[day];
 	}
@@ -359,17 +474,19 @@ function View(element, calendar, viewName) {
 	}
 
 
-	// Keep incrementing the current day until it is no longer a hidden day.
+	// Incrementing the current day until it is no longer a hidden day, returning a copy.
 	// If the initial value of `date` is not a hidden day, don't do anything.
 	// Pass `isExclusive` as `true` if you are dealing with an end date.
 	// `inc` defaults to `1` (increment one day forward each time)
 	function skipHiddenDays(date, inc, isExclusive) {
+		var out = date.clone();
 		inc = inc || 1;
 		while (
-			isHiddenDayHash[ ( date.getDay() + (isExclusive ? inc : 0) + 7 ) % 7 ]
+			isHiddenDayHash[ ( out.day() + (isExclusive ? inc : 0) + 7 ) % 7 ]
 		) {
-			addDays(date, inc);
+			out.add('days', inc);
 		}
+		return out;
 	}
 
 
@@ -410,7 +527,7 @@ function View(element, calendar, viewName) {
 
 	// cell offset -> day offset
 	function cellOffsetToDayOffset(cellOffset) {
-		var day0 = t.visStart.getDay(); // first date's day of week
+		var day0 = t.start.day(); // first date's day of week
 		cellOffset += dayToCellMap[day0]; // normlize cellOffset to beginning-of-week
 		return Math.floor(cellOffset / cellsPerWeek) * 7 // # of days from full weeks
 			+ cellToDayMap[ // # of days from partial last week
@@ -419,11 +536,9 @@ function View(element, calendar, viewName) {
 			- day0; // adjustment for beginning-of-week normalization
 	}
 
-	// day offset -> date (JavaScript Date object)
+	// day offset -> date
 	function dayOffsetToDate(dayOffset) {
-		var date = cloneDate(t.visStart);
-		addDays(date, dayOffset);
-		return date;
+		return t.start.clone().add('days', dayOffset);
 	}
 
 
@@ -441,12 +556,12 @@ function View(element, calendar, viewName) {
 
 	// date -> day offset
 	function dateToDayOffset(date) {
-		return dayDiff(date, t.visStart);
+		return date.clone().stripTime().diff(t.start, 'days');
 	}
 
 	// day offset -> cell offset
 	function dayOffsetToCellOffset(dayOffset) {
-		var day0 = t.visStart.getDay(); // first date's day of week
+		var day0 = t.start.day(); // first date's day of week
 		dayOffset += day0; // normalize dayOffset to beginning-of-week
 		return Math.floor(dayOffset / 7) * cellsPerWeek // # of cells from full weeks
 			+ dayToCellMap[ // # of cells from partial last week
@@ -481,14 +596,19 @@ function View(element, calendar, viewName) {
 	// - isStart
 	// - isEnd
 	//
-	function rangeToSegments(startDate, endDate) {
+	function rangeToSegments(start, end) {
+
 		var rowCnt = t.getRowCnt();
 		var colCnt = t.getColCnt();
 		var segments = []; // array of segments to return
 
 		// day offset for given date range
-		var rangeDayOffsetStart = dateToDayOffset(startDate);
-		var rangeDayOffsetEnd = dateToDayOffset(endDate); // exclusive
+		var rangeDayOffsetStart = dateToDayOffset(start);
+		var rangeDayOffsetEnd = dateToDayOffset(end); // an exclusive value
+		if (end.time() > nextDayThreshold) {
+			rangeDayOffsetEnd++;
+		}
+		rangeDayOffsetEnd = Math.max(rangeDayOffsetEnd, rangeDayOffsetStart + 1);
 
 		// first and last cell offset for the given date range
 		// "last" implies inclusivity

+ 19 - 13
src/common/common.css

@@ -52,32 +52,38 @@
 	and we'll try to make them look good cross-browser.
 */
 
-.fc-text-arrow {
+.fc-button .fc-icon {
 	margin: 0 .1em;
 	font-size: 2em;
 	font-family: "Courier New", Courier, monospace;
 	vertical-align: baseline; /* for IE7 */
 	}
 
-.fc-button-prev .fc-text-arrow,
-.fc-button-next .fc-text-arrow { /* for &lsaquo; &rsaquo; */
+.fc-icon-left-single-arrow:after {
+	content: "\02039";
 	font-weight: bold;
 	}
-	
-/* icon (for jquery ui) */
-	
-.fc-button .fc-icon-wrap {
-	position: relative;
-	float: left;
-	top: 50%;
+
+.fc-icon-right-single-arrow:after {
+	content: "\0203A";
+	font-weight: bold;
+	}
+
+.fc-icon-left-double-arrow:after {
+	content: "\000AB";
+	}
+
+.fc-icon-right-double-arrow:after {
+	content: "\000BB";
 	}
 	
+/* icon (for jquery ui) */
+
 .fc-button .ui-icon {
 	position: relative;
+	top: 50%;
 	float: left;
-	margin-top: -50%;
-	*margin-top: 0;
-	*top: -50%;
+	margin-top: -8px; /* we know jqui icons are always 16px tall */
 	}
 	
 /*

+ 383 - 297
src/date_util.js

@@ -1,376 +1,462 @@
 
+var dayIDs = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
+var ambigTimeRegex = /^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))$/;
+var ambigZoneRegex = /^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?)?$/;
+var momentFormatMethod = moment.fn.format;
+var momentToISOStringMethod = moment.fn.toISOString;
+
+
+// diffs the two moments into a Duration where full-days are recorded first,
+// then the remaining time.
+function dayishDiff(d1, d0) {
+	return moment.duration({
+		days: d1.clone().stripTime().diff(d0.clone().stripTime(), 'days'),
+		ms: d1.time() - d0.time()
+	});
+}
 
-fc.addDays = addDays;
-fc.cloneDate = cloneDate;
-fc.parseDate = parseDate;
-fc.parseISO8601 = parseISO8601;
-fc.parseTime = parseTime;
-fc.formatDate = formatDate;
-fc.formatDates = formatDates;
+function isNativeDate(input) {
+	return  Object.prototype.toString.call(input) === '[object Date]' ||
+		input instanceof Date;
+}
 
 
+// MOMENT: creating
+// -------------------------------------------------------------------------------------------------
 
-/* Date Math
------------------------------------------------------------------------------*/
+// Creates a moment in the local timezone, similar to the vanilla moment(...) constructor,
+// but with extra features:
+// - ambiguous times
+// - enhanced formatting (TODO)
+fc.moment = function() {
+	return buildMoment(arguments);
+};
 
-var dayIDs = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'],
-	DAY_MS = 86400000,
-	HOUR_MS = 3600000,
-	MINUTE_MS = 60000;
-	
+// Sames as fc.moment, but creates a moment in the UTC timezone.
+fc.moment.utc = function() {
+	return buildMoment(arguments, true);
+};
 
-function addYears(d, n, keepTime) {
-	d.setFullYear(d.getFullYear() + n);
-	if (!keepTime) {
-		clearTime(d);
+// Creates a moment and preserves the timezone offset of the ISO8601 string,
+// allowing for ambigous timezones. If the string is not an ISO8601 string,
+// the moment is processed in UTC-mode (a departure from moment's method).
+fc.moment.parseZone = function() {
+	return buildMoment(arguments, true, true);
+};
+
+// when parseZone==true, if can't figure it out, fall back to parseUTC
+function buildMoment(args, parseUTC, parseZone) {
+	var isSingleArg = args.length == 1;
+	var isSingleString = isSingleArg && typeof args[0] === 'string';
+	var isSingleArray = isSingleArg && $.isArray(args[0]);
+	var isSingleNativeDate = isSingleArg && isNativeDate(args[0]);
+	var isAmbigTime = isSingleString && ambigTimeRegex.test(args[0]);
+	var isAmbigZone = isAmbigTime || isSingleArray || isSingleString && ambigZoneRegex.test(args[0]);
+	var mom;
+
+	if (parseUTC || parseZone || isAmbigTime) {
+		mom = moment.utc.apply(moment, args);
+	}
+	else {
+		mom = moment.apply(null, args);
 	}
-	return d;
-}
 
+	if (isAmbigTime) {
+		mom._ambigTime = true;
+		mom._ambigZone = true; // if ambiguous time, also ambiguous timezone offset
+	}
 
-function addMonths(d, n, keepTime) { // prevents day overflow/underflow
-	if (+d) { // prevent infinite looping on invalid dates
-		var m = d.getMonth() + n,
-			check = cloneDate(d);
-		check.setDate(1);
-		check.setMonth(m);
-		d.setMonth(m);
-		if (!keepTime) {
-			clearTime(d);
+	if (parseZone) {
+		if (isAmbigZone) {
+			mom._ambigZone = true;
 		}
-		while (d.getMonth() != check.getMonth()) {
-			d.setDate(d.getDate() + (d < check ? 1 : -1));
+		else if (isSingleString) {
+			mom.zone(args[0]); // if fails, will set it to 0, which it already was
+		}
+		else if (isSingleNativeDate || args[0] === undefined) {
+			// native Date object?
+			// specified with no arguments?
+			// then consider the moment to be local
+			mom.local();
 		}
 	}
-	return d;
+
+	mom._fc = true; // flag for use other extended functionality (only formatting at this point)
+
+	return mom;
 }
 
 
-function addDays(d, n, keepTime) { // deals with daylight savings
-	if (+d) {
-		var dd = d.getDate() + n,
-			check = cloneDate(d);
-		check.setHours(9); // set to middle of day
-		check.setDate(dd);
-		d.setDate(dd);
-		if (!keepTime) {
-			clearTime(d);
-		}
-		fixDate(d, check);
+// MOMENT: time-of-day
+// -------------------------------------------------------------------------------------------------
+
+
+// GETTER
+// Returns a Duration with the hours/minutes/seconds/ms values of the moment.
+// If the moment has an ambiguous time, a duration of 00:00 will be returned.
+//
+// SETTER
+// You can supply a Duration, a Moment, or a Duration-like argument.
+// When setting the time, and the moment has an ambiguous time, it then becomes unambiguous.
+moment.fn.time = function(time) {
+	if (time === undefined) { // getter
+		return moment.duration({
+			hours: this.hours(),
+			minutes: this.minutes(),
+			seconds: this.seconds(),
+			milliseconds: this.milliseconds()
+		});
 	}
-	return d;
-}
+	else { // setter
 
+		delete this._ambigTime; // mark that the moment now has a time
 
-function fixDate(d, check) { // force d to be on check's YMD, for daylight savings purposes
-	if (+d) { // prevent infinite looping on invalid dates
-		while (d.getDate() != check.getDate()) {
-			d.setTime(+d + (d < check ? 1 : -1) * HOUR_MS);
+		if (!moment.isDuration(time) && !moment.isMoment(time)) {
+			time = moment.duration(time);
 		}
+
+		return this.hours(time.hours() + time.days() * 24) // day value will cause overflow (so 24 hours becomes 00:00:00 of next day)
+			.minutes(time.minutes())
+			.seconds(time.seconds())
+			.milliseconds(time.milliseconds());
 	}
-}
+};
 
+// Converts the moment to UTC, stripping out its time-of-day and timezone offset,
+// but preserving its YMD. A moment with a stripped time will display no time
+// nor timezone offset when .format() is called.
+moment.fn.stripTime = function() {
+	var a = this.toArray(); // year,month,date,hours,minutes,seconds as an array
+
+	this._ambigTime = true;
+	this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset
+
+	return this.utc()
+		.year(a[0])
+		.month(a[1])
+		.date(a[2])
+		.hours(0)
+		.minutes(0)
+		.seconds(0)
+		.milliseconds(0);
+};
 
-function addMinutes(d, n) {
-	d.setMinutes(d.getMinutes() + n);
-	return d;
-}
+// Returns if the moment has a non-ambiguous time (boolean)
+moment.fn.hasTime = function() {
+	return !this._ambigTime;
+};
 
 
-function clearTime(d) {
-	d.setHours(0);
-	d.setMinutes(0);
-	d.setSeconds(0); 
-	d.setMilliseconds(0);
-	return d;
-}
+// MOMENT: timezone offset
+// -------------------------------------------------------------------------------------------------
+
+// Converts the moment to UTC, stripping out its timezone offset, but preserving its
+// YMD and time-of-day. A moment with a stripped timezone offset will display no
+// timezone offset when .format() is called.
+moment.fn.stripZone = function() {
+	var a = this.toArray(); // year,month,date,hours,minutes,seconds as an array
+
+	this._ambigZone = true;
+
+	return this.utc()
+		.year(a[0])
+		.month(a[1])
+		.date(a[2])
+		.hours(a[3])
+		.minutes(a[4])
+		.seconds(a[5])
+		.milliseconds(a[6]);
+};
+
+// Returns of the moment has a non-ambiguous timezone offset (boolean)
+moment.fn.hasZone = function() {
+	return !this._ambigZone;
+};
 
 
-function cloneDate(d, dontKeepTime) {
-	if (dontKeepTime) {
-		return clearTime(new Date(+d));
+// MOMENT: formatting mods
+// -------------------------------------------------------------------------------------------------
+
+moment.fn.format = function() {
+	if (!arguments[0]) {
+		if (this._ambigTime) {
+			return momentFormat(this, 'YYYY-MM-DD');
+		}
+		if (this._ambigZone) {
+			return momentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
+		}
+	}
+	if (this._fc) {
+		return formatDate(this, arguments[0]); // our extended formatting
+	}
+	else {
+		return momentFormatMethod.apply(this, arguments); // pass along all arguments
 	}
-	return new Date(+d);
 }
 
+moment.fn.toISOString = function() {
+	if (this._ambigTime) {
+		return momentFormat(this, 'YYYY-MM-DD');
+	}
+	if (this._ambigZone) {
+		return momentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
+	}
+	return momentToISOStringMethod.apply(this, arguments); // pass along all arguments
+};
 
-function zeroDate() { // returns a Date with time 00:00:00 and dateOfMonth=1
-	var i=0, d;
-	do {
-		d = new Date(1970, i++, 1);
-	} while (d.getHours()); // != 0
-	return d;
+// call this if you want Moment's original format method to be used
+function momentFormat(moment, formatStr) {
+	return momentFormatMethod.call(moment, formatStr);
 }
 
 
-function dayDiff(d1, d2) { // d1 - d2
-	return Math.round((cloneDate(d1, true) - cloneDate(d2, true)) / DAY_MS);
+// MOMENT: misc utils
+// -------------------------------------------------------------------------------------------------
+
+// Is the moment within the specified range? `end` is exclusive.
+// TODO: rename for collision reasons?
+moment.fn.isWithin = function(start, end) {
+	return this >= moment(start) && this < moment(end);
+};
+
+$.each([
+	'isBefore',
+	'isAfter',
+	//'isSame', // nevermind. moment handles normalization to UTC
+	'isWithin'
+], function(i, methodName) {
+	var origMethod = moment.fn[methodName];
+	var momentCount = methodName == 'isWithin' ? 2 : 1;
+
+	moment.fn[methodName] = function() {
+		var newThis;
+		var i;
+
+		if (this._ambigZone) {
+			for (i=0; i<momentCount; i++) {
+				if (typeof arguments[i] === 'string') {
+					arguments[i] = fc.moment.parseZone(arguments[i]);
+				}
+			}
+		}
+
+		for (i=0; i<momentCount; i++) {
+			if (moment.isMoment(arguments[i]) && arguments[i]._ambigZone !== this._ambigZone) {
+				newThis = newThis || this.clone().stripZone();
+				arguments[i] = arguments[i].clone().stripZone();
+			}
+		}
+
+		return origMethod.apply(newThis || this, arguments);
+	};
+});
+
+
+// Single Date Formatting
+// -------------------------------------------------------------------------------------------------
+
+
+// Formats `date` with a Moment formatting string, but allow our non-zero areas and
+// additional token.
+function formatDate(date, formatStr) {
+	return formatDateWithChunks(date, getFormatStringChunks(formatStr));
 }
 
 
-function setYMD(date, y, m, d) {
-	if (y !== undefined && y != date.getFullYear()) {
-		date.setDate(1);
-		date.setMonth(0);
-		date.setFullYear(y);
-	}
-	if (m !== undefined && m != date.getMonth()) {
-		date.setDate(1);
-		date.setMonth(m);
-	}
-	if (d !== undefined) {
-		date.setDate(d);
+function formatDateWithChunks(date, chunks) {
+	var s = '';
+	var i;
+
+	for (i=0; i<chunks.length; i++) {
+		s += formatDateWithChunk(date, chunks[i]);
 	}
+
+	return s;
 }
 
 
+// addition formatting tokens we want recognized
+var tokenOverrides = {
+	t: function(date) { // "a" or "p"
+		return momentFormat(date, 'a').charAt(0);
+	},
+	T: function(date) { // "A" or "P"
+		return momentFormat(date, 'A').charAt(0);
+	}
+};
 
-/* Date Parsing
------------------------------------------------------------------------------*/
 
+function formatDateWithChunk(date, chunk) {
+	var token;
+	var maybeStr;
 
-function parseDate(s, ignoreTimezone) { // ignoreTimezone defaults to true
-	if (typeof s == 'object') { // already a Date object
-		return s;
-	}
-	if (typeof s == 'number') { // a UNIX timestamp
-		return new Date(s * 1000);
+	if (typeof chunk === 'string') { // a literal string
+		return chunk;
 	}
-	if (typeof s == 'string') {
-		if (s.match(/^\d+(\.\d+)?$/)) { // a UNIX timestamp
-			return new Date(parseFloat(s) * 1000);
+	else if (token = chunk.token) { // a token, like "YYYY"
+		if (tokenOverrides[token]) {
+			return tokenOverrides[token](date); // use our custom token
 		}
-		if (ignoreTimezone === undefined) {
-			ignoreTimezone = true;
+		return momentFormat(date, token);
+	}
+	else if (chunk.maybe) { // a grouping of other chunks that must be non-zero
+		maybeStr = formatDateWithChunks(date, chunk.maybe);
+		if (maybeStr.match(/[1-9]/)) {
+			return maybeStr;
 		}
-		return parseISO8601(s, ignoreTimezone) || (s ? new Date(s) : null);
 	}
-	// TODO: never return invalid dates (like from new Date(<string>)), return null instead
-	return null;
+
+	return '';
 }
 
 
-function parseISO8601(s, ignoreTimezone) { // ignoreTimezone defaults to false
-	// derived from http://delete.me.uk/2005/03/iso8601.html
-	// TODO: for a know glitch/feature, read tests/issue_206_parseDate_dst.html
-	var m = s.match(/^([0-9]{4})(-([0-9]{2})(-([0-9]{2})([T ]([0-9]{2}):([0-9]{2})(:([0-9]{2})(\.([0-9]+))?)?(Z|(([-+])([0-9]{2})(:?([0-9]{2}))?))?)?)?)?$/);
-	if (!m) {
-		return null;
-	}
-	var date = new Date(m[1], 0, 1);
-	if (ignoreTimezone || !m[13]) {
-		var check = new Date(m[1], 0, 1, 9, 0);
-		if (m[3]) {
-			date.setMonth(m[3] - 1);
-			check.setMonth(m[3] - 1);
-		}
-		if (m[5]) {
-			date.setDate(m[5]);
-			check.setDate(m[5]);
-		}
-		fixDate(date, check);
-		if (m[7]) {
-			date.setHours(m[7]);
-		}
-		if (m[8]) {
-			date.setMinutes(m[8]);
+// Date Range Formatting
+// -------------------------------------------------------------------------------------------------
+// TODO: make it work with timezone offset
+
+// Using a formatting string meant for a single date, generate a range string, like
+// "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ.
+// If the dates are the same as far as the format string is concerned, just return a single
+// rendering of one date, without any separator.
+function formatRange(date1, date2, formatStr, separator, isRTL) {
+
+	// Expand localized format strings, like "LL" -> "MMMM D YYYY"
+	formatStr = date1.lang().longDateFormat(formatStr) || formatStr;
+	// BTW, this is not important for `formatDate` because it is impossible to put custom tokens
+	// or non-zero areas in Moment's localized format strings.
+
+	separator = separator || ' - ';
+
+	return formatRangeWithChunks(
+		date1,
+		date2,
+		getFormatStringChunks(formatStr),
+		separator,
+		isRTL
+	);
+}
+fc.formatRange = formatRange; // expose
+
+
+function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) {
+	var chunkStr; // the rendering of the chunk
+	var leftI;
+	var leftStr = '';
+	var rightI;
+	var rightStr = '';
+	var middleI;
+	var middleStr1 = '';
+	var middleStr2 = '';
+	var middleStr = '';
+
+	// Start at the leftmost side of the formatting string and continue until you hit a token
+	// that is not the same between dates.
+	for (leftI=0; leftI<chunks.length; leftI++) {
+		chunkStr = formatSimilarChunk(date1, date2, chunks[leftI]);
+		if (chunkStr === false) {
+			break;
 		}
-		if (m[10]) {
-			date.setSeconds(m[10]);
+		leftStr += chunkStr;
+	}
+
+	// Similarly, start at the rightmost side of the formatting string and move left
+	for (rightI=chunks.length-1; rightI>leftI; rightI--) {
+		chunkStr = formatSimilarChunk(date1, date2, chunks[rightI]);
+		if (chunkStr === false) {
+			break;
 		}
-		if (m[12]) {
-			date.setMilliseconds(Number("0." + m[12]) * 1000);
+		rightStr = chunkStr + rightStr;
+	}
+
+	// The area in the middle is different for both of the dates.
+	// Collect them distinctly so we can jam them together later.
+	for (middleI=leftI; middleI<=rightI; middleI++) {
+		middleStr1 += formatDateWithChunk(date1, chunks[middleI]);
+		middleStr2 += formatDateWithChunk(date2, chunks[middleI]);
+	}
+
+	if (middleStr1 || middleStr2) {
+		if (isRTL) {
+			middleStr = middleStr2 + separator + middleStr1;
 		}
-		fixDate(date, check);
-	}else{
-		date.setUTCFullYear(
-			m[1],
-			m[3] ? m[3] - 1 : 0,
-			m[5] || 1
-		);
-		date.setUTCHours(
-			m[7] || 0,
-			m[8] || 0,
-			m[10] || 0,
-			m[12] ? Number("0." + m[12]) * 1000 : 0
-		);
-		if (m[14]) {
-			var offset = Number(m[16]) * 60 + (m[18] ? Number(m[18]) : 0);
-			offset *= m[15] == '-' ? 1 : -1;
-			date = new Date(+date + (offset * 60 * 1000));
+		else {
+			middleStr = middleStr1 + separator + middleStr2;
 		}
 	}
-	return date;
+
+	return leftStr + middleStr + rightStr;
 }
 
 
-function parseTime(s) { // returns minutes since start of day
-	if (typeof s == 'number') { // an hour
-		return s * 60;
-	}
-	if (typeof s == 'object') { // a Date object
-		return s.getHours() * 60 + s.getMinutes();
+var similarUnitMap = {
+	Y: 'year',
+	M: 'month',
+	D: 'day', // day of month
+	d: 'day' // day of week
+};
+// don't go any further than day, because we don't want to break apart times like "12:30:00"
+// TODO: week maybe?
+
+
+// Given a formatting chunk, and given that both dates are similar in the regard the
+// formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`.
+function formatSimilarChunk(date1, date2, chunk) {
+	var token;
+	var unit;
+
+	if (typeof chunk === 'string') { // a literal string
+		return chunk;
 	}
-	var m = s.match(/(\d+)(?::(\d+))?\s*(\w+)?/);
-	if (m) {
-		var h = parseInt(m[1], 10);
-		if (m[3]) {
-			h %= 12;
-			if (m[3].toLowerCase().charAt(0) == 'p') {
-				h += 12;
-			}
+	else if (token = chunk.token) {
+		unit = similarUnitMap[token.charAt(0)];
+		// are the dates the same for this unit of measurement?
+		if (unit && date1.isSame(date2, unit)) {
+			return momentFormat(date1, token); // would be the same if we used `date2`
+			// BTW, don't support custom tokens
 		}
-		return h * 60 + (m[2] ? parseInt(m[2], 10) : 0);
 	}
+
+	return false; // the chunk is NOT the same for the two dates
+	// BTW, don't support splitting on non-zero areas
 }
 
 
+// Chunking Utils
+// -------------------------------------------------------------------------------------------------
+
 
-/* Date Formatting
------------------------------------------------------------------------------*/
-// TODO: use same function formatDate(date, [date2], format, [options])
+var formatStringChunkCache = {};
 
 
-function formatDate(date, format, options) {
-	return formatDates(date, null, format, options);
+function getFormatStringChunks(formatStr) {
+	if (formatStr in formatStringChunkCache) {
+		return formatStringChunkCache[formatStr];
+	}
+	return formatStringChunkCache[formatStr] = chunkFormatString(formatStr);
 }
 
 
-function formatDates(date1, date2, format, options) {
-	options = options || defaults;
-	var date = date1,
-		otherDate = date2,
-		i, len = format.length, c,
-		i2, formatter,
-		res = '';
-	for (i=0; i<len; i++) {
-		c = format.charAt(i);
-		if (c == "'") {
-			for (i2=i+1; i2<len; i2++) {
-				if (format.charAt(i2) == "'") {
-					if (date) {
-						if (i2 == i+1) {
-							res += "'";
-						}else{
-							res += format.substring(i+1, i2);
-						}
-						i = i2;
-					}
-					break;
-				}
-			}
-		}
-		else if (c == '(') {
-			for (i2=i+1; i2<len; i2++) {
-				if (format.charAt(i2) == ')') {
-					var subres = formatDate(date, format.substring(i+1, i2), options);
-					if (parseInt(subres.replace(/\D/, ''), 10)) {
-						res += subres;
-					}
-					i = i2;
-					break;
-				}
-			}
-		}
-		else if (c == '[') {
-			for (i2=i+1; i2<len; i2++) {
-				if (format.charAt(i2) == ']') {
-					var subformat = format.substring(i+1, i2);
-					var subres = formatDate(date, subformat, options);
-					if (subres != formatDate(otherDate, subformat, options)) {
-						res += subres;
-					}
-					i = i2;
-					break;
-				}
-			}
+// Break the formatting string into an array of chunks
+function chunkFormatString(formatStr) {
+	var chunks = [];
+	var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|((\w)\4*o?T?)|([^\w\[\(]+)/g; // TODO: more descrimination
+	var match;
+
+	while (match = chunker.exec(formatStr)) {
+		if (match[1]) { // a literal string instead [ ... ]
+			chunks.push(match[1]);
 		}
-		else if (c == '{') {
-			date = date2;
-			otherDate = date1;
+		else if (match[2]) { // non-zero formatting inside ( ... )
+			chunks.push({ maybe: chunkFormatString(match[2]) });
 		}
-		else if (c == '}') {
-			date = date1;
-			otherDate = date2;
+		else if (match[3]) { // a formatting token
+			chunks.push({ token: match[3] });
 		}
-		else {
-			for (i2=len; i2>i; i2--) {
-				if (formatter = dateFormatters[format.substring(i, i2)]) {
-					if (date) {
-						res += formatter(date, options);
-					}
-					i = i2 - 1;
-					break;
-				}
-			}
-			if (i2 == i) {
-				if (date) {
-					res += c;
-				}
-			}
+		else if (match[5]) { // an unenclosed literal string
+			chunks.push(match[5]);
 		}
 	}
-	return res;
-};
 
-
-var dateFormatters = {
-	s	: function(d)	{ return d.getSeconds() },
-	ss	: function(d)	{ return zeroPad(d.getSeconds()) },
-	m	: function(d)	{ return d.getMinutes() },
-	mm	: function(d)	{ return zeroPad(d.getMinutes()) },
-	h	: function(d)	{ return d.getHours() % 12 || 12 },
-	hh	: function(d)	{ return zeroPad(d.getHours() % 12 || 12) },
-	H	: function(d)	{ return d.getHours() },
-	HH	: function(d)	{ return zeroPad(d.getHours()) },
-	d	: function(d)	{ return d.getDate() },
-	dd	: function(d)	{ return zeroPad(d.getDate()) },
-	ddd	: function(d,o)	{ return o.dayNamesShort[d.getDay()] },
-	dddd: function(d,o)	{ return o.dayNames[d.getDay()] },
-	M	: function(d)	{ return d.getMonth() + 1 },
-	MM	: function(d)	{ return zeroPad(d.getMonth() + 1) },
-	MMM	: function(d,o)	{ return o.monthNamesShort[d.getMonth()] },
-	MMMM: function(d,o)	{ return o.monthNames[d.getMonth()] },
-	yy	: function(d)	{ return (d.getFullYear()+'').substring(2) },
-	yyyy: function(d)	{ return d.getFullYear() },
-	t	: function(d)	{ return d.getHours() < 12 ? 'a' : 'p' },
-	tt	: function(d)	{ return d.getHours() < 12 ? 'am' : 'pm' },
-	T	: function(d)	{ return d.getHours() < 12 ? 'A' : 'P' },
-	TT	: function(d)	{ return d.getHours() < 12 ? 'AM' : 'PM' },
-	u	: function(d)	{ return formatDate(d, "yyyy-MM-dd'T'HH:mm:ss'Z'") },
-	S	: function(d)	{
-		var date = d.getDate();
-		if (date > 10 && date < 20) {
-			return 'th';
-		}
-		return ['st', 'nd', 'rd'][date%10-1] || 'th';
-	},
-	w   : function(d, o) { // local
-		return o.weekNumberCalculation(d);
-	},
-	W   : function(d) { // ISO
-		return iso8601Week(d);
-	}
-};
-fc.dateFormatters = dateFormatters;
-
-
-/* thanks jQuery UI (https://github.com/jquery/jquery-ui/blob/master/ui/jquery.ui.datepicker.js)
- * 
- * Set as calculateWeek to determine the week of the year based on the ISO 8601 definition.
- * `date` - the date to get the week for
- * `number` - the number of the week within the year that contains this date
- */
-function iso8601Week(date) {
-	var time;
-	var checkDate = new Date(date.getTime());
-
-	// Find Thursday of this week starting on Monday
-	checkDate.setDate(checkDate.getDate() + 4 - (checkDate.getDay() || 7));
-
-	time = checkDate.getTime();
-	checkDate.setMonth(0); // Compare with Jan 1
-	checkDate.setDate(1);
-	return Math.floor(Math.round((time - checkDate) / 86400000) / 7) + 1;
+	return chunks;
 }
 

+ 74 - 32
src/defaults.js

@@ -1,6 +1,13 @@
 
 var defaults = {
 
+	lang: 'en',
+
+	defaultTimedEventDuration: '02:00:00',
+	defaultAllDayEventDuration: { days: 1 },
+	forceEventDuration: false,
+	nextDayThreshold: '09:00:00', // 9am
+
 	// display
 	defaultView: 'month',
 	aspectRatio: 1.35,
@@ -11,60 +18,62 @@ var defaults = {
 	},
 	weekends: true,
 	weekNumbers: false,
-	weekNumberCalculation: 'iso',
+
 	weekNumberTitle: 'W',
+	weekNumberCalculation: 'local',
 	
-	// editing
 	//editable: false,
-	//disableDragging: false,
-	//disableResizing: false,
-	
-	allDayDefault: true,
-	ignoreTimezone: true,
 	
 	// event ajax
 	lazyFetching: true,
 	startParam: 'start',
 	endParam: 'end',
+	timezoneParam: 'timezone',
+
+	//allDayDefault: undefined,
 	
 	// time formats
 	titleFormat: {
-		month: 'MMMM yyyy',
-		week: "MMM d[ yyyy]{ '&#8212;'[ MMM] d yyyy}",
-		day: 'dddd, MMM d, yyyy'
+		month: 'MMMM YYYY', // like "September 1986". each language will override this
+		week: 'll', // like "Sep 4 1986"
+		day: 'LL' // like "September 4 1986"
 	},
 	columnFormat: {
-		month: 'ddd',
-		week: 'ddd M/d',
-		day: 'dddd M/d'
+		month: 'ddd', // like "Sat"
+		week: generateWeekColumnFormat,
+		day: 'dddd' // like "Saturday"
 	},
 	timeFormat: { // for event elements
-		'': 'h(:mm)t' // default
+		'default': generateShortTimeFormat
 	},
 	
 	// locale
 	isRTL: false,
-	firstDay: 0,
-	monthNames: ['January','February','March','April','May','June','July','August','September','October','November','December'],
-	monthNamesShort: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],
-	dayNames: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'],
-	dayNamesShort: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'],
 	buttonText: {
-		prev: "<span class='fc-text-arrow'>&lsaquo;</span>",
-		next: "<span class='fc-text-arrow'>&rsaquo;</span>",
-		prevYear: "<span class='fc-text-arrow'>&laquo;</span>",
-		nextYear: "<span class='fc-text-arrow'>&raquo;</span>",
+		prev: "prev",
+		next: "next",
+		prevYear: "prev year",
+		nextYear: "next year",
 		today: 'today',
 		month: 'month',
 		week: 'week',
 		day: 'day'
 	},
+
+	buttonIcons: {
+		prev: 'left-single-arrow',
+		next: 'right-single-arrow',
+		prevYear: 'left-double-arrow',
+		nextYear: 'right-double-arrow'
+	},
 	
 	// jquery-ui theming
 	theme: false,
-	buttonIcons: {
+	themeButtonIcons: {
 		prev: 'circle-triangle-w',
-		next: 'circle-triangle-e'
+		next: 'circle-triangle-e',
+		prevYear: 'seek-prev',
+		nextYear: 'seek-next'
 	},
 	
 	//selectable: false,
@@ -76,6 +85,37 @@ var defaults = {
 	
 };
 
+
+function generateShortTimeFormat(options, langData) {
+	return langData.longDateFormat('LT')
+		.replace(':mm', '(:mm)')
+		.replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs
+		.replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand
+}
+
+
+function generateWeekColumnFormat(options, langData) {
+	var format = langData.longDateFormat('L'); // for the format like "MM/DD/YYYY"
+	format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); // strip the year off the edge, as well as other misc non-whitespace chars
+	if (options.isRTL) {
+		format += ' ddd'; // for RTL, add day-of-week to end
+	}
+	else {
+		format = 'ddd ' + format; // for LTR, add day-of-week to beginning
+	}
+	return format;
+}
+
+
+var langOptionHash = {
+	en: {
+		columnFormat: {
+			week: 'ddd M/D' // override for english. different from the generated default, which is MM/DD
+		}
+	}
+};
+
+
 // right-to-left defaults
 var rtlDefaults = {
 	header: {
@@ -83,15 +123,17 @@ var rtlDefaults = {
 		center: '',
 		right: 'title'
 	},
-	buttonText: {
-		prev: "<span class='fc-text-arrow'>&rsaquo;</span>",
-		next: "<span class='fc-text-arrow'>&lsaquo;</span>",
-		prevYear: "<span class='fc-text-arrow'>&raquo;</span>",
-		nextYear: "<span class='fc-text-arrow'>&laquo;</span>"
-	},
 	buttonIcons: {
+		prev: 'right-single-arrow',
+		next: 'left-single-arrow',
+		prevYear: 'right-double-arrow',
+		nextYear: 'left-double-arrow'
+	},
+	themeButtonIcons: {
 		prev: 'circle-triangle-e',
-		next: 'circle-triangle-w'
+		next: 'circle-triangle-w',
+		nextYear: 'seek-prev',
+		prevYear: 'seek-next'
 	}
 };
 

+ 22 - 29
src/gcal/gcal.js

@@ -4,13 +4,17 @@
  * (c) <%= meta.copyright %>
  */
  
-(function($) {
+(function(factory) {
+	if (typeof define === 'function' && define.amd) {
+		define([ 'jquery' ], factory);
+	}
+	else {
+		factory(jQuery);
+	}
+})(function($) {
 
 
 var fc = $.fullCalendar;
-var formatDate = fc.formatDate;
-var parseISO8601 = fc.parseISO8601;
-var addDays = fc.addDays;
 var applyAll = fc.applyAll;
 
 
@@ -26,64 +30,53 @@ fc.sourceNormalizers.push(function(sourceOptions) {
 });
 
 
-fc.sourceFetchers.push(function(sourceOptions, start, end) {
+fc.sourceFetchers.push(function(sourceOptions, start, end, timezone) {
 	if (sourceOptions.dataType == 'gcal') {
-		return transformOptions(sourceOptions, start, end);
+		return transformOptions(sourceOptions, start, end, timezone);
 	}
 });
 
 
-function transformOptions(sourceOptions, start, end) {
+function transformOptions(sourceOptions, start, end, timezone) {
 
 	var success = sourceOptions.success;
 	var data = $.extend({}, sourceOptions.data || {}, {
-		'start-min': formatDate(start, 'u'),
-		'start-max': formatDate(end, 'u'),
 		'singleevents': true,
 		'max-results': 9999
 	});
-	
-	var ctz = sourceOptions.currentTimezone;
-	if (ctz) {
-		data.ctz = ctz = ctz.replace(' ', '_');
-	}
 
 	return $.extend({}, sourceOptions, {
 		url: sourceOptions.url.replace(/\/basic$/, '/full') + '?alt=json-in-script&callback=?',
 		dataType: 'jsonp',
 		data: data,
-		startParam: false,
-		endParam: false,
+		timezoneParam: 'ctz',
+		startParam: 'start-min',
+		endParam: 'start-max',
 		success: function(data) {
 			var events = [];
 			if (data.feed.entry) {
 				$.each(data.feed.entry, function(i, entry) {
-					var startStr = entry['gd$when'][0]['startTime'];
-					var start = parseISO8601(startStr, true);
-					var end = parseISO8601(entry['gd$when'][0]['endTime'], true);
-					var allDay = startStr.indexOf('T') == -1;
+
 					var url;
 					$.each(entry.link, function(i, link) {
 						if (link.type == 'text/html') {
 							url = link.href;
-							if (ctz) {
-								url += (url.indexOf('?') == -1 ? '?' : '&') + 'ctz=' + ctz;
+							if (timezone && timezone != 'local') {
+								url += (url.indexOf('?') == -1 ? '?' : '&') + 'ctz=' + encodeURIComponent(timezone);
 							}
 						}
 					});
-					if (allDay) {
-						addDays(end, -1); // make inclusive
-					}
+
 					events.push({
 						id: entry['gCal$uid']['value'],
 						title: entry['title']['$t'],
+						start: entry['gd$when'][0]['startTime'],
+						end: entry['gd$when'][0]['endTime'],
 						url: url,
-						start: start,
-						end: end,
-						allDay: allDay,
 						location: entry['gd$where'][0]['valueString'],
 						description: entry['content']['$t']
 					});
+
 				});
 			}
 			var args = [events].concat(Array.prototype.slice.call(arguments, 1));
@@ -104,4 +97,4 @@ fc.gcalFeed = function(url, sourceOptions) {
 };
 
 
-})(jQuery);
+});

+ 8 - 8
src/intro.js

@@ -4,11 +4,11 @@
  * (c) <%= meta.copyright %>
  */
 
-/*
- * Use fullcalendar.css for basic styling.
- * For event drag & drop, requires jQuery UI draggable.
- * For event resizing, requires jQuery UI resizable.
- */
- 
-(function($, undefined) {
-
+(function(factory) {
+	if (typeof define === 'function' && define.amd) {
+		define([ 'jquery', 'moment' ], factory);
+	}
+	else {
+		factory(jQuery, moment);
+	}
+})(function($, moment) {

+ 72 - 0
src/lang.js

@@ -0,0 +1,72 @@
+
+//var langOptionHash = {}; // initialized in defaults.js
+fc.langs = langOptionHash; // expose
+
+
+// Initialize jQuery UI Datepicker translations while using some of the translations
+// for our own purposes. Will set this as the default language for datepicker.
+// Called from a translation file.
+fc.datepickerLang = function(langCode, datepickerLangCode, options) {
+	var langOptions = langOptionHash[langCode];
+
+	// initialize FullCalendar's lang hash for this language
+	if (!langOptions) {
+		langOptions = langOptionHash[langCode] = {};
+	}
+
+	// merge certain Datepicker options into FullCalendar's options
+	mergeOptions(langOptions, {
+		isRTL: options.isRTL,
+		weekNumberTitle: options.weekHeader,
+		titleFormat: {
+			month: options.showMonthAfterYear ?
+				'YYYY[' + options.yearSuffix + '] MMMM' :
+				'MMMM YYYY[' + options.yearSuffix + ']'
+		},
+		buttonText: {
+			// the translations sometimes wrongly contain HTML entities
+			prev: stripHTMLEntities(options.prevText),
+			next: stripHTMLEntities(options.nextText),
+			today: stripHTMLEntities(options.currentText)
+		}
+	});
+
+	// is jQuery UI Datepicker is on the page?
+	if ($.datepicker) {
+
+		// Register the language data.
+		// FullCalendar and MomentJS use language codes like "pt-br" but Datepicker
+		// does it like "pt-BR" or if it doesn't have the language, maybe just "pt".
+		// Make an alias so the language can be referenced either way.
+		$.datepicker.regional[datepickerLangCode] =
+			$.datepicker.regional[langCode] = // alias
+				options;
+
+		// Alias 'en' to the default language data. Do this every time.
+		$.datepicker.regional['en'] = $.datepicker.regional[''];
+
+		// Set as Datepicker's global defaults.
+		$.datepicker.setDefaults(options);
+	}
+};
+
+
+// Sets FullCalendar-specific translations. Also sets the language as the global default.
+// Called from a translation file.
+fc.lang = function(langCode, options) {
+	var langOptions;
+
+	if (options) {
+		langOptions = langOptionHash[langCode];
+
+		// initialize the hash for this language
+		if (!langOptions) {
+			langOptions = langOptionHash[langCode] = {};
+		}
+
+		mergeOptions(langOptions, options || {});
+	}
+
+	// set it as the default language for FullCalendar
+	defaults.lang = langCode;
+};

+ 3 - 0
src/main.css

@@ -101,11 +101,14 @@ html .fc,
 ------------------------------------------------------------------------*/
 	
 .fc-content {
+	position: relative;
+	z-index: 1; /* scopes all other z-index's to be inside this container */
 	clear: both;
 	zoom: 1; /* for IE7, gives accurate coordinates for [un]freezeContentHeight */
 	}
 	
 .fc-view {
+	position: relative;
 	width: 100%;
 	overflow: hidden;
 	}

+ 33 - 24
src/main.js

@@ -5,7 +5,6 @@ var fcViews = fc.views = {};
 
 $.fn.fullCalendar = function(options) {
 
-
 	// method calling
 	if (typeof options == 'string') {
 		var args = Array.prototype.slice.call(arguments, 1);
@@ -27,41 +26,51 @@ $.fn.fullCalendar = function(options) {
 		}
 		return this;
 	}
-
-	options = options || {};
-	
-	// would like to have this logic in EventManager, but needs to happen before options are recursively extended
-	var eventSources = options.eventSources || [];
-	delete options.eventSources;
-	if (options.events) {
-		eventSources.push(options.events);
-		delete options.events;
-	}
-	
-
-	options = $.extend(true, {},
-		defaults,
-		(options.isRTL || options.isRTL===undefined && defaults.isRTL) ? rtlDefaults : {},
-		options
-	);
-	
 	
 	this.each(function(i, _element) {
 		var element = $(_element);
-		var calendar = new Calendar(element, options, eventSources);
-		element.data('fullCalendar', calendar); // TODO: look into memory leak implications
+		var calendar = new Calendar(element, options);
+		element.data('fullCalendar', calendar);
 		calendar.render();
 	});
 	
-	
 	return this;
-	
 };
 
 
 // function for adding/overriding defaults
 function setDefaults(d) {
-	$.extend(true, defaults, d);
+	mergeOptions(defaults, d);
 }
 
 
+// Recursively combines option hash-objects.
+// Better than `$.extend(true, ...)` because arrays are not traversed/copied.
+//
+// called like:
+//     mergeOptions(target, obj1, obj2, ...)
+//
+function mergeOptions(target) {
+	for (var i=1; i<arguments.length; i++) {
+		$.each(arguments[i], function(name, value) {
+			if ($.isPlainObject(value) && $.isPlainObject(target[name]) && !isForcedAtomicOption(name)) {
+				// merge into a new object to avoid destruction
+				target[name] = mergeOptions({}, target[name], value); // combine. `value` object takes precedence
+			}
+			else if (value !== undefined) { // only use values that are set and not undefined
+				target[name] = value;
+			}
+		});
+	}
+	return target;
+}
+
+
+// overcome sucky view-option-hash and option-merging behavior messing with options it shouldn't
+function isForcedAtomicOption(name) {
+	// Any option that ends in "Time" or "Duration" is probably a Duration,
+	// and these will commonly be specified as plain objects, which we don't want to mess up.
+	return /(Time|Duration)$/.test(name);
+}
+// FIX: find a different solution for view-option-hashes and have a whitelist
+// for options that can be recursively merged.

+ 1 - 1
src/outro.js

@@ -1,2 +1,2 @@
 
-})(jQuery);
+});

+ 13 - 32
src/util.js

@@ -2,25 +2,14 @@
 fc.applyAll = applyAll;
 
 
-/* Event Date Math
------------------------------------------------------------------------------*/
-
 
-function exclEndDay(event) {
-	if (event.end) {
-		return _exclEndDay(event.end, event.allDay);
-	}else{
-		return addDays(cloneDate(event.start), 1);
-	}
+function createObject(proto) { // like Object.create
+	var f = function() {};
+	f.prototype = proto;
+	return new f();
 }
 
 
-function _exclEndDay(end, allDay) {
-	end = cloneDate(end);
-	return allDay || end.getHours() || end.getMinutes() ? addDays(end, 1) : clearTime(end);
-	// why don't we check for seconds/ms too?
-}
-
 
 
 /* Event Element Binding
@@ -125,7 +114,7 @@ function vborders(element) {
 function noop() { }
 
 
-function dateCompare(a, b) {
+function dateCompare(a, b) { // works with moments too
 	return a - b;
 }
 
@@ -135,11 +124,6 @@ function arrayMax(a) {
 }
 
 
-function zeroPad(n) {
-	return (n < 10 ? '0' : '') + n;
-}
-
-
 function smartProperty(obj, name) { // get a camel-cased/namespaced property of an object
 	if (obj[name] !== undefined) {
 		return obj[name];
@@ -152,12 +136,12 @@ function smartProperty(obj, name) { // get a camel-cased/namespaced property of
 			return res;
 		}
 	}
-	return obj[''];
+	return obj['default'];
 }
 
 
 function htmlEscape(s) {
-	return s.replace(/&/g, '&amp;')
+	return (s + '').replace(/&/g, '&amp;')
 		.replace(/</g, '&lt;')
 		.replace(/>/g, '&gt;')
 		.replace(/'/g, '&#039;')
@@ -166,6 +150,11 @@ function htmlEscape(s) {
 }
 
 
+function stripHTMLEntities(text) {
+	return text.replace(/&.*?;/g, '');
+}
+
+
 function disableTextSelection(element) {
 	element
 		.attr('unselectable', 'on')
@@ -184,7 +173,7 @@ function enableTextSelection(element) {
 */
 
 
-function markFirstLast(e) {
+function markFirstLast(e) { // TODO: use CSS selectors instead
 	e.children()
 		.removeClass('fc-first fc-last')
 		.filter(':first-child')
@@ -195,14 +184,6 @@ function markFirstLast(e) {
 }
 
 
-function setDayID(cell, date) {
-	cell.each(function(i, _cell) {
-		_cell.className = _cell.className.replace(/^fc-\w*/, 'fc-' + dayIDs[date.getDay()]);
-		// TODO: make a way that doesn't rely on order of classes
-	});
-}
-
-
 function getSkinCss(event, opt) {
 	var source = event.source || {};
 	var eventColor = event.color;