"Something something modern JavaScript" Change-Id: I00abddd0a5b0abc4fcb4daee2d4ffa5189245d3b Reviewed-on: https://cl.tvl.fyi/c/depot/+/7916 Reviewed-by: wpcarro <wpcarro@gmail.com> Tested-by: BuildkiteCI Autosubmit: wpcarro <wpcarro@gmail.com>
		
			
				
	
	
		
			1256 lines
		
	
	
	
		
			46 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1256 lines
		
	
	
	
		
			46 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import React from 'react';
 | 
						|
import ReactDOM from 'react-dom/client';
 | 
						|
import Chart from 'chart.js/auto';
 | 
						|
import 'chartjs-adapter-date-fns';
 | 
						|
 | 
						|
const colors = {
 | 
						|
    red: 'rgb(255, 45, 70)',
 | 
						|
    green: 'rgb(75, 192, 35)',
 | 
						|
    white: 'rgb(249, 246, 238)',
 | 
						|
    blue: 'rgb(137, 207, 240)',
 | 
						|
    fadedBlue: 'rgb(137, 207, 240, 0.25)',
 | 
						|
    purple: 'rgb(203, 195, 227)',
 | 
						|
    brown: 'rgb(205, 127, 50)',
 | 
						|
    black: 'rgb(53, 57, 53)',
 | 
						|
};
 | 
						|
 | 
						|
const months = [
 | 
						|
    'January',
 | 
						|
    'February',
 | 
						|
    'March',
 | 
						|
    'April',
 | 
						|
    'May',
 | 
						|
    'June',
 | 
						|
    'July',
 | 
						|
    'August',
 | 
						|
    'September',
 | 
						|
    'October',
 | 
						|
    'November',
 | 
						|
    'December',
 | 
						|
];
 | 
						|
 | 
						|
function getWeek(x) {
 | 
						|
    const dowOffset = 0;
 | 
						|
    const newYear = new Dte(x.getFullYear(), 0, 1);
 | 
						|
    let day = newYear.getDay() - dowOffset; //the day of week the year begins on
 | 
						|
    day = (day >= 0 ? day : day + 7);
 | 
						|
    const daynum = Math.floor((x.getTime() - newYear.getTime() -
 | 
						|
        (x.getTimezoneOffset() - newYear.getTimezoneOffset()) * 60000) / 86400000) + 1;
 | 
						|
    let weeknum;
 | 
						|
 | 
						|
    //if the year starts before the middle of a week
 | 
						|
    if (day < 4) {
 | 
						|
        weeknum = Math.floor((daynum + day - 1) / 7) + 1;
 | 
						|
        if (weeknum > 52) {
 | 
						|
            const nYear = new Date(x.getFullYear() + 1, 0, 1);
 | 
						|
            let nday = nYear.getDay() - dowOffset;
 | 
						|
            nday = nday >= 0 ? nday : nday + 7;
 | 
						|
            /*if the next year starts before the middle of
 | 
						|
              the week, it is week #1 of that year*/
 | 
						|
            weeknum = nday < 4 ? 1 : 53;
 | 
						|
        }
 | 
						|
    }
 | 
						|
    else {
 | 
						|
        weeknum = Math.floor((daynum + day - 1) / 7);
 | 
						|
    }
 | 
						|
    return weeknum;
 | 
						|
}
 | 
						|
 | 
						|
// Convert a sorting expressions (e.g. "Outflow DESC; Date ASC; Category ASC")
 | 
						|
// into a function that can be passed to Array.prototype.sort.
 | 
						|
function compileSort(expr) {
 | 
						|
    return expr.split(/\s*;\s*/).reverse().reduce((acc, x) => {
 | 
						|
        const [k, dir] = x.split(/\s+/);
 | 
						|
        if (dir === 'ASC') {
 | 
						|
            return function(x, y) {
 | 
						|
                if (x[k] > y[k]) { return 1; }
 | 
						|
                if (x[k] < y[k]) { return -1; }
 | 
						|
                else { return acc(x, y); }
 | 
						|
            };
 | 
						|
        }
 | 
						|
        if (dir === 'DESC') {
 | 
						|
            return function(x, y) {
 | 
						|
                if (x[k] > y[k]) { return -1; }
 | 
						|
                if (x[k] < y[k]) { return 1; }
 | 
						|
                else { return acc(x, y); }
 | 
						|
            };
 | 
						|
        }
 | 
						|
        else {
 | 
						|
            throw new Error(`Sort direction not supported, ${dir}, must be either "ASC" or "DESC"`);
 | 
						|
        }
 | 
						|
    }, function(x, y) { return 0; })
 | 
						|
}
 | 
						|
 | 
						|
function dollars(n, sensitive) {
 | 
						|
    if (sensitive) {
 | 
						|
        const order = magnitude(n);
 | 
						|
        // Shortcut to avoid writing comma-insertion logic v0v.
 | 
						|
        if (n === 0) {
 | 
						|
            return '$X.XX';
 | 
						|
        }
 | 
						|
        if (order <= 0) {
 | 
						|
            return '$X.XX';
 | 
						|
        }
 | 
						|
        if (order === 1) {
 | 
						|
            return '$XX.XX';
 | 
						|
        }
 | 
						|
        if (order === 2) {
 | 
						|
            return '$XXX.XX';
 | 
						|
        }
 | 
						|
        if (order === 3) {
 | 
						|
            return '$X,XXX.XX';
 | 
						|
        }
 | 
						|
        if (order === 4) {
 | 
						|
            return '$XX,XXX.XX';
 | 
						|
        }
 | 
						|
        if (order === 4) {
 | 
						|
            return '$XX,XXX.XX';
 | 
						|
        }
 | 
						|
        if (order === 5) {
 | 
						|
            return '$XXX,XXX.XX';
 | 
						|
        }
 | 
						|
        // Coming soon! :P
 | 
						|
        if (order === 6) {
 | 
						|
            return '$X,XXX,XXX.XX';
 | 
						|
        }
 | 
						|
        if (order === 7) {
 | 
						|
            return '$XX,XXX,XXX.XX';
 | 
						|
        }
 | 
						|
        if (order === 8) {
 | 
						|
            return '$XXX,XXX,XXX.XX';
 | 
						|
        }
 | 
						|
        // Unsupported
 | 
						|
        else {
 | 
						|
            return '$???.??';
 | 
						|
        }
 | 
						|
    }
 | 
						|
    return usd.format(n);
 | 
						|
}
 | 
						|
 | 
						|
const usd = new Intl.NumberFormat('en-US', {
 | 
						|
    style: 'currency',
 | 
						|
    currency: 'USD',
 | 
						|
});
 | 
						|
 | 
						|
const queries = {
 | 
						|
    housing: 'Category:/(rent|electric)/',
 | 
						|
    food: 'Category:/(eating|alcohol|grocer)/',
 | 
						|
    commute: 'Category:/(vacation|gasoline|parking|car maintenance)/',
 | 
						|
};
 | 
						|
 | 
						|
/**
 | 
						|
 * Return the Order of Magnitude of some value, x.
 | 
						|
 */
 | 
						|
function magnitude(x) {
 | 
						|
    return Math.floor(Math.log(x) / Math.LN10 + 0.000000001);
 | 
						|
}
 | 
						|
 | 
						|
function getSum(transactions) {
 | 
						|
    return transactions.reduce((acc, x) => acc + x.Outflow, 0);
 | 
						|
}
 | 
						|
 | 
						|
function transactionKey(x) {
 | 
						|
    const keys = [
 | 
						|
        'Account',
 | 
						|
        'Flag',
 | 
						|
        'Date',
 | 
						|
        'Payee',
 | 
						|
        'Category',
 | 
						|
        'Memo',
 | 
						|
        'Outflow',
 | 
						|
        'Inflow',
 | 
						|
        'Cleared',
 | 
						|
    ];
 | 
						|
    return keys.map(k => x[k]).join('|');
 | 
						|
}
 | 
						|
 | 
						|
