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:
-
-
-Predefined window of time (TBD) for the orders to be shown on the map. All
-orders displayed will be chosen based on when they were placed.
-Discussed whether we wanted to have a periodic refresh, meaning we would
-blank the display at the beginning of the week/month and let it fill back in
-as orders come in, or keep a rolling refresh, meaning we would drop orders
-from the display when they are more than a week/month old.
-
-In other words, does the display show the current week/month or does it show
-the last 7/30 days?
-
-The decision was to expirement and see which was a more impressive display.
-I need to investigate the latest version of KML in case there are new
-features available that would be useful.
-
-
-
-
Other notes:
-
-
-QD is running Google Earth 7.1 (latest Beta)
-Chris will send me the Order Tour source code.
-We planned to have my old workstation setup and configured with GoToMyPC to
-allow access to the QD development environment.
-
-
-
-
- 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)
+
+
+
+
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(
+ "" +
+ "Add a category... ");
+ _.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);