Merge pull request #107 from radekstepan/react

switch from Ractive to React
This commit is contained in:
Radek Stepan 2016-01-26 12:47:58 +01:00
commit e76611078e
122 changed files with 28020 additions and 20378 deletions

3
.babelrc Normal file
View File

@ -0,0 +1,3 @@
{
"presets": [ "react", "es2015" ]
}

15
.gitignore vendored
View File

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

2
.nvmrc
View File

@ -1 +1 @@
0.12.0
v0.12.9

View File

@ -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'
])

44
Makefile Normal file
View File

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

View File

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

View File

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

64
bin/burnchart.js Executable file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

11
index.html Normal file
View File

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

View File

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

View File

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

1
public/css/bundle.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

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

34
public/js/bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

View File

@ -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 '/'

33
src/config.js Normal file
View File

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

137
src/js/App.jsx Normal file
View File

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

View File

@ -0,0 +1,4 @@
import EventEmitter from '../lib/EventEmitter.js';
// Just a namespace for all actions.
export default new EventEmitter();

View File

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

173
src/js/components/Chart.jsx Normal file
View File

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

View File

@ -0,0 +1,17 @@
import React from 'react';
export default React.createClass({
displayName: 'Footer.jsx',
render() {
return (
<div id="footer">
<div className="wrap">
&copy; 2012-2016 <a href="https:/radekstepan.com" target="_blank">Radek Stepan</a>
</div>
</div>
);
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
import React from 'react';
// Inserts a space before rendering text.
export default React.createClass({
displayName: 'Space.jsx',
render() {
return <span>&nbsp;</span>;
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

6
src/js/index.jsx Normal file
View File

@ -0,0 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.jsx';
ReactDOM.render(<App />, document.getElementById('app'));

View File

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

48
src/js/lib/PageMixin.js Normal file
View File

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

108
src/js/lib/Store.js Normal file
View File

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

View File

@ -0,0 +1,24 @@
import d3 from 'd3';
export default {
horizontal(height, x) {
return d3.svg.axis().scale(x)
.orient("bottom")
// Show vertical lines...
.tickSize(-height)
// with day of the month...
.tickFormat((d) => { return d.getDate(); })
// and give us a spacer.
.tickPadding(10);
},
vertical(width, y) {
return d3.svg.axis().scale(y)
.orient("left")
.tickSize(-width)
.ticks(5)
.tickPadding(10);
}
};

View File

@ -0,0 +1,149 @@
import _ from 'lodash';
import d3 from 'd3';
import moment from 'moment';
import config from '../../../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) }
];
}
};

42
src/js/modules/format.js Normal file
View File

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

View File

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

View File

@ -0,0 +1,8 @@
import request from './request.js';
export default {
// Fetch a milestone.
fetch: request.oneMilestone,
// Fetch all milestones.
fetchAll: request.allMilestones
};

View File

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

17
src/js/modules/lodash.js Normal file
View File

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

56
src/js/modules/stats.js Normal file
View File

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

78
src/js/stores/appStore.js Normal file
View File

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

8
src/js/stores/index.js Normal file
View File

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

View File

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

43
src/less/animations.less Normal file
View File

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

View File

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

View File

@ -8,4 +8,5 @@
@import "fonts.less";
@import "icons.less";
@import "chart.less";
@import "animations.less";
@import "app.less";

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +0,0 @@
Model = require '../utils/ractive/model.coffee'
# Currently logged-in user.
module.exports = new Model
'name': 'models/user'
'data':
'uid': null

View File

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

View File

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

View File

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

View File

@ -1,9 +0,0 @@
request = require './request.coffee'
module.exports =
# Fetch a milestone.
'fetch': request.oneMilestone
# Fetch all milestones.
'fetchAll': request.allMilestones

View File

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

View File

@ -1,5 +0,0 @@
Ractive = require 'ractive'
Mediator = Ractive.extend {}
module.exports = new Mediator()

View File

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

View File

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

View File

@ -1,14 +0,0 @@
<div id="app">
<Notify/>
<Header/>
<div id="page">
<!-- content loaded from a router -->
</div>
<div id="footer">
<div class="wrap">
&copy; 2012-2016 <a href="https:/radekstepan.com">Radek Stepan</a>
</div>
</div>
</div>

View File

@ -1 +0,0 @@
<div id="chart"></div>

View File

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

View File

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

View File

@ -1,3 +0,0 @@
{{#code}}
<span class="icon {{icon}}">{{{ '&#' + code + ';' }}}</span>
{{/code}}

View File

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

View File

@ -1,11 +0,0 @@
<div id="content" class="wrap">
{{#if projects.list}}
{{#ready}}
<div>
<Projects projects="{{projects}}"/>
</div>
{{/ready}}
{{else}}
<Hero/>
{{/if}}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 '&nbsp;' 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

View File

@ -1,6 +0,0 @@
module.exports =
is: (evt) ->
evt.original.type in [ 'keyup', 'keydown' ]
isEnter: (evt) ->
evt.original.which is 13

View File

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

View File

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

View File

@ -1,7 +0,0 @@
Eventful = require './eventful.coffee'
module.exports = (opts) ->
Model = Eventful.extend(opts)
model = new Model()
model.render()
model

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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