From b3a91ce57b2b55fe0ad246425f6528caef11a1e7 Mon Sep 17 00:00:00 2001 From: William Carroll Date: Sun, 22 Jan 2023 21:14:38 -0800 Subject: feat(wpcarro/ynabsql): Proof-of-concept demo Hacked this together during my week-off while I was in Telluride, CO. The git history is quite sloppy; so is some of the code. But it (mostly) works as a demo, and that was the point. Change-Id: Icfbc277090b69a802c00becdbd162652e4e8e156 Reviewed-on: https://cl.tvl.fyi/c/depot/+/7904 Reviewed-by: wpcarro Tested-by: BuildkiteCI Autosubmit: wpcarro --- users/wpcarro/ynabsql/dataviz/components.js | 1002 +++++++++++++++++++++++++-- 1 file changed, 938 insertions(+), 64 deletions(-) (limited to 'users/wpcarro/ynabsql/dataviz/components.js') 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 ; + } +} + +/** + * 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 ; + } +} + +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 ; + } +} + +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 ; + } +} + +/** + * 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 ; + } +} 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 ( -
- - this.setState({ - query, + let view = null; + if (this.state.view === 'query') { + view = ( + + ); + } else if (this.state.view === 'savings') { + view = ( + 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)), })} - /> - 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 })} /> -
+ ); + } 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 = (
+
+ Details +
+ + +
+
+ + +
+
+
    +
  • Available Spend: {dollars(this.state.paycheck * 2, this.state.sensitive)}
  • +
  • Target Spend: {dollars(this.state.paycheck, this.state.sensitive)}
  • +
  • Budgeted Spend (minus savings): {dollars(budgetedSpend, this.state.sensitive)}
  • +
  • Emergency Fund Size (recommended): {dollars(budgetedSpend * 3, this.state.sensitive)}
  • +
+
+ x.label)} datasets={[ + { + label: 'Categories', + data: this.state.budget.map(x => x.children.reduce((acc, y) => acc + y.monthly, 0)), + } + ]} /> +
    - {Object.keys(this.state.saved).map(k => ( -
  • - {usd.format(this.state.saved[k])} {k} + {this.state.budget.map(x => ( +
  • +
    {x.label} - {dollars(x.children.reduce((acc, x) => acc + x.monthly, 0), this.state.sensitive)}
    +
      {x.children.map(y =>
    • {y.label} - {dollars(y.monthly, this.state.sensitive)}
    • )}
  • ))}
-

{usd.format(savedSum)}

-
-
- this.setState({ - saved: { ...this.state.saved, [transactionKey(x)]: x.Outflow } - })} - /> + ); + } + + return ( + ); } } +const QueryView = ({ sensitive, query, focus, transactions, saved, setState }) => ( +
+ setState({ + query, + })} + onFilter={() => setState({ + transactions: select(query, data.data.transactions), + })} + /> +
+ +
+ setState({ + saved: { ...saved, [transactionKey(x)]: x.Outflow } + })} + /> +
+); + +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 ( +
+
+ Filtering +
+ + onSavingsViewChange(e.target.value)} /> + +
+
+ + onInflowQueryChange(e.target.value)} /> + +
+
+ + onOutflowQueryChange(e.target.value)} /> + +
+
+
    +
  • inflow: {dollars(inflow, sensitive)}
  • +
  • outflow: {dollars(outflow)}
  • +
  • savings: {dollars(inflow - outflow)}
  • +
  • rate: {parseFloat((inflow - outflow) / inflow * 100).toFixed(2)+"%"} ({classifyRate((inflow - outflow) / inflow)})
  • +
