mirror of
https://github.com/status-im/burnchart.git
synced 2025-02-17 04:46:30 +00:00
first stab at integrating burndown chart for community plan
This commit is contained in:
parent
0cbaaf321e
commit
eff268e1de
@ -18,6 +18,7 @@ module.exports = (grunt) ->
|
||||
src: [
|
||||
'src/styles/fonts.styl'
|
||||
'src/styles/icons.styl'
|
||||
'src/styles/chart.styl'
|
||||
'src/styles/app.styl'
|
||||
]
|
||||
dest: 'public/css/app.css'
|
||||
|
@ -9,8 +9,8 @@ GitHub Burndown Chart as a service. Public repos are free, for private access au
|
||||
### MVP - Community Plan
|
||||
|
||||
- [x] show a list of projects and their milestones with progress & due date
|
||||
- [ ] show burnchart for that project milestone
|
||||
- [ ] show all issues as [one size](https://github.com/radekstepan/github-burndown-chart/issues/46)
|
||||
- [x] show burnchart for that project milestone
|
||||
- [x] show all issues as [one size](https://github.com/radekstepan/github-burndown-chart/issues/46)
|
||||
- [x] use `localStorage` to save project names
|
||||
|
||||
### Extras
|
||||
|
@ -436,6 +436,22 @@ table {
|
||||
}@keyframes spin{0%{-webkit-transform:rotate(0);-moz-transform:rotate(0);-o-transform:rotate(0);-ms-transform:rotate(0);transform:rotate(0)}
|
||||
100%{-webkit-transform:rotate(360deg);-moz-transform:rotate(360deg);-o-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg)}
|
||||
}
|
||||
#chart{height:200px;position:relative;}
|
||||
#chart #tooltip{position:absolute;top:0;left:0}
|
||||
#chart svg path.line{fill:none;stroke-width:1px;clip-path:url("#clip");}
|
||||
#chart svg path.line.actual{stroke:#64584c;stroke-width:3px}
|
||||
#chart svg path.line.ideal{stroke:#cacaca;stroke-width:3px}
|
||||
#chart svg path.line.trendline{stroke:#64584c;stroke-width:1.5px;stroke-dasharray:5,5}
|
||||
#chart svg line.today{stroke:#cacaca;stroke-width:1px;shape-rendering:crispEdges;stroke-dasharray:5,5}
|
||||
#chart svg circle{fill:#64584c;stroke:transparent;stroke-width:15px;cursor:pointer}
|
||||
#chart svg .axis{shape-rendering:crispEdges;}
|
||||
#chart svg .axis line{stroke:rgba(202,202,202,0.25);shape-rendering:crispEdges}
|
||||
#chart svg .axis text{font-weight:bold;fill:#cacaca}
|
||||
#chart svg .axis path{display:none}
|
||||
.d3-tip{margin-top:-10px;font-size:11px;padding:8px 10px 7px 10px;text-align:center;background:rgba(0,0,0,0.75);color:#fff;-webkit-border-radius:3px;border-radius:3px;}
|
||||
.d3-tip:after{width:100%;color:rgba(0,0,0,0.8);content:"\25BC";position:absolute}
|
||||
.d3-tip.n:after{margin:-3px 0 0 0;top:100%;left:0}
|
||||
|
||||
body{color:#3e4457;font-family:'MuseoSans500Regular',sans-serif}
|
||||
a{text-decoration:none;color:#aaafbf;cursor:pointer}
|
||||
h1,h2,h3,p{margin:0}
|
||||
|
@ -29,6 +29,22 @@
|
||||
}@keyframes spin{0%{-webkit-transform:rotate(0);-moz-transform:rotate(0);-o-transform:rotate(0);-ms-transform:rotate(0);transform:rotate(0)}
|
||||
100%{-webkit-transform:rotate(360deg);-moz-transform:rotate(360deg);-o-transform:rotate(360deg);-ms-transform:rotate(360deg);transform:rotate(360deg)}
|
||||
}
|
||||
#chart{height:200px;position:relative;}
|
||||
#chart #tooltip{position:absolute;top:0;left:0}
|
||||
#chart svg path.line{fill:none;stroke-width:1px;clip-path:url("#clip");}
|
||||
#chart svg path.line.actual{stroke:#64584c;stroke-width:3px}
|
||||
#chart svg path.line.ideal{stroke:#cacaca;stroke-width:3px}
|
||||
#chart svg path.line.trendline{stroke:#64584c;stroke-width:1.5px;stroke-dasharray:5,5}
|
||||
#chart svg line.today{stroke:#cacaca;stroke-width:1px;shape-rendering:crispEdges;stroke-dasharray:5,5}
|
||||
#chart svg circle{fill:#64584c;stroke:transparent;stroke-width:15px;cursor:pointer}
|
||||
#chart svg .axis{shape-rendering:crispEdges;}
|
||||
#chart svg .axis line{stroke:rgba(202,202,202,0.25);shape-rendering:crispEdges}
|
||||
#chart svg .axis text{font-weight:bold;fill:#cacaca}
|
||||
#chart svg .axis path{display:none}
|
||||
.d3-tip{margin-top:-10px;font-size:11px;padding:8px 10px 7px 10px;text-align:center;background:rgba(0,0,0,0.75);color:#fff;-webkit-border-radius:3px;border-radius:3px;}
|
||||
.d3-tip:after{width:100%;color:rgba(0,0,0,0.8);content:"\25BC";position:absolute}
|
||||
.d3-tip.n:after{margin:-3px 0 0 0;top:100%;left:0}
|
||||
|
||||
body{color:#3e4457;font-family:'MuseoSans500Regular',sans-serif}
|
||||
a{text-decoration:none;color:#aaafbf;cursor:pointer}
|
||||
h1,h2,h3,p{margin:0}
|
||||
|
@ -40023,25 +40023,30 @@ if (typeof exports === 'object') {
|
||||
|
||||
});
|
||||
|
||||
// config.json
|
||||
// config.coffee
|
||||
root.require.register('burnchart/src/models/config.js', function(exports, require, module) {
|
||||
|
||||
module.exports = {
|
||||
var Model;
|
||||
|
||||
Model = require('../utils/model');
|
||||
|
||||
module.exports = new Model({
|
||||
"data": {
|
||||
"firebase": "burnchart",
|
||||
"provider": "github",
|
||||
"fields": {
|
||||
"milestone": [
|
||||
"closed_issues",
|
||||
"created_at",
|
||||
"description",
|
||||
"due_on",
|
||||
"number",
|
||||
"open_issues",
|
||||
"title",
|
||||
"updated_at"
|
||||
]
|
||||
"milestone": ["closed_issues", "created_at", "description", "due_on", "number", "open_issues", "title", "updated_at"]
|
||||
},
|
||||
"chart": {
|
||||
"off_days": [6, 7],
|
||||
"datetime": /^(\d{4}-\d{2}-\d{2})T(.*)/,
|
||||
"size_label": /^size (\d+)$/,
|
||||
"location": /^#!((\/[^\/]+){2,3})$/,
|
||||
"points": 'ALL_ONE_SIZE'
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// projects.coffee
|
||||
@ -40049,6 +40054,8 @@ if (typeof exports === 'object') {
|
||||
|
||||
var Model, config, date, mediator, request, user;
|
||||
|
||||
config = require('../models/config');
|
||||
|
||||
mediator = require('../modules/mediator');
|
||||
|
||||
request = require('../modules/request');
|
||||
@ -40057,8 +40064,6 @@ if (typeof exports === 'object') {
|
||||
|
||||
date = require('../utils/date');
|
||||
|
||||
config = require('./config');
|
||||
|
||||
user = require('./user');
|
||||
|
||||
module.exports = new Model({
|
||||
@ -40088,7 +40093,7 @@ if (typeof exports === 'object') {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
milestones = _.pluckMany(res, config.fields.milestone);
|
||||
milestones = _.pluckMany(res, config.get('fields.milestone'));
|
||||
_this.push('list', _.merge(repo, {
|
||||
milestones: milestones
|
||||
}));
|
||||
@ -40123,19 +40128,216 @@ if (typeof exports === 'object') {
|
||||
|
||||
});
|
||||
|
||||
// chart.coffee
|
||||
root.require.register('burnchart/src/modules/chart.js', function(exports, require, module) {
|
||||
|
||||
var config,
|
||||
__indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
|
||||
|
||||
config = require('../models/config');
|
||||
|
||||
module.exports = {
|
||||
'actual': function(collection, created_at, total, cb) {
|
||||
var head, max, min, range, rest;
|
||||
head = [
|
||||
{
|
||||
date: new Date(created_at),
|
||||
points: total
|
||||
}
|
||||
];
|
||||
min = +Infinity;
|
||||
max = -Infinity;
|
||||
rest = _.map(collection, function(issue) {
|
||||
var closed_at, size;
|
||||
size = issue.size, closed_at = issue.closed_at;
|
||||
if (size < min) {
|
||||
min = size;
|
||||
}
|
||||
if (size > max) {
|
||||
max = size;
|
||||
}
|
||||
return _.extend({}, issue, {
|
||||
date: new Date(closed_at),
|
||||
points: total -= size
|
||||
});
|
||||
});
|
||||
range = d3.scale.linear().domain([min, max]).range([5, 8]);
|
||||
rest = _.map(rest, function(issue) {
|
||||
issue.radius = range(issue.size);
|
||||
return issue;
|
||||
});
|
||||
return cb(null, [].concat(head, rest));
|
||||
},
|
||||
'ideal': function(a, b, total, cb) {
|
||||
var cutoff, d, days, length, m, now, once, velocity, y, _ref, _ref1;
|
||||
if (b < a) {
|
||||
_ref = [a, b], b = _ref[0], a = _ref[1];
|
||||
}
|
||||
_ref1 = _.map(a.match(config.get('chart.datetime'))[1].split('-'), function(v) {
|
||||
return parseInt(v);
|
||||
}), y = _ref1[0], m = _ref1[1], d = _ref1[2];
|
||||
cutoff = new Date(b);
|
||||
days = [];
|
||||
length = 0;
|
||||
(once = function(inc) {
|
||||
var day, day_of;
|
||||
day = new Date(y, m - 1, d + inc);
|
||||
if (!(day_of = day.getDay())) {
|
||||
day_of = 7;
|
||||
}
|
||||
if (__indexOf.call(config.get('chart.off_days'), day_of) >= 0) {
|
||||
days.push({
|
||||
date: day,
|
||||
off_day: true
|
||||
});
|
||||
} else {
|
||||
length += 1;
|
||||
days.push({
|
||||
date: day
|
||||
});
|
||||
}
|
||||
if (!(day > cutoff)) {
|
||||
return once(inc + 1);
|
||||
}
|
||||
})(0);
|
||||
velocity = total / (length - 1);
|
||||
days = _.map(days, function(day, i) {
|
||||
day.points = total;
|
||||
if (days[i] && !days[i].off_day) {
|
||||
total -= velocity;
|
||||
}
|
||||
return day;
|
||||
});
|
||||
if ((now = new Date()) > cutoff) {
|
||||
days.push({
|
||||
date: now,
|
||||
points: 0
|
||||
});
|
||||
}
|
||||
return cb(null, days);
|
||||
},
|
||||
'trendline': function(actual, created_at, due_on) {
|
||||
var a, b, b1, c1, e, fn, intercept, l, last, slope, start, values;
|
||||
start = +actual[0].date;
|
||||
values = _.map(actual, function(_arg) {
|
||||
var date, points;
|
||||
date = _arg.date, points = _arg.points;
|
||||
return [+date - start, points];
|
||||
});
|
||||
last = actual[actual.length - 1];
|
||||
values.push([+new Date() - start, last.points]);
|
||||
b1 = 0;
|
||||
e = 0;
|
||||
c1 = 0;
|
||||
a = (l = values.length) * _.reduce(values, function(sum, _arg) {
|
||||
var a, b;
|
||||
a = _arg[0], b = _arg[1];
|
||||
b1 += a;
|
||||
e += b;
|
||||
c1 += Math.pow(a, 2);
|
||||
return sum + (a * b);
|
||||
}, 0);
|
||||
slope = (a - (b1 * e)) / ((l * c1) - (Math.pow(b1, 2)));
|
||||
intercept = (e - (slope * b1)) / l;
|
||||
fn = function(x) {
|
||||
return slope * x + intercept;
|
||||
};
|
||||
created_at = new Date(created_at);
|
||||
due_on = due_on ? new Date(due_on) : new Date();
|
||||
a = created_at - start;
|
||||
b = due_on - start;
|
||||
return [
|
||||
{
|
||||
date: created_at,
|
||||
points: fn(a)
|
||||
}, {
|
||||
date: due_on,
|
||||
points: fn(b)
|
||||
}
|
||||
];
|
||||
},
|
||||
'render': function(_arg, cb) {
|
||||
var actual, height, ideal, line, m, mAxis, margin, svg, tooltip, trendline, width, x, xAxis, y, yAxis, _ref;
|
||||
actual = _arg[0], ideal = _arg[1], trendline = _arg[2];
|
||||
document.querySelector('#svg').innerHTML = '';
|
||||
_ref = document.querySelector('#chart').getBoundingClientRect(), height = _ref.height, width = _ref.width;
|
||||
margin = {
|
||||
top: 30,
|
||||
right: 30,
|
||||
bottom: 40,
|
||||
left: 50
|
||||
};
|
||||
width -= margin.left + margin.right;
|
||||
height -= margin.top + margin.bottom;
|
||||
x = d3.time.scale().range([0, width]);
|
||||
y = d3.scale.linear().range([height, 0]);
|
||||
xAxis = d3.svg.axis().scale(x).orient("bottom").tickSize(-height).tickFormat(function(d) {
|
||||
return d.getDate();
|
||||
}).tickPadding(10);
|
||||
yAxis = d3.svg.axis().scale(y).orient("left").tickSize(-width).ticks(5).tickPadding(10);
|
||||
line = d3.svg.line().interpolate("linear").x(function(d) {
|
||||
return x(d.date);
|
||||
}).y(function(d) {
|
||||
return y(d.points);
|
||||
});
|
||||
x.domain([ideal[0].date, ideal[ideal.length - 1].date]);
|
||||
y.domain([0, ideal[0].points]).nice();
|
||||
svg = d3.select("#svg").append("svg").attr("width", width + margin.left + margin.right).attr("height", height + margin.top + margin.bottom).append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
svg.append("g").attr("class", "x axis day").attr("transform", "translate(0," + height + ")").call(xAxis);
|
||||
m = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
mAxis = xAxis.orient("top").tickSize(height).tickFormat(function(d) {
|
||||
return m[d.getMonth()];
|
||||
}).ticks(2);
|
||||
svg.append("g").attr("class", "x axis month").attr("transform", "translate(0," + height + ")").call(mAxis);
|
||||
svg.append("g").attr("class", "y axis").call(yAxis);
|
||||
svg.append("svg:line").attr("class", "today").attr("x1", x(new Date())).attr("y1", 0).attr("x2", x(new Date())).attr("y2", height);
|
||||
svg.append("path").attr("class", "ideal line").attr("d", line.interpolate("basis")(ideal));
|
||||
svg.append("path").attr("class", "trendline line").attr("d", line.interpolate("linear")(trendline));
|
||||
svg.append("path").attr("class", "actual line").attr("d", line.interpolate("linear").y(function(d) {
|
||||
return y(d.points);
|
||||
})(actual));
|
||||
tooltip = d3.tip().attr('class', 'd3-tip').html(function(_arg1) {
|
||||
var number, title;
|
||||
number = _arg1.number, title = _arg1.title;
|
||||
return "#" + number + ": " + title;
|
||||
});
|
||||
svg.call(tooltip);
|
||||
svg.selectAll("a.issue").data(actual.slice(1)).enter().append('svg:a').attr("xlink:href", function(_arg1) {
|
||||
var html_url;
|
||||
html_url = _arg1.html_url;
|
||||
return html_url;
|
||||
}).attr("xlink:show", 'new').append('svg:circle').attr("cx", function(_arg1) {
|
||||
var date;
|
||||
date = _arg1.date;
|
||||
return x(date);
|
||||
}).attr("cy", function(_arg1) {
|
||||
var points;
|
||||
points = _arg1.points;
|
||||
return y(points);
|
||||
}).attr("r", function(_arg1) {
|
||||
var radius;
|
||||
radius = _arg1.radius;
|
||||
return 5;
|
||||
}).on('mouseover', tooltip.show).on('mouseout', tooltip.hide);
|
||||
return cb(null);
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
// firebase.coffee
|
||||
root.require.register('burnchart/src/modules/firebase.js', function(exports, require, module) {
|
||||
|
||||
var Class, config, user;
|
||||
|
||||
config = require('../models/config');
|
||||
|
||||
user = require('../models/user');
|
||||
|
||||
config = require('../models/config');
|
||||
|
||||
Class = (function() {
|
||||
function Class() {
|
||||
var _this = this;
|
||||
this.client = new Firebase("https://" + config.firebase + ".firebaseio.com");
|
||||
this.client = new Firebase("https://" + (config.get('firebase')) + ".firebaseio.com");
|
||||
this.auth = new FirebaseSimpleLogin(this.client, function(err, obj) {
|
||||
if (err || !obj) {
|
||||
return _this.authCb(err);
|
||||
@ -40151,7 +40353,7 @@ if (typeof exports === 'object') {
|
||||
return cb('Client is not setup');
|
||||
}
|
||||
this.authCb = cb;
|
||||
return this.auth.login(config.provider, {
|
||||
return this.auth.login(config.get('provider'), {
|
||||
'rememberMe': true,
|
||||
'scope': 'public_repo'
|
||||
});
|
||||
@ -40173,6 +40375,76 @@ if (typeof exports === 'object') {
|
||||
|
||||
});
|
||||
|
||||
// issues.coffee
|
||||
root.require.register('burnchart/src/modules/issues.js', function(exports, require, module) {
|
||||
|
||||
var config, request;
|
||||
|
||||
config = require('../models/config');
|
||||
|
||||
request = require('./request');
|
||||
|
||||
module.exports = {
|
||||
'get_all': function(opts, cb) {
|
||||
var one_status;
|
||||
one_status = function(state, cb) {
|
||||
var fetch_page, results;
|
||||
results = [];
|
||||
return (fetch_page = function(page) {
|
||||
return request.allIssues(opts, {
|
||||
state: state,
|
||||
page: page
|
||||
}, function(err, data) {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
if (!data.length) {
|
||||
return cb(null, results);
|
||||
}
|
||||
results = results.concat(_.sortBy(data, 'closed_at'));
|
||||
if (data.length < 100) {
|
||||
return cb(null, results);
|
||||
}
|
||||
return fetch_page(page + 1);
|
||||
});
|
||||
})(1);
|
||||
};
|
||||
return async.parallel([_.partial(one_status, 'open'), _.partial(one_status, 'closed')], cb);
|
||||
},
|
||||
'filter': function(collection, regex, cb) {
|
||||
var filtered, total;
|
||||
total = 0;
|
||||
switch (config.get('chart.points')) {
|
||||
case 'ALL_ONE_SIZE':
|
||||
total = collection.length;
|
||||
filtered = _.map(collection, function(issue) {
|
||||
issue.size = 1;
|
||||
return issue;
|
||||
});
|
||||
break;
|
||||
case 'LABELS':
|
||||
filtered = _.filter(collection, function(issue) {
|
||||
var labels;
|
||||
if (!(labels = issue.labels)) {
|
||||
return false;
|
||||
}
|
||||
issue.size = _.reduce(labels, function(sum, label) {
|
||||
var matches;
|
||||
if (!(matches = label.name.match(regex))) {
|
||||
return sum;
|
||||
}
|
||||
return sum += parseInt(matches[1]);
|
||||
}, 0);
|
||||
total += issue.size;
|
||||
return !!issue.size;
|
||||
});
|
||||
}
|
||||
return cb(null, filtered, total);
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
// mediator.coffee
|
||||
root.require.register('burnchart/src/modules/mediator.js', function(exports, require, module) {
|
||||
|
||||
@ -40180,6 +40452,124 @@ if (typeof exports === 'object') {
|
||||
|
||||
});
|
||||
|
||||
// milestones.coffee
|
||||
root.require.register('burnchart/src/modules/milestones.js', function(exports, require, module) {
|
||||
|
||||
var request;
|
||||
|
||||
request = require('./request');
|
||||
|
||||
module.exports = function(repo, cb) {
|
||||
var parse;
|
||||
parse = function(data) {
|
||||
if (data.description) {
|
||||
data.description = marked(data.description).slice(3, -5);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
if (repo.milestone) {
|
||||
return request.oneMilestone(repo, repo.milestone, function(err, m) {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
if (m.open_issues + m.closed_issues === 0) {
|
||||
return cb(null, "No issues for milestone `" + m.title + "`");
|
||||
}
|
||||
m = parse(m);
|
||||
return cb(null, null, m);
|
||||
});
|
||||
} else {
|
||||
return request.allMilestones(repo, function(err, data) {
|
||||
var m;
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
if (!data.length) {
|
||||
return cb(null, "No open milestones for repo " + repo.path);
|
||||
}
|
||||
m = data[0];
|
||||
m = _.rest(data, {
|
||||
'due_on': null
|
||||
});
|
||||
m = m[0] ? m[0] : data[0];
|
||||
if (m.open_issues + m.closed_issues === 0) {
|
||||
return cb(null, "No issues for milestone `" + m.title + "`");
|
||||
}
|
||||
m = parse(m);
|
||||
return cb(null, null, m);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
// project.coffee
|
||||
root.require.register('burnchart/src/modules/project.js', function(exports, require, module) {
|
||||
|
||||
var chart, issues, milestones;
|
||||
|
||||
milestones = require('./milestones');
|
||||
|
||||
issues = require('./issues');
|
||||
|
||||
chart = require('./chart');
|
||||
|
||||
module.exports = function(opts, cb) {
|
||||
return async.waterfall([
|
||||
function(cb) {
|
||||
return milestones(opts, function(err, warn, milestone) {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
if (warn) {
|
||||
return cb(warn);
|
||||
}
|
||||
opts.milestone = milestone;
|
||||
return cb(null);
|
||||
});
|
||||
}, function(cb) {
|
||||
return issues.get_all(opts, cb);
|
||||
}, function(all, cb) {
|
||||
return async.map(all, function(array, cb) {
|
||||
return issues.filter(array, opts.size_label, function(err, filtered, total) {
|
||||
return cb(err, [filtered, total]);
|
||||
});
|
||||
}, function(err, _arg) {
|
||||
var closed, open;
|
||||
open = _arg[0], closed = _arg[1];
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
if (open[1] + closed[1] === 0) {
|
||||
return cb('No matching issues found');
|
||||
}
|
||||
opts.issues = {
|
||||
closed: {
|
||||
'points': closed[1],
|
||||
'data': closed[0]
|
||||
},
|
||||
open: {
|
||||
'points': open[1],
|
||||
'data': open[0]
|
||||
}
|
||||
};
|
||||
return cb(null);
|
||||
});
|
||||
}, function(cb) {
|
||||
var total;
|
||||
total = opts.issues.open.points + opts.issues.closed.points;
|
||||
return async.parallel([_.partial(chart.actual, opts.issues.closed.data, opts.milestone.created_at, total), _.partial(chart.ideal, opts.milestone.created_at, opts.milestone.due_on, total)], function(err, values) {
|
||||
if (values[0].length) {
|
||||
values.push(chart.trendline(values[0], opts.milestone.created_at, opts.milestone.due_on));
|
||||
}
|
||||
return chart.render(values, cb);
|
||||
});
|
||||
}
|
||||
], cb);
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
// request.coffee
|
||||
root.require.register('burnchart/src/modules/request.js', function(exports, require, module) {
|
||||
|
||||
@ -40210,8 +40600,6 @@ if (typeof exports === 'object') {
|
||||
'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);
|
||||
@ -40220,8 +40608,6 @@ if (typeof exports === 'object') {
|
||||
'allMilestones': function(repo, cb) {
|
||||
var data;
|
||||
data = _.defaults({
|
||||
'protocol': repo.protocol,
|
||||
'host': repo.host,
|
||||
'path': "/repos/" + repo.owner + "/" + repo.name + "/milestones",
|
||||
'query': {
|
||||
'state': 'open',
|
||||
@ -40233,9 +40619,8 @@ if (typeof exports === 'object') {
|
||||
return request(data, cb);
|
||||
},
|
||||
'oneMilestone': function(repo, number, cb) {
|
||||
return request({
|
||||
'protocol': repo.protocol,
|
||||
'host': repo.host,
|
||||
var data;
|
||||
data = _.defaults({
|
||||
'path': "/repos/" + repo.owner + "/" + repo.name + "/milestones/" + number,
|
||||
'query': {
|
||||
'state': 'open',
|
||||
@ -40243,18 +40628,19 @@ if (typeof exports === 'object') {
|
||||
'direction': 'asc'
|
||||
},
|
||||
'headers': headers(user.get('token'))
|
||||
}, cb);
|
||||
}, defaults.github);
|
||||
return request(data, cb);
|
||||
},
|
||||
'allIssues': function(repo, query, cb) {
|
||||
return request({
|
||||
'protocol': repo.protocol,
|
||||
'host': repo.host,
|
||||
var data;
|
||||
data = _.defaults({
|
||||
'path': "/repos/" + repo.owner + "/" + repo.name + "/issues",
|
||||
'query': _.extend(query, {
|
||||
'per_page': '100'
|
||||
}),
|
||||
'headers': headers(user.get('token'))
|
||||
}, cb);
|
||||
}, defaults.github);
|
||||
return request(data, cb);
|
||||
}
|
||||
};
|
||||
|
||||
@ -40563,11 +40949,15 @@ if (typeof exports === 'object') {
|
||||
// showChart.coffee
|
||||
root.require.register('burnchart/src/views/pages/showChart.js', function(exports, require, module) {
|
||||
|
||||
var project;
|
||||
|
||||
project = require('../../modules/project');
|
||||
|
||||
module.exports = Ractive.extend({
|
||||
'template': require('../../templates/pages/showChart'),
|
||||
'adapt': [Ractive.adaptors.Ractive],
|
||||
init: function() {
|
||||
return console.log(this.get('route'));
|
||||
return project(this.get('route'));
|
||||
}
|
||||
});
|
||||
|
||||
|
456
public/js/app.js
456
public/js/app.js
@ -57,25 +57,30 @@
|
||||
|
||||
});
|
||||
|
||||
// config.json
|
||||
// config.coffee
|
||||
root.require.register('burnchart/src/models/config.js', function(exports, require, module) {
|
||||
|
||||
module.exports = {
|
||||
var Model;
|
||||
|
||||
Model = require('../utils/model');
|
||||
|
||||
module.exports = new Model({
|
||||
"data": {
|
||||
"firebase": "burnchart",
|
||||
"provider": "github",
|
||||
"fields": {
|
||||
"milestone": [
|
||||
"closed_issues",
|
||||
"created_at",
|
||||
"description",
|
||||
"due_on",
|
||||
"number",
|
||||
"open_issues",
|
||||
"title",
|
||||
"updated_at"
|
||||
]
|
||||
"milestone": ["closed_issues", "created_at", "description", "due_on", "number", "open_issues", "title", "updated_at"]
|
||||
},
|
||||
"chart": {
|
||||
"off_days": [6, 7],
|
||||
"datetime": /^(\d{4}-\d{2}-\d{2})T(.*)/,
|
||||
"size_label": /^size (\d+)$/,
|
||||
"location": /^#!((\/[^\/]+){2,3})$/,
|
||||
"points": 'ALL_ONE_SIZE'
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// projects.coffee
|
||||
@ -83,6 +88,8 @@
|
||||
|
||||
var Model, config, date, mediator, request, user;
|
||||
|
||||
config = require('../models/config');
|
||||
|
||||
mediator = require('../modules/mediator');
|
||||
|
||||
request = require('../modules/request');
|
||||
@ -91,8 +98,6 @@
|
||||
|
||||
date = require('../utils/date');
|
||||
|
||||
config = require('./config');
|
||||
|
||||
user = require('./user');
|
||||
|
||||
module.exports = new Model({
|
||||
@ -122,7 +127,7 @@
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
milestones = _.pluckMany(res, config.fields.milestone);
|
||||
milestones = _.pluckMany(res, config.get('fields.milestone'));
|
||||
_this.push('list', _.merge(repo, {
|
||||
milestones: milestones
|
||||
}));
|
||||
@ -157,19 +162,216 @@
|
||||
|
||||
});
|
||||
|
||||
// chart.coffee
|
||||
root.require.register('burnchart/src/modules/chart.js', function(exports, require, module) {
|
||||
|
||||
var config,
|
||||
__indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
|
||||
|
||||
config = require('../models/config');
|
||||
|
||||
module.exports = {
|
||||
'actual': function(collection, created_at, total, cb) {
|
||||
var head, max, min, range, rest;
|
||||
head = [
|
||||
{
|
||||
date: new Date(created_at),
|
||||
points: total
|
||||
}
|
||||
];
|
||||
min = +Infinity;
|
||||
max = -Infinity;
|
||||
rest = _.map(collection, function(issue) {
|
||||
var closed_at, size;
|
||||
size = issue.size, closed_at = issue.closed_at;
|
||||
if (size < min) {
|
||||
min = size;
|
||||
}
|
||||
if (size > max) {
|
||||
max = size;
|
||||
}
|
||||
return _.extend({}, issue, {
|
||||
date: new Date(closed_at),
|
||||
points: total -= size
|
||||
});
|
||||
});
|
||||
range = d3.scale.linear().domain([min, max]).range([5, 8]);
|
||||
rest = _.map(rest, function(issue) {
|
||||
issue.radius = range(issue.size);
|
||||
return issue;
|
||||
});
|
||||
return cb(null, [].concat(head, rest));
|
||||
},
|
||||
'ideal': function(a, b, total, cb) {
|
||||
var cutoff, d, days, length, m, now, once, velocity, y, _ref, _ref1;
|
||||
if (b < a) {
|
||||
_ref = [a, b], b = _ref[0], a = _ref[1];
|
||||
}
|
||||
_ref1 = _.map(a.match(config.get('chart.datetime'))[1].split('-'), function(v) {
|
||||
return parseInt(v);
|
||||
}), y = _ref1[0], m = _ref1[1], d = _ref1[2];
|
||||
cutoff = new Date(b);
|
||||
days = [];
|
||||
length = 0;
|
||||
(once = function(inc) {
|
||||
var day, day_of;
|
||||
day = new Date(y, m - 1, d + inc);
|
||||
if (!(day_of = day.getDay())) {
|
||||
day_of = 7;
|
||||
}
|
||||
if (__indexOf.call(config.get('chart.off_days'), day_of) >= 0) {
|
||||
days.push({
|
||||
date: day,
|
||||
off_day: true
|
||||
});
|
||||
} else {
|
||||
length += 1;
|
||||
days.push({
|
||||
date: day
|
||||
});
|
||||
}
|
||||
if (!(day > cutoff)) {
|
||||
return once(inc + 1);
|
||||
}
|
||||
})(0);
|
||||
velocity = total / (length - 1);
|
||||
days = _.map(days, function(day, i) {
|
||||
day.points = total;
|
||||
if (days[i] && !days[i].off_day) {
|
||||
total -= velocity;
|
||||
}
|
||||
return day;
|
||||
});
|
||||
if ((now = new Date()) > cutoff) {
|
||||
days.push({
|
||||
date: now,
|
||||
points: 0
|
||||
});
|
||||
}
|
||||
return cb(null, days);
|
||||
},
|
||||
'trendline': function(actual, created_at, due_on) {
|
||||
var a, b, b1, c1, e, fn, intercept, l, last, slope, start, values;
|
||||
start = +actual[0].date;
|
||||
values = _.map(actual, function(_arg) {
|
||||
var date, points;
|
||||
date = _arg.date, points = _arg.points;
|
||||
return [+date - start, points];
|
||||
});
|
||||
last = actual[actual.length - 1];
|
||||
values.push([+new Date() - start, last.points]);
|
||||
b1 = 0;
|
||||
e = 0;
|
||||
c1 = 0;
|
||||
a = (l = values.length) * _.reduce(values, function(sum, _arg) {
|
||||
var a, b;
|
||||
a = _arg[0], b = _arg[1];
|
||||
b1 += a;
|
||||
e += b;
|
||||
c1 += Math.pow(a, 2);
|
||||
return sum + (a * b);
|
||||
}, 0);
|
||||
slope = (a - (b1 * e)) / ((l * c1) - (Math.pow(b1, 2)));
|
||||
intercept = (e - (slope * b1)) / l;
|
||||
fn = function(x) {
|
||||
return slope * x + intercept;
|
||||
};
|
||||
created_at = new Date(created_at);
|
||||
due_on = due_on ? new Date(due_on) : new Date();
|
||||
a = created_at - start;
|
||||
b = due_on - start;
|
||||
return [
|
||||
{
|
||||
date: created_at,
|
||||
points: fn(a)
|
||||
}, {
|
||||
date: due_on,
|
||||
points: fn(b)
|
||||
}
|
||||
];
|
||||
},
|
||||
'render': function(_arg, cb) {
|
||||
var actual, height, ideal, line, m, mAxis, margin, svg, tooltip, trendline, width, x, xAxis, y, yAxis, _ref;
|
||||
actual = _arg[0], ideal = _arg[1], trendline = _arg[2];
|
||||
document.querySelector('#svg').innerHTML = '';
|
||||
_ref = document.querySelector('#chart').getBoundingClientRect(), height = _ref.height, width = _ref.width;
|
||||
margin = {
|
||||
top: 30,
|
||||
right: 30,
|
||||
bottom: 40,
|
||||
left: 50
|
||||
};
|
||||
width -= margin.left + margin.right;
|
||||
height -= margin.top + margin.bottom;
|
||||
x = d3.time.scale().range([0, width]);
|
||||
y = d3.scale.linear().range([height, 0]);
|
||||
xAxis = d3.svg.axis().scale(x).orient("bottom").tickSize(-height).tickFormat(function(d) {
|
||||
return d.getDate();
|
||||
}).tickPadding(10);
|
||||
yAxis = d3.svg.axis().scale(y).orient("left").tickSize(-width).ticks(5).tickPadding(10);
|
||||
line = d3.svg.line().interpolate("linear").x(function(d) {
|
||||
return x(d.date);
|
||||
}).y(function(d) {
|
||||
return y(d.points);
|
||||
});
|
||||
x.domain([ideal[0].date, ideal[ideal.length - 1].date]);
|
||||
y.domain([0, ideal[0].points]).nice();
|
||||
svg = d3.select("#svg").append("svg").attr("width", width + margin.left + margin.right).attr("height", height + margin.top + margin.bottom).append("g").attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
svg.append("g").attr("class", "x axis day").attr("transform", "translate(0," + height + ")").call(xAxis);
|
||||
m = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
mAxis = xAxis.orient("top").tickSize(height).tickFormat(function(d) {
|
||||
return m[d.getMonth()];
|
||||
}).ticks(2);
|
||||
svg.append("g").attr("class", "x axis month").attr("transform", "translate(0," + height + ")").call(mAxis);
|
||||
svg.append("g").attr("class", "y axis").call(yAxis);
|
||||
svg.append("svg:line").attr("class", "today").attr("x1", x(new Date())).attr("y1", 0).attr("x2", x(new Date())).attr("y2", height);
|
||||
svg.append("path").attr("class", "ideal line").attr("d", line.interpolate("basis")(ideal));
|
||||
svg.append("path").attr("class", "trendline line").attr("d", line.interpolate("linear")(trendline));
|
||||
svg.append("path").attr("class", "actual line").attr("d", line.interpolate("linear").y(function(d) {
|
||||
return y(d.points);
|
||||
})(actual));
|
||||
tooltip = d3.tip().attr('class', 'd3-tip').html(function(_arg1) {
|
||||
var number, title;
|
||||
number = _arg1.number, title = _arg1.title;
|
||||
return "#" + number + ": " + title;
|
||||
});
|
||||
svg.call(tooltip);
|
||||
svg.selectAll("a.issue").data(actual.slice(1)).enter().append('svg:a').attr("xlink:href", function(_arg1) {
|
||||
var html_url;
|
||||
html_url = _arg1.html_url;
|
||||
return html_url;
|
||||
}).attr("xlink:show", 'new').append('svg:circle').attr("cx", function(_arg1) {
|
||||
var date;
|
||||
date = _arg1.date;
|
||||
return x(date);
|
||||
}).attr("cy", function(_arg1) {
|
||||
var points;
|
||||
points = _arg1.points;
|
||||
return y(points);
|
||||
}).attr("r", function(_arg1) {
|
||||
var radius;
|
||||
radius = _arg1.radius;
|
||||
return 5;
|
||||
}).on('mouseover', tooltip.show).on('mouseout', tooltip.hide);
|
||||
return cb(null);
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
// firebase.coffee
|
||||
root.require.register('burnchart/src/modules/firebase.js', function(exports, require, module) {
|
||||
|
||||
var Class, config, user;
|
||||
|
||||
config = require('../models/config');
|
||||
|
||||
user = require('../models/user');
|
||||
|
||||
config = require('../models/config');
|
||||
|
||||
Class = (function() {
|
||||
function Class() {
|
||||
var _this = this;
|
||||
this.client = new Firebase("https://" + config.firebase + ".firebaseio.com");
|
||||
this.client = new Firebase("https://" + (config.get('firebase')) + ".firebaseio.com");
|
||||
this.auth = new FirebaseSimpleLogin(this.client, function(err, obj) {
|
||||
if (err || !obj) {
|
||||
return _this.authCb(err);
|
||||
@ -185,7 +387,7 @@
|
||||
return cb('Client is not setup');
|
||||
}
|
||||
this.authCb = cb;
|
||||
return this.auth.login(config.provider, {
|
||||
return this.auth.login(config.get('provider'), {
|
||||
'rememberMe': true,
|
||||
'scope': 'public_repo'
|
||||
});
|
||||
@ -207,6 +409,76 @@
|
||||
|
||||
});
|
||||
|
||||
// issues.coffee
|
||||
root.require.register('burnchart/src/modules/issues.js', function(exports, require, module) {
|
||||
|
||||
var config, request;
|
||||
|
||||
config = require('../models/config');
|
||||
|
||||
request = require('./request');
|
||||
|
||||
module.exports = {
|
||||
'get_all': function(opts, cb) {
|
||||
var one_status;
|
||||
one_status = function(state, cb) {
|
||||
var fetch_page, results;
|
||||
results = [];
|
||||
return (fetch_page = function(page) {
|
||||
return request.allIssues(opts, {
|
||||
state: state,
|
||||
page: page
|
||||
}, function(err, data) {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
if (!data.length) {
|
||||
return cb(null, results);
|
||||
}
|
||||
results = results.concat(_.sortBy(data, 'closed_at'));
|
||||
if (data.length < 100) {
|
||||
return cb(null, results);
|
||||
}
|
||||
return fetch_page(page + 1);
|
||||
});
|
||||
})(1);
|
||||
};
|
||||
return async.parallel([_.partial(one_status, 'open'), _.partial(one_status, 'closed')], cb);
|
||||
},
|
||||
'filter': function(collection, regex, cb) {
|
||||
var filtered, total;
|
||||
total = 0;
|
||||
switch (config.get('chart.points')) {
|
||||
case 'ALL_ONE_SIZE':
|
||||
total = collection.length;
|
||||
filtered = _.map(collection, function(issue) {
|
||||
issue.size = 1;
|
||||
return issue;
|
||||
});
|
||||
break;
|
||||
case 'LABELS':
|
||||
filtered = _.filter(collection, function(issue) {
|
||||
var labels;
|
||||
if (!(labels = issue.labels)) {
|
||||
return false;
|
||||
}
|
||||
issue.size = _.reduce(labels, function(sum, label) {
|
||||
var matches;
|
||||
if (!(matches = label.name.match(regex))) {
|
||||
return sum;
|
||||
}
|
||||
return sum += parseInt(matches[1]);
|
||||
}, 0);
|
||||
total += issue.size;
|
||||
return !!issue.size;
|
||||
});
|
||||
}
|
||||
return cb(null, filtered, total);
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
// mediator.coffee
|
||||
root.require.register('burnchart/src/modules/mediator.js', function(exports, require, module) {
|
||||
|
||||
@ -214,6 +486,124 @@
|
||||
|
||||
});
|
||||
|
||||
// milestones.coffee
|
||||
root.require.register('burnchart/src/modules/milestones.js', function(exports, require, module) {
|
||||
|
||||
var request;
|
||||
|
||||
request = require('./request');
|
||||
|
||||
module.exports = function(repo, cb) {
|
||||
var parse;
|
||||
parse = function(data) {
|
||||
if (data.description) {
|
||||
data.description = marked(data.description).slice(3, -5);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
if (repo.milestone) {
|
||||
return request.oneMilestone(repo, repo.milestone, function(err, m) {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
if (m.open_issues + m.closed_issues === 0) {
|
||||
return cb(null, "No issues for milestone `" + m.title + "`");
|
||||
}
|
||||
m = parse(m);
|
||||
return cb(null, null, m);
|
||||
});
|
||||
} else {
|
||||
return request.allMilestones(repo, function(err, data) {
|
||||
var m;
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
if (!data.length) {
|
||||
return cb(null, "No open milestones for repo " + repo.path);
|
||||
}
|
||||
m = data[0];
|
||||
m = _.rest(data, {
|
||||
'due_on': null
|
||||
});
|
||||
m = m[0] ? m[0] : data[0];
|
||||
if (m.open_issues + m.closed_issues === 0) {
|
||||
return cb(null, "No issues for milestone `" + m.title + "`");
|
||||
}
|
||||
m = parse(m);
|
||||
return cb(null, null, m);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
// project.coffee
|
||||
root.require.register('burnchart/src/modules/project.js', function(exports, require, module) {
|
||||
|
||||
var chart, issues, milestones;
|
||||
|
||||
milestones = require('./milestones');
|
||||
|
||||
issues = require('./issues');
|
||||
|
||||
chart = require('./chart');
|
||||
|
||||
module.exports = function(opts, cb) {
|
||||
return async.waterfall([
|
||||
function(cb) {
|
||||
return milestones(opts, function(err, warn, milestone) {
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
if (warn) {
|
||||
return cb(warn);
|
||||
}
|
||||
opts.milestone = milestone;
|
||||
return cb(null);
|
||||
});
|
||||
}, function(cb) {
|
||||
return issues.get_all(opts, cb);
|
||||
}, function(all, cb) {
|
||||
return async.map(all, function(array, cb) {
|
||||
return issues.filter(array, opts.size_label, function(err, filtered, total) {
|
||||
return cb(err, [filtered, total]);
|
||||
});
|
||||
}, function(err, _arg) {
|
||||
var closed, open;
|
||||
open = _arg[0], closed = _arg[1];
|
||||
if (err) {
|
||||
return cb(err);
|
||||
}
|
||||
if (open[1] + closed[1] === 0) {
|
||||
return cb('No matching issues found');
|
||||
}
|
||||
opts.issues = {
|
||||
closed: {
|
||||
'points': closed[1],
|
||||
'data': closed[0]
|
||||
},
|
||||
open: {
|
||||
'points': open[1],
|
||||
'data': open[0]
|
||||
}
|
||||
};
|
||||
return cb(null);
|
||||
});
|
||||
}, function(cb) {
|
||||
var total;
|
||||
total = opts.issues.open.points + opts.issues.closed.points;
|
||||
return async.parallel([_.partial(chart.actual, opts.issues.closed.data, opts.milestone.created_at, total), _.partial(chart.ideal, opts.milestone.created_at, opts.milestone.due_on, total)], function(err, values) {
|
||||
if (values[0].length) {
|
||||
values.push(chart.trendline(values[0], opts.milestone.created_at, opts.milestone.due_on));
|
||||
}
|
||||
return chart.render(values, cb);
|
||||
});
|
||||
}
|
||||
], cb);
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
// request.coffee
|
||||
root.require.register('burnchart/src/modules/request.js', function(exports, require, module) {
|
||||
|
||||
@ -244,8 +634,6 @@
|
||||
'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);
|
||||
@ -254,8 +642,6 @@
|
||||
'allMilestones': function(repo, cb) {
|
||||
var data;
|
||||
data = _.defaults({
|
||||
'protocol': repo.protocol,
|
||||
'host': repo.host,
|
||||
'path': "/repos/" + repo.owner + "/" + repo.name + "/milestones",
|
||||
'query': {
|
||||
'state': 'open',
|
||||
@ -267,9 +653,8 @@
|
||||
return request(data, cb);
|
||||
},
|
||||
'oneMilestone': function(repo, number, cb) {
|
||||
return request({
|
||||
'protocol': repo.protocol,
|
||||
'host': repo.host,
|
||||
var data;
|
||||
data = _.defaults({
|
||||
'path': "/repos/" + repo.owner + "/" + repo.name + "/milestones/" + number,
|
||||
'query': {
|
||||
'state': 'open',
|
||||
@ -277,18 +662,19 @@
|
||||
'direction': 'asc'
|
||||
},
|
||||
'headers': headers(user.get('token'))
|
||||
}, cb);
|
||||
}, defaults.github);
|
||||
return request(data, cb);
|
||||
},
|
||||
'allIssues': function(repo, query, cb) {
|
||||
return request({
|
||||
'protocol': repo.protocol,
|
||||
'host': repo.host,
|
||||
var data;
|
||||
data = _.defaults({
|
||||
'path': "/repos/" + repo.owner + "/" + repo.name + "/issues",
|
||||
'query': _.extend(query, {
|
||||
'per_page': '100'
|
||||
}),
|
||||
'headers': headers(user.get('token'))
|
||||
}, cb);
|
||||
}, defaults.github);
|
||||
return request(data, cb);
|
||||
}
|
||||
};
|
||||
|
||||
@ -597,11 +983,15 @@
|
||||
// showChart.coffee
|
||||
root.require.register('burnchart/src/views/pages/showChart.js', function(exports, require, module) {
|
||||
|
||||
var project;
|
||||
|
||||
project = require('../../modules/project');
|
||||
|
||||
module.exports = Ractive.extend({
|
||||
'template': require('../../templates/pages/showChart'),
|
||||
'adapt': [Ractive.adaptors.Ractive],
|
||||
init: function() {
|
||||
return console.log(this.get('route'));
|
||||
return project(this.get('route'));
|
||||
}
|
||||
});
|
||||
|
||||
|
32
src/models/config.coffee
Normal file
32
src/models/config.coffee
Normal file
@ -0,0 +1,32 @@
|
||||
Model = require '../utils/model'
|
||||
|
||||
module.exports = new Model
|
||||
|
||||
"data":
|
||||
# Firebase app name.
|
||||
"firebase": "burnchart"
|
||||
# Data source provider.
|
||||
"provider": "github"
|
||||
# Fields to keep from GH responses.
|
||||
"fields":
|
||||
"milestone": [
|
||||
"closed_issues"
|
||||
"created_at"
|
||||
"description"
|
||||
"due_on"
|
||||
"number"
|
||||
"open_issues"
|
||||
"title"
|
||||
"updated_at"
|
||||
]
|
||||
# Chart configuration.
|
||||
"chart":
|
||||
"off_days": [ 6, 7 ]
|
||||
# How do we parse GitHub dates?
|
||||
"datetime": /^(\d{4}-\d{2}-\d{2})T(.*)/
|
||||
# How does a size label look like?
|
||||
"size_label": /^size (\d+)$/
|
||||
# How do we specify which user/repo/(milestone) we want?
|
||||
"location": /^#!((\/[^\/]+){2,3})$/
|
||||
# Process all issues as one size.
|
||||
"points": 'ALL_ONE_SIZE'
|
@ -1,16 +0,0 @@
|
||||
{
|
||||
"firebase": "burnchart",
|
||||
"provider": "github",
|
||||
"fields": {
|
||||
"milestone": [
|
||||
"closed_issues",
|
||||
"created_at",
|
||||
"description",
|
||||
"due_on",
|
||||
"number",
|
||||
"open_issues",
|
||||
"title",
|
||||
"updated_at"
|
||||
]
|
||||
}
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
config = require '../models/config'
|
||||
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
|
||||
@ -32,7 +32,7 @@ module.exports = new Model
|
||||
return done err if err
|
||||
|
||||
# Pluck these fields for milestones.
|
||||
milestones = _.pluckMany res, config.fields.milestone
|
||||
milestones = _.pluckMany res, config.get('fields.milestone')
|
||||
|
||||
# Push to the stack.
|
||||
@push 'list', _.merge repo, { milestones }
|
||||
|
242
src/modules/chart.coffee
Normal file
242
src/modules/chart.coffee
Normal file
@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env coffee
|
||||
config = require '../models/config'
|
||||
|
||||
module.exports =
|
||||
|
||||
# A graph of closed issues.
|
||||
'actual': (collection, created_at, total, cb) ->
|
||||
head = [ {
|
||||
date: new Date(created_at)
|
||||
points: total
|
||||
} ]
|
||||
|
||||
min = +Infinity ; max = -Infinity
|
||||
|
||||
# Generate the actual closes.
|
||||
rest = _.map collection, (issue) ->
|
||||
{ size, closed_at } = issue
|
||||
# Determine the range.
|
||||
min = size if size < min
|
||||
max = size if size > max
|
||||
|
||||
# Dropping points remaining.
|
||||
_.extend {}, issue,
|
||||
date: new Date(closed_at)
|
||||
points: total -= size
|
||||
|
||||
# Now add a radius in a range (will be used for a circle).
|
||||
range = d3.scale.linear().domain([ min, max ]).range([ 5, 8 ])
|
||||
|
||||
rest = _.map rest, (issue) ->
|
||||
issue.radius = range issue.size
|
||||
issue
|
||||
|
||||
cb null, [].concat head, rest
|
||||
|
||||
# A graph of an ideal progression..
|
||||
# `a`: milestone start date
|
||||
# `b`: milestone end date
|
||||
# `total`: total number of points (open & closed issues)
|
||||
'ideal': (a, b, total, cb) ->
|
||||
# Swap?
|
||||
[ b, a ] = [ a, b ] if b < a
|
||||
|
||||
# We start here adding days to `d`.
|
||||
[ y, m, d ] = _.map a.match(config.get('chart.datetime'))[1].split('-'), (v) -> parseInt v
|
||||
# We want to end here.
|
||||
cutoff = new Date(b)
|
||||
|
||||
# Go through the beginning to the end skipping off days.
|
||||
days = [] ; length = 0
|
||||
do once = (inc = 0) ->
|
||||
# A new day.
|
||||
day = new Date y, m - 1, d + inc
|
||||
|
||||
# Does this day count?
|
||||
day_of = 7 if !day_of = day.getDay()
|
||||
if day_of in config.get('chart.off_days')
|
||||
days.push { date: day, off_day: yes }
|
||||
else
|
||||
length += 1
|
||||
days.push { date: day }
|
||||
|
||||
# Go again?
|
||||
once(inc + 1) unless day > cutoff
|
||||
|
||||
# Map points on the array of days now.
|
||||
velocity = total / (length - 1)
|
||||
|
||||
days = _.map days, (day, i) ->
|
||||
day.points = total
|
||||
total -= velocity if days[i] and not days[i].off_day
|
||||
day
|
||||
|
||||
# Do we need to make a link to right now?
|
||||
days.push { date: now, points: 0 } if (now = new Date()) > cutoff
|
||||
|
||||
cb null, days
|
||||
|
||||
# Graph representing a trendling of actual issues.
|
||||
'trendline': (actual, created_at, due_on) ->
|
||||
start = +actual[0].date
|
||||
|
||||
# Values is a list of time from the start and points remaining.
|
||||
values = _.map actual, ({ date, points }) ->
|
||||
[ +date - start, points ]
|
||||
|
||||
# Now is an actual point too.
|
||||
last = actual[actual.length - 1]
|
||||
values.push [ + new Date() - start, last.points ]
|
||||
|
||||
# http://classroom.synonym.com/calculate-trendline-2709.html
|
||||
b1 = 0 ; e = 0 ; c1 = 0
|
||||
a = (l = values.length) * _.reduce(values, (sum, [ a, b ]) ->
|
||||
b1 += a ; e += b
|
||||
c1 += Math.pow(a, 2)
|
||||
sum + (a * b)
|
||||
, 0)
|
||||
|
||||
slope = (a - (b1 * e)) / ((l * c1) - (Math.pow(b1, 2)))
|
||||
intercept = (e - (slope * b1)) / l
|
||||
fn = (x) -> slope * x + intercept
|
||||
|
||||
# Milestone always has a creation date.
|
||||
created_at = new Date created_at
|
||||
# Due date can be empty.
|
||||
due_on = if due_on then new Date(due_on) else new Date()
|
||||
|
||||
a = created_at - start
|
||||
b = due_on - start
|
||||
|
||||
[
|
||||
{
|
||||
date: created_at
|
||||
points: fn(a)
|
||||
}, {
|
||||
date: due_on
|
||||
points: fn(b)
|
||||
}
|
||||
]
|
||||
|
||||
# The graph as a whole.
|
||||
'render': ([ actual, ideal, trendline ], cb) ->
|
||||
document.querySelector('#svg').innerHTML = ''
|
||||
|
||||
# Get available space.
|
||||
{ height, width } = document.querySelector('#chart').getBoundingClientRect()
|
||||
|
||||
margin = { top: 30, right: 30, bottom: 40, left: 50 }
|
||||
width -= margin.left + margin.right
|
||||
height -= margin.top + margin.bottom
|
||||
|
||||
# Scales.
|
||||
x = d3.time.scale().range([ 0, width ])
|
||||
y = d3.scale.linear().range([ height, 0 ])
|
||||
|
||||
# Axes.
|
||||
xAxis = d3.svg.axis().scale(x)
|
||||
.orient("bottom")
|
||||
# Show vertical lines...
|
||||
.tickSize(-height)
|
||||
# ...with day of the month...
|
||||
.tickFormat( (d) -> d.getDate() )
|
||||
# ...and give us a spacer.
|
||||
.tickPadding(10)
|
||||
|
||||
yAxis = d3.svg.axis().scale(y)
|
||||
.orient("left")
|
||||
.tickSize(-width)
|
||||
.ticks(5)
|
||||
.tickPadding(10)
|
||||
|
||||
# Line generator.
|
||||
line = d3.svg.line()
|
||||
.interpolate("linear")
|
||||
.x( (d) -> x(d.date) )
|
||||
.y( (d) -> y(d.points) )
|
||||
|
||||
# Get the minimum and maximum date, and initial points.
|
||||
x.domain([ ideal[0].date, ideal[ideal.length - 1].date ])
|
||||
y.domain([ 0, ideal[0].points ]).nice()
|
||||
|
||||
# Add an SVG element with the desired dimensions and margin.
|
||||
svg = d3.select("#svg").append("svg")
|
||||
.attr("width", width + margin.left + margin.right)
|
||||
.attr("height", height + margin.top + margin.bottom)
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
|
||||
|
||||
# Add the days x-axis.
|
||||
svg.append("g")
|
||||
.attr("class", "x axis day")
|
||||
.attr("transform", "translate(0,#{height})")
|
||||
.call(xAxis)
|
||||
|
||||
# Add the months x-axis.
|
||||
m = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
]
|
||||
|
||||
mAxis = xAxis
|
||||
.orient("top")
|
||||
.tickSize(height)
|
||||
.tickFormat( (d) -> m[d.getMonth()] )
|
||||
.ticks(2)
|
||||
|
||||
svg.append("g")
|
||||
.attr("class", "x axis month")
|
||||
.attr("transform", "translate(0,#{height})")
|
||||
.call(mAxis)
|
||||
|
||||
# Add the y-axis.
|
||||
svg.append("g")
|
||||
.attr("class", "y axis")
|
||||
.call(yAxis)
|
||||
|
||||
# Add a line showing where we are now.
|
||||
svg.append("svg:line")
|
||||
.attr("class", "today")
|
||||
.attr("x1", x(new Date()))
|
||||
.attr("y1", 0)
|
||||
.attr("x2", x(new Date()))
|
||||
.attr("y2", height)
|
||||
|
||||
# Add the ideal line path.
|
||||
svg.append("path")
|
||||
.attr("class", "ideal line")
|
||||
.attr("d", line.interpolate("basis")(ideal))
|
||||
|
||||
# Add the trendline path.
|
||||
svg.append("path")
|
||||
.attr("class", "trendline line")
|
||||
.attr("d", line.interpolate("linear")(trendline))
|
||||
|
||||
# Add the actual line path.
|
||||
svg.append("path")
|
||||
.attr("class", "actual line")
|
||||
.attr("d", line.interpolate("linear").y( (d) -> y(d.points) )(actual))
|
||||
|
||||
# Collect the tooltip here.
|
||||
tooltip = d3.tip().attr('class', 'd3-tip').html ({ number, title }) ->
|
||||
"##{number}: #{title}"
|
||||
|
||||
svg.call(tooltip)
|
||||
|
||||
# Show when we closed an issue.
|
||||
svg.selectAll("a.issue")
|
||||
.data(actual[1...]) # skip the starting point
|
||||
.enter()
|
||||
|
||||
# A wrapping link.
|
||||
.append('svg:a')
|
||||
.attr("xlink:href", ({ html_url }) -> html_url )
|
||||
.attr("xlink:show", 'new')
|
||||
.append('svg:circle')
|
||||
.attr("cx", ({ date }) -> x date )
|
||||
.attr("cy", ({ points }) -> y points )
|
||||
.attr("r", ({ radius }) -> 5 ) # fixed for now
|
||||
.on('mouseover', tooltip.show)
|
||||
.on('mouseout', tooltip.hide)
|
||||
|
||||
cb null
|
@ -1,12 +1,12 @@
|
||||
config = require '../models/config'
|
||||
user = require '../models/user'
|
||||
config = require '../models/config'
|
||||
|
||||
# Default "silent" callback for auth.
|
||||
class Class
|
||||
|
||||
constructor: ->
|
||||
# Setup a new client.
|
||||
@client = new Firebase "https://#{config.firebase}.firebaseio.com"
|
||||
@client = new Firebase "https://#{config.get('firebase')}.firebaseio.com"
|
||||
|
||||
# Check if we have a user in session.
|
||||
@auth = new FirebaseSimpleLogin @client, (err, obj) =>
|
||||
@ -26,7 +26,7 @@ class Class
|
||||
@authCb = cb
|
||||
|
||||
# Login.
|
||||
@auth.login config.provider,
|
||||
@auth.login config.get('provider'),
|
||||
'rememberMe': yes
|
||||
'scope': 'public_repo'
|
||||
|
||||
|
67
src/modules/issues.coffee
Normal file
67
src/modules/issues.coffee
Normal file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env coffee
|
||||
config = require '../models/config'
|
||||
request = require './request'
|
||||
|
||||
module.exports =
|
||||
|
||||
# Used on an initial fetch of issues for a repo.
|
||||
'get_all': (opts, cb) ->
|
||||
# For each state...
|
||||
one_status = (state, cb) ->
|
||||
# Concat them here.
|
||||
results = []
|
||||
# One pageful fetch (next pages in series).
|
||||
do fetch_page = (page = 1) ->
|
||||
request.allIssues opts, { state, page }, (err, data) ->
|
||||
# Errors?
|
||||
return cb err if err
|
||||
# Empty?
|
||||
return cb null, results unless data.length
|
||||
# Concat sorted (API does not sort on closed_at!).
|
||||
results = results.concat _.sortBy data, 'closed_at'
|
||||
# < 100 results?
|
||||
return cb null, results if data.length < 100
|
||||
# Fetch the next page then.
|
||||
fetch_page page + 1
|
||||
|
||||
# For each `open` and `closed` issues in parallel.
|
||||
async.parallel [
|
||||
_.partial one_status, 'open'
|
||||
_.partial one_status, 'closed'
|
||||
], cb
|
||||
|
||||
# Filter an array of incoming issues based on a regex & save size on them.
|
||||
'filter': (collection, regex, cb) ->
|
||||
# The total size of all issues.
|
||||
total = 0
|
||||
|
||||
# Which point counting mode are we in?
|
||||
switch config.get 'chart.points'
|
||||
# All issues are the same size
|
||||
when 'ALL_ONE_SIZE'
|
||||
total = collection.length
|
||||
filtered = _.map collection, (issue) ->
|
||||
issue.size = 1
|
||||
issue
|
||||
|
||||
# Take the points size from issue label.
|
||||
when 'LABELS'
|
||||
filtered = _.filter collection, (issue) ->
|
||||
# Skip if no labels exist.
|
||||
return no unless labels = issue.labels
|
||||
|
||||
# Determine the total issue size from all labels.
|
||||
issue.size = _.reduce labels, (sum, label) ->
|
||||
# Not matching.
|
||||
return sum unless matches = label.name.match(regex)
|
||||
# Increase sum.
|
||||
sum += parseInt matches[1]
|
||||
, 0
|
||||
|
||||
# Increase the total.
|
||||
total += issue.size
|
||||
|
||||
# Are we saving it?
|
||||
!!issue.size
|
||||
|
||||
cb null, filtered, total
|
43
src/modules/milestones.coffee
Normal file
43
src/modules/milestones.coffee
Normal file
@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env coffee
|
||||
request = require './request'
|
||||
|
||||
# Get current/specified milestone for a repo.
|
||||
module.exports = (repo, cb) ->
|
||||
# Has description? Parse GFM.
|
||||
parse = (data) ->
|
||||
data.description = marked(data.description)[3...-5] if data.description
|
||||
data
|
||||
|
||||
# Get a specific milestone.
|
||||
if repo.milestone
|
||||
request.oneMilestone repo, repo.milestone, (err, m) ->
|
||||
# Errors?
|
||||
return cb err if err
|
||||
# Empty milestone?
|
||||
if m.open_issues + m.closed_issues is 0
|
||||
return cb null, "No issues for milestone `#{m.title}`"
|
||||
# Parse GFM.
|
||||
m = parse m
|
||||
|
||||
cb null, null, m
|
||||
|
||||
# Get the current milestone out of many.
|
||||
else
|
||||
request.allMilestones repo, (err, data) ->
|
||||
# Errors?
|
||||
return cb err if err
|
||||
# Empty warning?
|
||||
return cb null, "No open milestones for repo #{repo.path}" unless data.length
|
||||
# The first milestone should be ending soonest.
|
||||
m = data[0]
|
||||
# Filter milestones without due date.
|
||||
m = _.rest data, { 'due_on' : null }
|
||||
# The first milestone should be ending soonest. Prefer milestones with due dates.
|
||||
m = if m[0] then m[0] else data[0]
|
||||
# Empty milestone?
|
||||
if m.open_issues + m.closed_issues is 0
|
||||
return cb null, "No issues for milestone `#{m.title}`"
|
||||
# Parse GFM.
|
||||
m = parse m
|
||||
|
||||
cb null, null, m
|
67
src/modules/project.coffee
Normal file
67
src/modules/project.coffee
Normal file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env coffee
|
||||
milestones = require './milestones'
|
||||
issues = require './issues'
|
||||
chart = require './chart'
|
||||
|
||||
# Setup a project and render it.
|
||||
module.exports = (opts, cb) ->
|
||||
|
||||
# Resolve the milestone.
|
||||
async.waterfall [ (cb) ->
|
||||
milestones opts, (err, warn, milestone) ->
|
||||
return cb err if err
|
||||
return cb warn if warn
|
||||
opts.milestone = milestone
|
||||
cb null
|
||||
|
||||
# Get all issues.
|
||||
(cb) ->
|
||||
issues.get_all opts, cb
|
||||
|
||||
# Filter them to labeled ones.
|
||||
(all, cb) ->
|
||||
async.map all, (array, cb) ->
|
||||
issues.filter array, opts.size_label, (err, filtered, total) ->
|
||||
cb err, [ filtered, total ]
|
||||
, (err, [ open, closed ]) ->
|
||||
return cb err if err
|
||||
# Empty?
|
||||
return cb 'No matching issues found' if open[1] + closed[1] is 0
|
||||
# Save the open/closed on us first.
|
||||
opts.issues =
|
||||
closed: { 'points': closed[1], 'data': closed[0] }
|
||||
open: { 'points': open[1], 'data': open[0] }
|
||||
cb null
|
||||
|
||||
# Create actual and ideal lines & render.
|
||||
(cb) ->
|
||||
total = opts.issues.open.points + opts.issues.closed.points
|
||||
|
||||
async.parallel [
|
||||
_.partial(
|
||||
chart.actual, # actual line
|
||||
opts.issues.closed.data,
|
||||
opts.milestone.created_at,
|
||||
total
|
||||
)
|
||||
_.partial(
|
||||
chart.ideal, # ideal line
|
||||
opts.milestone.created_at,
|
||||
opts.milestone.due_on,
|
||||
total
|
||||
)
|
||||
], (err, values) ->
|
||||
# Generate a trendline?
|
||||
values.push(chart.trendline(
|
||||
values[0],
|
||||
opts.milestone.created_at,
|
||||
opts.milestone.due_on
|
||||
)) if values[0].length
|
||||
|
||||
# Render the chart.
|
||||
chart.render values, cb
|
||||
|
||||
# Watch window resize from now on?
|
||||
# window.onresize = doit if 'onresize' of window
|
||||
|
||||
], cb
|
@ -20,8 +20,6 @@ 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
|
||||
@ -31,8 +29,6 @@ module.exports =
|
||||
# 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')
|
||||
@ -42,23 +38,23 @@ module.exports =
|
||||
|
||||
# Get one open milestone.
|
||||
'oneMilestone': (repo, number, cb) ->
|
||||
request
|
||||
'protocol': repo.protocol
|
||||
'host': repo.host
|
||||
data = _.defaults
|
||||
'path': "/repos/#{repo.owner}/#{repo.name}/milestones/#{number}"
|
||||
'query': { 'state': 'open', 'sort': 'due_date', 'direction': 'asc' }
|
||||
'headers': headers user.get('token')
|
||||
, cb
|
||||
, defaults.github
|
||||
|
||||
request data, cb
|
||||
|
||||
# Get all issues for a state.
|
||||
'allIssues': (repo, query, cb) ->
|
||||
request
|
||||
'protocol': repo.protocol
|
||||
'host': repo.host
|
||||
data = _.defaults
|
||||
'path': "/repos/#{repo.owner}/#{repo.name}/issues"
|
||||
'query': _.extend query, { 'per_page': '100' }
|
||||
'headers': headers user.get('token')
|
||||
, cb
|
||||
, defaults.github
|
||||
|
||||
request data, cb
|
||||
|
||||
# Make a request using SuperAgent.
|
||||
request = ({ protocol, host, path, query, headers }, cb) ->
|
||||
|
@ -1,3 +1,5 @@
|
||||
project = require '../../modules/project'
|
||||
|
||||
module.exports = Ractive.extend
|
||||
|
||||
'template': require '../../templates/pages/showChart'
|
||||
@ -5,4 +7,4 @@ module.exports = Ractive.extend
|
||||
'adapt': [ Ractive.adaptors.Ractive ]
|
||||
|
||||
init: ->
|
||||
console.log @get 'route'
|
||||
project @get 'route'
|
Loading…
x
Reference in New Issue
Block a user