function parseCSV(csv) {
 | 
						|
  let lines=csv.split("\n");
 | 
						|
  let result = [];
 | 
						|
 | 
						|
  // Strip the surrounding 2x-quotes from the header.
 | 
						|
  //
 | 
						|
  // NOTE: If your columns contain commas in their values, you'll need
 | 
						|
  // to deal with those before doing the next step
 | 
						|
  let headers = lines[0].split(",").map(x => x.slice(1, -1));
 | 
						|
 | 
						|
  for(let i = 1; i < lines.length; i += 1) {
 | 
						|
      let obj = {};
 | 
						|
      let currentline=lines[i].split(",");
 | 
						|
 | 
						|
      for(let j = 0; j < headers.length; j += 1) {
 | 
						|
        obj[headers[j]] = currentline[j].slice(1, -1);
 | 
						|
      }
 | 
						|
 | 
						|
      result.push(obj);
 | 
						|
  }
 | 
						|
 | 
						|
  return result.map(x => ({
 | 
						|
    ...x,
 | 
						|
    Date: new Date(x.Date),
 | 
						|
    Inflow: parseFloat(x.Inflow),
 | 
						|
    Outflow: parseFloat(x.Outflow),
 | 
						|
  }));
 | 
						|
}
 | 
						|
 | 
						|
 | 
						|
