chart skeleton and tests

This commit is contained in:
Radek Stepan 2016-01-19 22:28:22 +01:00
parent eec0259fea
commit 56aedfeb23
12 changed files with 378 additions and 89 deletions

View File

@ -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",

View File

@ -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} />;
},

View File

@ -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);
}
});

View File

@ -22,7 +22,7 @@ export default {
// Markdown formatting.
// TODO: works?
_markdown(...args) {
marked.apply(args);
marked.apply(null, args);
},
// Format milestone title.

View File

@ -13,10 +13,5 @@ _.mixin({
}
return obj;
});
},
isInt: (val) => {
return !isNaN(val) && parseInt(Number(val)) === val &&
!isNaN(parseInt(val, 10));
}
});

View File

@ -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);
}
};

View File

@ -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) }
];
}
};

View File

@ -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;

View File

@ -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">

View File

@ -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({

View File

@ -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));
}
}));
}

56
test/lines.js Normal file
View File

@ -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();
}
};