// 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'; }