class UploadJSON extends React.Component {
 | 
						|
    handleUpload(e) {
 | 
						|
        let files = e.target.files;
 | 
						|
        if (!files.length) {
 | 
						|
            alert('No file selected!');
 | 
						|
            return;
 | 
						|
        }
 | 
						|
        let file = files[0];
 | 
						|
        let reader = new FileReader();
 | 
						|
        reader.onload = (event) => {
 | 
						|
            this.props.onUpload(parseCSV(event.target.result));
 | 
						|
        };
 | 
						|
        reader.readAsText(file);
 | 
						|
    }
 | 
						|
    render() {
 | 
						|
        return <input onChange={e => this.handleUpload(e)} id="json-upload" type="file" accept="application/csv" />;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
class ScatterChart extends React.Component {
 | 
						|
    constructor(props) {
 | 
						|
        super(props);
 | 
						|
        this.chart = null;
 | 
						|
        // Generate a 1/1M random ID.
 | 
						|
        this.id = btoa(Math.floor(Math.random() * 1e9));
 | 
						|
    }
 | 
						|
    componentDidUpdate(prevProps) {
 | 
						|
        if (this.props.transactions !== prevProps.transactions) {
 | 
						|
            this.chart.data.datasets[0].data = this.props.transactions.filter(x => x.Inflow > 0).map(x => ({
 | 
						|
                x: x.Date,
 | 
						|
                y: x.Inflow,
 | 
						|
                metadata: x,
 | 
						|
            }));
 | 
						|
            this.chart.data.datasets[1].data = this.props.transactions.filter(x => x.Outflow > 0).map(x => ({
 | 
						|
                x: x.Date,
 | 
						|
                y: x.Outflow,
 | 
						|
                metadata: x,
 | 
						|
            }));
 | 
						|
            this.chart.update();
 | 
						|
        }
 | 
						|
    }
 | 
						|
    componentDidMount() {
 | 
						|
        const mount = document.getElementById(this.id);
 | 
						|
        this.chart = new Chart(mount, {
 | 
						|
            type: 'scatter',
 | 
						|
            data: {
 | 
						|
                datasets: [
 | 
						|
                    {
 | 
						|
                        label: 'Revenue',
 | 
						|
                        data: this.props.transactions.filter(x => x.Inflow > 0).map(x => ({
 | 
						|
                            x: x.Date,
 | 
						|
                            y: x.Inflow,
 | 
						|
                            metadata: x,
 | 
						|
                        })),
 | 
						|
                        backgroundColor: colors.green,
 | 
						|
                    },
 | 
						|
                    {
 | 
						|
                        label: 'Expenses',
 | 
						|
                        data: this.props.transactions.filter(x => x.Outflow).map(x => ({
 | 
						|
                            x: x.Date,
 | 
						|
                            y: x.Outflow,
 | 
						|
                            metadata: x,
 | 
						|
                        })),
 | 
						|
                        backgroundColor: colors.red,
 | 
						|
                    },
 | 
						|
                ],
 | 
						|
            },
 | 
						|
            options: {
 | 
						|
                scales: {
 | 
						|
                    x: {
 | 
						|
                        type: 'time',
 | 
						|
                        title: {
 | 
						|
                            display: true,
 | 
						|
                            text: 'Date',
 | 
						|
                        },
 | 
						|
                    },
 | 
						|
                    y: {
 | 
						|
                        title: {
 | 
						|
                            display: true,
 | 
						|
                            text: 'Amount ($USD)'
 | 
						|
                        },
 | 
						|
                    },
 | 
						|
                },
 | 
						|
                plugins: {
 | 
						|
                    tooltip: {
 | 
						|
                        callbacks: {
 | 
						|
                            title: function (x) {
 | 
						|
                                return `$${x[0].raw.y} (${x[0].raw.metadata.Date.toLocaleDateString()})`;
 | 
						|
                            },
 | 
						|
                            label: function (x) {
 | 
						|
                                const { Category, Payee, Memo } = x.raw.metadata;
 | 
						|
                                return `${Payee} - ${Category} (${Memo})`;
 | 
						|
                            },
 | 
						|
                        },
 | 
						|
                    },
 | 
						|
                },
 | 
						|
            },
 | 
						|
        });
 | 
						|
    }
 | 
						|
    render() {
 | 
						|
        return <canvas id={this.id}></canvas>;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Generic line chart parameterized by:
 | 
						|
 * - datasets: forwarded to chart.js library
 | 
						|
 * - x: string label for x-axis
 | 
						|
 * - y: string label for y-axis
 | 
						|
 */
 | 
						|
class GenLineChart extends React.Component {
 | 
						|
    constructor(props) {
 | 
						|
        super(props);
 | 
						|
        this.chart = null;
 | 
						|
        // Generate a 1/1M random ID.
 | 
						|
        this.id = btoa(Math.floor(Math.random() * 1e9));
 | 
						|
    }
 | 
						|
 | 
						|
    componentDidUpdate(prevProps, prevState) {
 | 
						|
        if (this.props.datasets != prevProps.datasets) {
 | 
						|
            this.chart.data.datasets = this.props.datasets;
 | 
						|
            this.chart.update();
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    componentDidMount() {
 | 
						|
        const mount = document.getElementById(this.id);
 | 
						|
        this.chart = new Chart(mount, {
 | 
						|
            type: 'line',
 | 
						|
            data: {
 | 
						|
                datasets: this.props.datasets,
 | 
						|
            },
 | 
						|
            options: {
 | 
						|
                scales: {
 | 
						|
                    x: {
 | 
						|
                        type: 'time',
 | 
						|
                        title: {
 | 
						|
                            display: true,
 | 
						|
                            text: this.props.x,
 | 
						|
                        },
 | 
						|
                    },
 | 
						|
                    y: {
 | 
						|
                        title: {
 | 
						|
                            display: true,
 | 
						|
                            text: this.props.y
 | 
						|
                        },
 | 
						|
                    },
 | 
						|
                },
 | 
						|
            },
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    render() {
 | 
						|
        return <canvas id={this.id}></canvas>;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
class DonutChart extends React.Component {
 | 
						|
    constructor(props) {
 | 
						|
        super(props);
 | 
						|
        this.chart = null;
 | 
						|
        // Generate a 1/1M random ID.
 | 
						|
        this.id = btoa(Math.floor(Math.random() * 1e9));
 | 
						|
    }
 | 
						|
 | 
						|
    componentDidUpdate(prevProps, prevState) {
 | 
						|
        if (this.props.datasets != prevProps.datasets) {
 | 
						|
            this.chart.data.datasets = this.props.datasets;
 | 
						|
            this.chart.update();
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    componentDidMount() {
 | 
						|
        const mount = document.getElementById(this.id);
 | 
						|
        this.chart = new Chart(mount, {
 | 
						|
            type: 'doughnut',
 | 
						|
            data: {
 | 
						|
                labels: this.props.labels,
 | 
						|
                datasets: this.props.datasets,
 | 
						|
            },
 | 
						|
            options: {
 | 
						|
                resonsive: true,
 | 
						|
            },
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    render() {
 | 
						|
        return <canvas id={this.id}></canvas>;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
class StackedHistogram extends React.Component {
 | 
						|
    constructor(props) {
 | 
						|
        super(props);
 | 
						|
        this.chart = null;
 | 
						|
        // Generate a 1/1M random ID.
 | 
						|
        this.id = btoa(Math.floor(Math.random() * 1e9));
 | 
						|
    }
 | 
						|
 | 
						|
    componentDidUpdate(prevProps, prevState) {
 | 
						|
        if (this.props.datasets != prevProps.datasets) {
 | 
						|
            this.chart.data.datasets = this.props.datasets;
 | 
						|
            this.chart.update();
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    componentDidMount() {
 | 
						|
        const mount = document.getElementById(this.id);
 | 
						|
        this.chart = new Chart(mount, {
 | 
						|
            type: 'bar',
 | 
						|
            data: {
 | 
						|
                labels: this.props.labels,
 | 
						|
                datasets: this.props.datasets,
 | 
						|
            },
 | 
						|
            options: {
 | 
						|
                scales: {
 | 
						|
                    x: {
 | 
						|
                        stacked: true,
 | 
						|
                    },
 | 
						|
                    y: {
 | 
						|
                        stacked: true,
 | 
						|
                    },
 | 
						|
                },
 | 
						|
            },
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    render() {
 | 
						|
        return <canvas id={this.id}></canvas>;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
/**
 | 
						|
 * Display the "Actual Savings Rate" (bucketed by month) as a line chart with
 | 
						|
 * the "Expected Savings Rate" overlay.
 | 
						|
 */
 | 
						|
class SavingsRateLineChart extends React.Component {
 | 
						|
    constructor(props) {
 | 
						|
        super(props);
 | 
						|
        this.chart = null;
 | 
						|
        // Generate a 1/1M random ID.
 | 
						|
        this.id = btoa(Math.floor(Math.random() * 1e9));
 | 
						|
    }
 | 
						|
 | 
						|
    static getRevenue(transactions) {
 | 
						|
        // Bucket revenues into months.
 | 
						|
        return transactions.reduce((acc, x) => {
 | 
						|
            const month = x.Date.getMonth();
 | 
						|
            acc[month] += x.Inflow;
 | 
						|
            return acc;
 | 
						|
        }, new Array(12).fill(0));
 | 
						|
    }
 | 
						|
 | 
						|
    static getExpenses(transactions) {
 | 
						|
        // Bucket revenues into months.
 | 
						|
        return transactions.reduce((acc, x) => {
 | 
						|
            const month = x.Date.getMonth();
 | 
						|
            acc[month] += x.Outflow;
 | 
						|
            return acc;
 | 
						|
        }, new Array(12).fill(0));
 | 
						|
    }
 | 
						|
 | 
						|
    componentDidMount() {
 | 
						|
        const mount = document.getElementById(this.id);
 | 
						|
        const revenue = SavingsRateLineChart.getRevenue(this.props.transactions);
 | 
						|
        const expenses = SavingsRateLineChart.getExpenses(this.props.transactions);
 | 
						|
 | 
						|
        this.chart = new Chart(mount, {
 | 
						|
            type: 'line',
 | 
						|
            data: {
 | 
						|
                datasets: [
 | 
						|
                    {
 | 
						|
                        label: 'actual savings (by month)',
 | 
						|
                        data: new Array(12).fill(null).map((_, i) => ({
 | 
						|
                            x: i,
 | 
						|
                            y: (revenue[i] - expenses[i]) / revenue[i],
 | 
						|
                        })),
 | 
						|
                        cubicInterpolationMode: 'monotone',
 | 
						|
                        tension: 0.4,
 | 
						|
                        borderColor: colors.fadedBlue,
 | 
						|
                        backgroundColor: colors.fadedBlue,
 | 
						|
                    },
 | 
						|
                    {
 | 
						|
                        label: 'actual savings (overall)',
 | 
						|
                        data: new Array(12).fill(null).map((_, i) => ({
 | 
						|
                            x: i,
 | 
						|
                            y: this.props.rate,
 | 
						|
                        })),
 | 
						|
                        cubicInterpolationMode: 'monotone',
 | 
						|
                        tension: 0.4,
 | 
						|
                        borderColor: colors.blue,
 | 
						|
                        backgroundColor: colors.blue,
 | 
						|
                    },
 | 
						|
                    // 0% marker (out of debt)
 | 
						|
                    {
 | 
						|
                        label: 'beginner (0%)',
 | 
						|
                        data: new Array(12).fill(null).map((x, i) => ({
 | 
						|
                            x: i,
 | 
						|
                            y: 0.00,
 | 
						|
                        })),
 | 
						|
                        cubicInterpolationMode: 'monotone',
 | 
						|
                        tension: 0.4,
 | 
						|
                        borderColor: colors.white,
 | 
						|
                        backgroundColor: colors.white,
 | 
						|
                    },
 | 
						|
                    // 25% marker (quarter "Washington" club)
 | 
						|
                    {
 | 
						|
                        label: 'healthy (25%)',
 | 
						|
                        data: new Array(12).fill(null).map((x, i) => ({
 | 
						|
                            x: i,
 | 
						|
                            y: 0.25,
 | 
						|
                        })),
 | 
						|
                        cubicInterpolationMode: 'monotone',
 | 
						|
                        tension: 0.4,
 | 
						|
                        borderColor: colors.purple,
 | 
						|
                        backgroundColor: colors.purple,
 | 
						|
                    },
 | 
						|
                    // 50% marker (1/2-dollar "Kennedy" club)
 | 
						|
                    {
 | 
						|
                        label: 'rich (50%)',
 | 
						|
                        data: new Array(12).fill(null).map((x, i) => ({
 | 
						|
                            x: i,
 | 
						|
                            y: 0.50,
 | 
						|
                        })),
 | 
						|
                        cubicInterpolationMode: 'monotone',
 | 
						|
                        tension: 0.4,
 | 
						|
                        borderColor: colors.brown,
 | 
						|
                        backgroundColor: colors.brown,
 | 
						|
                    },
 | 
						|
                    // 75% marker
 | 
						|
                    {
 | 
						|
                        label: 'wealthy (75%)',
 | 
						|
                        data: new Array(12).fill(null).map((x, i) => ({
 | 
						|
                            x: i,
 | 
						|
                            y: 0.75,
 | 
						|
                        })),
 | 
						|
                        cubicInterpolationMode: 'monotone',
 | 
						|
                        tension: 0.4,
 | 
						|
                        borderColor: colors.black,
 | 
						|
                        backgroundColor: colors.black,
 | 
						|
                    },
 | 
						|
                ],
 | 
						|
                labels: months,
 | 
						|
            },
 | 
						|
            options: {
 | 
						|
                scales: {
 | 
						|
                    y: {
 | 
						|
                        max: 1.0,
 | 
						|
                        min: -1.0,
 | 
						|
                        title: {
 | 
						|
                            display: true,
 | 
						|
                            text: 'Savings Rate (%)'
 | 
						|
                        },
 | 
						|
                    },
 | 
						|
                },
 | 
						|
            },
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    componentDidUpdate(prevProps, prevState) {
 | 
						|
        // Bucket revenues into months.
 | 
						|
        const revenue = SavingsRateLineChart.getRevenue(this.props.transactions);
 | 
						|
        const expenses = SavingsRateLineChart.getExpenses(this.props.transactions);
 | 
						|
 | 
						|
        this.chart.data.datasets[0].data = new Array(12).fill(null).map((_, i) => ({
 | 
						|
            x: i,
 | 
						|
            y: (revenue[i] - expenses[i]) / revenue[i],
 | 
						|
        }));
 | 
						|
        this.chart.update();
 | 
						|
    }
 | 
						|
 | 
						|
    render() {
 | 
						|
        return <canvas id={this.id}></canvas>;
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
class App extends React.Component {
 | 
						|
    constructor(props) {
 | 
						|
        super(props);
 | 
						|
        const query = 'Account:/checking/';
 | 
						|
        const allTransactions = [];
 | 
						|
        const savingsView = 'after:"01/01/2022"';
 | 
						|
        const inflowQuery = 'Account:/checking/';
 | 
						|
        const outflowQuery = 'Account:/checking/ -Category:/(stocks|crypto)/';
 | 
						|
 | 
						|
        // slx configuration
 | 
						|
        const slxCaseSensitive = false;
 | 
						|
        const slxPreferRegex = true;
 | 
						|
        const slxDateKey = 'Date';
 | 
						|
 | 
						|
        this.state = {
 | 
						|
            query,
 | 
						|
            transactionsView: 'table',
 | 
						|
            sensitive: false,
 | 
						|
            allTransactions,
 | 
						|
            slxCaseSensitive,
 | 
						|
            slxPreferRegex,
 | 
						|
            slxDateKey,
 | 
						|
            filteredTransactions: select(query, allTransactions, {
 | 
						|
                caseSensitive: slxCaseSensitive,
 | 
						|
                preferRegex: slxPreferRegex,
 | 
						|
                dateKey: slxDateKey,
 | 
						|
            }),
 | 
						|
            saved: {},
 | 
						|
            focus: {
 | 
						|
                sum: false,
 | 
						|
                1000: false,
 | 
						|
                100: false,
 | 
						|
                10: false,
 | 
						|
                1: false,
 | 
						|
                0.1: false,
 | 
						|
            },
 | 
						|
            budget: [
 | 
						|
                {
 | 
						|
                    label: 'Flexible',
 | 
						|
                    children: [
 | 
						|
                        { label: 'groceries - $200/mo', savings: false, monthly: 400.00 },
 | 
						|
                        { label: 'eating out - $150/mo', savings: false, monthly: 200.00 },
 | 
						|
                        { label: 'alcohol - $200/mo', savings: false, monthly: 200.00 },
 | 
						|
                        { label: 'household items - $50/mo', savings: false, monthly: 50.00 },
 | 
						|
                        { label: 'toiletries - $200/yr', savings: false, monthly: 200.00 / 12 },
 | 
						|
                        { label: 'haircuts - $400/yr', savings: false, monthly: 400.00 / 12 },
 | 
						|
                        { label: 'gasoline - $100/mo', savings: false, monthly: 100.00 },
 | 
						|
                        { label: 'parking - $10/mo', savings: false, monthly: 10.00 },
 | 
						|
                        { label: 'ride services - $25/mo', savings: false, monthly: 50.00 },
 | 
						|
                        { label: 'LMNT - $45/mo', savings: false, monthly: 45.00 },
 | 
						|
                        { label: 'books - $25/mo', savings: false, monthly: 25.00 },
 | 
						|
                        { label: 'vacation - $4,000/yr', savings: false, monthly: 4000.00 / 12 },
 | 
						|
                        { label: 'reimbursements - $5,000 balance', savings: false, monthly: 0.00 },
 | 
						|
                    ],
 | 
						|
                },
 | 
						|
                {
 | 
						|
                    label: 'Fixed',
 | 
						|
                    children: [
 | 
						|
                        { label: 'rent - $3,100/mo', savings: false, monthly: 3100.00 },
 | 
						|
                        { label: 'electric - $50/mo', savings: false, monthly: 50.00 },
 | 
						|
                        { label: 'SoCalGas - $30/mo', savings: false, monthly: 30.00 },
 | 
						|
                        { label: 'YNAB', savings: false, monthly: 100.00 / 12 },
 | 
						|
                        { label: 'Robinhood Gold', savings: false, monthly: 5.00 },
 | 
						|
                        { label: 'Spotify', savings: false, monthly: 10.00 },
 | 
						|
                        { label: 'Surfline', savings: false, monthly: 100.00 / 12 },
 | 
						|
                        { label: 'HBO Max', savings: false, monthly: 170.00 },
 | 
						|
                        { label: 'Clear', savings: false, monthly: 179.00 },
 | 
						|
                        { label: 'car insurance', savings: false, monthly: 100.00 },
 | 
						|
                        { label: 'Making Sense', savings: false, monthly: 50.00 / 12 },
 | 
						|
                        { label: 'internet', savings: false, monthly: 100.00 },
 | 
						|
                        { label: 'tax return', savings: false, monthly: 200.00 / 12 },
 | 
						|
                    ],
 | 
						|
                },
 | 
						|
                {
 | 
						|
                    label: 'Rainy Day (dont touch)',
 | 
						|
                    children: [
 | 
						|
                        { label: 'emergency fund', savings: false, monthly: 0.00 },
 | 
						|
                        { label: 'check-ups', savings: false, monthly: 7.50 },
 | 
						|
                        { label: 'car maintenance', savings: false, monthly: 98.33 },
 | 
						|
                    ],
 | 
						|
                },
 | 
						|
                {
 | 
						|
                    label: 'Savings (dont touch)',
 | 
						|
                    children: [
 | 
						|
                        { label: 'stocks', savings: true, monthly: 4000.00 },
 | 
						|
                        { label: 'crypto', savings: true, monthly: 736.00 },
 | 
						|
                    ],
 | 
						|
                },
 | 
						|
                {
 | 
						|
                    label: 'Gifts (dont touch)',
 | 
						|
                    children: [
 | 
						|
                        { label: 'birthdays', savings: false, monthly: 250.00 / 12 },
 | 
						|
                        { label: 'Valentines Day', savings: false, monthly: 100.00 / 12 },
 | 
						|
                        { label: 'Mothers Day', savings: false, monthly: 25.00 / 12 },
 | 
						|
                        { label: 'Fathers Day', savings: false, monthly: 25.00 / 12 },
 | 
						|
                        { label: 'Christmas', savings: false, monthly: 500.00 / 12 },
 | 
						|
                    ],
 | 
						|
                },
 | 
						|
                {
 | 
						|
                    label: 'Error Budget',
 | 
						|
                    children: [
 | 
						|
                        { label: 'stuff I forgot to budget for - $2,254/mo', savings: false, monthly: 0.00 },
 | 
						|
                    ],
 | 
						|
                },
 | 
						|
            ],
 | 
						|
            paycheck: 6000.00,
 | 
						|
            view: 'query',
 | 
						|
            savingsView,
 | 
						|
            inflowQuery,
 | 
						|
            outflowQuery,
 | 
						|
            inflowTransactions: select(inflowQuery, select(savingsView, allTransactions, {
 | 
						|
                caseSensitive: slxCaseSensitive,
 | 
						|
                preferRegex: slxPreferRegex,
 | 
						|
                dateKey: slxDateKey,
 | 
						|
            }), {
 | 
						|
                caseSensitive: slxCaseSensitive,
 | 
						|
                preferRegex: slxPreferRegex,
 | 
						|
                dateKey: slxDateKey,
 | 
						|
            }),
 | 
						|
            outflowTransactions: select(outflowQuery, select(savingsView, allTransactions, {
 | 
						|
                caseSensitive: slxCaseSensitive,
 | 
						|
                preferRegex: slxPreferRegex,
 | 
						|
                dateKey: slxDateKey,
 | 
						|
            }), {
 | 
						|
                caseSensitive: slxCaseSensitive,
 | 
						|
                preferRegex: slxPreferRegex,
 | 
						|
                dateKey: slxDateKey,
 | 
						|
            }),
 | 
						|
            sortExpr: 'Date DESC; Outflow DESC',
 | 
						|
        };
 | 
						|
    }
 | 
						|
 | 
						|
    render() {
 | 
						|
        const sum = this.state.filteredTransactions.reduce((acc, { Outflow }) => acc + Outflow, 0);
 | 
						|
        const savedSum = Object.values(this.state.saved).reduce((acc, sum) => acc + sum, 0);
 | 
						|
        const categories = this.state.allTransactions.reduce((acc, x) => {
 | 
						|
            if (!(x.Category in acc)) {
 | 
						|
                acc[x.Category] = [];
 | 
						|
            }
 | 
						|
            acc[x.Category].push(x);
 | 
						|
            return acc;
 | 
						|
        }, {});
 | 
						|
 | 
						|
        let view = null;
 | 
						|
        if (this.state.view === 'query') {
 | 
						|
            view = (
 | 
						|
                <QueryView
 | 
						|
                    transactionsView={this.state.transactionsView}
 | 
						|
                    sortExpr={this.state.sortExpr}
 | 
						|
                    onSortExprChange={sortExpr => this.setState({ sortExpr })}
 | 
						|
                    sensitive={this.state.sensitive}
 | 
						|
                    query={this.state.query}
 | 
						|
                    focus={this.state.focus}
 | 
						|
                    allTransactions={this.state.allTransactions}
 | 
						|
                    transactions={this.state.filteredTransactions}
 | 
						|
                    saved={this.state.saved}
 | 
						|
                    setState={this.setState.bind(this)}
 | 
						|
                    slxConfig={{
 | 
						|
                        caseSensitive: this.state.slxCaseSensitive,
 | 
						|
                        preferRegex: this.state.slxPreferRegex,
 | 
						|
                        dateKey: this.state.slxDateKey,
 | 
						|
                    }}
 | 
						|
                />
 | 
						|
            );
 | 
						|
        } else if (this.state.view === 'savings') {
 | 
						|
            view = (
 | 
						|
                <SavingsView
 | 
						|
                    categories={categories}
 | 
						|
                    sensitive={this.state.sensitive}
 | 
						|
                    savingsView={this.state.savingsView}
 | 
						|
                    inflowQuery={this.state.inflowQuery}
 | 
						|
                    outflowQuery={this.state.outflowQuery}
 | 
						|
                    inflowTransactions={this.state.inflowTransactions}
 | 
						|
                    outflowTransactions={this.state.outflowTransactions}
 | 
						|
                    onFilterInflow={() => this.setState({
 | 
						|
                        inflowTransactions: select(this.state.inflowQuery, select(this.state.savingsView, this.state.allTransactions, {
 | 
						|
                            caseSensitive: this.state.slxCaseSensitive,
 | 
						|
                            preferRegex: this.state.slxPreferRegex,
 | 
						|
                            dateKey: this.state.slxDateKey,
 | 
						|
                        }), {
 | 
						|
                            caseSensitive: this.state.slxCaseSensitive,
 | 
						|
                            preferRegex: this.state.slxPreferRegex,
 | 
						|
                            dateKey: this.state.slxDateKey,
 | 
						|
                        }),
 | 
						|
                    })}
 | 
						|
                    onFilterOutflow={() => this.setState({
 | 
						|
                        outflowTransactions: select(this.state.outflowQuery, select(this.state.savingsView, this.state.allTransactions, {
 | 
						|
                            caseSensitive: this.state.slxCaseSensitive,
 | 
						|
                            preferRegex: this.state.slxPreferRegex,
 | 
						|
                            dateKey: this.state.slxDateKey,
 | 
						|
                        }), {
 | 
						|
                            caseSensitive: this.state.slxCaseSensitive,
 | 
						|
                            preferRegex: this.state.slxPreferRegex,
 | 
						|
                            dateKey: this.state.slxDateKey,
 | 
						|
                        }),
 | 
						|
                    })}
 | 
						|
                    onFilterSavingsView={() => this.setState({
 | 
						|
                        inflowTransactions: select(this.state.inflowQuery, select(this.state.savingsView, this.state.allTransactions, {
 | 
						|
                            caseSensitive: this.state.slxCaseSensitive,
 | 
						|
                            preferRegex: this.state.slxPreferRegex,
 | 
						|
                            dateKey: this.state.slxDateKey,
 | 
						|
                        }), {
 | 
						|
                            caseSensitive: this.state.slxCaseSensitive,
 | 
						|
                            preferRegex: this.state.slxPreferRegex,
 | 
						|
                            dateKey: this.state.slxDateKey,
 | 
						|
                        }),
 | 
						|
                        outflowTransactions: select(this.state.outflowQuery, select(this.state.savingsView, this.state.allTransactions, {
 | 
						|
                            caseSensitive: this.state.slxCaseSensitive,
 | 
						|
                            preferRegex: this.state.slxPreferRegex,
 | 
						|
                            dateKey: this.state.slxDateKey,
 | 
						|
                        }), {
 | 
						|
                            caseSensitive: this.state.slxCaseSensitive,
 | 
						|
                            preferRegex: this.state.slxPreferRegex,
 | 
						|
                            dateKey: this.state.slxDateKey,
 | 
						|
                        }),
 | 
						|
                    })}
 | 
						|
                    onSavingsViewChange={x => this.setState({ savingsView: x })}
 | 
						|
                    onInflowQueryChange={x => this.setState({ inflowQuery: x })}
 | 
						|
                    onOutflowQueryChange={x => this.setState({ outflowQuery: x })}
 | 
						|
                />
 | 
						|
            );
 | 
						|
        } else if (this.state.view === 'budget') {
 | 
						|
            // Planned expenses:
 | 
						|
            // - minus planned assignment to emergency fund (not an expense)
 | 
						|
            // - minus planned spend to investments (e.g. stocks, crypto)
 | 
						|
            const budgetedSpend = this.state.budget.reduce((acc, x) => acc + x.children.filter(x => x.savings).reduce((acc, x) => acc + x.monthly, 0), 0);
 | 
						|
 | 
						|
            view = (
 | 
						|
                <div>
 | 
						|
                    <fieldset>
 | 
						|
                        <legend>Details</legend>
 | 
						|
                        <div className="form-group">
 | 
						|
                            <label htmlFor="paycheck">Paycheck</label>
 | 
						|
                            <input name="paycheck" type="text" />
 | 
						|
                        </div>
 | 
						|
                        <div className="form-group">
 | 
						|
                            <label htmlFor="savings-rate">Savings Rate</label>
 | 
						|
                            <input name="savings-rate" type="text" />
 | 
						|
                        </div>
 | 
						|
                    </fieldset>
 | 
						|
                    <ul>
 | 
						|
                        <li>Available Spend: {dollars(this.state.paycheck * 2, this.state.sensitive)}</li>
 | 
						|
                        <li>Target Spend: {dollars(this.state.paycheck, this.state.sensitive)}</li>
 | 
						|
                        <li>Budgeted Spend (minus savings): {dollars(budgetedSpend, this.state.sensitive)}</li>
 | 
						|
                        <li>Emergency Fund Size (recommended): {dollars(budgetedSpend * 3, this.state.sensitive)}</li>
 | 
						|
                    </ul>
 | 
						|
                    <div style={{ width: '30em' }}>
 | 
						|
                        <DonutChart labels={this.state.budget.map(x => x.label)} datasets={[
 | 
						|
                            {
 | 
						|
                                label: 'Categories',
 | 
						|
                                data: this.state.budget.map(x => x.children.reduce((acc, y) => acc + y.monthly, 0)),
 | 
						|
                            }
 | 
						|
                        ]} />
 | 
						|
                    </div>
 | 
						|
                    <ul>
 | 
						|
                        {this.state.budget.map(x => (
 | 
						|
                            <li>
 | 
						|
                                <div>{x.label} - {dollars(x.children.reduce((acc, x) => acc + x.monthly, 0), this.state.sensitive)}</div>
 | 
						|
                                <table>
 | 
						|
                                    <thead>
 | 
						|
                                        <tr>
 | 
						|
                                            <th>category</th>
 | 
						|
                                            <th>assigned (target)</th>
 | 
						|
                                            <th>last year (actual)</th>
 | 
						|
                                        </tr>
 | 
						|
                                    </thead>
 | 
						|
                                    <tbody>
 | 
						|
                                        {x.children.map(y => (
 | 
						|
                                            <tr>
 | 
						|
                                                <td>{y.label}</td>
 | 
						|
                                                <td>{dollars(y.monthly, this.state.sensitive)}</td>
 | 
						|
                                                <td>{dollars((categories[y.label] || []).reduce((acc, x) => acc + x.Outflow, 0) / 12, this.state.sensitive)}</td>
 | 
						|
                                            </tr>
 | 
						|
                                        ))}
 | 
						|
                                    </tbody>
 | 
						|
                                </table>
 | 
						|
                            </li>
 | 
						|
                        ))}
 | 
						|
                    </ul>
 | 
						|
                </div>
 | 
						|
            );
 | 
						|
        }
 | 
						|
 | 
						|
        return (
 | 
						|
            <div className="container">
 | 
						|
                <nav className="terminal-menu">
 | 
						|
                    <ul>
 | 
						|
                        <li><a href="#" onClick={() => this.setState({ view: 'query' })}>query</a></li>
 | 
						|
                        <li><a href="#" onClick={() => this.setState({ view: 'savings' })}>savings</a></li>
 | 
						|
                        <li><a href="#" onClick={() => this.setState({ view: 'budget' })}>budget</a></li>
 | 
						|
                        <li><a href="#" onClick={() => this.setState({ sensitive: !this.state.sensitive })}>sensitive</a></li>
 | 
						|
                    </ul>
 | 
						|
                </nav>
 | 
						|
                <UploadJSON onUpload={xs => this.setState({
 | 
						|
                    allTransactions: xs,
 | 
						|
                    filteredTransactions: select(this.state.query, xs, {
 | 
						|
                        caseSensitive: this.state.slxCaseSensitive,
 | 
						|
                        preferRegex: this.state.slxPreferRegex,
 | 
						|
                        slxDateKey: this.state.slxDateKey,
 | 
						|
                    }).sort(compileSort(this.state.sortExpr))
 | 
						|
                })} />
 | 
						|
                {view}
 | 
						|
            </div>
 | 
						|
        );
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
const QueryView = ({ sensitive, query, focus, allTransactions, transactions, saved, setState, slxConfig, sortExpr, transactionsView }) => (
 | 
						|
    <div>
 | 
						|
        <Query
 | 
						|
            query={query}
 | 
						|
            onChange={query => setState({
 | 
						|
                query,
 | 
						|
            })}
 | 
						|
            onFilter={() => setState({
 | 
						|
                filteredTransactions: select(query, allTransactions, slxConfig),
 | 
						|
            })}
 | 
						|
        />
 | 
						|
        <fieldset>
 | 
						|
            <legend>Sort</legend>
 | 
						|
            <div className="form-group">
 | 
						|
                <input type="text" value={sortExpr} onChange={e => setState({ sortExpr: e.target.value, })} />
 | 
						|
            </div>
 | 
						|
            <div className="form-group">
 | 
						|
                <button className="btn btn-default" onClick={() => setState({
 | 
						|
                    filteredTransactions: transactions.slice().sort(compileSort(sortExpr)),
 | 
						|
                })}>Sort</button>
 | 
						|
            </div>
 | 
						|
        </fieldset>
 | 
						|
        <hr />
 | 
						|
        <ScatterChart transactions={transactions} />
 | 
						|
        <hr />
 | 
						|
        <Transactions
 | 
						|
            transactionsView={transactionsView}
 | 
						|
            sensitive={sensitive}
 | 
						|
            transactions={transactions}
 | 
						|
            onClick={x => setState({
 | 
						|
                saved: { ...saved, [transactionKey(x)]: x.Outflow }
 | 
						|
            })}
 | 
						|
            onViewChange={transactionsView => setState({ transactionsView })}
 | 
						|
        />
 | 
						|
    </div>
 | 
						|
);
 | 
						|
 | 
						|
function classifyRate(x) {
 | 
						|
    if (x < 0.25) {
 | 
						|
        return 'needs improvement';
 | 
						|
    }
 | 
						|
    if (x < 0.50) {
 | 
						|
        return 'healthy';
 | 
						|
    }
 | 
						|
    if (x < 0.75) {
 | 
						|
        return 'rich';
 | 
						|
    }
 | 
						|
    if (x < 1.00) {
 | 
						|
        return 'wealthy';
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
const SavingsView = ({
 | 
						|
    sensitive,
 | 
						|
    categories,
 | 
						|
    savingsView,
 | 
						|
    inflowQuery,
 | 
						|
    outflowQuery,
 | 
						|
    inflowTransactions,
 | 
						|
    outflowTransactions,
 | 
						|
    onSavingsViewChange,
 | 
						|
    onInflowQueryChange,
 | 
						|
    onOutflowQueryChange,
 | 
						|
    onFilterInflow,
 | 
						|
    onFilterOutflow,
 | 
						|
    onFilterSavingsView,
 | 
						|
}) => {
 | 
						|
    const revenue = inflowTransactions.reduce((acc, x) => {
 | 
						|
        acc[x.Date.getMonth()] += x.Inflow;
 | 
						|
        return acc;
 | 
						|
    }, new Array(12).fill(0));
 | 
						|
 | 
						|
    const inflow = inflowTransactions.reduce((acc, x) => acc + x.Inflow, 0);
 | 
						|
    const outflow = outflowTransactions.reduce((acc, x) => acc + x.Outflow, 0);
 | 
						|
 | 
						|
    const delta25Sum = new Array(12).fill(0);
 | 
						|
    for (let i = 1; i < 12; i += 1) {
 | 
						|
        delta25Sum[i] = delta25Sum[i - 1] + revenue[i] * 0.25;
 | 
						|
    }
 | 
						|
 | 
						|
    const delta50Sum = new Array(12).fill(0);
 | 
						|
    for (let i = 1; i < 12; i += 1) {
 | 
						|
        delta50Sum[i] = delta50Sum[i - 1] + revenue[i] * 0.5;
 | 
						|
    }
 | 
						|
 | 
						|
    const delta75Sum = new Array(12).fill(0);
 | 
						|
    for (let i = 1; i < 12; i += 1) {
 | 
						|
        delta75Sum[i] = delta75Sum[i - 1] + revenue[i] * 0.75;
 | 
						|
    }
 | 
						|
 | 
						|
    return (
 | 
						|
        <section>
 | 
						|
            <fieldset>
 | 
						|
                <legend>Filtering</legend>
 | 
						|
                <div className="form-group">
 | 
						|
                    <label htmlFor="savings-view">Savings View</label>
 | 
						|
                    <input name="savings-view" type="text" placeholder="Savings View..." value={savingsView}
 | 
						|
                        onChange={e => onSavingsViewChange(e.target.value)} />
 | 
						|
                    <button className="btn btn-default" onClick={() => onFilterSavingsView()}>Apply</button>
 | 
						|
                </div>
 | 
						|
                <div className="form-group">
 | 
						|
                    <label htmlFor="inflow-query">Inflow Query</label>
 | 
						|
                    <input name="inflow-query" type="text" placeholder="Inflow query..." value={inflowQuery}
 | 
						|
                        onChange={e => onInflowQueryChange(e.target.value)} />
 | 
						|
                    <button className="btn btn-default" onClick={() => onFilterInflow()}>Filter</button>
 | 
						|
                </div>
 | 
						|
                <div className="form-group">
 | 
						|
                    <label htmlFor="outflow-query">Outflow Query</label>
 | 
						|
                    <input name="outflow-query" type="text" placeholder="Outflow query..." value={outflowQuery}
 | 
						|
                        onChange={e => onOutflowQueryChange(e.target.value)} />
 | 
						|
                    <button className="btn btn-default" onClick={() => onFilterOutflow()}>Filter</button>
 | 
						|
                </div>
 | 
						|
            </fieldset>
 | 
						|
            <ul>
 | 
						|
                <li>inflow: {dollars(inflow, sensitive)}</li>
 | 
						|
                <li>outflow: {dollars(outflow)}</li>
 | 
						|
                <li>savings: {dollars(inflow - outflow)}</li>
 | 
						|
                <li>rate: {parseFloat((inflow - outflow) / inflow * 100).toFixed(2) + "%"} ({classifyRate((inflow - outflow) / inflow)})</li>
 | 
						|
            </ul>
 | 
						|
            <SavingsRateLineChart rate={(inflow - outflow) / inflow} transactions={outflowTransactions} />
 | 
						|
            {/* O($1,000) */}
 | 
						|
            <StackedHistogram labels={months} datasets={Object.keys(categories).map(k => ({
 | 
						|
                label: k,
 | 
						|
                data: categories[k].reduce((acc, x) => {
 | 
						|
                    acc[x.Date.getMonth()] += x.Outflow;
 | 
						|
                    return acc;
 | 
						|
                }, new Array(12).fill(0)).map((x, i) => ({
 | 
						|
                    x: i,
 | 
						|
                    y: x,
 | 
						|
                }))
 | 
						|
            }))} />
 | 
						|
            {/* O($100) */}
 | 
						|
            <StackedHistogram labels={months} datasets={Object.keys(categories).map(k => ({
 | 
						|
                label: k,
 | 
						|
                data: categories[k].reduce((acc, x) => {
 | 
						|
                    acc[x.Date.getMonth()] += x.Outflow;
 | 
						|
                    return acc;
 | 
						|
                }, new Array(12).fill(0)).map((x, i) => ({
 | 
						|
                    x: i,
 | 
						|
                    y: x,
 | 
						|
                }))
 | 
						|
            }))} />
 | 
						|
            {/* O($10) */}
 | 
						|
            <StackedHistogram labels={months} datasets={Object.keys(categories).map(k => ({
 | 
						|
                label: k,
 | 
						|
                data: categories[k].reduce((acc, x) => {
 | 
						|
                    acc[x.Date.getMonth()] += x.Outflow;
 | 
						|
                    return acc;
 | 
						|
                }, new Array(12).fill(0)).map((x, i) => ({
 | 
						|
                    x: i,
 | 
						|
                    y: x,
 | 
						|
                }))
 | 
						|
            }))} />
 | 
						|
            {/* <GenLineChart x="X" y="Y" datasets={[
 | 
						|
                {
 | 
						|
                    label: '25%',
 | 
						|
                    data: delta25Sum.map((x, i) => ({
 | 
						|
                        x: i,
 | 
						|
                        y: x,
 | 
						|
                    })),
 | 
						|
                },
 | 
						|
                {
 | 
						|
                    label: '50%',
 | 
						|
                    data: delta50Sum.map((x, i) => ({
 | 
						|
                        x: i,
 | 
						|
                        y: x,
 | 
						|
                    })),
 | 
						|
                },
 | 
						|
                {
 | 
						|
                    label: '75%',
 | 
						|
                    data: delta75Sum.map((x, i) => ({
 | 
						|
                        x: i,
 | 
						|
                        y: x,
 | 
						|
                    })),
 | 
						|
                },
 | 
						|
            ]} /> */}
 | 
						|
            <hr />
 | 
						|
            <table>
 | 
						|
                <thead>
 | 
						|
                    <tr>
 | 
						|
                        <th>Month</th>
 | 
						|
                        <th>Delta (25%)</th>
 | 
						|
                        <th>Delta (50%)</th>
 | 
						|
                        <th>Delta (75%)</th>
 | 
						|
                    </tr>
 | 
						|
                </thead>
 | 
						|
                <tbody>
 | 
						|
                    {months.map((x, i) => (
 | 
						|
                        <tr key={i}>
 | 
						|
                            <td>{x}</td>
 | 
						|
                            <td>{dollars(delta25Sum[i], sensitive)}</td>
 | 
						|
                            <td>{dollars(delta50Sum[i], sensitive)}</td>
 | 
						|
                            <td>{dollars(delta75Sum[i], sensitive)}</td>
 | 
						|
                        </tr>
 | 
						|
                    ))}
 | 
						|
                </tbody>
 | 
						|
            </table>
 | 
						|
        </section>
 | 
						|
    );
 | 
						|
};
 | 
						|
 | 
						|
const Calculator = ({ sensitive, saved, onClear }) => (
 | 
						|
    <div>
 | 
						|
        <ul>
 | 
						|
            {Object.keys(saved).map(k => (
 | 
						|
                <li key={k}>
 | 
						|
                    {dollars(saved[k], sensitive)} {k}
 | 
						|
                </li>
 | 
						|
            ))}
 | 
						|
        </ul>
 | 
						|
        <p>{dollars(savedSum, sensitive)}</p>
 | 
						|
        <button className="btn btn-default" onClick={() => onClear()}>clear</button>
 | 
						|
    </div>
 | 
						|
);
 | 
						|
 | 
						|
/**
 | 
						|
 * Table rendering information about transactions bucketed by its order of
 | 
						|
 * magnitude.
 | 
						|
 */
 | 
						|
const Magnitable = ({ sensitive, label, transactions }) => {
 | 
						|
    const categories = transactions.reduce((acc, x) => {
 | 
						|
        if (x.Category === '') {
 | 
						|
            return acc;
 | 
						|
        }
 | 
						|
        if (!(x.Category in acc)) {
 | 
						|
            acc[x.Category] = 0;
 | 
						|
        }
 | 
						|
        acc[x.Category] += x.Outflow;
 | 
						|
        return acc;
 | 
						|
    }, {});
 | 
						|
 | 
						|
    // Sort category keys by sum decreasing.
 | 
						|
    const keys = [...Object.keys(categories)].sort((x, y) => {
 | 
						|
        if (categories[x] < categories[y]) {
 | 
						|
            return 1;
 | 
						|
        } else if (categories[x] > categories[y]) {
 | 
						|
            return -1;
 | 
						|
        } else {
 | 
						|
            return 0;
 | 
						|
        }
 | 
						|
    });
 | 
						|
 | 
						|
    return (
 | 
						|
        <React.Fragment>
 | 
						|
            {keys.map(k => (
 | 
						|
                <tr style={{ backgroundColor: '#F0F8FF' }}>
 | 
						|
                    <td>{k}</td><td>{dollars(categories[k], sensitive)}</td>
 | 
						|
                </tr>
 | 
						|
            ))}
 | 
						|
        </React.Fragment>
 | 
						|
    );
 | 
						|
};
 | 
						|
 | 
						|
/**
 | 
						|
 * Calculates and renders various aggregates over an input list of transactions.
 | 
						|
 */
 | 
						|
const AggregateTable = ({ sensitive, focus, onFocus, transactions }) => {
 | 
						|
    const net = transactions.reduce((acc, x) => acc + x.Inflow - x.Outflow, 0);
 | 
						|
    const sum = transactions.reduce((acc, x) => acc + x.Outflow, 0);
 | 
						|
    const buckets = transactions.reduce((acc, x) => {
 | 
						|
        const order = magnitude(x.Outflow);
 | 
						|
        const bucket = Math.pow(10, order);
 | 
						|
        acc[bucket].push(x);
 | 
						|
        return acc;
 | 
						|
    }, { 0.1: [], 0: [], 1: [], 10: [], 100: [], 1000: [] });
 | 
						|
 | 
						|
    return (
 | 
						|
        <div>
 | 
						|
            <table>
 | 
						|
                <caption>Aggregations</caption>
 | 
						|
                <thead>
 | 
						|
                    <tr>
 | 
						|
                        <th>function</th>
 | 
						|
                        <th>value</th>
 | 
						|
                    </tr>
 | 
						|
                </thead>
 | 
						|
                <tbody>
 | 
						|
                    <tr><td>net</td><td>{dollars(net, sensitive)}</td></tr>
 | 
						|
                    <tr onClick={() => onFocus('sum')}><td>sum</td><td>{dollars(sum, sensitive)}</td></tr>
 | 
						|
                    {focus.sum && <Magnitable sensitive={sensitive} label="sum" transactions={transactions} />}
 | 
						|
                    <tr><td>per day</td><td>{dollars(sum / 365, sensitive)}</td></tr>
 | 
						|
                    <tr><td>per week</td><td>{dollars(sum / 52, sensitive)}</td></tr>
 | 
						|
                    <tr><td>per month</td><td>{dollars(sum / 12, sensitive)}</td></tr>
 | 
						|
                    <tr onClick={() => onFocus(1000)}><td>Σ Θ($1,000)</td><td>{dollars(buckets[1000].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
 | 
						|
                    {(focus[1000]) && <Magnitable sensitive={sensitive} label="$1,000" transactions={buckets[1000]} />}
 | 
						|
                    <tr onClick={() => onFocus(100)}><td>Σ Θ($100)</td><td>{dollars(buckets[100].reduce((acc, x, sensitive) => acc + x.Outflow, 0), sensitive)}</td></tr>
 | 
						|
                    {(focus[100]) && <Magnitable sensitive={sensitive} label="$100" transactions={buckets[100]} />}
 | 
						|
                    <tr onClick={() => onFocus(10)}><td>Σ Θ($10)</td><td>{dollars(buckets[10].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
 | 
						|
                    {(focus[10]) && <Magnitable sensitive={sensitive} label="$10" transactions={buckets[10]} />}
 | 
						|
                    <tr onClick={() => onFocus(1)}><td>Σ Θ($1)</td><td>{dollars(buckets[1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
 | 
						|
                    {(focus[1]) && <Magnitable sensitive={sensitive} label="$1.00" transactions={buckets[1]} />}
 | 
						|
                    <tr onClick={() => onFocus(0.1)}><td>Σ Θ($0.10)</td><td>{dollars(buckets[0.1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr>
 | 
						|
                    {(focus[0.1]) && <Magnitable sensitive={sensitive} label="$0.10" transactions={buckets[0.1]} />}
 | 
						|
                    <tr><td>average</td><td>{dollars(sum / transactions.length, sensitive)}</td></tr>
 | 
						|
                    <tr><td>count</td><td>{transactions.length}</td></tr>
 | 
						|
                </tbody>
 | 
						|
            </table>
 | 
						|
        </div>
 | 
						|
    );
 | 
						|
};
 | 
						|
 | 
						|
const Query = ({ query, onChange, onFilter }) => (
 | 
						|
    <fieldset>
 | 
						|
        <legend>Query</legend>
 | 
						|
        <div className="form-group">
 | 
						|
            <input name="query" type="text" value={query} onChange={e => onChange(e.target.value)} />
 | 
						|
        </div>
 | 
						|
        <div className="form-group">
 | 
						|
            <button className="btn btn-default" onClick={() => onFilter()}>Filter</button>
 | 
						|
        </div>
 | 
						|
    </fieldset>
 | 
						|
);
 | 
						|
 | 
						|
const tableHeaders = [
 | 
						|
    'Account',
 | 
						|
    'Category',
 | 
						|
    'Date',
 | 
						|
    'Inflow',
 | 
						|
    'Outflow',
 | 
						|
    'Payee',
 | 
						|
    'Memo',
 | 
						|
];
 | 
						|
 | 
						|
const Transactions = ({ sensitive, transactions, onSort, onClick, onViewChange, transactionsView }) => {
 | 
						|
    let view = null;
 | 
						|
    if (transactionsView === 'table') {
 | 
						|
        view = (
 | 
						|
            <table>
 | 
						|
                <thead>
 | 
						|
                    <tr>
 | 
						|
                        {tableHeaders.map(x => <th>{x}</th>)}
 | 
						|
                    </tr>
 | 
						|
                </thead>
 | 
						|
                <tbody>
 | 
						|
                    {transactions.map(x => (
 | 
						|
                        <tr onClick={() => onClick(x)}>
 | 
						|
                            <td>{x.Account}</td>
 | 
						|
                            <td>{x.Category}</td>
 | 
						|
                            <td>{x.Date.toLocaleDateString()}</td>
 | 
						|
                            <td>{dollars(x.Inflow, sensitive)}</td>
 | 
						|
                            <td>{dollars(x.Outflow, sensitive)}</td>
 | 
						|
                            <td>{x.Payee}</td>
 | 
						|
                            <td>{x.Memo}</td>
 | 
						|
                        </tr>
 | 
						|
                    ))}
 | 
						|
                </tbody>
 | 
						|
            </table>
 | 
						|
        );
 | 
						|
    }
 | 
						|
    else if (transactionsView === 'csv') {
 | 
						|
        view = (
 | 
						|
            <code>{tableHeaders.join(',') + '\n' + transactions.map(x => tableHeaders.map(k => x[k]).join(',')).join("\n")}</code>
 | 
						|
        );
 | 
						|
    }
 | 
						|
    else if (transactionsView === 'json') {
 | 
						|
        view = (
 | 
						|
            <code>{JSON.stringify(transactions)}</code>
 | 
						|
        );
 | 
						|
    }
 | 
						|
 | 
						|
    return (
 | 
						|
        <div>
 | 
						|
            <legend>Transactions</legend>
 | 
						|
            <div className="btn-group">
 | 
						|
                <button onClick={() => onViewChange('table')} className="btn btn-default btn-ghost">Table</button>
 | 
						|
                <button onClick={() => onViewChange('csv')} className="btn btn-default btn-ghost">CSV</button>
 | 
						|
                <button onClick={() => onViewChange('json')} className="btn btn-default btn-ghost">JSON</button>
 | 
						|
            </div>
 | 
						|
            {view}
 | 
						|
        </div>
 | 
						|
    );
 | 
						|
}
 | 
						|
 | 
						|
const domContainer = document.querySelector('#mount');
 | 
						|
const root = ReactDOM.createRoot(domContainer);
 | 
						|
 | 
						|
root.render(<App />);
 |