From 56aedfeb2396e82bc2c469a4afb6a8f46ecd0fda Mon Sep 17 00:00:00 2001 From: Radek Stepan Date: Tue, 19 Jan 2016 22:28:22 +0100 Subject: [PATCH] chart skeleton and tests --- package.json | 2 + src/js/App.jsx | 8 +- src/js/components/Chart.jsx | 38 ++++++++ src/js/mixins/Format.js | 2 +- src/js/mixins/lodash.js | 5 -- src/js/modules/chart/axes.js | 24 +++++ src/js/modules/chart/lines.js | 149 +++++++++++++++++++++++++++++++ src/js/modules/github/request.js | 38 +------- src/js/pages/ChartPage.jsx | 27 +++++- src/js/pages/MilestonesPage.jsx | 1 - src/js/stores/projectsStore.js | 117 ++++++++++++++---------- test/lines.js | 56 ++++++++++++ 12 files changed, 378 insertions(+), 89 deletions(-) create mode 100644 src/js/components/Chart.jsx create mode 100644 src/js/modules/chart/axes.js create mode 100644 src/js/modules/chart/lines.js create mode 100644 test/lines.js 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 (
-
+
{content}