diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..eabca30 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +build : + mkdir -p build/css + cp src/www/*.* build + cp -r src/www/js build + cp -r resources/* build/. + sass src/www/css/personal-display.scss build/css/personal-display.css + tar czf personal-display.tar.gz build + +clean : + rm -r build + +local-deploy: build + cp -r build ~/temp/server + ssh jdb-server 'rm -r ~/public_html/personal-display; mv temp/build ~/public_html/personal-display' diff --git a/mockup/display.html b/mockup/display.html deleted file mode 100644 index e8e257a..0000000 --- a/mockup/display.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - What I am Doing - - - - - -
-

Current Activity

- QD Lobby Map: Meeting with JCA. -
-

Discussed current display procedure, plans for new display. -Thoughts for new display:

- - - - -

Other notes:

- - -
-
-
-

Next Actions (unsorted)

-
- Respond to ELance job proposal. - Mon, 08/05 -
-
- Create matching project folder structure in - the done folder as the project folder and move action items - appropriately. - GTD CLI -
-
- Implement category drill down. - Time analyzer software - Double-clicking on a category should show a - graph of sub-items in that category. -
-
- - diff --git a/resources/img/loading-spinner.gif b/resources/img/loading-spinner.gif new file mode 100644 index 0000000..1f1577c Binary files /dev/null and b/resources/img/loading-spinner.gif differ diff --git a/mockup/css/display.scss b/src/www/css/personal-display.scss similarity index 62% rename from mockup/css/display.scss rename to src/www/css/personal-display.scss index 3e80024..1228331 100644 --- a/mockup/css/display.scss +++ b/src/www/css/personal-display.scss @@ -110,10 +110,10 @@ body { font-family: Advent Pro, Rosario, Jura, Average Sans, Cantarell; margin: 0.5rem 1rem; } -section { +body > section { padding: 0.2rem; - & > h3 { + h3 { border-bottom: solid 2px $accent1; color: $accent2; font-family: Play, Jura, Exo, Rationale, Quicksand, Average Sans, sans-serif; @@ -131,13 +131,19 @@ section { .next-action { margin-bottom: 0.5rem; + span { display: none; } + + .action { display: inline-block; } + .date, .project { background: $bgColor2; border-radius: 5px; color: $mutedFgColor; + display: inline-block; font-size: 66%; + margin: 0 0.5rem; max-width: 33%; - padding: 0 0.5em; + padding: 0 0.5rem; white-space: nowrap; } .details { @@ -147,12 +153,100 @@ section { .project:before { content: 'Project: '; + display: inline-block; font-family: Play; font-variant: small-caps; } .date:before { content: 'Due: '; + display: inline-block; font-family: Play; font-variant: small-caps; } } } + +#config-dialog { + display: none; + + background: rgba(0, 0, 0, 0.5); + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + form { + border: solid thin $accent1; + background: $bgColor; + border-radius: 10px; + margin-left: 10%; + margin-right: 10%; + margin-top: 1em; + padding: 0 0.5em; + position: relative; + width: 80%; + max-width: 32em; + + .validate-tips { display: block; } + + .wait-overlay { + display: none; + + background: rgba(0, 0, 0, 0.8); + padding: 1em 0.5em; + position: absolute; + text-align: center; + + top: 0; + bottom:0; + left: 0; + right: 0; } + + .button-panel { + padding: 0.5em; + text-align: right; + width: 100%; + + .global-config { float: left; } + + .save-button { + border: $accent2 solid thin; + border-radius: 5px; + display: inline-block; + padding: 0.1em 0.3em; } } } + + .config-section-header { color: $accent2; } + + label { + display: inline-block; + width: 6rem; } + + input, select { width: 8rem; } + + .timestamper-config, .gtd-config { + vertical-align: top; + display: inline-block; + width: 15em; } + + .category-name { + display: inline-block; + margin-left: 0.2rem; + width: 8rem; } + + .remove-button { + color: $mutedFgColor; + cursor: pointer; + display: inline-block; + margin-left: 2rem; + width: 4rem; } + + ul { + list-style: none; + margin: 0; + padding: 0; + + li { + display-style: block; + margin: 0; + padding: 0 } } +} diff --git a/src/www/index.html b/src/www/index.html new file mode 100644 index 0000000..455647c --- /dev/null +++ b/src/www/index.html @@ -0,0 +1,73 @@ + + + + What I am Doing + + + + + + + + + + + + + + + +
+

Current Activity

+ Loading... +
+
+
+

Next Actions (unsorted)

+
+
+
+

Configuration

+ +
+ TimeStamper +
+
+
+
+
+
+
+
+
+
+ Getting Things Done +
+
+
+
+
+
+
  • +
+
+
+
+ +
+ +
+

+
+
+ + diff --git a/src/www/js/personal-display.js b/src/www/js/personal-display.js new file mode 100644 index 0000000..b930dd1 --- /dev/null +++ b/src/www/js/personal-display.js @@ -0,0 +1,499 @@ +(function() { + var root = this; + + var PD = root.PersonalDisplay = {}; + + PD.hasHTML5LocalStorage = function() { + try { + return 'localStorage' in window && window['localStorage'] !== null; } + catch (e) { return false; } } + + /// ## Models + + PD.TimelineMarkModel = Backbone.Model.extend({ + initialize: function() { _.bindAll(this, "equals"); }, + equals: function(that) { return this.id == that.id; } + }); + + PD.GTDEntryModel = Backbone.Model.extend({ + initialize: function() { _.bindAll(this, "equals"); }, + equals: function(that) { return this.id == that.id; } + }); + + // ## Views + + PD.CurrentActivityView = Backbone.View.extend({ + el: $("#current-task"), + + initialize: function() { + _.bindAll(this, "render"); + + this.model.on('change', this.render, this); }, + + render: function() { + this.$el.find(".task").text(this.model.get("mark")); + this.$el.find(".task-notes").text(this.model.get("notes")); + return this; + } + }); + + PD.GTDNextActionView = Backbone.View.extend({ + className: "next-action", + tagName: "div", + ignoredProperties: ["action", "details", "id"], + + initialize: function() { + _.bindAll(this, "render"); + $("#priorities").append(this.el); + + this.model.on('change', this.render, this); }, + + render: function() { + var elements = []; + + // Look for the "action" property first + var actionEl = $(document.createElement("span")) + .addClass("action") + actionEl.text(this.model.get("action").toString()); + elements.push(actionEl); + + // Add span elements for each of the other attributes. + _.each(this.model.attributes, function(val, key) { + if (!_.contains(this.ignoredProperties, key)) { + var el = $(document.createElement("span")) + .addClass(key.toString()); + el.text(val); + elements.push(el); } }, this); + + // Finally, look for the "details" property + if (this.model.get("details")) { + var detailEl = $(document.createElement("span")) + .addClass("details"); + detailEl.text(this.model.get("details").toString()); + elements.push(detailEl); } + + // Clear the old data and add our new elements in order + this.$el.empty(); + _.each(elements, function(el) { this.$el.append(el); }, this); + + return this; + } + }); + + PD.GTDNextActionCollection = Backbone.Collection.extend({ + model: PD.GTDEntryModel, + + initialize: function() { + _.bindAll(this, "addNextActionView", "removeNextActionView"); + this.views = {}; + this.on('add', this.addNextActionView); + this.on('remove', this.removeNextActionView); }, + + addNextActionView: function(entryModel) { + var view = new PD.GTDNextActionView({model: entryModel}); + view.render(); + this.views[entryModel.get("id")] = view; }, + + removeNextActionView: function(entryModel) { + var view = this.views[entryModel.get("id")]; + view.$el.remove(); } + }); + + PD.ConfigDialog = Backbone.View.extend({ + el: $("#config-dialog"), + + events: { + "blur .timestamper-config .password" : "tsLogin", + "blur .gtd-config .password" : "gtdLogin", + + "change .gtd-config .category" : "addCategory", + "click .remove-button" : "removeCategory", + + "click .save-button" : "saveAndClose" }, + + initialize: function() { + _.bindAll(this, "show", "hide", "tsLogin", "gtdLogin", + "loadTsData", "loadGtdData", "addCategory", "makeCategoryItem", + "removeCategory", "saveAndClose"); }, + + show: function() { + var $tsSection = this.$el.find(".timestamper-config"); + var $gtdSection = this.$el.find(".gtd-config"); + + // Load TimeStamper configuration values. + if (PD.tsCfg) { + $tsSection.find(".username").val(PD.tsCfg.username); + $tsSection.find(".password").val(PD.tsCfg.password); + $tsSection.find(".host").val(PD.tsCfg.host); + + if (PD.tsAuth) { + this.loadTsData(PD.tsCfg.host, PD.tsCfg.username); } + + this.$el.find('.timeline').val(PD.tsCfg.timelineId); } + + // Or suggest a default server. + else { $tsSection.find(".host").val("timestamper.jdb-labs.com"); } + + // Load GTD configuration values. + if (PD.gtdCfg) { + $gtdSection.find(".username").val(PD.gtdCfg.username); + $gtdSection.find(".password").val(PD.gtdCfg.password); + $gtdSection.find(".host").val(PD.gtdCfg.host); + + if (PD.gtdAuth) { this.loadGtdData(PD.gtdCfg.host); } + + // Create the items for the selected categories + $(".category-name").parent().remove(); + _.forEach(PD.gtdCfg.categories, this.makeCategoryItem); } + + this.$el.find('.refresh').val( + PD.refreshPeriod ? PD.refreshPeriod / 1000 : 15); + + this.$el.fadeIn(); }, + + hide: function() { this.$el.fadeOut(); }, + + tsLogin: function() { + var username = this.$el.find(".timestamper-config .username").val(); + var password = this.$el.find(".timestamper-config .password").val(); + var host = this.$el.find(".timestamper-config .host").val(); + + if (!PD.tsCfg) { PD.tsCfg = {}; } + + // Hide the configuration dialog. + this.$el.find(".wait-overlay span").text("Connecting to " + host); + this.$el.find(".wait-overlay").fadeIn(); + + // Try to log in to the TimeStamper service. + $.ajax({ + url: "https://" + host + "/ts_api/login", + xhrFields: { withCredentials: true }, + processData: false, + type: 'POST', + async: false, + + data: JSON.stringify( + {"username": username, "password": password}), + + error: function(jqXHR, textStatus, error) { + if (jqXHR.status == 401) { $(".validate-tips") + .text("Invalid username/password combination for " + + "the TimeStamper service."); } + else { $(".validate-tips").text("There was an error " + + "trying to log into the TimeStamper service: " + + error); } + PD.tsAuth = false; }, + + success: function(data, textStatus, jqXHR) { + PD.tsAuth = true; + + $(".validate-tips").text(""); + + // Load the user's timelines. + PD.configDialog.loadTsData(host, username); + } + }); + + // Success or failure we hide the wait overlay. + this.$el.find(".wait-overlay").fadeOut(); + + }, + + gtdLogin: function() { + var username = this.$el.find(".gtd-config .username").val(); + var password = this.$el.find(".gtd-config .password").val(); + var host = this.$el.find(".gtd-config .host").val(); + + if (!PD.gtdCfg) { PD.gtdCfg = {}; } + + // Hide the configuration dialog. + this.$el.find(".wait-overlay span").text("Connecting to " + host); + this.$el.find(".wait-overlay").fadeIn(); + + // Try to log in to the GTD service. + $.ajax({ + url: "http://" + host + "/gtd/login", + xhrFields: { withCredentials: true }, + processData: false, + type: 'POST', + async: false, + + data: JSON.stringify( + {"username": username, "password": password}), + + error: function(jqXHR, textStatus, error) { + if (jqXHR.status == 401) { $(".validate-tips") + .text("Invalid username/password combination for " + + "the Getting Things Done service."); } + else { $(".validate-tips").text("There was an error " + + "trying to log into the Getting Things Done service: " + + error); } + PD.gtdAuth = false; }, + + success: function(data, textStatus, jqXHR) { + PD.gtdAuth = true; + + $(".validate-tips").text(""); + + PD.configDialog.loadGtdData(host); } + }); + + this.$el.find(".wait-overlay").fadeOut(); + }, + + loadTsData: function(host, username) { + // (Re)load the user's timelines. + PD.tsCfg.timelines = JSON.parse($.ajax({ + url: 'https://' + host + '/ts_api/timelines/' + username, + xhrFields: { withCredentials: true }, + async: false}).responseText); + + // Populate the available timelines list. + var $timelineSelectEl = this.$el.find(".timestamper-config .timeline"); + $timelineSelectEl.empty(); + _.forEach(PD.tsCfg.timelines, function(timeline) { + var $optionEl = $(document.createElement("option")); + $optionEl.attr("value", timeline.id); + $optionEl.text(timeline.description); + $timelineSelectEl.append($optionEl); }); }, + + loadGtdData: function(host) { + // Load the user's contexts + PD.gtdCfg.contexts = JSON.parse($.ajax({ + url: 'http://' + host + '/gtd/contexts', + xhrFields: { withCredentials: true }, + async: false }).responseText); + + // Load the user's projects + PD.gtdCfg.projects = JSON.parse($.ajax({ + url: 'http://' + host + '/gtd/projects', + xhrFields: { withCredentials: true }, + async: false }).responseText); + + // Populate the available contexts and projects drop-down. + var $categorySelectEl = $(".gtd-config .category") + $categorySelectEl.empty(); + $categorySelectEl.append( + ""); + _.forEach(PD.gtdCfg.contexts.concat(PD.gtdCfg.projects), + function(category) { + var $optionEl = $(document.createElement("option")); + $optionEl.attr("value", category.id); + $optionEl.text(category.id); + $categorySelectEl.append($optionEl); }); + $categorySelectEl[0].selectedIndex = 0; }, + + makeCategoryItem: function(catName) { + var $liEl = $( + "
  • remove" + + "
  • "); + $liEl.find('.category-name').text(catName); + this.$el.find(".gtd-config ul").append($liEl); }, + + addCategory: function(source) { + var selectEl = source.target; + var $selectEl = $(selectEl); + if (selectEl.selectedIndex == 0) { return; } + this.makeCategoryItem($selectEl.val()); + selectEl.selectedIndex = 0; }, + + removeCategory: function(source) { + $(source.target).parent().remove(); }, + + saveAndClose: function() { + if (!PD.tsCfg) { PD.tsCfg = {}; } + if (!PD.gtdCfg) { PD.gtdCfg = {}; } + + // Save TimeStamper configuration. + var $tsEl = this.$el.find(".timestamper-config"); + PD.tsCfg.host = $tsEl.find(".host").val(); + PD.tsCfg.username = $tsEl.find(".username").val(); + PD.tsCfg.password = $tsEl.find(".password").val(); + PD.tsCfg.timelineId = $tsEl.find(".timeline").val(); + + // Save Getting Things Done configuration. + var $gtdEl = this.$el.find(".gtd-config"); + PD.gtdCfg.host = $gtdEl.find(".host").val(); + PD.gtdCfg.username = $gtdEl.find(".username").val(); + PD.gtdCfg.password = $gtdEl.find(".password").val(); + PD.gtdCfg.categories = _.map( + this.$el.find(".category-name"), + function(span) { return $(span).text(); }); + + // Save global data + PD.refreshPeriod = parseInt(this.$el.find(".refresh").val()) * 1000; + + if (PD.hasHTML5LocalStorage()) { + localStorage.setItem("tsCfg", JSON.stringify(PD.tsCfg)); + localStorage.setItem("gtdCfg", JSON.stringify(PD.gtdCfg)); + localStorage.setItem("refreshPeriod", + JSON.stringify(PD.refreshPeriod)); } + + this.hide(); + } + }); + + PD.Main = Backbone.View.extend({ + el: $("body"), + + initialize: function() { + + _.bindAll(this, "refresh"); + + // Create our config dialog view. + PD.configDialog = new PD.ConfigDialog(); + + // Create our initial models and views. + PD.currentActivityModel = new PD.TimelineMarkModel({}); + PD.currentActivityView = new PD.CurrentActivityView( + {model: PD.currentActivityModel}) + + // Test for localStorage support + if (!PD.hasHTML5LocalStorage()) { + alert("Your browser does not support HTML5 localStorage." + + "Without this I cannot store your preferences."); + PD.configDialog.show(); } + else { + PD.tsCfg = JSON.parse(localStorage.getItem('tsCfg')); + PD.gtdCfg = JSON.parse(localStorage.getItem('gtdCfg')); + PD.refreshPeriod = JSON.parse( + localStorage.getItem('refreshPeriod')); } + + PD.gtdNextActionCollection = new PD.GTDNextActionCollection(); + + // Perform the initial refresh. + this.refresh(); + + // Schedule future refreshes. + setInterval(this.refresh, PD.refreshPeriod ? PD.refreshPeriod : 15000); + }, + + refresh: function() { + // If the dialog is still open we skip this sync to give the user + // a chance to finish configuration. + if ($("#config-dialog").is(":visible")) { return; } + + // Otherwise, if we do not have configuration information, open the + // dialog so the user can enter it. + if (!(PD.tsCfg && PD.gtdCfg)) { PD.configDialog.show(); return; } + + // Check that we are authenticated to the services we need. Try to + // authenticate if we are not. + if (!PD.tsAuth) { + $.ajax({ + url: "https://" + PD.tsCfg.host + "/ts_api/login", + xhrFields: { withCredentials: true }, + processData: false, + type: "POST", + async: false, + + data: JSON.stringify( + { "username": PD.tsCfg.username, + "password": PD.tsCfg.password }), + + error: function(jqXHR, textStatus, error) { + // TODO: Handle error. + PD.tsAuth=false; + alert("Unable to authenticate to the TimeStamper " + + "service: " + error); + PD.configDialog.show(); }, + + success: function(data, textStatus, jqXHR) { + PD.tsAuth = true; }}); } + + if (!PD.gtdAuth) { + $.ajax({ + url: "http://" + PD.gtdCfg.host + "/gtd/login", + xhrFields: { withCredentials: true }, + processData: false, + type: "POST", + async: false, + + data: JSON.stringify( + { "username": PD.gtdCfg.username, + "password": PD.gtdCfg.password }), + + error: function(jqXHR, textStatus, error) { + // TODO: Handle error. + PD.gtdAuth=false; + alert("Unable to authenticate to the GTD service: " + + error); + PD.configDialog.show(); }, + + success: function(data, textStatus, jqXHR) { + PD.gtdAuth = true; }}); } + + + // Check that we have successfully authenticated to both services. + // If we are not, we will skip this refresh. + if (!(PD.tsAuth && PD.gtdAuth)) { return; } + + // Get the latest timestamp from the TimeStamper service. + $.ajax({ + url: "https://" + PD.tsCfg.host + "/ts_api/entries/" + + PD.tsCfg.username + "/" + PD.tsCfg.timelineId, + xhrFields: { withCredentials: true }, + data: {"order": "asc" }, + dataType: 'json', + type: 'GET', + async: true, + + error: function(jqXHR, textStatus, errorText) { + if (jqXHR.status == 401) { PD.tsAuth = false; } + else { + alert("Unable to retrieve current timestamp: " + errorText); + PD.configDialog.show(); } + }, + + success: function(data, textStatus, jqXHR) { + PD.currentActivityModel.set(data[0]); } + }); + + // Get the list of GTD entries for each of our categories. + var categories = _.reduce( + PD.gtdCfg.categories, + function(acc, cat) { return acc ? acc + "," + cat : cat; }, ""); + + $.ajax({ + url: "http://" + PD.gtdCfg.host + "/gtd/next-actions/" + + categories, + xhrFields: { withCredentials: true }, + dataType: 'json', + type: 'GET', + async: true, + + error: function(jqXHR, textStatus, errorText) { + if (jqXHR.status == 401) { PD.gtdAtuh = false; } + else if (jqXHR.status == 500) { return; } + else { + alert("Unable to retrieve next actions: " + errorText); + PD.configDialog.show(); } + }, + + success: function(data, textStatus, jqXHR) { + var collection = PD.gtdNextActionCollection; + + // Add all the retrieved items to the collection. + _.forEach(data, function(actionAttr) { + + // Try to find this entry in out collection. + var model = collection.get(actionAttr.id); + // Update it if found + if (model) { model.set(actionAttr); } + // Insert a new model if not found. + else { collection.add( + new PD.GTDEntryModel(actionAttr)); }}); + + // Look through our collection for entries that are no + // longer in our retrieved data and remove them. + collection.forEach(function(model) { + if (!_.any(data, model.equals)) { + collection.remove(model); }}); + } + }); + } + }); + + PD.main = new PD.Main(); +}).call(this);