Merge pull request #107 from radekstepan/react
switch from Ractive to React
This commit is contained in:
commit
e76611078e
|
@ -1,10 +1,7 @@
|
|||
node_modules/
|
||||
public/js/.bundle.js
|
||||
public/css/.bundle.css
|
||||
public/js/.bundle.min.js
|
||||
public/css/.bundle.min.css
|
||||
/node_modules
|
||||
*.log
|
||||
.build_cache~
|
||||
.grunt/
|
||||
build/*.map
|
||||
public/js/.app.bundle.js
|
||||
public/js/app.js
|
||||
public/css/.app.bundle.css
|
||||
public/css/app.css
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
|
|
@ -1,69 +0,0 @@
|
|||
module.exports = (grunt) ->
|
||||
grunt.initConfig
|
||||
|
||||
pkg: grunt.file.readJSON("package.json")
|
||||
|
||||
'clean':
|
||||
public: [
|
||||
'public/js'
|
||||
'public/css'
|
||||
]
|
||||
pages: [
|
||||
'.grunt'
|
||||
]
|
||||
|
||||
'mkdir':
|
||||
all:
|
||||
options:
|
||||
create: [
|
||||
'public/js'
|
||||
'public/css'
|
||||
]
|
||||
|
||||
'uglify':
|
||||
bundle:
|
||||
files:
|
||||
'public/js/app.bundle.min.js': 'public/js/app.bundle.js'
|
||||
|
||||
'cssmin':
|
||||
bundle:
|
||||
files:
|
||||
'public/css/app.bundle.min.css': 'public/css/app.bundle.css'
|
||||
|
||||
'gh-pages':
|
||||
options:
|
||||
base: 'public'
|
||||
branch: 'gh-pages'
|
||||
message: 'Publish to GitHub Pages'
|
||||
push: yes
|
||||
add: yes
|
||||
src: [
|
||||
'css/**/*'
|
||||
'fonts/**/*'
|
||||
'img/**/*'
|
||||
'js/**/*'
|
||||
]
|
||||
|
||||
grunt.loadNpmTasks('grunt-mkdir')
|
||||
grunt.loadNpmTasks('grunt-contrib-clean')
|
||||
grunt.loadNpmTasks('grunt-contrib-uglify')
|
||||
grunt.loadNpmTasks('grunt-contrib-cssmin')
|
||||
grunt.loadNpmTasks('grunt-gh-pages')
|
||||
|
||||
# Cleanup public directories.
|
||||
grunt.registerTask('init', [
|
||||
'clean:public'
|
||||
'mkdir'
|
||||
])
|
||||
|
||||
# Minify JS, CSS and concat JS.
|
||||
grunt.registerTask('minify', [
|
||||
'uglify'
|
||||
'cssmin'
|
||||
])
|
||||
|
||||
# Publish to GitHub Pages.
|
||||
grunt.registerTask('pages', [
|
||||
'gh-pages'
|
||||
'clean:pages'
|
||||
])
|
|
@ -0,0 +1,44 @@
|
|||
WATCHIFY = ./node_modules/.bin/watchify
|
||||
WATCH = ./node_modules/.bin/watch
|
||||
LESS = ./node_modules/.bin/lessc
|
||||
BROWSERIFY = ./node_modules/.bin/browserify
|
||||
UGLIFY = ./node_modules/.bin/uglifyjs
|
||||
CLEANCSS = ./node_modules/.bin/cleancss
|
||||
MOCHA = ./node_modules/.bin/mocha
|
||||
BIN = ./bin/burnchart.js
|
||||
|
||||
MOCHA-OPTS = --compilers js:babel-register --ui exports --timeout 5000 --bail
|
||||
|
||||
start:
|
||||
${BIN}
|
||||
|
||||
start-dev:
|
||||
${BIN} --dev
|
||||
|
||||
watch-js: build-js
|
||||
${WATCHIFY} -e -s burnchart ./src/js/index.jsx -t babelify -o public/js/bundle.js -d -v
|
||||
|
||||
watch-css: build-css
|
||||
${WATCH} "${MAKE} build-css" src/less
|
||||
|
||||
watch:
|
||||
${MAKE} watch-js & ${MAKE} watch-css
|
||||
|
||||
build-js:
|
||||
${BROWSERIFY} -e -s burnchart ./src/js/index.jsx -t babelify > public/js/bundle.js
|
||||
|
||||
build-css:
|
||||
${LESS} src/less/burnchart.less > public/css/bundle.css
|
||||
|
||||
build: build-js build-css
|
||||
|
||||
minify-js:
|
||||
${UGLIFY} public/js/bundle.js > public/js/bundle.min.js
|
||||
|
||||
minify-css:
|
||||
${CLEANCSS} public/css/bundle.css > public/css/bundle.min.css
|
||||
|
||||
test:
|
||||
${MOCHA} ${MOCHA-OPTS} --reporter spec
|
||||
|
||||
.PHONY: test
|
55
README.md
55
README.md
|
@ -3,11 +3,10 @@
|
|||
GitHub Burndown Chart as a Service. Answers the question "are my projects on track"?
|
||||
|
||||
![Build Status](http://img.shields.io/codeship/5645c5d0-4b7e-0132-641d-623ee7e48d08/master.svg?style=flat)
|
||||
[![Coverage](http://img.shields.io/coveralls/radekstepan/burnchart/master.svg?style=flat)](<https://coveralls.io/r/radekstepan/burnchart>)
|
||||
[![Dependencies](http://img.shields.io/david/radekstepan/burnchart.svg?style=flat)](https://david-dm.org/radekstepan/burnchart)
|
||||
[![License](http://img.shields.io/badge/license-AGPL--3.0-red.svg?style=flat)](LICENSE)
|
||||
|
||||
![image](https://raw.githubusercontent.com/radekstepan/burnchart/master/public/screenshots.jpg)
|
||||
![image](https://raw.githubusercontent.com/radekstepan/burnchart/master/screenshots.jpg)
|
||||
|
||||
##Features
|
||||
|
||||
|
@ -18,64 +17,56 @@ GitHub Burndown Chart as a Service. Answers the question "are my projects on tra
|
|||
1. **Trend line**; to see if you can make it to the deadline at this pace.
|
||||
1. Different **point counting** strategies; select from 1 issues = 1 point or read size from issue label.
|
||||
|
||||
##Quick Start
|
||||
##Quickstart
|
||||
|
||||
```bash
|
||||
$ npm install burnchart -g
|
||||
$ burnchart 8080
|
||||
# burnchart/2.0.8 started on port 8080
|
||||
$ burnchart --port 8080
|
||||
# burnchart/3.0.0 started on port 8080
|
||||
```
|
||||
|
||||
##Configuration
|
||||
|
||||
At the moment, there is no ui exposed to change the app settings. You have to edit the `src/models/config.coffee` file.
|
||||
At the moment, there is no ui exposed to change the app settings. You have to edit the `src/config.js` file.
|
||||
|
||||
An array of days when we are not working where Monday = 1. The ideal progression line won't *drop* on these days.
|
||||
|
||||
```coffeescript
|
||||
```js
|
||||
"off_days": [ ]
|
||||
```
|
||||
|
||||
Choose from `ONE_SIZE` which means each issue is worth 1 point or `LABELS` where issue labels determine its size.
|
||||
|
||||
```coffeescript
|
||||
```js
|
||||
"points": "ONE_SIZE"
|
||||
```
|
||||
|
||||
If you specify `LABELS` above, this is the place to set a regex used to parse a label and extract points size from it. When multiple matching size labels exist, their sum is taken.
|
||||
|
||||
```coffeescript
|
||||
```js
|
||||
"size_label": /^size (\d+)$/
|
||||
```
|
||||
|
||||
##Development
|
||||
|
||||
[Rake](https://www.ruby-lang.org/en/documentation/installation/) is used as a tool to execute tasks, the steps would be roughly as follows:
|
||||
To run your local version of the app, install all the NPM dependencies, watch the source files in one window, and start the static file server in the other in `--dev` mode.
|
||||
|
||||
```bash
|
||||
apt-get install ruby-full
|
||||
gem install rake
|
||||
rake build
|
||||
rake serve
|
||||
$ nvm use
|
||||
$ npm install
|
||||
$ make watch
|
||||
$ make start-dev
|
||||
# burnchart/3.0.0 (dev) started on port 8080
|
||||
```
|
||||
|
||||
You can run the following tasks:
|
||||
###GitHub Pages
|
||||
|
||||
To serve the app from GitHub Pages that are in sync with master branch, add these two lines to `.git/config`, in the `[remote "origin"]` section:
|
||||
|
||||
```bash
|
||||
rake build # Build everything & minify
|
||||
rake build:css # Build the styles with LESS
|
||||
rake build:js # Build the app with Browserify
|
||||
rake build:minify # Minify build for production
|
||||
rake commit[message] # Build app and make a commit with latest changes
|
||||
rake install # Install dependencies with NPM
|
||||
rake publish # Publish to GitHub Pages
|
||||
rake serve # Start a web server on port 8080
|
||||
rake test # Run tests with mocha
|
||||
rake test:coverage # Run code coverage, mocha with Blanket.js
|
||||
rake test:coveralls[token] # Run code coverage and publish to Coveralls
|
||||
rake watch # Watch everything
|
||||
rake watch:css # Watch the styles
|
||||
rake watch:js # Watch the app
|
||||
```
|
||||
|
||||
Please read the [Architecture](docs/ARCHITECTURE.md) document when contributing code.
|
||||
[remote "origin"]
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
url = git@github.com:user/repo.git
|
||||
push = +refs/heads/master:refs/heads/gh-pages
|
||||
push = +refs/heads/master:refs/heads/master
|
||||
```
|
||||
|
|
96
Rakefile
96
Rakefile
|
@ -1,96 +0,0 @@
|
|||
GRUNT = "./node_modules/.bin/grunt"
|
||||
|
||||
task :default => "build"
|
||||
|
||||
desc "Install dependencies with NPM"
|
||||
task :install do
|
||||
sh "npm install"
|
||||
end
|
||||
|
||||
desc "Build everything & minify"
|
||||
task :build => [ "build:js", "build:css", "build:minify" ] do end
|
||||
|
||||
desc "Watch everything."
|
||||
multitask :watch => [ "watch:js", "watch:css" ]
|
||||
|
||||
desc "Run tests with mocha"
|
||||
task :test do
|
||||
sh "#{MOCHA} #{OPTS} --reporter spec"
|
||||
end
|
||||
|
||||
desc "Start a web server on port 8080"
|
||||
task :serve do
|
||||
SERVER = "./node_modules/.bin/static"
|
||||
|
||||
sh "#{SERVER} public -H '{\"Cache-Control\": \"no-cache, must-revalidate\"}'"
|
||||
end
|
||||
|
||||
desc "Publish to GitHub Pages"
|
||||
task :publish do
|
||||
sh "#{GRUNT} pages"
|
||||
end
|
||||
|
||||
desc "Build app and make a commit with latest changes"
|
||||
task :commit, [ :message ] => [ "build" ] do |t, args|
|
||||
args.with_defaults(:message => ":speech_balloon")
|
||||
|
||||
sh "git add -A"
|
||||
sh "git commit -am \"#{args.message}\""
|
||||
sh "git push -u origin master"
|
||||
end
|
||||
|
||||
namespace :watch do
|
||||
WATCHIFY = "./node_modules/.bin/watchify"
|
||||
WATCH = "./node_modules/.bin/watch"
|
||||
|
||||
desc "Watch the app"
|
||||
task :js do
|
||||
sh "#{WATCHIFY} -e ./src/app.coffee -o public/js/app.bundle.js -d -v"
|
||||
end
|
||||
|
||||
desc "Watch the styles"
|
||||
task :css => [ "build:css" ] do
|
||||
sh "#{WATCH} \"rake build:css\" src/styles"
|
||||
end
|
||||
end
|
||||
|
||||
namespace :build do
|
||||
BROWSERIFY = "./node_modules/.bin/browserify"
|
||||
LESS = "./node_modules/.bin/lessc"
|
||||
|
||||
desc "Build the app with Browserify"
|
||||
task :js do
|
||||
sh "#{BROWSERIFY} -e ./src/app.coffee -o public/js/app.bundle.js"
|
||||
end
|
||||
|
||||
desc "Build the styles with LESS"
|
||||
task :css do
|
||||
sh "#{LESS} src/styles/burnchart.less > public/css/app.bundle.css"
|
||||
end
|
||||
|
||||
desc "Minify build for production"
|
||||
task :minify do
|
||||
sh "#{GRUNT} minify"
|
||||
end
|
||||
end
|
||||
|
||||
namespace :test do
|
||||
MOCHA = "./node_modules/.bin/mocha"
|
||||
COVERALLS = "./node_modules/.bin/coveralls"
|
||||
|
||||
OPTS = "--compilers coffee:coffee-script/register --ui exports --timeout 5000 --bail"
|
||||
|
||||
desc "Run code coverage, mocha with Blanket.js"
|
||||
task :coverage do
|
||||
sh "#{MOCHA} #{OPTS} --reporter html-cov --require blanket > docs/COVERAGE.html"
|
||||
end
|
||||
|
||||
desc "Run code coverage and publish to Coveralls"
|
||||
task :coveralls, :token do |t, args|
|
||||
args.with_defaults(:token => "ABC")
|
||||
|
||||
a = "#{MOCHA} #{OPTS} --reporter mocha-lcov-reporter --require blanket"
|
||||
b = "COVERALLS_REPO_TOKEN=#{args.token} COVERALLS_SERVICE_NAME=MOCHA #{COVERALLS}"
|
||||
sh "#{a} | #{b}"
|
||||
end
|
||||
end
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env node
|
||||
var Args = require('argparse').ArgumentParser,
|
||||
clrs = require('colors/safe'),
|
||||
stat = require('node-static'),
|
||||
path = require('path'),
|
||||
http = require('http'),
|
||||
exec = require('child_process').exec,
|
||||
pakg = require('../package.json'),
|
||||
fs = require('fs');
|
||||
|
||||
var parser = new Args({
|
||||
version: pakg.version
|
||||
});
|
||||
|
||||
parser.addArgument(
|
||||
[ '-p', '--port' ],
|
||||
{
|
||||
'help': 'Specify port number to start app on',
|
||||
'defaultValue': 8080,
|
||||
'type': 'int'
|
||||
}
|
||||
);
|
||||
parser.addArgument(
|
||||
[ '-d', '--dev' ],
|
||||
{
|
||||
'help': 'Development mode, unminified builds are served',
|
||||
'nargs': 0
|
||||
}
|
||||
);
|
||||
|
||||
var args = parser.parseArgs();
|
||||
|
||||
var opts = {
|
||||
'serverInfo': 'burnchart/' + pakg.version
|
||||
};
|
||||
|
||||
var dir = path.resolve(__dirname, '../');
|
||||
|
||||
var pub = new stat.Server(dir, opts);
|
||||
|
||||
// Be ready to serve unminified builds.
|
||||
var index = fs.readFileSync(dir + '/index.html', 'utf8');
|
||||
index = index.replace(/bundle\.min/gm, 'bundle');
|
||||
|
||||
var server = http.createServer(function(req, res) {
|
||||
req.addListener('end', function() {
|
||||
// Serve a custom index file in dev mode.
|
||||
if (args.dev && req.url == '/') {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': index.length,
|
||||
'Content-Type': 'text/html'
|
||||
});
|
||||
res.end(index);
|
||||
} else {
|
||||
pub.serve(req, res);
|
||||
}
|
||||
}).resume();
|
||||
}).listen(args.port);
|
||||
|
||||
server.on('listening', function() {
|
||||
var addr = server.address();
|
||||
var dev = args.dev ? ' (' + clrs.yellow.bold('dev') + ')' : '';
|
||||
console.log('burnchart/' + pakg.version + dev + ' started on port ' + addr.port);
|
||||
});
|
25
bin/run.js
25
bin/run.js
|
@ -1,25 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
var stat = require('node-static'),
|
||||
path = require('path'),
|
||||
http = require('http'),
|
||||
exec = require('child_process').exec,
|
||||
pakg = require('../package.json');
|
||||
|
||||
var opts = {
|
||||
'serverInfo': 'burnchart/' + pakg.version
|
||||
};
|
||||
|
||||
var dir = path.resolve(__dirname, '../public');
|
||||
|
||||
var file = new stat.Server(dir, opts);
|
||||
|
||||
var server = http.createServer(function(req, res) {
|
||||
req.addListener('end', function() {
|
||||
file.serve(req, res);
|
||||
}).resume();
|
||||
}).listen(process.argv[2]);
|
||||
|
||||
server.on('listening', function() {
|
||||
var addr = server.address();
|
||||
console.log('burnchart/' + pakg.version + ' started on port ' + addr.port);
|
||||
});
|
|
@ -1,49 +0,0 @@
|
|||
#Architecture
|
||||
|
||||
Captures how the app is build and what happens where.
|
||||
|
||||
##Build
|
||||
|
||||
Vendor libraries are fetched through npm. For CSS libs we `@import` them in LESS, for JS libs we `require` them using [Browserify](https://github.com/substack/node-browserify). All app dependencies are in `package.dependencies` rather than `package.devDependencies`, so that [David](https://david-dm.org/radekstepan/burnchart) can see them and we get a nice icon if things go out of date.
|
||||
|
||||
##Code
|
||||
|
||||
###JavaScript
|
||||
|
||||
####Ractive
|
||||
|
||||
The app is written as a series of [Ractive](http://www.ractivejs.org/) components. We use the library for both views and models. The important bit is that we can observe changes on them. So that these changes propagate, we use the [ractive-ractive](https://github.com/rstacruz/ractive-ractive) plugin. This means that one ractive can be passed another ractive as a data attribute. All ractive components have a `name` attribute which makes it easier to debug them when things go wrong.
|
||||
|
||||
####Projects
|
||||
|
||||
The projects collection is a simple stack. When rendering it, we are actually working off an index. Index is a list of tuples where first value is an index of a project and the second is an index of a milestone. When sort order changes, we only change the index. `models/projects` has a map of functions which handle the different sort orders.
|
||||
|
||||
####Mediator Pattern
|
||||
|
||||
Whenever something happens that other components on the page should know about, we use the mediator component. It is accessible by extending the `utils/ractive/eventfull` *class*. You can then call `@subscribe(message, fn)` or `@publish(message, data...)`. Subscriptions are automatically cancelled on teardown.
|
||||
|
||||
####Config
|
||||
|
||||
All configuration lives in `models/config`.
|
||||
|
||||
####Router
|
||||
|
||||
All routes are handled via `modules/router`. There you can see that each route is prefixed with a context - name of the view which will handle it and a bunch of functions to execute. As an example, the project and milestone routes both add a project, behind the scenes, if it does not exist already.
|
||||
|
||||
####Icons
|
||||
|
||||
Icons are loaded on the page through `views/icons`. This view has a list of entity codes which correspond to codes provided by [Fontello](http://fontello.com) custom icon packs.
|
||||
|
||||
####Async
|
||||
|
||||
Whenever there is an asynchronous block of code, wrap it in `models/system.async()` which returns a callback function which you call when done. That gives us a consistent loading spinner when things are happening.
|
||||
|
||||
###CSS
|
||||
|
||||
When developing in LESS, be aware that [LESS Hat](http://lesshat.madebysource.com/) is imported into the app.
|
||||
|
||||
##Tests
|
||||
|
||||
Tests run via Mocha and [Blanket](http://blanketjs.org/) for coverage. You can use [proxyquire](https://github.com/thlorenz/proxyquire) to override requires, but results in incorrect test coverage when used with Blanket.
|
||||
|
||||
The `test/fixtures` folder contains example responses from GitHub.
|
File diff suppressed because one or more lines are too long
45
docs/IDEA.md
45
docs/IDEA.md
|
@ -1,45 +0,0 @@
|
|||
#Idea
|
||||
|
||||
##Summary
|
||||
|
||||
An app showing a burndown chart for issues in a GitHub milestone. A choice of strategies for calculating the size of each issue to determine the progress. Running completely client-side apart from GitHub authentication via a Firebase service. In use by the community since 2012.
|
||||
|
||||
##Community
|
||||
|
||||
Anyone can contibute their time by working on issues. Read the [Architecture](ARCHITECTURE.md) document to get oriented. Ours are tracked in Assembly as [bounties](https://assembly.com/burnchart/bounties). You can use the contact form widget inside the app or [burnchart@helpful.io](mailto:burnchart@helpful.io) to contact the lead developer, Radek. You can also use [Tally](http://tally.tl/) to vote on upcoming features.
|
||||
|
||||
##Background
|
||||
|
||||
The project started in 2012 at the University of Cambridge in a bioinformatics team. The aim was to get better at estimating the workload for each release we were marking. The original app was running on Node.js. Then a major rewrite in 2013 moved it completely client side. Another rewrite is happening now, 2014, on the Assembly platform.
|
||||
|
||||
##Goals
|
||||
|
||||
Make developers better at managing their workload.
|
||||
|
||||
##Key Features
|
||||
|
||||
1. Running from the **browser**, apart from GitHub account sign in which uses Firebase backend.
|
||||
1. **Private repos**; sign in with your GitHub account.
|
||||
1. **Store** projects in browser's `localStorage`.
|
||||
1. **Off days**; specify which days of the week to leave out from ideal burndown progression line.
|
||||
1. **Trend line**; to see if you can make it to the deadline at this pace.
|
||||
1. Different **point counting** strategies; select from 1 issues = 1 point or read size from issue label.
|
||||
|
||||
##Target Audience
|
||||
|
||||
Developers who use simple issue trackers like GitHub issues and want to graduate from the basic progress bar that GitHub provides.
|
||||
|
||||
##Competing Products
|
||||
|
||||
The burndown or burndown chart concept is pretty widespread in more enterprisey ([Jira](https://www.atlassian.com/software/jira), [PivotalTracker](http://www.pivotaltracker.com/), [ThoughtWorks](http://www.thoughtworks.com/products/mingle-agile-project-management)) software. These are too heavy.
|
||||
|
||||
There are also products that nicely integrate with GitHub ([AgileZen](http://www.agilezen.com/), [Scrumwise](https://www.scrumwise.com/features.html)). But these are not GitHub-first.
|
||||
|
||||
|
||||
And finally products built on top of the GitHub API ([Burndown](http://burndown.io/), [SweepBoard](http://sweepboard.com/)). One is not pretty and one does not do charts yet.
|
||||
|
||||
This product puts the chart front and centre, as a place from which insights can be gained. Some people use Kanban boards, we use Burncharts.
|
||||
|
||||
##Monetization Strategy
|
||||
|
||||
I think that this product is useful but, like with [gitter.im](https://gitter.im/) or [david-dm.org](http://david-dm.org) hasn't reached a threshold where people would pay for it.
|
|
@ -1,33 +0,0 @@
|
|||
##Notes
|
||||
|
||||
- *payment gateways* in Canada: [Shopify](http://www.shopify.com/payment-gateways/canada), [Chargify](http://chargify.com/payment-gateways/) list; I get free processing on first $1000 with [Stripe](https://education.github.com/pack/offers)
|
||||
- [credit card form](http://designmodo.com/ux-credit-card-payment-form/) ux from Designmodo or [here](https://d13yacurqjgara.cloudfront.net/users/79914/screenshots/1048397/attachments/127794/payments_page.jpg).
|
||||
- workers: using a free instance of IronWorker and assuming 5s runtime each time gives us a poll every 6 minutes. Zapier would poll every 15 minutes but already integrates Stripe and FB.
|
||||
- $2.5 Node.js PaaS via Gandi with promo code `PAASLAUNCH-C50E-B077-A317`.
|
||||
|
||||
##Plans
|
||||
|
||||
###Community Plan
|
||||
|
||||
- your repos are saved locally
|
||||
- no auto-updates to milestones, everything fetched on page load
|
||||
- no private repos
|
||||
|
||||
###Business Plan
|
||||
|
||||
- you need to pay for a license to use the app for business purposes
|
||||
- repos, milestones saved remotely
|
||||
- auto-update with new information
|
||||
- private repos
|
||||
|
||||
###Free Forever Business Plan (= Community Shareholder/Partners Plan)
|
||||
|
||||
I can't sell people on free membership, that is only a small incentive. But I can sell them on an app that does what they want. Have early access to features etc. If someone sees that my app can help them, why not tell me about it so I can make it happen? (their time is valuable)
|
||||
|
||||
I could also provide people with Assembly coins for each feedback session I've had with them, thus making them share in the profits. They are basically startup members with equity by being Product Developers.
|
||||
|
||||
To qualify, these people need to be businesses actively using the software. Thus being stand-in users for other such $ paying businesses.
|
||||
|
||||
Let me call you every 3 months to ask how you are doing, how you are using the software, what can I improve, and you will get 3 months usage for free. The idea is to keep in touch with the most loyal customers, to hear them say how great/shabby the app is. If they don't want to talk they can always pay for the Business Plan (most would pay or quit).
|
||||
|
||||
If someone stops using the app, send them an email asking them for a good time to call so I can make things right. They would get 3 months usage as well (that's like giving me a book for free that I dislike). It would be better to ask them what they were expecting and why we failed.
|
|
@ -1,87 +0,0 @@
|
|||
#Stargazers
|
||||
|
||||
*Original list of stargazers for `radekstepan/github-burndown-chart`.*
|
||||
|
||||
1. radekstepan
|
||||
1. anissen
|
||||
1. HudsonAfonso
|
||||
1. jcberthon
|
||||
1. vad710
|
||||
1. Tomohiro
|
||||
1. terite
|
||||
1. MachineTi
|
||||
1. ttsuruoka
|
||||
1. ph
|
||||
1. xiechao06
|
||||
1. sbusso
|
||||
1. icesoar
|
||||
1. toland
|
||||
1. cymantic
|
||||
1. yuriykulikov
|
||||
1. icecreammatt
|
||||
1. mps
|
||||
1. jhnns
|
||||
1. PyroMani
|
||||
1. nathankleyn
|
||||
1. ecarreras
|
||||
1. dmihalcik
|
||||
1. xavierchou
|
||||
1. xavierchow
|
||||
1. chaselee
|
||||
1. jamespjh
|
||||
1. egi
|
||||
1. HasAMcNett
|
||||
1. mkuprionis
|
||||
1. morgan
|
||||
1. davidtingsu
|
||||
1. steevee
|
||||
1. menzer
|
||||
1. rauhryan
|
||||
1. gilday
|
||||
1. nside
|
||||
1. rukku
|
||||
1. tnira
|
||||
1. savage69kr
|
||||
1. uzulla
|
||||
1. yoiang
|
||||
1. andyberry88
|
||||
1. polidog
|
||||
1. acouch
|
||||
1. y-uuki
|
||||
1. Sixeight
|
||||
1. mzyy94
|
||||
1. Vorzard
|
||||
1. kasperisager
|
||||
1. sychedelix
|
||||
1. rochacbruno
|
||||
1. ellisonleao
|
||||
1. avelino
|
||||
1. rturk
|
||||
1. checkcheckzz
|
||||
1. tkmoteki
|
||||
1. lerrua
|
||||
1. opn
|
||||
1. tea-mac
|
||||
1. u01jmg3
|
||||
1. dwcaraway
|
||||
1. emanuelvianna
|
||||
1. dwightwatson
|
||||
1. donkirkby
|
||||
1. you21979
|
||||
1. taka011239
|
||||
1. monzou
|
||||
1. h-ikio
|
||||
1. jinky32
|
||||
1. alantrrs
|
||||
1. concertman
|
||||
1. AntoinePlu
|
||||
1. motemen
|
||||
1. mackagy1
|
||||
1. zoncoen
|
||||
1. lukebrooker
|
||||
1. katryo
|
||||
1. l0gicpath
|
||||
1. bshyong
|
||||
1. nightlyworker
|
||||
1. Meroje
|
||||
1. marcioukita
|
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
|
||||
<link href="public/css/bundle.min.css" media="all" rel="stylesheet" type="text/css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" />
|
||||
<script type="text/javascript" src="public/js/bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
127
package.json
127
package.json
|
@ -1,84 +1,61 @@
|
|||
{
|
||||
"name": "burnchart",
|
||||
"version": "2.0.10",
|
||||
"version": "3.0.0",
|
||||
"description": "GitHub Burndown Chart as a Service",
|
||||
"author": "Radek Stepan <dev@radekstepan.com> (http://radekstepan.com)",
|
||||
"license": "AGPL-3.0",
|
||||
"keywords": [
|
||||
"github",
|
||||
"issues",
|
||||
"burndown",
|
||||
"chart",
|
||||
"scrum"
|
||||
],
|
||||
"bin": {
|
||||
"burnchart": "./bin/burnchart.js"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "make start",
|
||||
"test": "make test"
|
||||
},
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.4",
|
||||
"colors": "^1.1.2",
|
||||
"node-static": "^0.7.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"async": "^1.5.2",
|
||||
"babel": "^6.3.26",
|
||||
"babel-preset-es2015": "^6.3.13",
|
||||
"babel-preset-react": "^6.3.13",
|
||||
"babel-register": "^6.4.3",
|
||||
"babelify": "^7.2.0",
|
||||
"browserify": "^13.0.0",
|
||||
"chai": "^3.4.1",
|
||||
"classnames": "^2.2.3",
|
||||
"clean-css": "^3.4.9",
|
||||
"coffeeify": "^2.0.1",
|
||||
"d3": "^3.5.12",
|
||||
"d3-tip": "^0.6.7",
|
||||
"deep-diff": "^0.3.3",
|
||||
"firebase": "^2.3.2",
|
||||
"less": "^2.5.3",
|
||||
"lesshat": "^3.0.2",
|
||||
"lodash": "^3.10.1",
|
||||
"lscache": "^1.0.5",
|
||||
"marked": "^0.3.5",
|
||||
"mocha": "^2.3.4",
|
||||
"moment": "^2.11.1",
|
||||
"normalize.less": "^1.0.0",
|
||||
"object-assign": "^4.0.1",
|
||||
"object-path": "^0.9.2",
|
||||
"proxyquire": "^1.7.3",
|
||||
"react": "^0.14.6",
|
||||
"react-addons-css-transition-group": "^0.14.6",
|
||||
"react-mini-router": "^2.0.0",
|
||||
"semver": "^5.1.0",
|
||||
"sortedindex-compare": "0.0.1",
|
||||
"superagent": "^1.6.1",
|
||||
"uglify-js": "^2.6.1",
|
||||
"watch": "^0.17.1",
|
||||
"watch-less": "0.0.4",
|
||||
"watchify": "^3.7.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/radekstepan/burnchart.git"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "rake serve",
|
||||
"test": "rake test"
|
||||
},
|
||||
"bin": {
|
||||
"burnchart": "./bin/run.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"async": "1.5.2",
|
||||
"brain": "0.7.0",
|
||||
"chance": "0.8.0",
|
||||
"d3": "3.5.12",
|
||||
"d3-tip": "git://github.com/Caged/d3-tip",
|
||||
"director": "1.2.8",
|
||||
"firebase": "2.3.2",
|
||||
"lodash": "3.10.1",
|
||||
"lscache": "1.0.5",
|
||||
"marked": "0.3.5",
|
||||
"moment": "2.11.1",
|
||||
"node-static": "0.7.7",
|
||||
"normalize.less": "1.0.0",
|
||||
"ractive": "0.6.1",
|
||||
"ractive-ractive": "0.4.4",
|
||||
"semver": "5.1.0",
|
||||
"sortedindex-compare": "0.0.1",
|
||||
"superagent": "1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"blanket": "1.2.1",
|
||||
"browserify": "13.0.0",
|
||||
"chai": "3.4.1",
|
||||
"coffee-script": "1.10.0",
|
||||
"coffeeify": "2.0.1",
|
||||
"coveralls": "2.11.6",
|
||||
"grunt": "0.4.5",
|
||||
"grunt-cli": "0.1.13",
|
||||
"grunt-contrib-clean": "0.7.0",
|
||||
"grunt-contrib-cssmin": "0.14.0",
|
||||
"grunt-contrib-uglify": "0.11.0",
|
||||
"grunt-gh-pages": "1.0.0",
|
||||
"grunt-mkdir": "0.1.2",
|
||||
"less": "2.5.3",
|
||||
"lesshat": "3.0.2",
|
||||
"mocha": "2.3.4",
|
||||
"mocha-lcov-reporter": "1.0.0",
|
||||
"proxyquire": "1.7.3",
|
||||
"ractivate": "0.2.0",
|
||||
"watch": "0.17.1",
|
||||
"watchify": "3.7.0"
|
||||
},
|
||||
"browserify": {
|
||||
"transform": [
|
||||
"coffeeify",
|
||||
"ractivate"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"blanket": {
|
||||
"loader": "./node-loaders/coffee-script",
|
||||
"pattern": "src",
|
||||
"data-cover-never": "node_modules",
|
||||
"data-cover-flags": {
|
||||
"engineOnly": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -478,6 +478,46 @@ lesshat-selector {
|
|||
top: 100%;
|
||||
left: 0;
|
||||
}
|
||||
.animTop-enter {
|
||||
top: -68px;
|
||||
}
|
||||
.animTop-enter-active {
|
||||
top: 0px;
|
||||
-webkit-transition: top 2000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
-moz-transition: top 2000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
-o-transition: top 2000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transition: top 2000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
.animTop-leave {
|
||||
top: 0px;
|
||||
}
|
||||
.animTop-leave-active {
|
||||
top: -68px;
|
||||
-webkit-transition: top 1000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
-moz-transition: top 1000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
-o-transition: top 1000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transition: top 1000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
.animCenter-enter {
|
||||
top: 0%;
|
||||
}
|
||||
.animCenter-enter-active {
|
||||
top: 50%;
|
||||
-webkit-transition: top 2000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
-moz-transition: top 2000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
-o-transition: top 2000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transition: top 2000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
.animCenter-leave {
|
||||
top: 50%;
|
||||
}
|
||||
.animCenter-leave-active {
|
||||
top: 0%;
|
||||
-webkit-transition: top 1000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
-moz-transition: top 1000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
-o-transition: top 1000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
transition: top 1000ms cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
|
@ -497,6 +537,10 @@ a {
|
|||
text-decoration: none;
|
||||
color: #aaafbf;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
|
@ -518,13 +562,17 @@ ul li {
|
|||
}
|
||||
#notify {
|
||||
position: fixed;
|
||||
top: -68px;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
background: #fcfcfc;
|
||||
color: #aaafbf;
|
||||
border-top: 3px solid #aaafbf;
|
||||
border-bottom: 1px solid #f3f4f8;
|
||||
cursor: default;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
#notify .close {
|
||||
float: right;
|
||||
|
@ -537,7 +585,7 @@ ul li {
|
|||
display: block;
|
||||
}
|
||||
#notify.system {
|
||||
top: 0%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 500px;
|
||||
-webkit-transform: translateX(-50%) translateY(-50%);
|
File diff suppressed because one or more lines are too long
|
@ -1,10 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
|
||||
<link rel="stylesheet" type="text/css" href="css/app.bundle.css">
|
||||
</head>
|
||||
<body>
|
||||
<script src="js/app.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
|
@ -1,25 +0,0 @@
|
|||
Ractive = require 'ractive'
|
||||
|
||||
# Load Ractive transitions and adapters.
|
||||
require 'ractive-ractive'
|
||||
|
||||
# Will load projects from localStorage.
|
||||
require './models/projects.coffee'
|
||||
|
||||
Header = require './views/header.coffee'
|
||||
Notify = require './views/notify.coffee'
|
||||
Icons = require './views/icons.coffee'
|
||||
|
||||
router = require './modules/router.coffee'
|
||||
|
||||
new Ractive
|
||||
|
||||
'template': require './templates/app.html'
|
||||
|
||||
'el': 'body'
|
||||
|
||||
'components': { Header, Notify, Icons }
|
||||
|
||||
onrender: ->
|
||||
# Start the router.
|
||||
router.init '/'
|
|
@ -0,0 +1,33 @@
|
|||
export default {
|
||||
// Firebase app name.
|
||||
"firebase": "burnchart",
|
||||
// Data source provider.
|
||||
"provider": "github",
|
||||
// Fields to keep from GH responses.
|
||||
"fields": {
|
||||
"milestone": [
|
||||
"closed_issues",
|
||||
"created_at",
|
||||
"description",
|
||||
"due_on",
|
||||
"number",
|
||||
"open_issues",
|
||||
"title",
|
||||
"updated_at"
|
||||
]
|
||||
},
|
||||
// Chart configuration.
|
||||
"chart": {
|
||||
// Days we are not working. Mon = 1
|
||||
"off_days": [ ],
|
||||
// How does a size label look like?
|
||||
"size_label": /^size (\d+)$/,
|
||||
// Process all issues as one size (ONE_SIZE) or use labels (LABELS).
|
||||
"points": 'ONE_SIZE'
|
||||
},
|
||||
// Request pertaining.
|
||||
"request": {
|
||||
// Default timeout of 5s.
|
||||
"timeout": 5e3
|
||||
}
|
||||
};
|
|
@ -0,0 +1,137 @@
|
|||
import React from 'react';
|
||||
import { RouterMixin, navigate } from 'react-mini-router';
|
||||
import _ from 'lodash';
|
||||
import './modules/lodash.js';
|
||||
|
||||
import ProjectsPage from './components/pages/ProjectsPage.jsx';
|
||||
import MilestonesPage from './components/pages/MilestonesPage.jsx';
|
||||
import ChartPage from './components/pages/ChartPage.jsx';
|
||||
import AddProjectPage from './components/pages/AddProjectPage.jsx';
|
||||
import NotFoundPage from './components/pages/NotFoundPage.jsx';
|
||||
|
||||
import actions from './actions/appActions.js';
|
||||
|
||||
import appStore from './stores/appStore.js';
|
||||
|
||||
// Will fire even if event is prevented from propagating.
|
||||
delete RouterMixin.handleClick;
|
||||
|
||||
// Values are function names below.
|
||||
let routes = {
|
||||
'/': 'projects',
|
||||
'/new/project': 'addProject',
|
||||
'/:owner/:name': 'milestones',
|
||||
'/:owner/:name/:milestone': 'chart',
|
||||
'/demo': 'demo'
|
||||
};
|
||||
|
||||
let blank = false;
|
||||
|
||||
// Build a link to a page.
|
||||
let find = ({ to, params, query }) => {
|
||||
let $url;
|
||||
let re = /:[^\/]+/g;
|
||||
|
||||
// Skip empty objects.
|
||||
[ params, query ] = [ _.isObject(params) ? params : {}, query ].map(o => _.pick(o, _.identity));
|
||||
|
||||
// Find among the routes.
|
||||
_.find(routes, (name, url) => {
|
||||
if (name != to) return;
|
||||
let matches = url.match(re);
|
||||
|
||||
// Do not match on the number of params.
|
||||
if (_.keys(params).length != (matches || []).length) return;
|
||||
|
||||
// Do not match on the name of params.
|
||||
if (!_.every(matches, m => m.slice(1) in params)) return;
|
||||
|
||||
// Fill in the params.
|
||||
$url = url.replace(re, m => params[m.slice(1)]);
|
||||
|
||||
// Found it.
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!$url) throw new Error(`path ${to} ${JSON.stringify(params)} is not recognized`);
|
||||
|
||||
// Append querystring.
|
||||
if (_.keys(query).length) {
|
||||
$url += "?" + _.map(query, (v, k) => `${k}=${v}`).join("&");
|
||||
}
|
||||
|
||||
return $url;
|
||||
};
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'App.jsx',
|
||||
|
||||
mixins: [ RouterMixin ],
|
||||
|
||||
routes: routes,
|
||||
|
||||
statics: {
|
||||
// Build a link to a page.
|
||||
link: (route) => find(route),
|
||||
// Route to a link.
|
||||
navigate: (route) => {
|
||||
let fn = _.isString(route) ? _.identity : find;
|
||||
navigate(fn(route));
|
||||
}
|
||||
},
|
||||
|
||||
// Show projects.
|
||||
projects() {
|
||||
document.title = 'Burnchart: GitHub Burndown Chart as a Service';
|
||||
process.nextTick(() => actions.emit('projects.load'));
|
||||
return <ProjectsPage />;
|
||||
},
|
||||
|
||||
// Show project milestones.
|
||||
milestones(owner, name) {
|
||||
document.title = `${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} />;
|
||||
},
|
||||
|
||||
// Add a project.
|
||||
addProject() {
|
||||
document.title = 'Add a project';
|
||||
return <AddProjectPage />;
|
||||
},
|
||||
|
||||
// Demo projects.
|
||||
demo() {
|
||||
actions.emit('projects.demo');
|
||||
navigate(find({ 'to': 'projects' }));
|
||||
return <div />;
|
||||
},
|
||||
|
||||
// 404.
|
||||
notFound(path) {
|
||||
return <NotFoundPage path={path} />;
|
||||
},
|
||||
|
||||
// Use blank <div /> to always re-mount a Page.
|
||||
render() {
|
||||
if (blank) {
|
||||
process.nextTick(() => this.setState({ tick: true }));
|
||||
blank = false;
|
||||
return <div />;
|
||||
} else {
|
||||
blank = true;
|
||||
// Clear any notifications.
|
||||
process.nextTick(() => actions.emit('system.notify'));
|
||||
return this.renderCurrentRoute();
|
||||
}
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,4 @@
|
|||
import EventEmitter from '../lib/EventEmitter.js';
|
||||
|
||||
// Just a namespace for all actions.
|
||||
export default new EventEmitter();
|
|
@ -0,0 +1,85 @@
|
|||
import React from 'react';
|
||||
|
||||
import App from '../App.jsx';
|
||||
|
||||
import actions from '../actions/appActions.js';
|
||||
|
||||
import Icon from './Icon.jsx';
|
||||
import S from './Space.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'AddProjectForm.jsx',
|
||||
|
||||
// Sign user in.
|
||||
_onSignIn() {
|
||||
actions.emit('user.signin');
|
||||
},
|
||||
|
||||
_onChange(evt) {
|
||||
this.setState({ 'val': evt.target.value });
|
||||
},
|
||||
|
||||
// Add the project (via Enter keypress).
|
||||
_onKeyUp(evt) {
|
||||
if (evt.key == 'Enter') this._onAdd();
|
||||
},
|
||||
|
||||
// Add the project.
|
||||
_onAdd() {
|
||||
let [ owner, name ] = this.state.val.split('/');
|
||||
actions.emit('projects.add', { owner, name });
|
||||
// Redirect to the dashboard.
|
||||
App.navigate({ 'to': 'projects' });
|
||||
},
|
||||
|
||||
// Blank input.
|
||||
getInitialState() {
|
||||
return { 'val': '' };
|
||||
},
|
||||
|
||||
render() {
|
||||
let user;
|
||||
if (!(this.props.user != null && 'uid' in this.props.user)) {
|
||||
user = (
|
||||
<span><S />If you'd like to add a private GitHub repo,
|
||||
<S /><a onClick={this._onSignIn}>Sign In</a> first.</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="add">
|
||||
<div className="header">
|
||||
<h2>Add a Project</h2>
|
||||
<p>Type the name of a GitHub repository that has some
|
||||
milestones with issues.{user}</p>
|
||||
</div>
|
||||
|
||||
<div className="form">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" ref="el" placeholder="user/repo" autoComplete="off"
|
||||
onChange={this._onChange} value={this.state.val} onKeyUp={this._onKeyUp} />
|
||||
</td>
|
||||
<td><a onClick={this._onAdd}>Add</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="protip">
|
||||
<Icon name="rocket"/> Protip: To see if a milestone is on track or not,
|
||||
make sure it has a due date assigned to it.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
// Focus input field on mount.
|
||||
componentDidMount() {
|
||||
this.refs.el.focus();
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,173 @@
|
|||
import React from 'react';
|
||||
import d3 from 'd3';
|
||||
import d3Tip from 'd3-tip';
|
||||
d3Tip(d3);
|
||||
|
||||
import format from '../modules/format.js';
|
||||
|
||||
import lines from '../modules/chart/lines.js';
|
||||
import axes from '../modules/chart/axes.js';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'Chart.jsx',
|
||||
|
||||
render() {
|
||||
let milestone = this.props.milestone;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div id="title">
|
||||
<div className="wrap">
|
||||
<h2 className="title">{format.title(milestone.title)}</h2>
|
||||
<span className="sub">{format.due(milestone.due_on)}</span>
|
||||
<div className="description">{format.markdown(milestone.description)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="content" className="wrap">
|
||||
<div id="chart" ref="el" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
componentDidMount() {
|
||||
let milestone = this.props.milestone;
|
||||
let issues = milestone.issues;
|
||||
// Total number of points in the milestone.
|
||||
let total = issues.open.size + issues.closed.size;
|
||||
|
||||
// An issue may have been closed before the start of a milestone.
|
||||
if (issues.closed.size > 0) {
|
||||
let head = issues.closed.list[0].closed_at;
|
||||
if (issues.length && milestone.created_at > head) {
|
||||
// This is the new start.
|
||||
milestone.created_at = head;
|
||||
}
|
||||
}
|
||||
|
||||
// Actual, ideal & trend lines.
|
||||
let actual = lines.actual(issues.closed.list, milestone.created_at, total);
|
||||
let ideal = lines.ideal(milestone.created_at, milestone.due_on, total);
|
||||
let trend = lines.trend(actual, milestone.created_at, milestone.due_on);
|
||||
|
||||
// Get available space.
|
||||
let { height, width } = this.refs.el.getBoundingClientRect();
|
||||
|
||||
let margin = { 'top': 30, 'right': 30, 'bottom': 40, 'left': 50 };
|
||||
width -= margin.left + margin.right;
|
||||
height -= margin.top + margin.bottom;
|
||||
|
||||
// Scales.
|
||||
let x = d3.time.scale().range([ 0, width ]);
|
||||
let y = d3.scale.linear().range([ height, 0 ]);
|
||||
|
||||
// Axes.
|
||||
let xAxis = axes.horizontal(height, x);
|
||||
let yAxis = axes.vertical(width, y);
|
||||
|
||||
// Line generator.
|
||||
let line = d3.svg.line()
|
||||
.interpolate("linear")
|
||||
.x((d) => x(new Date(d.date))) // convert to Date only now
|
||||
.y((d) => y(d.points));
|
||||
|
||||
// Get the minimum and maximum date, and initial points.
|
||||
let first = ideal[0], last = ideal[ideal.length - 1];
|
||||
x.domain([ new Date(first.date), new Date(last.date) ]);
|
||||
y.domain([ 0, first.points ]).nice();
|
||||
|
||||
// Add an SVG element with the desired dimensions and margin.
|
||||
let svg = d3.select(this.refs.el).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 so that lines are not drawn outside of the boundary.
|
||||
svg.append("defs").append("svg:clipPath")
|
||||
.attr("id", "clip")
|
||||
.append("svg:rect")
|
||||
.attr("id", "clip-rect")
|
||||
.attr("x", 0)
|
||||
.attr("y", 0)
|
||||
.attr("width", width)
|
||||
.attr("height", height);
|
||||
|
||||
// Add the days x-axis.
|
||||
svg.append("g")
|
||||
.attr("class", "x axis day")
|
||||
.attr("transform", `translate(0,${height})`)
|
||||
.call(xAxis);
|
||||
|
||||
// Add the months x-axis.
|
||||
let m = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
];
|
||||
|
||||
let mAxis = xAxis
|
||||
.orient("top")
|
||||
.tickSize(height)
|
||||
.tickFormat((d) => m[d.getMonth()])
|
||||
.ticks(2);
|
||||
|
||||
svg.append("g")
|
||||
.attr("class", "x axis month")
|
||||
.attr("transform", `translate(0,${height})`)
|
||||
.call(mAxis);
|
||||
|
||||
// Add the y-axis.
|
||||
svg.append("g")
|
||||
.attr("class", "y axis")
|
||||
.call(yAxis);
|
||||
|
||||
// Add a line showing where we are now.
|
||||
svg.append("svg:line")
|
||||
.attr("class", "today")
|
||||
.attr("x1", x(new Date))
|
||||
.attr("y1", 0)
|
||||
.attr("x2", x(new Date))
|
||||
.attr("y2", height);
|
||||
|
||||
// Add the ideal line path.
|
||||
svg.append("path")
|
||||
.attr("class", "ideal line")
|
||||
// Piecewise linear segments, as in a polyline.
|
||||
.attr("d", line.interpolate("linear")(ideal));
|
||||
|
||||
// Add the trendline path.
|
||||
svg.append("path")
|
||||
.attr("class", "trendline line")
|
||||
// Piecewise linear segments, as in a polyline.
|
||||
.attr("d", line.interpolate("linear")(trend));
|
||||
|
||||
// Add the actual line path.
|
||||
svg.append("path")
|
||||
.attr("class", "actual line")
|
||||
// Piecewise linear segments, as in a polyline.
|
||||
.attr("d", line.interpolate("linear").y((d) => y(d.points))(actual));
|
||||
|
||||
// Collect the tooltip here.
|
||||
let tooltip = d3.tip().attr('class', 'd3-tip')
|
||||
.html(({ number, title }) => `#${number}: ${title}`);
|
||||
|
||||
svg.call(tooltip);
|
||||
|
||||
// Show when we closed an issue.
|
||||
svg.selectAll("a.issue")
|
||||
.data(actual.slice(1)) // skip the starting point
|
||||
.enter()
|
||||
// A wrapping link.
|
||||
.append('svg:a')
|
||||
.attr("xlink:href", ({ html_url }) => html_url )
|
||||
.attr("xlink:show", 'new')
|
||||
.append('svg:circle')
|
||||
.attr("cx", ({ date }) => x(new Date(date)))
|
||||
.attr("cy", ({ points }) => y(points))
|
||||
.attr("r", ({ radius }) => 5)
|
||||
.on('mouseover', tooltip.show)
|
||||
.on('mouseout', tooltip.hide);
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'Footer.jsx',
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="footer">
|
||||
<div className="wrap">
|
||||
© 2012-2016 <a href="https:/radekstepan.com" target="_blank">Radek Stepan</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,82 @@
|
|||
import React from 'react';
|
||||
|
||||
import actions from '../actions/appActions.js';
|
||||
|
||||
import Notify from './Notify.jsx';
|
||||
import Icon from './Icon.jsx';
|
||||
import Link from './Link.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'Header.jsx',
|
||||
|
||||
// Sign user in.
|
||||
_onSignIn() {
|
||||
actions.emit('user.signin');
|
||||
},
|
||||
|
||||
// Sign user out.
|
||||
_onSignOut() {
|
||||
actions.emit('user.signout');
|
||||
},
|
||||
|
||||
// Add example projects.
|
||||
_onDemo() {
|
||||
actions.emit('projects.demo');
|
||||
},
|
||||
|
||||
render() {
|
||||
// From app store.
|
||||
let props = this.props.app;
|
||||
|
||||
// Sign-in/out.
|
||||
let user;
|
||||
if (props.user != null && 'uid' in props.user) {
|
||||
user = (
|
||||
<div className="right">
|
||||
<a onClick={this._onSignOut}>
|
||||
<Icon name="signout" /> Sign Out {props.user.github.displayName}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
user = (
|
||||
<div className="right">
|
||||
<a className="button" onClick={this._onSignIn}>
|
||||
<Icon name="github"/> Sign In
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Switch loading icon with app icon.
|
||||
let icon = [ 'fire', 'spinner' ][ +props.system.loading ];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Notify {...props.system.notification} />
|
||||
<div id="head">
|
||||
{user}
|
||||
|
||||
<Link route={{ 'to': 'projects' }} id="icon">
|
||||
<Icon name={icon} />
|
||||
</Link>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<Link route={{ 'to': 'addProject' }}>
|
||||
<Icon name="plus" /> Add a Project
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link route={{ 'to': 'demo' }}>
|
||||
<Icon name="computer" /> See Examples
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
|
||||
import actions from '../actions/appActions.js';
|
||||
|
||||
import Icon from './Icon.jsx';
|
||||
import Link from './Link.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'Hero.jsx',
|
||||
|
||||
// Add example projects.
|
||||
_onDemo() {
|
||||
actions.emit('projects.demo');
|
||||
},
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="hero">
|
||||
<div className="content">
|
||||
<Icon name="direction" />
|
||||
<h2>See your project progress</h2>
|
||||
<p>Serious about a project deadline? Add your GitHub project
|
||||
and we'll tell you if it is running on time or not.</p>
|
||||
<div className="cta">
|
||||
<Link route={{ to: 'addProject' }} className="primary">
|
||||
<Icon name="plus" /> Add a Project
|
||||
</Link>
|
||||
<Link route={{ to: 'demo' }} className="secondary">
|
||||
<Icon name="computer" /> See Examples
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
|
||||
import format from '../modules/format.js';
|
||||
|
||||
// Fontello icon hex codes.
|
||||
let codes = {
|
||||
'spyglass': '\e801', // Font Awesome - search
|
||||
'plus': '\e804', // Font Awesome - plus-circled
|
||||
'settings': '\e800', // Font Awesome - cog
|
||||
'rocket': '\e80a', // Font Awesome - rocket
|
||||
'computer': '\e807', // Font Awesome - desktop
|
||||
'help': '\e80f', // Font Awesome - lifebuoy
|
||||
'signout': '\e809', // Font Awesome - logout
|
||||
'github': '\e802', // Font Awesome - github
|
||||
'warning': '\e80c', // Entypo - attention
|
||||
'direction': '\e803', // Entypo - address
|
||||
'megaphone': '\e808', // Entypo - megaphone
|
||||
'heart': '\e80e', // Typicons - heart
|
||||
'sort': '\e806', // Typicons - sort-alphabet
|
||||
'spinner': '\e80b', // MFG Labs - spinner1
|
||||
'fire': '\e805' // Maki - fire-station
|
||||
};
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'Icon.jsx',
|
||||
|
||||
render() {
|
||||
let name = this.props.name;
|
||||
|
||||
if (name && name in codes) {
|
||||
let code = format.hexToDec(codes[name]);
|
||||
return (
|
||||
<span
|
||||
className={`icon ${name}`}
|
||||
dangerouslySetInnerHTML={{ '__html': `&#${code};` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
|
||||
import App from '../App.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'Link.jsx',
|
||||
|
||||
// Navigate to a route.
|
||||
_navigate(link, evt) {
|
||||
App.navigate(link);
|
||||
evt.preventDefault();
|
||||
},
|
||||
|
||||
render() {
|
||||
let route = this.props.route;
|
||||
let link = App.link(route);
|
||||
|
||||
return (
|
||||
<a
|
||||
{...this.props}
|
||||
href={`#!${link}`}
|
||||
onClick={this._navigate.bind(this, link)}
|
||||
>
|
||||
{this.props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,120 @@
|
|||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import cls from 'classnames';
|
||||
|
||||
import format from '../modules/format.js';
|
||||
|
||||
import actions from '../actions/appActions.js';
|
||||
|
||||
import Icon from './Icon.jsx';
|
||||
import Link from './Link.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'Milestones.jsx',
|
||||
|
||||
// Cycle through milestones sort order.
|
||||
_onSort() {
|
||||
actions.emit('projects.sort');
|
||||
},
|
||||
|
||||
render() {
|
||||
let { projects, project } = this.props;
|
||||
|
||||
// Show the projects with errors first.
|
||||
let errors = _(projects.list).filter('errors').map((project, i) => {
|
||||
let text = project.errors.join('\n');
|
||||
return (
|
||||
<tr key={`err-${i}`}>
|
||||
<td colSpan="3" className="repo">
|
||||
<div className="project">{project.owner}/{project.name}
|
||||
<span className="error" title={text}><Icon name="warning"/></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}).value();
|
||||
|
||||
// Now for the list of milestones, index sorted.
|
||||
let list = [];
|
||||
_.each(projects.index, ([ pI, mI ]) => {
|
||||
let { owner, name, milestones } = projects.list[pI];
|
||||
let milestone = milestones[mI];
|
||||
|
||||
// Filter down?
|
||||
if (!(!project || (project.owner == owner && project.name == name))) return;
|
||||
|
||||
list.push(
|
||||
<tr className={cls({ 'done': milestone.stats.isDone })} key={`${pI}-${mI}`}>
|
||||
<td className="repo">
|
||||
<Link
|
||||
route={{ 'to': 'milestones', 'params': { owner, name } }}
|
||||
className="project"
|
||||
>
|
||||
{owner}/{name}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
route={{ 'to': 'chart', 'params': { owner, name, 'milestone': milestone.number } }}
|
||||
className="milestone"
|
||||
>
|
||||
{milestone.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ 'width': '1%' }}>
|
||||
<div className="progress">
|
||||
<span className="percent">{Math.floor(milestone.stats.progress.points)}%</span>
|
||||
<span className={cls('due', { 'red': milestone.stats.isOverdue })}>
|
||||
{format.due(milestone.due_on)}
|
||||
</span>
|
||||
<div className="outer bar">
|
||||
<div
|
||||
className={cls('inner', 'bar', { 'green': milestone.stats.isOnTime, 'red': !milestone.stats.isOnTime })}
|
||||
style={{ 'width': `${milestone.stats.progress.points}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for something to show.
|
||||
if (!errors.length && !list.length) return false;
|
||||
|
||||
if (project) {
|
||||
// List of projects and their milestones.
|
||||
return (
|
||||
<div id="projects">
|
||||
<div className="header">
|
||||
<a className="sort" onClick={this._onSort}><Icon name="sort"/> Sorted by {projects.sortBy}</a>
|
||||
<h2>Milestones</h2>
|
||||
</div>
|
||||
<table>
|
||||
<tbody>{list}</tbody>
|
||||
</table>
|
||||
<div className="footer" />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// Project-specific milestones.
|
||||
return (
|
||||
<div id="projects">
|
||||
<div className="header">
|
||||
<a className="sort" onClick={this._onSort}><Icon name="sort"/> Sorted by {projects.sortBy}</a>
|
||||
<h2>Projects</h2>
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
{errors}
|
||||
{list}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,76 @@
|
|||
import React from 'react';
|
||||
import Transition from 'react-addons-css-transition-group';
|
||||
|
||||
import actions from '../actions/appActions.js';
|
||||
|
||||
import Icon from './Icon.jsx';
|
||||
|
||||
let Notify = React.createClass({
|
||||
|
||||
displayName: 'Notify.jsx',
|
||||
|
||||
// Close notification.
|
||||
_onClose() {
|
||||
actions.emit('system.notify');
|
||||
},
|
||||
|
||||
getDefaultProps() {
|
||||
return {
|
||||
// No text.
|
||||
'text': null,
|
||||
// Grey style.
|
||||
'type': '',
|
||||
// Top notification.
|
||||
'system': false,
|
||||
// Just announcing.
|
||||
'icon': 'megaphone'
|
||||
};
|
||||
},
|
||||
|
||||
render() {
|
||||
let { text, system, type, icon, ttl } = this.props;
|
||||
|
||||
// No text = no message.
|
||||
if (!text) return false;
|
||||
|
||||
if (system) {
|
||||
return (
|
||||
<div id="notify" className={`system ${type}`}>
|
||||
<Icon name={icon} />
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div id="notify" className={type}>
|
||||
<span className="close" onClick={this._onClose} />
|
||||
<Icon name={icon} />
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
// TODO: animate in
|
||||
render() {
|
||||
if (!this.props.id) return false; // TODO: fix ghost
|
||||
|
||||
// Top bar or center positioned?
|
||||
let name = (this.props.system) ? 'animCenter' : 'animTop';
|
||||
|
||||
return (
|
||||
<Transition transitionName={name}
|
||||
transitionEnterTimeout={2000}
|
||||
transitionLeaveTimeout={1000}
|
||||
component="div"
|
||||
>
|
||||
<Notify {...this.props} key={this.props.id} />
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
|
||||
// Inserts a space before rendering text.
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'Space.jsx',
|
||||
|
||||
render() {
|
||||
return <span> </span>;
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
|
||||
import actions from '../../actions/appActions.js';
|
||||
|
||||
import Page from '../../lib/PageMixin.js';
|
||||
|
||||
import Notify from '../Notify.jsx';
|
||||
import Header from '../Header.jsx';
|
||||
import Footer from '../Footer.jsx';
|
||||
import AddProjectForm from '../AddProjectForm.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'AddProjectPage.jsx',
|
||||
|
||||
mixins: [ Page ],
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<Notify />
|
||||
<Header {...this.state} />
|
||||
|
||||
<div id="page">
|
||||
<div id="content" className="wrap">
|
||||
<AddProjectForm user={this.state.app.user} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import Page from '../../lib/PageMixin.js';
|
||||
|
||||
import Notify from '../Notify.jsx';
|
||||
import Header from '../Header.jsx';
|
||||
import Footer from '../Footer.jsx';
|
||||
import Chart from '../Chart.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'ChartPage.jsx',
|
||||
|
||||
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">{content}</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
|
||||
import Page from '../../lib/PageMixin.js';
|
||||
|
||||
import Notify from '../Notify.jsx';
|
||||
import Header from '../Header.jsx';
|
||||
import Footer from '../Footer.jsx';
|
||||
import Milestones from '../Milestones.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'MilestonesPage.jsx',
|
||||
|
||||
mixins: [ Page ],
|
||||
|
||||
render() {
|
||||
let content;
|
||||
if (!this.state.app.loading) {
|
||||
let projects = this.state.projects;
|
||||
content = <Milestones projects={projects} project={this.props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Notify />
|
||||
<Header {...this.state} />
|
||||
|
||||
<div id="page">
|
||||
<div id="title">
|
||||
<div className="wrap">
|
||||
<h2 className="title">{this.props.owner}/{this.props.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="content" className="wrap">{content}</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
|
||||
import Page from '../../lib/PageMixin.js';
|
||||
|
||||
// TODO: implement
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'NotFoundPage.jsx',
|
||||
|
||||
mixins: [ Page ],
|
||||
|
||||
render() {
|
||||
return <div>Page {this.props.path} not found</div>;
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
|
||||
import Page from '../../lib/PageMixin.js';
|
||||
|
||||
import Notify from '../Notify.jsx';
|
||||
import Header from '../Header.jsx';
|
||||
import Footer from '../Footer.jsx';
|
||||
import Milestones from '../Milestones.jsx';
|
||||
import Hero from '../Hero.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
|
||||
displayName: 'ProjectsPage.jsx',
|
||||
|
||||
mixins: [ Page ],
|
||||
|
||||
render() {
|
||||
let content;
|
||||
if (!this.state.app.loading) {
|
||||
let projects = this.state.projects;
|
||||
if (projects.list.length) {
|
||||
content = <Milestones projects={projects} />;
|
||||
} else {
|
||||
content = <Hero />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Notify />
|
||||
<Header {...this.state} />
|
||||
|
||||
<div id="page">
|
||||
<div id="content" className="wrap">{content}</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import App from './App.jsx';
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('app'));
|
|
@ -0,0 +1,42 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
// TODO: add `onOnce` fn.
|
||||
export default class EventEmitter {
|
||||
|
||||
constructor() {
|
||||
this.list = [];
|
||||
}
|
||||
|
||||
// Trigger an event with obj and context.
|
||||
emit(event, obj, ctx) {
|
||||
if (!event.length) return;
|
||||
|
||||
this.list.forEach((sub) => {
|
||||
if (sub.pattern.test(event)) {
|
||||
sub.cb.call(ctx, obj, event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add a listener on this path/regex.
|
||||
on(path, cb) {
|
||||
if (!_.isRegExp(path)) path = new RegExp(`^${path}$`);
|
||||
this.list.push({ pattern: path, cb: cb });
|
||||
}
|
||||
|
||||
// Add a listener to all events.
|
||||
onAny(cb) {
|
||||
this.list.push({ pattern: /./, cb: cb });
|
||||
}
|
||||
|
||||
// Assume we can have multiple.
|
||||
off(path, cb) {
|
||||
_.remove(this.list, (sub) => sub.pattern.test(path) && sub.cb === cb);
|
||||
}
|
||||
|
||||
// Remove all listeners with this callback.
|
||||
offAny(cb) {
|
||||
_.remove(this.list, (sub) => sub.cb === cb);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import stores from '../stores';
|
||||
|
||||
export default {
|
||||
|
||||
// Get the POJO of the store.
|
||||
_getData(store) {
|
||||
let obj = {};
|
||||
if (store) {
|
||||
obj[store] = stores[store].get();
|
||||
} else {
|
||||
// Get all stores.
|
||||
let key;
|
||||
for (key in stores) {
|
||||
obj[key] = stores[key].get();
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
},
|
||||
|
||||
_onChange(store, val, key) {
|
||||
if (this.isMounted()) { // not ideal
|
||||
this.setState(this._getData(store));
|
||||
}
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
return this._getData();
|
||||
},
|
||||
|
||||
// Listen to all events (data changes).
|
||||
componentDidMount() {
|
||||
let key;
|
||||
for (key in stores) {
|
||||
stores[key].onAny(_.partial(this._onChange, key));
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount() {
|
||||
let key;
|
||||
for (key in stores) {
|
||||
stores[key].clean(this._onChange);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
|
@ -0,0 +1,108 @@
|
|||
import opa from 'object-path';
|
||||
import assign from 'object-assign';
|
||||
import _ from 'lodash';
|
||||
import { diff } from 'deep-diff';
|
||||
|
||||
import EventEmitter from './EventEmitter.js';
|
||||
|
||||
import actions from '../actions/appActions.js';
|
||||
|
||||
const DATA = 'data';
|
||||
|
||||
export default class Store extends EventEmitter {
|
||||
|
||||
constructor(data) {
|
||||
super();
|
||||
// Initial payload.
|
||||
this[DATA] = data || {};
|
||||
// Callbacks to cleanup.
|
||||
this._cbs = {};
|
||||
}
|
||||
|
||||
// Register an async function callback, handle loading state.
|
||||
// TODO: unit-test.
|
||||
cb(fn) {
|
||||
let id = _.uniqueId();
|
||||
actions.emit('system.loading', true);
|
||||
return this._cbs[id] = (...args) => {
|
||||
// Still running?
|
||||
if (!(id in this._cbs)) return;
|
||||
fn.apply(this, args);
|
||||
delete this._cbs[id];
|
||||
if (!(Object.keys(this._cbs).length)) {
|
||||
actions.emit('system.loading', false);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Cleanup callbacks because a View has changed thus long-running
|
||||
// functions need to end. Unreference any onChange events too.
|
||||
clean(onChange) {
|
||||
for (let id in this._cbs) delete this._cbs[id];
|
||||
if (_.isFunction(onChange)) this.offAny(onChange);
|
||||
actions.emit('system.loading', false);
|
||||
}
|
||||
|
||||
// Set a value on a key. Pass falsy value as 3rd param to not emit changes.
|
||||
set(...args) {
|
||||
if (args.length == 1) {
|
||||
var val = args[0];
|
||||
} else {
|
||||
var [ key, val, emit=true ] = args;
|
||||
}
|
||||
|
||||
// A list of changes.
|
||||
let changes = [];
|
||||
|
||||
// Object assign.
|
||||
if (!key) {
|
||||
if (emit) changes = diff(this[DATA], val) || [];
|
||||
assign(this[DATA], val);
|
||||
key = [];
|
||||
// When path is provided.
|
||||
} else {
|
||||
if (emit) changes = diff(opa.get(this[DATA], key), val) || [];
|
||||
opa.set(this[DATA], key, val);
|
||||
}
|
||||
|
||||
// Make sure the key is an array.
|
||||
if (!_.isArray(key)) key = key.split('.');
|
||||
|
||||
// Emit all changes.
|
||||
changes.forEach(ch => {
|
||||
// Form the full path.
|
||||
let path = key.concat(ch.path || []).join('.');
|
||||
// Emit the path changed and the associated object.
|
||||
this.emit(path, this.get(path));
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Unit-test.
|
||||
push(key, val) {
|
||||
let obj = this.get(key);
|
||||
if (_.isArray(obj)) {
|
||||
// TODO: Don't assume a string.
|
||||
this.set(`${key}.${obj.length}`, val); // TODO: won't emit for root key
|
||||
return obj.length - 1;
|
||||
} else {
|
||||
this.set(key, [ val ]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Get this key path or everything. Pass a callback to be
|
||||
// provided with value once it is set.
|
||||
get(path, cb) {
|
||||
let val = opa.get(this[DATA], path);
|
||||
if (!_.isFunction(cb)) return val;
|
||||
|
||||
if (opa.has(this[DATA], path)) return cb(val);
|
||||
|
||||
// TODO: unit-test.
|
||||
this.on(path, (...args) => {
|
||||
this.off(path, cb);
|
||||
cb.apply(this, args);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -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 '../../../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) }
|
||||
];
|
||||
}
|
||||
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import marked from 'marked';
|
||||
|
||||
export default {
|
||||
|
||||
// Time from now.
|
||||
// TODO: Memoize.
|
||||
fromNow(jsonDate) {
|
||||
return moment(jsonDate, moment.ISO_8601).fromNow();
|
||||
},
|
||||
|
||||
// When is a milestone due?
|
||||
due(jsonDate) {
|
||||
if (!jsonDate) {
|
||||
return '\u00a0'; // for React
|
||||
} else {
|
||||
return `due ${this.fromNow(jsonDate)}`;
|
||||
}
|
||||
},
|
||||
|
||||
// Markdown formatting.
|
||||
// TODO: works?
|
||||
markdown(...args) {
|
||||
marked.apply(null, args);
|
||||
},
|
||||
|
||||
// Format milestone title.
|
||||
title(text) {
|
||||
if (text.toLowerCase().indexOf('milestone') > -1) {
|
||||
return text;
|
||||
} else {
|
||||
return `Milestone ${text}`;
|
||||
}
|
||||
},
|
||||
|
||||
// Hex to decimal.
|
||||
hexToDec(hex) {
|
||||
return parseInt(hex, 16);
|
||||
}
|
||||
|
||||
};
|
|
@ -0,0 +1,94 @@
|
|||
import _ from 'lodash';
|
||||
import async from 'async';
|
||||
|
||||
import config from '../../../config.js';
|
||||
import request from './request.js';
|
||||
|
||||
// Fetch issues for a milestone.
|
||||
export default {
|
||||
fetchAll: (user, repo, cb) => {
|
||||
// For each `open` and `closed` issues in parallel.
|
||||
async.parallel([
|
||||
_.partial(oneStatus, user, repo, 'open'),
|
||||
_.partial(oneStatus, user, repo, 'closed')
|
||||
], (err=null, [ open, closed ]) => {
|
||||
cb(err, { open, closed });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate size of either open or closed issues.
|
||||
// Modifies issues by ref.
|
||||
let calcSize = (list) => {
|
||||
let size;
|
||||
|
||||
switch (config.chart.points) {
|
||||
case 'ONE_SIZE':
|
||||
size = list.length;
|
||||
// TODO: check we have an object?
|
||||
for (let issue of list) issue.size = 1;
|
||||
break;
|
||||
|
||||
case 'LABELS':
|
||||
size = 0;
|
||||
list = _.filter(list, (issue) => {
|
||||
let labels;
|
||||
// Skip if no labels exist.
|
||||
if (!(labels = issue.labels)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine the total issue size from all labels.
|
||||
issue.size = _.reduce(labels, (sum, label) => {
|
||||
let matches;
|
||||
if (!(matches = label.name.match(config.chart.size_label))) {
|
||||
return sum;
|
||||
}
|
||||
// Increase sum.
|
||||
return sum += parseInt(matches[1], 10);
|
||||
}, 0);
|
||||
|
||||
// Increase the total.
|
||||
size += issue.size;
|
||||
|
||||
// Issues without size (no matching labels) are not saved.
|
||||
return !!issue.size;
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw 500;
|
||||
}
|
||||
|
||||
// Sync return.
|
||||
return { list, size };
|
||||
};
|
||||
|
||||
// For each state...
|
||||
let oneStatus = (user, repo, state, cb) => {
|
||||
// Concat them here.
|
||||
let results = [];
|
||||
|
||||
let done = (err) => {
|
||||
if (err) return cb(err);
|
||||
// Sort by closed time and add the size.
|
||||
cb(null, calcSize(_.sortBy(results, 'closed_at')));
|
||||
};
|
||||
|
||||
let fetchPage;
|
||||
// One pageful fetch (next pages in series).
|
||||
return (fetchPage = (page) => {
|
||||
request.allIssues(user, repo, { state, page }, (err, data) => {
|
||||
// Errors?
|
||||
if (err) return done(err);
|
||||
// Empty?
|
||||
if (!data.length) return done(null, results);
|
||||
// Append the data.
|
||||
results = results.concat(data);
|
||||
// < 100 results?
|
||||
if (data.length < 100) return done(null, results);
|
||||
// Fetch the next page then.
|
||||
fetchPage(page + 1);
|
||||
});
|
||||
})(1);
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
import request from './request.js';
|
||||
|
||||
export default {
|
||||
// Fetch a milestone.
|
||||
fetch: request.oneMilestone,
|
||||
// Fetch all milestones.
|
||||
fetchAll: request.allMilestones
|
||||
};
|
|
@ -0,0 +1,160 @@
|
|||
import _ from 'lodash';
|
||||
import superagent from 'superagent';
|
||||
|
||||
import actions from '../../actions/appActions.js';
|
||||
|
||||
import config from '../../../config.js';
|
||||
|
||||
// Custom JSON parser.
|
||||
superagent.parse = {
|
||||
'application/json': (res) => {
|
||||
try {
|
||||
return JSON.parse(res);
|
||||
} catch(err) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Default args.
|
||||
let defaults = {
|
||||
'github': {
|
||||
'host': 'api.github.com',
|
||||
'protocol': 'https'
|
||||
}
|
||||
};
|
||||
|
||||
// Public api.
|
||||
export default {
|
||||
|
||||
// Get a repo.
|
||||
repo: (user, { owner, name }, cb) => {
|
||||
let token = (user && user.github != null) ? user.github.accessToken : null;
|
||||
let data = _.defaults({
|
||||
'path': `/repos/${owner}/${name}`,
|
||||
'headers': headers(token)
|
||||
}, defaults.github);
|
||||
|
||||
request(data, cb);
|
||||
},
|
||||
|
||||
// Get all open milestones.
|
||||
allMilestones: (user, { owner, name }, cb) => {
|
||||
let token = (user && user.github != null) ? user.github.accessToken : null;
|
||||
let data = _.defaults({
|
||||
'path': `/repos/${owner}/${name}/milestones`,
|
||||
'query': { 'state': 'open', 'sort': 'due_date', 'direction': 'asc' },
|
||||
'headers': headers(token)
|
||||
}, defaults.github);
|
||||
|
||||
request(data, cb);
|
||||
},
|
||||
|
||||
// Get one open milestone.
|
||||
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}`,
|
||||
'query': { 'state': 'open', 'sort': 'due_date', 'direction': 'asc' },
|
||||
'headers': headers(token)
|
||||
}, defaults.github);
|
||||
|
||||
request(data, cb);
|
||||
},
|
||||
|
||||
// Get all issues for a state..
|
||||
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`,
|
||||
'query': _.extend(query, { milestone, 'per_page': '100' }),
|
||||
'headers': headers(token)
|
||||
}, defaults.github);
|
||||
|
||||
return request(data, cb);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Make a request using SuperAgent.
|
||||
let request = ({ protocol, host, path, query, headers }, cb) => {
|
||||
let exited = false;
|
||||
|
||||
// Make the query params.
|
||||
let q = '';
|
||||
if (query) {
|
||||
q = '?' + _.map(query, (v, k) => { return `${k}=${v}`; }).join('&');
|
||||
}
|
||||
|
||||
// The URI.
|
||||
let req = superagent.get(`${protocol}://${host}${path}${q}`);
|
||||
// Add headers.
|
||||
_.each(headers, (v, k) => { req.set(k, v); });
|
||||
|
||||
// Timeout for requests that do not finish... see #32.
|
||||
let timeout = setTimeout(() => {
|
||||
exited = true;
|
||||
cb('Request has timed out');
|
||||
}, config.request.timeout);
|
||||
|
||||
// Send.
|
||||
req.end((err, data) => {
|
||||
// Arrived too late.
|
||||
if (exited) return;
|
||||
// All fine.
|
||||
exited = true;
|
||||
clearTimeout(timeout);
|
||||
// Actually process the response.
|
||||
response(err, data, cb);
|
||||
});
|
||||
};
|
||||
|
||||
// How do we respond to a response?
|
||||
let response = (err, data, cb) => {
|
||||
if (err) return cb(error(data.body || err));
|
||||
// 2xx?
|
||||
if (data.statusType !== 2) return cb(error(data.body));
|
||||
// All good.
|
||||
cb(null, data.body);
|
||||
};
|
||||
|
||||
// Give us headers.
|
||||
let headers = (token) => {
|
||||
// The defaults.
|
||||
let h = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/vnd.github.v3'
|
||||
};
|
||||
// Add token?
|
||||
if (token) h.Authorization = `token ${token}`;
|
||||
|
||||
return h;
|
||||
};
|
||||
|
||||
// Parse an error.
|
||||
let error = (err) => {
|
||||
let text, type;
|
||||
|
||||
switch (false) {
|
||||
case !_.isString(err):
|
||||
text = err;
|
||||
break;
|
||||
|
||||
case !_.isArray(err):
|
||||
text = err[1];
|
||||
break;
|
||||
|
||||
case !(_.isObject(err) && _.isString(err.message)):
|
||||
text = err.message;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
try {
|
||||
text = JSON.stringify(err);
|
||||
} catch (_err) {
|
||||
text = err.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
_.mixin({
|
||||
pluckMany: (source, keys) => {
|
||||
if (!_.isArray(keys)) {
|
||||
throw '`keys` needs to be an Array';
|
||||
}
|
||||
|
||||
return _.map(source, (item) => {
|
||||
let obj = {};
|
||||
for (let key of keys) {
|
||||
obj[key] = item[key];
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
import moment from 'moment';
|
||||
|
||||
// Progress in %.
|
||||
let progress = (a, b) => {
|
||||
if (a + b === 0) {
|
||||
return 0;
|
||||
} else {
|
||||
return 100 * (a / (b + a));
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate the stats for a milestone.
|
||||
// Is it on time? What is the progress?
|
||||
export default (milestone) => {
|
||||
// Makes testing easier...
|
||||
if (milestone.stats != null) return milestone.stats;
|
||||
|
||||
let isDone = false, isOnTime = true, isOverdue = false,
|
||||
isEmpty = true, points = 0, a, b, c, time, days;
|
||||
|
||||
// Progress in points.
|
||||
a = milestone.issues.closed.size;
|
||||
b = milestone.issues.open.size;
|
||||
if (a + b > 0) {
|
||||
isEmpty = false;
|
||||
points = progress(a, b);
|
||||
if (points === 100) isDone = true;
|
||||
}
|
||||
|
||||
// Milestones with no due date are always on track.
|
||||
if (!(milestone.due_on != null)) {
|
||||
return { isOverdue, isOnTime, isDone, isEmpty, 'progress': { points } };
|
||||
}
|
||||
|
||||
a = moment(milestone.created_at, moment.ISO_8601);
|
||||
b = moment.utc();
|
||||
c = moment(milestone.due_on, moment.ISO_8601);
|
||||
|
||||
// Overdue? Regardless of the date, if we have closed all
|
||||
// issues, we are no longer overdue.
|
||||
if (b.isAfter(c) && !isDone) isOverdue = true;
|
||||
|
||||
// Progress in time.
|
||||
time = progress(b.diff(a), c.diff(b));
|
||||
|
||||
// How many days is 1% of the time?
|
||||
days = (b.diff(a, 'days')) / 100;
|
||||
|
||||
// Are we on time?
|
||||
isOnTime = points > time;
|
||||
|
||||
// If we have closed all issues, we are "on time".
|
||||
if (isDone) isOnTime = true;
|
||||
|
||||
return { isDone, days, isOnTime, isOverdue, 'progress': { points, time } };
|
||||
};
|
|
@ -0,0 +1,78 @@
|
|||
import _ from 'lodash';
|
||||
import Firebase from 'firebase';
|
||||
|
||||
import Store from '../lib/Store.js';
|
||||
|
||||
import actions from '../actions/appActions.js';
|
||||
|
||||
import config from '../../config.js';
|
||||
|
||||
// Setup a new client.
|
||||
let client;
|
||||
|
||||
class AppStore extends Store {
|
||||
|
||||
// Initial payload.
|
||||
constructor() {
|
||||
super({
|
||||
'system': {
|
||||
'loading': false,
|
||||
'notification': null
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to all app actions.
|
||||
actions.onAny((obj, event) => {
|
||||
let fn = `on.${event}`.replace(/[.]+(\w|$)/g, (m, p) => p.toUpperCase());
|
||||
// Run?
|
||||
(fn in this) && this[fn](obj);
|
||||
});
|
||||
|
||||
client = new Firebase(`https://${config.firebase}.firebaseio.com`);
|
||||
|
||||
// When user is already authenticated.
|
||||
client.onAuth((data={}) => actions.emit('user.ready', data));
|
||||
}
|
||||
|
||||
onUserSignin() {
|
||||
client.authWithOAuthPopup("github", (err, data) => {
|
||||
if (!err) return actions.emit('firebase.auth', data);
|
||||
|
||||
actions.emit('system.notify', {
|
||||
'text': err.toString(),
|
||||
'type': 'alert',
|
||||
'system': true
|
||||
});
|
||||
}, {
|
||||
'rememberMe': true,
|
||||
// See https://developer.github.com/v3/oauth/#scopes
|
||||
'scope': 'repo'
|
||||
});
|
||||
}
|
||||
|
||||
// Sign-out a user.
|
||||
onUserSignout() {
|
||||
this.set('user', {});
|
||||
client.unauth();
|
||||
}
|
||||
|
||||
// Called by Firebase.
|
||||
onUserReady(user) {
|
||||
this.set('user', user);
|
||||
}
|
||||
|
||||
onSystemLoading(state) {
|
||||
this.set('system.loading', state);
|
||||
}
|
||||
|
||||
// Show a notification.
|
||||
// TODO: multiple notifications & ttl
|
||||
onSystemNotify(args) {
|
||||
if (!_.isObject(args)) args = { 'text': args };
|
||||
args.id = _.uniqueId('m-');
|
||||
this.set('system.notification', args);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new AppStore();
|
|
@ -0,0 +1,8 @@
|
|||
// The app store needs to go last because it loads user.
|
||||
import projectsStore from './projectsStore.js';
|
||||
import appStore from './appStore.js';
|
||||
|
||||
export default {
|
||||
'app': appStore,
|
||||
'projects': projectsStore
|
||||
};
|
|
@ -0,0 +1,357 @@
|
|||
import _ from 'lodash';
|
||||
import lscache from 'lscache';
|
||||
import opa from 'object-path';
|
||||
import semver from 'semver';
|
||||
import sortedIndex from 'sortedindex-compare';
|
||||
|
||||
import Store from '../lib/Store.js';
|
||||
|
||||
import actions from '../actions/appActions.js';
|
||||
|
||||
import stats from '../modules/stats.js';
|
||||
import milestones from '../modules/github/milestones.js';
|
||||
import issues from '../modules/github/issues.js';
|
||||
|
||||
class ProjectsStore extends Store {
|
||||
|
||||
// Initial payload.
|
||||
constructor() {
|
||||
// Init the projects from local storage.
|
||||
let list = lscache.get('projects') || [];
|
||||
|
||||
super({
|
||||
// A stack of projects.
|
||||
list: list,
|
||||
// A sorted projects and milestones index.
|
||||
'index': [],
|
||||
// The default sort order.
|
||||
'sortBy': 'priority',
|
||||
// Sort functions to toggle through.
|
||||
'sortFns': [ 'progress', 'priority', 'name' ]
|
||||
});
|
||||
|
||||
// Listen to only projects actions.
|
||||
actions.on('projects.*', (obj, event) => {
|
||||
let fn = `on.${event}`.replace(/[.]+(\w|$)/g, (m, p) => p.toUpperCase());
|
||||
// Run?
|
||||
(fn in this) && this[fn](obj);
|
||||
});
|
||||
|
||||
// Listen to when user is ready and save info on us.
|
||||
actions.on('user.ready', (user) => this.set('user', user));
|
||||
|
||||
// Persist projects in local storage (sans milestones and issues).
|
||||
this.on('list.*', () => {
|
||||
if (process.browser) {
|
||||
lscache.set('projects', _.pluckMany(this.get('list'), [ 'owner', 'name' ]));
|
||||
}
|
||||
});
|
||||
|
||||
// Reset our index and re-sort.
|
||||
this.on('sortBy', () => {
|
||||
this.set('index', []);
|
||||
// Run the sort again.
|
||||
this.sort();
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Wait for the user to get resolved.
|
||||
this.get('user', this.cb((user) => { // async
|
||||
if (args) {
|
||||
if ('milestone' in args) {
|
||||
// For a single milestone.
|
||||
this.getMilestone(user, {
|
||||
'owner': args.owner,
|
||||
'name': args.name
|
||||
}, args.milestone, true); // notify as well
|
||||
} 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.
|
||||
_.each(projects, _.partial(this.getProject, user), this);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Push to the stack unless it exists already.
|
||||
onProjectsAdd(project) {
|
||||
if (!_.find(this.get('list'), project)) {
|
||||
this.push('list', project);
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle through projects sort order.
|
||||
onProjectsSort() {
|
||||
let { sortBy, sortFns } = this.get();
|
||||
|
||||
let idx = 1 + sortFns.indexOf(sortBy);
|
||||
if (idx === sortFns.length) idx = 0;
|
||||
|
||||
this.set('sortBy', sortFns[idx]);
|
||||
}
|
||||
|
||||
// Demonstration projects.
|
||||
onProjectsDemo() {
|
||||
this.set({
|
||||
'list': [
|
||||
{ 'owner': 'mbostock', 'name': 'd3' },
|
||||
{ 'owner': 'radekstepan', 'name': 'disposable' },
|
||||
{ 'owner': 'rails', 'name': 'rails' }
|
||||
],
|
||||
'index': []
|
||||
});
|
||||
}
|
||||
|
||||
// Return a sort order comparator.
|
||||
comparator() {
|
||||
let { list, sortBy } = this.get();
|
||||
|
||||
// Convert existing index into actual project milestone.
|
||||
let deIdx = (fn) => {
|
||||
return ([ i, j ], ...rest) => {
|
||||
return fn.apply(this, [ [ list[i], list[i].milestones[j] ] ].concat(rest));
|
||||
};
|
||||
};
|
||||
|
||||
// Set default fields.
|
||||
let defaults = (arr, hash) => {
|
||||
for (let item of arr) {
|
||||
for (let key in hash) {
|
||||
if (!opa.has(item, key)) {
|
||||
opa.set(item, key, hash[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The actual fn selection.
|
||||
switch (sortBy) {
|
||||
// From highest progress points.
|
||||
case 'progress':
|
||||
return deIdx(([ , aM ], [ , bM ]) => {
|
||||
defaults([ aM, bM ], { 'stats.progress.points': 0 });
|
||||
// Simple points difference.
|
||||
return aM.stats.progress.points - bM.stats.progress.points;
|
||||
});
|
||||
|
||||
// From most delayed in days.
|
||||
case 'priority':
|
||||
return deIdx(([ , aM ], [ , bM ]) => {
|
||||
// Milestones with no deadline are always at the "beginning".
|
||||
defaults([ aM, bM ], { 'stats.progress.time': 0, 'stats.days': 1e3 });
|
||||
// % difference in progress times the number of days ahead or behind.
|
||||
let [ $a, $b ] = _.map([ aM, bM ], ({ stats }) => {
|
||||
return (stats.progress.points - stats.progress.time) * stats.days;
|
||||
});
|
||||
|
||||
return $b - $a;
|
||||
});
|
||||
|
||||
// Based on project then milestone name including semver.
|
||||
case 'name':
|
||||
return deIdx(([ aP, aM ], [ bP, bM ]) => {
|
||||
let owner, name;
|
||||
|
||||
if (owner = bP.owner.localeCompare(aP.owner)) {
|
||||
return owner;
|
||||
}
|
||||
if (name = bP.name.localeCompare(aP.name)) {
|
||||
return name;
|
||||
}
|
||||
|
||||
// Try semver.
|
||||
if (semver.valid(bM.title) && semver.valid(aM.title)) {
|
||||
return semver.gt(bM.title, aM.title);
|
||||
// Back to string compare.
|
||||
} else {
|
||||
return bM.title.localeCompare(aM.title);
|
||||
}
|
||||
});
|
||||
|
||||
// The "whatever" sort order...
|
||||
default:
|
||||
return () => { return 0; }
|
||||
}
|
||||
}
|
||||
|
||||
// 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, say) {
|
||||
// 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, say);
|
||||
// Now add in the issues.
|
||||
this.getIssues(user, p, milestone, say);
|
||||
}));
|
||||
}
|
||||
|
||||
// Fetch all issues for a milestone.
|
||||
getIssues(user, p, m, say) {
|
||||
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, say);
|
||||
// Add in the issues to the milestone.
|
||||
_.extend(m, { 'issues': obj });
|
||||
// Save the milestone.
|
||||
this.addMilestone(p, m, say);
|
||||
}));
|
||||
}
|
||||
|
||||
// Talk about the stats of a milestone.
|
||||
notify(stats) {
|
||||
if (stats.isEmpty) {
|
||||
return actions.emit('system.notify', {
|
||||
'text': 'This milestone has no issues',
|
||||
'type': 'warn',
|
||||
'system': true,
|
||||
'ttl': null
|
||||
});
|
||||
}
|
||||
|
||||
if (stats.isDone) {
|
||||
actions.emit('system.notify', {
|
||||
'text': 'This milestone is complete',
|
||||
'type': 'success'
|
||||
});
|
||||
}
|
||||
|
||||
if (stats.isOverdue) {
|
||||
actions.emit('system.notify', {
|
||||
'text': 'This milestone is overdue',
|
||||
'type': 'warn'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add a milestone for a project.
|
||||
addMilestone(project, milestone, say) {
|
||||
// Add in the stats.
|
||||
let i, j;
|
||||
_.extend(milestone, { 'stats': stats(milestone) });
|
||||
|
||||
// Notify?
|
||||
if (say) this.notify(milestone.stats);
|
||||
|
||||
// We are supposed to exist already.
|
||||
if ((i = this.findIndex(project)) < 0) { throw 500; }
|
||||
|
||||
// Does the milestone exist already?
|
||||
let milestones;
|
||||
if (milestones = this.get(`list.${i}.milestones`)) {
|
||||
j = _.findIndex(milestones, { 'number': milestone.number });
|
||||
// Just make an update then.
|
||||
if (j != -1) {
|
||||
return this.set(`list.${i}.milestones.${j}`, milestone);
|
||||
}
|
||||
}
|
||||
|
||||
// Push the milestone and return the index.
|
||||
j = this.push(`list.${i}.milestones`, milestone);
|
||||
|
||||
// Now index this milestone.
|
||||
this.sort([ i, j ], [ project, milestone ]);
|
||||
}
|
||||
|
||||
// Find index of a project.
|
||||
findIndex({ owner, name }) {
|
||||
return _.findIndex(this.get('list'), { owner, name });
|
||||
}
|
||||
|
||||
// Save an error from loading milestones or issues.
|
||||
// TODO: clear these when we fetch all projects anew.
|
||||
saveError(project, err, say=false) {
|
||||
var idx;
|
||||
if ((idx = this.findIndex(project)) > -1) {
|
||||
this.push(`list.${idx}.errors`, err);
|
||||
} else {
|
||||
// We are supposed to exist already.
|
||||
throw 500;
|
||||
}
|
||||
|
||||
// Notify?
|
||||
if (!say) return;
|
||||
actions.emit('system.notify', {
|
||||
'text': err,
|
||||
'type': 'alert',
|
||||
'system': true,
|
||||
'ttl': null
|
||||
});
|
||||
}
|
||||
|
||||
// Sort projects (update the index). Can pass reference to the
|
||||
// project and milestone index in the stack.
|
||||
sort(ref, data) {
|
||||
let idx;
|
||||
// Get the existing index.
|
||||
let index = this.get('index');
|
||||
|
||||
// Index one milestone in an already sorted index.
|
||||
if (ref) {
|
||||
idx = sortedIndex(index, data, this.comparator());
|
||||
index.splice(idx, 0, ref);
|
||||
// Sort them all.
|
||||
} else {
|
||||
let list = this.get('list');
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
let p = list[i];
|
||||
// TODO: need to show projects that failed too...
|
||||
if (p.milestones == null) continue;
|
||||
// Walk the milestones.
|
||||
for (let j = 0; j < p.milestones.length; j++) {
|
||||
let m = p.milestones[j];
|
||||
// Run a comparator here inserting into index.
|
||||
idx = sortedIndex(index, [ p, m ], this.comparator());
|
||||
index.splice(idx, 0, [ i, j ]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.set('index', index);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new ProjectsStore();
|
|
@ -0,0 +1,43 @@
|
|||
.easeInOutBack(@time) {
|
||||
.transition(top @time cubic-bezier(0.68, -0.55, 0.265, 1.55));
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
.animTop-enter {
|
||||
top: -68px;
|
||||
}
|
||||
|
||||
.animTop-enter-active {
|
||||
top: 0px;
|
||||
.easeInOutBack(2000ms);
|
||||
}
|
||||
|
||||
.animTop-leave {
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
.animTop-leave-active {
|
||||
top: -68px;
|
||||
.easeInOutBack(1000ms);
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
.animCenter-enter {
|
||||
top: 0%;
|
||||
}
|
||||
|
||||
.animCenter-enter-active {
|
||||
top: 50%;
|
||||
.easeInOutBack(2000ms);
|
||||
}
|
||||
|
||||
.animCenter-leave {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.animCenter-leave-active {
|
||||
top: 0%;
|
||||
.easeInOutBack(1000ms);
|
||||
}
|
|
@ -25,6 +25,7 @@ a {
|
|||
text-decoration: none;
|
||||
color: #aaafbf;
|
||||
cursor: pointer;
|
||||
.user-select(none);
|
||||
}
|
||||
|
||||
h1, h2, h3, p {
|
||||
|
@ -49,13 +50,14 @@ ul {
|
|||
|
||||
#notify {
|
||||
position: fixed;
|
||||
top: -68px;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
background: #fcfcfc;
|
||||
color: #aaafbf;
|
||||
border-top: 3px solid #aaafbf;
|
||||
border-bottom: 1px solid #f3f4f8;
|
||||
cursor: default;
|
||||
.user-select(none);
|
||||
|
||||
.close {
|
||||
float: right;
|
||||
|
@ -70,7 +72,7 @@ ul {
|
|||
}
|
||||
|
||||
&.system {
|
||||
top: 0%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 500px;
|
||||
.transform(translateX(-50%) translateY(-50%));
|
|
@ -8,4 +8,5 @@
|
|||
@import "fonts.less";
|
||||
@import "icons.less";
|
||||
@import "chart.less";
|
||||
@import "animations.less";
|
||||
@import "app.less";
|
|
@ -1,35 +0,0 @@
|
|||
Model = require '../utils/ractive/model.coffee'
|
||||
|
||||
module.exports = new Model
|
||||
|
||||
'name': 'models/config'
|
||||
|
||||
"data":
|
||||
# Firebase app name.
|
||||
"firebase": "burnchart"
|
||||
# Data source provider.
|
||||
"provider": "github"
|
||||
# Fields to keep from GH responses.
|
||||
"fields":
|
||||
"milestone": [
|
||||
"closed_issues"
|
||||
"created_at"
|
||||
"description"
|
||||
"due_on"
|
||||
"number"
|
||||
"open_issues"
|
||||
"title"
|
||||
"updated_at"
|
||||
]
|
||||
# Chart configuration.
|
||||
"chart":
|
||||
# Days we are not working. Mon = 1
|
||||
"off_days": [ ]
|
||||
# How does a size label look like?
|
||||
"size_label": /^size (\d+)$/
|
||||
# Process all issues as one size (ONE_SIZE) or use labels (LABELS).
|
||||
"points": 'ONE_SIZE'
|
||||
# Request pertaining.
|
||||
"request":
|
||||
# Default timeout of 5s.
|
||||
"timeout": 5e3
|
|
@ -1,46 +0,0 @@
|
|||
Firebase = require 'firebase'
|
||||
|
||||
Model = require '../utils/ractive/model.coffee'
|
||||
user = require './user.coffee'
|
||||
config = require './config.coffee'
|
||||
|
||||
module.exports = new Model
|
||||
|
||||
'name': 'models/firebase'
|
||||
|
||||
# Sign-in a user.
|
||||
signin: (cb) ->
|
||||
cb 'Not ready yet' unless @data.client
|
||||
|
||||
@data.client.authWithOAuthPopup "github", (err, authData) =>
|
||||
return @publish '!app/notify', {
|
||||
'text': do err.toString
|
||||
'type': 'alert'
|
||||
'system': yes
|
||||
} if err
|
||||
|
||||
@onAuth authData
|
||||
,
|
||||
'rememberMe': yes
|
||||
# See https://developer.github.com/v3/oauth/#scopes
|
||||
'scope': 'repo'
|
||||
|
||||
# When we sign-in/-out.
|
||||
onAuth: (data={}) ->
|
||||
# Save user.
|
||||
user.set data
|
||||
# Say we are done.
|
||||
user.set 'ready', yes
|
||||
|
||||
# Sign-out a user.
|
||||
signout: ->
|
||||
do user.reset
|
||||
user.set 'uid', null
|
||||
do @data.client.unauth
|
||||
|
||||
onrender: ->
|
||||
# Setup a new client.
|
||||
@set 'client', client = new Firebase "https://#{config.data.firebase}.firebaseio.com"
|
||||
|
||||
# When user is authenticated.
|
||||
client.onAuth @onAuth
|
|
@ -1,172 +0,0 @@
|
|||
_ = require 'lodash'
|
||||
lscache = require 'lscache'
|
||||
sortedIndex = require 'sortedindex-compare'
|
||||
semver = require 'semver'
|
||||
|
||||
Model = require '../utils/ractive/model.coffee'
|
||||
config = require '../models/config.coffee'
|
||||
stats = require '../modules/stats.coffee'
|
||||
user = require './user.coffee'
|
||||
|
||||
module.exports = new Model
|
||||
|
||||
'name': 'models/projects'
|
||||
|
||||
'data':
|
||||
# Current sort order.
|
||||
'sortBy': 'priority'
|
||||
# Sort functions.
|
||||
'sortFns': [ 'progress', 'priority', 'name' ]
|
||||
|
||||
# Return a sort order comparator.
|
||||
comparator: ->
|
||||
{ list, sortBy } = @data
|
||||
|
||||
# Convert existing index into actual project milestone.
|
||||
deIdx = (fn) =>
|
||||
([ i, j ], rest...) =>
|
||||
fn.apply @, [ [ list[i], list[i].milestones[j] ] ].concat rest
|
||||
|
||||
# Set default fields, in place.
|
||||
defaults = (arr, hash) ->
|
||||
for item in arr
|
||||
for k, v of hash
|
||||
ref = item
|
||||
for p, i in keys = k.split '.'
|
||||
if i is keys.length - 1
|
||||
ref[p] ?= v
|
||||
else
|
||||
ref = ref[p] ?= {}
|
||||
|
||||
# The actual fn selection.
|
||||
switch sortBy
|
||||
# From highest progress points.
|
||||
when 'progress' then deIdx ([ aP, aM ], [ bP, bM ]) ->
|
||||
defaults [ aM, bM ], { 'stats.progress.points': 0 }
|
||||
# Simple points difference.
|
||||
aM.stats.progress.points - bM.stats.progress.points
|
||||
|
||||
# From most delayed in days.
|
||||
when 'priority' then deIdx ([ aP, aM ], [ bP, bM ]) ->
|
||||
# Milestones with no deadline are always at the "beginning".
|
||||
defaults [ aM, bM ], { 'stats.progress.time': 0, 'stats.days': 1e3 }
|
||||
# % difference in progress times the number of days ahead or behind.
|
||||
[ $a, $b ] = _.map [ aM, bM ], ({ stats }) ->
|
||||
(stats.progress.points - stats.progress.time) * stats.days
|
||||
|
||||
$b - $a
|
||||
|
||||
# Based on project then milestone name including semver.
|
||||
when 'name' then deIdx ([ aP, aM ], [ bP, bM ]) ->
|
||||
return owner if owner = bP.owner.localeCompare aP.owner
|
||||
return name if name = bP.name.localeCompare aP.name
|
||||
# Try semver.
|
||||
if semver.valid(bM.title) and semver.valid(aM.title)
|
||||
semver.gt bM.title, aM.title
|
||||
# Back to string compare.
|
||||
else
|
||||
bM.title.localeCompare aM.title
|
||||
|
||||
# The "whatever" sort order...
|
||||
else -> 0
|
||||
|
||||
find: (project) ->
|
||||
_.find @data.list, project
|
||||
|
||||
exists: ->
|
||||
!!@find.apply @, arguments
|
||||
|
||||
# Push to the stack unless it exists already.
|
||||
add: (project) ->
|
||||
@push 'list', project unless @exists project
|
||||
|
||||
# Find index of a project.
|
||||
findIndex: ({ owner, name }) ->
|
||||
_.findIndex @data.list, { owner, name }
|
||||
|
||||
# Add a milestone for a project.
|
||||
addMilestone: (project, milestone) ->
|
||||
# Add in the stats.
|
||||
_.extend milestone, { 'stats': stats(milestone) }
|
||||
# We are supposed to exist already.
|
||||
throw 500 if (i = @findIndex(project)) < 0
|
||||
|
||||
# Have milestones already?
|
||||
if project.milestones?
|
||||
@push "list.#{i}.milestones", milestone
|
||||
j = @data.list[i].milestones.length - 1 # index in milestones
|
||||
else
|
||||
@set "list.#{i}.milestones", [ milestone ]
|
||||
j = 0 # index in milestones
|
||||
|
||||
# Now index this milestone.
|
||||
@sort [ i, j ], [ project, milestone ]
|
||||
|
||||
# Save an error from loading milestones or issues
|
||||
saveError: (project, err) ->
|
||||
if (idx = @findIndex(project)) > -1
|
||||
if project.errors?
|
||||
@push "list.#{idx}.errors", err
|
||||
else
|
||||
@set "list.#{idx}.errors", [ err ]
|
||||
else
|
||||
# We are supposed to exist already.
|
||||
throw 500
|
||||
|
||||
# TODO: remove in production.
|
||||
demo: ->
|
||||
@set 'list': [
|
||||
{ 'owner': 'mbostock', 'name': 'd3' }
|
||||
{ 'owner': 'medic', 'name': 'medic-webapp' }
|
||||
{ 'owner': 'ractivejs', 'name': 'ractive' }
|
||||
{ 'owner': 'radekstepan', 'name': 'disposable' }
|
||||
{ 'owner': 'rails', 'name': 'rails' }
|
||||
{ 'owner': 'rails', 'name': 'spring' }
|
||||
], 'index': []
|
||||
|
||||
clear: ->
|
||||
@set 'list': [], 'index': []
|
||||
|
||||
# Sort/or insert into an already sorted index.
|
||||
sort: (ref, data) ->
|
||||
# Get or initialize the index.
|
||||
index = @data.index or []
|
||||
|
||||
# Do one.
|
||||
if ref
|
||||
idx = sortedIndex index, data, do @comparator
|
||||
index.splice idx, 0, ref
|
||||
# Do all.
|
||||
else
|
||||
for p, i in @data.list
|
||||
# TODO: need to show projects that failed too...
|
||||
continue unless p.milestones?
|
||||
for m, j in p.milestones
|
||||
# Run a comparator here inserting into index.
|
||||
idx = sortedIndex index, [ p, m ], do @comparator
|
||||
# Log.
|
||||
index.splice idx, 0, [ i, j ]
|
||||
|
||||
# Save the index.
|
||||
@set 'index', index
|
||||
|
||||
onconstruct: ->
|
||||
@subscribe '!projects/add', @add, @
|
||||
@subscribe '!projects/demo', @demo, @
|
||||
|
||||
onrender: ->
|
||||
# Init the projects.
|
||||
@set 'list', lscache.get('projects') or []
|
||||
|
||||
# Persist projects in local storage (sans milestones).
|
||||
@observe 'list', (projects) ->
|
||||
lscache.set 'projects', _.pluckMany projects, [ 'owner', 'name' ]
|
||||
, 'init': no
|
||||
|
||||
# Reset our index and re-sort.
|
||||
@observe 'sortBy', ->
|
||||
# Use pop as Ractive is glitchy when resetting arrays.
|
||||
@set 'index', null
|
||||
# Run the sort again.
|
||||
do @sort
|
||||
, 'init': no
|
|
@ -1,19 +0,0 @@
|
|||
Model = require '../utils/ractive/model.coffee'
|
||||
|
||||
# System state.
|
||||
system = new Model
|
||||
|
||||
'name': 'models/system'
|
||||
|
||||
'data':
|
||||
'loading': no
|
||||
|
||||
counter = 0
|
||||
async = ->
|
||||
counter += 1
|
||||
system.set 'loading', yes
|
||||
->
|
||||
counter -= 1
|
||||
system.set 'loading', +counter
|
||||
|
||||
module.exports = { system, async }
|
|
@ -1,9 +0,0 @@
|
|||
Model = require '../utils/ractive/model.coffee'
|
||||
|
||||
# Currently logged-in user.
|
||||
module.exports = new Model
|
||||
|
||||
'name': 'models/user'
|
||||
|
||||
'data':
|
||||
'uid': null
|
|
@ -1,20 +0,0 @@
|
|||
d3 = require 'd3'
|
||||
|
||||
module.exports =
|
||||
|
||||
horizontal: (height, x) ->
|
||||
d3.svg.axis().scale(x)
|
||||
.orient("bottom")
|
||||
# Show vertical lines...
|
||||
.tickSize(-height)
|
||||
# ...with day of the month...
|
||||
.tickFormat( (d) -> d.getDate() )
|
||||
# ...and give us a spacer.
|
||||
.tickPadding(10)
|
||||
|
||||
vertical: (width, y) ->
|
||||
d3.svg.axis().scale(y)
|
||||
.orient("left")
|
||||
.tickSize(-width)
|
||||
.ticks(5)
|
||||
.tickPadding(10)
|
|
@ -1,136 +0,0 @@
|
|||
_ = require 'lodash'
|
||||
d3 = require 'd3'
|
||||
moment = require 'moment'
|
||||
|
||||
config = require '../../models/config.coffee'
|
||||
|
||||
module.exports =
|
||||
|
||||
# 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) ->
|
||||
head = [ {
|
||||
'date': moment(created_at, moment.ISO_8601).toJSON()
|
||||
'points': total
|
||||
} ]
|
||||
|
||||
min = +Infinity ; max = -Infinity
|
||||
|
||||
# Generate the actual closes.
|
||||
rest = _.map issues, (issue) ->
|
||||
{ size, closed_at } = issue
|
||||
# Determine the range.
|
||||
min = size if size < min
|
||||
max = size if size > max
|
||||
|
||||
# Dropping points remaining.
|
||||
issue.date = moment(closed_at, moment.ISO_8601).toJSON()
|
||||
issue.points = total -= size
|
||||
issue
|
||||
|
||||
# Now add a radius in a range (will be used for a circle).
|
||||
range = d3.scale.linear().domain([ min, max ]).range([ 5, 8 ])
|
||||
|
||||
rest = _.map rest, (issue) ->
|
||||
issue.radius = range issue.size
|
||||
issue
|
||||
|
||||
[].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...
|
||||
[ b, a ] = [ a, b ] if b < a
|
||||
|
||||
a = moment a, moment.ISO_8601
|
||||
# Do we have a due date?
|
||||
b = if b? then moment(b, moment.ISO_8601) else do moment.utc
|
||||
|
||||
# Go through the beginning to the end skipping off days.
|
||||
days = [] ; length = 0
|
||||
do once = (inc = 0) ->
|
||||
# A new day. TODO: deal with hours and minutes!
|
||||
day = a.add 1, 'days'
|
||||
|
||||
# Does this day count?
|
||||
day_of = 7 unless day_of = do day.weekday
|
||||
if day_of in config.data.chart.off_days
|
||||
days.push { 'date': do day.toJSON, 'off_day': yes }
|
||||
else
|
||||
length += 1
|
||||
days.push { 'date': do day.toJSON }
|
||||
|
||||
# Go again?
|
||||
once(inc + 1) unless day > b
|
||||
|
||||
# Map points on the array of days now.
|
||||
velocity = total / (length - 1)
|
||||
|
||||
days = _.map days, (day, i) ->
|
||||
day.points = total
|
||||
total -= velocity if days[i] and not days[i].off_day
|
||||
day
|
||||
|
||||
# Do we need to make a link to right now?
|
||||
days.push { 'date': do now.toJSON, 'points': 0 } if (now = do moment.utc) > b
|
||||
|
||||
days
|
||||
|
||||
# Graph representing a trendling of actual issues.
|
||||
trend: (actual, created_at, due_on) ->
|
||||
return [] unless actual.length
|
||||
|
||||
[ first, ..., last ] = actual
|
||||
|
||||
start = moment first.date, moment.ISO_8601
|
||||
|
||||
# Values is a list of time from the start and points remaining.
|
||||
values = _.map actual, ({ date, points }) ->
|
||||
[ moment(date, moment.ISO_8601).diff(start), points ]
|
||||
|
||||
# Now is an actual point too.
|
||||
now = do moment.utc
|
||||
values.push [ now.diff(start), last.points ]
|
||||
|
||||
# http://classroom.synonym.com/calculate-trendline-2709.html
|
||||
b1 = 0 ; e = 0 ; c1 = 0
|
||||
a = (l = values.length) * _.reduce(values, (sum, [ a, b ]) ->
|
||||
b1 += a ; e += b
|
||||
c1 += Math.pow(a, 2)
|
||||
sum + (a * b)
|
||||
, 0)
|
||||
|
||||
slope = (a - (b1 * e)) / ((l * c1) - (Math.pow(b1, 2)))
|
||||
intercept = (e - (slope * b1)) / l
|
||||
|
||||
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?
|
||||
due_on = now if now > due_on
|
||||
# No due date
|
||||
else
|
||||
due_on = now
|
||||
|
||||
a = created_at.diff start
|
||||
b = due_on.diff start
|
||||
|
||||
[
|
||||
{
|
||||
'date': do created_at.toJSON
|
||||
'points': fn a
|
||||
}, {
|
||||
'date': do due_on.toJSON
|
||||
'points': fn b
|
||||
}
|
||||
]
|
|
@ -1,76 +0,0 @@
|
|||
_ = require 'lodash'
|
||||
async = require 'async'
|
||||
|
||||
config = require '../../models/config.coffee'
|
||||
request = require './request.coffee'
|
||||
|
||||
module.exports =
|
||||
|
||||
# Fetch issues for a milestone.
|
||||
fetchAll: (repo, cb) ->
|
||||
# For each `open` and `closed` issues in parallel.
|
||||
async.parallel [
|
||||
_.partial(oneStatus, repo, 'open')
|
||||
_.partial(oneStatus, repo, 'closed')
|
||||
], (err, [ open, closed ]) ->
|
||||
err ?= null
|
||||
cb err, { open, closed }
|
||||
|
||||
# Calculate size of either open or closed issues.
|
||||
# Modifies issues by ref.
|
||||
calcSize = (list) ->
|
||||
switch config.data.chart.points
|
||||
when 'ONE_SIZE'
|
||||
size = list.length
|
||||
( issue.size = 1 for issue in list )
|
||||
|
||||
when 'LABELS'
|
||||
size = 0
|
||||
|
||||
list = _.filter list, (issue) ->
|
||||
# Skip if no labels exist.
|
||||
return no unless labels = issue.labels
|
||||
|
||||
# Determine the total issue size from all labels.
|
||||
issue.size = _.reduce labels, (sum, label) ->
|
||||
# Not matching.
|
||||
return sum unless matches = label.name.match config.data.chart.size_label
|
||||
# Increase sum.
|
||||
sum += parseInt matches[1]
|
||||
, 0
|
||||
|
||||
# Increase the total.
|
||||
size += issue.size
|
||||
|
||||
# Issues without size (no matching labels) are not saved.
|
||||
!!issue.size
|
||||
|
||||
else
|
||||
throw 500
|
||||
|
||||
# Sync return.
|
||||
{ list, size }
|
||||
|
||||
# For each state...
|
||||
oneStatus = (repo, state, cb) ->
|
||||
# Concat them here.
|
||||
results = []
|
||||
|
||||
done = (err) ->
|
||||
return cb err if err
|
||||
# Sort by closed time and add the size.
|
||||
cb null, calcSize _.sortBy results, 'closed_at'
|
||||
|
||||
# One pageful fetch (next pages in series).
|
||||
do fetchPage = (page=1) ->
|
||||
request.allIssues repo, { state, page }, (err, data) ->
|
||||
# Errors?
|
||||
return done err if err
|
||||
# Empty?
|
||||
return done null, results unless data.length
|
||||
# Append the data.
|
||||
results = results.concat data
|
||||
# < 100 results?
|
||||
return done null, results if data.length < 100
|
||||
# Fetch the next page then.
|
||||
fetchPage page + 1
|
|
@ -1,9 +0,0 @@
|
|||
request = require './request.coffee'
|
||||
|
||||
module.exports =
|
||||
|
||||
# Fetch a milestone.
|
||||
'fetch': request.oneMilestone
|
||||
|
||||
# Fetch all milestones.
|
||||
'fetchAll': request.allMilestones
|
|
@ -1,171 +0,0 @@
|
|||
_ = require 'lodash'
|
||||
superagent = require 'superagent'
|
||||
|
||||
# Lodash mixins.
|
||||
require '../../utils/mixins.coffee'
|
||||
|
||||
config = require '../../models/config.coffee'
|
||||
user = require '../../models/user.coffee'
|
||||
mediator = require '../mediator.coffee'
|
||||
|
||||
# Custom JSON parser.
|
||||
superagent.parse =
|
||||
'application/json': (res) ->
|
||||
try
|
||||
JSON.parse res
|
||||
catch e
|
||||
{} # it was not to be...
|
||||
|
||||
# Default args.
|
||||
defaults =
|
||||
'github':
|
||||
'host': 'api.github.com'
|
||||
'protocol': 'https'
|
||||
|
||||
# Public api.
|
||||
module.exports =
|
||||
|
||||
# Get a repo.
|
||||
repo: ({ owner, name }, cb) ->
|
||||
return cb 'Request is malformed' unless isValid { owner, name }
|
||||
|
||||
ready ->
|
||||
data = _.defaults
|
||||
'path': "/repos/#{owner}/#{name}"
|
||||
'headers': headers user.data.github?.accessToken
|
||||
, defaults.github
|
||||
|
||||
request data, cb
|
||||
|
||||
# Get all open milestones.
|
||||
allMilestones: ({ owner, name }, cb) ->
|
||||
return cb 'Request is malformed' unless isValid { owner, name }
|
||||
|
||||
ready ->
|
||||
data = _.defaults
|
||||
'path': "/repos/#{owner}/#{name}/milestones"
|
||||
'query': { 'state': 'open', 'sort': 'due_date', 'direction': 'asc' }
|
||||
'headers': headers user.data.github?.accessToken
|
||||
, defaults.github
|
||||
|
||||
request data, cb
|
||||
|
||||
# Get one open milestone.
|
||||
oneMilestone: ({ owner, name, milestone }, cb) ->
|
||||
return cb 'Request is malformed' unless isValid { owner, name, milestone }
|
||||
|
||||
ready ->
|
||||
data = _.defaults
|
||||
'path': "/repos/#{owner}/#{name}/milestones/#{milestone}"
|
||||
'query': { 'state': 'open', 'sort': 'due_date', 'direction': 'asc' }
|
||||
'headers': headers user.data.github?.accessToken
|
||||
, defaults.github
|
||||
|
||||
request data, cb
|
||||
|
||||
# Get all issues for a state.
|
||||
allIssues: ({ owner, name, milestone }, query, cb) ->
|
||||
return cb 'Request is malformed' unless isValid { owner, name, milestone }
|
||||
|
||||
ready ->
|
||||
data = _.defaults
|
||||
'path': "/repos/#{owner}/#{name}/issues"
|
||||
'query': _.extend query, { milestone, 'per_page': '100' }
|
||||
'headers': headers user.data.github?.accessToken
|
||||
, defaults.github
|
||||
|
||||
request data, cb
|
||||
|
||||
# Make a request using SuperAgent.
|
||||
request = ({ protocol, host, path, query, headers }, cb) ->
|
||||
exited = no
|
||||
|
||||
# Make the query params.
|
||||
q = if query then '?' + ( "#{k}=#{v}" for k, v of query ).join('&') else ''
|
||||
|
||||
# The URI.
|
||||
req = superagent.get "#{protocol}://#{host}#{path}#{q}"
|
||||
# Add headers.
|
||||
( req.set(k, v) for k, v of headers )
|
||||
|
||||
# Timeout for requests that do not finish... see #32.
|
||||
timeout = setTimeout ->
|
||||
exited = yes
|
||||
cb 'Request has timed out'
|
||||
, config.data.request.timeout # wait this long
|
||||
|
||||
# Send.
|
||||
req.end (err, data) ->
|
||||
# Arrived too late.
|
||||
return if exited
|
||||
# All fine.
|
||||
exited = yes
|
||||
clearTimeout timeout
|
||||
# Actually process the response.
|
||||
response err, data, cb
|
||||
|
||||
# How do we respond to a response?
|
||||
response = (err, data, cb) ->
|
||||
return cb error err if err
|
||||
# 2xx?
|
||||
return cb error data.body if data.statusType isnt 2
|
||||
# All good.
|
||||
cb null, data.body
|
||||
|
||||
# Give us headers.
|
||||
headers = (token) ->
|
||||
# The defaults.
|
||||
h =
|
||||
'Content-Type': 'application/json'
|
||||
'Accept': 'application/vnd.github.v3'
|
||||
# Add token?
|
||||
h.Authorization = "token #{token}" if token?
|
||||
h
|
||||
|
||||
isValid = (obj) ->
|
||||
rules =
|
||||
'owner': (val) -> val?
|
||||
'name': (val) -> val?
|
||||
'milestone': (val) -> _.isInt val
|
||||
|
||||
( return no for key, val of obj when key of rules and not rules[key](val) )
|
||||
|
||||
yes
|
||||
|
||||
# Switch when user is ready.
|
||||
isReady = user.data.ready
|
||||
|
||||
# A stack of requests to execute once ready.
|
||||
stack = []
|
||||
ready = (cb) ->
|
||||
if isReady then do cb else stack.push cb
|
||||
|
||||
# Observe user's readiness.
|
||||
user.observe 'ready', (val) ->
|
||||
isReady = val
|
||||
# Clear the stack?
|
||||
( do stack.shift() while stack.length ) if val
|
||||
|
||||
# Parse an error.
|
||||
error = (err) ->
|
||||
switch
|
||||
when _.isString err
|
||||
text = err
|
||||
when _.isArray err
|
||||
text = err[1]
|
||||
when _.isObject(err) and _.isString(err.message)
|
||||
text = err.message
|
||||
|
||||
unless text
|
||||
try
|
||||
text = JSON.stringify err
|
||||
catch
|
||||
text = do err.toString
|
||||
|
||||
# API rate limit exceeded? Flash a message to that effect.
|
||||
# https://developer.github.com/v3/#rate-limiting
|
||||
if /API rate limit exceeded/.test text
|
||||
type = 'warn'
|
||||
mediator.fire '!app/notify', { type, text }
|
||||
|
||||
text
|
|
@ -1,5 +0,0 @@
|
|||
Ractive = require 'ractive'
|
||||
|
||||
Mediator = Ractive.extend {}
|
||||
|
||||
module.exports = new Mediator()
|
|
@ -1,49 +0,0 @@
|
|||
_ = require 'lodash'
|
||||
director = require 'director'
|
||||
|
||||
mediator = require './mediator.coffee'
|
||||
system = require '../models/system.coffee'
|
||||
|
||||
el = '#page'
|
||||
|
||||
pages =
|
||||
"index": require "../views/pages/index.coffee"
|
||||
"milestone": require "../views/pages/milestone.coffee"
|
||||
"new": require "../views/pages/new.coffee"
|
||||
"project": require "../views/pages/project.coffee"
|
||||
|
||||
# Add a project from a route.
|
||||
addProject = (page, owner, name) ->
|
||||
mediator.fire '!projects/add', { owner, name }
|
||||
|
||||
# Preapply all functions with our page name/context.
|
||||
c = (name, fns=[]) ->
|
||||
( _.partial fn, name for fn in fns )
|
||||
|
||||
view = null
|
||||
route = (page, args...) ->
|
||||
# Unrender the previous one.
|
||||
do view?.teardown
|
||||
# Hide any notifications.
|
||||
mediator.fire '!app/notify/hide'
|
||||
# Require the new one.
|
||||
Page = pages[page]
|
||||
# Render it.
|
||||
view = new Page { el, 'data': { 'route': args } }
|
||||
|
||||
routes =
|
||||
'/': c 'index', [ route ]
|
||||
'/new/project': c 'new', [ route ]
|
||||
# The following two routes add a project in the background.
|
||||
'/:owner/:name': c 'project', [ addProject, route ]
|
||||
'/:owner/:name/:milestone': c 'milestone', [ addProject, route ]
|
||||
# TODO: remove in production.
|
||||
'/demo': ->
|
||||
mediator.fire '!projects/demo'
|
||||
window.location.hash = '#'
|
||||
|
||||
# Flatiron Director router.
|
||||
module.exports = director.Router(routes).configure
|
||||
'strict': no # allow trailing slashes
|
||||
notfound: ->
|
||||
throw 404
|
|
@ -1,49 +0,0 @@
|
|||
moment = require 'moment'
|
||||
|
||||
# Progress in %.
|
||||
progress = (a, b) ->
|
||||
if a + b is 0 then 0 else 100 * (a / (b + a))
|
||||
|
||||
# Calculate the stats for a milestone.
|
||||
# Is it on time? What is the progress?
|
||||
module.exports = (milestone) ->
|
||||
# Makes testing easier...
|
||||
return milestone.stats if milestone.stats?
|
||||
|
||||
isDone = no ; isOnTime = yes ; isOverdue = no ; isEmpty = yes; points = 0
|
||||
|
||||
# Progress in points.
|
||||
a = milestone.issues.closed.size
|
||||
b = milestone.issues.open.size
|
||||
if a + b > 0
|
||||
isEmpty = no
|
||||
points = progress a, b
|
||||
isDone = yes if points is 100
|
||||
|
||||
# Milestones with no due date are always on track.
|
||||
return { isOverdue, isOnTime, isDone, isEmpty, 'progress': { points } } unless milestone.due_on?
|
||||
|
||||
a = moment milestone.created_at, moment.ISO_8601
|
||||
b = do moment.utc
|
||||
c = moment milestone.due_on, moment.ISO_8601
|
||||
|
||||
# Overdue? Regardless of the date, if we have closed all
|
||||
# issues, we are no longer overdue.
|
||||
isOverdue = yes if b.isAfter(c) and not isDone
|
||||
|
||||
# Progress in time.
|
||||
time = progress b.diff(a), c.diff(b)
|
||||
|
||||
# How many days is 1% of the time?
|
||||
days = (b.diff(a, 'days')) / 100
|
||||
|
||||
# Are we on time?
|
||||
isOnTime = points > time
|
||||
|
||||
# If we have closed all issues, we are "on time".
|
||||
isOnTime = yes if isDone
|
||||
|
||||
{
|
||||
isDone, days, isOnTime, isOverdue
|
||||
'progress': { points, time }
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
<div id="app">
|
||||
<Notify/>
|
||||
<Header/>
|
||||
|
||||
<div id="page">
|
||||
<!-- content loaded from a router -->
|
||||
</div>
|
||||
|
||||
<div id="footer">
|
||||
<div class="wrap">
|
||||
© 2012-2016 <a href="https:/radekstepan.com">Radek Stepan</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1 +0,0 @@
|
|||
<div id="chart"></div>
|
|
@ -1,30 +0,0 @@
|
|||
<div id="head">
|
||||
{{#with user}}
|
||||
{{#ready}}
|
||||
<div class="right">
|
||||
{{#if uid}}
|
||||
<a on-click="!signout"><Icons icon="signout" /> Sign Out {{github.displayName}}</a>
|
||||
{{else}}
|
||||
<a class="button" on-click="!signin"><Icons icon="github"/> Sign In</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/ready}}
|
||||
{{/with}}
|
||||
|
||||
<a id="icon" href="#">
|
||||
<Icons icon="{{icon}}"/>
|
||||
</a>
|
||||
|
||||
<!--
|
||||
<div class="q">
|
||||
<Icons icon="spyglass"/>
|
||||
<Icons icon="down-open"/>
|
||||
<input type="text" placeholder="Jump to...">
|
||||
</div>
|
||||
-->
|
||||
|
||||
<ul>
|
||||
<li><a href="#new/project" class="add"><Icons icon="plus"/> Add a Project</a></li>
|
||||
<li><a href="#demo"><Icons icon="computer"/> See Examples</a></li>
|
||||
</ul>
|
||||
</div>
|
|
@ -1,11 +0,0 @@
|
|||
<div id="hero">
|
||||
<div class="content">
|
||||
<Icons icon="direction"/>
|
||||
<h2>See your project progress</h2>
|
||||
<p>Serious about a project deadline? Add your GitHub project and we'll tell you if it is running on time or not.</p>
|
||||
<div class="cta">
|
||||
<a href="#new/project" class="primary"><Icons icon="plus"/> Add a Project</a>
|
||||
<a href="#demo" class="secondary"><Icons icon="computer"/> See Examples</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,3 +0,0 @@
|
|||
{{#code}}
|
||||
<span class="icon {{icon}}">{{{ '&#' + code + ';' }}}</span>
|
||||
{{/code}}
|
|
@ -1,14 +0,0 @@
|
|||
{{#text}}
|
||||
{{#system}}
|
||||
<div id="notify" class="{{type}} system" style="top:{{top}}%">
|
||||
<Icons icon="{{icon}}"/>
|
||||
<p>{{text}}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div id="notify" class="{{type}}" style="top:{{-top}}px">
|
||||
<span class="close" on-click="close" />
|
||||
<Icons icon="{{icon}}"/>
|
||||
<p>{{text}}</p>
|
||||
</div>
|
||||
{{/system}}
|
||||
{{/text}}
|
|
@ -1,11 +0,0 @@
|
|||
<div id="content" class="wrap">
|
||||
{{#if projects.list}}
|
||||
{{#ready}}
|
||||
<div>
|
||||
<Projects projects="{{projects}}"/>
|
||||
</div>
|
||||
{{/ready}}
|
||||
{{else}}
|
||||
<Hero/>
|
||||
{{/if}}
|
||||
</div>
|
|
@ -1,15 +0,0 @@
|
|||
{{#ready}}
|
||||
<div>
|
||||
<div id="title">
|
||||
<div class="wrap">
|
||||
<h2 class="title">{{ format.title(milestone.title) }}</h2>
|
||||
<span class="sub">{{{ format.due(milestone.due_on) }}}</span>
|
||||
<div class="description">{{{ format.markdown(milestone.description) }}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="content" class="wrap">
|
||||
<Chart milestone="{{milestone}}"/>
|
||||
</div>
|
||||
</div>
|
||||
{{/ready}}
|
|
@ -1,33 +0,0 @@
|
|||
<div id="content" class="wrap">
|
||||
<div id="add">
|
||||
<div class="header">
|
||||
<h2>Add a Project</h2>
|
||||
<p>Type the name of a GitHub repository that has some milestones with issues.
|
||||
{{#with user}}
|
||||
{{#ready}}
|
||||
{{^uid}}
|
||||
If you'd like to add a private GitHub repo, <a on-click="!signin">Sign In</a> first.
|
||||
{{/uid}}
|
||||
{{/ready}}
|
||||
{{/with}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form">
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" placeholder="user/repo" autocomplete="off" value="{{value}}" on-keyup="submit:{{value}}">
|
||||
</td>
|
||||
<td>
|
||||
<a on-click="submit:{{value}}">Add</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="protip">
|
||||
<Icons icon="rocket"/> Protip: To see if a milestone is on track or not, make sure it has a due date assigned to it.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,13 +0,0 @@
|
|||
{{#ready}}
|
||||
<div>
|
||||
<div id="title">
|
||||
<div class="wrap">
|
||||
<h2 class="title">{{route.join('/')}}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="content" class="wrap">
|
||||
<Milestones project="{{project}}"/>
|
||||
</div>
|
||||
</div>
|
||||
{{/ready}}
|
|
@ -1,39 +0,0 @@
|
|||
<div id="projects">
|
||||
<div class="header">
|
||||
<a class="sort" on-click="sortBy"><Icons icon="sort"/> Sorted by {{projects.sortBy}}</a>
|
||||
<h2>Milestones</h2>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
{{#projects.index}}
|
||||
{{# { index: this } }}
|
||||
|
||||
{{# { p: projects.list[index[0]] } }}
|
||||
{{#if p.owner == project.owner && p.name == project.name }}
|
||||
{{# { milestone: project.milestones[index[1]] } }}
|
||||
<tr class="{{#if milestone.stats.isDone}}done{{/if}}">
|
||||
<td>
|
||||
<a class="milestone" href="#{{project.owner}}/{{project.name}}/{{milestone.number}}">{{ milestone.title }}</a>
|
||||
</td>
|
||||
<td style="width:1%">
|
||||
<div class="progress">
|
||||
<span class="percent">{{Math.floor(milestone.stats.progress.points)}}%</span>
|
||||
<span class="due {{#if milestone.stats.isOverdue}}red{{/if}}">{{{ format.due(milestone.due_on) }}}</span>
|
||||
<div class="outer bar">
|
||||
<div class="inner bar {{(milestone.stats.isOnTime) ? 'green' : 'red'}}" style="width:{{milestone.stats.progress.points}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/}}
|
||||
{{/if}}
|
||||
{{/}}
|
||||
|
||||
{{/}}
|
||||
{{/projects.index}}
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<!--<a href="#"><Icons icon="settings"/> Edit</a>-->
|
||||
</div>
|
||||
</div>
|
|
@ -1,52 +0,0 @@
|
|||
<div id="projects">
|
||||
<div class="header">
|
||||
<a class="sort" on-click="sortBy"><Icons icon="sort"/> Sorted by {{projects.sortBy}}</a>
|
||||
<h2>Projects</h2>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
{{#projects.list}}
|
||||
{{#if errors}}
|
||||
<tr>
|
||||
<td colspan="3" class="repo">
|
||||
<div class="project">{{owner}}/{{name}} <span class="error" title="{{errors.join('\n')}}"><Icons icon="warning"/></span></div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{/projects.list}}
|
||||
|
||||
{{#projects.index}}
|
||||
{{# { index: this } }}
|
||||
|
||||
{{# { project: projects.list[index[0]] } }}
|
||||
{{#with project}}
|
||||
{{# { milestone: project.milestones[index[1]] } }}
|
||||
<tr class="{{#if milestone.stats.isDone}}done{{/if}}">
|
||||
<td class="repo">
|
||||
<a class="project" href="#{{owner}}/{{name}}">{{owner}}/{{name}}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="milestone" href="#{{owner}}/{{name}}/{{milestone.number}}">{{ milestone.title }}</a>
|
||||
</td>
|
||||
<td style="width:1%">
|
||||
<div class="progress">
|
||||
<span class="percent">{{Math.floor(milestone.stats.progress.points)}}%</span>
|
||||
<span class="due {{#if milestone.stats.isOverdue}}red{{/if}}">{{{ format.due(milestone.due_on) }}}</span>
|
||||
<div class="outer bar">
|
||||
<div class="inner bar {{(milestone.stats.isOnTime) ? 'green' : 'red'}}" style="width:{{milestone.stats.progress.points}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/}}
|
||||
{{/with}}
|
||||
{{/}}
|
||||
|
||||
{{/}}
|
||||
{{/projects.index}}
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<!--<a href="#"><Icons icon="settings"/> Edit</a>-->
|
||||
</div>
|
||||
</div>
|
|
@ -1,28 +0,0 @@
|
|||
_ = require 'lodash'
|
||||
moment = require 'moment'
|
||||
marked = require 'marked'
|
||||
|
||||
module.exports =
|
||||
|
||||
# Time from now.
|
||||
fromNow: _.memoize (jsonDate) ->
|
||||
moment(jsonDate, moment.ISO_8601).fromNow()
|
||||
|
||||
# When is a milestone due?
|
||||
due: (jsonDate) ->
|
||||
return ' ' unless jsonDate
|
||||
[ 'due', @fromNow jsonDate ].join(' ')
|
||||
|
||||
# Markdown formatting.
|
||||
'markdown': marked
|
||||
|
||||
# Format milestone title.
|
||||
title: (text) ->
|
||||
if text.toLowerCase().indexOf('milestone') > -1
|
||||
text
|
||||
else
|
||||
[ 'Milestone', text ].join(' ')
|
||||
|
||||
# Hex to decimal.
|
||||
hexToDec: (hex) ->
|
||||
parseInt hex, 16
|
|
@ -1,6 +0,0 @@
|
|||
module.exports =
|
||||
is: (evt) ->
|
||||
evt.original.type in [ 'keyup', 'keydown' ]
|
||||
|
||||
isEnter: (evt) ->
|
||||
evt.original.which is 13
|
|
@ -1,13 +0,0 @@
|
|||
_ = require 'lodash'
|
||||
|
||||
_.mixin
|
||||
'pluckMany': (source, keys) ->
|
||||
throw '`keys` needs to be an Array' unless _.isArray keys
|
||||
_.map source, (item) ->
|
||||
obj = {}
|
||||
_.each keys, (key) ->
|
||||
obj[key] = item[key]
|
||||
obj
|
||||
|
||||
'isInt': (val) ->
|
||||
not isNaN(val) and parseInt(Number(val)) is val and not isNaN(parseInt(val, 10))
|
|
@ -1,27 +0,0 @@
|
|||
_ = require 'lodash'
|
||||
Ractive = require 'ractive'
|
||||
|
||||
mediator = require '../../modules/mediator.coffee'
|
||||
|
||||
# An Ractive that subscribes and listens to messages on `mediator` event bus.
|
||||
# Usage: this.subscribe('!event', function() { /* listener */ }, context);
|
||||
module.exports = Ractive.extend
|
||||
|
||||
subscribe: (name, cb, ctx) ->
|
||||
ctx ?= @
|
||||
@_subs = [] unless _.isArray @_subs
|
||||
if _.isFunction cb
|
||||
@_subs.push mediator.on name, _.bind cb, ctx
|
||||
else
|
||||
console.log "Warning: `cb` is not a function"
|
||||
|
||||
publish: ->
|
||||
mediator.fire.apply mediator, arguments
|
||||
|
||||
onteardown: ->
|
||||
if _.isArray @_subs
|
||||
for sub in @_subs
|
||||
if _.isFunction sub.cancel
|
||||
do sub.cancel
|
||||
else
|
||||
console.log "Warning: `sub.cancel` is not a function"
|
|
@ -1,7 +0,0 @@
|
|||
Eventful = require './eventful.coffee'
|
||||
|
||||
module.exports = (opts) ->
|
||||
Model = Eventful.extend(opts)
|
||||
model = new Model()
|
||||
model.render()
|
||||
model
|
|
@ -1,151 +0,0 @@
|
|||
Ractive = require 'ractive'
|
||||
d3 = require 'd3'
|
||||
|
||||
require('d3-tip')(d3)
|
||||
|
||||
lines = require '../modules/chart/lines.coffee'
|
||||
axes = require '../modules/chart/axes.coffee'
|
||||
|
||||
module.exports = Ractive.extend
|
||||
|
||||
'name': 'views/chart'
|
||||
|
||||
'template': require '../templates/chart.html'
|
||||
|
||||
oncomplete: ->
|
||||
milestone = @data.milestone
|
||||
issues = milestone.issues
|
||||
# Total number of points in the milestone.
|
||||
total = issues.open.size + issues.closed.size
|
||||
|
||||
# An issue may have been closed before the start of a milestone.
|
||||
head = issues.closed.list[0].closed_at
|
||||
if issues.length and milestone.created_at > head
|
||||
# This is the new start.
|
||||
milestone.created_at = head
|
||||
|
||||
# Actual, ideal & trend lines.
|
||||
actual = lines.actual issues.closed.list, milestone.created_at, total
|
||||
ideal = lines.ideal milestone.created_at, milestone.due_on, total
|
||||
trend = lines.trend actual, milestone.created_at, milestone.due_on
|
||||
|
||||
# Get available space.
|
||||
{ height, width } = do @el.getBoundingClientRect
|
||||
|
||||
margin = { 'top': 30, 'right': 30, 'bottom': 40, 'left': 50 }
|
||||
width -= margin.left + margin.right
|
||||
height -= margin.top + margin.bottom
|
||||
|
||||
# Scales.
|
||||
x = d3.time.scale().range([ 0, width ])
|
||||
y = d3.scale.linear().range([ height, 0 ])
|
||||
|
||||
# Axes.
|
||||
xAxis = axes.horizontal height, x
|
||||
yAxis = axes.vertical width, y
|
||||
|
||||
# Line generator.
|
||||
line = d3.svg.line()
|
||||
.interpolate("linear")
|
||||
.x( (d) -> x(new Date(d.date)) ) # convert to Date only now
|
||||
.y( (d) -> y(d.points) )
|
||||
|
||||
# Get the minimum and maximum date, and initial points.
|
||||
[ first, ..., last ] = ideal
|
||||
x.domain [
|
||||
new Date(first.date)
|
||||
new Date(last.date)
|
||||
]
|
||||
y.domain([ 0, first.points ]).nice()
|
||||
|
||||
# Add an SVG element with the desired dimensions and margin.
|
||||
svg = d3.select(this.el.querySelector('#chart')).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 so that lines are not drawn outside of the boundary.
|
||||
svg.append("defs").append("svg:clipPath")
|
||||
.attr("id", "clip")
|
||||
.append("svg:rect")
|
||||
.attr("id", "clip-rect")
|
||||
.attr("x", 0)
|
||||
.attr("y", 0)
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
|
||||
# Add the days x-axis.
|
||||
svg.append("g")
|
||||
.attr("class", "x axis day")
|
||||
.attr("transform", "translate(0,#{height})")
|
||||
.call(xAxis)
|
||||
|
||||
# Add the months x-axis.
|
||||
m = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
]
|
||||
|
||||
mAxis = xAxis
|
||||
.orient("top")
|
||||
.tickSize(height)
|
||||
.tickFormat( (d) -> m[d.getMonth()] )
|
||||
.ticks(2)
|
||||
|
||||
svg.append("g")
|
||||
.attr("class", "x axis month")
|
||||
.attr("transform", "translate(0,#{height})")
|
||||
.call(mAxis)
|
||||
|
||||
# Add the y-axis.
|
||||
svg.append("g")
|
||||
.attr("class", "y axis")
|
||||
.call(yAxis)
|
||||
|
||||
# Add a line showing where we are now.
|
||||
svg.append("svg:line")
|
||||
.attr("class", "today")
|
||||
.attr("x1", x(new Date))
|
||||
.attr("y1", 0)
|
||||
.attr("x2", x(new Date))
|
||||
.attr("y2", height)
|
||||
|
||||
# Add the ideal line path.
|
||||
svg.append("path")
|
||||
.attr("class", "ideal line")
|
||||
# Piecewise linear segments, as in a polyline.
|
||||
.attr("d", line.interpolate("linear")(ideal))
|
||||
|
||||
# Add the trendline path.
|
||||
svg.append("path")
|
||||
.attr("class", "trendline line")
|
||||
# Piecewise linear segments, as in a polyline.
|
||||
.attr("d", line.interpolate("linear")(trend))
|
||||
|
||||
# Add the actual line path.
|
||||
svg.append("path")
|
||||
.attr("class", "actual line")
|
||||
# Piecewise linear segments, as in a polyline.
|
||||
.attr("d", line.interpolate("linear").y( (d) -> y(d.points) )(actual))
|
||||
|
||||
# Collect the tooltip here.
|
||||
tooltip = d3.tip().attr('class', 'd3-tip').html ({ number, title }) ->
|
||||
"##{number}: #{title}"
|
||||
|
||||
svg.call(tooltip)
|
||||
|
||||
# Show when we closed an issue.
|
||||
svg.selectAll("a.issue")
|
||||
.data(actual.slice(1)) # skip the starting point
|
||||
.enter()
|
||||
# A wrapping link.
|
||||
.append('svg:a')
|
||||
.attr("xlink:href", ({ html_url }) -> html_url )
|
||||
.attr("xlink:show", 'new')
|
||||
.append('svg:circle')
|
||||
.attr("cx", ({ date }) -> x new Date date )
|
||||
.attr("cy", ({ points }) -> y points )
|
||||
.attr("r", ({ radius }) -> 5 ) # fixed for now
|
||||
.on('mouseover', tooltip.show)
|
||||
.on('mouseout', tooltip.hide)
|
|
@ -1,35 +0,0 @@
|
|||
Ractive = require 'ractive'
|
||||
|
||||
{ system } = require '../models/system.coffee'
|
||||
firebase = require '../models/firebase.coffee'
|
||||
user = require '../models/user.coffee'
|
||||
Icons = require './icons.coffee'
|
||||
|
||||
module.exports = Ractive.extend
|
||||
|
||||
'name': 'views/header'
|
||||
|
||||
'template': require '../templates/header.html'
|
||||
|
||||
'data':
|
||||
'user': user
|
||||
# Default app icon.
|
||||
'icon': 'fire'
|
||||
|
||||
'components': { Icons }
|
||||
|
||||
'adapt': [ Ractive.adaptors.Ractive ]
|
||||
|
||||
onconstruct: ->
|
||||
# Sign-in a user.
|
||||
@on '!signin', ->
|
||||
do firebase.signin
|
||||
|
||||
# Sign-out a user.
|
||||
@on '!signout', ->
|
||||
do firebase.signout
|
||||
|
||||
onrender: ->
|
||||
# Switch loading icon with app icon.
|
||||
system.observe 'loading', (ya) =>
|
||||
@set 'icon', if ya then 'spinner' else 'fire'
|
|
@ -1,13 +0,0 @@
|
|||
Ractive = require 'ractive'
|
||||
|
||||
Icons = require './icons.coffee'
|
||||
|
||||
module.exports = Ractive.extend
|
||||
|
||||
'name': 'views/hero'
|
||||
|
||||
'template': require '../templates/hero.html'
|
||||
|
||||
'components': { Icons }
|
||||
|
||||
'adapt': [ Ractive.adaptors.Ractive ]
|
|
@ -1,37 +0,0 @@
|
|||
Ractive = require 'ractive'
|
||||
|
||||
format = require '../utils/format.coffee'
|
||||
|
||||
# Fontello icon hex codes.
|
||||
codes =
|
||||
'spyglass': '\e801' # Font Awesome - search
|
||||
'plus': '\e804' # Font Awesome - plus-circled
|
||||
'settings': '\e800' # Font Awesome - cog
|
||||
'rocket': '\e80a' # Font Awesome - rocket
|
||||
'computer': '\e807' # Font Awesome - desktop
|
||||
'help': '\e80f' # Font Awesome - lifebuoy
|
||||
'signout': '\e809' # Font Awesome - logout
|
||||
'github': '\e802' # Font Awesome - github
|
||||
'warning': '\e80c' # Entypo - attention
|
||||
'direction': '\e803' # Entypo - address
|
||||
'megaphone': '\e808' # Entypo - megaphone
|
||||
'heart': '\e80e' # Typicons - heart
|
||||
'sort': '\e806' # Typicons - sort-alphabet
|
||||
'spinner': '\e80b' # MFG Labs - spinner1
|
||||
'fire': '\e805' # Maki - fire-station
|
||||
|
||||
|
||||
module.exports = Ractive.extend
|
||||
|
||||
'name': 'views/icons'
|
||||
|
||||
'template': require '../templates/icons.html'
|
||||
|
||||
'isolated': yes
|
||||
|
||||
onrender: ->
|
||||
@observe 'icon', (icon) ->
|
||||
if icon and hex = codes[icon]
|
||||
@set 'code', format.hexToDec hex
|
||||
else
|
||||
@set 'code', null
|
|
@ -1,65 +0,0 @@
|
|||
_ = require 'lodash'
|
||||
Ractive = require 'ractive'
|
||||
d3 = require 'd3'
|
||||
|
||||
Eventful = require '../utils/ractive/eventful.coffee'
|
||||
Icons = require './icons.coffee'
|
||||
|
||||
HEIGHT = 68 # height of div in px
|
||||
|
||||
module.exports = Eventful.extend
|
||||
|
||||
'name': 'views/notify'
|
||||
|
||||
'template': require '../templates/notify.html'
|
||||
|
||||
'data':
|
||||
'top': HEIGHT
|
||||
'hidden': yes
|
||||
'defaults':
|
||||
'text': ''
|
||||
'type': '' # bland grey style
|
||||
'system': no
|
||||
'icon': 'megaphone'
|
||||
'ttl': 5e3
|
||||
|
||||
'components': { Icons }
|
||||
|
||||
'adapt': [ Ractive.adaptors.Ractive ]
|
||||
|
||||
# Show a notification.
|
||||
show: (opts) ->
|
||||
@set 'hidden', no
|
||||
# Set the opts.
|
||||
@set opts = _.defaults opts, @data.defaults
|
||||
# Which position to slide to?
|
||||
pos = [ 0, 50 ][ +opts.system ] # 0px or 50% from top
|
||||
# Slide into view.
|
||||
@animate 'top', pos,
|
||||
'easing': d3.ease('bounce')
|
||||
'duration': 800
|
||||
|
||||
# If no ttl then show permanently.
|
||||
return unless opts.ttl
|
||||
|
||||
# Slide out of the view.
|
||||
_.delay _.bind(@hide, @), opts.ttl
|
||||
|
||||
# Hide a notification.
|
||||
hide: ->
|
||||
return if @data.hidden
|
||||
@set 'hidden', yes
|
||||
|
||||
@animate 'top', HEIGHT,
|
||||
'easing': d3.ease('back')
|
||||
'complete': =>
|
||||
# Reset the text when all is done.
|
||||
@set 'text', null
|
||||
|
||||
onconstruct: ->
|
||||
# On outside messages.
|
||||
@subscribe '!app/notify', @show, @
|
||||
@subscribe '!app/notify/hide', @hide, @
|
||||
|
||||
# Close us prematurely...
|
||||
@on 'close', @hide
|
|
@ -1,77 +0,0 @@
|
|||
_ = require 'lodash'
|
||||
Ractive = require 'ractive'
|
||||
async = require 'async'
|
||||
|
||||
Hero = require '../hero.coffee'
|
||||
Projects = require '../tables/projects.coffee'
|
||||
|
||||
projects = require '../../models/projects.coffee'
|
||||
system = require '../../models/system.coffee'
|
||||
milestones = require '../../modules/github/milestones.coffee'
|
||||
issues = require '../../modules/github/issues.coffee'
|
||||
|
||||
module.exports = Ractive.extend
|
||||
|
||||
'name': 'views/pages/index'
|
||||
|
||||
'template': require '../../templates/pages/index.html'
|
||||
|
||||
'components': { Hero, Projects }
|
||||
|
||||
'data':
|
||||
'projects': projects
|
||||
'ready': no
|
||||
|
||||
'adapt': [ Ractive.adaptors.Ractive ]
|
||||
|
||||
cb: ->
|
||||
@set 'ready', yes
|
||||
|
||||
onrender: ->
|
||||
document.title = 'Burnchart: GitHub Burndown Chart as a Service'
|
||||
|
||||
# Quit if we have no projects.
|
||||
return do @cb unless projects.list.length
|
||||
|
||||
# ---
|
||||
|
||||
done = do system.async
|
||||
|
||||
# For all projects.
|
||||
async.map projects.data.list, (project, cb) ->
|
||||
# Fetch their milestones.
|
||||
milestones.fetchAll project, (err, list) ->
|
||||
# Save the error if project does not exist.
|
||||
if err
|
||||
projects.saveError project, err
|
||||
return do cb
|
||||
|
||||
# Now add in the issues.
|
||||
async.each list, (milestone, cb) ->
|
||||
# Do we have this milestone already?
|
||||
return cb null if _.find project.milestones, ({ number }) ->
|
||||
milestone.number is number
|
||||
|
||||
# OK fetch all the issues for this milestone then.
|
||||
issues.fetchAll
|
||||
'owner': project.owner
|
||||
'name': project.name
|
||||
'milestone': milestone.number
|
||||
, (err, obj) ->
|
||||
# Save any errors on the project.
|
||||
if err
|
||||
projects.saveError project, err
|
||||
return do cb
|
||||
|
||||
# Add in the issues to the milestone.
|
||||
_.extend milestone, { 'issues': obj }
|
||||
# Save the milestone.
|
||||
projects.addMilestone project, milestone
|
||||
# Done
|
||||
do cb
|
||||
|
||||
, cb
|
||||
|
||||
, =>
|
||||
do done
|
||||
do @cb
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue