235 lines
8.2 KiB
JavaScript
235 lines
8.2 KiB
JavaScript
|
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 />);
|