+ + {/* O($1,000) */} + ({ + 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) */} + ({ + 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) */} + ({ + 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, + })) + }))} /> + {/* ({ + 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, + })), + }, + ]} /> */} +
+
+ + + + + + + + + + {months.map((x, i) => ( + + + + + + + ))} + +
MonthDelta (25%)Delta (50%)Delta (75%)
{x}{dollars(delta25Sum[i], sensitive)}{dollars(delta50Sum[i], sensitive)}{dollars(delta75Sum[i], sensitive)}
+ + ); +}; + +const Calculator = ({ sensitive, saved, onClear }) => ( +
+
    + {Object.keys(saved).map(k => ( +
  • + {dollars(saved[k], sensitive)} {k} +
  • + ))} +
+

{dollars(savedSum, sensitive)}

+ +
+); + +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 ( +
+ Forecasting + + onPaycheck(parseFloat(e.target.value))} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
categorymaxlast yearsurplus
Housing{dollars(housing.max, sensitive)}{dollars(housing.lastYear, sensitive)}{dollars(housing.surplus, sensitive)}
Food{dollars(food.max, sensitive)}{dollars(food.lastYear, sensitive)}{dollars(food.surplus, sensitive)}
Commute{dollars(commute.max, sensitive)}{dollars(commute.lastYear, sensitive)}{dollars(commute.surplus, sensitive)}
Sum--{dollars(housing.surplus + food.surplus + commute.surplus, sensitive)}
+
+ ); +}; + /** * 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 }) => { {keys.map(k => ( - {k}{usd.format(categories[k])} + {k}{dollars(categories[k], sensitive)} ))} @@ -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 }) => { - sum{usd.format(sum)} - per day{usd.format(sum / 365)} - per week{usd.format(sum / 52)} - per month{usd.format(sum / 12)} - onFocus(1000)}>Σ Θ($1,000){usd.format(buckets[1000].reduce((acc, x) => acc + x.Outflow, 0))} - {(focus[1000]) && } - onFocus(100)}>Σ Θ($100){usd.format(buckets[100].reduce((acc, x) => acc + x.Outflow, 0))} - {(focus[100]) && } - onFocus(10)}>Σ Θ($10){usd.format(buckets[10].reduce((acc, x) => acc + x.Outflow, 0))} - {(focus[10]) && } - onFocus(1)}>Σ Θ($1){usd.format(buckets[1].reduce((acc, x) => acc + x.Outflow, 0))} - {(focus[1]) && } - onFocus(0.1)}>Σ Θ($0.10){usd.format(buckets[0.1].reduce((acc, x) => acc + x.Outflow, 0))} - {(focus[0.1]) && } - average{usd.format(sum / transactions.length)} + net{dollars(net, sensitive)} + onFocus('sum')}>sum{dollars(sum, sensitive)} + {focus.sum && } + per day{dollars(sum / 365, sensitive)} + per week{dollars(sum / 52, sensitive)} + per month{dollars(sum / 12, sensitive)} + onFocus(1000)}>Σ Θ($1,000){dollars(buckets[1000].reduce((acc, x) => acc + x.Outflow, 0), sensitive)} + {(focus[1000]) && } + onFocus(100)}>Σ Θ($100){dollars(buckets[100].reduce((acc, x, sensitive) => acc + x.Outflow, 0), sensitive)} + {(focus[100]) && } + onFocus(10)}>Σ Θ($10){dollars(buckets[10].reduce((acc, x) => acc + x.Outflow, 0), sensitive)} + {(focus[10]) && } + onFocus(1)}>Σ Θ($1){dollars(buckets[1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)} + {(focus[1]) && } + onFocus(0.1)}>Σ Θ($0.10){dollars(buckets[0.1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)} + {(focus[0.1]) && } + average{dollars(sum / transactions.length, sensitive)} count{transactions.length} @@ -188,20 +1061,19 @@ const AggregateTable = ({ focus, onFocus, transactions }) => { ); }; -const Input = ({ query, onChange, onFilter, onSave }) => ( +const Query = ({ query, onChange, onFilter }) => (
Query
onChange(e.target.value)} />
-
); -const Table = ({ transactions, onClick }) => ( +const Transactions = ({ sensitive, transactions, onClick }) => ( @@ -209,6 +1081,7 @@ const Table = ({ transactions, onClick }) => ( + @@ -220,7 +1093,8 @@ const Table = ({ transactions, onClick }) => ( - + + @@ -229,7 +1103,7 @@ const Table = ({ transactions, onClick }) => (
Transactions
Account Category DateInflow Outflow Payee Memo{x.Account} {x.Category} {x.Date.toLocaleDateString()}{usd.format(x.Outflow)}{dollars(x.Inflow, sensitive)}{dollars(x.Outflow, sensitive)} {x.Payee} {x.Memo}
); -const domContainer = document.querySelector('#react-mount'); +const domContainer = document.querySelector('#mount'); const root = ReactDOM.createRoot(domContainer); -root.render(); \ No newline at end of file +root.render(); -- cgit 1.4.1