Jonathan Bernard 99d04935cb Implemented timeline selection.
* Added personal VIM ide extension.
* Implemented timeline selection (resolves D0007)
* Slightly restyled the new timeline button and timeline list menu.
2011-06-10 11:49:45 -05:00

684 lines
21 KiB
JavaScript

// TimeStamper namespace
var TS = {};
/* Setup after the document is ready for manipulation. */
$(document).ready(function(){
// ======== DEFINE MODELS ========//
/* Entry model.
* Attributes
* - id
* - mark
* - notes
* - start
*/
TS.EntryModel = Backbone.Model.extend({
});
/* Timeline model.
* Attributes:
* - id
* - description
* - created
*/
TS.TimelineModel = Backbone.Model.extend({
});
/* User model.
* Attributes:
* - username
* - fullname
* - email
* - join_date
*/
TS.UserModel = Backbone.Model.extend({
url: function() { return '/ts_api/users/' + this.get('id'); },
initialize: function(attrs, options) {
_.bind(this, 'url');
}
});
TS.EntryList = Backbone.Collection.extend({
model: TS.EntryModel,
comparator: function(entry) { return entry.get('timestamp'); },
initialize: function(model, options) {
if (options.timeline == undefined) {
throw "Cannot create an EntryList without a TimelineModel reference."
} else { this.timeline = options.timeline; }
_.bindAll(this, "url");
},
url: function() {
return "/ts_api/entries/" + this.timeline.get('user_id') + "/" + this.timeline.get('id');
}
});
TS.TimelineList = Backbone.Collection.extend({
model: TS.TimelineModel,
initialize: function(models, options) {
if (options.user == undefined) {
throw "Cannot create a TimelineList without a UserModel reference.";
} else { this.user = options.user; }
_.bindAll(this, 'url');
},
comparator: function(timeline) {
return timeline.get('id');
},
url: function() {
return "/ts_api/timelines/" + this.user.get('id');
}
});
// ======== DEFINE VIEWS ========//
/* Entry view
*/
TS.EntryView = Backbone.View.extend({
model: TS.EntryModel,
className: 'entry',
notesCache: false,
events: {
"click img.expand-entry" : "showNotes",
"click img.collapse-entry" : "hideNotes",
"dblclick div.mark" : "editMark",
"dblclick div.timestamp" : "editTimestamp",
"dblclick div.notes" : "editNotes",
"keypress .mark-input" : "updateOnEnter",
"keypress .timestamp-input" : "updateOnEnter",
"keypress .notes-input" : "updateOnCtrlEnter",
"blur .mark-input" : "close",
"blur .timestamp-input" : "close",
"blur .notes-input" : "close"
},
initialize: function(options) {
_.bindAll(this, 'render', 'close', 'editTImestamp', 'editMark',
'update', 'updateOnEnter', 'updateOnCtrlEnter', 'getViewModel',
'renderNotes', 'showNotes', 'hideNotes');
this.markdownConverter = options.markdownConverter;
this.model.bind('change', this.update);
this.model.view = this;
this.nextModel = options.nextModel;
},
/**
* Refresh the display based on the model replacing the existing
* HTML content. Add new `blur` listeners to the input fields.
*/
render: function() {
// render the HTML
$(this.el).html(ich.entryTemplate(this.getViewModel()));
// invalidate the notes display cache
this.notesCache = false;
return this;
},
/**
* Refresh the display based on the model using the existing DOM
* elements.
*/
update: function() {
var data = this.getViewModel();
this.$('.mark span').text(data.mark);
this.$('.mark-input').val(data.mark);
this.$('.timestamp').text(data.start);
this.$('.timestamp-input').val(data.timestamp);
this.$('.duration').text(data.duration);
this.$('.notes-text').html(this.renderNotes(data.notes));
this.$('.notes-input').val(data.notes);
return this;
},
renderNotes: function(source) {
if (!this.notesCache) {
this.notesCache = this.markdownConverter.makeHtml(source);
}
return this.notesCache
},
editMark: function() {
$(this.el).addClass('edit-mark');
this.$('.mark-input').focus();
return this;
},
editTimestamp: function() {
$(this.el).addClass('edit-timestamp');
this.$('timestamp-input').focus();
return this;
},
editNotes: function() {
// invalidate notes HTML cache
this.notesCache = false;
// show notes textarea, hide display
$(this.el).addClass('edit-notes');
// focus input
this.$('.notes-input').focus();
return this;
},
/**
* Translate the model data into a form suitable to be displayed.
* @return a map including display-able `start` and `duration` values.
*/
getViewModel: function() {
var data = this.model.toJSON();
// create start and duration values
var tsDate = new Date(data.timestamp);
data.start = this.formatStart(tsDate);
data.duration = this.formatDuration(this.model, this.nextModel);
data.notes = data.notes ? data.notes : '*No notes for this entry.*';
return data;
},
/** Close editable fields. */
close: function() {
$(this.el).removeClass('edit-mark edit-timestamp edit-notes');
},
/** Persist changes in input fields. */
save: function() {
this.model.save({
mark: this.$('.mark-input').val(),
timestamp: this.$('.timestamp-input').val(),
notes: this.$('.notes-input').val()});
},
/** Event handler for keypresses on entry input fields. */
updateOnEnter: function(e) {
if(e.keyCode == 13) { this.save(); this.close(); }
},
updateOnCtrlEnter: function(e) {
if (e.keyCode == 10) { this.save(); this.close(); }
},
/**
* Get the display-able start time from the entry timestamp.
* @param startDate a Date object, the entry timestamp.
* @return display-able start time in HH:MM format.
*/
formatStart: function(startDate) {
/* Code is written for both 24hr and 12hr formats. I still need to
* create a mechanism for selecting between them. For now, use the
* 12hr format. */
// 24 hour
// return startDate.getHours() + ":" + startDate.getMinutes();
// 12 hour
var hour = startDate.getHours() % 12;
return (hour == 0 ? 12 : hour) + ":" + startDate.getMinutes() +
" " + (startDate.getHours() > 11 ? "pm" : "am");
},
/**
* Get the duration of the entry based on this entry's timestamp and
* and the next entry's timestamp in a display-able form. If nextModel
* is `null` or `undefined` it is assumed that `model` is the most
* recent model and duration is calculated against the current time.
*
* @param model EntryModel representing this entry.
* @param nextModel EntryModel representing the next entry.
* @return the duration between model and nextModel, formatted for
* display: `Xd Yhr Zm`. */
formatDuration: function(model, nextModel) {
var d1 = new Date(model.get('timestamp'));
var d2, diff;
var day, hr, min;
// if no next model, assume it's an onoing task
if (nextModel) { d2 = new Date(nextModel.get('timestamp')); }
else { d2 = new Date(); }
diff= d2.getTime() - d1.getTime();
day = Math.floor(diff / 86400000); // milliseconds in a day
diff %= 86400000;
hr = Math.floor(diff / 3600000); // millis in an hour
diff %= 3600000;
min = Math.floor(diff / 60000); // millis in a minute
return (day > 0 ? day + "d " : "") +
(hr > 0 ? hr + "hr " : "") +
min + "m ";
},
showNotes: function() {
if (!this.notesCache) {
this.$('.notes-text').html(
this.renderNotes(this.model.get('notes')))
}
this.$('.notes').slideDown();
$(this.el).addClass('show-notes');
},
hideNotes: function() {
this.$('.notes').slideUp();
$(this.el).removeClass('show-notes');
}
});
TS.EntryListView = Backbone.View.extend({
el: $("#entry-list"),
events: {
"keypress #new-entry-input" : "createNewEntryOnEnter"
},
initialize: function() {
_.bindAll(this, 'addOne', 'createNewEntry', 'render', 'renderOne');
this.collection.bind('add', this.addOne);
this.collection.bind('refresh', this.render);
this.collection.view = this;
this.entryContainer = this.$("#entries")
this.markdownConverter = new Showdown.converter();
},
addOne: function(entry) {
var lastEntry = this.collection.at(this.collection.length - 2);
lastEntry.view.nextModel = entry;
lastEntry.view.update();
this.renderOne(entry, null);
},
renderOne: function(entry, nextEntry) {
if (!entry.view) { new TS.EntryView(
{model: entry, markdownConverter: this.markdownConverter}); }
entry.view.nextModel = nextEntry
this.entryContainer.prepend(entry.view.render().el);
},
createNewEntryOnEnter: function(e) {
if (e.keyCode == 13) {
// grab the mark data
var entryMark = this.$("#new-entry-input").val();
// create the mark. Immediately fetch to get server-side timestamp
this.collection.create({mark: entryMark,
notes: '',
timestamp: getUTCTimestamp()}).fetch();
// clear the input for the next entry
this.$("#new-entry-input").val("");
}
},
render: function() {
this.entryContainer.empty();
for (var i = 0, len = this.collection.length; i < len; i++) {
var entry = this.collection.at(i);
var nextEntry = (i + 1 < len ? this.collection.at(i + 1) : null);
this.renderOne(entry, nextEntry);
}
}
});
TS.TimelineListView = Backbone.View.extend({
el: $("#timeline"),
collection: TS.TimelineList,
events: {
"dblclick .timeline-id" : "editId",
"dblclick .timeline-desc" : "editDesc",
"keypress .timeline-id-input" : "updateOnEnter",
"keypress .timeline-desc-input" : "updateOnEnter",
"click .new-timeline-link" : "showNewTimelineDialog"
},
initialize: function(options) {
_.bindAll(this, 'render', 'renderOne', 'editId',
'editDesc', 'updateOnEnter', 'close');
if (options.initialTimelineId == undefined) {
throw "Can not create a TimelineListView without an initial timeline."
} else {
this.selected = this.collection.get(options.initialTimelineId);
}
this.collection.bind('add', this.renderOne);
this.collection.bind('refresh', this.render);
},
renderOne: function(timeline) {
this.$('.drop-menu-items').append(
ich.timelineLinkTemplate(timeline.toJSON()));
},
render: function() {
// render the basic template
$(this.el).html(ich.timelineTemplate(this.selected.toJSON()));
// render the selection list
_.each(this.collection.without([this.selected]), this.renderOne);
},
editId: function() {
$(this.el).addClass('edit-id');
this.$('.timeline-id-input').focus();
return this;
},
editDesc: function() {
$(this.el).addClass('edit-desc');
this.$('.timeline-desc-input').focus();
return this;
},
close: function() {
this.selected.save({
id: this.$('.timeline-id-input').val(),
description: this.$('.timeline-desc-input').val()});
$(this.el).removeClass('edit-id edit-desc');
this.render();
},
showNewTimelineDialog: function() {
TS.app.newTimelineDialog.show();
},
updateOnEnter: function(e) {
if (e.keyCode == 13) { this.close(); this.save() }
}
});
TS.UserView = Backbone.View.extend({
el: $("#user"),
model: TS.UserModel,
events: {
'dblclick .fullname': 'editFullname',
'keypress .fullname-input': 'updateOnEnter'
},
initialize: function() {
_.bindAll(this, 'render', 'close', 'editFullname', 'updateOnEnter');
this.model.bind('change', this.render);
this.model.view = this;
},
render: function() {
$(this.el).html(ich.userTemplate(this.model.toJSON()));
return this;
},
editFullname: function() {
$(this.el).addClass('edit-fullname');
this.$('.fullname-input').focus();
return this;
},
close: function() {
this.model.set({name: this.$('fullname-input').val()});
this.model.save();
$(this.el).removeClass('edit-fullname');
},
updateOnEnter: function(e) {
if (e.keyCode == 13) this.close();
}
});
TS.AppView = Backbone.View.extend({
el: $("body"),
events: {
'click #timeline .drop-menu-items a': 'selectTimeline'
},
initialize: function() {
_.bindAll(this, 'initializeViews', 'loadInitialData',
'selectTimeline');
appThis = this;
// create the login dialog
this.loginDialog = new TS.LoginView
// initialize data, either from boostrapped data, or via user login
if (window.bootstrap) { this.initializeData(window.bootstrap()) }
else {
// this is async (waiting for user input)
this.loginDialog.authenticate(function() {
appThis.initializeData(appThis.loadInitialData())});
}
},
initializeData: function(data) {
// create user data
this.user = {};
this.user.model = new TS.UserModel(data.user);
this.user.view = new TS.UserView({model: this.user.model});
// create timeline models from the bootstrapped data
var tlModels = _.map(data.timelines, function(timeline) {
return new TS.TimelineModel(timeline);
});
// create the timeline list collection
this.timelines = {};
this.timelines.collection = new TS.TimelineList(
tlModels, {user: this.user.model});
this.timelines.view = new TS.TimelineListView(
{collection: this.timelines.collection,
initialTimelineId: data.initialTimelineId});
// create the new timeline dialog
this.newTimelineDialog = new TS.NewTimelineView(
{timelineCollection: this.timelines.collection});
// create entry models from the bootstrapped data
var entryModels = _.map(data.entries, function(entry) {
return new TS.EntryModel(entry);
});
// create the entry collection
this.entries = {};
this.entries.collection = new TS.EntryList(entryModels,
{timeline: this.timelines.view.selected});
this.entries.view = new TS.EntryListView(
{collection: this.entries.collection});
// render views
this.user.view.render();
this.timelines.view.render();
this.entries.view.render();
},
loadInitialData: function() {
// assume we are authenticated
var username = $("#username-input").val(); // hackish
var data = jQuery.parseJSON($.ajax({
url: '/ts_api/app/user_summary/' + username,
async: false}).responseText);
data.initialTimelineId = data.timelines[0].id;
data.entries = jQuery.parseJSON($.ajax({
url: '/ts_api/entries/' + username + '/' +
data.initialTimelineId,
async: false}).responseText);
return data;
},
selectTimeline: function(e) {
if (e) {
// get the timeline model
var tl = this.timelines.collection.get(e.srcElement.text);
// set the on the timeline view
this.timelines.view.selected = tl;
// set the timeline on the EntryList
this.entries.collection.timeline = tl;
// refresh TimelineListView
this.timelines.view.render();
// refresh EntryList records
this.entries.collection.fetch()
}
}
});
TS.LoginView = Backbone.View.extend({
el: $("#login"),
events: {
"keypress #password-input" : "loginOnEnter",
"click #login-button a" : "doLogin"
},
initialize: function() {
_.bindAll(this, 'authenticate', 'doLogin', 'hide', 'loginOnEnter',
'show');
},
action: function() {},
authenticate: function(nextAction) {
this.action = nextAction;
this.show();
},
doLogin: function(){
var viewThis = this;
var name = this.$("#username-input");
var pwd = $("#password-input");
// call the API via AJAX
$.ajax({
url: "/ts_api/login",
processData: false,
data: JSON.stringify({username: name.val(), password: pwd.val()}),
type: "POST",
async: false,
error: function(jqXHR, textStatus, error) {
// assuming bad credentials (possible server error or bad request,
// we should check that, FIXME
var tips = $(".validate-tips");
tips.text("Incorrect username/password combination.");
tips.slideDown();
},
success: function(data, textStatus, jqXHR) {
viewThis.hide();
viewThis.action();
}
});
},
hide: function() { $(this.el).addClass('hidden'); },
show: function() {
$(this.el).removeClass('hidden');
this.$("#username-input").focus();
},
loginOnEnter: function(e) {
if (e.keyCode == 13) { this.doLogin(); }
}
});
TS.NewTimelineView = Backbone.View.extend({
el: $("#new-timeline"),
events: {
"click #new-timeline-create a" : "createTimeline",
"click #new-timeline-cancel a" : "hide"
},
initialize: function(options) {
_.bindAll(this, 'createTimeline', 'hide', 'show');
if (options.timelineCollection == undefined) {
throw "Can not create the NewTimelineView without the timeline collection."
} else {
this.timelineCollection = options.timelineCollection;
}
},
createTimeline: function() {
var timelineId = this.$("#new-timeline-id").val();
var timelineDesc = this.$("#new-timeline-desc").val();
this.timelineCollection.create(
{id: timelineId, description: timelineDesc,
created: getUTCTimestamp()});
this.hide();
},
hide: function() { $(this.el).addClass('hidden'); },
show: function() {
this.$("#new-timeline-id").val("");
this.$("#new-timeline-desc").val("");
$(this.el).removeClass('hidden');
this.$("#new-timeline-id").focus();
}
});
TS.app = new TS.AppView;
})
function getUTCTimestamp() {
var d = new Date();
function pad(n){return n<10 ? '0'+n : n}
return d.getUTCFullYear()+'-'
+ pad(d.getUTCMonth()+1)+'-'
+ pad(d.getUTCDate())+'T'
+ pad(d.getUTCHours())+':'
+ pad(d.getUTCMinutes())+':'
+ pad(d.getUTCSeconds())+'Z';
}