about summary refs log tree commit diff
path: root/users/wpcarro/ynabsql/dataviz/components.js
diff options
context:
space:
mode:
Diffstat (limited to 'users/wpcarro/ynabsql/dataviz/components.js')
-rw-r--r--users/wpcarro/ynabsql/dataviz/components.js1002
1 files changed, 938 insertions, 64 deletions
diff --git a/users/wpcarro/ynabsql/dataviz/components.js b/users/wpcarro/ynabsql/dataviz/components.js
index 40653189785e..c385e84e6311 100644
--- a/users/wpcarro/ynabsql/dataviz/components.js
+++ b/users/wpcarro/ynabsql/dataviz/components.js
@@ -1,10 +1,131 @@
+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;
+    var newYear = new Date(x.getFullYear(), 0, 1);
+    var day = newYear.getDay() - dowOffset; //the day of week the year begins on
+    day = (day >= 0 ? day : day + 7);
+    var daynum = Math.floor((x.getTime() - newYear.getTime() -
+        (x.getTimezoneOffset() - newYear.getTimezoneOffset()) * 60000) / 86400000) + 1;
+    var 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) {
+            nYear = new Date(x.getFullYear() + 1, 0, 1);
+            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;
+}
+  
+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 categories = data.data.transactions.reduce((xs, x) => { xs[x.Category] = null; return xs; }, {});
-
+  
+const categories = data.data.transactions.reduce((xs, x) => { 
+    if (!(x.Category in xs)) {
+        xs[x.Category] = [];
+    }
+    xs[x.Category].push(x);
+    return xs;
+}, {});
+  
+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 sortTransactions(transactions) {
     return [...transactions].sort((x, y) => {
         if (x.Outflow < y.Outflow) {
@@ -16,7 +137,7 @@ function sortTransactions(transactions) {
         }
     });
 }
-
+  
 function transactionKey(x) {
     const keys = [
         'Account',
@@ -31,23 +152,461 @@ function transactionKey(x) {
     ];
     return keys.map(k => x[k]).join('|');
 }
+  
+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/ after:"01/01/2022" before:"01/01/2023"';
+        const savingsView = 'after:"01/01/2022" before:"01/01/2023"';
+        const inflowQuery = 'Account:/checking/';
+        const outflowQuery = 'Account:/checking/ -Category:/(stocks|crypto)/';
 
         this.state = {
             query,
+            sensitive: false,
             transactions: select(query, data.data.transactions),
             saved: {},
             focus: {
+                sum: false,
                 1000: false,
                 100: false,
                 10: false,
                 1: false,
                 0.1: false,
             },
+            budget: [
+                { 
+                    label: 'Flexible', 
+                    children: [
+                        { label: 'groceries', savings: false, monthly: 400.00 },
+                        { label: 'eating out', savings: false, monthly: 200.00 },
+                        { label: 'alcohol', savings: false, monthly: 200.00 },
+                        { label: 'household items', savings: false, monthly: 50.00 },
+                        { label: 'toiletries', savings: false, monthly: 200.00 / 12 },
+                        { label: 'haircuts', savings: false, monthly: 400.00 / 12 },
+                        { label: 'gasoline', savings: false, monthly: 100.00 },
+                        { label: 'parking', savings: false, monthly: 10.00 },
+                        { label: 'ride services', savings: false, monthly: 50.00 },
+                        { label: 'LMNT', savings: false, monthly: 45.00 },
+                        { label: 'books', savings: false, monthly: 25.00 },
+                        { label: 'vacation', savings: false, monthly: 4000.00 / 12 },
+                        { label: 'reimbursement', savings: false, monthly: 0.00 },
+                    ],
+                },
+                { 
+                    label: 'Fixed', 
+                    children: [
+                        { label: 'rent', savings: false, monthly: 3100.00 },
+                        { label: 'electric', savings: false, monthly: 50.00 },
+                        { label: 'gas', 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', savings: false, monthly: 0.00 },
+                    ],
+                },
+            ],
+            paycheck: 6000.00,
+            view: 'budget',
+            savingsView,
+            inflowQuery,
+            outflowQuery,
+            inflowTransactions: select(inflowQuery, select(savingsView, data.data.transactions)),
+            outflowTransactions: select(outflowQuery, select(savingsView, data.data.transactions)),
         };
     }
 
@@ -55,61 +614,372 @@ class App extends React.Component {
         const sum = this.state.transactions.reduce((acc, { Outflow }) => acc + Outflow, 0);
         const savedSum = Object.values(this.state.saved).reduce((acc, sum) => acc + sum, 0);
 
-        return (
-            <div className="container">
-                <select>
-                    {Object.keys(categories).map(x => (
-                        <option value={x} key={x}>{x}</option>
-                    ))}
-                </select>
-                <Input 
-                    query={this.state.query} 
-                    onChange={query => this.setState({ 
-                        query,
+        let view = null;
+        if (this.state.view === 'query') {
+            view = (
+                <QueryView
+                    sensitive={this.state.sensitive}
+                    query={this.state.query}
+                    focus={this.state.focus}
+                    transactions={this.state.transactions}
+                    saved={this.state.saved}
+                    setState={this.setState.bind(this)}
+                />
+            );
+        } else if (this.state.view === 'savings') {
+            view = (
+                <SavingsView
+                    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, data.data.transactions)),
                     })}
-                    onFilter={() => this.setState({
-                        transactions: select(this.state.query, data.data.transactions),
-                    })} 
-                    onSave={() => this.setState({
-                        saved: { ...this.state.saved, [this.state.query]: sum }
+                    onFilterOutflow={() => this.setState({
+                        outflowTransactions: select(this.state.outflowQuery, select(this.state.savingsView, data.data.transactions)),
                     })}
-                />
-                <AggregateTable 
-                    focus={this.state.focus} 
-                    onFocus={(n) => this.setState({
-                        focus: { ...this.state.focus, [n]: !this.state.focus[n] },
+                    onFilterSavingsView={() => this.setState({
+                        inflowTransactions: select(this.state.inflowQuery, select(this.state.savingsView, data.data.transactions)),
+                        outflowTransactions: select(this.state.outflowQuery, select(this.state.savingsView, data.data.transactions)),
                     })}
-                    transactions={this.state.transactions} 
+                    onSavingsViewChange={x => this.setState({ savingsView: x })}
+                    onInflowQueryChange={x => this.setState({ inflowQuery: x })}
+                    onOutflowQueryChange={x => this.setState({ outflowQuery: x })}
                 />
-                <hr />
+            );
+        } 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>
-                        {Object.keys(this.state.saved).map(k => (
-                            <li key={k}>
-                                {usd.format(this.state.saved[k])} {k}
+                        {this.state.budget.map(x => (
+                            <li>
+                                <div>{x.label} - {dollars(x.children.reduce((acc, x) => acc + x.monthly, 0), this.state.sensitive)}</div>
+                                <ul>{x.children.map(y => <li>{y.label} - {dollars(y.monthly, this.state.sensitive)}</li>)}</ul>
                             </li>
                         ))}
                     </ul>
-                    <p>{usd.format(savedSum)}</p>
-                    <button className="btn btn-default" onClick={() => this.setState({ saved: {} })}>clear</button>
                 </div>
-                <hr />
-                <Table 
-                    transactions={sortTransactions(this.state.transactions)} 
-                    onClick={x => this.setState({
-                        saved: { ...this.state.saved, [transactionKey(x)]: x.Outflow }
-                    })}
-                />
+            );
+        }
+
+        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>
+                {view}
             </div>
         );
     }
 }
 
+const QueryView = ({ sensitive, query, focus, transactions, saved, setState }) => (
+    <div>
+        <Query
+            query={query}
+            onChange={query => setState({
+                query,
+            })}
+            onFilter={() => setState({
+                transactions: select(query, data.data.transactions),
+            })}
+        />
+        <hr />
+        <ScatterChart transactions={transactions} />
+        <hr />
+        <Transactions
+            sensitive={sensitive}
+            transactions={sortTransactions(transactions)}
+            onClick={x => setState({
+                saved: { ...saved, [transactionKey(x)]: x.Outflow }
+            })}
+        />
+    </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,
+    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>
+);
+
+const Forecast = ({ sensitive, paycheck, onPaycheck }) => {
+    const getModel = k => {
+        const max = paycheck / 3;
+        const lastYear = getSum(select(`Account:/checking/ after:"01/01/2022" before:"01/01/2023" ${queries[k]}`, data.data.transactions)) / 12;
+        return {
+            max,
+            lastYear,
+            surplus: max - lastYear,
+        };
+    };
+
+    const housing = getModel('housing');
+    const food = getModel('food');
+    const commute = getModel('commute');
+
+    return (
+        <fieldset>
+            <legend>Forecasting</legend>
+            <label htmlFor="paycheck">Paycheck</label>
+            <input
+                name="paycheck" type="text" placeholder="Last paycheck ($USD)"
+                value={paycheck}
+                onChange={e => onPaycheck(parseFloat(e.target.value))} />
+            <table>
+                <thead>
+                    <tr>
+                        <th>category</th>
+                        <th>max</th>
+                        <th>last year</th>
+                        <th>surplus</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr>
+                        <td>Housing</td>
+                        <td>{dollars(housing.max, sensitive)}</td>
+                        <td>{dollars(housing.lastYear, sensitive)}</td>
+                        <td>{dollars(housing.surplus, sensitive)}</td>
+                    </tr>
+                    <tr>
+                        <td>Food</td>
+                        <td>{dollars(food.max, sensitive)}</td>
+                        <td>{dollars(food.lastYear, sensitive)}</td>
+                        <td>{dollars(food.surplus, sensitive)}</td>
+                    </tr>
+                    <tr>
+                        <td>Commute</td>
+                        <td>{dollars(commute.max, sensitive)}</td>
+                        <td>{dollars(commute.lastYear, sensitive)}</td>
+                        <td>{dollars(commute.surplus, sensitive)}</td>
+                    </tr>
+                    <tr>
+                        <td>Sum</td>
+                        <td>-</td>
+                        <td>-</td>
+                        <td>{dollars(housing.surplus + food.surplus + commute.surplus, sensitive)}</td>
+                    </tr>
+                </tbody>
+            </table>
+        </fieldset>
+    );
+};
+
 /**
  * Table rendering information about transactions bucketed by its order of
  * magnitude.
  */
-const Magnitable = ({ label, transactions }) => {
+const Magnitable = ({ sensitive, label, transactions }) => {
     const categories = transactions.reduce((acc, x) => {
         if (x.Category === '') {
             return acc;
@@ -136,7 +1006,7 @@ const Magnitable = ({ label, transactions }) => {
         <React.Fragment>
             {keys.map(k => (
                 <tr style={{backgroundColor: '#F0F8FF'}}>
-                    <td>{k}</td><td>{usd.format(categories[k])}</td>
+                    <td>{k}</td><td>{dollars(categories[k], sensitive)}</td>
                 </tr>
             ))}
         </React.Fragment>
@@ -146,10 +1016,11 @@ const Magnitable = ({ label, transactions }) => {
 /**
  * Calculates and renders various aggregates over an input list of transactions.
  */
-const AggregateTable = ({ focus, onFocus, 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 = Math.floor(Math.log(x.Outflow) / Math.LN10 + 0.000000001);
+        const order = magnitude(x.Outflow);
         const bucket = Math.pow(10, order);
         acc[bucket].push(x);
         return acc;
@@ -166,21 +1037,23 @@ const AggregateTable = ({ focus, onFocus, transactions }) => {
                     </tr>
                 </thead>
                 <tbody>
-                    <tr><td>sum</td><td>{usd.format(sum)}</td></tr>
-                    <tr><td>per day</td><td>{usd.format(sum / 365)}</td></tr>
-                    <tr><td>per week</td><td>{usd.format(sum / 52)}</td></tr>
-                    <tr><td>per month</td><td>{usd.format(sum / 12)}</td></tr>
-                    <tr onClick={() => onFocus(1000)}><td>Σ Θ($1,000)</td><td>{usd.format(buckets[1000].reduce((acc, x) => acc + x.Outflow, 0))}</td></tr>
-                    {(focus[1000]) && <Magnitable label="$1,000" transactions={buckets[1000]} />}
-                    <tr onClick={() => onFocus(100)}><td>Σ Θ($100)</td><td>{usd.format(buckets[100].reduce((acc, x) => acc + x.Outflow, 0))}</td></tr>
-                    {(focus[100]) && <Magnitable label="$100" transactions={buckets[100]} />}
-                    <tr onClick={() => onFocus(10)}><td>Σ Θ($10)</td><td>{usd.format(buckets[10].reduce((acc, x) => acc + x.Outflow, 0))}</td></tr>
-                    {(focus[10]) && <Magnitable label="$10" transactions={buckets[10]} />}
-                    <tr onClick={() => onFocus(1)}><td>Σ Θ($1)</td><td>{usd.format(buckets[1].reduce((acc, x) => acc + x.Outflow, 0))}</td></tr>
-                    {(focus[1]) && <Magnitable label="$1.00" transactions={buckets[1]} />}
-                    <tr onClick={() => onFocus(0.1)}><td>Σ Θ($0.10)</td><td>{usd.format(buckets[0.1].reduce((acc, x) => acc + x.Outflow, 0))}</td></tr>
-                    {(focus[0.1]) && <Magnitable label="$0.10" transactions={buckets[0.1]} />}
-                    <tr><td>average</td><td>{usd.format(sum / transactions.length)}</td></tr>
+                    <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>
@@ -188,20 +1061,19 @@ const AggregateTable = ({ focus, onFocus, transactions }) => {
     );
 };
 
-const Input = ({ query, onChange, onFilter, onSave }) => (
+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 className="btn-group">
                 <button className="btn btn-default" onClick={() => onFilter()}>Filter</button>
-                <button className="btn btn-default" onClick={() => onSave()}>Save</button>
             </div>
         </div>
     </fieldset>
 );
 
-const Table = ({ transactions, onClick }) => (
+const Transactions = ({ sensitive, transactions, onClick }) => (
     <table>
         <caption>Transactions</caption>
         <thead>
@@ -209,6 +1081,7 @@ const Table = ({ transactions, onClick }) => (
                 <th>Account</th>
                 <th>Category</th>
                 <th>Date</th>
+                <th>Inflow</th>
                 <th>Outflow</th>
                 <th>Payee</th>
                 <th>Memo</th>
@@ -220,7 +1093,8 @@ const Table = ({ transactions, onClick }) => (
                     <td>{x.Account}</td>
                     <td>{x.Category}</td>
                     <td>{x.Date.toLocaleDateString()}</td>
-                    <td>{usd.format(x.Outflow)}</td>
+                    <td>{dollars(x.Inflow, sensitive)}</td>
+                    <td>{dollars(x.Outflow, sensitive)}</td>
                     <td>{x.Payee}</td>
                     <td>{x.Memo}</td>
                 </tr>
@@ -229,7 +1103,7 @@ const Table = ({ transactions, onClick }) => (
     </table>
 );
 
-const domContainer = document.querySelector('#react-mount');
+const domContainer = document.querySelector('#mount');
 const root = ReactDOM.createRoot(domContainer);
 
-root.render(<App />);
\ No newline at end of file
+root.render(<App />);