about summary refs log blame commit diff
path: root/users/wpcarro/ynabsql/dataviz/components.js
blob: 40653189785ec002ccf7a7f2efc326ce3c23c6f8 (plain) (tree)










































































































































































































































                                                                                                                                                            
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; }, {});

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 App extends React.Component {
    constructor(props) {
        super(props);
        const query = 'Account:/checking/ after:"01/01/2022" before:"01/01/2023"';

        this.state = {
            query,
            transactions: select(query, data.data.transactions),
            saved: {},
            focus: {
                1000: false,
                100: false,
                10: false,
                1: false,
                0.1: false,
            },
        };
    }

    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);

        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,
                    })}
                    onFilter={() => this.setState({
                        transactions: select(this.state.query, data.data.transactions),
                    })} 
                    onSave={() => this.setState({
                        saved: { ...this.state.saved, [this.state.query]: sum }
                    })}
                />
                <AggregateTable 
                    focus={this.state.focus} 
                    onFocus={(n) => this.setState({
                        focus: { ...this.state.focus, [n]: !this.state.focus[n] },
                    })}
                    transactions={this.state.transactions} 
                />
                <hr />
                <div>
                    <ul>
                        {Object.keys(this.state.saved).map(k => (
                            <li key={k}>
                                {usd.format(this.state.saved[k])} {k}
                            </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 }
                    })}
                />
            </div>
        );
    }
}

/**
 * Table rendering information about transactions bucketed by its order of
 * magnitude.
 */
const Magnitable = ({ 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>{usd.format(categories[k])}</td>
                </tr>
            ))}
        </React.Fragment>
    );
};

/**
 * Calculates and renders various aggregates over an input list of transactions.
 */
const AggregateTable = ({ focus, onFocus, transactions }) => {
    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 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>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>count</td><td>{transactions.length}</td></tr>
                </tbody>
            </table>
        </div>
    );
};

const Input = ({ query, onChange, onFilter, onSave }) => (
    <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 }) => (
    <table>
        <caption>Transactions</caption>
        <thead>
            <tr>
                <th>Account</th>
                <th>Category</th>
                <th>Date</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>{usd.format(x.Outflow)}</td>
                    <td>{x.Payee}</td>
                    <td>{x.Memo}</td>
                </tr>
            ))}
        </tbody>
    </table>
);

const domContainer = document.querySelector('#react-mount');
const root = ReactDOM.createRoot(domContainer);

root.render(<App />);