--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore Sun Dec 16 19:00:48 2012 +0900
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/REQUIREMENTS Sun Dec 16 19:00:48 2012 +0900
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/app.py Sun Dec 16 19:00:48 2012 +0900
+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
+db_path = os.path.join(os.path.dirname(__file__), 'db')
+cdb = ThreadSafeDatabase(db_path)
+ 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})
+class ResourceList(Resource):
+ return [project['doc'] for project in g.db.all(self.db_index, with_doc=True)]
+ 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!
+ def get(self, resource_id):
+ doc = g.db.get('id', resource_id, with_doc=True)
+ def put(self, resource_id):
+ doc = g.db.get('id', resource_id, with_doc=True)
+ userdata = dict((k, v) for (k, v) in request.json.items() if k in self.safe_fields)
+ response = g.db.update(doc)
+ return self.get(response['_id'])
+ def delete(self, resource_id):
+ doc = g.db.get('id', resource_id, with_doc=True)
+class ProjectList(ResourceList):
+ doc_stub = {'_t': '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'}
+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>/')
+ '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)
+if __name__ == '__main__':
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/fruitbar/indexes.py Sun Dec 16 19:00:48 2012 +0900
+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()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/static/css/custom.css Sun Dec 16 19:00:48 2012 +0900
+.task .task-note.present {
+ background-color: #f7f7f9;
+ border: 1px solid #e1e1e8;
+.task input[type="checkbox"] {
+.inline-editable:hover {
+ border-bottom: 1px dashed #08c;
+.inline-editor input[type="text"] {
+.inline-editor textarea {
Binary file static/favicon.ico has changed
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/backbone.shard.js Sun Dec 16 19:00:48 2012 +0900
+ * (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);
+ .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);
+ .on('add', function(model) {
+ if (this._filter(model) && !_(this.models).contains(model)) {
+ this.models.push(model);
+ this.trigger('add', model);
+ .on('remove', function(model) {
+ if (this._filter(model) && _(this.models).contains(model)) {
+ this.models = _.without(this.models, model);
+ this.trigger('remove', model);
+ .on('reset', function() {
+ this.models = this._collection.filter(this._filter);
+ 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); });
+ 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)));
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/framework/collections.js Sun Dec 16 19:00:48 2012 +0900
+var Projects = Backbone.Collection.extend({
+var Tasks = Backbone.Collection.extend({
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/framework/models.js Sun Dec 16 19:00:48 2012 +0900
+var Model = Backbone.Model.extend({
+ var url = Backbone.Model.prototype.url.call(this);
+ return url[url.length - 1] == '/' ? url : url + '/';
+ adhoc: function(data) {
+ 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({
+ 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({
--- /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
+var CollectionView = Backbone.View.extend({
+ appendItem: function() {},
+ updateItem: function() {},
+ removeItem: function() {},
+ repopulate: function() {
+ this.collection.each(this.appendItem, this);
+ bindCollection: function() {
+ .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) {
+ _.each(templates, function(template, name) {
+ this.templates[name] = _.template(template);
+var CollectionCounterView = CollectionView.extend({
+ initialize: function(options) {
+ appendItem: function() {
+ removeItem: function() {
+ repopulate: function() {
+ this.$el.html(this.collection.models.length);
--- /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
+var InlineEditorView = Backbone.View.extend({
+ initialize: function(options) {
+ renderInput: function(options) {
+ this.$input = $('<input>').attr('type', 'text').addClass('input-xlarge').val(options.target.text());
+ this.$input.keyup(function(e) {
+ view.$saveButton.click();
+ view.$cancelButton.click();
+ this.$el.append(this.$input, this.$saveButton, this.$cancelButton);
+ options.target.after(this.$el);
+ renderTextarea: function(options) {
+ this.$input = $('<textarea>').addClass('input-xlarge').attr('rows', 3).text(options.target.text());
+ this.$input.keyup(function(e) {
+ view.$saveButton.click();
+ view.$cancelButton.click();
+ this.$el.append(this.$input, this.$saveButton, this.$cancelButton);
+ options.target.after(this.$el);
+ render: function(options) {
+ 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() {
+ this.$saveButton.click(function() {
+ view.trigger('save', view.$input.val());
+ switch (options.type) {
+ return this.renderInput(options);
+ return this.renderTextarea(options);
+var CollectionViewWithInlineEditor = CollectionView.extend({
+ bindInlineEditable: function(model, selector) {
+ model.$item.delegate(selector, 'click', function(e) {
+ var attribute = $this.attr('data-model-attribute');
+ var inlineEditor = new InlineEditorView({
+ type: $this.attr('data-input-type') || 'input'
+ inlineEditor.on('save', function(value) {
+ data[attribute] = value;
+ model.adhoc(data).then(function() {
--- /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
+var ProjectsView = CollectionViewWithInlineEditor.extend({
+ initialize: function(options) {
+ this.tasks = options.tasks;
+ this.rawTemplates = options.templates;
+ this.compileTemplates(options.templates);
+ options.controls.create.click(function() {
+ options.collection.create({
+ renderItem: function(project) {
+ var $item = $(this.templates.project(project.toJSON()));
+ $item.tasksView = new TasksView({
+ el: $item.find('.tasks'),
+ collection: project.tasks,
+ templates: this.rawTemplates,
+ create: $item.find('.btn-new-task')
+ var $progress = $(this);
+ $progress.addClass('active');
+ project.switchColor().then(function() {
+ $progress.removeClass('active');
+ .find('.btn-delete-project')
+ project.destroy({wait: true});
+ updateProgress: function(project) {
+ .removeClass('progress-info progress-success progress-warning progress-danger')
+ .addClass(project.get('color') ? 'progress-' + project.get('color') : '')
+ .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);
+ project.tasks.on('add remove change:done', function() {
+ this.updateProgress(project);
+ this.$el.append(project.$item);
+ removeItem: function(project) {
+ project.$item.remove();
--- /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
+var TasksView = CollectionViewWithInlineEditor.extend({
+ initialize: function(options) {
+ this.compileTemplates(options.templates);
+ options.controls.create.click(function() {
+ options.collection.create({
+ renderItem: function(task) {
+ var $item = $(this.templates.task(task.toJSON()));
+ .find('input:checkbox')
+ task.adhoc({'done': this.checked});
+ .find('.btn-delete-task')
+ task.destroy({wait: true});
+ 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');
+ 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"]');
+ 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.$el.append(task.$item);
+ removeItem: function(task) {
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/static/js/ui.js Sun Dec 16 19:00:48 2012 +0900
+ 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({
+ collection: fruitbar.projects,
+ project: $('#project-template').html(),
+ task: $('#task-template').html()
+ create: $('.btn-new-project')
+ window.setInterval(function() {fruitbar.projects.fetch({update: true});}, 60000);
+ window.setInterval(function() {fruitbar.tasks.fetch({update: true});}, 60000);
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/templates/index.html Sun Dec 16 19:00:48 2012 +0900
+ <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, {
+ <div class="container">
+ <h3 class="align-right">
+ <button class="btn btn-mini btn-success btn-new-project">New project</button>
+ All projects <small>(<span class="project-counter"></span>)</small>
+ <section class="projects">
+ <script id="project-template" type="text/template">
+ <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>
+ <span class="inline-editable"
+ data-input-type="input"
+ data-model-attribute="name"><%- data.name %></span>
+ <div class="progress progress-striped">
+ <div class="bar"></div>
+ <div class="tasks"></div>
+ <script id="task-template" type="text/template">
+ <div class="span2 align-right ondemand">
+ <button class="btn btn-mini btn-danger btn-delete-task">Delete</button>
+ <input type="checkbox" <% if (data.done) print('checked') %>>
+ <span class="inline-editable"
+ data-input-type="input"
+ data-model-attribute="name"><%- data.name %></span>
+ <div class="task-note">
+ <span class="inline-editable"
+ data-input-type="textarea"
+ data-model-attribute="note"></span>
+ <script type="text/javascript">
+ var data = {{ data|tojson|safe }};