├── .bowerrc
├── views
├── home.html
└── layout.html
├── doc
├── home.png
├── income.png
├── balance.png
├── net-worth.png
├── spending.png
├── home-preview.png
├── income-preview.png
├── balance-preview.png
├── spending-preview.png
└── net-worth-preview.png
├── sample-config.json
├── public
├── js
│ └── ledger
│ │ ├── vent.js
│ │ ├── dashboard
│ │ ├── router.js
│ │ ├── module.js
│ │ ├── model.js
│ │ ├── navigation.jsx
│ │ ├── navigation.js
│ │ ├── dashboard.jsx
│ │ ├── dashboard.js
│ │ └── controller.js
│ │ ├── income
│ │ ├── router.js
│ │ ├── module.js
│ │ ├── controller.js
│ │ ├── model.js
│ │ ├── income-vs-expenditure-chart.jsx
│ │ └── income-vs-expenditure-chart.js
│ │ ├── worth
│ │ ├── router.js
│ │ ├── module.js
│ │ ├── controller.js
│ │ ├── model.js
│ │ ├── net-worth-chart.jsx
│ │ └── net-worth-chart.js
│ │ ├── spending
│ │ ├── router.js
│ │ ├── module.js
│ │ ├── controller.js
│ │ ├── model.js
│ │ ├── expenditure-chart.jsx
│ │ └── expenditure-chart.js
│ │ ├── balance
│ │ ├── router.js
│ │ ├── module.js
│ │ ├── model.js
│ │ ├── controller.js
│ │ ├── chart.jsx
│ │ └── chart.js
│ │ ├── ledger.js
│ │ ├── singleActiveItem.js
│ │ ├── groupByDate.js
│ │ ├── controlNavigation.js
│ │ ├── main.js
│ │ ├── controls
│ │ ├── model.js
│ │ ├── charting.jsx
│ │ └── charting.js
│ │ ├── aggregateCollection.js
│ │ ├── charting
│ │ ├── pie-chart.jsx
│ │ ├── pie-chart.js
│ │ ├── line-plus-bar-chart.jsx
│ │ ├── multi-bar-chart.jsx
│ │ ├── line-plus-bar-chart.js
│ │ └── multi-bar-chart.js
│ │ ├── dateRange.js
│ │ ├── react.backbone.js
│ │ └── filteredCollection.js
└── css
│ └── less
│ └── app.less
├── .gitignore
├── routes
└── home.js
├── TODO.md
├── bower.json
├── package.json
├── LICENSE
├── example
├── drewr.dat
└── example.dat
├── app.js
├── README.md
└── Gruntfile.js
/.bowerrc:
--------------------------------------------------------------------------------
1 | {
2 | "directory": "bower_components"
3 | }
4 |
--------------------------------------------------------------------------------
/views/home.html:
--------------------------------------------------------------------------------
1 | <% layout('layout') -%>
2 |
3 |
4 |
--------------------------------------------------------------------------------
/doc/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/slashdotdash/node-ledger-web/HEAD/doc/home.png
--------------------------------------------------------------------------------
/doc/income.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/slashdotdash/node-ledger-web/HEAD/doc/income.png
--------------------------------------------------------------------------------
/sample-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "binary": "ledger",
3 | "file": "example/example.dat"
4 | }
5 |
--------------------------------------------------------------------------------
/doc/balance.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/slashdotdash/node-ledger-web/HEAD/doc/balance.png
--------------------------------------------------------------------------------
/doc/net-worth.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/slashdotdash/node-ledger-web/HEAD/doc/net-worth.png
--------------------------------------------------------------------------------
/doc/spending.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/slashdotdash/node-ledger-web/HEAD/doc/spending.png
--------------------------------------------------------------------------------
/doc/home-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/slashdotdash/node-ledger-web/HEAD/doc/home-preview.png
--------------------------------------------------------------------------------
/doc/income-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/slashdotdash/node-ledger-web/HEAD/doc/income-preview.png
--------------------------------------------------------------------------------
/doc/balance-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/slashdotdash/node-ledger-web/HEAD/doc/balance-preview.png
--------------------------------------------------------------------------------
/doc/spending-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/slashdotdash/node-ledger-web/HEAD/doc/spending-preview.png
--------------------------------------------------------------------------------
/doc/net-worth-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/slashdotdash/node-ledger-web/HEAD/doc/net-worth-preview.png
--------------------------------------------------------------------------------
/public/js/ledger/vent.js:
--------------------------------------------------------------------------------
1 | define(['backbone', 'marionette'], function(Backbone) {
2 | return new Backbone.Wreqr.EventAggregator();
3 | });
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | bower_components/
3 | public/css/main.css
4 | public/css/vendor/
5 | public/js/ledger.js
6 | public/js/vendor/
7 | config.json
8 |
--------------------------------------------------------------------------------
/routes/home.js:
--------------------------------------------------------------------------------
1 |
2 | var environment = process.env.NODE_ENV || 'development';
3 |
4 | exports.index = function(req, res) {
5 | res.render('home', { title: 'Ledger Web', environment: environment });
6 | };
--------------------------------------------------------------------------------
/public/js/ledger/dashboard/router.js:
--------------------------------------------------------------------------------
1 | define(['marionette'], function(Marionette) {
2 | 'use strict';
3 |
4 | var Router = Marionette.AppRouter.extend({
5 | appRoutes: {
6 | '': 'showDashboard'
7 | }
8 | });
9 |
10 | return Router;
11 | });
--------------------------------------------------------------------------------
/public/js/ledger/income/router.js:
--------------------------------------------------------------------------------
1 | define(['marionette'], function(Marionette) {
2 | 'use strict';
3 |
4 | var Router = Marionette.AppRouter.extend({
5 | appRoutes: {
6 | 'income(/:groupBy)': 'showIncome'
7 | }
8 | });
9 |
10 | return Router;
11 | });
--------------------------------------------------------------------------------
/public/js/ledger/worth/router.js:
--------------------------------------------------------------------------------
1 | define(['marionette'], function(Marionette) {
2 | 'use strict';
3 |
4 | var Router = Marionette.AppRouter.extend({
5 | appRoutes: {
6 | 'worth(/:groupBy)': 'showNetWorth'
7 | }
8 | });
9 |
10 | return Router;
11 | });
--------------------------------------------------------------------------------
/public/js/ledger/spending/router.js:
--------------------------------------------------------------------------------
1 | define(['marionette'], function(Marionette) {
2 | 'use strict';
3 |
4 | var Router = Marionette.AppRouter.extend({
5 | appRoutes: {
6 | 'spending(/:groupBy)': 'showSpending'
7 | }
8 | });
9 |
10 | return Router;
11 | });
--------------------------------------------------------------------------------
/public/js/ledger/balance/router.js:
--------------------------------------------------------------------------------
1 | define(['marionette'], function(Marionette) {
2 | 'use strict';
3 |
4 | var Router = Marionette.AppRouter.extend({
5 | appRoutes: {
6 | 'balance': 'showBalance',
7 | 'balance/*account': 'showBalanceForAccount'
8 | }
9 | });
10 |
11 | return Router;
12 | });
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
1 | # TODO
2 |
3 | [ ] Income chart colours - green for income, red for expenses.
4 |
5 | [ ] Balance chart - include categories without any contribution, but that have children (currently not shown).
6 |
7 | [ ] Chart of delta between income and expenses - financial health.
8 |
9 | [ ] Ledger tabular reports
10 | - assets v liabilities
11 |
12 | [ ] Dashboard overview with current state of financial health.
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "node-ledger-web",
3 | "version": "0.0.1",
4 | "dependencies": {
5 | "bootstrap": "~2.3.2",
6 | "modernizr": "~2.7.1",
7 | "jquery": "~2.0.3",
8 | "backbone.marionette": "~1.4.1",
9 | "handlebars": "~1.2.1",
10 | "nvd3": "git://github.com/novus/nvd3",
11 | "requirejs": "~2.1.9",
12 | "requirejs-tpl": "latest",
13 | "react": "~0.8.0"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/public/js/ledger/ledger.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'backbone',
3 | 'marionette'
4 | ], function(Backbone, Marionette) {
5 | 'use strict';
6 |
7 | var Ledger = new Marionette.Application();
8 |
9 | Ledger.addRegions({
10 | nav: '#nav',
11 | main: '#main'
12 | });
13 |
14 | Ledger.on('initialize:after', function() {
15 | Backbone.history.start({pushState: true});
16 | });
17 |
18 | return Ledger;
19 | });
--------------------------------------------------------------------------------
/public/css/less/app.less:
--------------------------------------------------------------------------------
1 | .container .navbar, .container-fluid .navbar { margin-top: 20px; }
2 |
3 | .jumbotron {
4 | margin: 80px 0;
5 | text-align: center;
6 | }
7 | .jumbotron h1 {
8 | font-size: 100px;
9 | line-height: 1;
10 | }
11 | .jumbotron .lead {
12 | font-size: 24px;
13 | line-height: 1.25;
14 | }
15 | .jumbotron .btn {
16 | font-size: 21px;
17 | padding: 14px 24px;
18 | }
19 |
20 | th.balance-amount, td.balance-amount { text-align: right; }
--------------------------------------------------------------------------------
/public/js/ledger/singleActiveItem.js:
--------------------------------------------------------------------------------
1 | define(function() {
2 | // Ensure only one model in the collection is active at any time
3 | var activeToggled = function(model, value) {
4 | if (value === true) {
5 | this.forEach(function(m) {
6 | if (m.get('active') === true && m !== model) {
7 | m.set('active', false);
8 | }
9 | });
10 | }
11 | };
12 |
13 | return function(collection) {
14 | collection.listenTo(collection, 'change:active', activeToggled, collection);
15 | };
16 | });
--------------------------------------------------------------------------------
/public/js/ledger/worth/module.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'ledger',
3 | './router',
4 | './controller',
5 | 'controlNavigation',
6 | 'vent'
7 | ], function(Ledger, Router, Controller, ControlNavigation, vent) {
8 | 'use strict';
9 |
10 | var Worth = Ledger.module('Worth');
11 |
12 | Worth.addInitializer(function() {
13 | var controller = new Controller(),
14 | router = new Router({ controller: controller });
15 |
16 | // Start the controller on first route to this module
17 | this.listenToOnce(router, 'route', function() {
18 | controller.start();
19 | });
20 |
21 | // Update groupBy param in URL when changed
22 | new ControlNavigation(this, vent, router, 'worth');
23 | });
24 |
25 | return Worth;
26 | });
--------------------------------------------------------------------------------
/public/js/ledger/balance/module.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'ledger',
3 | './router',
4 | './controller',
5 | 'controlNavigation',
6 | 'vent'
7 | ], function(Ledger, Router, Controller, ControlNavigation, vent) {
8 | 'use strict';
9 |
10 | var Balance = Ledger.module('Balance');
11 |
12 | Balance.addInitializer(function() {
13 | var controller = new Controller(),
14 | router = new Router({ controller: controller });
15 |
16 | // Start the controller on first route to this module
17 | this.listenToOnce(router, 'route', function() {
18 | controller.start();
19 | });
20 |
21 | this.listenTo(router, 'route', function() {
22 | vent.trigger('section:activated', {name: 'balance'});
23 | });
24 | });
25 |
26 | return Balance;
27 | });
--------------------------------------------------------------------------------
/public/js/ledger/dashboard/module.js:
--------------------------------------------------------------------------------
1 | define(['ledger', './router', './controller', 'backbone', 'marionette', 'vent'],
2 | function(Ledger, Router, Controller, Backbone, Marionette, vent) {
3 | 'use strict';
4 |
5 | var Dashboard = Ledger.module('Dashboard');
6 |
7 | // Initializer
8 | // -----------
9 | Dashboard.addInitializer(function() {
10 | var controller = new Controller(),
11 | router = new Router({ controller: controller });
12 |
13 | controller.router = router;
14 |
15 | // Immediately start the dashboard controller (home page)
16 | controller.start();
17 |
18 | this.listenTo(router, 'route', function() {
19 | vent.trigger('section:activated', {name: 'dashboard'});
20 | });
21 | });
22 |
23 | return Dashboard;
24 | });
--------------------------------------------------------------------------------
/public/js/ledger/income/module.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'ledger', 'income/router', 'income/controller', 'controlNavigation', 'marionette', 'vent'
3 | ], function(Ledger, Router, Controller, ControlNavigation, Marionette, vent) {
4 | 'use strict';
5 |
6 | var Income = Ledger.module('Income');
7 |
8 | // Initializer
9 | // -----------
10 | Income.addInitializer(function(){
11 | var controller = new Controller(),
12 | router = new Router({ controller: controller });
13 |
14 | // Start the controller on first route to this module
15 | this.listenToOnce(router, 'route', function() {
16 | controller.start();
17 | });
18 |
19 | // Update groupBy param in URL when changed
20 | new ControlNavigation(this, vent, router, 'income');
21 | });
22 |
23 | return Income;
24 | });
--------------------------------------------------------------------------------
/public/js/ledger/dashboard/model.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'singleActiveItem',
3 | 'backbone'
4 | ], function(singleActiveItem, Backbone) {
5 | 'use strict';
6 |
7 | var Section = Backbone.Model.extend({
8 | defaults: {
9 | title: '',
10 | url: '',
11 | active: false
12 | },
13 |
14 | select: function() {
15 | this.set('active', true);
16 | }
17 | });
18 |
19 | var Sections = Backbone.Collection.extend({
20 | model: Section,
21 |
22 | initialize: function() {
23 | singleActiveItem(this);
24 | },
25 |
26 | activate: function(name) {
27 | if (name && name.length !== 0) {
28 | this.findWhere({name: name}).select();
29 | }
30 | }
31 | });
32 |
33 | return {
34 | Section: Section,
35 | Sections: Sections
36 | };
37 | });
--------------------------------------------------------------------------------
/public/js/ledger/spending/module.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'ledger',
3 | './router',
4 | './controller',
5 | 'controlNavigation',
6 | 'vent'
7 | ], function(Ledger, Router, Controller, ControlNavigation, vent) {
8 | 'use strict';
9 |
10 | var Spending = Ledger.module('Spending');
11 |
12 | // Spending Initializer
13 | // -----------
14 | Spending.addInitializer(function(){
15 | var controller = new Controller(),
16 | router = new Router({ controller: controller });
17 |
18 | controller.router = router;
19 |
20 | // Start the controller on first route to this module
21 | this.listenToOnce(router, 'route', function() {
22 | controller.start();
23 | });
24 |
25 | // Update groupBy param in URL when changed
26 | new ControlNavigation(this, vent, router, 'spending');
27 | });
28 |
29 | return Spending;
30 | });
--------------------------------------------------------------------------------
/public/js/ledger/groupByDate.js:
--------------------------------------------------------------------------------
1 | define(function() {
2 | var groupByDate = function(date) {
3 | return {
4 | // Get the time for the given date granularity to use for grouping dates together
5 | groupBy: function(granularity) {
6 | switch (granularity) {
7 | case 'month':
8 | return this.getMonth().getTime();
9 | case 'day':
10 | return this.getDate().getTime();
11 | case 'year':
12 | return this.getYear().getTime();
13 | }
14 |
15 | throw 'Date range granularity "' + granularity + '" is not supported';
16 | },
17 |
18 | getDate: function() {
19 | return date;
20 | },
21 |
22 | getMonth: function() {
23 | return new Date(date.getFullYear(), date.getMonth(), 1);
24 | },
25 |
26 | getYear: function() {
27 | return new Date(date.getFullYear(), 0, 1);
28 | }
29 | };
30 | };
31 |
32 | return groupByDate;
33 | });
--------------------------------------------------------------------------------
/public/js/ledger/controlNavigation.js:
--------------------------------------------------------------------------------
1 | define(function() {
2 | var ControlNavigation = function(module, vent, router, section) {
3 | this.vent = vent;
4 | this.router = router;
5 | this.section = section;
6 | this.isActive = false;
7 |
8 | this.initialize(module);
9 | };
10 |
11 | ControlNavigation.prototype.initialize = function(module) {
12 | var self = this;
13 |
14 | module.listenTo(this.router, 'route', function() {
15 | self.vent.trigger('section:activated', { name: self.section });
16 | });
17 |
18 | module.listenTo(this.vent, 'section:activated', function(params) {
19 | self.isActive = (params.name === self.section);
20 | });
21 |
22 | // Update groupBy param in URL when changed
23 | module.listenTo(this.vent, 'controls:groupby', function(groupBy) {
24 | if (self.isActive === true) {
25 | self.router.navigate(self.section + '/' + groupBy.name, {trigger: false});
26 | }
27 | });
28 | };
29 |
30 | return ControlNavigation;
31 | });
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ledger-web",
3 | "version": "0.0.1",
4 | "description": "Web front-end to access ledger cli data.",
5 | "repository": {
6 | "type": "git",
7 | "url": "git://github.com/slashdotdash/node-ledger-web.git"
8 | },
9 | "keywords": [
10 | "ledger",
11 | "accounting",
12 | "finance"
13 | ],
14 | "author": {
15 | "name": "Ben Smith",
16 | "email": "ben@10consulting.com"
17 | },
18 | "license": "MIT",
19 | "main": "app.js",
20 | "directories": {},
21 | "scripts": {
22 | "start": "node app"
23 | },
24 | "devDependencies": {
25 | "eventemitter3": "^0.1.6",
26 | "grunt": "~0.4.2",
27 | "grunt-contrib-copy": "~0.5.0",
28 | "grunt-contrib-jshint": "~0.8.0",
29 | "grunt-contrib-requirejs": "~0.4.1",
30 | "grunt-contrib-watch": "~0.5.3",
31 | "grunt-react": "~0.6.0",
32 | "grunt-recess": "~0.5.0"
33 | },
34 | "dependencies": {
35 | "express": "~3.4.8",
36 | "ejs-locals": "~1.0.2",
37 | "http-proxy": "^1.11.1",
38 | "ledger-rest": "0.1.0",
39 | "lodash": "~2.4.1"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | Copyright (c) 2016 Ben Smith (ben@10consulting.com)
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5 |
6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7 |
8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/public/js/ledger/main.js:
--------------------------------------------------------------------------------
1 | require.config({
2 | paths : {
3 | backbone : '../vendor/backbone',
4 | d3 : '../vendor/d3',
5 | jquery : '../vendor/jquery',
6 | marionette : '../vendor/backbone.marionette',
7 | nvd3: '../vendor/nv.d3',
8 | react: '../vendor/react',
9 | tpl: '../vendor/tpl',
10 | underscore : '../vendor/underscore'
11 | },
12 | shim : {
13 | backbone : {
14 | deps : ['jquery', 'underscore'],
15 | exports : 'Backbone'
16 | },
17 | d3 : {
18 | exports: 'd3'
19 | },
20 | jquery : {
21 | exports : 'jquery'
22 | },
23 | marionette : {
24 | deps : ['jquery', 'underscore', 'backbone'],
25 | exports : 'Marionette'
26 | },
27 | nvd3 : {
28 | deps : ['d3'],
29 | exports: 'nv'
30 | },
31 | underscore : {
32 | exports : '_'
33 | }
34 | }
35 | });
36 |
37 | require([
38 | 'ledger',
39 | 'dashboard/module',
40 | 'income/module',
41 | 'spending/module',
42 | 'worth/module',
43 | 'balance/module'
44 | ], function(Ledger) {
45 | 'use strict';
46 |
47 | Ledger.start();
48 | });
--------------------------------------------------------------------------------
/public/js/ledger/controls/model.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'singleActiveItem',
3 | 'backbone'
4 | ], function(singleActiveItem, Backbone) {
5 | 'use strict';
6 |
7 | var GroupBy = Backbone.Model.extend({
8 | defaults: {
9 | name: '',
10 | active: false
11 | },
12 |
13 | select: function() {
14 | this.set('active', true);
15 | }
16 | });
17 |
18 | var Grouping = Backbone.Collection.extend({
19 | model: GroupBy,
20 |
21 | initialize: function() {
22 | singleActiveItem(this);
23 | },
24 |
25 | // Get the currently active groupby
26 | active: function() {
27 | return this.findWhere({active: true}).get('name');
28 | },
29 |
30 | activate: function(name) {
31 | if (name && name.length !== 0) {
32 | this.findWhere({name: name}).select();
33 | }
34 | }
35 | });
36 |
37 | // Default Grouping Collection
38 | var defaults = new Grouping([
39 | new GroupBy({name: 'day'}),
40 | new GroupBy({name: 'month', active: true}),
41 | new GroupBy({name: 'year'})
42 | ]);
43 |
44 | return {
45 | GroupBy: GroupBy,
46 | Grouping: Grouping,
47 | defaults: defaults
48 | };
49 | });
--------------------------------------------------------------------------------
/public/js/ledger/dashboard/navigation.jsx:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'underscore',
5 | 'react',
6 | 'react.backbone'
7 | ], function(_, React, createBackboneClass) {
8 | 'use strict';
9 |
10 | var Item = createBackboneClass({
11 | select: function(evt) {
12 | evt.preventDefault();
13 | this.props.model.select();
14 | },
15 |
16 | render: function() {
17 | return (
18 |
19 | {this.props.model.get('title')}
20 |
21 | );
22 | }
23 | });
24 |
25 | var Navigation = createBackboneClass({
26 | render: function() {
27 | var itemNodes = this.props.model.map(function(section) {
28 | return (
29 |
30 | );
31 | });
32 |
33 | return (
34 |
40 | );
41 | }
42 | });
43 |
44 | return Navigation;
45 | });
--------------------------------------------------------------------------------
/views/layout.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ledger web
6 |
7 |
8 |
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | <%- body %>
21 |
22 |
23 |
24 | <% if (environment === 'production') { %>
25 |
26 | <% } else { %>
27 |
28 | <% } %>
29 |
30 |
33 |
34 |
--------------------------------------------------------------------------------
/public/js/ledger/dashboard/navigation.js:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'underscore',
5 | 'react',
6 | 'react.backbone'
7 | ], function(_, React, createBackboneClass) {
8 | 'use strict';
9 |
10 | var Item = createBackboneClass({
11 | select: function(evt) {
12 | evt.preventDefault();
13 | this.props.model.select();
14 | },
15 |
16 | render: function() {
17 | return (
18 | React.DOM.li( {className:this.props.model.get('active') ? 'active' : ''},
19 | React.DOM.a( {href:this.props.model.get('url'), onClick:this.select}, this.props.model.get('title'))
20 | )
21 | );
22 | }
23 | });
24 |
25 | var Navigation = createBackboneClass({
26 | render: function() {
27 | var itemNodes = this.props.model.map(function(section) {
28 | return (
29 | Item( {model:section, key:section.get('url')} )
30 | );
31 | });
32 |
33 | return (
34 | React.DOM.div( {className:"navbar-inner"},
35 | React.DOM.a( {className:"brand", href:"/"}, "Ledger"),
36 | React.DOM.ul( {className:"nav"},
37 | itemNodes
38 | )
39 | )
40 | );
41 | }
42 | });
43 |
44 | return Navigation;
45 | });
--------------------------------------------------------------------------------
/public/js/ledger/dashboard/dashboard.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jsx React.DOM
3 | */
4 |
5 | define([
6 | 'underscore',
7 | 'react'
8 | ], function(_, React) {
9 | 'use strict';
10 |
11 | var Dashboard = React.createClass({
12 | render: function() {
13 | return (
14 |
15 |
Ledger Web
16 |
Your financial dashboard.
17 |
18 |
19 |
20 |
21 |
Income
22 |
Compared to expenditure
23 |
Over time
24 |
By Category
25 |
26 |
27 |
28 |
Spending
29 |
Over time
30 |
By Category
31 |
32 |
33 |
34 |
Net Worth
35 |
36 |
37 |
38 |
Balance
39 |
By account
40 |
41 |
42 |
43 | );
44 | }
45 | });
46 |
47 | return Dashboard;
48 | });
--------------------------------------------------------------------------------
/public/js/ledger/spending/controller.js:
--------------------------------------------------------------------------------
1 | define([
2 | './model',
3 | './expenditure-chart',
4 | 'controls/model',
5 | 'controls/charting',
6 | 'backbone',
7 | 'marionette',
8 | 'vent',
9 | 'underscore',
10 | 'react'
11 | ], function(Models, ExpenditureChart, Controls, Charting, Backbone, Marionette, vent, _, React) {
12 | 'use strict';
13 |
14 | var Controller = function () {
15 | this.controls = {
16 | grouping: Controls.defaults
17 | };
18 |
19 | this.expenses = new Models.Expenses();
20 | };
21 |
22 | _.extend(Controller.prototype, {
23 | start: _.once(function() {
24 | this.expenses.fetch({reset: true});
25 | }),
26 |
27 | showSpending: function (groupBy) {
28 | groupBy = groupBy || this.controls.grouping.active();
29 |
30 | this.controls.grouping.activate(groupBy);
31 |
32 | var chart = new ExpenditureChart({ model: this.expenses, groupBy: groupBy, category: 'account' });
33 |
34 | var onGroupBy = function(groupBy) {
35 | var name = groupBy.get('name');
36 |
37 | chart.setProps({ groupBy: name });
38 |
39 | vent.trigger('controls:groupby', { name: name });
40 | };
41 |
42 | React.renderComponent(
43 | new Charting({ grouping: this.controls.grouping, onGroupBy: onGroupBy }, chart),
44 | document.getElementById('main')
45 | );
46 | }
47 | });
48 |
49 | return Controller;
50 | });
--------------------------------------------------------------------------------
/public/js/ledger/aggregateCollection.js:
--------------------------------------------------------------------------------
1 | // Collection Decorator For Aggregating many collections
2 | // ----------------------------------
3 |
4 | define(['underscore'], function(_) {
5 | function AggregateCollection(aggregateCollection, sourceCollections) {
6 | var aggregated = new aggregateCollection();
7 |
8 | // allow this object to have it's own events
9 | aggregated._callbacks = {};
10 |
11 | _.each(sourceCollections, function(collection) {
12 | aggregated.listenTo(collection, 'reset', function() {
13 | var models = _.flatten(_.map(sourceCollections, function(c) {
14 | return c.models;
15 | }));
16 | aggregated.reset(models);
17 | });
18 |
19 | // Add matching models to aggregated collection
20 | aggregated.listenTo(collection, 'add', function(model) {
21 | aggregated.add(model);
22 | });
23 |
24 | // Remove matching models from aggregated collection
25 | aggregated.listenTo(collection, 'remove', function(model) {
26 | aggregated.remove(model);
27 | });
28 |
29 | // Unsubscribe from all events when underlying collection is destroyed
30 | aggregated.listenToOnce(collection, 'destroy', function() {
31 | aggregated.stopListening(collection);
32 | });
33 | });
34 |
35 | return aggregated;
36 | }
37 |
38 | return AggregateCollection;
39 | });
--------------------------------------------------------------------------------
/public/js/ledger/dashboard/dashboard.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @jsx React.DOM
3 | */
4 |
5 | define([
6 | 'underscore',
7 | 'react'
8 | ], function(_, React) {
9 | 'use strict';
10 |
11 | var Dashboard = React.createClass({displayName: 'Dashboard',
12 | render: function() {
13 | return (
14 | React.DOM.div( {className:"jumbotron"},
15 | React.DOM.h1(null, "Ledger Web"),
16 | React.DOM.p( {className:"lead"}, "Your financial dashboard."),
17 |
18 | React.DOM.hr(null ),
19 | React.DOM.div( {className:"row-fluid"},
20 | React.DOM.div( {className:"span3"},
21 | React.DOM.h2(null, "Income"),
22 | React.DOM.p(null, "Compared to expenditure"),
23 | React.DOM.p(null, "Over time"),
24 | React.DOM.p(null, "By Category")
25 | ),
26 |
27 | React.DOM.div( {className:"span3"},
28 | React.DOM.h2(null, "Spending"),
29 | React.DOM.p(null, "Over time"),
30 | React.DOM.p(null, "By Category")
31 | ),
32 |
33 | React.DOM.div( {className:"span3"},
34 | React.DOM.h2(null, "Net Worth")
35 | ),
36 |
37 | React.DOM.div( {className:"span3"},
38 | React.DOM.h2(null, "Balance"),
39 | React.DOM.p(null, "By account")
40 | )
41 | )
42 | )
43 | );
44 | }
45 | });
46 |
47 | return Dashboard;
48 | });
--------------------------------------------------------------------------------
/public/js/ledger/income/controller.js:
--------------------------------------------------------------------------------
1 | define([
2 | './model',
3 | './income-vs-expenditure-chart',
4 | 'controls/model',
5 | 'controls/charting',
6 | 'aggregateCollection',
7 | 'vent',
8 | 'underscore',
9 | 'react'
10 | ], function(Models, IncomeVsExpenditureChart, Controls, Charting, AggregateCollection, vent, _, React) {
11 | 'use strict';
12 |
13 | var Controller = function () {
14 | this.controls = {
15 | grouping: Controls.defaults
16 | };
17 |
18 | this.income = new Models.Income();
19 | this.expenses = new Models.Expenses();
20 | this.aggregated = new AggregateCollection(Models.Aggregated, [this.income, this.expenses]);
21 | };
22 |
23 | _.extend(Controller.prototype, {
24 | start: _.once(function() {
25 | this.income.fetch({reset: true});
26 | this.expenses.fetch({reset: true});
27 | }),
28 |
29 | showIncome: function(groupBy) {
30 | groupBy = groupBy || this.controls.grouping.active();
31 |
32 | this.controls.grouping.activate(groupBy);
33 |
34 | var chart = new IncomeVsExpenditureChart({ model: this.aggregated, groupBy: groupBy });
35 |
36 | var onGroupBy = function(groupBy) {
37 | var name = groupBy.get('name');
38 |
39 | chart.setProps({ groupBy: name });
40 |
41 | vent.trigger('controls:groupby', { name: name });
42 | };
43 |
44 | React.renderComponent(
45 | new Charting({ grouping: this.controls.grouping, onGroupBy: onGroupBy }, chart),
46 | document.getElementById('main')
47 | );
48 | }
49 | });
50 |
51 | return Controller;
52 | });
--------------------------------------------------------------------------------
/public/js/ledger/balance/model.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'groupByDate',
3 | 'dateRange',
4 | 'backbone'
5 | ], function(groupByDate, DateRange, Backbone) {
6 | 'use strict';
7 |
8 | var Account = Backbone.Model.extend({
9 | defaults: {
10 | fullname: '',
11 | shortname: ''
12 | }
13 | });
14 |
15 | var Balance = Backbone.Model.extend({
16 | defaults: {
17 | total: {
18 | currency: '',
19 | amount: 0,
20 | formatted: ''
21 | },
22 | account: new Account()
23 | },
24 |
25 | parse: function(response, options) {
26 | var attrs = Backbone.Model.prototype.parse(response, options);
27 | attrs.account = new Account(response.account, options);
28 | return attrs;
29 | },
30 |
31 | fullname: function() {
32 | return this.get('account').get('fullname');
33 | },
34 |
35 | filterByDepth: function(depth) {
36 | return this.get('account').get('depth') === depth;
37 | },
38 |
39 | filterByParentName: function(name) {
40 | return this.fullname().indexOf(name + ':') === 0;
41 | },
42 |
43 | filterByParentNameAndDepth: function(name) {
44 | if (name.length === 0) {
45 | return this.filterByDepth(1);
46 | }
47 |
48 | var depth = name.split(':').length + 1;
49 |
50 | return this.filterByDepth(depth) && this.filterByParentName(name);
51 | }
52 | });
53 |
54 | var Balances = Backbone.Collection.extend({
55 | model: Balance,
56 | url: '/api/balance'
57 | });
58 |
59 | return {
60 | Account: Account,
61 | Balance: Balance,
62 | Balances: Balances
63 | };
64 | });
--------------------------------------------------------------------------------
/public/js/ledger/charting/pie-chart.jsx:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'react',
5 | 'nvd3',
6 | 'd3'
7 | ], function(React, nv, d3) {
8 | 'use strict';
9 |
10 | var PieChart = React.createClass({
11 | componentDidMount: function(rootNode) {
12 | this.buildChart(rootNode);
13 | },
14 |
15 | componentDidUpdate: function(prevProps, prevState, rootNode) {
16 | this.buildChart(rootNode);
17 | },
18 |
19 | buildChart: function(el) {
20 | var self = this,
21 | sourceData = this.props.data;
22 |
23 | if (sourceData.length === 0) {
24 | return;
25 | }
26 |
27 | if (this.chart) {
28 | d3.select(el)
29 | .datum(sourceData)
30 | .transition()
31 | .call(this.chart);
32 |
33 | return;
34 | }
35 |
36 | nv.addGraph(function() {
37 | var chart = nv.models.pieChart()
38 | .x(function(d) { return d.label; })
39 | .y(function(d) { return d.value; })
40 | .showLabels(true)
41 | .labelThreshold(0.05)
42 | .donut(true);
43 |
44 | d3.select(el)
45 | .datum(sourceData)
46 | .transition()
47 | .call(chart);
48 |
49 | self.chart = chart;
50 |
51 | return chart;
52 | });
53 | },
54 |
55 | render: function() {
56 | if (this.props.data.length === 0) {
57 | return (
58 | No data
59 | );
60 | }
61 |
62 | return (
63 |
64 | );
65 | }
66 | });
67 |
68 | return PieChart;
69 | });
--------------------------------------------------------------------------------
/public/js/ledger/charting/pie-chart.js:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'react',
5 | 'nvd3',
6 | 'd3'
7 | ], function(React, nv, d3) {
8 | 'use strict';
9 |
10 | var PieChart = React.createClass({displayName: 'PieChart',
11 | componentDidMount: function(rootNode) {
12 | this.buildChart(rootNode);
13 | },
14 |
15 | componentDidUpdate: function(prevProps, prevState, rootNode) {
16 | this.buildChart(rootNode);
17 | },
18 |
19 | buildChart: function(el) {
20 | var self = this,
21 | sourceData = this.props.data;
22 |
23 | if (sourceData.length === 0) {
24 | return;
25 | }
26 |
27 | if (this.chart) {
28 | d3.select(el)
29 | .datum(sourceData)
30 | .transition()
31 | .call(this.chart);
32 |
33 | return;
34 | }
35 |
36 | nv.addGraph(function() {
37 | var chart = nv.models.pieChart()
38 | .x(function(d) { return d.label; })
39 | .y(function(d) { return d.value; })
40 | .showLabels(true)
41 | .labelThreshold(0.05)
42 | .donut(true);
43 |
44 | d3.select(el)
45 | .datum(sourceData)
46 | .transition()
47 | .call(chart);
48 |
49 | self.chart = chart;
50 |
51 | return chart;
52 | });
53 | },
54 |
55 | render: function() {
56 | if (this.props.data.length === 0) {
57 | return (
58 | React.DOM.p( {className:"text-center"}, "No data")
59 | );
60 | }
61 |
62 | return (
63 | React.DOM.svg( {style:{height: this.props.height, width: this.props.width}} )
64 | );
65 | }
66 | });
67 |
68 | return PieChart;
69 | });
--------------------------------------------------------------------------------
/public/js/ledger/controls/charting.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jsx React.DOM
3 | */
4 |
5 | define([
6 | 'underscore',
7 | 'react',
8 | 'react.backbone'
9 | ], function(_, React, createBackboneClass) {
10 | 'use strict';
11 |
12 | var GroupBy = createBackboneClass({
13 | onGroupBy: function(evt) {
14 | evt.preventDefault();
15 | this.props.model.select();
16 | this.props.onGroupBy(this.props.model);
17 | },
18 |
19 | render: function() {
20 | return (
21 |
22 | {this.props.model.get('name')}
23 |
24 | );
25 | }
26 | });
27 |
28 | var Grouping = createBackboneClass({
29 | render: function() {
30 | var onGroupBy = this.props.onGroupBy;
31 |
32 | var groupings = this.props.model.map(function(groupBy) {
33 | return (
34 |
35 | );
36 | });
37 |
38 | return (
39 |
40 | - Group by
41 | {groupings}
42 |
43 | );
44 | }
45 | });
46 |
47 | var Charting = createBackboneClass({
48 | render: function() {
49 | return (
50 |
51 |
54 |
55 | {this.props.children}
56 |
57 |
58 | );
59 | }
60 | });
61 |
62 | return Charting;
63 | });
--------------------------------------------------------------------------------
/public/js/ledger/controls/charting.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @jsx React.DOM
3 | */
4 |
5 | define([
6 | 'underscore',
7 | 'react',
8 | 'react.backbone'
9 | ], function(_, React, createBackboneClass) {
10 | 'use strict';
11 |
12 | var GroupBy = createBackboneClass({
13 | onGroupBy: function(evt) {
14 | evt.preventDefault();
15 | this.props.model.select();
16 | this.props.onGroupBy(this.props.model);
17 | },
18 |
19 | render: function() {
20 | return (
21 | React.DOM.li( {className:this.props.model.get('active') ? 'active' : ''},
22 | React.DOM.a( {href:"#", onClick:this.onGroupBy}, this.props.model.get('name'))
23 | )
24 | );
25 | }
26 | });
27 |
28 | var Grouping = createBackboneClass({
29 | render: function() {
30 | var onGroupBy = this.props.onGroupBy;
31 |
32 | var groupings = this.props.model.map(function(groupBy) {
33 | return (
34 | GroupBy( {model:groupBy, onGroupBy:onGroupBy, key:groupBy.get('name')} )
35 | );
36 | });
37 |
38 | return (
39 | React.DOM.ul( {id:"groupby", className:"nav nav-list"},
40 | React.DOM.li( {className:"nav-header"}, "Group by"),
41 | groupings
42 | )
43 | );
44 | }
45 | });
46 |
47 | var Charting = createBackboneClass({
48 | render: function() {
49 | return (
50 | React.DOM.section(null,
51 | React.DOM.nav( {id:"controls", className:"span2"},
52 | Grouping( {model:this.props.grouping, onGroupBy:this.props.onGroupBy} )
53 | ),
54 | React.DOM.article( {id:"chart", className:"span10"},
55 | this.props.children
56 | )
57 | )
58 | );
59 | }
60 | });
61 |
62 | return Charting;
63 | });
--------------------------------------------------------------------------------
/public/js/ledger/dashboard/controller.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'ledger',
3 | './model',
4 | './navigation',
5 | './dashboard',
6 | 'backbone', 'marionette', 'vent', 'jquery', 'underscore', 'react'
7 | ], function(Ledger, Models, Navigation, Dashboard, Backbone, Marionette, vent, $, _, React) {
8 | 'use strict';
9 |
10 | var Controller = function () {
11 | this.sections = new Models.Sections([
12 | new Models.Section({title: 'Home', name: 'dashboard', url: '/', active: true}),
13 | new Models.Section({title: 'Income', name: 'income', url: 'income'}),
14 | new Models.Section({title: 'Spending', name: 'spending', url: 'spending'}),
15 | new Models.Section({title: 'Worth', name: 'worth', url: 'worth'}),
16 | new Models.Section({title: 'Balance', name: 'balance', url: 'balance'})
17 | ]);
18 |
19 | this.sections.on('change:active', this.showSection, this);
20 |
21 | // Select the activated section on navigation
22 | vent.on('section:activated', function(params) {
23 | this.sections.activate(params.name);
24 | }, this);
25 | };
26 |
27 | _.extend(Controller.prototype, {
28 | start: _.once(function() {
29 | React.renderComponent(
30 | new Navigation({ model: this.sections }),
31 | document.getElementById('nav')
32 | );
33 | }),
34 |
35 | showDashboard: function () {
36 | React.renderComponent(
37 | new Dashboard(),
38 | document.getElementById('main')
39 | );
40 | },
41 |
42 | showSection: function(section, value) {
43 | if (value === true) {
44 | var url = section.get('url');
45 |
46 | // Don't navigate if we are already in this section
47 | if (window.location.pathname.indexOf('/' + url) === 0) {
48 | return;
49 | }
50 |
51 | Backbone.history.navigate(url, {trigger: true});
52 | }
53 | }
54 | });
55 |
56 | return Controller;
57 | });
--------------------------------------------------------------------------------
/public/js/ledger/worth/controller.js:
--------------------------------------------------------------------------------
1 | define([
2 | './model',
3 | './net-worth-chart',
4 | 'controls/model',
5 | 'controls/charting',
6 | 'aggregateCollection',
7 | 'vent',
8 | 'jquery',
9 | 'underscore',
10 | 'react'
11 | ], function(Models, NetWorthChart, Controls, Charting, AggregateCollection, vent, $, _, React) {
12 | 'use strict';
13 |
14 | var Controller = function () {
15 | this.controls = {
16 | grouping: Controls.defaults
17 | };
18 |
19 | this.assets = new Models.Assets();
20 | this.liabilities = new Models.Liabilities();
21 |
22 | // Net worth is the combined total of Assets and Liabilities.
23 | this.netWorth = new AggregateCollection(Models.Aggregated, [this.assets, this.liabilities]);
24 | };
25 |
26 | _.extend(Controller.prototype, {
27 | start: _.once(function () {
28 | var self = this;
29 |
30 | $.when(
31 | this.assets.fetch({reset: true}),
32 | this.liabilities.fetch({reset: true})
33 | ).done(function() {
34 | // show net worth chart after both assets and liabilities have been fetched
35 | self.showNetWorth();
36 | });
37 | }),
38 |
39 | showNetWorth: function(groupBy) {
40 | groupBy = groupBy || this.controls.grouping.active();
41 |
42 | this.controls.grouping.activate(groupBy);
43 |
44 | if (this.netWorth.length === 0) { return; }
45 |
46 | var chart = new NetWorthChart({ model: this.netWorth, groupBy: groupBy });
47 |
48 | var onGroupBy = function(groupBy) {
49 | var name = groupBy.get('name');
50 |
51 | chart.setProps({ groupBy: name });
52 |
53 | vent.trigger('controls:groupby', { name: name });
54 | };
55 |
56 | React.renderComponent(
57 | new Charting({ grouping: this.controls.grouping, onGroupBy: onGroupBy }, chart),
58 | document.getElementById('main')
59 | );
60 | }
61 | });
62 |
63 | return Controller;
64 | });
--------------------------------------------------------------------------------
/public/js/ledger/balance/controller.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'ledger',
3 | './model',
4 | './chart',
5 | 'controls/model',
6 | 'filteredCollection',
7 | 'backbone',
8 | 'vent',
9 | 'underscore',
10 | 'react'
11 | ], function(Ledger, Models, Chart, Controls, FilteredCollection, Backbone, vent, _, React) {
12 | 'use strict';
13 |
14 | var Controller = function () {
15 | this.balance = new Models.Balances();
16 | this.filteredBalance = new FilteredCollection(this.balance);
17 | };
18 |
19 | _.extend(Controller.prototype, {
20 | start: _.once(function () {
21 | this.balance.fetch({reset: true});
22 | }),
23 |
24 | showBalance: function() {
25 | this.showBalanceChartView();
26 |
27 | // Initially show top-level accounts (e.g. Assets, Expenses, Income, Liabilities)
28 | this.filteredBalance.where(function(entry) {
29 | return entry.filterByDepth(1);
30 | });
31 | },
32 |
33 | // filter balance by an account
34 | showBalanceForAccount: function(account) {
35 | account = (account || '').split('/').join(':');
36 |
37 | this.filterByAccount(account);
38 | this.showBalanceChartView();
39 | },
40 |
41 | showBalanceChartView: function() {
42 | React.renderComponent(
43 | new Chart({ model: this.filteredBalance, onFilter: this.filterByAccount.bind(this) }),
44 | document.getElementById('main')
45 | );
46 | },
47 |
48 | filterByAccount: function(account) {
49 | this.filteredBalance.where(function(entry) {
50 | return entry.filterByParentNameAndDepth(account);
51 | });
52 |
53 | vent.trigger('balance:filter', { name: account });
54 | }
55 | });
56 |
57 | vent.on('balance:filter', function(filter) {
58 | var name = filter.name,
59 | url = name.split(':').join('/');
60 |
61 | Backbone.history.navigate('balance/' + url, {trigger: false});
62 | });
63 |
64 | return Controller;
65 | });
--------------------------------------------------------------------------------
/public/js/ledger/dateRange.js:
--------------------------------------------------------------------------------
1 | define(function() {
2 | var DateRange = function(from, to) {
3 | this.from = from;
4 | this.to = to;
5 | };
6 |
7 | // Returns an array of dates between from and to.
8 | DateRange.prototype.between = function(granularity) {
9 | switch (granularity) {
10 | case 'year': return this.yearsBetween();
11 | case 'month': return this.monthsBetween();
12 | case 'day': return this.daysBetween();
13 | }
14 |
15 | throw 'Date range granularity "' + granularity + '" is not supported';
16 | };
17 |
18 | // Returns an array of years between from and to.
19 | DateRange.prototype.yearsBetween = function() {
20 | var current = this.from.getFullYear(),
21 | toYear = this.to.getFullYear(),
22 | range = [];
23 |
24 | while (current <= toYear) {
25 | range.push(new Date(current, 0, 1));
26 |
27 | // Move to the next year
28 | current += 1;
29 | }
30 |
31 | return range;
32 | };
33 |
34 | // Returns an array of months between from and to.
35 | DateRange.prototype.monthsBetween = function() {
36 | var current = new Date(this.from.getFullYear(), this.from.getMonth(), 1),
37 | toMonth = new Date(this.to.getFullYear(), this.to.getMonth(), 1),
38 | range = [];
39 |
40 | while (current <= toMonth) {
41 | range.push(current);
42 |
43 | // Move to the next month
44 | current = new Date(current);
45 | current.setMonth(current.getMonth() + 1);
46 | }
47 |
48 | return range;
49 | };
50 |
51 | // Returns an array of days between from and to.
52 | DateRange.prototype.daysBetween = function() {
53 | var current = this.from,
54 | range = [];
55 |
56 | while (current < this.to) {
57 | range.push(current);
58 |
59 | current = new Date(current);
60 | current.setDate(current.getDate() + 1);
61 | }
62 |
63 | return range;
64 | };
65 |
66 | return DateRange;
67 | });
--------------------------------------------------------------------------------
/public/js/ledger/react.backbone.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'backbone',
3 | 'react'
4 | ], function(Backbone, React) {
5 | 'use strict';
6 |
7 | var BackboneMixin = {
8 | _subscribe: function(model) {
9 | if (!model) {
10 | return;
11 | }
12 | // Detect if it's a collection
13 | if (model instanceof Backbone.Collection) {
14 | model.on('add remove reset sort', function () { this.forceUpdate(); }, this);
15 | }
16 | else if (model) {
17 | var changeOptions = this.changeOptions || 'change';
18 | model.on(changeOptions, (this.onModelChange || function () { this.forceUpdate(); }), this);
19 | }
20 | },
21 |
22 | _unsubscribe: function(model) {
23 | if (!model) {
24 | return;
25 | }
26 | model.off(null, null, this);
27 | },
28 |
29 | componentDidMount: function() {
30 | // Whenever there may be a change in the Backbone data, trigger a reconcile.
31 | this._subscribe(this.props.model);
32 | },
33 |
34 | componentWillReceiveProps: function(nextProps) {
35 | if (this.props.model !== nextProps.model) {
36 | this._unsubscribe(this.props.model);
37 | this._subscribe(nextProps.model);
38 | }
39 | },
40 |
41 | componentWillUnmount: function() {
42 | // Ensure that we clean up any dangling references when the component is destroyed.
43 | this._unsubscribe(this.props.model);
44 | }
45 | };
46 |
47 | var createBackboneClass = function(spec) {
48 | var currentMixins = spec.mixins || [];
49 |
50 | spec.mixins = currentMixins.concat([ BackboneMixin ]);
51 |
52 | spec.getModel = function() {
53 | return this.props.model;
54 | };
55 |
56 | spec.model = function() {
57 | return this.getModel();
58 | };
59 |
60 | spec.el = function() {
61 | return this.isMounted() && this.getDOMNode();
62 | };
63 |
64 | return React.createClass(spec);
65 | };
66 |
67 | return createBackboneClass;
68 | });
--------------------------------------------------------------------------------
/example/drewr.dat:
--------------------------------------------------------------------------------
1 | ; -*- ledger -*-
2 |
3 | = /^Income/
4 | (Liabilities:Tithe) £0.12
5 |
6 | ~ Monthly
7 | Assets:Checking £500.00
8 | Income:Salary
9 |
10 | 2003/12/01 * Checking balance
11 | Assets:Checking £1000.00
12 | Equity:Opening Balances
13 |
14 | 2003/12/20 Organic Co-op
15 | Expenses:Food:Groceries £ 37.50 ; [=2004/01/01]
16 | Expenses:Food:Groceries £ 37.50 ; [=2004/02/01]
17 | Expenses:Food:Groceries £ 37.50 ; [=2004/03/01]
18 | Expenses:Food:Groceries £ 37.50 ; [=2004/04/01]
19 | Expenses:Food:Groceries £ 37.50 ; [=2004/05/01]
20 | Expenses:Food:Groceries £ 37.50 ; [=2004/06/01]
21 | Assets:Checking £ -225.00
22 |
23 | 2003/12/28=2004/01/01 Acme Mortgage
24 | Liabilities:Mortgage:Principal £ 200.00
25 | Expenses:Interest:Mortgage £ 500.00
26 | Expenses:Escrow £ 300.00
27 | Assets:Checking £-1,000.00
28 |
29 | 2004/01/02 Grocery Store
30 | Expenses:Food:Groceries £65.00
31 | Assets:Checking
32 |
33 | 2004/01/05 Employer
34 | Assets:Checking £2000.00
35 | Income:Salary
36 |
37 | 2004/01/14 Bank
38 | ; Regular monthly savings transfer
39 | Assets:Savings £ 300.00
40 | Assets:Checking
41 |
42 | 2004/01/19 Grocery Store
43 | Expenses:Food:Groceries £ 44.00
44 | Assets:Checking
45 |
46 | 2004/01/25 Bank
47 | ; Transfer to cover car purchase
48 | Assets:Checking £ 5,500.00
49 | Assets:Savings
50 | ; :nobudget:
51 |
52 | 2004/01/25 Tom’s Used Cars
53 | Expenses:Auto £ 5,500.00
54 | ; :nobudget:
55 | Assets:Checking
56 |
57 | 2004/01/27 Book Store
58 | Expenses:Books £20.00
59 | Liabilities:MasterCard
60 |
61 | 2004/02/01 Sale
62 | Assets:Checking:Business £30.00
63 | Income:Sales
--------------------------------------------------------------------------------
/public/js/ledger/spending/model.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'groupByDate',
3 | 'dateRange',
4 | 'backbone',
5 | 'underscore'
6 | ], function(groupByDate, DateRange, Backbone, _) {
7 | 'use strict';
8 |
9 | var Entry = Backbone.Model.extend({
10 | defaults: {
11 | date: null,
12 | payee: '',
13 | postings: []
14 | },
15 |
16 | initialize: function() {
17 | _.extend(this, groupByDate(new Date(this.get('date'))));
18 | },
19 |
20 | totalAmount: function() {
21 | return this.totalByAccount('Expenses');
22 | },
23 |
24 | totalByAccount: function(account) {
25 | return _.reduce(this.get('postings'), function(memo, posting) {
26 | return (posting.account.indexOf(account) === 0) ? memo + posting.commodity.amount : memo;
27 | }, 0);
28 | },
29 |
30 | getAccounts: function() {
31 | return _.map(this.get('postings'), function(posting) {
32 | return posting.account;
33 | });
34 | },
35 |
36 | hasAccount: function(account) {
37 | return _.any(this.get('postings'), function(posting) {
38 | return posting.account === account;
39 | });
40 | }
41 | });
42 |
43 | var Expenses = Backbone.Collection.extend({
44 | model: Entry,
45 | url: '/api/register/Expenses',
46 |
47 | getDateRange: function() {
48 | var from = _.min(this.map(function(entry) { return entry.getDate(); })),
49 | to = _.max(this.map(function(entry) { return entry.getDate(); }));
50 |
51 | return new DateRange(from, to);
52 | },
53 |
54 | getAccounts: function() {
55 | var accounts = this.map(function(entry) {
56 | return entry.getAccounts();
57 | });
58 |
59 | return _.uniq(_.flatten(accounts));
60 | },
61 |
62 | getByAccount: function(account) {
63 | return this.select(function(entry) {
64 | return entry.hasAccount(account);
65 | });
66 | }
67 | });
68 |
69 | return {
70 | Entry: Entry,
71 | Expenses: Expenses
72 | };
73 | });
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | var _ = require('lodash'),
2 | express = require('express'),
3 | home = require('./routes/home'),
4 | http = require('http'),
5 | path = require('path'),
6 | engine = require('ejs-locals'),
7 | httpProxy = require('http-proxy'),
8 | LedgerRest = require('ledger-rest').LedgerRest;
9 |
10 | var config;
11 | try {
12 | config = require('./config.json');
13 | } catch (e) {
14 | config = require('./sample-config.json');
15 | }
16 |
17 | var app = express();
18 |
19 | app.configure(function() {
20 | var port = parseInt(process.env.PORT || 3000, 10);
21 |
22 | app.set('port', port);
23 | app.set('views', __dirname + '/views');
24 | app.set('view engine', 'html');
25 | app.engine('html', engine);
26 |
27 | app.use(express.favicon());
28 | app.use(express.logger('dev'));
29 | app.use(express.methodOverride());
30 | app.use(app.router);
31 | app.use(express.static(path.join(__dirname, 'public')));
32 |
33 | var proxy = httpProxy.createProxyServer();
34 |
35 | // Example ledger .dat file from the appendix of the Ledger 3 manual
36 | var ledgerRest = new LedgerRest(config);
37 |
38 | var ledgerRestPort = port + 1;
39 | ledgerRest.listen(ledgerRestPort, function() {
40 | console.log('Ledger REST server listening on port ' + ledgerRestPort);
41 | });
42 |
43 | // Proxy API requests to the ledger REST service
44 | app.use('/api', function (req, res) {
45 | proxy.web(req, res, { target: {
46 | host: 'localhost',
47 | port: port + 1
48 | }});
49 | });
50 | });
51 |
52 | app.configure('development', function(){
53 | app.use(express.errorHandler());
54 | });
55 |
56 | var routes = [
57 | '/',
58 | '/income', '/income/*',
59 | '/spending', '/spending/*',
60 | '/worth', '/worth/*',
61 | '/balance', '/balance/*',
62 | ];
63 |
64 | _.each(routes, function(route) {
65 | app.get(route, home.index);
66 | });
67 |
68 | http.createServer(app).listen(app.get('port'), function(){
69 | console.log('Express server listening on port ' + app.get('port'));
70 | });
71 |
--------------------------------------------------------------------------------
/public/js/ledger/income/model.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'groupByDate', 'dateRange', 'backbone', 'marionette', 'jquery', 'underscore'
3 | ], function(groupByDate, DateRange, Backbone, Marionette, $, _) {
4 | 'use strict';
5 |
6 | // Income + Expenses Entry Model
7 | // ----------
8 | var Entry = Backbone.Model.extend({
9 | defaults: {
10 | date: null,
11 | payee: '',
12 | postings: []
13 | },
14 |
15 | initialize: function () {
16 | _.extend(this, groupByDate(new Date(this.get('date'))));
17 | },
18 |
19 | isIncome: function() {
20 | return _.any(this.get('postings'), function(posting) {
21 | return posting.account.indexOf('Income:') === 0;
22 | });
23 | },
24 |
25 | isExpense: function() {
26 | return _.any(this.get('postings'), function(posting) {
27 | return posting.account.indexOf('Expenses:') === 0;
28 | });
29 | },
30 |
31 | totalByAccount: function(account) {
32 | return _.reduce(this.get('postings'), function(memo, posting) {
33 | return (posting.account.indexOf(account) === 0) ? memo + posting.commodity.amount : memo;
34 | }, 0);
35 | }
36 | });
37 |
38 | // Income Collection
39 | // ---------------
40 | var Income = Backbone.Collection.extend({
41 | model: Entry,
42 | url: '/api/register/Income'
43 | });
44 |
45 | // Expenses Collection
46 | // ---------------
47 | var Expenses = Backbone.Collection.extend({
48 | model: Entry,
49 | url: '/api/register/Expenses'
50 | });
51 |
52 | // Aggregated Income + Expenses Collection
53 | // ---------------
54 | var Aggregated = Backbone.Collection.extend({
55 | model: Entry,
56 |
57 | getDateRange: function() {
58 | var from = _.min(this.map(function(entry) { return entry.getDate(); })),
59 | to = _.max(this.map(function(entry) { return entry.getDate(); }));
60 |
61 | return new DateRange(from, to);
62 | }
63 | });
64 |
65 | return {
66 | Entry: Entry,
67 | Income: Income,
68 | Expenses: Expenses,
69 | Aggregated: Aggregated
70 | };
71 | });
--------------------------------------------------------------------------------
/public/js/ledger/balance/chart.jsx:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'charting/pie-chart',
5 | 'underscore',
6 | 'react',
7 | 'react.backbone'
8 | ], function(PieChart, _, React, createBackboneClass) {
9 | 'use strict';
10 |
11 | var Filter = createBackboneClass({
12 | filter: function(evt) {
13 | evt.preventDefault();
14 | this.props.onFilter(this.props.model.fullname());
15 | },
16 |
17 | render: function() {
18 | return (
19 |
20 | {this.props.model.get('account').get('shortname')}
21 |
22 | );
23 | }
24 | });
25 |
26 | var FilterByCategory = createBackboneClass({
27 | render: function() {
28 | var onFilter = this.props.onFilter;
29 |
30 | var filters = this.props.model.map(function(balance) {
31 | return (
32 |
33 | );
34 | });
35 |
36 | return (
37 |
38 | - Filter by category
39 | {filters}
40 |
41 | );
42 | }
43 | });
44 |
45 | var Chart = createBackboneClass({
46 | propTypes: {
47 | onFilter: React.PropTypes.func.isRequired
48 | },
49 |
50 | chartData: function() {
51 | var values = this.props.model
52 | .map(function(entry) {
53 | return {
54 | label: entry.get('account').get('shortname'),
55 | value: Math.abs(entry.get('total').amount)
56 | };
57 | });
58 |
59 | return values;
60 | },
61 |
62 | render: function() {
63 | return (
64 |
74 | );
75 | }
76 | });
77 |
78 | return Chart;
79 | });
--------------------------------------------------------------------------------
/public/js/ledger/balance/chart.js:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'charting/pie-chart',
5 | 'underscore',
6 | 'react',
7 | 'react.backbone'
8 | ], function(PieChart, _, React, createBackboneClass) {
9 | 'use strict';
10 |
11 | var Filter = createBackboneClass({
12 | filter: function(evt) {
13 | evt.preventDefault();
14 | this.props.onFilter(this.props.model.fullname());
15 | },
16 |
17 | render: function() {
18 | return (
19 | React.DOM.li(null,
20 | React.DOM.a( {href:"#", onClick:this.filter}, this.props.model.get('account').get('shortname'))
21 | )
22 | );
23 | }
24 | });
25 |
26 | var FilterByCategory = createBackboneClass({
27 | render: function() {
28 | var onFilter = this.props.onFilter;
29 |
30 | var filters = this.props.model.map(function(balance) {
31 | return (
32 | Filter( {model:balance, onFilter:onFilter, key:balance.cid} )
33 | );
34 | });
35 |
36 | return (
37 | React.DOM.ul( {id:"filter", className:"nav nav-list"},
38 | React.DOM.li( {className:"nav-header"}, "Filter by category"),
39 | filters
40 | )
41 | );
42 | }
43 | });
44 |
45 | var Chart = createBackboneClass({
46 | propTypes: {
47 | onFilter: React.PropTypes.func.isRequired
48 | },
49 |
50 | chartData: function() {
51 | var values = this.props.model
52 | .map(function(entry) {
53 | return {
54 | label: entry.get('account').get('shortname'),
55 | value: Math.abs(entry.get('total').amount)
56 | };
57 | });
58 |
59 | return values;
60 | },
61 |
62 | render: function() {
63 | return (
64 | React.DOM.div(null,
65 | React.DOM.div( {className:"span2"},
66 | FilterByCategory( {model:this.props.model, onFilter:this.props.onFilter} )
67 | ),
68 | React.DOM.div( {className:"span10"},
69 | React.DOM.div( {id:"chart"},
70 | PieChart( {height:"700px", width:"970px", data:this.chartData()} )
71 | )
72 | )
73 | )
74 | );
75 | }
76 | });
77 |
78 | return Chart;
79 | });
--------------------------------------------------------------------------------
/public/js/ledger/worth/model.js:
--------------------------------------------------------------------------------
1 | define([
2 | 'groupByDate',
3 | 'dateRange',
4 | 'backbone',
5 | 'underscore'
6 | ], function(groupByDate, DateRange, Backbone, _) {
7 | 'use strict';
8 |
9 | // Assets + Liabilities Entry Model
10 | // ----------
11 | var Entry = Backbone.Model.extend({
12 | defaults: {
13 | date: null,
14 | payee: '',
15 | postings: []
16 | },
17 |
18 | initialize: function () {
19 | _.extend(this, groupByDate(new Date(this.get('date'))));
20 | },
21 |
22 | isAsset: function() {
23 | return _.any(this.get('postings'), function(posting) {
24 | return posting.account.indexOf('Assets:') === 0;
25 | });
26 | },
27 |
28 | isLiability: function() {
29 | return _.any(this.get('postings'), function(posting) {
30 | return posting.account.indexOf('Liabilities:') === 0;
31 | });
32 | },
33 |
34 | totalByAccount: function(account) {
35 | return _.reduce(this.get('postings'), function(memo, posting) {
36 | return (account.length === 0 || posting.account.indexOf(account + ':') === 0) ? memo + posting.commodity.amount : memo;
37 | }, 0);
38 | },
39 |
40 | totalAmount: function() {
41 | return this.totalByAccount('');
42 | }
43 | });
44 |
45 | // Assets Collection
46 | // ---------------
47 | var Assets = Backbone.Collection.extend({
48 | model: Entry,
49 | url: '/api/register/Assets'
50 | });
51 |
52 | // Liabilities Collection
53 | // ---------------
54 | var Liabilities = Backbone.Collection.extend({
55 | model: Entry,
56 | url: '/api/register/Liabilities'
57 | });
58 |
59 | // Aggregated Assets + Liabilities Collection
60 | // ---------------
61 | var Aggregated = Backbone.Collection.extend({
62 | model: Entry,
63 |
64 | getDateRange: function() {
65 | var from = _.min(this.map(function(entry) { return entry.getDate(); })),
66 | to = _.max(this.map(function(entry) { return entry.getDate(); }));
67 |
68 | return new DateRange(from, to);
69 | }
70 | });
71 |
72 | return {
73 | Entry: Entry,
74 | Assets: Assets,
75 | Liabilities: Liabilities,
76 | Aggregated: Aggregated
77 | };
78 | });
--------------------------------------------------------------------------------
/public/js/ledger/filteredCollection.js:
--------------------------------------------------------------------------------
1 | define(function() {
2 |
3 | // Collection Decorator For Filtering
4 | // ----------------------------------
5 | function FilteredCollection(original) {
6 | var filtered = new original.constructor();
7 |
8 | // allow this object to have it's own events
9 | filtered._callbacks = {};
10 |
11 | // call 'where' on the original function so that
12 | // filtering will happen from the complete collection
13 | filtered.where = function(criteria){
14 | var items;
15 |
16 | // call 'where' if we have criteria
17 | // or just get all the models if we don't
18 | if (criteria) {
19 | items = original.filter(criteria);
20 | } else {
21 | items = original.models;
22 | }
23 |
24 | // store current criteria
25 | filtered._currentCriteria = criteria;
26 |
27 | // reset the filtered collection with the new items
28 | filtered.reset(items);
29 | };
30 |
31 | var matches = function(model) {
32 | return !filtered._currentCriteria || filtered._currentCriteria(model);
33 | };
34 |
35 | // when the original collection is reset,
36 | // the filtered collection will re-filter itself
37 | // and end up with the new filtered result set
38 | filtered.listenTo(original, 'reset', function() {
39 | filtered.where(filtered._currentCriteria);
40 | });
41 |
42 | // Add matching models to filtered collection
43 | filtered.listenTo(original, 'add', function(model) {
44 | if (matches(model)) {
45 | filtered.add(model);
46 | }
47 | });
48 |
49 | // Remove matching models from filtered collection
50 | filtered.listenTo(original, 'remove', function(model) {
51 | if (matches(model)) {
52 | filtered.remove(model);
53 | }
54 | });
55 |
56 | // TODO: Add item if now matches (and not already in filtered list).
57 | // Or remove item if no longer matches (and exists in filtered list).
58 | // filtered.listenTo(original, 'change', function(model) {
59 | // });
60 |
61 | // Unsubscribe from all events when underlying collection is destroyed
62 | filtered.listenToOnce(original, 'destroy', function() {
63 | filtered.stopListening(original);
64 | });
65 |
66 | return filtered;
67 | }
68 |
69 | return FilteredCollection;
70 | });
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ledger-web
2 |
3 | Web front-end to access the Ledger command-line interface ([ledger-cli.org](http://ledger-cli.org/)).
4 |
5 | > Ledger is a powerful, double-entry accounting system that is accessed from the UNIX command-line.
6 |
7 | 
8 |
9 | ### Income
10 |
11 | Income compared to expenditure over time (daily, monthly or yearly).
12 |
13 | 
14 |
15 | ### Spending
16 |
17 | Over time and grouped by expense (daily, monthly or yearly).
18 |
19 | 
20 |
21 | ### Net Worth
22 |
23 | Assets minus liabilities over time (daily, monthly or yearly).
24 |
25 | 
26 |
27 | ### Balance
28 |
29 | Breakdown of transactions, filterable by type.
30 |
31 | 
32 |
33 | ## Dependencies
34 |
35 | * [Ledger 3](http://ledger-cli.org/)
36 | * [Node.js](nodejs.org) and npm
37 |
38 | ### Installing Ledger
39 |
40 | The simplest way to install Ledger 3 is through [Homebrew](http://mxcl.github.com/homebrew/).
41 |
42 | brew install ledger --HEAD
43 |
44 | The `--HEAD` option is required to install version 3.x.
45 |
46 | ## Usage
47 |
48 | Clone the `node-ledger-web` git repository from GitHub.
49 |
50 | git clone https://github.com/slashdotdash/node-ledger-web.git
51 |
52 | Install the dependencies with npm.
53 |
54 | cd node-ledger-web
55 | npm install
56 |
57 | Bower is used to manage JavaScript and CSS dependencies. Install it and our dependencies
58 |
59 | npm install -g bower
60 | bower install
61 |
62 | Grunt is used for building the front-end assets. Install grunt and run its default build task.
63 |
64 | npm install -g grunt-cli
65 | grunt
66 |
67 | Finally, run the express application and open [http://localhost:3000/](http://localhost:3000/) in a web browser.
68 |
69 | node app.js
70 |
71 | Two http servers will be started: One to listen on port 3000 for web requests, and one on port 3001 for API requests.
72 |
73 | ### Configuration
74 |
75 | Copy and edit the sample config.
76 |
77 | cp sample-config.json config.json
78 | vim config.json
79 |
80 | #### Binary
81 |
82 | Specify the ledger binary path. Leave it as "ledger" if it's already on your `$PATH`. Otherwise, specify the absolute path.
83 |
84 | #### File
85 |
86 | Specify the path to your ledger file.
87 |
88 |
--------------------------------------------------------------------------------
/public/js/ledger/charting/line-plus-bar-chart.jsx:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'dateRange',
5 | 'underscore',
6 | 'react',
7 | 'nvd3',
8 | 'd3'
9 | ], function(DateRange, _, React, nv, d3) {
10 | 'use strict';
11 |
12 | var LinePlusBarChart = React.createClass({
13 | propTypes: {
14 | data: React.PropTypes.array.isRequired,
15 | dateRange: React.PropTypes.array.isRequired,
16 | dateFormatting: React.PropTypes.string.isRequired
17 | },
18 |
19 | render: function() {
20 | if (this.props.data.length === 0) {
21 | return No data
;
22 | }
23 |
24 | return (
25 |
26 | );
27 | },
28 |
29 | componentDidMount: function(rootNode) {
30 | this.buildChart(rootNode);
31 | },
32 |
33 | componentDidUpdate: function(prevProps, prevState, rootNode) {
34 | this.buildChart(rootNode);
35 | },
36 |
37 | buildChart: function(el) {
38 | var self = this,
39 | sourceData = this.props.data,
40 | dateRange = this.props.dateRange,
41 | dateFormatting = this.props.dateFormatting;
42 |
43 | if (sourceData.length === 0) {
44 | return;
45 | }
46 |
47 | if (this.chart) {
48 | d3.select(el)
49 | .datum(sourceData)
50 | .transition()
51 | .call(this.chart);
52 |
53 | return;
54 | }
55 |
56 | nv.addGraph(function() {
57 | var chart = nv.models.linePlusBarChart()
58 | .x(function(d, i) { return i; });
59 |
60 | chart.xAxis
61 | .axisLabel('Date')
62 | .tickFormat(function(d) {
63 | if (parseInt(d, 10) === parseInt(d + 0.5, 10)) {
64 | return d3.time.format(dateFormatting)(dateRange[parseInt(d, 10)]);
65 | }
66 | return '';
67 | });
68 |
69 | chart.y1Axis
70 | .axisLabel('Amount')
71 | .tickFormat(function(d) { return '£' + d3.format(',.2f')(d); });
72 |
73 | chart.y2Axis
74 | .axisLabel('Amount')
75 | .tickFormat(function(d) { return '£' + d3.format(',.2f')(d); });
76 |
77 | d3.select(el)
78 | .datum(sourceData)
79 | .transition()
80 | .call(chart);
81 |
82 | self.chart = chart;
83 |
84 | return chart;
85 | });
86 | }
87 | });
88 |
89 | return LinePlusBarChart;
90 | });
--------------------------------------------------------------------------------
/public/js/ledger/charting/multi-bar-chart.jsx:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'dateRange',
5 | 'underscore',
6 | 'react',
7 | 'nvd3',
8 | 'd3'
9 | ], function(DateRange, _, React, nv, d3) {
10 | 'use strict';
11 |
12 | var MultiBarChart = React.createClass({
13 | propTypes: {
14 | data: React.PropTypes.array.isRequired,
15 | dateFormatting: React.PropTypes.string.isRequired,
16 |
17 | // chart formatting options
18 | stacked: React.PropTypes.bool,
19 | showLegend: React.PropTypes.bool
20 | },
21 |
22 | getDefaultProps: function() {
23 | return {
24 | stacked: true,
25 | showLegend: true
26 | };
27 | },
28 |
29 | render: function() {
30 | if (this.props.data.length === 0) {
31 | return No data
;
32 | }
33 |
34 | return (
35 |
36 | );
37 | },
38 |
39 | componentDidMount: function(rootNode) {
40 | this.buildChart(rootNode);
41 | },
42 |
43 | componentDidUpdate: function(prevProps, prevState, rootNode) {
44 | this.buildChart(rootNode);
45 | },
46 |
47 | buildChart: function(el) {
48 | var self = this,
49 | sourceData = this.props.data,
50 | dateFormatting = this.props.dateFormatting;
51 |
52 | if (sourceData.length === 0) {
53 | return;
54 | }
55 |
56 | if (this.chart) {
57 | d3.select(el)
58 | .datum(sourceData)
59 | .transition()
60 | .call(this.chart);
61 |
62 | return;
63 | }
64 |
65 | nv.addGraph(function() {
66 | var chart = nv.models.multiBarChart()
67 | .stacked(self.props.stacked)
68 | .showLegend(self.props.showLegend)
69 | .x(function(d) { return d.date; })
70 | .y(function(d) { return d.total; });
71 |
72 | chart.xAxis
73 | .axisLabel('Date')
74 | .showMaxMin(true)
75 | .tickFormat(function(d) { return d3.time.format(dateFormatting)(new Date(d)); });
76 |
77 | chart.yAxis
78 | .axisLabel('Amount')
79 | .tickFormat(d3.format(',.1f'));
80 |
81 | d3.select(el)
82 | .datum(sourceData)
83 | .transition()
84 | .call(chart);
85 |
86 | self.chart = chart;
87 |
88 | return chart;
89 | });
90 | }
91 | });
92 |
93 | return MultiBarChart;
94 | });
--------------------------------------------------------------------------------
/public/js/ledger/charting/line-plus-bar-chart.js:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'dateRange',
5 | 'underscore',
6 | 'react',
7 | 'nvd3',
8 | 'd3'
9 | ], function(DateRange, _, React, nv, d3) {
10 | 'use strict';
11 |
12 | var LinePlusBarChart = React.createClass({displayName: 'LinePlusBarChart',
13 | propTypes: {
14 | data: React.PropTypes.array.isRequired,
15 | dateRange: React.PropTypes.array.isRequired,
16 | dateFormatting: React.PropTypes.string.isRequired
17 | },
18 |
19 | render: function() {
20 | if (this.props.data.length === 0) {
21 | return React.DOM.p( {className:"text-center"}, "No data");
22 | }
23 |
24 | return (
25 | React.DOM.svg( {style:{height: this.props.height, width: this.props.width}} )
26 | );
27 | },
28 |
29 | componentDidMount: function(rootNode) {
30 | this.buildChart(rootNode);
31 | },
32 |
33 | componentDidUpdate: function(prevProps, prevState, rootNode) {
34 | this.buildChart(rootNode);
35 | },
36 |
37 | buildChart: function(el) {
38 | var self = this,
39 | sourceData = this.props.data,
40 | dateRange = this.props.dateRange,
41 | dateFormatting = this.props.dateFormatting;
42 |
43 | if (sourceData.length === 0) {
44 | return;
45 | }
46 |
47 | if (this.chart) {
48 | d3.select(el)
49 | .datum(sourceData)
50 | .transition()
51 | .call(this.chart);
52 |
53 | return;
54 | }
55 |
56 | nv.addGraph(function() {
57 | var chart = nv.models.linePlusBarChart()
58 | .x(function(d, i) { return i; });
59 |
60 | chart.xAxis
61 | .axisLabel('Date')
62 | .tickFormat(function(d) {
63 | if (parseInt(d, 10) === parseInt(d + 0.5, 10)) {
64 | return d3.time.format(dateFormatting)(dateRange[parseInt(d, 10)]);
65 | }
66 | return '';
67 | });
68 |
69 | chart.y1Axis
70 | .axisLabel('Amount')
71 | .tickFormat(function(d) { return '£' + d3.format(',.2f')(d); });
72 |
73 | chart.y2Axis
74 | .axisLabel('Amount')
75 | .tickFormat(function(d) { return '£' + d3.format(',.2f')(d); });
76 |
77 | d3.select(el)
78 | .datum(sourceData)
79 | .transition()
80 | .call(chart);
81 |
82 | self.chart = chart;
83 |
84 | return chart;
85 | });
86 | }
87 | });
88 |
89 | return LinePlusBarChart;
90 | });
--------------------------------------------------------------------------------
/public/js/ledger/charting/multi-bar-chart.js:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'dateRange',
5 | 'underscore',
6 | 'react',
7 | 'nvd3',
8 | 'd3'
9 | ], function(DateRange, _, React, nv, d3) {
10 | 'use strict';
11 |
12 | var MultiBarChart = React.createClass({displayName: 'MultiBarChart',
13 | propTypes: {
14 | data: React.PropTypes.array.isRequired,
15 | dateFormatting: React.PropTypes.string.isRequired,
16 |
17 | // chart formatting options
18 | stacked: React.PropTypes.bool,
19 | showLegend: React.PropTypes.bool
20 | },
21 |
22 | getDefaultProps: function() {
23 | return {
24 | stacked: true,
25 | showLegend: true
26 | };
27 | },
28 |
29 | render: function() {
30 | if (this.props.data.length === 0) {
31 | return React.DOM.p( {className:"text-center"}, "No data");
32 | }
33 |
34 | return (
35 | React.DOM.svg( {style:{height: this.props.height, width: this.props.width}} )
36 | );
37 | },
38 |
39 | componentDidMount: function(rootNode) {
40 | this.buildChart(rootNode);
41 | },
42 |
43 | componentDidUpdate: function(prevProps, prevState, rootNode) {
44 | this.buildChart(rootNode);
45 | },
46 |
47 | buildChart: function(el) {
48 | var self = this,
49 | sourceData = this.props.data,
50 | dateFormatting = this.props.dateFormatting;
51 |
52 | if (sourceData.length === 0) {
53 | return;
54 | }
55 |
56 | if (this.chart) {
57 | d3.select(el)
58 | .datum(sourceData)
59 | .transition()
60 | .call(this.chart);
61 |
62 | return;
63 | }
64 |
65 | nv.addGraph(function() {
66 | var chart = nv.models.multiBarChart()
67 | .stacked(self.props.stacked)
68 | .showLegend(self.props.showLegend)
69 | .x(function(d) { return d.date; })
70 | .y(function(d) { return d.total; });
71 |
72 | chart.xAxis
73 | .axisLabel('Date')
74 | .showMaxMin(true)
75 | .tickFormat(function(d) { return d3.time.format(dateFormatting)(new Date(d)); });
76 |
77 | chart.yAxis
78 | .axisLabel('Amount')
79 | .tickFormat(d3.format(',.1f'));
80 |
81 | d3.select(el)
82 | .datum(sourceData)
83 | .transition()
84 | .call(chart);
85 |
86 | self.chart = chart;
87 |
88 | return chart;
89 | });
90 | }
91 | });
92 |
93 | return MultiBarChart;
94 | });
--------------------------------------------------------------------------------
/public/js/ledger/income/income-vs-expenditure-chart.jsx:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'dateRange',
5 | 'controls/model',
6 | 'charting/multi-bar-chart',
7 | 'underscore',
8 | 'react',
9 | 'react.backbone'
10 | ], function(DateRange, Controls, MultiBarChart, _, React, createBackboneClass) {
11 | 'use strict';
12 |
13 | var IncomeVsExpenditureChart = createBackboneClass({
14 | propTypes: {
15 | model: React.PropTypes.object.isRequired,
16 | groupBy: React.PropTypes.string.isRequired
17 | },
18 |
19 | render: function() {
20 | if (this.props.model.length === 0) {
21 | return (
22 | No data
23 | );
24 | }
25 |
26 | var dateRange = this.props.model.getDateRange(),
27 | data = this.chartData(dateRange),
28 | dateFormatting = this.dateFormatString(this.props.groupBy);
29 |
30 | return (
31 |
32 | );
33 | },
34 |
35 | chartData: function(dateRange) {
36 | var income = this.props.model.filter(function(entry) { return entry.isIncome(); }),
37 | expenses = this.props.model.filter(function(entry) { return entry.isExpense(); });
38 |
39 | var incomeByDate = this.totalByDate(dateRange.between(this.props.groupBy), income, 'Income'),
40 | expensesByDate = this.totalByDate(dateRange.between(this.props.groupBy), expenses, 'Expenses');
41 |
42 | return [
43 | { key: 'Income', values: incomeByDate },
44 | { key: 'Expenses', values: expensesByDate }
45 | ];
46 | },
47 |
48 | // Total amount for each date in the given range
49 | totalByDate: function(dateRange, entries, type) {
50 | return _.map(dateRange, function(date) {
51 | return {
52 | date: date,
53 | total: this.totalByDateAndAccount(entries, date, type)
54 | };
55 | }, this);
56 | },
57 |
58 | totalByDateAndAccount: function(entries, date, type) {
59 | var total = 0;
60 |
61 | _.each(entries, function(entry) {
62 | if (entry.groupBy(this.props.groupBy) === date.getTime()) {
63 | total += entry.totalByAccount(type) * -1; // Invert amounts
64 | }
65 | }, this);
66 |
67 | return total;
68 | },
69 |
70 |
71 | dateFormatString: function(granularity) {
72 | switch (granularity) {
73 | case 'day': return '%d/%m/%Y';
74 | case 'month': return '%B %Y';
75 | case 'year': return '%Y';
76 | }
77 |
78 | throw 'Date range granularity "' + granularity + '" is not supported';
79 | }
80 | });
81 |
82 | return IncomeVsExpenditureChart;
83 | });
--------------------------------------------------------------------------------
/public/js/ledger/income/income-vs-expenditure-chart.js:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'dateRange',
5 | 'controls/model',
6 | 'charting/multi-bar-chart',
7 | 'underscore',
8 | 'react',
9 | 'react.backbone'
10 | ], function(DateRange, Controls, MultiBarChart, _, React, createBackboneClass) {
11 | 'use strict';
12 |
13 | var IncomeVsExpenditureChart = createBackboneClass({
14 | propTypes: {
15 | model: React.PropTypes.object.isRequired,
16 | groupBy: React.PropTypes.string.isRequired
17 | },
18 |
19 | render: function() {
20 | if (this.props.model.length === 0) {
21 | return (
22 | React.DOM.p( {className:"text-center"}, "No data")
23 | );
24 | }
25 |
26 | var dateRange = this.props.model.getDateRange(),
27 | data = this.chartData(dateRange),
28 | dateFormatting = this.dateFormatString(this.props.groupBy);
29 |
30 | return (
31 | MultiBarChart( {height:"700px", width:"970px", data:data, dateFormatting:dateFormatting} )
32 | );
33 | },
34 |
35 | chartData: function(dateRange) {
36 | var income = this.props.model.filter(function(entry) { return entry.isIncome(); }),
37 | expenses = this.props.model.filter(function(entry) { return entry.isExpense(); });
38 |
39 | var incomeByDate = this.totalByDate(dateRange.between(this.props.groupBy), income, 'Income'),
40 | expensesByDate = this.totalByDate(dateRange.between(this.props.groupBy), expenses, 'Expenses');
41 |
42 | return [
43 | { key: 'Income', values: incomeByDate },
44 | { key: 'Expenses', values: expensesByDate }
45 | ];
46 | },
47 |
48 | // Total amount for each date in the given range
49 | totalByDate: function(dateRange, entries, type) {
50 | return _.map(dateRange, function(date) {
51 | return {
52 | date: date,
53 | total: this.totalByDateAndAccount(entries, date, type)
54 | };
55 | }, this);
56 | },
57 |
58 | totalByDateAndAccount: function(entries, date, type) {
59 | var total = 0;
60 |
61 | _.each(entries, function(entry) {
62 | if (entry.groupBy(this.props.groupBy) === date.getTime()) {
63 | total += entry.totalByAccount(type) * -1; // Invert amounts
64 | }
65 | }, this);
66 |
67 | return total;
68 | },
69 |
70 |
71 | dateFormatString: function(granularity) {
72 | switch (granularity) {
73 | case 'day': return '%d/%m/%Y';
74 | case 'month': return '%B %Y';
75 | case 'year': return '%Y';
76 | }
77 |
78 | throw 'Date range granularity "' + granularity + '" is not supported';
79 | }
80 | });
81 |
82 | return IncomeVsExpenditureChart;
83 | });
--------------------------------------------------------------------------------
/public/js/ledger/spending/expenditure-chart.jsx:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'charting/multi-bar-chart',
5 | 'underscore',
6 | 'react',
7 | 'react.backbone'
8 | ], function(MultiBarChart, _, React, createBackboneClass) {
9 | 'use strict';
10 |
11 | var ExpenditureChart = createBackboneClass({
12 | propTypes: {
13 | category: React.PropTypes.string.isRequired,
14 | groupBy: React.PropTypes.string.isRequired,
15 | model: React.PropTypes.object.isRequired
16 | },
17 |
18 | render: function() {
19 | if (this.props.model.length === 0) {
20 | return No data
;
21 | }
22 |
23 | var dateRange = this.props.model.getDateRange().between(this.props.groupBy),
24 | data = this.chartData(dateRange, this.props.category),
25 | dateFormatting = this.dateFormatString(this.props.groupBy);
26 |
27 | return (
28 |
29 | );
30 | },
31 |
32 | chartData: function(dateRange, category) {
33 | if (category === 'account') {
34 | // Show expenses per account
35 | var data = [],
36 | accounts = this.props.model.getAccounts();
37 |
38 | _.each(accounts, function(account) {
39 | data.push({
40 | key: account.toString().substr(9),
41 | values: this.totalByDate(dateRange, account, this.props.model.getByAccount(account))
42 | });
43 | }, this);
44 |
45 | return data;
46 | } else {
47 | // Total expenses for all accounts
48 | var expenses = this.totalByDate(dateRange, this.props.model.models);
49 |
50 | return [
51 | { key: 'Spending', values: expenses }
52 | ];
53 | }
54 | },
55 |
56 | // Total amount for each date in the given range
57 | totalByDate: function(dateRange, account, entries) {
58 | return _.map(dateRange, function(date) {
59 | return {
60 | date: date,
61 | total: this.totalByDateAndAccount(entries, date, account)
62 | };
63 | }, this);
64 | },
65 |
66 | totalByDateAndAccount: function(entries, date, account) {
67 | var total = 0;
68 |
69 | _.each(entries, function(entry) {
70 | if (entry.groupBy(this.props.groupBy) === date.getTime()) {
71 | total += entry.totalByAccount(account);
72 | }
73 | }, this);
74 |
75 | return Math.max(total, 0);
76 | },
77 |
78 | dateFormatString: function(granularity) {
79 | switch (granularity) {
80 | case 'day': return '%d/%m/%Y';
81 | case 'month': return '%B %Y';
82 | case 'year': return '%Y';
83 | }
84 |
85 | throw 'Date range granularity "' + granularity + '" is not supported';
86 | }
87 | });
88 |
89 | return ExpenditureChart;
90 | });
--------------------------------------------------------------------------------
/public/js/ledger/spending/expenditure-chart.js:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'charting/multi-bar-chart',
5 | 'underscore',
6 | 'react',
7 | 'react.backbone'
8 | ], function(MultiBarChart, _, React, createBackboneClass) {
9 | 'use strict';
10 |
11 | var ExpenditureChart = createBackboneClass({
12 | propTypes: {
13 | category: React.PropTypes.string.isRequired,
14 | groupBy: React.PropTypes.string.isRequired,
15 | model: React.PropTypes.object.isRequired
16 | },
17 |
18 | render: function() {
19 | if (this.props.model.length === 0) {
20 | return React.DOM.p( {className:"text-center"}, "No data");
21 | }
22 |
23 | var dateRange = this.props.model.getDateRange().between(this.props.groupBy),
24 | data = this.chartData(dateRange, this.props.category),
25 | dateFormatting = this.dateFormatString(this.props.groupBy);
26 |
27 | return (
28 | MultiBarChart( {height:"700px", width:"970px", data:data, dateFormatting:dateFormatting} )
29 | );
30 | },
31 |
32 | chartData: function(dateRange, category) {
33 | if (category === 'account') {
34 | // Show expenses per account
35 | var data = [],
36 | accounts = this.props.model.getAccounts();
37 |
38 | _.each(accounts, function(account) {
39 | data.push({
40 | key: account.toString().substr(9),
41 | values: this.totalByDate(dateRange, account, this.props.model.getByAccount(account))
42 | });
43 | }, this);
44 |
45 | return data;
46 | } else {
47 | // Total expenses for all accounts
48 | var expenses = this.totalByDate(dateRange, this.props.model.models);
49 |
50 | return [
51 | { key: 'Spending', values: expenses }
52 | ];
53 | }
54 | },
55 |
56 | // Total amount for each date in the given range
57 | totalByDate: function(dateRange, account, entries) {
58 | return _.map(dateRange, function(date) {
59 | return {
60 | date: date,
61 | total: this.totalByDateAndAccount(entries, date, account)
62 | };
63 | }, this);
64 | },
65 |
66 | totalByDateAndAccount: function(entries, date, account) {
67 | var total = 0;
68 |
69 | _.each(entries, function(entry) {
70 | if (entry.groupBy(this.props.groupBy) === date.getTime()) {
71 | total += entry.totalByAccount(account);
72 | }
73 | }, this);
74 |
75 | return Math.max(total, 0);
76 | },
77 |
78 | dateFormatString: function(granularity) {
79 | switch (granularity) {
80 | case 'day': return '%d/%m/%Y';
81 | case 'month': return '%B %Y';
82 | case 'year': return '%Y';
83 | }
84 |
85 | throw 'Date range granularity "' + granularity + '" is not supported';
86 | }
87 | });
88 |
89 | return ExpenditureChart;
90 | });
--------------------------------------------------------------------------------
/public/js/ledger/worth/net-worth-chart.jsx:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'charting/line-plus-bar-chart',
5 | 'underscore',
6 | 'react',
7 | 'react.backbone'
8 | ], function(LinePlusBarChart, _, React, createBackboneClass) {
9 | 'use strict';
10 |
11 | var NetWorthChart = createBackboneClass({
12 | propTypes: {
13 | groupBy: React.PropTypes.string.isRequired,
14 | model: React.PropTypes.object.isRequired
15 | },
16 |
17 | render: function() {
18 | if (this.props.model.length === 0) {
19 | return No data
;
20 | }
21 |
22 | var dateRange = this.props.model.getDateRange().between(this.props.groupBy),
23 | data = this.chartData(dateRange),
24 | dateFormatting = this.dateFormatString(this.props.groupBy);
25 |
26 | return (
27 |
28 | );
29 | },
30 |
31 | chartData: function(dateRange) {
32 | var assets = this.props.model.filter(function(entry) { return entry.isAsset(); }),
33 | liabilities = this.props.model.filter(function(entry) { return entry.isLiability(); });
34 |
35 | assets = this.totalByDate(dateRange, assets, 'Assets');
36 | liabilities = this.totalByDate(dateRange, liabilities, 'Liabilities');
37 |
38 | var assetsMinusLiabilities = _.map(dateRange, function(date, index) {
39 | return {
40 | date: date,
41 | total: assets[index].total + liabilities[index].total // assets are positive, liabilities are negative
42 | };
43 | });
44 |
45 | // net worth = assets - liabilities
46 | var netWorth = this.cumulativeByDate(dateRange, [assets, liabilities]);
47 |
48 | return [
49 | { key: 'Assets minus Liabilities', values: this.convertToCoordinates(assetsMinusLiabilities), bar: true },
50 | { key: 'Net Worth', values: this.convertToCoordinates(netWorth) }
51 | ];
52 | },
53 |
54 | convertToCoordinates: function(list) {
55 | return _.map(list, function(entry) {
56 | return { x: entry.date, y: entry.total };
57 | });
58 | },
59 |
60 | // Total amount for each date in the given range
61 | totalByDate: function(dateRange, entries, type) {
62 | return _.map(dateRange, function(date) {
63 | return {
64 | date: date,
65 | total: this.totalByDateAndAccount(entries, date, type)
66 | };
67 | }, this);
68 | },
69 |
70 | totalByDateAndAccount: function(entries, date, account) {
71 | var total = 0;
72 |
73 | _.each(entries, function(entry) {
74 | if (entry.groupBy(this.props.groupBy) === date.getTime()) {
75 | total += entry.totalByAccount(account);
76 | }
77 | }, this);
78 |
79 | return total;
80 | },
81 |
82 | cumulativeByDate: function(dateRange, lists) {
83 | var cumulative = 0;
84 |
85 | return _.map(dateRange, function(date, index) {
86 | var total = 0;
87 |
88 | _.each(lists, function(list) {
89 | total += list[index].total;
90 | });
91 |
92 | cumulative += total;
93 |
94 | return {
95 | date: date,
96 | total: cumulative
97 | };
98 | });
99 | },
100 |
101 | dateFormatString: function(granularity) {
102 | switch (granularity) {
103 | case 'day': return '%d/%m/%Y';
104 | case 'month': return '%B %Y';
105 | case 'year': return '%Y';
106 | }
107 |
108 | throw 'Date range granularity "' + granularity + '" is not supported';
109 | }
110 | });
111 |
112 | return NetWorthChart;
113 | });
--------------------------------------------------------------------------------
/public/js/ledger/worth/net-worth-chart.js:
--------------------------------------------------------------------------------
1 | /** @jsx React.DOM */
2 |
3 | define([
4 | 'charting/line-plus-bar-chart',
5 | 'underscore',
6 | 'react',
7 | 'react.backbone'
8 | ], function(LinePlusBarChart, _, React, createBackboneClass) {
9 | 'use strict';
10 |
11 | var NetWorthChart = createBackboneClass({
12 | propTypes: {
13 | groupBy: React.PropTypes.string.isRequired,
14 | model: React.PropTypes.object.isRequired
15 | },
16 |
17 | render: function() {
18 | if (this.props.model.length === 0) {
19 | return React.DOM.p( {className:"text-center"}, "No data");
20 | }
21 |
22 | var dateRange = this.props.model.getDateRange().between(this.props.groupBy),
23 | data = this.chartData(dateRange),
24 | dateFormatting = this.dateFormatString(this.props.groupBy);
25 |
26 | return (
27 | LinePlusBarChart( {height:"700px", width:"970px", data:data, dateRange:dateRange, dateFormatting:dateFormatting} )
28 | );
29 | },
30 |
31 | chartData: function(dateRange) {
32 | var assets = this.props.model.filter(function(entry) { return entry.isAsset(); }),
33 | liabilities = this.props.model.filter(function(entry) { return entry.isLiability(); });
34 |
35 | assets = this.totalByDate(dateRange, assets, 'Assets');
36 | liabilities = this.totalByDate(dateRange, liabilities, 'Liabilities');
37 |
38 | var assetsMinusLiabilities = _.map(dateRange, function(date, index) {
39 | return {
40 | date: date,
41 | total: assets[index].total + liabilities[index].total // assets are positive, liabilities are negative
42 | };
43 | });
44 |
45 | // net worth = assets - liabilities
46 | var netWorth = this.cumulativeByDate(dateRange, [assets, liabilities]);
47 |
48 | return [
49 | { key: 'Assets minus Liabilities', values: this.convertToCoordinates(assetsMinusLiabilities), bar: true },
50 | { key: 'Net Worth', values: this.convertToCoordinates(netWorth) }
51 | ];
52 | },
53 |
54 | convertToCoordinates: function(list) {
55 | return _.map(list, function(entry) {
56 | return { x: entry.date, y: entry.total };
57 | });
58 | },
59 |
60 | // Total amount for each date in the given range
61 | totalByDate: function(dateRange, entries, type) {
62 | return _.map(dateRange, function(date) {
63 | return {
64 | date: date,
65 | total: this.totalByDateAndAccount(entries, date, type)
66 | };
67 | }, this);
68 | },
69 |
70 | totalByDateAndAccount: function(entries, date, account) {
71 | var total = 0;
72 |
73 | _.each(entries, function(entry) {
74 | if (entry.groupBy(this.props.groupBy) === date.getTime()) {
75 | total += entry.totalByAccount(account);
76 | }
77 | }, this);
78 |
79 | return total;
80 | },
81 |
82 | cumulativeByDate: function(dateRange, lists) {
83 | var cumulative = 0;
84 |
85 | return _.map(dateRange, function(date, index) {
86 | var total = 0;
87 |
88 | _.each(lists, function(list) {
89 | total += list[index].total;
90 | });
91 |
92 | cumulative += total;
93 |
94 | return {
95 | date: date,
96 | total: cumulative
97 | };
98 | });
99 | },
100 |
101 | dateFormatString: function(granularity) {
102 | switch (granularity) {
103 | case 'day': return '%d/%m/%Y';
104 | case 'month': return '%B %Y';
105 | case 'year': return '%Y';
106 | }
107 |
108 | throw 'Date range granularity "' + granularity + '" is not supported';
109 | }
110 | });
111 |
112 | return NetWorthChart;
113 | });
--------------------------------------------------------------------------------
/Gruntfile.js:
--------------------------------------------------------------------------------
1 | /*global module:false*/
2 | module.exports = function(grunt) {
3 | grunt.initConfig({
4 | jshint: {
5 | options: {
6 | curly: true,
7 | eqeqeq: true,
8 | immed: true,
9 | latedef: true,
10 | newcap: true,
11 | noarg: true,
12 | sub: true,
13 | undef: true,
14 | unused: true,
15 | boss: true,
16 | eqnull: true
17 | },
18 | gruntfile: {
19 | src: 'Gruntfile.js'
20 | },
21 | ledger: {
22 | options: {
23 | '-W055': true,
24 | '-W064': true,
25 | globals: {
26 | jQuery: true,
27 | document: true,
28 | define: true,
29 | require: true,
30 | window: true
31 | }
32 | },
33 | src: ['public/js/ledger/**/*.js']
34 | }
35 | },
36 |
37 | copy: {
38 | vendor: {
39 | files: [
40 | { src: 'bower_components/backbone/backbone.js', dest: 'public/js/vendor/backbone.js' },
41 | { src: 'bower_components/backbone.marionette/lib/backbone.marionette.js', dest: 'public/js/vendor/backbone.marionette.js' },
42 | { src: 'bower_components/bootstrap/docs/assets/js/bootstrap.js', dest: 'public/js/vendor/bootstrap.js' },
43 | { src: 'bower_components/d3/d3.js', dest: 'public/js/vendor/d3.js' },
44 | { src: 'bower_components/jquery/jquery.js', dest: 'public/js/vendor/jquery.js' },
45 | { src: 'bower_components/nvd3/build/nv.d3.js', dest: 'public/js/vendor/nv.d3.js' },
46 | { src: 'bower_components/nvd3/build/nv.d3.css', dest: 'public/css/vendor/nv.d3.css' },
47 | { src: 'bower_components/react/react.js', dest: 'public/js/vendor/react.js' },
48 | { src: 'bower_components/requirejs/require.js', dest: 'public/js/vendor/require.js' },
49 | { src: 'bower_components/requirejs-tpl/tpl.js', dest: 'public/js/vendor/tpl.js' },
50 | { src: 'bower_components/underscore/underscore.js', dest: 'public/js/vendor/underscore.js' }
51 | ]
52 | }
53 | },
54 |
55 | react: {
56 | jsx: {
57 | files: [
58 | {
59 | expand: true,
60 | cwd: 'public/js/ledger',
61 | src: [ '**/*.jsx' ],
62 | dest: 'public/js/ledger',
63 | ext: '.js'
64 | }
65 | ]
66 | }
67 | },
68 |
69 | recess: {
70 | dist: {
71 | src: [
72 | 'bower_components/bootstrap/less/bootstrap.less',
73 | 'bower_components/bootstrap/less/responsive.less',
74 | 'public/css/less/app.less'
75 | ],
76 | dest: 'public/css/main.css',
77 | options: {
78 | compile: true,
79 | compress: true
80 | }
81 | }
82 | },
83 |
84 | requirejs: {
85 | compile: {
86 | options: {
87 | baseUrl: 'public/js/ledger',
88 | mainConfigFile: 'public/js/ledger/main.js',
89 | name: 'main',
90 | out: 'public/js/ledger.js'
91 | }
92 | }
93 | },
94 |
95 | watch: {
96 | gruntfile: {
97 | files: '<%= jshint.gruntfile.src %>',
98 | tasks: ['jshint:gruntfile']
99 | },
100 | lib: {
101 | files: '<%= jshint.lib.src %>',
102 | tasks: ['jshint:lib']
103 | },
104 | react: {
105 | files: 'public/js/ledger/**/*.jsx',
106 | tasks: ['react']
107 | }
108 | }
109 | });
110 |
111 | grunt.loadNpmTasks('grunt-contrib-copy');
112 | grunt.loadNpmTasks('grunt-contrib-jshint');
113 | grunt.loadNpmTasks('grunt-contrib-requirejs');
114 | grunt.loadNpmTasks('grunt-contrib-watch');
115 | grunt.loadNpmTasks('grunt-react');
116 | grunt.loadNpmTasks('grunt-recess');
117 |
118 | // Default task.
119 | grunt.registerTask('default', ['copy', 'recess', 'requirejs']);
120 | };
121 |
--------------------------------------------------------------------------------
/example/example.dat:
--------------------------------------------------------------------------------
1 | account Assets:Current Account
2 | account Assets:Savings
3 | account Equity:Opening Balances
4 | account Expenses:Books
5 | account Expenses:Car
6 | account Expenses:Cash
7 | account Expenses:Clothing
8 | account Expenses:Computer
9 | account Expenses:Council Tax
10 | account Expenses:Food
11 | account Expenses:Holiday
12 | account Expenses:Home
13 | account Expenses:Leisure
14 | account Expenses:Mobile Phone
15 | account Expenses:Mortgage
16 | account Expenses:Utilities:Energy
17 | account Expenses:Utilities:Water
18 | account Income:Interest
19 | account Income:Salary
20 | account Liabilities:Mastercard
21 |
22 | 2013/01/01 * Opening Balance
23 | Assets:Current Account £ 100.00
24 | Assets:Savings £ 500.00
25 | Liabilities:Mastercard £ -250.00
26 | Equity:Opening Balances
27 |
28 | 2013/01/01 Salary
29 | Assets:Current Account £1,000.00
30 | Income:Salary
31 |
32 | 2013/01/02 Mortgage payment
33 | Expenses:Mortgage £446.52
34 | Assets:Current Account
35 |
36 | 2013/01/04 Tesco Store
37 | Expenses:Food £26.50
38 | Liabilities:Mastercard
39 |
40 | 2013/01/05 Savings
41 | Assets:Savings £100.00
42 | Assets:Current Account
43 |
44 | 2013/01/07 Southern Water
45 | Expenses:Utilities:Water £25.20
46 | Assets:Current Account
47 |
48 | 2013/01/08 Local Council Tax
49 | Expenses:Council Tax £64.00
50 | Assets:Current Account
51 |
52 | 2013/01/09 Gas & Electricity Supply
53 | Expenses:Utilities:Energy £39.82
54 | Assets:Current Account
55 |
56 | 2013/01/13 Clothes
57 | Expenses:Clothing £37.50
58 | Liabilities:Mastercard
59 |
60 | 2013/01/13 Home Improvements
61 | Expenses:Home £20.18
62 | Liabilities:Mastercard
63 |
64 | 2013/01/16 Introduction to Accounting Book
65 | Expenses:Books £31.73
66 | Liabilities:Mastercard
67 |
68 | 2013/01/17 Ski Holiday
69 | Expenses:Holiday £314.37
70 | Liabilities:Mastercard
71 |
72 | 2013/01/24 ATM
73 | Expenses:Cash £40.00
74 | Assets:Current Account
75 |
76 | 2013/01/30 Credit Card Payment
77 | Liabilities:Mastercard £680.28
78 | Assets:Current Account
79 |
80 | 2013/02/01 Salary
81 | Assets:Current Account £1,000.00
82 | Income:Salary
83 |
84 | 2013/02/02 Mortgage payment
85 | Expenses:Mortgage £446.52
86 | Assets:Current Account
87 |
88 | 2013/02/05 Savings
89 | Assets:Savings £100.00
90 | Assets:Current Account
91 |
92 | 2013/02/07 Southern Water
93 | Expenses:Utilities:Water £25.20
94 | Assets:Current Account
95 |
96 | 2013/02/07 Tesco Store
97 | Expenses:Food £47.89
98 | Liabilities:Mastercard
99 |
100 | 2013/02/08 Local Council Tax
101 | Expenses:Council Tax £64.00
102 | Assets:Current Account
103 |
104 | 2013/02/09 Gas & Electricity Supply
105 | Expenses:Utilities:Energy £39.82
106 | Assets:Current Account
107 |
108 | 2013/02/18 Tesco Store
109 | Expenses:Food £32.12
110 | Liabilities:Mastercard
111 |
112 | 2013/03/01 Salary
113 | Assets:Current Account £1,000.00
114 | Income:Salary
115 |
116 | 2013/03/02 Mortgage payment
117 | Expenses:Mortgage £446.52
118 | Assets:Current Account
119 |
120 | 2013/03/05 Savings
121 | Assets:Savings £100.00
122 | Assets:Current Account
123 |
124 | 2013/03/07 Southern Water
125 | Expenses:Utilities:Water £25.20
126 | Assets:Current Account
127 |
128 | 2013/03/08 Local Council Tax
129 | Expenses:Council Tax £64.00
130 | Assets:Current Account
131 |
132 | 2013/03/09 Gas & Electricity Supply
133 | Expenses:Utilities:Energy £39.82
134 | Assets:Current Account
135 |
136 | 2013/03/11 Tesco Store
137 | Expenses:Food £36.21
138 | Liabilities:Mastercard
139 |
140 | 2013/04/01 Salary
141 | Assets:Current Account £1,000.00
142 | Income:Salary
143 |
144 | 2013/04/02 Mortgage payment
145 | Expenses:Mortgage £446.52
146 | Assets:Current Account
147 |
148 | 2013/04/05 Savings
149 | Assets:Savings £100.00
150 | Assets:Current Account
151 |
152 | 2013/04/06 Tesco Store
153 | Expenses:Food £23.74
154 | Liabilities:Mastercard
155 |
156 | 2013/04/07 Southern Water
157 | Expenses:Utilities:Water £25.20
158 | Assets:Current Account
159 |
160 | 2013/04/08 Local Council Tax
161 | Expenses:Council Tax £64.00
162 | Assets:Current Account
163 |
164 | 2013/04/09 Gas & Electricity Supply
165 | Expenses:Utilities:Energy £39.82
166 | Assets:Current Account
167 |
168 | 2013/04/21 Tesco Store
169 | Expenses:Food £42.60
170 | Liabilities:Mastercard
171 |
172 | 2013/05/01 Salary
173 | Assets:Current Account £1,000.00
174 | Income:Salary
175 |
176 | 2013/05/02 Mortgage payment
177 | Expenses:Mortgage £446.52
178 | Assets:Current Account
179 |
180 | 2013/05/05 Savings
181 | Assets:Savings £100.00
182 | Assets:Current Account
183 |
184 | 2013/05/07 Southern Water
185 | Expenses:Utilities:Water £25.20
186 | Assets:Current Account
187 |
188 | 2013/05/08 Local Council Tax
189 | Expenses:Council Tax £64.00
190 | Assets:Current Account
191 |
192 | 2013/05/08 Tesco Store
193 | Expenses:Food £24.82
194 | Liabilities:Mastercard
195 |
196 | 2013/05/09 Gas & Electricity Supply
197 | Expenses:Utilities:Energy £39.82
198 | Assets:Current Account
--------------------------------------------------------------------------------