feat(wpcarro/slx): Render transactions

Wire-up clientside slx with HTML.

Change-Id: Ieef517b47fae8d1af67bb0c7fcb7eae853f138e1
Reviewed-on: https://cl.tvl.fyi/c/depot/+/7832
Reviewed-by: wpcarro <wpcarro@gmail.com>
Tested-by: BuildkiteCI
This commit is contained in:
William Carroll 2023-01-13 17:36:49 -08:00 committed by wpcarro
parent 98b155c8c1
commit 0196555f07
3 changed files with 250 additions and 3 deletions

View file

@ -0,0 +1,235 @@
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 />);

View file

@ -1 +0,0 @@
/* testing */

View file

@ -2,11 +2,24 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="stylesheet" href="./index.css" /> <link rel="stylesheet" href="https://unpkg.com/terminal.css@0.7.2/dist/terminal.min.css" />
<link rel="stylesheet" href="https://unpkg.com/terminal.css@0.7.1/dist/terminal.min.css" />
<link rel="stylesheet" href="https://unpkg.com/terminal.css@0.7.1/dist/terminal.min.css" />
</head> </head>
<body> <body>
<canvas id="mount"></canvas> <div id="react-mount"></div>
<!-- <canvas id="mount"></canvas> -->
<!-- chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- react.js -->
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<!-- depot JS -->
<script src="http://localhost:8002/index.js"></script>
<script src="./data.js"></script>
<script src="./index.js"></script> <script src="./index.js"></script>
<script src="./components.js" type="text/babel"></script>
</body> </body>
</html> </html>