1
0
mirror of https://github.com/aclindsa/moneygo.git synced 2024-12-26 15:42:27 -05:00

reports: Allow drilling down

This commit is contained in:
Aaron Lindsay 2017-02-17 10:01:31 -05:00
parent 4d642d1772
commit b443963375
16 changed files with 250 additions and 82 deletions

View File

@ -55,7 +55,6 @@ func luaGetAccounts(L *lua.LState) int {
return 1 return 1
} }
// Registers my account type to given L.
func luaRegisterAccounts(L *lua.LState) { func luaRegisterAccounts(L *lua.LState) {
mt := L.NewTypeMetatable(luaAccountTypeName) mt := L.NewTypeMetatable(luaAccountTypeName)
L.SetGlobal("account", mt) L.SetGlobal("account", mt)

View File

@ -12,7 +12,6 @@ type Balance struct {
const luaBalanceTypeName = "balance" const luaBalanceTypeName = "balance"
// Registers my balance type to given L.
func luaRegisterBalances(L *lua.LState) { func luaRegisterBalances(L *lua.LState) {
mt := L.NewTypeMetatable(luaBalanceTypeName) mt := L.NewTypeMetatable(luaBalanceTypeName)
L.SetGlobal("balance", mt) L.SetGlobal("balance", mt)

View File

@ -8,7 +8,6 @@ import (
const luaDateTypeName = "date" const luaDateTypeName = "date"
const timeFormat = "2006-01-02" const timeFormat = "2006-01-02"
// Registers my date type to given L.
func luaRegisterDates(L *lua.LState) { func luaRegisterDates(L *lua.LState) {
mt := L.NewTypeMetatable(luaDateTypeName) mt := L.NewTypeMetatable(luaDateTypeName)
L.SetGlobal("date", mt) L.SetGlobal("date", mt)

View File

@ -21,6 +21,17 @@ function ajaxError(error) {
}; };
} }
function clientError(error) {
var e = new Error();
e.ErrorId = 999;
e.ErrorString = "Client Error: " + error;
return {
type: ErrorConstants.ERROR_CLIENT,
error: e
};
}
function clearError() { function clearError() {
return { return {
type: ErrorConstants.CLEAR_ERROR, type: ErrorConstants.CLEAR_ERROR,
@ -30,5 +41,6 @@ function clearError() {
module.exports = { module.exports = {
serverError: serverError, serverError: serverError,
ajaxError: ajaxError, ajaxError: ajaxError,
clientError: clientError,
clearError: clearError clearError: clearError
}; };

View File

@ -6,9 +6,10 @@ var models = require('../models.js');
var Report = models.Report; var Report = models.Report;
var Error = models.Error; var Error = models.Error;
function fetchReport() { function fetchReport(reportName) {
return { return {
type: ReportConstants.FETCH_REPORT type: ReportConstants.FETCH_REPORT,
reportName: reportName
} }
} }
@ -19,9 +20,25 @@ function reportFetched(report) {
} }
} }
function selectReport(report, seriesTraversal) {
return {
type: ReportConstants.SELECT_REPORT,
report: report,
seriesTraversal: seriesTraversal
}
}
function reportSelected(flattenedReport, seriesTraversal) {
return {
type: ReportConstants.REPORT_SELECTED,
report: flattenedReport,
seriesTraversal: seriesTraversal
}
}
function fetch(report) { function fetch(report) {
return function (dispatch) { return function (dispatch) {
dispatch(fetchReport()); dispatch(fetchReport(report));
$.ajax({ $.ajax({
type: "GET", type: "GET",
@ -45,6 +62,48 @@ function fetch(report) {
}; };
} }
function select(report, seriesTraversal) {
return function (dispatch) {
if (!seriesTraversal)
seriesTraversal = [];
dispatch(selectReport(report, seriesTraversal));
// Descend the tree to the right series to flatten
var series = report;
for (var i=0; i < seriesTraversal.length; i++) {
if (!series.Series.hasOwnProperty(seriesTraversal[i])) {
dispatch(ErrorActions.clientError("Invalid series"));
return;
}
series = series.Series[seriesTraversal[i]];
}
// Actually flatten the data
var flattenedSeries = series.mapReduceChildren(null,
function(accumulator, currentValue, currentIndex, array) {
return accumulator + currentValue;
}
);
// Add back in any values from the current level
if (series.hasOwnProperty('Values'))
flattenedSeries[report.topLevelAccountName] = series.Values;
var flattenedReport = new Report();
flattenedReport.ReportId = report.ReportId;
flattenedReport.Title = report.Title;
flattenedReport.Subtitle = report.Subtitle;
flattenedReport.XAxisLabel = report.XAxisLabel;
flattenedReport.YAxisLabel = report.YAxisLabel;
flattenedReport.Labels = report.Labels.slice();
flattenedReport.FlattenedSeries = flattenedSeries;
dispatch(reportSelected(flattenedReport, seriesTraversal));
};
}
module.exports = { module.exports = {
fetch: fetch fetch: fetch,
select: select
}; };

View File

@ -1,5 +1,10 @@
var React = require('react'); var React = require('react');
var ReactBootstrap = require('react-bootstrap');
var Button = ReactBootstrap.Button;
var Panel = ReactBootstrap.Panel;
var StackedBarChart = require('../components/StackedBarChart'); var StackedBarChart = require('../components/StackedBarChart');
module.exports = React.createClass({ module.exports = React.createClass({
@ -10,10 +15,69 @@ module.exports = React.createClass({
componentWillMount: function() { componentWillMount: function() {
this.props.onFetchReport("monthly_expenses"); this.props.onFetchReport("monthly_expenses");
}, },
componentWillReceiveProps: function(nextProps) {
if (nextProps.reports['monthly_expenses'] && !nextProps.selectedReport.report) {
this.props.onSelectReport(nextProps.reports['monthly_expenses'], []);
}
},
onSelectSeries: function(series) {
if (series == this.props.selectedReport.report.topLevelAccountName)
return;
var seriesTraversal = this.props.selectedReport.seriesTraversal.slice();
seriesTraversal.push(series);
this.props.onSelectReport(this.props.reports[this.props.selectedReport.report.ReportId], seriesTraversal);
},
render: function() { render: function() {
report = []; var report = [];
if (this.props.reports['monthly_expenses']) if (this.props.selectedReport.report) {
report = (<StackedBarChart data={this.props.reports['monthly_expenses']} />); var titleTracks = [];
var seriesTraversal = [];
for (var i = 0; i < this.props.selectedReport.seriesTraversal.length; i++) {
var name = this.props.selectedReport.report.Title;
if (i > 0)
name = this.props.selectedReport.seriesTraversal[i-1];
// Make a closure for going up the food chain
var self = this;
var navOnClick = function() {
var onSelectReport = self.props.onSelectReport;
var report = self.props.reports[self.props.selectedReport.report.ReportId];
var mySeriesTraversal = seriesTraversal.slice();
return function() {
onSelectReport(report, mySeriesTraversal);
};
}();
titleTracks.push((
<Button bsStyle="link"
onClick={navOnClick}>
{name}
</Button>
));
titleTracks.push((<span>/</span>));
seriesTraversal.push(this.props.selectedReport.seriesTraversal[i]);
}
if (titleTracks.length == 0)
titleTracks.push((
<Button bsStyle="link">
{this.props.selectedReport.report.Title}
</Button>
));
else
titleTracks.push((
<Button bsStyle="link">
{this.props.selectedReport.seriesTraversal[this.props.selectedReport.seriesTraversal.length-1]}
</Button>
));
report = (<Panel header={titleTracks}>
<StackedBarChart
report={this.props.selectedReport.report}
onSelectSeries={this.onSelectSeries}
seriesTraversal={this.props.selectedReport.seriesTraversal} />
</Panel>
);
}
return ( return (
<div> <div>
{report} {report}

View File

@ -1,15 +1,13 @@
var d3 = require('d3'); var d3 = require('d3');
var React = require('react'); var React = require('react');
var Panel = require('react-bootstrap').Panel;
module.exports = React.createClass({ module.exports = React.createClass({
displayName: "StackedBarChart", displayName: "StackedBarChart",
calcMinMax: function(data) { calcMinMax: function(series) {
var children = []; var children = [];
for (var child in data) { for (var child in series) {
if (data.hasOwnProperty(child)) if (series.hasOwnProperty(child))
children.push(data[child]); children.push(series[child]);
} }
var positiveValues = [0]; var positiveValues = [0];
@ -40,12 +38,6 @@ module.exports = React.createClass({
return Math.ceil(rangePerTick/roundTo)*roundTo; return Math.ceil(rangePerTick/roundTo)*roundTo;
}, },
render: function() { render: function() {
var data = this.props.data.mapReduceChildren(null,
function(accumulator, currentValue, currentIndex, array) {
return accumulator + currentValue;
}
);
height = 400; height = 400;
width = 600; width = 600;
legendWidth = 100; legendWidth = 100;
@ -54,7 +46,7 @@ module.exports = React.createClass({
height -= yMargin*2; height -= yMargin*2;
width -= xMargin*2; width -= xMargin*2;
var minMax = this.calcMinMax(data); var minMax = this.calcMinMax(this.props.report.FlattenedSeries);
var y = d3.scaleLinear() var y = d3.scaleLinear()
.range([0, height]) .range([0, height])
.domain(minMax); .domain(minMax);
@ -63,7 +55,7 @@ module.exports = React.createClass({
var x = d3.scaleLinear() var x = d3.scaleLinear()
.range([0, width]) .range([0, width])
.domain([0, this.props.data.Labels.length + 0.5]); .domain([0, this.props.report.Labels.length + 0.5]);
var bars = []; var bars = [];
var labels = []; var labels = [];
@ -76,13 +68,13 @@ module.exports = React.createClass({
// negativeSum arrays // negativeSum arrays
var positiveSum = []; var positiveSum = [];
var negativeSum = []; var negativeSum = [];
for (var i=0; i < this.props.data.Labels.length; i++) { for (var i=0; i < this.props.report.Labels.length; i++) {
positiveSum.push(0); positiveSum.push(0);
negativeSum.push(0); negativeSum.push(0);
var labelX = x(i) + barStart + barWidth/2; var labelX = x(i) + barStart + barWidth/2;
var labelY = height + 15; var labelY = height + 15;
labels.push(( labels.push((
<text x={labelX} y={labelY} transform={"rotate(45 "+labelX+" "+labelY+")"}>{this.props.data.Labels[i]}</text> <text x={labelX} y={labelY} transform={"rotate(45 "+labelX+" "+labelY+")"}>{this.props.report.Labels[i]}</text>
)); ));
labels.push(( labels.push((
<line className="axis-tick" x1={labelX} y1={height-3} x2={labelX} y2={height+3} /> <line className="axis-tick" x1={labelX} y1={height-3} x2={labelX} y2={height+3} />
@ -103,14 +95,24 @@ module.exports = React.createClass({
for (var i=0-xAxisMarksEvery; i > minMax[0]; i -= xAxisMarksEvery) for (var i=0-xAxisMarksEvery; i > minMax[0]; i -= xAxisMarksEvery)
makeXLabel(i); makeXLabel(i);
//TODO handle Values from current series?
var legendMap = {}; var legendMap = {};
for (var child in data) { for (var child in this.props.report.FlattenedSeries) {
childId++; if (this.props.report.FlattenedSeries.hasOwnProperty(child)) {
var rectClasses = "chart-element chart-color" + (childId % 12); childId++;
if (data.hasOwnProperty(child)) { var childData = this.props.report.FlattenedSeries[child];
for (var i=0; i < data[child].length; i++) { var rectClasses = "chart-element chart-color" + (childId % 12);
var value = data[child][i]; var self = this;
var rectOnClick = function() {
var childName = child;
var onSelectSeries = self.props.onSelectSeries;
return function() {
onSelectSeries(childName);
};
}();
var seriesBars = [];
for (var i=0; i < childData.length; i++) {
var value = childData[i];
if (value == 0) if (value == 0)
continue; continue;
legendMap[child] = childId; legendMap[child] = childId;
@ -124,10 +126,15 @@ module.exports = React.createClass({
negativeSum[i] += rectHeight; negativeSum[i] += rectHeight;
} }
bars.push(( seriesBars.push((
<rect className={rectClasses} x={x(i) + barStart} y={rectY} width={barWidth} height={rectHeight} rx={1} ry={1}/> <rect onClick={rectOnClick} className={rectClasses} x={x(i) + barStart} y={rectY} width={barWidth} height={rectHeight} rx={1} ry={1}/>
)); ));
} }
bars.push((
<g className="chart-series">
{seriesBars}
</g>
));
} }
} }
@ -144,19 +151,17 @@ module.exports = React.createClass({
} }
return ( return (
<Panel header={this.props.data.Title}> <svg height={height + 2*yMargin} width={width + 2*xMargin + legendWidth}>
<svg height={height + 2*yMargin} width={width + 2*xMargin + legendWidth}> <g className="stacked-bar-chart" transform={"translate("+xMargin+" "+yMargin+")"}>
<g className="stacked-bar-chart" transform={"translate("+xMargin+" "+yMargin+")"}> {bars}
{bars} <line className="axis x-axis" x1={0} y1={height} x2={width} y2={height} />
<line className="axis x-axis" x1={0} y1={height} x2={width} y2={height} /> <line className="axis y-axis" x1={0} y1={0} x2={0} y2={height} />
<line className="axis y-axis" x1={0} y1={0} x2={0} y2={height} /> {labels}
{labels} </g>
</g> <g className="chart-legend" transform={"translate("+(width + 2*xMargin)+" "+yMargin+")"}>
<g className="chart-legend" transform={"translate("+(width + 2*xMargin)+" "+yMargin+")"}> {legend}
{legend} </g>
</g> </svg>
</svg>
</Panel>
); );
} }
}); });

View File

@ -2,5 +2,7 @@ var keyMirror = require('keymirror');
module.exports = keyMirror({ module.exports = keyMirror({
FETCH_REPORT: null, FETCH_REPORT: null,
REPORT_FETCHED: null REPORT_FETCHED: null,
SELECT_REPORT: null,
REPORT_SELECTED: null
}); });

View File

@ -5,13 +5,15 @@ var ReportsTab = require('../components/ReportsTab');
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
reports: state.reports reports: state.reports,
selectedReport: state.selectedReport
} }
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
onFetchReport: function(reportname) {dispatch(ReportActions.fetch(reportname))} onFetchReport: function(reportname) {dispatch(ReportActions.fetch(reportname))},
onSelectReport: function(report, seriesTraversal) {dispatch(ReportActions.select(report, seriesTraversal))}
} }
} }

View File

@ -399,16 +399,16 @@ Error.prototype.isError = function() {
function Series() { function Series() {
this.Values = []; this.Values = [];
this.Children = {}; this.Series = {};
} }
Series.prototype.toJSONobj = function() { Series.prototype.toJSONobj = function() {
var json_obj = {}; var json_obj = {};
json_obj.Values = this.Values; json_obj.Values = this.Values;
json_obj.Children = {}; json_obj.Series = {};
for (var child in this.Children) { for (var child in this.Series) {
if (this.Children.hasOwnProperty(child)) if (this.Series.hasOwnProperty(child))
json_obj.Children[child] = this.Children[child].toJSONobj(); json_obj.Series[child] = this.Series[child].toJSONobj();
} }
return json_obj; return json_obj;
} }
@ -416,20 +416,20 @@ Series.prototype.toJSONobj = function() {
Series.prototype.fromJSONobj = function(json_obj) { Series.prototype.fromJSONobj = function(json_obj) {
if (json_obj.hasOwnProperty("Values")) if (json_obj.hasOwnProperty("Values"))
this.Values = json_obj.Values; this.Values = json_obj.Values;
if (json_obj.hasOwnProperty("Children")) { if (json_obj.hasOwnProperty("Series")) {
for (var child in json_obj.Children) { for (var child in json_obj.Series) {
if (json_obj.Children.hasOwnProperty(child)) if (json_obj.Series.hasOwnProperty(child))
this.Children[child] = new Series(); this.Series[child] = new Series();
this.Children[child].fromJSONobj(json_obj.Children[child]); this.Series[child].fromJSONobj(json_obj.Series[child]);
} }
} }
} }
Series.prototype.mapReduceChildren = function(mapFn, reduceFn) { Series.prototype.mapReduceChildren = function(mapFn, reduceFn) {
var children = {} var children = {}
for (var child in this.Children) { for (var child in this.Series) {
if (this.Children.hasOwnProperty(child)) if (this.Series.hasOwnProperty(child))
children[child] = this.Children[child].mapReduce(mapFn, reduceFn); children[child] = this.Series[child].mapReduce(mapFn, reduceFn);
} }
return children; return children;
} }
@ -441,9 +441,9 @@ Series.prototype.mapReduce = function(mapFn, reduceFn) {
else else
childValues.push(this.Values.slice()); childValues.push(this.Values.slice());
for (var child in this.Children) { for (var child in this.Series) {
if (this.Children.hasOwnProperty(child)) if (this.Series.hasOwnProperty(child))
childValues.push(this.Children[child].mapReduce(mapFn, reduceFn)); childValues.push(this.Series[child].mapReduce(mapFn, reduceFn));
} }
var reducedValues = []; var reducedValues = [];
@ -466,8 +466,11 @@ function Report() {
this.YAxisLabel = ""; this.YAxisLabel = "";
this.Labels = []; this.Labels = [];
this.Series = {}; this.Series = {};
this.FlattenedSeries = {};
} }
Report.prototype.topLevelAccountName = "(top level)";
Report.prototype.toJSON = function() { Report.prototype.toJSON = function() {
var json_obj = {}; var json_obj = {};
json_obj.ReportId = this.ReportId; json_obj.ReportId = this.ReportId;

View File

@ -8,6 +8,7 @@ var SecurityTemplateReducer = require('./SecurityTemplateReducer');
var SelectedAccountReducer = require('./SelectedAccountReducer'); var SelectedAccountReducer = require('./SelectedAccountReducer');
var SelectedSecurityReducer = require('./SelectedSecurityReducer'); var SelectedSecurityReducer = require('./SelectedSecurityReducer');
var ReportReducer = require('./ReportReducer'); var ReportReducer = require('./ReportReducer');
var SelectedReportReducer = require('./SelectedReportReducer');
var ErrorReducer = require('./ErrorReducer'); var ErrorReducer = require('./ErrorReducer');
module.exports = Redux.combineReducers({ module.exports = Redux.combineReducers({
@ -19,5 +20,6 @@ module.exports = Redux.combineReducers({
selectedAccount: SelectedAccountReducer, selectedAccount: SelectedAccountReducer,
selectedSecurity: SelectedSecurityReducer, selectedSecurity: SelectedSecurityReducer,
reports: ReportReducer, reports: ReportReducer,
selectedReport: SelectedReportReducer,
error: ErrorReducer error: ErrorReducer
}); });

View File

@ -0,0 +1,23 @@
var assign = require('object-assign');
var ReportConstants = require('../constants/ReportConstants');
var UserConstants = require('../constants/UserConstants');
const initialState = {
report: null,
seriesTraversal: []
};
module.exports = function(state = initialState, action) {
switch (action.type) {
case ReportConstants.REPORT_SELECTED:
return {
report: action.report,
seriesTraversal: action.seriesTraversal
};
case UserConstants.USER_LOGGEDOUT:
return initialState;
default:
return state;
}
};

View File

@ -25,8 +25,8 @@ const (
const luaTimeoutSeconds time.Duration = 5 // maximum time a lua request can run for const luaTimeoutSeconds time.Duration = 5 // maximum time a lua request can run for
type Series struct { type Series struct {
Values []float64 Values []float64
Children map[string]*Series Series map[string]*Series
} }
type Report struct { type Report struct {

View File

@ -97,8 +97,8 @@ func luaReportSeries(L *lua.LState) int {
ud.Value = s ud.Value = s
} else { } else {
report.Series[name] = &Series{ report.Series[name] = &Series{
Children: make(map[string]*Series), Series: make(map[string]*Series),
Values: make([]float64, cap(report.Labels)), Values: make([]float64, cap(report.Labels)),
} }
ud.Value = report.Series[name] ud.Value = report.Series[name]
} }
@ -157,8 +157,8 @@ func luaSeries__index(L *lua.LState) int {
switch field { switch field {
case "Value", "value": case "Value", "value":
L.Push(L.NewFunction(luaSeriesValue)) L.Push(L.NewFunction(luaSeriesValue))
case "Series", "series", "Child", "child": case "Series", "series":
L.Push(L.NewFunction(luaSeriesChildren)) L.Push(L.NewFunction(luaSeriesSeries))
default: default:
L.ArgError(2, "unexpected series attribute: "+field) L.ArgError(2, "unexpected series attribute: "+field)
} }
@ -179,20 +179,20 @@ func luaSeriesValue(L *lua.LState) int {
return 0 return 0
} }
func luaSeriesChildren(L *lua.LState) int { func luaSeriesSeries(L *lua.LState) int {
parent := luaCheckSeries(L, 1) parent := luaCheckSeries(L, 1)
name := L.CheckString(2) name := L.CheckString(2)
ud := L.NewUserData() ud := L.NewUserData()
s, ok := parent.Children[name] s, ok := parent.Series[name]
if ok { if ok {
ud.Value = s ud.Value = s
} else { } else {
parent.Children[name] = &Series{ parent.Series[name] = &Series{
Children: make(map[string]*Series), Series: make(map[string]*Series),
Values: make([]float64, cap(parent.Values)), Values: make([]float64, cap(parent.Values)),
} }
ud.Value = parent.Children[name] ud.Value = parent.Series[name]
} }
L.SetMetatable(ud, L.GetTypeMetatable(luaSeriesTypeName)) L.SetMetatable(ud, L.GetTypeMetatable(luaSeriesTypeName))
L.Push(ud) L.Push(ud)

View File

@ -53,7 +53,6 @@ func luaGetSecurities(L *lua.LState) int {
return 1 return 1
} }
// Registers my security type to given L.
func luaRegisterSecurities(L *lua.LState) { func luaRegisterSecurities(L *lua.LState) {
mt := L.NewTypeMetatable(luaSecurityTypeName) mt := L.NewTypeMetatable(luaSecurityTypeName)
L.SetGlobal("security", mt) L.SetGlobal("security", mt)

View File

@ -28,7 +28,7 @@
} }
.chart-color8 { .chart-color8 {
fill: #ff7f00; fill: #ff7f00;
fill: #df5f00; stroke: #df5f00;
} }
.chart-color9 { .chart-color9 {
fill: #cab2d6; fill: #cab2d6;
@ -50,7 +50,7 @@
.chart-element { .chart-element {
stroke-width: 0; stroke-width: 0;
} }
.chart-element:hover { g.chart-series:hover .chart-element {
stroke-width: 2; stroke-width: 2;
} }
.chart-legend rect { .chart-legend rect {