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 ; } } /** * 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)), }; } 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 = ( ); } else if (this.state.view === 'savings') { view = ( 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 = (
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)), } ]} />
    {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)}
    • )}
  • ))}
); } return (
{view}
); } } 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) => ( ))}
Month Delta (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))} />
category max last year surplus
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 = ({ 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 ( {keys.map(k => ( {k}{dollars(categories[k], sensitive)} ))} ); }; /** * 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 (
onFocus('sum')}> {focus.sum && } onFocus(1000)}> {(focus[1000]) && } onFocus(100)}> {(focus[100]) && } onFocus(10)}> {(focus[10]) && } onFocus(1)}> {(focus[1]) && } onFocus(0.1)}> {(focus[0.1]) && }
Aggregations
function value
net{dollars(net, sensitive)}
sum{dollars(sum, sensitive)}
per day{dollars(sum / 365, sensitive)}
per week{dollars(sum / 52, sensitive)}
per month{dollars(sum / 12, sensitive)}
Σ Θ($1,000){dollars(buckets[1000].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}
Σ Θ($100){dollars(buckets[100].reduce((acc, x, sensitive) => acc + x.Outflow, 0), sensitive)}
Σ Θ($10){dollars(buckets[10].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}
Σ Θ($1){dollars(buckets[1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}
Σ Θ($0.10){dollars(buckets[0.1].reduce((acc, x) => acc + x.Outflow, 0), sensitive)}
average{dollars(sum / transactions.length, sensitive)}
count{transactions.length}
); }; const Query = ({ query, onChange, onFilter }) => (
Query
onChange(e.target.value)} />
); const Transactions = ({ sensitive, transactions, onClick }) => ( {transactions.map(x => ( onClick(x)}> ))}
Transactions
Account Category Date Inflow Outflow Payee Memo
{x.Account} {x.Category} {x.Date.toLocaleDateString()} {dollars(x.Inflow, sensitive)} {dollars(x.Outflow, sensitive)} {x.Payee} {x.Memo}
); const domContainer = document.querySelector('#mount'); const root = ReactDOM.createRoot(domContainer); root.render();