diff --git a/.gitignore b/.gitignore index 92e1fd6..f90f822 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ .idea/ *.log src/components/ -build/ \ No newline at end of file +build/ +config.json \ No newline at end of file diff --git a/src/app.coffee b/src/app.coffee index 577e2d8..44d15f1 100644 --- a/src/app.coffee +++ b/src/app.coffee @@ -1,58 +1,14 @@ #!/usr/bin/env coffee -{ _ } = require 'lodash' -async = require 'async' -Rickshaw = require 'rickshaw' - -# Modules. -milestones = require './milestones' -issues = require './issues' -graph = require './graph' -reg = require './regex' - -# Eco templates as functions. -templates = {} -( templates[t] = require("./#{t}") for t in [ 'body', 'label' ] ) - -user = 'radekstepan' -repo = 'disposable' +{ Repos } = require './repos' module.exports = -> - milestones.get_current { user, repo }, (err, warn, m) -> - issues.get_all { user, repo, milestone: m.number }, (err, [ open, closed ]) -> - issues.filter closed, reg.size_label, (err, warn, closed) -> - async.parallel [ - _.partial(graph.actual, closed, m.created_at, 10) - _.partial(graph.ideal, m.created_at, m.due_on, 10) - ], (err, [ actual, ideal ]) -> - document.querySelector('body').innerHTML = templates.body({}) - - graph = new Rickshaw.Graph - 'element': document.querySelector('#graph') - 'renderer': 'line' - 'series': [ - { 'data': actual, 'color': '#73C03A', 'name': 'actual' } - { 'data': ideal, 'color': 'rgba(0,0,0,0.2)', 'name': 'ideal' } - ] - - hoverDetail = new Rickshaw.Graph.HoverDetail - 'graph': graph - 'xFormatter': (timestamp) -> - new Date(timestamp * 1e3).toUTCString().substring(0, 11) - - 'formatter': (series, timestamp, points) -> - templates.label { 'class': series.name, points } - - xAxis = new Rickshaw.Graph.Axis.Time 'graph': graph - - yAxis = new Rickshaw.Graph.Axis.Y - 'graph': graph - 'orientation': 'left' - 'tickFormat': Rickshaw.Fixtures.Number.formatKMBT - - annotator = new Rickshaw.Graph.Annotate - 'graph': graph - 'element': document.querySelector('#timeline') - - annotator.add +new Date / 1e3, 'Now' - - graph.render() \ No newline at end of file + # A new repo collection. + collection = new Repos() + # Get the coll/config. + collection.fetch (err) -> + throw err if err + # Use the head. + repo = collection.at(0) + # Render the repo. + repo.render (err) -> + throw err if err \ No newline at end of file diff --git a/src/component.json b/src/component.json index 1d1d555..7199e74 100644 --- a/src/component.json +++ b/src/component.json @@ -5,8 +5,9 @@ "dependencies": { "bestiejs/lodash": "*", "caolan/async": "*", - "cristiandouce/rickshaw": "*", - "visionmedia/superagent": "*" + "mbostock/d3": "*", + "visionmedia/superagent": "*", + "necolas/normalize.css": "*" }, "scripts": [ "app.coffee", @@ -15,6 +16,7 @@ "modules/milestones.coffee", "modules/regex.coffee", "modules/request.coffee", + "modules/repos.coffee", "templates/body.eco", "templates/label.eco" ], diff --git a/src/modules/graph.coffee b/src/modules/graph.coffee index 708786d..8d34c35 100644 --- a/src/modules/graph.coffee +++ b/src/modules/graph.coffee @@ -1,5 +1,6 @@ #!/usr/bin/env coffee { _ } = require 'lodash' +d3 = require 'd3' reg = require './regex' @@ -7,9 +8,9 @@ module.exports = # Map closed issues ready to be visualized by Rickshaw. # Assumes collection has been `filter`ed and is ordered. 'actual': (collection, created_at, total, cb) -> - head = [ { x: +new Date(created_at) / 1e3, y: total } ] + head = [ { date: new Date(created_at), points: total } ] rest = _.map collection, ({ closed_at, size }) -> - { x: +new Date(closed_at) / 1e3, y: total -= size } + { date: new Date(closed_at), points: total -= size } cb null, head.concat rest # Map ideal velocity for each day ready to be visualized by Rickshaw. @@ -17,21 +18,23 @@ module.exports = # Swap? [ b, a ] = [ a, b ] if b < a + return cb null, [ + { date: new Date(a), points: total } + { date: new Date(b), points: 0 } + ] + # When do we start & end? [ year, month, day ] = _.map(a.match(reg.datetime)[1].split('-'), (d) -> parseInt(d) ) - b = b.match(reg.datetime)[1] # The head/tail are quite specific. - head = { x: +new Date(a) / 1e3, y: total } - tail = { x: b = +new Date(b) / 1e3, y: 0 } + head = { date: new Date(a), points: total } + tail = { date: b = new Date(b.match(reg.datetime)[1]), points: 0 } # The fillers... days = [] do add = (i = 1) -> - # Lunchtime to "handle" daylight saving. - c = +new Date year, month - 1, day + i, 12 - # Add the time point. - days.push { x: c / 1e3 } + # Add the time point at lunchtime. + days.push { date: c = new Date(year, month - 1, day + i, 12) } # Moar? add(i + 1) if c < b @@ -39,7 +42,81 @@ module.exports = daily = total / (days.length + 1) # Map points to days. days = _.map days, (day) -> - day.y = total -= daily + day.points = total -= daily day - cb null, [ head ].concat(days).concat([ tail ]) \ No newline at end of file + cb null, [ head ].concat(days).concat([ tail ]) + + 'render': ([ actual, ideal ], cb) -> + # Get available space. + { height, width } = document.querySelector('#graph').getBoundingClientRect() + + margin = { top: 20, right: 20, bottom: 20, left: 20 } + width -= margin.left + margin.right + height -= margin.top + margin.bottom + + # Scales and axis. + x = d3.time.scale().range([ 0, width ]) + y = d3.scale.linear().range([ height, 0 ]) + + xAxis = d3.svg.axis().scale(x) + # Show vertical lines... + .tickSize(-height) + # ...with day of the month... + .tickFormat( (d) -> d.getDate() ) + # ...once per day. + .ticks(d3.time.hours, 24) + + # Area generator. + area = d3.svg.area() + .interpolate("monotone") + .x( (d) -> x(d.date) ) + .y0(height) + .y1( (d) -> y(d.points) ) + + # Line generator. + line = d3.svg.line() + .interpolate("basis") + .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("#graph").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 clip path. + svg.append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width) + .attr("height", height) + + # Add the area path. + svg.append("path") + .attr("class", "area") + .attr("d", area(ideal)) + + # Add the x-axis. + svg.append("g") + .attr("class", "x axis") + .attr("transform", "translate(0,#{height})") + .call(xAxis) + + # Add the ideal line path. + svg.append("path") + .attr("class", "ideal line") + .attr("d", line(ideal)) + + # Add the actual line path. + svg.append("path") + .attr("class", "actual line") + .attr("d", line(actual)) + + cb null \ No newline at end of file diff --git a/src/modules/issues.coffee b/src/modules/issues.coffee index 16c39c4..a75bede 100644 --- a/src/modules/issues.coffee +++ b/src/modules/issues.coffee @@ -7,19 +7,17 @@ reg = require './regex' module.exports = # Used on an initial fetch of issues for a repo. - 'get_all': ({ user, repo, milestone }, cb) -> + 'get_all': (repo, cb) -> # For each state... one_status = (state, cb) -> # Concat them here. results = [] # One pageful fetch (next pages in series). do fetch_page = (page = 1) -> - req.all_issues { - user - repo - milestone - state: state - page: page + req.all_issues repo, { + milestone: repo.milestone.number + state + page }, (err, data) -> # Request errors. return cb err if err @@ -27,8 +25,8 @@ module.exports = return cb data.message if data.message # Empty? return cb null, results unless data.length - # Concat. - results = results.concat data + # 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. @@ -42,7 +40,7 @@ module.exports = # Filter an array of incoming issues based on a regex & save size on them. 'filter': (collection, regex, cb) -> - warnings = null + warnings = null ; total = 0 try filtered = _.filter collection, (issue) -> { labels, number } = issue @@ -52,14 +50,14 @@ module.exports = when 0 then false when 1 # Provide the size attribute on the issue. - issue.size = parseInt name.match(regex)[1] + total += issue.size = parseInt name.match(regex)[1] true else warnings ?= [] warnings.push "Issue ##{number} has multiple matching size labels" true - cb null, warnings, filtered + cb null, warnings, filtered, total catch err return cb err, warnings diff --git a/src/modules/milestones.coffee b/src/modules/milestones.coffee index cbd9ef7..823f844 100644 --- a/src/modules/milestones.coffee +++ b/src/modules/milestones.coffee @@ -3,8 +3,8 @@ req = require './request' module.exports = # Used at initialization stage. - 'get_current': (opts, cb) -> - req.all_milestones opts, (err, data) -> + 'get_current': (repo, cb) -> + req.all_milestones repo, (err, data) -> # Request errors. return cb err if err # GitHub errors. diff --git a/src/modules/repos.coffee b/src/modules/repos.coffee new file mode 100644 index 0000000..8a45826 --- /dev/null +++ b/src/modules/repos.coffee @@ -0,0 +1,74 @@ +#!/usr/bin/env coffee +{ _ } = require 'lodash' +async = require 'async' + +milestones = require './milestones' +issues = require './issues' +graph = require './graph' +reg = require './regex' +req = require './request' + +# Eco templates as functions. +templates = {} ; ( templates[t] = require("./#{t}") for t in [ 'body', 'label' ] ) + +class exports.Repos + + constructor: -> + @models = [] + + fetch: (cb) -> + self = @ + req.config (err, config) -> + return cb err if err + self.models = ( new Repo(entry) for entry in config ) + cb null + + at: (index) -> + @models[index] + + +class Repo + + constructor: (opts) -> + ( @[k] = v for k, v of opts ) + + render: (cb) -> + self = @ + + async.waterfall [ (cb) -> + # Get the current milestone. + milestones.get_current self, (err, warn, milestone) -> + self.milestone = milestone + cb err + + # Get all issues. + (cb) -> + issues.get_all self, cb + + # Filter them to labeled ones. + (all, cb) -> + async.map all, (array, cb) -> + issues.filter array, reg.size_label, (err, warn, filtered, total) -> + cb err, [ filtered, total ] + , (err, [ open, closed ]) -> + return cb err if err + # Save the open/closed on us first. + self.issues = + closed: { points: closed[1], data: closed[0] } + open: { points: open[1], data: open[0] } + cb null + + # Create actual and ideal lines & render. + (cb) -> + progress = 100 * self.issues.closed.points / + (total = self.issues.open.points + self.issues.closed.points) + + async.parallel [ + _.partial(graph.actual, self.issues.closed.data, self.milestone.created_at, total) + _.partial(graph.ideal, self.milestone.created_at, self.milestone.due_on, total) + ], (err, values) -> + document.querySelector('body').innerHTML = templates.body { progress } + + graph.render values, cb + + ], cb \ No newline at end of file diff --git a/src/modules/request.coffee b/src/modules/request.coffee index 916f314..5d27863 100644 --- a/src/modules/request.coffee +++ b/src/modules/request.coffee @@ -1,27 +1,39 @@ #!/usr/bin/env coffee +sa = require 'superagent' { _ } = require 'lodash' -protocol = 'https' -domain = 'api.github.com' -token = '' - module.exports = - 'all_milestones': ({ user, repo }, cb) -> - opts = { state: 'open', sort: 'due_date', direction: 'asc' } - request { user, repo, opts, path: 'milestones' }, cb + # Get all milestones. + 'all_milestones': (repo, cb) -> + query = { state: 'open', sort: 'due_date', direction: 'asc' } + request repo, query, 'milestones', cb - 'all_issues': ({ user, repo }, cb) -> - opts = _.extend {}, arguments[0], { per_page: '100', direction: 'asc' } - request { user, repo, opts, path: 'issues' }, cb + # Get all issues for a state. + 'all_issues': (repo, query, cb) -> + _.extend query, { per_page: '100' } + request repo, query, 'issues', cb + + # Get config from our domain always. + 'config': (cb) -> + sa + .get("http://#{window.location.host}/config.json") + .set('Content-Type', 'application/json') + .end (err, data) -> + cb err, data?.body # Make a request using SuperAgent. -request = ({ user, repo, path, opts }, cb) -> - opts = ( "#{k}=#{v}" for k, v of opts when k not in [ 'user', 'repo' ] ).join('&') +request = ({ domain, token, user, repo }, query, path, cb) -> + # Make the query params. + q = ( "#{k}=#{v}" for k, v of query ).join('&') - (require 'superagent') - .get("#{protocol}://#{domain}/repos/#{user}/#{repo}/#{path}?#{opts}") + req = sa + .get("https://#{domain}/repos/#{user}/#{repo}/#{path}?#{q}") .set('Content-Type', 'application/json') .set('Accept', 'application/vnd.github.raw') - .set('Authorization', "token #{token}") - .end (err, data) -> + + # Auth token? + req = req.set('Authorization', "token #{token}") if token + + # Send. + req.end (err, data) -> cb err, data?.body \ No newline at end of file diff --git a/src/styles/app.styl b/src/styles/app.styl index 5d8a802..e7652a8 100644 --- a/src/styles/app.styl +++ b/src/styles/app.styl @@ -1,5 +1,93 @@ +$closed = #4ACAB4 +$opened = #FE5D55 + body + background: #31323A padding: 100px +#box + background: #43444f + border-radius: 6px + box-shadow: 2px 4px 6px rgba(0,0,0,0.2) + +#progress + padding: 20px + border-radius: 0 0 6px 6px + + &:after + clear: both + display: block + content: "" + + .bars + position: relative + + div + border-radius: 6px + height: 12px + + &.closed + position: absolute + top: 0 + left: 0 + background: $closed + + &:not(.done) + border-radius: 6px 0 0 6px + + &.opened + width: 100% + background: $opened + + h2 + font-size: 14px + text-transform: uppercase + margin: 10px 0 0 0 + + &.closed + float: left + color: $closed + + &.opened + float: right + color: $opened + #graph - height: 200px \ No newline at end of file + background: #FFF + border-radius: 6px 6px 0 0 + height: 200px + + svg + path + &.line + fill: none + stroke: #CBC8C3 + stroke-width: 1px + clip-path: url(#clip) + + &.actual + stroke-width: 2px + stroke: $closed + + &.area + clip-path: url(#clip) + fill: #FAFAF8 + + .axis + shape-rendering: crispEdges + + &.x + line + stroke: #EBEBE9 + + text + font-weight: bold + fill: #CBC8C3 + + path + display: none + + &.y + line, path + fill: none + stroke: #000 \ No newline at end of file diff --git a/src/templates/body.eco b/src/templates/body.eco index e44b614..2eae46b 100644 --- a/src/templates/body.eco +++ b/src/templates/body.eco @@ -1,2 +1,15 @@ -
- \ No newline at end of file +