├── .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 |
    35 | Ledger 36 | 39 |
    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 | 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 | 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 |
    65 |
    66 | 67 |
    68 |
    69 |
    70 | 71 |
    72 |
    73 |
    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 | ![Ledger Web](doc/home-preview.png) 8 | 9 | ### Income 10 | 11 | Income compared to expenditure over time (daily, monthly or yearly). 12 | 13 | ![Income](doc/income-preview.png) 14 | 15 | ### Spending 16 | 17 | Over time and grouped by expense (daily, monthly or yearly). 18 | 19 | ![Spending](doc/spending-preview.png) 20 | 21 | ### Net Worth 22 | 23 | Assets minus liabilities over time (daily, monthly or yearly). 24 | 25 | ![Net Worth](doc/net-worth-preview.png) 26 | 27 | ### Balance 28 | 29 | Breakdown of transactions, filterable by type. 30 | 31 | ![Balance](doc/balance-preview.png) 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 --------------------------------------------------------------------------------