chart skeleton and tests
This commit is contained in:
parent
eec0259fea
commit
56aedfeb23
|
@ -8,6 +8,8 @@
|
|||
"dependencies": {
|
||||
"async": "^1.5.2",
|
||||
"classnames": "^2.2.3",
|
||||
"d3": "^3.5.12",
|
||||
"d3-tip": "^0.6.7",
|
||||
"deep-diff": "^0.3.3",
|
||||
"firebase": "^2.3.2",
|
||||
"lesshat": "^3.0.2",
|
||||
|
|
|
@ -93,12 +93,18 @@ export default React.createClass({
|
|||
// Show project milestones.
|
||||
milestones(owner, name) {
|
||||
document.title = `${owner}/${name}`;
|
||||
process.nextTick(() => { actions.emit('projects.load', { owner, name }); });
|
||||
process.nextTick(() => {
|
||||
actions.emit('projects.load', { owner, name });
|
||||
});
|
||||
return <MilestonesPage owner={owner} name={name} />;
|
||||
},
|
||||
|
||||
// Show a project milestone chart.
|
||||
chart(owner, name, milestone) {
|
||||
document.title = `${owner}/${name}/${milestone}`;
|
||||
process.nextTick(() => {
|
||||
actions.emit('projects.load', { owner, name, milestone });
|
||||
});
|
||||
return <ChartPage owner={owner} name={name} milestone={milestone} />;
|
||||
},
|
||||
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import d3 from 'd3';
|
||||
import d3Tip from 'd3-tip';
|
||||
|
||||
d3Tip(d3);
|
||||
|
||||
import Format from '../mixins/Format.js';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'Chart.jsx',
|
||||
|
||||
mixins: [ Format ],
|
||||
|
||||
render() {
|
||||
let milestone = this.props.milestone;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div id="title">
|
||||
<div className="wrap">
|
||||
<h2 className="title">{this._title(milestone.title)}</h2>
|
||||
<span className="sub">{this._due(milestone.due_on)}</span>
|
||||
<div className="description">{this._markdown(milestone.description)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="content" className="wrap">
|
||||
<div id="chart" ref="el" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
componentDidMount() {
|
||||
console.log(this.refs);
|
||||
}
|
||||
|
||||
});
|
|
@ -22,7 +22,7 @@ export default {
|
|||
// Markdown formatting.
|
||||
// TODO: works?
|
||||
_markdown(...args) {
|
||||
marked.apply(args);
|
||||
marked.apply(null, args);
|
||||
},
|
||||
|
||||
// Format milestone title.
|
||||
|
|
|
@ -13,10 +13,5 @@ _.mixin({
|
|||
}
|
||||
return obj;
|
||||
});
|
||||
},
|
||||
|
||||
isInt: (val) => {
|
||||
return !isNaN(val) && parseInt(Number(val)) === val &&
|
||||
!isNaN(parseInt(val, 10));
|
||||
}
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
import d3 from 'd3';
|
||||
|
||||
export default {
|
||||
|
||||
horizontal(height, x) {
|
||||
return d3.svg.axis().scale(x)
|
||||
.orient("bottom")
|
||||
// Show vertical lines...
|
||||
.tickSize(-height)
|
||||
// with day of the month...
|
||||
.tickFormat((d) => { return d.getDate(); })
|
||||
// and give us a spacer.
|
||||
.tickPadding(10);
|
||||
},
|
||||
|
||||
vertical(width, y) {
|
||||
return d3.svg.axis().scale(y)
|
||||
.orient("left")
|
||||
.tickSize(-width)
|
||||
.ticks(5)
|
||||
.tickPadding(10);
|
||||
}
|
||||
|
||||
};
|
|
@ -0,0 +1,149 @@
|
|||
import _ from 'lodash';
|
||||
import d3 from 'd3';
|
||||
import moment from 'moment';
|
||||
|
||||
import config from '../../models/config.js';
|
||||
|
||||
export default {
|
||||
|
||||
// A graph of closed issues.
|
||||
// `issues`: closed issues list
|
||||
// `created_at`: milestone start date
|
||||
// `total`: total number of points (open & closed issues)
|
||||
actual(issues, created_at, total) {
|
||||
let min, max;
|
||||
let head = [{
|
||||
'date': moment(created_at, moment.ISO_8601).toJSON(),
|
||||
'points': total
|
||||
}];
|
||||
|
||||
min = +Infinity , max = -Infinity;
|
||||
|
||||
// Generate the actual closes.
|
||||
let rest = _.map(issues, (issue) => {
|
||||
let { size, closed_at } = issue;
|
||||
// Determine the range.
|
||||
if (size < min) min = size;
|
||||
if (size > max) max = size;
|
||||
|
||||
// Dropping points remaining.
|
||||
issue.date = moment(closed_at, moment.ISO_8601).toJSON();
|
||||
issue.points = total -= size;
|
||||
return issue;
|
||||
});
|
||||
|
||||
// Now add a radius in a range (will be used for a circle).
|
||||
let range = d3.scale.linear().domain([ min, max ]).range([ 5, 8 ]);
|
||||
|
||||
rest = _.map(rest, (issue) => {
|
||||
issue.radius = range(issue.size);
|
||||
return issue;
|
||||
});
|
||||
|
||||
return [].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) {
|
||||
// Swap if end is before the start...
|
||||
if (b < a) [ b, a ] = [ a, b ];
|
||||
|
||||
a = moment(a, moment.ISO_8601);
|
||||
// Do we have a due date?
|
||||
b = (b != null) ? moment(b, moment.ISO_8601) : moment.utc();
|
||||
|
||||
// Go through the beginning to the end skipping off days.
|
||||
let days = [], length = 0, once;
|
||||
(once = (inc) => {
|
||||
// A new day. TODO: deal with hours and minutes!
|
||||
let day = a.add(1, 'days');
|
||||
|
||||
// Does this day count?
|
||||
let day_of;
|
||||
if (!(day_of = day.weekday())) day_of = 7;
|
||||
|
||||
if (config.chart.off_days.indexOf(day_of) != -1) {
|
||||
days.push({ 'date': day.toJSON(), 'off_day': true });
|
||||
} else {
|
||||
length += 1;
|
||||
days.push({ 'date': day.toJSON() });
|
||||
}
|
||||
|
||||
// Go again?
|
||||
if (!(day > b)) once(inc + 1);
|
||||
})(0);
|
||||
|
||||
// Map points on the array of days now.
|
||||
let velocity = total / (length - 1);
|
||||
|
||||
days = _.map(days, (day, i) => {
|
||||
day.points = total;
|
||||
if (days[i] && !days[i].off_day) total -= velocity;
|
||||
return day;
|
||||
});
|
||||
|
||||
// Do we need to make a link to right now?
|
||||
let now;
|
||||
if ((now = moment.utc()) > b) {
|
||||
days.push({ 'date': now.toJSON(), 'points': 0 });
|
||||
}
|
||||
|
||||
return days;
|
||||
},
|
||||
|
||||
// Graph representing a trendling of actual issues.
|
||||
trend(actual, created_at, due_on) {
|
||||
if (!actual.length) return [];
|
||||
|
||||
let first = actual[0], last = actual[actual.length - 1];
|
||||
|
||||
let start = moment(first.date, moment.ISO_8601);
|
||||
|
||||
// Values is a list of time from the start and points remaining.
|
||||
let values = _.map(actual, ({ date, points }) => {
|
||||
return [ moment(date, moment.ISO_8601).diff(start), points ];
|
||||
});
|
||||
|
||||
// Now is an actual point too.
|
||||
let now = moment.utc();
|
||||
values.push([ now.diff(start), last.points ]);
|
||||
|
||||
// http://classroom.synonym.com/calculate-trendline-2709.html
|
||||
let b1 = 0, e = 0, c1 = 0, l = values.length;
|
||||
let a = l * _.reduce(values, (sum, [ a, b ]) => {
|
||||
b1 += a; e += b;
|
||||
c1 += Math.pow(a, 2);
|
||||
return sum + (a * b);
|
||||
}, 0);
|
||||
|
||||
let slope = (a - (b1 * e)) / ((l * c1) - (Math.pow(b1, 2)));
|
||||
let intercept = (e - (slope * b1)) / l;
|
||||
|
||||
let fn = (x) => slope * x + intercept;
|
||||
|
||||
// Milestone always has a creation date.
|
||||
created_at = moment(created_at, moment.ISO_8601);
|
||||
|
||||
// Due date specified.
|
||||
if (due_on) {
|
||||
due_on = moment(due_on, moment.ISO_8601);
|
||||
// In the past?
|
||||
if (now > due_on) due_on = now;
|
||||
// No due date
|
||||
} else {
|
||||
due_on = now;
|
||||
}
|
||||
|
||||
a = created_at.diff(start);
|
||||
let b = due_on.diff(start);
|
||||
|
||||
return [
|
||||
{ 'date': created_at.toJSON(), 'points': fn(a) },
|
||||
{ 'date': due_on.toJSON(), 'points': fn(b) }
|
||||
];
|
||||
}
|
||||
|
||||
};
|
|
@ -28,10 +28,7 @@ let defaults = {
|
|||
export default {
|
||||
|
||||
// Get a repo.
|
||||
repo: (user, args, cb) => {
|
||||
if (!isValid(args)) return cb('Request is malformed');
|
||||
let { owner, name } = args;
|
||||
|
||||
repo: (user, { owner, name }, cb) => {
|
||||
let token = (user && user.github != null) ? user.github.accessToken : null;
|
||||
let data = _.defaults({
|
||||
'path': `/repos/${owner}/${name}`,
|
||||
|
@ -42,10 +39,7 @@ export default {
|
|||
},
|
||||
|
||||
// Get all open milestones.
|
||||
allMilestones: (user, args, cb) => {
|
||||
if (!isValid(args)) return cb('Request is malformed');
|
||||
let { owner, name } = args;
|
||||
|
||||
allMilestones: (user, { owner, name }, cb) => {
|
||||
let token = (user && user.github != null) ? user.github.accessToken : null;
|
||||
let data = _.defaults({
|
||||
'path': `/repos/${owner}/${name}/milestones`,
|
||||
|
@ -57,10 +51,7 @@ export default {
|
|||
},
|
||||
|
||||
// Get one open milestone.
|
||||
oneMilestone: (user, args, cb) => {
|
||||
if (!isValid(args)) return cb('Request is malformed');
|
||||
let { owner, name, milestone } = args;
|
||||
|
||||
oneMilestone: (user, { owner, name, milestone }, cb) => {
|
||||
let token = (user && user.github != null) ? user.github.accessToken : null;
|
||||
let data = _.defaults({
|
||||
'path': `/repos/${owner}/${name}/milestones/${milestone}`,
|
||||
|
@ -72,10 +63,7 @@ export default {
|
|||
},
|
||||
|
||||
// Get all issues for a state..
|
||||
allIssues: (user, args, query, cb) => {
|
||||
if (!isValid(args)) return cb('Request is malformed');
|
||||
let { owner, name, milestone } = args;
|
||||
|
||||
allIssues: (user, { owner, name, milestone }, query, cb) => {
|
||||
let token = (user && user.github != null) ? user.github.accessToken : null;
|
||||
let data = _.defaults({
|
||||
'path': `/repos/${owner}/${name}/issues`,
|
||||
|
@ -143,24 +131,6 @@ let headers = (token) => {
|
|||
return h;
|
||||
};
|
||||
|
||||
// Validate args.
|
||||
let isValid = (obj) => {
|
||||
let rules = {
|
||||
owner: (x) => { return (typeof x !== "undefined" && x !== null); },
|
||||
name: (x) => { return (typeof x !== "undefined" && x !== null); },
|
||||
milestone: (x) => { return _.isInt(x); } // mixin
|
||||
};
|
||||
|
||||
for (let key in obj) {
|
||||
let val = obj[key];
|
||||
if (key in rules && !rules[key](val)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Parse an error.
|
||||
let error = (err) => {
|
||||
let text, type;
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import Page from '../mixins/Page.js';
|
||||
|
||||
import Notify from '../components/Notify.jsx';
|
||||
import Header from '../components/Header.jsx';
|
||||
import Chart from '../components/Chart.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
|
@ -12,12 +14,35 @@ export default React.createClass({
|
|||
mixins: [ Page ],
|
||||
|
||||
render() {
|
||||
let content;
|
||||
if (!this.state.app.loading) {
|
||||
let projects = this.state.projects;
|
||||
// Find the milestone.
|
||||
let milestone;
|
||||
_.find(projects.list, (obj) => {
|
||||
if (obj.owner == this.props.owner && obj.name == this.props.name) {
|
||||
return _.find(obj.milestones, (m) => {
|
||||
if (m.number == this.props.milestone) {
|
||||
milestone = m;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (milestone) {
|
||||
content = <Chart milestone={milestone} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Notify />
|
||||
<Header {...this.state} />
|
||||
|
||||
<div id="page" />
|
||||
<div id="page">{content}</div>
|
||||
|
||||
<div id="footer">
|
||||
<div className="wrap">
|
||||
|
|
|
@ -5,7 +5,6 @@ import Page from '../mixins/Page.js';
|
|||
import Notify from '../components/Notify.jsx';
|
||||
import Header from '../components/Header.jsx';
|
||||
import Milestones from '../components/Milestones.jsx';
|
||||
import Hero from '../components/Hero.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
|
|
|
@ -59,61 +59,86 @@ class ProjectsStore extends Store {
|
|||
});
|
||||
}
|
||||
|
||||
// Fetch milestones and issues for a project(s).
|
||||
onProjectsLoad(project) {
|
||||
// Fetch milestones and issues for a project.
|
||||
getProject(user, p) {
|
||||
// Fetch their milestones.
|
||||
milestones.fetchAll(user, p, this.cb((err, milestones) => { // async
|
||||
// Save the error if project does not exist.
|
||||
if (err) return this.saveError(p, err);
|
||||
// Now add in the issues.
|
||||
milestones.forEach((milestone) => {
|
||||
// Do we have this milestone already? Skip fetching issues then.
|
||||
if (!_.find(p.milestones, ({ number }) => {
|
||||
return milestone.number === number;
|
||||
})) {
|
||||
// Fetch all the issues for this milestone.
|
||||
this.getIssues(user, p, milestone);
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// Fetch a single milestone.
|
||||
getMilestone(user, p, m) {
|
||||
// Fetch the single milestone.
|
||||
milestones.fetch(user, {
|
||||
'owner': p.owner,
|
||||
'name': p.name,
|
||||
'milestone': m
|
||||
}, this.cb((err, milestone) => { // async
|
||||
// Save the error if project does not exist.
|
||||
if (err) return this.saveError(p, err);
|
||||
// Now add in the issues.
|
||||
this.getIssues(user, p, milestone);
|
||||
}));
|
||||
}
|
||||
|
||||
// Fetch all issues for a milestone.
|
||||
getIssues(user, p, m) {
|
||||
issues.fetchAll(user, {
|
||||
'owner': p.owner,
|
||||
'name': p.name,
|
||||
'milestone': m.number
|
||||
}, this.cb((err, obj) => { // async
|
||||
// Save any errors on the project.
|
||||
if (err) return this.saveError(p, err);
|
||||
// Add in the issues to the milestone.
|
||||
_.extend(m, { 'issues': obj });
|
||||
// Save the milestone.
|
||||
this.addMilestone(p, m);
|
||||
}));
|
||||
}
|
||||
|
||||
// Fetch milestone(s) and issues for a project(s).
|
||||
onProjectsLoad(args) {
|
||||
let projects = this.get('list');
|
||||
|
||||
// Quit if we have no projects.
|
||||
if (!projects.length) return;
|
||||
|
||||
// Fetch milestones and issues for a project.
|
||||
let get = (user, p) => {
|
||||
// Fetch their milestones.
|
||||
milestones.fetchAll(user, p, this.cb((err, milestones) => { // async
|
||||
// Save the error if project does not exist.
|
||||
if (err) return this.saveError(p, err);
|
||||
// Now add in the issues.
|
||||
milestones.forEach((milestone) => {
|
||||
// Do we have this milestone already? Skip fetching issues then.
|
||||
if (_.find(p.milestones, ({ number }) => {
|
||||
return milestone.number === number;
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
// OK fetch all the issues for this milestone then.
|
||||
issues.fetchAll(user, {
|
||||
'owner': p.owner,
|
||||
'name': p.name,
|
||||
'milestone': milestone.number
|
||||
}, this.cb((err, obj) => { // async
|
||||
// Save any errors on the project.
|
||||
if (err) return this.saveError(p, err);
|
||||
// Add in the issues to the milestone.
|
||||
_.extend(milestone, { 'issues': obj });
|
||||
// Save the milestone.
|
||||
this.addMilestone(p, milestone);
|
||||
}));
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
// Wait for the user to get resolved.
|
||||
this.get('user', this.cb((user) => { // async
|
||||
if (project) {
|
||||
// For a single project.
|
||||
_.find(this.get('list'), (obj) => {
|
||||
if (project.owner == obj.owner && project.name == obj.name) {
|
||||
project = obj; // expand by saved properties
|
||||
return true;
|
||||
};
|
||||
return false;
|
||||
});
|
||||
// For a single project.
|
||||
get(user, project);
|
||||
if (args) {
|
||||
if ('milestone' in args) {
|
||||
// For a single milestone.
|
||||
this.getMilestone(user, {
|
||||
'owner': args.owner,
|
||||
'name': args.name
|
||||
}, args.milestone);
|
||||
} else {
|
||||
// For a single project.
|
||||
_.find(this.get('list'), (obj) => {
|
||||
if (args.owner == obj.owner && args.name == obj.name) {
|
||||
args = obj; // expand by saved properties
|
||||
return true;
|
||||
};
|
||||
return false;
|
||||
});
|
||||
this.getProject(user, args);
|
||||
}
|
||||
} else {
|
||||
// For all projects.
|
||||
projects.forEach(_.partial(get, user));
|
||||
projects.forEach(_.partial(this.getProject, user));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import { assert } from 'chai';
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash';
|
||||
|
||||
import lines from '../src/js/modules/chart/lines.js';
|
||||
|
||||
export default {
|
||||
'lines - actual': (done) => {
|
||||
let issues = [
|
||||
{ 'size': 3, 'date': 2 },
|
||||
{ 'size': 2, 'date': 3 },
|
||||
{ 'size': 1, 'date': 4 }
|
||||
];
|
||||
|
||||
let points = _.map(lines.actual(issues, 1, 6), ({ points }) => points);
|
||||
|
||||
assert.deepEqual(points, [6, 3, 1, 0]);
|
||||
|
||||
done();
|
||||
},
|
||||
|
||||
'lines - ideal': (done) => {
|
||||
let a = '2011-04-01T00:00:00Z';
|
||||
let b = '2011-04-03T00:00:00Z';
|
||||
|
||||
let line = lines.ideal(a, b, 4).slice(0, 3);
|
||||
|
||||
assert.deepEqual(line, [
|
||||
{ 'date': '2011-04-02T00:00:00.000Z', 'points': 4 },
|
||||
{ 'date': '2011-04-03T00:00:00.000Z', 'points': 2 },
|
||||
{ 'date': '2011-04-04T00:00:00.000Z', 'points': 0 }
|
||||
]);
|
||||
|
||||
done();
|
||||
},
|
||||
|
||||
'lines - trend': (done) => {
|
||||
let issues = [
|
||||
{ 'date': '2011-04-02T00:00:00.000Z', 'points': 4 },
|
||||
{ 'date': '2011-04-03T00:00:00.000Z', 'points': 1 },
|
||||
{ 'date': '2011-04-04T00:00:00.000Z', 'points': 1 }
|
||||
];
|
||||
|
||||
let opts = [
|
||||
issues,
|
||||
'2011-04-02T00:00:00.000Z',
|
||||
moment.utc()
|
||||
];
|
||||
|
||||
let line = _.map(lines.trend.apply(null, opts), ({ points }) => Math.round(points));
|
||||
|
||||
assert.deepEqual(line, [2, 1]);
|
||||
|
||||
done();
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue