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…
	
	Add table
		Add a link
		
	
		Reference in a new issue