show current milestone

This commit is contained in:
Radek Stepan 2014-09-14 13:14:57 -07:00
parent ec99547712
commit 0823078eb5
19 changed files with 6320 additions and 990 deletions

View File

@ -34,9 +34,10 @@ module.exports = (grunt) ->
'vendor/firebase/firebase.js'
'vendor/firebase-simple-login/firebase-simple-login.js'
'vendor/grapnel/src/grapnel.js'
'vendor/github/lib/base64.js'
'vendor/github/github.js'
'vendor/superagent/superagent.js'
'vendor/localforage/dist/localforage.js'
'vendor/async/lib/async.js'
'vendor/moment/moment.js'
# Our app.
'public/js/app.js'
]

View File

@ -8,12 +8,12 @@ GitHub Burndown Chart as a service. Public repos are free, for private access au
### MVP
- [ ] landing page allows you to immediately jump into action
- [x] landing page allows you to immediately jump into action
- [ ] show chart for the current milestone, default to the first one returned and allow to choose a custom one
- [ ] sort projects based on their closest due dates
- [ ] show only repo name if all projects are under our name
- [ ] show all issues as [one size](https://github.com/radekstepan/github-burndown-chart/issues/46)
- [ ] use local storage to save information about us, but keep the API open for Firebase
- [x] use local storage to save information about us, but keep the API open for Firebase
### The 20%
@ -65,4 +65,4 @@ GitHub Burndown Chart as a service. Public repos are free, for private access au
- keep discussion going via [gitter](http://gitter.im)
- [credit card form](http://designmodo.com/ux-credit-card-payment-form/) ux from Designmodo
- workers: using a free instance of IronWorker and assuming 5s runtime each time gives us a poll every 6 minutes. Zapier would poll every 15 minutes but already integrates Stripe and FB.
- worst case scenario I provide even Small Business plan for free and provide a better experience
- worst case scenario I provide even Small Business plan for free and provide a better experience

View File

@ -10,6 +10,9 @@
"firebase-simple-login": "~1.6.3",
"grapnel": "~0.4.2",
"github": "~0.9.0",
"localforage": "~0.9.2"
"localforage": "~0.9.2",
"superagent": "~0.19.0",
"async": "~0.9.0",
"moment": "~2.8.3"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -7,16 +7,18 @@
// app.coffee
root.require.register('burnchart/src/app.js', function(exports, require, module) {
var App, Header, el, key, route, router, _i, _len, _ref;
var App, Header, el, key, mediator, route, router, _i, _len, _ref;
_ref = ['projects'];
_ref = ['utils/mixins', 'models/projects'];
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
key = _ref[_i];
require("./models/" + key);
require("./" + key);
}
Header = require('./views/header');
mediator = require('./modules/mediator');
el = '#page';
route = function(page, req, evt) {
@ -30,7 +32,11 @@
router = {
'': _.partial(route, 'index'),
'project/add': _.partial(route, 'addProject')
'project/add': _.partial(route, 'addProject'),
'reset': function() {
mediator.fire('!projects/clear');
return window.location.hash = '#';
}
};
App = Ractive.extend({
@ -52,42 +58,81 @@
module.exports = {
"firebase": "burnchart",
"provider": "github"
"provider": "github",
"fields": {
"milestone": [
"closed_issues",
"created_at",
"description",
"due_on",
"number",
"open_issues",
"title",
"updated_at"
]
}
};
});
// projects.coffee
root.require.register('burnchart/src/models/projects.js', function(exports, require, module) {
var Model, mediator, user;
var Model, config, date, mediator, request, user;
mediator = require('../modules/mediator');
request = require('../modules/request');
Model = require('../utils/model');
date = require('../utils/date');
config = require('./config');
user = require('./user');
module.exports = new Model({
'data': {
'items': []
'list': []
},
init: function() {
var _this = this;
localforage.getItem('projects', function(items) {
if (items == null) {
items = [];
var getMilestones,
_this = this;
getMilestones = function() {};
localforage.getItem('projects', function(projects) {
if (projects == null) {
projects = [];
}
return _this.set('items', items);
return _this.set('list', projects);
});
this.observe('items', function() {
return localforage.setItem('projects', this.get('items'));
this.observe('list', function(projects) {
return localforage.setItem('projects', projects);
});
return mediator.on('!projects/add', function(repo) {
return _this.push('items', {
'owner': repo.owner.login,
'name': repo.name
mediator.on('!projects/add', function(repo, done) {
return request.allMilestones(repo, function(err, res) {
var active, milestones;
if (err) {
throw err;
}
milestones = _.pluckMany(res, config.fields.milestone);
active = _.find(milestones, function(m) {
return 0 < m.open_issues + m.closed_issues;
});
if (active != null) {
active.active = true;
}
_this.push('list', _.merge(repo, {
'milestones': {
'list': milestones,
'checked_at': date.now()
}
}));
return done();
});
});
return mediator.on('!projects/clear', function() {
return _this.set('list', []);
});
}
});
@ -106,7 +151,8 @@
'data': {
'provider': "local",
'id': "0",
'uid': "local:0"
'uid': "local:0",
'token': null
}
});
@ -162,30 +208,6 @@
});
// github.coffee
root.require.register('burnchart/src/modules/github.js', function(exports, require, module) {
var auth, github, setToken, user;
user = require('../models/user');
auth = 'oauth';
github = null;
(setToken = function(token) {
return github = new Github({
token: token,
auth: auth
});
})(null);
user.observe('accessToken', setToken);
module.exports = github;
});
// mediator.coffee
root.require.register('burnchart/src/modules/mediator.js', function(exports, require, module) {
@ -193,16 +215,176 @@
});
// request.coffee
root.require.register('burnchart/src/modules/request.js', function(exports, require, module) {
var defaults, error, headers, request, response, user;
user = require('../models/user');
superagent.parse = {
'application/json': function(res) {
var e;
try {
return JSON.parse(res);
} catch (_error) {
e = _error;
return {};
}
}
};
defaults = {
'github': {
'host': 'api.github.com',
'protocol': 'https'
}
};
module.exports = {
'repo': function(repo, cb) {
var data;
data = _.defaults({
'protocol': repo.protocol,
'host': repo.host,
'path': "/repos/" + repo.owner + "/" + repo.name,
'headers': headers(user.get('token'))
}, defaults.github);
return request(data, cb);
},
'allMilestones': function(repo, cb) {
var data;
data = _.defaults({
'protocol': repo.protocol,
'host': repo.host,
'path': "/repos/" + repo.owner + "/" + repo.name + "/milestones",
'query': {
'state': 'open',
'sort': 'due_date',
'direction': 'asc'
},
'headers': headers(user.get('token'))
}, defaults.github);
return request(data, cb);
},
'oneMilestone': function(repo, number, cb) {
return request({
'protocol': repo.protocol,
'host': repo.host,
'path': "/repos/" + repo.owner + "/" + repo.name + "/milestones/" + number,
'query': {
'state': 'open',
'sort': 'due_date',
'direction': 'asc'
},
'headers': headers(user.get('token'))
}, cb);
},
'allIssues': function(repo, query, cb) {
return request({
'protocol': repo.protocol,
'host': repo.host,
'path': "/repos/" + repo.owner + "/" + repo.name + "/issues",
'query': _.extend(query, {
'per_page': '100'
}),
'headers': headers(user.get('token'))
}, cb);
}
};
request = function(_arg, cb) {
var exited, headers, host, k, path, protocol, q, query, req, timeout, v;
protocol = _arg.protocol, host = _arg.host, path = _arg.path, query = _arg.query, headers = _arg.headers;
exited = false;
q = query ? '?' + ((function() {
var _results;
_results = [];
for (k in query) {
v = query[k];
_results.push("" + k + "=" + v);
}
return _results;
})()).join('&') : '';
req = superagent.get("" + protocol + "://" + host + path + q);
for (k in headers) {
v = headers[k];
req.set(k, v);
}
timeout = setTimeout(function() {
exited = true;
return cb('Request has timed out');
}, 1e4);
return req.end(function(err, data) {
if (exited) {
return;
}
exited = true;
clearTimeout(timeout);
return response(err, data, cb);
});
};
response = function(err, data, cb) {
var _ref;
if (err) {
return cb(error(err));
}
if (data.statusType !== 2) {
if ((data != null ? (_ref = data.body) != null ? _ref.message : void 0 : void 0) != null) {
return cb(data.body.message);
}
return cb(data.error.message);
}
return cb(null, data.body);
};
headers = function(token) {
var h;
h = _.extend({}, {
'Content-Type': 'application/json',
'Accept': 'application/vnd.github.v3'
});
if (token != null) {
h.Authorization = "token " + token;
}
return h;
};
error = function(err) {
var message;
switch (false) {
case !_.isString(err):
message = err;
break;
case !_.isArray(err):
message = err[1];
break;
case !(_.isObject(err) && _.isString(err.message)):
message = err.message;
}
if (!message) {
try {
message = JSON.stringify(err);
} catch (_error) {
message = err.toString();
}
}
return message;
};
});
// header.mustache
root.require.register('burnchart/src/templates/header.js', function(exports, require, module) {
module.exports = ["<div id=\"head\">"," <div class=\"right\">"," {{#user.displayName}}"," {{user.displayName}} logged in"," {{else}}"," <a class=\"github\" on-click=\"!login\"><span class=\"icon github\"></span> Sign In</a>"," {{/user.displayName}}"," </div>",""," <h1><span class=\"icon fire-station\"></span></h1>",""," <div class=\"q\">"," <span class=\"icon search\"></span>"," <span class=\"icon down-open\"></span>"," <input type=\"text\" placeholder=\"Jump to...\">"," </div>",""," <ul>"," <li><a href=\"#project/add\" class=\"add\"><span class=\"icon plus-circled\"></span> Add a Project</a></li>"," <li><a href=\"#\" class=\"faq\">FAQ</a></li>"," </ul>","</div>"].join("\n");
module.exports = ["<div id=\"head\">"," <div class=\"right\">"," {{#user.displayName}}"," {{user.displayName}} logged in"," {{else}}"," <a class=\"github\" on-click=\"!login\"><span class=\"icon github\"></span> Sign In</a>"," {{/user.displayName}}"," </div>",""," <h1><span class=\"icon fire-station\"></span></h1>",""," <div class=\"q\">"," <span class=\"icon search\"></span>"," <span class=\"icon down-open\"></span>"," <input type=\"text\" placeholder=\"Jump to...\">"," </div>",""," <ul>"," <li><a href=\"#project/add\" class=\"add\"><span class=\"icon plus-circled\"></span> Add a Project</a></li>"," <li><a href=\"#\" class=\"faq\">FAQ</a></li>"," <li><a href=\"#reset\">DB Reset</a></li>"," </ul>","</div>"].join("\n");
});
// hero.mustache
root.require.register('burnchart/src/templates/hero.js', function(exports, require, module) {
module.exports = ["{{^projects.items}}"," <div id=\"hero\">"," <div class=\"content\">"," <span class=\"icon address\"></span>"," <h2>See your project progress</h2>"," <p>Not sure where to start? Just add a demo repo to see a chart. There are many variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour, or randomised words which don't look even slightly believable.</p>"," <div class=\"cta\">"," <a href=\"#project/add\" class=\"primary\"><span class=\"icon plus-circled\"></span> Add your project</a>"," <a href=\"#\" class=\"secondary\">Read the Guide</a>"," </div>"," </div>"," </div>","{{/projects.items}}"].join("\n");
module.exports = ["{{^projects.list}}"," <div id=\"hero\">"," <div class=\"content\">"," <span class=\"icon address\"></span>"," <h2>See your project progress</h2>"," <p>Not sure where to start? Just add a demo repo to see a chart. There are many variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour, or randomised words which don't look even slightly believable.</p>"," <div class=\"cta\">"," <a href=\"#project/add\" class=\"primary\"><span class=\"icon plus-circled\"></span> Add your project</a>"," <a href=\"#\" class=\"secondary\">Read the Guide</a>"," </div>"," </div>"," </div>","{{/projects.list}}"].join("\n");
});
// layout.mustache
@ -226,7 +408,62 @@
// projects.mustache
root.require.register('burnchart/src/templates/projects.js', function(exports, require, module) {
module.exports = ["{{#projects.items}}"," <div id=\"projects\">"," <div class=\"header\">"," <a href=\"#\" class=\"sort\"><span class=\"icon sort-alphabet\"></span> Sorted by priority</a>"," <h2>Projects</h2>"," </div>",""," <table>"," {{#projects.items}}"," <tr>"," <td><a class=\"repo\" href=\"#\">{{owner}}/{{name}}</a></td>"," <td><span class=\"milestone\">??? <span class=\"icon down-open\"></span></a></td>"," <td>"," <div class=\"progress\">"," <span class=\"percent\">10%</span>"," <span class=\"due\">???</span>"," <div class=\"outer bar\">"," <div class=\"inner bar green\" style=\"width:10%\"></div>"," </div>"," </div>"," </td>"," </tr>"," {{/projects.items}}",""," <tr>"," <td><a class=\"repo\" href=\"#\">radekstepan/disposable</a></td>"," <td><span class=\"milestone\">Milestone 1.0 <span class=\"icon down-open\"></span></a></td>"," <td>"," <div class=\"progress\">"," <span class=\"percent\">40%</span>"," <span class=\"due\">due on Friday</span>"," <div class=\"outer bar\">"," <div class=\"inner bar red\" style=\"width:40%\"></div>"," </div>"," </div>"," </td>"," </tr>"," <tr class=\"done\">"," <td><a class=\"repo\" href=\"#\">radekstepan/burnchart</a></td>"," <td><span class=\"milestone\">Beta Milestone <span class=\"icon down-open\"></span></a></td>"," <td>"," <div class=\"progress\">"," <span class=\"percent\">100%</span>"," <span class=\"due\">due tomorrow</span>"," <div class=\"outer bar\">"," <div class=\"inner bar green\" style=\"width:100%\"></div>"," </div>"," </div>"," </td>"," </tr>"," <tr>"," <td><a class=\"repo\" href=\"#\">intermine/intermine</a></td>"," <td><span class=\"milestone\">Emma Release 96 <span class=\"icon down-open\"></span></a></td>"," <td>"," <div class=\"progress\">"," <span class=\"percent\">27%</span>"," <span class=\"due\">due in 2 weeks</span>"," <div class=\"outer bar\">"," <div class=\"inner bar red\" style=\"width:27%\"></div>"," </div>"," </div>"," </td>"," </tr>"," <tr>"," <td><a class=\"repo\" href=\"#\">microsoft/windows</a></td>"," <td><span class=\"milestone\">RC 9 <span class=\"icon down-open\"></span></a></td>"," <td>"," <div class=\"progress\">"," <span class=\"percent\">90%</span>"," <span class=\"due red\">overdue by a month</span>"," <div class=\"outer bar\">"," <div class=\"inner bar red\" style=\"width:90%\"></div>"," </div>"," </div>"," </td>"," </tr>"," </table>",""," <div class=\"footer\">"," <a href=\"#\"><span class=\"icon cog\"></span> Edit</a>"," </div>"," </div>","{{/projects.items}}"].join("\n");
module.exports = ["{{#projects.list}}"," <div id=\"projects\">"," <div class=\"header\">"," <a href=\"#\" class=\"sort\"><span class=\"icon sort-alphabet\"></span> Sorted by priority</a>"," <h2>Projects</h2>"," </div>",""," <table>"," {{#projects.list}}"," <tr>"," <td><a class=\"repo\" href=\"#\">{{owner}}/{{name}}</a></td>"," {{# { milestone: getMilestone(milestones.list) } }}"," {{#milestone}}"," <td>"," <span class=\"milestone\">"," {{ milestone.title }}"," <span class=\"icon down-open\">"," </span>"," </td>"," <td>"," <div class=\"progress\">"," <span class=\"percent\">{{Math.floor(format.progress(closed_issues, open_issues))}}%</span>"," <span class=\"due\">due {{format.fromNow(due_on)}}</span>"," <div class=\"outer bar\">"," <div class=\"inner bar {{format.onTime(milestone)}}\" style=\"width:{{format.progress(closed_issues, open_issues)}}%\"></div>"," </div>"," </div>"," </td>"," {{/milestone}}"," {{^milestone}}"," <td colspan=\"2\"><span class=\"milestone\"><em>No milestones yet</em></td>"," {{/milestone}}"," {{/}}"," </tr>"," {{/projects.list}}",""," <tr>"," <td><a class=\"repo\" href=\"#\">radekstepan/disposable</a></td>"," <td><span class=\"milestone\">Milestone 1.0 <span class=\"icon down-open\"></span></td>"," <td>"," <div class=\"progress\">"," <span class=\"percent\">40%</span>"," <span class=\"due\">due on Friday</span>"," <div class=\"outer bar\">"," <div class=\"inner bar red\" style=\"width:40%\"></div>"," </div>"," </div>"," </td>"," </tr>"," <tr class=\"done\">"," <td><a class=\"repo\" href=\"#\">radekstepan/burnchart</a></td>"," <td><span class=\"milestone\">Beta Milestone <span class=\"icon down-open\"></span></a></td>"," <td>"," <div class=\"progress\">"," <span class=\"percent\">100%</span>"," <span class=\"due\">due tomorrow</span>"," <div class=\"outer bar\">"," <div class=\"inner bar green\" style=\"width:100%\"></div>"," </div>"," </div>"," </td>"," </tr>"," <tr>"," <td><a class=\"repo\" href=\"#\">intermine/intermine</a></td>"," <td><span class=\"milestone\">Emma Release 96 <span class=\"icon down-open\"></span></a></td>"," <td>"," <div class=\"progress\">"," <span class=\"percent\">27%</span>"," <span class=\"due\">due in 2 weeks</span>"," <div class=\"outer bar\">"," <div class=\"inner bar red\" style=\"width:27%\"></div>"," </div>"," </div>"," </td>"," </tr>"," <tr>"," <td><a class=\"repo\" href=\"#\">microsoft/windows</a></td>"," <td><span class=\"milestone\">RC 9 <span class=\"icon down-open\"></span></a></td>"," <td>"," <div class=\"progress\">"," <span class=\"percent\">90%</span>"," <span class=\"due red\">overdue by a month</span>"," <div class=\"outer bar\">"," <div class=\"inner bar red\" style=\"width:90%\"></div>"," </div>"," </div>"," </td>"," </tr>"," </table>",""," <div class=\"footer\">"," <a href=\"#\"><span class=\"icon cog\"></span> Edit</a>"," </div>"," </div>","{{/projects.list}}"].join("\n");
});
// date.coffee
root.require.register('burnchart/src/utils/date.js', function(exports, require, module) {
module.exports = {
now: function() {
return new Date().toJSON();
}
};
});
// format.coffee
root.require.register('burnchart/src/utils/format.js', function(exports, require, module) {
module.exports = {
'progress': _.memoize(function(a, b) {
return 100 * (a / (b + a));
}),
'onTime': _.memoize(function(milestone) {
var a, b, c, points, time;
points = this.progress(milestone.closed_issues, milestone.open_issues);
a = +new Date(milestone.created_at);
b = +(new Date);
c = +new Date(milestone.due_on);
time = this.progress(b - a, c - b);
return ['red', 'green'][+(points > time)];
}),
'fromNow': _.memoize(function(jsonDate) {
return moment(new Date(jsonDate)).fromNow();
})
};
});
// mixins.coffee
root.require.register('burnchart/src/utils/mixins.js', function(exports, require, module) {
_.mixin({
'pluckMany': function(source, keys) {
if (!_.isArray(keys)) {
throw '`keys` needs to be an Array';
}
return _.map(source, function(item) {
var obj;
obj = {};
_.each(keys, function(key) {
return obj[key] = item[key];
});
return obj;
});
}
});
});
// model.coffee
@ -294,18 +531,16 @@
// addProject.coffee
root.require.register('burnchart/src/views/pages/addProject.js', function(exports, require, module) {
var github, mediator, user;
var mediator, user;
mediator = require('../../modules/mediator');
github = require('../../modules/github');
user = require('../../models/user');
module.exports = Ractive.extend({
'template': require('../../templates/pages/addProject'),
'data': {
'value': null,
'value': 'radekstepan/disposable',
user: user
},
'adapt': [Ractive.adaptors.Ractive],
@ -316,14 +551,12 @@
'init': false
});
return this.on('submit', function() {
var name, owner, repo, _ref;
var name, owner, _ref;
_ref = this.get('value').split('/'), owner = _ref[0], name = _ref[1];
repo = github.getRepo(owner, name);
return repo.show(function(err, repo, xhr) {
if (err) {
throw err;
}
mediator.fire('!projects/add', repo);
return mediator.fire('!projects/add', {
owner: owner,
name: name
}, function() {
return window.location.hash = '#';
});
});
@ -335,17 +568,25 @@
// index.coffee
root.require.register('burnchart/src/views/pages/index.js', function(exports, require, module) {
var Hero, Projects;
var Hero, Projects, format;
Hero = require('../hero');
Projects = require('../projects');
format = require('../../utils/format');
module.exports = Ractive.extend({
'template': require('../../templates/pages/index'),
'components': {
Hero: Hero,
Projects: Projects
},
'data': {
'format': format,
getMilestone: function(list) {
return _.findWhere(list, 'active');
}
}
});

View File

@ -1,9 +1,11 @@
( require "./models/#{key}" for key in [
'projects'
( require "./#{key}" for key in [
'utils/mixins'
'models/projects'
] )
Header = require './views/header'
mediator = require './modules/mediator'
el = '#page'
@ -15,6 +17,10 @@ route = (page, req, evt) ->
router =
'': _.partial route, 'index'
'project/add': _.partial route, 'addProject'
# TODO: remove in production.
'reset': ->
mediator.fire '!projects/clear'
window.location.hash = '#'
App = Ractive.extend

View File

@ -1,4 +1,16 @@
{
"firebase": "burnchart",
"provider": "github"
"provider": "github",
"fields": {
"milestone": [
"closed_issues",
"created_at",
"description",
"due_on",
"number",
"open_issues",
"title",
"updated_at"
]
}
}

View File

@ -1,21 +1,52 @@
mediator = require '../modules/mediator'
request = require '../modules/request'
Model = require '../utils/model'
date = require '../utils/date'
config = require './config'
user = require './user'
module.exports = new Model
'data':
'items': []
'list': []
init: ->
# Fetches a list of milestones for a repo.
getMilestones = ->
# Initialize with items stored locally.
localforage.getItem 'projects', (items=[]) =>
@set 'items', items
localforage.getItem 'projects', (projects=[]) =>
@set 'list', projects
# Persist in local storage.
@observe 'items', ->
localforage.setItem 'projects', @get('items')
@observe 'list', (projects) ->
localforage.setItem 'projects', projects
mediator.on '!projects/add', (repo) =>
# TODO: deal with repo.hasIssues and warn if there are none.
@push 'items', { 'owner': repo.owner.login, 'name': repo.name }
mediator.on '!projects/add', (repo, done) =>
# TODO: warn when we are adding an existing repo (or
# silently go to index again).
# Fetch milestones (which validates repo too).
request.allMilestones repo, (err, res) =>
throw err if err
# Pluck these fields for milestones.
milestones = _.pluckMany res, config.fields.milestone
# Set the default milestone as the soonest one with issues.
active = _.find milestones, (m) ->
0 < m.open_issues + m.closed_issues
active?.active = true
# Push to the stack
@push 'list', _.merge repo,
'milestones':
'list': milestones
'checked_at': do date.now # checked now
# Call back so we can redirect.
do done
mediator.on '!projects/clear', =>
@set 'list', []

View File

@ -8,4 +8,5 @@ module.exports = new Model
'data':
'provider': "local"
'id': "0"
'uid': "local:0"
'uid': "local:0"
'token': null

View File

@ -1,13 +0,0 @@
user = require '../models/user'
auth = 'oauth'
github = null
do setToken = (token=null) ->
github = new Github { token, auth }
# Set token when we have one (otherwise init to null).
user.observe 'accessToken', setToken
module.exports = github

129
src/modules/request.coffee Normal file
View File

@ -0,0 +1,129 @@
user = require '../models/user'
# Custom JSON parser.
superagent.parse =
'application/json': (res) ->
try
JSON.parse res
catch e
{} # it was not to be...
# Default args.
defaults =
'github':
'host': 'api.github.com'
'protocol': 'https'
# Public api.
module.exports =
# Get a repo.
'repo': (repo, cb) ->
data = _.defaults
'protocol': repo.protocol
'host': repo.host
'path': "/repos/#{repo.owner}/#{repo.name}"
'headers': headers user.get('token')
, defaults.github
request data, cb
# Get all open milestones.
'allMilestones': (repo, cb) ->
data = _.defaults
'protocol': repo.protocol
'host': repo.host
'path': "/repos/#{repo.owner}/#{repo.name}/milestones"
'query': { 'state': 'open', 'sort': 'due_date', 'direction': 'asc' }
'headers': headers user.get('token')
, defaults.github
request data, cb
# Get one open milestone.
'oneMilestone': (repo, number, cb) ->
request
'protocol': repo.protocol
'host': repo.host
'path': "/repos/#{repo.owner}/#{repo.name}/milestones/#{number}"
'query': { 'state': 'open', 'sort': 'due_date', 'direction': 'asc' }
'headers': headers user.get('token')
, cb
# Get all issues for a state.
'allIssues': (repo, query, cb) ->
request
'protocol': repo.protocol
'host': repo.host
'path': "/repos/#{repo.owner}/#{repo.name}/issues"
'query': _.extend query, { 'per_page': '100' }
'headers': headers user.get('token')
, cb
# Make a request using SuperAgent.
request = ({ protocol, host, path, query, headers }, cb) ->
exited = no
# Make the query params.
q = if query then '?' + ( "#{k}=#{v}" for k, v of query ).join('&') else ''
# The URI.
req = superagent.get("#{protocol}://#{host}#{path}#{q}")
# Add headers.
( req.set(k, v) for k, v of headers )
# Timeout for requests that do not finish... see #32.
timeout = setTimeout ->
exited = yes
cb 'Request has timed out'
, 1e4 # give us 10s
# Send.
req.end (err, data) ->
# Arrived too late.
return if exited
# All fine.
exited = yes
clearTimeout timeout
# Actually process the response.
response err, data, cb
# How do we respond to a response?
response = (err, data, cb) ->
return cb error err if err
# 2xx?
if data.statusType isnt 2
# Do we have a message from GitHub?
return cb data.body.message if data?.body?.message?
# Use SA one.
return cb data.error.message
# All good.
cb null, data.body
# Give us headers.
headers = (token) ->
# The defaults.
h = _.extend {},
'Content-Type': 'application/json'
'Accept': 'application/vnd.github.v3'
# Add token?
h.Authorization = "token #{token}" if token?
h
# Parse an error.
error = (err) ->
switch
when _.isString err
message = err
when _.isArray err
message = err[1]
when _.isObject(err) and _.isString(err.message)
message = err.message
unless message
try
message = JSON.stringify err
catch
message = do err.toString
message

View File

@ -18,5 +18,6 @@
<ul>
<li><a href="#project/add" class="add"><span class="icon plus-circled"></span> Add a Project</a></li>
<li><a href="#" class="faq">FAQ</a></li>
<li><a href="#reset">DB Reset</a></li>
</ul>
</div>

View File

@ -1,4 +1,4 @@
{{^projects.items}}
{{^projects.list}}
<div id="hero">
<div class="content">
<span class="icon address"></span>
@ -10,4 +10,4 @@
</div>
</div>
</div>
{{/projects.items}}
{{/projects.list}}

View File

@ -1,4 +1,4 @@
{{#projects.items}}
{{#projects.list}}
<div id="projects">
<div class="header">
<a href="#" class="sort"><span class="icon sort-alphabet"></span> Sorted by priority</a>
@ -6,25 +6,37 @@
</div>
<table>
{{#projects.items}}
{{#projects.list}}
<tr>
<td><a class="repo" href="#">{{owner}}/{{name}}</a></td>
<td><span class="milestone">??? <span class="icon down-open"></span></a></td>
<td>
<div class="progress">
<span class="percent">10%</span>
<span class="due">???</span>
<div class="outer bar">
<div class="inner bar green" style="width:10%"></div>
</div>
</div>
</td>
{{# { milestone: getMilestone(milestones.list) } }}
{{#milestone}}
<td>
<span class="milestone">
{{ milestone.title }}
<span class="icon down-open">
</span>
</td>
<td>
<div class="progress">
<span class="percent">{{Math.floor(format.progress(closed_issues, open_issues))}}%</span>
<span class="due">due {{format.fromNow(due_on)}}</span>
<div class="outer bar">
<div class="inner bar {{format.onTime(milestone)}}" style="width:{{format.progress(closed_issues, open_issues)}}%"></div>
</div>
</div>
</td>
{{/milestone}}
{{^milestone}}
<td colspan="2"><span class="milestone"><em>No milestones yet</em></td>
{{/milestone}}
{{/}}
</tr>
{{/projects.items}}
{{/projects.list}}
<tr>
<td><a class="repo" href="#">radekstepan/disposable</a></td>
<td><span class="milestone">Milestone 1.0 <span class="icon down-open"></span></a></td>
<td><span class="milestone">Milestone 1.0 <span class="icon down-open"></span></td>
<td>
<div class="progress">
<span class="percent">40%</span>
@ -80,4 +92,4 @@
<a href="#"><span class="icon cog"></span> Edit</a>
</div>
</div>
{{/projects.items}}
{{/projects.list}}

2
src/utils/date.coffee Normal file
View File

@ -0,0 +1,2 @@
module.exports =
now: -> new Date().toJSON()

24
src/utils/format.coffee Normal file
View File

@ -0,0 +1,24 @@
module.exports =
# Progress in percentages.
'progress': _.memoize (a, b) ->
100 * (a / (b + a))
# Is a milestone on time?
'onTime': _.memoize (milestone) ->
# Progress in points.
points = @progress milestone.closed_issues, milestone.open_issues
# Calculate the progress in days.
a = +new Date milestone.created_at
b = +new Date
c = +new Date milestone.due_on
# Progress in time.
time = @progress b - a, c - b
[ 'red', 'green' ][ +(points > time) ]
# When is this milestone due?
'fromNow': _.memoize (jsonDate) ->
moment(new Date(jsonDate)).fromNow()

8
src/utils/mixins.coffee Normal file
View File

@ -0,0 +1,8 @@
_.mixin
'pluckMany': (source, keys) ->
throw '`keys` needs to be an Array' unless _.isArray keys
_.map source, (item) ->
obj = {}
_.each keys, (key) ->
obj[key] = item[key]
obj

View File

@ -1,12 +1,11 @@
mediator = require '../../modules/mediator'
github = require '../../modules/github'
user = require '../../models/user'
module.exports = Ractive.extend
'template': require '../../templates/pages/addProject'
'data': { 'value': null, user }
'data': { 'value': 'radekstepan/disposable', user }
'adapt': [ Ractive.adaptors.Ractive ]
@ -22,11 +21,9 @@ module.exports = Ractive.extend
# TODO: listen to Enter keypress.
@on 'submit', ->
[ owner, name ] = @get('value').split('/')
repo = github.getRepo owner, name
repo.show (err, repo, xhr) ->
throw err if err
# TODO: save repo to us & Firebase.
mediator.fire '!projects/add', repo
# TODO: save repo & persist.
mediator.fire '!projects/add', { owner, name }, ->
# Redirect to the dashboard.
# TODO: trigger a named route
window.location.hash = '#'

View File

@ -1,8 +1,15 @@
Hero = require '../hero'
Projects = require '../projects'
format = require '../../utils/format'
module.exports = Ractive.extend
'template': require '../../templates/pages/index'
'components': { Hero, Projects }
'components': { Hero, Projects }
'data':
'format': format
# Find the milestone that is active.
getMilestone: (list) ->
_.findWhere list, 'active'