diff options
Diffstat (limited to 'users/wpcarro/ynabsql/dataviz/components.js')
-rw-r--r-- | users/wpcarro/ynabsql/dataviz/components.js | 1002 |
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 />); |