diff --git a/package.json b/package.json
index 423547d..b3010b1 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/js/App.jsx b/src/js/App.jsx
index 1392475..3baf6d7 100644
--- a/src/js/App.jsx
+++ b/src/js/App.jsx
@@ -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 ;
},
// Show a project milestone chart.
chart(owner, name, milestone) {
+ document.title = `${owner}/${name}/${milestone}`;
+ process.nextTick(() => {
+ actions.emit('projects.load', { owner, name, milestone });
+ });
return ;
},
diff --git a/src/js/components/Chart.jsx b/src/js/components/Chart.jsx
new file mode 100644
index 0000000..83ec7dc
--- /dev/null
+++ b/src/js/components/Chart.jsx
@@ -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 (
+
+
+
+
{this._title(milestone.title)}
+
{this._due(milestone.due_on)}
+
{this._markdown(milestone.description)}
+
+
+
+
+ );
+ },
+
+ componentDidMount() {
+ console.log(this.refs);
+ }
+
+});
diff --git a/src/js/mixins/Format.js b/src/js/mixins/Format.js
index 2607fcb..efa884b 100644
--- a/src/js/mixins/Format.js
+++ b/src/js/mixins/Format.js
@@ -22,7 +22,7 @@ export default {
// Markdown formatting.
// TODO: works?
_markdown(...args) {
- marked.apply(args);
+ marked.apply(null, args);
},
// Format milestone title.
diff --git a/src/js/mixins/lodash.js b/src/js/mixins/lodash.js
index 7e5d10d..2dd5459 100644
--- a/src/js/mixins/lodash.js
+++ b/src/js/mixins/lodash.js
@@ -13,10 +13,5 @@ _.mixin({
}
return obj;
});
- },
-
- isInt: (val) => {
- return !isNaN(val) && parseInt(Number(val)) === val &&
- !isNaN(parseInt(val, 10));
}
});
\ No newline at end of file
diff --git a/src/js/modules/chart/axes.js b/src/js/modules/chart/axes.js
new file mode 100644
index 0000000..8be77aa
--- /dev/null
+++ b/src/js/modules/chart/axes.js
@@ -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);
+ }
+
+};
diff --git a/src/js/modules/chart/lines.js b/src/js/modules/chart/lines.js
new file mode 100644
index 0000000..b31b971
--- /dev/null
+++ b/src/js/modules/chart/lines.js
@@ -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) }
+ ];
+ }
+
+};
diff --git a/src/js/modules/github/request.js b/src/js/modules/github/request.js
index ee652b4..e8d2723 100644
--- a/src/js/modules/github/request.js
+++ b/src/js/modules/github/request.js
@@ -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;
diff --git a/src/js/pages/ChartPage.jsx b/src/js/pages/ChartPage.jsx
index 2a4f60b..5c0fc66 100644
--- a/src/js/pages/ChartPage.jsx
+++ b/src/js/pages/ChartPage.jsx
@@ -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 = ;
+ }
+ }
+
return (