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:
parent
98b155c8c1
commit
0196555f07
3 changed files with 250 additions and 3 deletions
235
users/wpcarro/ynabsql/dataviz/components.js
Normal file
235
users/wpcarro/ynabsql/dataviz/components.js
Normal 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 />);
|
|
@ -1 +0,0 @@
|
||||||
/* testing */
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue