diff options
Diffstat (limited to 'users/wpcarro/ynabsql')
-rw-r--r-- | users/wpcarro/ynabsql/dataviz/chart.js | 66 | ||||
-rw-r--r-- | users/wpcarro/ynabsql/dataviz/components.js | 1109 | ||||
-rw-r--r-- | users/wpcarro/ynabsql/dataviz/components.jsx | 21 | ||||
-rw-r--r-- | users/wpcarro/ynabsql/dataviz/index.html | 12 |
4 files changed, 12 insertions, 1196 deletions
diff --git a/users/wpcarro/ynabsql/dataviz/chart.js b/users/wpcarro/ynabsql/dataviz/chart.js deleted file mode 100644 index 7ec8f00aae06..000000000000 --- a/users/wpcarro/ynabsql/dataviz/chart.js +++ /dev/null @@ -1,66 +0,0 @@ -const colors = { - red: 'rgb(255, 45, 70)', - green: 'rgb(75, 192, 35)', -}; - -//////////////////////////////////////////////////////////////////////////////// -// Main -//////////////////////////////////////////////////////////////////////////////// - -const mount = document.getElementById('mount'); - -const chart = new Chart(mount, { - type: 'scatter', - data: { - datasets: [ - { - label: 'Revenue', - data: data.data.transactions.filter(x => x.Inflow > 0).map(x => ({ - x: x.Date, - y: x.Inflow, - metadata: x, - })), - backgroundColor: colors.green, - }, - { - label: 'Expenses', - data: data.data.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})`; - }, - }, - }, - }, - }, -}); diff --git a/users/wpcarro/ynabsql/dataviz/components.js b/users/wpcarro/ynabsql/dataviz/components.js deleted file mode 100644 index c385e84e6311..000000000000 --- a/users/wpcarro/ynabsql/dataviz/components.js +++ /dev/null @@ -1,1109 +0,0 @@ -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) => { - 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) { - return 1; - } else if (x.Outflow > y.Outflow) { - return -1; - } else { - return 0; - } - }); -} - -function transactionKey(x) { - const keys = [ - 'Account', - 'Flag', - 'Date', - 'Payee', - 'Category', - 'Memo', - 'Outflow', - 'Inflow', - 'Cleared', - ]; - 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)), - }; - } - - render() { - const sum = this.state.transactions.reduce((acc, { Outflow }) => acc + Outflow, 0); - const savedSum = Object.values(this.state.saved).reduce((acc, sum) => acc + sum, 0); - - 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)), - })} - onFilterOutflow={() => this.setState({ - outflowTransactions: select(this.state.outflowQuery, select(this.state.savingsView, data.data.transactions)), - })} - 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)), - })} - onSavingsViewChange={x => this.setState({ savingsView: x })} - onInflowQueryChange={x => this.setState({ inflowQuery: x })} - onOutflowQueryChange={x => this.setState({ outflowQuery: x })} - /> - ); - } else if (this.state.view === 'budget') { - // Planned expenses: - // - minus planned assignment to emergency fund (not an expense) - // - minus planned spend to investments (e.g. stocks, crypto) - const budgetedSpend = this.state.budget.reduce((acc, x) => acc + x.children.filter(x => x.savings).reduce((acc, x) => acc + x.monthly, 0), 0); - - view = ( - <div> - <fieldset> - <legend>Details</legend> - <div className="form-group"> - <label htmlFor="paycheck">Paycheck</label> - <input name="paycheck" type="text" /> - </div> - <div className="form-group"> - <label htmlFor="savings-rate">Savings Rate</label> - <input name="savings-rate" type="text" /> - </div> - </fieldset> - <ul> - <li>Available Spend: {dollars(this.state.paycheck * 2, this.state.sensitive)}</li> - <li>Target Spend: {dollars(this.state.paycheck, this.state.sensitive)}</li> - <li>Budgeted Spend (minus savings): {dollars(budgetedSpend, this.state.sensitive)}</li> - <li>Emergency Fund Size (recommended): {dollars(budgetedSpend * 3, this.state.sensitive)}</li> - </ul> - <div style={{ width: '30em' }}> - <DonutChart labels={this.state.budget.map(x => x.label)} datasets={[ - { - label: 'Categories', - data: this.state.budget.map(x => x.children.reduce((acc, y) => acc + y.monthly, 0)), - } - ]} /> - </div> - <ul> - {this.state.budget.map(x => ( - <li> - <div>{x.label} - {dollars(x.children.reduce((acc, x) => acc + x.monthly, 0), this.state.sensitive)}</div> - <ul>{x.children.map(y => <li>{y.label} - {dollars(y.monthly, this.state.sensitive)}</li>)}</ul> - </li> - ))} - </ul> - </div> - ); - } - - return ( - <div className="container"> - <nav className="terminal-menu"> - <ul> - <li><a href="#" onClick={() => this.setState({ view: 'query' })}>query</a></li> - <li><a href="#" onClick={() => this.setState({ view: 'savings' })}>savings</a></li> - <li><a href="#" onClick={() => this.setState({ view: 'budget' })}>budget</a></li> - <li><a href="#" onClick={() => this.setState({ sensitive: !this.state.sensitive })}>sensitive</a></li> - </ul> - </nav> - {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 = ({ sensitive, label, transactions }) => { - const categories = transactions.reduce((acc, x) => { - if (x.Category === '') { - return acc; - } - if (!(x.Category in acc)) { - acc[x.Category] = 0; - } - acc[x.Category] += x.Outflow; - return acc; - }, {}); - - // Sort category keys by sum decreasing. - const keys = [...Object.keys(categories)].sort((x, y) => { - if (categories[x] < categories[y]) { - return 1; - } else if (categories[x] > categories[y]) { - return -1; - } else { - return 0; - } - }); - - return ( - <React.Fragment> - {keys.map(k => ( - <tr style={{backgroundColor: '#F0F8FF'}}> - <td>{k}</td><td>{dollars(categories[k], sensitive)}</td> - </tr> - ))} - </React.Fragment> - ); -}; - -/** - * Calculates and renders various aggregates over an input list of transactions. - */ -const AggregateTable = ({ sensitive, focus, onFocus, transactions }) => { - const net = transactions.reduce((acc, x) => acc + x.Inflow - x.Outflow, 0); - const sum = transactions.reduce((acc, x) => acc + x.Outflow, 0); - const buckets = transactions.reduce((acc, x) => { - const order = magnitude(x.Outflow); - const bucket = Math.pow(10, order); - acc[bucket].push(x); - return acc; - }, {0.1: [], 0: [], 1: [], 10: [], 100: [], 1000: []}); - - return ( - <div> - <table> - <caption>Aggregations</caption> - <thead> - <tr> - <th>function</th> - <th>value</th> - </tr> - </thead> - <tbody> - <tr><td>net</td><td>{dollars(net, sensitive)}</td></tr> - <tr onClick={() => onFocus('sum')}><td>sum</td><td>{dollars(sum, sensitive)}</td></tr> - {focus.sum && <Magnitable sensitive={sensitive} label="sum" transactions={transactions} />} - <tr><td>per day</td><td>{dollars(sum / 365, sensitive)}</td></tr> - <tr><td>per week</td><td>{dollars(sum / 52, sensitive)}</td></tr> - <tr><td>per month</td><td>{dollars(sum / 12, sensitive)}</td></tr> - <tr onClick={() => onFocus(1000)}><td>Σ Θ($1,000)</td><td>{dollars(buckets[1000].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr> - {(focus[1000]) && <Magnitable sensitive={sensitive} label="$1,000" transactions={buckets[1000]} />} - <tr onClick={() => onFocus(100)}><td>Σ Θ($100)</td><td>{dollars(buckets[100].reduce((acc, x, sensitive) => acc + x.Outflow, 0), sensitive)}</td></tr> - {(focus[100]) && <Magnitable sensitive={sensitive} label="$100" transactions={buckets[100]} />} - <tr onClick={() => onFocus(10)}><td>Σ Θ($10)</td><td>{dollars(buckets[10].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr> - {(focus[10]) && <Magnitable sensitive={sensitive} label="$10" transactions={buckets[10]} />} - <tr onClick={() => onFocus(1)}><td>Σ Θ($1)</td><td>{dollars(buckets[1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr> - {(focus[1]) && <Magnitable sensitive={sensitive} label="$1.00" transactions={buckets[1]} />} - <tr onClick={() => onFocus(0.1)}><td>Σ Θ($0.10)</td><td>{dollars(buckets[0.1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}</td></tr> - {(focus[0.1]) && <Magnitable sensitive={sensitive} label="$0.10" transactions={buckets[0.1]} />} - <tr><td>average</td><td>{dollars(sum / transactions.length, sensitive)}</td></tr> - <tr><td>count</td><td>{transactions.length}</td></tr> - </tbody> - </table> - </div> - ); -}; - -const Query = ({ query, onChange, onFilter }) => ( - <fieldset> - <legend>Query</legend> - <div className="form-group"> - <input name="query" type="text" value={query} onChange={e => onChange(e.target.value)} /> - <div className="btn-group"> - <button className="btn btn-default" onClick={() => onFilter()}>Filter</button> - </div> - </div> - </fieldset> -); - -const Transactions = ({ sensitive, transactions, onClick }) => ( - <table> - <caption>Transactions</caption> - <thead> - <tr> - <th>Account</th> - <th>Category</th> - <th>Date</th> - <th>Inflow</th> - <th>Outflow</th> - <th>Payee</th> - <th>Memo</th> - </tr> - </thead> - <tbody> - {transactions.map(x => ( - <tr onClick={() => onClick(x)}> - <td>{x.Account}</td> - <td>{x.Category}</td> - <td>{x.Date.toLocaleDateString()}</td> - <td>{dollars(x.Inflow, sensitive)}</td> - <td>{dollars(x.Outflow, sensitive)}</td> - <td>{x.Payee}</td> - <td>{x.Memo}</td> - </tr> - ))} - </tbody> - </table> -); - -const domContainer = document.querySelector('#mount'); -const root = ReactDOM.createRoot(domContainer); - -root.render(<App />); diff --git a/users/wpcarro/ynabsql/dataviz/components.jsx b/users/wpcarro/ynabsql/dataviz/components.jsx index 7a9b7ae958dd..88f69b5f5152 100644 --- a/users/wpcarro/ynabsql/dataviz/components.jsx +++ b/users/wpcarro/ynabsql/dataviz/components.jsx @@ -132,14 +132,6 @@ const usd = new Intl.NumberFormat('en-US', { currency: 'USD', }); -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)/', @@ -581,8 +573,8 @@ class SavingsRateLineChart extends React.Component { class App extends React.Component { constructor(props) { super(props); - const query = 'Account:/checking/ (Inflow>1000 OR Outflow>1000)'; - const allTransactions = data.data.transactions; + const query = 'Account:/checking/'; + const allTransactions = []; const savingsView = 'after:"01/01/2022"'; const inflowQuery = 'Account:/checking/'; const outflowQuery = 'Account:/checking/ -Category:/(stocks|crypto)/'; @@ -713,6 +705,13 @@ class App extends React.Component { render() { const sum = this.state.filteredTransactions.reduce((acc, { Outflow }) => acc + Outflow, 0); const savedSum = Object.values(this.state.saved).reduce((acc, sum) => acc + sum, 0); + const categories = this.state.allTransactions.reduce((acc, x) => { + if (!(x.Category in acc)) { + acc[x.Category] = []; + } + acc[x.Category].push(x); + return acc; + }, {}); let view = null; if (this.state.view === 'query') { @@ -738,6 +737,7 @@ class App extends React.Component { } else if (this.state.view === 'savings') { view = ( <SavingsView + categories={categories} sensitive={this.state.sensitive} savingsView={this.state.savingsView} inflowQuery={this.state.inflowQuery} @@ -924,6 +924,7 @@ function classifyRate(x) { const SavingsView = ({ sensitive, + categories, savingsView, inflowQuery, outflowQuery, diff --git a/users/wpcarro/ynabsql/dataviz/index.html b/users/wpcarro/ynabsql/dataviz/index.html index 2fdf8866cfb4..e30c8682b25c 100644 --- a/users/wpcarro/ynabsql/dataviz/index.html +++ b/users/wpcarro/ynabsql/dataviz/index.html @@ -12,18 +12,8 @@ </head> <body class="container"> <div id="mount"></div> - - <!-- chart.js --> - <script src="./cdn/chart.js"></script> - <script src="./cdn/date_fns.js"></script> - <script src="./cdn/chartjs-adapter-date-fns.bundle.min.js"></script> - <!-- react.js --> - <script src="./cdn/react.development.js" crossorigin></script> - <script src="./cdn/react-dom.development.js" crossorigin></script> - <script src="./cdn/babel.min.js"></script> <!-- depot JS --> <script src="./cdn/slx.js"></script> - <script src="./data.js"></script> - <script src="./components.js" type="text/babel"></script> + <script src="./components.jsx" type="module"></script> </body> </html> |