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">
|
||||
<head>
|
||||
<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>
|
||||
<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>
|
||||
<!-- 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="./components.js" type="text/babel"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
Loading…
Reference in a new issue