Jonathan Bernard a3c55e918e Bugfixes: session management, new entry creation.
* Changed the cookie Path value to allow the cookie to be reused for the
  domain, not just ths `/ts_api` path. This allows the user to refresh the page
  and reuse their existing session as long as it is not stale.
* Fixed a bug in the `ts_json:ejson_to_record_strict/2` function. It was
  expecting a record out of `ts_json:ejson_to_record/2` but that function
  returns a tuple with the record and extended data. Because of the way
  `ejson_to_record_strict` uses case statements to check for specific values it
  was still passing the parsed record and data through, but all the checks were
  being bypassed.
* Fixed bugs in the `index.yaws` bootstrap code for the case where the user
  already has a valid session.
* Added `urlRoot` functions to the Backbone model definitions.
* Changed the behavior of the new entry creation method. We were trying to
  fetch just updated attributes from the server for the model we created, but
  we were pulling all the entries due to the URL backbone was using. This led
  to the new client-side model having all the previous entry models as
  attributes. Ideally we would fix the fetch so that only the one model is
  requested from the server, but we run into a catch-22 because the lookup id
  is not know by the client as it is generated on the server-side. For now I
  have changed this behavior so that we still pull all entries, but we pull
  them into the collection. The collection is then smart enough to update the
  entries that have changed (namely the new one). The server returns the newly
  created entry attributes in response to the POST request that the client
  makes initially, so when I have more time to work on this I plan to do away
  with the fetch after create, and just pull in the data from the server's
  response.
* Changed formatting.
2013-10-22 15:32:22 +00:00

727 lines
26 KiB
JavaScript

/**
* # `ts.js`: TimeStamper Web UI Client-side
* @author Jonathan Bernard <jdb@jdb-labs.com>
**/
// 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"); },
/*create: function(model, options) {
if (!(model instanceof Backbone.Model)) {
model.user_id = this.timelineModel.get('user_id');
model.timeline_id = this.timelineModel.get('timeline_id'); }
else {
model.set('user_id') = this.timelineModel.get('user_id')
},*/
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()});
this.collection.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) {
TS.app = this;
// 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(); } });
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';
}