# HG changeset patch
# User Anton Shestakov <engored@ya.ru>
# Date 1355652048 -32400
# Node ID a02e94c5b96b12a3cd0b9a7fc6e33ca3b1136ffc

Made public.

diff -r 000000000000 -r a02e94c5b96b .hgignore
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,5 @@
+syntax: glob
+*.pyc
+*.pyo
+venv/
+db/
diff -r 000000000000 -r a02e94c5b96b REQUIREMENTS
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/REQUIREMENTS	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,3 @@
+CodernityDB==0.3.63
+Flask==0.9
+Flask-RESTful==0.1.2
diff -r 000000000000 -r a02e94c5b96b app.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/app.py	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,137 @@
+#!/usr/bin/env python
+#-*- coding:utf-8 -*-
+
+import os
+
+from flask import Flask, g, request, render_template
+from flask.ext.restful import Resource, Api
+from CodernityDB.database import RecordNotFound
+from CodernityDB.database_thread_safe import ThreadSafeDatabase
+
+from fruitbar.indexes import ProjectIndex, TaskIndex
+
+
+app = Flask(__name__)
+api = Api(app)
+
+
+db_path = os.path.join(os.path.dirname(__file__), 'db')
+cdb = ThreadSafeDatabase(db_path)
+
+if cdb.exists():
+    cdb.open()
+    cdb.reindex()
+else:
+    cdb.create()
+    
+    cdb.add_index(ProjectIndex(cdb.path, 'project'))
+    cdb.add_index(TaskIndex(cdb.path, 'task'))
+    
+    cdb.insert({'_id': '29c21f00190f475ba2d855f810c1085e', '_t': 'project', 'name': 'Test Project', 'color': 'success'})
+    cdb.insert({'_t': 'task', 'project': '29c21f00190f475ba2d855f810c1085e', 'name': 'Test Task', 'note': 'Task Note', 'done': True})
+
+
+@app.before_request
+def before_request():
+    g.db = cdb
+
+
+class ResourceList(Resource):
+    db_index = 'id'
+    doc_stub = {}
+    
+    def get(self):
+        return [project['doc'] for project in g.db.all(self.db_index, with_doc=True)]
+    
+    def post(self):
+        doc = self.doc_stub.copy()
+        doc.update(request.json)
+        
+        response = g.db.insert(doc)
+        
+        return g.db.get('id', response['_id'], with_doc=True)
+
+
+class CRUDResource(Resource):
+    """ CRUD? More like RUD!
+    """
+    
+    safe_fields = tuple()
+    
+    def get(self, resource_id):
+        try:
+            doc = g.db.get('id', resource_id, with_doc=True)
+        except RecordNotFound:
+            return '', 404
+        
+        return doc
+    
+    def put(self, resource_id):
+        try:
+            doc = g.db.get('id', resource_id, with_doc=True)
+        except RecordNotFound:
+            return '', 404
+        
+        userdata = dict((k, v) for (k, v) in request.json.items() if k in self.safe_fields)
+        doc.update(userdata)
+        response = g.db.update(doc)
+        
+        return self.get(response['_id'])
+    
+    def delete(self, resource_id):
+        try:
+            doc = g.db.get('id', resource_id, with_doc=True)
+        except RecordNotFound:
+            return '', 404
+        
+        g.db.delete(doc)
+        
+        return '', 200
+
+
+class ProjectList(ResourceList):
+    doc_stub = {'_t': 'project'}
+    db_index = 'project'
+
+
+class Project(CRUDResource):
+    safe_fields = ('name', 'color')
+    
+    def delete(self, resource_id):
+        for task in g.db.get_many('task', resource_id, limit=-1, with_doc=True):
+            response = g.db.delete(task['doc'])
+        
+        return super(Project, self).delete(resource_id)
+
+
+class TaskList(ResourceList):
+    doc_stub = {'_t': 'task'}
+    db_index = 'task'
+
+
+class Task(CRUDResource):
+    safe_fields = ('name', 'note', 'done')
+
+
+api.add_resource(ProjectList, '/projects/')
+api.add_resource(Project, '/projects/<resource_id>/')
+api.add_resource(TaskList, '/tasks/')
+api.add_resource(Task, '/tasks/<resource_id>/')
+
+
+@app.route('/')
+def index():
+    data = {
+        'projects': [project['doc'] for project in g.db.all('project', with_doc=True)],
+        'tasks': [task['doc'] for task in g.db.all('task', with_doc=True)]
+    }
+    
+    return render_template('index.html', data=data)
+
+
+def main():
+    app.run(debug=True)
+
+
+if __name__ == '__main__':
+    main()
diff -r 000000000000 -r a02e94c5b96b fruitbar/__init__.py
diff -r 000000000000 -r a02e94c5b96b fruitbar/indexes.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/fruitbar/indexes.py	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,31 @@
+#-*- coding:utf-8 -*-
+
+from hashlib import md5
+
+from CodernityDB.hash_index import HashIndex
+
+
+class ProjectIndex(HashIndex):
+    def __init__(self, *args, **kwargs):
+        kwargs['key_format'] = '16s'
+        super(ProjectIndex, self).__init__(*args, **kwargs)
+
+    def make_key_value(self, data):
+        if data['_t'] == 'project':
+            return md5(data['name'].encode('utf-8')).digest(), None
+
+    def make_key(self, key):
+        return md5(key).digest()
+
+
+class TaskIndex(HashIndex):
+    def __init__(self, *args, **kwargs):
+        kwargs['key_format'] = '16s'
+        super(TaskIndex, self).__init__(*args, **kwargs)
+
+    def make_key_value(self, data):
+        if data['_t'] == 'task':
+            return md5(data['project'].encode('utf-8')).digest(), None
+
+    def make_key(self, key):
+        return md5(key).digest()
diff -r 000000000000 -r a02e94c5b96b static/css/custom.css
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/css/custom.css	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,60 @@
+.project {
+    padding-bottom: 20px;
+}
+
+.project h4 {
+    margin-top: 0;
+}
+
+.project .progress {
+    cursor: pointer;
+}
+
+.task .task-note {
+    font-size: 12px;
+    line-height: 15px;
+    padding: 2px 4px;
+    margin-bottom: 2px;
+    border-radius: 3px;
+}
+
+.task .task-note.present {
+    background-color: #f7f7f9;
+    border: 1px solid #e1e1e8;
+}
+
+.task input[type="checkbox"] {
+    float: left;
+    margin-right: 6px;
+}
+
+.row .ondemand {
+    visibility: hidden;
+}
+
+.row:hover .ondemand {
+    visibility: visible;
+}
+
+.inline-editable:hover {
+    color: #08c;
+    border-bottom: 1px dashed #08c;
+}
+
+.inline-editor {
+    position: relative;
+    margin-bottom: 0;
+}
+
+.inline-editor input[type="text"] {
+    padding-top: 2px;
+    padding-bottom: 2px;
+}
+
+.inline-editor textarea {
+    margin-bottom: 0;
+}
+
+.align-right {
+    text-align: right;
+}
diff -r 000000000000 -r a02e94c5b96b static/favicon.ico
Binary file static/favicon.ico has changed
diff -r 000000000000 -r a02e94c5b96b static/js/backbone.shard.js
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/backbone.shard.js	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,79 @@
+/*
+ *      backbone.shard.js.
+ * 
+ *      (c) 2012 Anton Shestakov.
+ * 
+ *      This extension to Backbone may be freely distributed
+ *      under the MIT license:
+ *      http://opensource.org/licenses/mit-license.php
+ */
+
+Backbone.Shard = function(options) {
+    this._collection = options.collection;
+    this._filter = options.filter;
+    
+    this.models = this._collection.filter(this._filter);
+    
+    this._collection
+        .on('all', function(event, model) {
+            if (event == 'change') {
+                if (this._filter(model) && _(this.models).contains(model)) {
+                    this.trigger.apply(this, arguments);
+                }
+                if (this._filter(model) && !_(this.models).contains(model)) {
+                    this.models.push(model);
+                    this.trigger('add', model);
+                }
+                if (!this._filter(model) && _(this.models).contains(model)) {
+                    this.models = _.without(this.models, model);
+                    this.trigger('remove', model);
+                }
+            } else if (/^change:/.test(event) && this._filter(model) && _(this.models).contains(model)) {
+                this.trigger.apply(this, arguments);
+            }
+        }, this)
+        .on('add', function(model) {
+            if (this._filter(model) && !_(this.models).contains(model)) {
+                this.models.push(model);
+                this.trigger('add', model);
+            }
+        }, this)
+        .on('remove', function(model) {
+            if (this._filter(model) && _(this.models).contains(model)) {
+                this.models = _.without(this.models, model);
+                this.trigger('remove', model);
+            }
+        }, this)
+        .on('reset', function() {
+            this.models = this._collection.filter(this._filter);
+            this.trigger('reset');
+        }, this);
+    
+    this.initialize.apply(this, arguments);
+};
+
+_.extend(Backbone.Shard.prototype, Backbone.Events, {
+    initialize: function() {},
+    
+    pluck: function(attr) {
+        return _.map(this.models, function(model) { return model.get(attr); });
+    },
+    
+    chain: function () {
+      return _(this.models).chain();
+    }
+});
+
+// Underscore methods that we want to implement on the Shard.
+var methods = ['forEach', 'each', 'map', 'reduce', 'reduceRight', 'find',
+    'detect', 'filter', 'select', 'reject', 'every', 'all', 'some', 'any',
+    'include', 'contains', 'invoke', 'max', 'min', 'sortBy', 'sortedIndex',
+    'toArray', 'size', 'first', 'initial', 'rest', 'last', 'without', 'indexOf',
+    'shuffle', 'lastIndexOf', 'isEmpty', 'groupBy'];
+
+// Mix in each Underscore method as a proxy to `Shard#models`.
+_.each(methods, function(method) {
+    Backbone.Shard.prototype[method] = function() {
+        return _[method].apply(_, [this.models].concat(_.toArray(arguments)));
+    };
+});
diff -r 000000000000 -r a02e94c5b96b static/js/framework/collections.js
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/framework/collections.js	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,9 @@
+var Projects = Backbone.Collection.extend({
+    model: Project,
+    url: '/projects/'
+});
+
+var Tasks = Backbone.Collection.extend({
+    model: Task,
+    url: '/tasks/'
+});
diff -r 000000000000 -r a02e94c5b96b static/js/framework/models.js
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/framework/models.js	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,37 @@
+var Model = Backbone.Model.extend({
+    url: function() {
+        var url = Backbone.Model.prototype.url.call(this);
+        return url[url.length - 1] == '/' ? url : url + '/';
+    },
+    
+    adhoc: function(data) {
+        var model = this;
+        var options = {
+            url: this.urlRoot + encodeURIComponent(this.id) + '/',
+            contentType: 'application/json',
+            data: JSON.stringify(data)
+        };
+        
+        return (this.sync || Backbone.sync).call(this, 'update', model, options).then(function(serverAttrs) {
+            model.set(serverAttrs);
+        });
+    }
+});
+
+var Project = Model.extend({
+    idAttribute: '_id',
+    urlRoot: '/projects/',
+    
+    switchColor: function() {
+        var colorChain = [null, 'info', 'success', 'warning', 'danger'];
+        var currentColorIndex = colorChain.indexOf(this.get('color') || null);
+        var nextColor = colorChain[(currentColorIndex + 1) % colorChain.length];
+        
+        return this.adhoc({color: nextColor});
+    }
+});
+
+var Task = Model.extend({
+    idAttribute: '_id',
+    urlRoot: '/tasks/'
+});
diff -r 000000000000 -r a02e94c5b96b static/js/framework/views/base.js
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/framework/views/base.js	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,44 @@
+var CollectionView = Backbone.View.extend({
+    appendItem: function() {},
+    updateItem: function() {},
+    removeItem: function() {},
+    clear: function() {},
+    repopulate: function() {
+        this.clear();
+        this.collection.each(this.appendItem, this);
+    },
+    bindCollection: function() {
+        this.repopulate();
+        
+        this.collection
+            .on('add', this.appendItem, this)
+            .on('change', this.updateItem, this)
+            .on('remove', this.removeItem, this)
+            .on('reset', this.repopulate, this);
+    },
+    unbindCollection: function() {
+        this.collection.off(null, null, this);
+    },
+    compileTemplates: function(templates) {
+        this.templates = {};
+        
+        _.each(templates, function(template, name) {
+            this.templates[name] = _.template(template);
+        }, this);
+    }
+});
+
+var CollectionCounterView = CollectionView.extend({
+    initialize: function(options) {
+        this.bindCollection();
+    },
+    appendItem: function() {
+        this.repopulate();
+    },
+    removeItem: function() {
+        this.repopulate();
+    },
+    repopulate: function() {
+        this.$el.html(this.collection.models.length);
+    }
+});
diff -r 000000000000 -r a02e94c5b96b static/js/framework/views/inline.js
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/framework/views/inline.js	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,105 @@
+var InlineEditorView = Backbone.View.extend({
+    initialize: function(options) {
+        this.render(options);
+    },
+    
+    renderInput: function(options) {
+        var view = this;
+        
+        this.$input = $('<input>').attr('type', 'text').addClass('input-xlarge').val(options.target.text());
+        
+        this.$input.keyup(function(e) {
+            switch (e.keyCode) {
+                case 13:
+                    view.$saveButton.click();
+                    break;
+                case 27:
+                    view.$cancelButton.click();
+                    break;
+            }
+        });
+        
+        this.$el.append(this.$input, this.$saveButton, this.$cancelButton);
+        options.target.after(this.$el);
+        this.$input.focus();
+            
+        return this;
+    },
+    
+    renderTextarea: function(options) {
+        var view = this;
+        
+        this.$input = $('<textarea>').addClass('input-xlarge').attr('rows', 3).text(options.target.text());
+        
+        this.$input.keyup(function(e) {
+            switch (e.keyCode) {
+                case 13:
+                    if (e.ctrlKey) {
+                        view.$saveButton.click();
+                    }
+                    break;
+                case 27:
+                    view.$cancelButton.click();
+                    break;
+            }
+        });
+        
+        this.$el.append(this.$input, this.$saveButton, this.$cancelButton);
+        options.target.after(this.$el);
+        this.$input.focus();
+            
+        return this;
+    },
+    
+    render: function(options) {
+        var view = this;
+        
+        this.$saveButton = $('<button>').addClass('btn btn-small btn-success').html('<i class="icon-white icon-ok"></i>');
+        this.$cancelButton = $('<button>').addClass('btn btn-small').html('<i class="icon-remove"></i>');
+        this.$el = $('<div>').addClass('inline-editor input-append');
+        
+        this.$cancelButton.click(function() {
+            view.$el.remove();
+            options.target.show();
+        });
+        
+        this.$saveButton.click(function() {
+            view.trigger('save', view.$input.val());
+        });
+        
+        switch (options.type) {
+            case 'input':
+                return this.renderInput(options);
+            case 'textarea':
+                return this.renderTextarea(options);
+        }
+    }
+});
+
+
+var CollectionViewWithInlineEditor = CollectionView.extend({
+    bindInlineEditable: function(model, selector) {
+        model.$item.delegate(selector, 'click', function(e) {
+            e.preventDefault();
+            
+            var $this = $(this);
+            var attribute = $this.attr('data-model-attribute');
+            var inlineEditor = new InlineEditorView({
+                target: $this,
+                type: $this.attr('data-input-type') || 'input'
+            });
+            
+            $this.hide();
+            
+            inlineEditor.on('save', function(value) {
+                var data = {};
+                data[attribute] = value;
+                
+                model.adhoc(data).then(function() {
+                    inlineEditor.remove();
+                    $this.show();
+                });
+            });
+        });
+    }
+});
diff -r 000000000000 -r a02e94c5b96b static/js/framework/views/projects.js
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/framework/views/projects.js	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,101 @@
+var ProjectsView = CollectionViewWithInlineEditor.extend({
+    initialize: function(options) {
+        this.tasks = options.tasks;
+        this.rawTemplates = options.templates;
+        
+        this.compileTemplates(options.templates);
+        this.bindCollection();
+        
+        options.controls.create.click(function() {
+            options.collection.create({
+                name: 'new project'
+            }, {wait: true});
+        });
+    },
+    
+    clear: function() {
+        this.$el.empty();
+    },
+    
+    renderItem: function(project) {
+        var $item = $(this.templates.project(project.toJSON()));
+        
+        $item.tasksView = new TasksView({
+            el: $item.find('.tasks'),
+            collection: project.tasks,
+            templates: this.rawTemplates,
+            controls: {
+                create: $item.find('.btn-new-task')
+            }
+        });
+        
+        $item
+            .find('.progress')
+            .click(function() {
+                var $progress = $(this);
+                $progress.addClass('active');
+                project.switchColor().then(function() {
+                    $progress.removeClass('active');
+                });
+            });
+        
+        $item
+            .find('.btn-delete-project')
+            .click(function() {
+                project.destroy({wait: true});
+            });
+        
+        return $item;
+    },
+    
+    updateProgress: function(project) {
+        project.$item
+            .find('.progress')
+            .removeClass('progress-info progress-success progress-warning progress-danger')
+            .addClass(project.get('color') ? 'progress-' + project.get('color') : '')
+            .find('.bar')
+            .css('width', (100 * project.tasks.progress()) + '%');
+    },
+    
+    appendItem: function(project) {
+        var tasks = this.tasks;
+        
+        project.tasks = new Backbone.Shard({
+            collection: this.tasks,
+            filter: function(task) { return task.get('project') === project.id; }
+        });
+        
+        project.tasks.progress = function() {
+            return this.filter(function(task) { return task.get('done') === true; }).length / this.size();
+        };
+        
+        project.tasks.create = function(attributes, options) {
+            attributes = attributes || {};
+            attributes['project'] = project.id;
+            return tasks.create(attributes, options);
+        };
+        
+        project.$item = this.renderItem(project);
+        
+        this.bindInlineEditable(project, '.inline-editable[data-model="project"]');
+        this.updateProgress(project);
+        
+        project.on('change:name', function() {
+            project.$item.find('[data-model="project"][data-model-attribute="name"]').text(project.get('name'));
+        });
+        
+        project.on('change:color', function() {
+            this.updateProgress(project);
+        }, this);
+        
+        project.tasks.on('add remove change:done', function() {
+            this.updateProgress(project);
+        }, this);
+        
+        this.$el.append(project.$item);
+    },
+    
+    removeItem: function(project) {
+        project.$item.remove();
+    }
+});
diff -r 000000000000 -r a02e94c5b96b static/js/framework/views/tasks.js
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/framework/views/tasks.js	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,71 @@
+var TasksView = CollectionViewWithInlineEditor.extend({
+    initialize: function(options) {
+        this.compileTemplates(options.templates);
+        this.bindCollection();
+        
+        options.controls.create.click(function() {
+            options.collection.create({
+                name: 'new task'
+            }, {wait: true});
+        });
+    },
+    
+    clear: function() {
+        this.$el.empty();
+    },
+    
+    renderItem: function(task) {
+        var $item = $(this.templates.task(task.toJSON()));
+        
+        $item
+            .find('input:checkbox')
+            .change(function() {
+                task.adhoc({'done': this.checked});
+            });
+        
+        $item
+            .find('.btn-delete-task')
+            .click(function() {
+                task.destroy({wait: true});
+            });
+        
+        return $item;
+    },
+    
+    updateNote: function(task) {
+        var $note = task.$item.find('[data-model="task"][data-model-attribute="note"]');
+        
+        if (task.get('note')) {
+            $note.text(task.get('note'));
+            task.$item.find('.task-note').removeClass('muted').addClass('present');
+        } else {
+            $note.text('...');
+            task.$item.find('.task-note').addClass('muted').removeClass('present');
+        }
+    },
+    
+    appendItem: function(task) {
+        task.$item = this.renderItem(task);
+        
+        this.bindInlineEditable(task, '.inline-editable[data-model="task"]');
+        this.updateNote(task);
+        
+        task.on('change:done', function() {
+            task.$item.find('input:checkbox').prop('checked', task.get('done'));
+        });
+        
+        task.on('change:name', function() {
+            task.$item.find('[data-model="task"][data-model-attribute="name"]').text(task.get('name'));
+        });
+        
+        task.on('change:note', function() {
+            this.updateNote(task);
+        }, this);
+        
+        this.$el.append(task.$item);
+    },
+    
+    removeItem: function(task) {
+        task.$item.remove();
+    }
+});
diff -r 000000000000 -r a02e94c5b96b static/js/ui.js
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/ui.js	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,26 @@
+ $(function() {
+    window.fruitbar = {};
+    fruitbar.tasks = new Tasks(data['tasks']);
+    fruitbar.projects = new Projects(data['projects']);
+    
+    fruitbar.projectCounter = new CollectionCounterView({
+        el: $('.project-counter'),
+        collection: fruitbar.projects
+    });
+    
+    fruitbar.projectsView = new ProjectsView({
+        el: $('.projects'),
+        collection: fruitbar.projects,
+        tasks: fruitbar.tasks,
+        templates: {
+            project: $('#project-template').html(),
+            task: $('#task-template').html()
+        },
+        controls: {
+            create: $('.btn-new-project')
+        }
+    });
+    
+    window.setInterval(function() {fruitbar.projects.fetch({update: true});}, 60000);
+    window.setInterval(function() {fruitbar.tasks.fetch({update: true});}, 60000);
+});
diff -r 000000000000 -r a02e94c5b96b templates/index.html
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/templates/index.html	Sun Dec 16 19:00:48 2012 +0900
@@ -0,0 +1,104 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Fruit Bar Progress Tracker</title>
+    <link type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}" rel="shortcut icon">
+    
+    <link type="text/css" href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.2.1/css/bootstrap-combined.min.css" rel="stylesheet">
+    <link type="text/css" href="{{ url_for('static', filename='css/custom.css') }}" rel="stylesheet">
+    
+    <script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min.js"></script>
+    <script type="text/javascript" src="//underscorejs.org/underscore-min.js"></script>
+    <script type="text/javascript" src="//backbonejs.org/backbone-min.js"></script>
+    <script type="text/javascript" src="//netdna.bootstrapcdn.com/twitter-bootstrap/2.2.1/js/bootstrap.min.js"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/backbone.shard.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/framework/models.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/framework/collections.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/framework/views/base.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/framework/views/inline.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/framework/views/projects.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/framework/views/tasks.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/ui.js') }}"></script>
+    
+    <script type="text/javascript">
+      _.extend(_.templateSettings, {
+        variable: 'data'
+      });
+    </script>
+  </head>
+  <body>
+    <div class="container">
+      <div class="row">
+        <div class="span2">
+          <h3 class="align-right">
+            <button class="btn btn-mini btn-success btn-new-project">New project</button>
+          </h3>
+        </div>
+        <div class="span8">
+          <h3 class="muted">
+            All projects <small>(<span class="project-counter"></span>)</small>
+          </h3>
+          <hr>
+        </div>
+      </div>
+      
+      <section class="projects">
+        <script id="project-template" type="text/template">
+          <div class="project">
+            <div class="row">
+              <div class="span2 align-right ondemand">
+                <button class="btn btn-mini btn-danger btn-delete-project">Delete</button>
+                <button class="btn btn-mini btn-success btn-new-task">New task</button>
+              </div>
+              <div class="span4">
+                <h4>
+                  <span class="inline-editable"
+                    data-input-type="input"
+                    data-model="project"
+                    data-model-attribute="name"><%- data.name %></span>
+                </h4>
+              </div>
+              
+              <div class="span4">
+                <div class="progress progress-striped">
+                  <div class="bar"></div>
+                </div>
+              </div>
+            </div>
+            
+            <div class="tasks"></div>
+          </div>
+        </script>
+        
+        <script id="task-template" type="text/template">
+          <div class="task">
+            <div class="row">
+              <div class="span2 align-right ondemand">
+                <button class="btn btn-mini btn-danger btn-delete-task">Delete</button>
+              </div>
+              <div class="span4">
+                <input type="checkbox" <% if (data.done) print('checked') %>>
+                <span class="inline-editable" 
+                  data-input-type="input"
+                  data-model="task"
+                  data-model-attribute="name"><%- data.name %></span>
+              </div>
+              <div class="span4">
+                <div class="task-note">
+                  <span class="inline-editable"
+                    data-input-type="textarea"
+                    data-model="task"
+                    data-model-attribute="note"></span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </script>
+      </section>
+    </div>
+      
+    <script type="text/javascript">
+      var data = {{ data|tojson|safe }};
+    </script>
+  </body>
+</html>