Jonathan Bernard 98032e2b89 Added UUIDs to timestamps, refactored the build process
* Added UUIDs to `ts_entry` records. Updated `ts_json:construct_record` to
  respond to `uuid` member properties if present. UUIDs are not required by the
  strict parsing functions in `ts_json` because the client will make a request
  with no UUID if it is a purely new timestamp. IN fact, this is the normal
  case. The UUID is only present when another tool is syncing its copy of this
  timeline wand adding entries that it has created and assigned UUIDs to.
* `ts_entry:new` will create a UUID for a new entry if it does not already have
  one.
* Restructured the build process to put all build artifacts into a dedicated
  `build` subdirectory, instead of mising them in an amongst the source code.
* Added the `uuid` module to the project. It can be found at

      https://gitorious.org/avtobiff/erlang-uuid

* Rewrote asset URLs to use relative paths instead of absolute paths. Relative
  paths are correct in this case, becuase assets always live alongside the HTML
  pages. This change was needed to accomodate the new organization of the JDB
  Labs dev environment, where all projects live under subdirectories of the
  same virtual server instead of subdomains.
* Tweaked the timestamp entry fields in the web UI to save when the field is
  blurred, not just when <Enter> or <Ctrl>-<Enter> is pressed (though those
  still work).
2013-10-11 20:06:31 +00:00

802 lines
26 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
* - timestamp
*/
TS.EntryModel = Backbone.Model.extend({
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);
}
});
/* 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.timelineModel == undefined) {
throw "Cannot create an EntryList without a TimelineModel reference."
} else { this.timelineModel = options.timelineModel; }
_.bindAll(this, "url");
},
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);
// 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()}).fetch();
// 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();
// last day we have printed a separator for
var today = new Date()
var currentDay = this.collection.at(0) ?
this.collection.at(0).get("timestamp"): today;
// add the top-most day separator
this.topSeparator = ich.daySeparatorTemplate({
separatorLabel: this.formatDaySeparator(today, today) });
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);
// we are rendering buttom up, which means we need to insert the
// day separator before the first entry of the *next* period
if (currentDay.getDate() != entry.get("timestamp").getDate()) {
this.topSeparator.after(ich.daySeparatorTemplate(
{separatorLabel: this.formatDaySeparator(today, currentDay)}));
currentDay = entry.get('timestamp');
}
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
if (yearDiff > 0) {
return days[labelDay.getDay()] + ", " +
months[labelDay.getMonth()] + " " + labelDay.getDate() +
", " + labelDay.getFullYear(); }
// same calendar year, more than a week ago
else if (monthDiff > 0 || dayDiff > 7) {
return days[labelDay.getDay()] + ", " +
months[labelDay.getMonth()] + " " + labelDay.getDate(); }
// less than a week ago, more than yesterday
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',
'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,
{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;
},
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();
// 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: 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();
}
});
TS.app = 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';
}