/** * # `ts.js`: TimeStamper Web UI Client-side * @author Jonathan Bernard **/ // TimeStamper namespace var TS = {}; /* Setup after the document is ready for manipulation. */ $(document).ready(function(){ // ======== DEFINE MODELS ========// /* Entry model. * Attributes * - user_id * - timeline_id * - id * - mark * - uuid * - notes * - timestamp */ TS.EntryModel = Backbone.Model.extend({ initialize: function(attrs, options) { _.bindAll(this, 'get', 'set', 'urlRoot'); }, get: function(attribute) { if (attribute == "timestamp") { if (!this.timestampDate) { this.timestampDate = new Date( Backbone.Model.prototype.get.call(this, attribute)); } return this.timestampDate; } else { return Backbone.Model.prototype.get.call(this, attribute); } }, set: function(attributes, options) { var attrsToSet = {} _.each(attributes, function(val, key) { if (key == "timestamp") { if (val instanceof Date) { this.timestampDate = val; attrsToSet.timestamp = dateToJSON(val); } else { this.timestampDate = new Date(val); attrsToSet.timestamp = dateToJSON(this.timestampDate); } } else { attrsToSet[key] = val; } }); return Backbone.Model.prototype.set.call( this, attrsToSet, options); }, urlRoot: function() { return '/ts_api/entries/' + this.get('user_id') + '/' + this.get('timeline_id') + '/'; } }); /* Timeline model. * Attributes: * - user_id * - id * - description * - created */ TS.TimelineModel = Backbone.Model.extend({ urlRoot: function() { return '/ts_api/timelines/' + this.get('user_id') + '/'; }, initialze: function(attrs, options) { _.bindAll(this, 'urlRoot'); } }); /* User model. * Attributes: * - id * - name * - email * - join_date */ TS.UserModel = Backbone.Model.extend({ url: function() { return '/ts_api/users/' + this.get('id'); }, initialize: function(attrs, options) { _.bindAll(this, 'url'); } }); TS.EntryList = Backbone.Collection.extend({ model: TS.EntryModel, comparator: function(entry) { return entry.get('timestamp'); }, initialize: function(model, options) { if (options.timelineModel == undefined) { throw "Cannot create an EntryList without a " + "TimelineModel reference." } else { this.timelineModel = options.timelineModel; } _.bindAll(this, "url", "create"); }, url: function() { return "/ts_api/entries/" + this.timelineModel.get('user_id') + "/" + this.timelineModel.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" : "saveOnEnter", "keypress .timestamp-input" : "saveOnEnter", "keypress .notes-input" : "saveOnCtrlEnter", "blur .mark-input" : "save", "blur .timestamp-input" : "save", "blur .notes-input" : "save" }, initialize: function(options) { _.bindAll(this, 'render', 'editTImestamp', 'editMark', 'update', 'saveOnEnter', 'saveOnCtrlEnter', 'getViewModel', 'renderNotes', 'showNotes', 'hideNotes', 'save'); 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; }, /** Save and close editable fields. */ save: function() { this.model.save({ mark: this.$('.mark-input').val(), timestamp: new Date(this.$('.timestamp-input').val()), notes: this.$('.notes-input').val()}); this.$('.notes-text').html(this.renderNotes(this.model.get('notes'))); $(this.el).removeClass('edit-mark edit-timestamp edit-notes'); }, /** Event handler for keypresses on entry input fields. */ saveOnEnter: function(e) { if(e.keyCode == 13) { this.save(); } }, saveOnCtrlEnter: function(e) { if (e.keyCode == 10) { this.save(); } }, /** * 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 = model.get('timestamp'); var d2, diff; var day, hr, min; // if no next model, assume it's an onoing task if (nextModel) { d2 = 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) { // exclude if any exclusion RegExps match var excluded = _.any(this.entryExclusions, function(exclusion) { return exclusion.test(entry.get("mark"))}); // create the view if it does not exist if (!entry.view) { new TS.EntryView( {model: entry, markdownConverter: this.markdownConverter}); } entry.view.nextModel = nextEntry // render the element var el = entry.view.render().el; // add it to the container after the topmost separator ("Today") this.topSeparator.after(el); // If this entry and the next entry are not on the same day, put a // day separator between them. var nextDay = nextEntry ? nextEntry.get("timestamp") : new Date(); if (entry.get("timestamp").getDate() != nextDay.getDate()) { this.topSeparator.after(ich.daySeparatorTemplate( {separatorLabel: this.formatDaySeparator( TS.app.currentDay, entry.get("timestamp")) })); } // hide it if excluded if (excluded) { $(el).fadeOut('slow'); $(el).addClass('excluded'); } }, 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: new Date()}, {wait: true, success: function(model, resp, options) { model.set(resp); }}); // clear the input for the next entry this.$("#new-entry-input").val(""); } }, render: function() { // get our user exclusions var userExclusions = TS.app.user.model.get("entry_exclusions") || []; // get the current timeline exclusions var timelineExclusions = this.collection.timelineModel.get("entry_exclusions") || []; // turn them into RegExps and store them this.entryExclusions = _.map( userExclusions.concat(timelineExclusions), function(exclusion) { return new RegExp(exclusion)} ); // clear existing elements in the view container this.entryContainer.empty(); // add the top-most day separator; should always be "Today" this.topSeparator = ich.daySeparatorTemplate({ separatorLabel: this.formatDaySeparator( TS.app.currentDay, new Date()) }); this.entryContainer.prepend(this.topSeparator); // iterate through the collection and render the elements. 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); } }, formatDaySeparator: function(today, labelDay) { var days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; var months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; var yearDiff = today.getFullYear() - labelDay.getFullYear(); var monthDiff = today.getMonth() - labelDay.getMonth(); var dayDiff = today.getDate() - labelDay.getDate(); // more than a calendar year old: Weekday, Month Date, Year if (yearDiff > 0) { return days[labelDay.getDay()] + ", " + months[labelDay.getMonth()] + " " + labelDay.getDate() + ", " + labelDay.getFullYear(); } // same calendar year, more than a week ago: Weekday, Month Date else if (monthDiff > 0 || dayDiff > 7) { return days[labelDay.getDay()] + ", " + months[labelDay.getMonth()] + " " + labelDay.getDate(); } // less than a week ago, more than yesterday, Last Weekday else if (dayDiff > 1) { return "Last " + days[labelDay.getDay()]; } // Yesterday else if (dayDiff == 1) { return "Yesterday"; } // Today else if (dayDiff == 0) { return "Today"; } } }); TS.TimelineListView = Backbone.View.extend({ el: $("#timeline"), collection: TS.TimelineList, events: { "dblclick .timeline-id" : "editId", "dblclick .timeline-desc" : "editDesc", "keypress .timeline-id-input" : "saveOnEnter", "keypress .timeline-desc-input" : "saveOnEnter", "click .new-timeline-link" : "showNewTimelineDialog" }, initialize: function(options) { _.bindAll(this, 'render', 'renderOne', 'editId', 'editDesc', 'saveOnEnter', 'save'); if (options.initialTimelineId == undefined) { throw "Can not create a TimelineListView without an " + "initial timeline." } else { this.selectedModel = 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.selectedModel.toJSON())); // render the selection list _.each(this.collection.without([this.selectedModel]), 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; }, save: function() { this.selectedModel.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(); }, saveOnEnter: function(e) { if (e.keyCode == 13) { this.save(); } } }); TS.UserView = Backbone.View.extend({ el: $("#user"), model: TS.UserModel, events: { 'dblclick .fullname': 'editFullname', 'keypress .fullname-input': 'saveOnEnter' }, initialize: function() { _.bindAll(this, 'render', 'save', 'editFullname', 'saveOnEnter'); 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; }, save: function() { this.model.set({name: this.$('fullname-input').val()}); this.model.save(); $(this.el).removeClass('edit-fullname'); }, saveOnEnter: function(e) { if (e.keyCode == 13) this.save(); } }); TS.AppView = Backbone.View.extend({ el: $("body"), events: { 'click #timeline .drop-menu-items a': 'selectTimeline' }, initialize: function() { _.bindAll(this, 'initializeViews', 'loadInitialData', 'periodicRefresh', 'selectTimeline'); TS.app = this; TS.app.currentDay = new Date(); // 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() { TS.app.initializeData(TS.app.loadInitialData())}); } // Schedule our update function. setInterval(this.periodicRefresh, 60000); }, 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, {timelineModel: this.timelines.view.selectedModel}); 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); // look for the last used timeline, default to first timeline data.initialTimelineId = data.user.last_timeline || data.timelines[0].id data.entries = jQuery.parseJSON($.ajax({ url: '/ts_api/entries/' + username + '/' + data.initialTimelineId, async: false}).responseText); return data; }, periodicRefresh: function() { var now = new Date(); if (this.currentDay.getDate() != now.getDate()) { // It's a new day! Rerender our whole list. this.entries.render(); } else { // Refresh our latest entry view so the duration is up to date. var models = this.entries.collection.models; models[models.length - 1].view.render(); } }, 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.selectedModel = tl; // set the timeline on the EntryList this.entries.collection.timelineModel = tl; // update the last_timeline field of the user model this.user.model.set({last_timeline: tl.get('id')}); this.user.model.save(); // re-render the TimelineListView this.timelines.view.render(); // fetch the new 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: dateToJSON(new Date())}); 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(); } }); new TS.AppView(); }) function dateToJSON(d) { 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'; }