// The browser side of `money serve`. It renders what the API returns and // nothing more: amounts arrive formatted, globs are matched on the server, and // every tag still comes from rules.toml — there is no control here that tags a // transaction, only the builder that writes a rule for it. 'use strict'; const VIEWS = [ { id: 'accounts', title: 'Accounts', mount: mountAccounts }, { id: 'txns', title: 'Transactions', mount: mountTxns }, { id: 'report', title: 'Report', mount: mountReport }, { id: 'rule', title: 'Rule builder', mount: mountRuleBuilder }, { id: 'rules', title: 'Rules', mount: mountRules }, { id: 'transfer', title: 'Transfer builder', mount: mountTransferBuilder }, { id: 'transfers', title: 'Transfers', mount: mountTransfers }, ]; const state = { view: 'accounts', overview: null, // The scope the transaction list and the report share. The report's period // lives apart from it on purpose: it narrows the report and nothing else. filter: { account: '', search: '', untagged: false }, wide: false, report: { period: '', sort: 'out' }, rule: blankRule(), ruleSort: 'name', transfer: blankTransfer(), status: '', error: '', busy: false, }; function blankRule() { return { match: '', account: '', tag: '', note: '', editing: null }; } function blankTransfer() { return { fromAccount: '', fromDesc: '', toAccount: '', toDesc: '', tolerance: '', note: '' }; } // --- plumbing ------------------------------------------------------------- // h builds an element. Text always goes in as a text node, never as HTML, // since descriptions come straight off bank statements. Children flatten all // the way down: a map that yields a group of rows — the report's transfers // and their fee — would otherwise be stringified into the page. function h(tag, attrs, ...children) { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (v === null || v === undefined || v === false) continue; if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else if (k === 'class') el.className = v; else if (k === 'value') el.value = v; else el.setAttribute(k, v === true ? '' : v); } for (const c of children.flat(Infinity)) { if (c === null || c === undefined || c === false) continue; el.append(c instanceof Node ? c : document.createTextNode(String(c))); } return el; } async function api(method, path, body) { const opts = { method, headers: {} }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } const res = await fetch(path, opts); const data = await res.json().catch(() => ({ error: `${res.status} ${res.statusText}` })); if (!res.ok) throw new Error(data.error || `${res.status} ${res.statusText}`); return data; } // latest wraps a loader so only the most recent call's answer is used: typing // fires one request per keystroke, and they do not always come back in order. function latest(fn) { let seq = 0; return async (...args) => { const mine = ++seq; const out = await fn(...args); return mine === seq ? out : undefined; }; } function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } function query(params) { const q = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === '' || v === false || v === null || v === undefined) continue; q.set(k, v === true ? '1' : v); } const s = q.toString(); return s ? '?' + s : ''; } function amount(m) { return h('td', { class: 'num' + (m.negative ? ' neg' : '') }, m.text); } function setStatus(msg) { state.status = msg; state.error = ''; renderBanners(); } function setError(err) { state.error = err instanceof Error ? err.message : String(err); renderBanners(); } function renderBanners() { const box = document.getElementById('banners'); box.replaceChildren(); const o = state.overview; if (o && o.stale) { const text = o.rulesError ? `rules.toml does not load: ${o.rulesError}` : 'rules.toml has changed on disk since the index was last derived from it.'; box.append(h('div', { class: 'banner warn' }, h('span', {}, text), o.rulesError ? null : h('button', { type: 'button', onclick: retag }, 'Retag to apply it'))); } if (state.error) { box.append(h('div', { class: 'banner error' }, h('span', {}, state.error), h('button', { type: 'button', class: 'link', onclick: () => setError('') }, 'dismiss'))); } else if (state.status) { box.append(h('div', { class: 'banner info' }, h('span', {}, state.status), h('button', { type: 'button', class: 'link', onclick: () => setStatus('') }, 'dismiss'))); } } async function loadOverview() { state.overview = await api('GET', '/api/overview'); renderBanners(); fillDatalists(); } // The account and tag fields complete natively, from lists the server // builds. (transfer) is never among the tags: it is a label. function fillDatalists() { const o = state.overview; if (!o) return; for (const [id, values] of [['dl-accounts', o.accountSlugs], ['dl-tags', o.tags]]) { let dl = document.getElementById(id); if (!dl) { dl = h('datalist', { id }); document.body.append(dl); } dl.replaceChildren(...values.map((v) => h('option', { value: v }))); } } function scopeLabel() { const f = state.filter; let s = f.account || 'all accounts'; if (f.untagged) s += ' · untagged'; if (f.search) s += ' · /' + f.search; return s; } // --- navigation ----------------------------------------------------------- let current = null; // the mounted view, whose refresh() reloads its data function renderNav() { const nav = document.getElementById('nav'); nav.replaceChildren(...VIEWS.map((v, i) => h('a', { href: '#' + v.id, class: v.id === state.view ? 'active' : null, }, h('kbd', {}, String(i + 1)), v.title))); } function go(id) { if (location.hash !== '#' + id) { location.hash = id; // hashchange calls show() return; } show(); } function show() { const id = location.hash.slice(1) || 'accounts'; const view = VIEWS.find((v) => v.id === id) || VIEWS[0]; // Leaving the builder mid-edit abandons the edit, as esc does: a new rule // must not start out as a copy of the one being edited. if (view.id !== 'rule' && state.rule.editing) state.rule = blankRule(); state.view = view.id; renderNav(); const main = document.getElementById('main'); main.replaceChildren(); current = view.mount(main) || null; } async function refreshAll() { await loadOverview(); if (current && current.refresh) await current.refresh(); } // --- import and retag ----------------------------------------------------- function runImport(force) { return importing(() => api('POST', '/api/import', { force: !!force })); } // importing runs a request that ends in an import — the Import button, or an // upload — and reports it the same way. It answers whether the request // succeeded; per-file failures inside the import still count as success. async function importing(request) { if (state.busy) return false; state.busy = true; const btn = document.getElementById('import'); btn.disabled = true; btn.textContent = 'Importing…'; setStatus('importing… parsing PDF statements can take a while'); try { const res = await request(); state.status = res.status; state.error = res.failed ? `${res.failed} file(s) failed to import` : ''; renderBanners(); showImportDetails(res); await refreshAll(); return true; } catch (e) { setError(e); return false; } finally { state.busy = false; btn.disabled = false; btn.textContent = 'Import'; } } // showImportDetails lists what each file contributed, like `money import` // does. Files skipped by checksum are not listed: unchanged is not news. function showImportDetails(res) { if (!res.files.length) return; const items = res.files.map((f) => h('li', {}, f.error ? h('span', { class: 'neg' }, `${f.path}: ${f.error}`) : `${f.path}: ${f.new} new, ${f.skipped} duplicate (${f.parsed} parsed)`, (f.warnings || []).map((w) => h('div', { class: 'warn' }, 'warning: ' + w)))); const box = document.getElementById('banners'); box.append(h('div', { class: 'banner info' }, h('ul', { class: 'import-files' }, items), h('button', { type: 'button', class: 'link', onclick: (e) => e.target.parentElement.remove() }, 'dismiss'))); } async function retag() { if (state.busy) return; try { const res = await api('POST', '/api/retag', {}); setStatus(res.status); await refreshAll(); } catch (e) { setError(e); } } // --- accounts ------------------------------------------------------------- function mountAccounts(main) { const body = h('div'); const upload = h('div'); main.append(h('h2', {}, 'Accounts'), body, upload); async function refresh() { if (!state.overview) await loadOverview(); const o = state.overview; upload.replaceChildren(uploader(o)); if (!o.accounts.length) { // An account.toml is not enough on its own: folders reach the index // only through an import, so say that rather than show nothing. body.replaceChildren(h('div', { class: 'panel empty' }, o.folders.length ? `No accounts imported yet.\n\n${o.folders.length} account folder(s) in ${o.root}.\nAdd statements below, or put them in the folders and press Import.` : `No account folders found in ${o.root}.\n\nAn account is a folder containing an account.toml.\nCreate one, then add statements to it here.`)); return; } body.replaceChildren(h('div', { class: 'panel' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'Account'), h('th', {}, 'Slug'), h('th', { class: 'num' }, 'Balance'), h('th', {}, 'Cur'), h('th', { class: 'num' }, 'Txns'))), h('tbody', {}, o.accounts.map((a) => h('tr', { class: 'clickable', title: 'Show this account\'s transactions', onclick: () => { state.filter.account = a.slug; go('txns'); }, }, h('td', {}, a.name), h('td', { class: 'muted' }, a.slug), amount(a.balance), h('td', {}, a.currency), h('td', { class: 'num' }, a.count))))))); } refresh().catch(setError); return { refresh }; } // uploader puts statement files into an account folder and imports them. The // folder is where they belong: the index is rebuilt from it, so a statement // that lived only in the index would be lost the next time it is deleted. function uploader(o) { const panel = h('div', { class: 'panel upload' }, h('h3', {}, 'Add statements')); if (!o.folders.length) { panel.append(h('div', { class: 'muted' }, o.foldersError ? `Account folders do not load: ${o.foldersError}` : `There is no account folder to add statements to. Create one in ${o.root} with an account.toml.`)); return panel; } let files = []; const account = h('select', { 'aria-label': 'Account to add statements to' }, o.folders.map((f) => h('option', { value: f }, f))); // Default to the account being browsed, since that is usually the one. account.value = o.folders.includes(state.filter.account) ? state.filter.account : o.folders[0]; const picker = h('input', { type: 'file', multiple: true, hidden: true, onchange: (e) => choose(e.target.files), }); const chosen = h('div', { class: 'chosen muted' }, 'No files chosen'); const send = h('button', { type: 'button', class: 'primary', disabled: true, onclick: submit }, 'Upload and import'); const zone = h('div', { class: 'dropzone', tabindex: 0, role: 'button', onclick: () => picker.click(), onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); picker.click(); } }, ondragover: (e) => { e.preventDefault(); zone.classList.add('over'); }, ondragleave: () => zone.classList.remove('over'), ondrop: (e) => { e.preventDefault(); zone.classList.remove('over'); choose(e.dataTransfer.files); }, }, 'Drop statement files here, or click to choose'); function choose(list) { files = Array.from(list || []); send.disabled = !files.length; chosen.textContent = files.length ? files.map((f) => `${f.name} (${Math.ceil(f.size / 1024)} KB)`).join(' · ') : 'No files chosen'; } async function submit() { send.disabled = true; try { const payload = { account: account.value, files: await Promise.all(files.map(async (f) => ({ name: f.name, data: await base64(f) }))), }; if (await importing(() => api('POST', '/api/upload', payload))) { picker.value = ''; choose([]); } } catch (e) { setError(e); } finally { send.disabled = !files.length; } } panel.append( h('div', { class: 'toolbar' }, h('label', {}, 'Account ', account), send), zone, picker, chosen, h('div', { class: 'hint muted' }, 'Files are saved into the account folder, next to the statements already there, and imported. ' + 'A file with the same name and different contents is refused rather than replaced.')); return panel; } // base64 encodes a file for the JSON body. Chunked, because spreading a whole // statement into String.fromCharCode overflows the call stack. async function base64(file) { const bytes = new Uint8Array(await file.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 0x8000) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); } return btoa(bin); } // --- shared scope toolbar --------------------------------------------------- function scopeToolbar(onChange) { const accounts = (state.overview && state.overview.accounts) || []; const select = h('select', { 'aria-label': 'Account', onchange: (e) => { state.filter.account = e.target.value; onChange(); }, }, h('option', { value: '' }, 'All accounts'), accounts.map((a) => h('option', { value: a.slug }, a.slug))); select.value = state.filter.account; const search = h('input', { type: 'search', id: 'search', placeholder: 'Filter by description ( / )', value: state.filter.search, oninput: debounce((e) => { state.filter.search = e.target.value.trim(); onChange(); }, 200), }); const untagged = h('input', { type: 'checkbox', checked: state.filter.untagged, onchange: (e) => { state.filter.untagged = e.target.checked; onChange(); }, }); return h('div', { class: 'toolbar' }, select, search, h('label', { class: 'check', title: 'No tag and not a matched transfer leg (u)' }, untagged, 'Untagged only')); } // --- transactions --------------------------------------------------------- const PAGE = 500; function mountTxns(main) { const title = h('h2'); const body = h('div'); let shown = PAGE; let rows = []; const wide = h('input', { type: 'checkbox', checked: state.wide, onchange: (e) => { state.wide = e.target.checked; draw(); }, }); const toolbar = scopeToolbar(() => { shown = PAGE; refresh(); }); toolbar.append(h('label', { class: 'check', title: 'Also show type and reported balance' }, wide, 'Wide'), h('button', { type: 'button', onclick: () => go('rule') }, 'New rule…')); main.append(title, toolbar, body); const load = latest(() => api('GET', '/api/transactions' + query(state.filter))); function emptyMessage() { const f = state.filter; if (f.search) return `No transactions match /${f.search}.`; if (f.untagged) return 'Nothing untagged.\n\nEvery transaction here has a tag or belongs to a transfer.'; if (f.account) return `No transactions in ${f.account}.\n\nImport, or choose all accounts.`; return 'No transactions yet.\n\nPress Import to read your statements.'; } function draw() { title.replaceChildren(`Transactions `, h('span', { class: 'sub' }, `· ${scopeLabel()} · ${rows.length} rows`)); if (!rows.length) { body.replaceChildren(h('div', { class: 'panel empty' }, emptyMessage())); return; } const head = ['Date', 'Account', 'Amount', 'Tag']; if (state.wide) head.push('Balance', 'Type'); head.push('Description'); const visible = rows.slice(0, shown); body.replaceChildren(h('div', { class: 'panel' }, h('table', {}, h('thead', {}, h('tr', {}, head.map((c) => h('th', { class: c === 'Amount' || c === 'Balance' ? 'num' : null }, c)))), h('tbody', {}, visible.map((t) => h('tr', {}, h('td', {}, t.date), h('td', {}, t.account), amount(t.amount), h('td', { class: t.supplied ? 'muted' : null }, t.tag), state.wide ? [h('td', { class: 'num' }, t.balance), h('td', { class: 'muted' }, t.type)] : null, h('td', { class: 'desc' }, t.description))))), rows.length > shown ? h('div', { class: 'more' }, h('button', { type: 'button', onclick: () => { shown += PAGE; draw(); } }, `Show more (${rows.length - shown} hidden)`)) : null)); } async function refresh() { try { const res = await load(); if (!res) return; rows = res.rows; draw(); } catch (e) { setError(e); } } refresh(); return { refresh }; } // --- report --------------------------------------------------------------- const TRANSFERS_ROW = '(transfers)'; const FEES_ROW = '⤷ fees'; function mountReport(main) { const title = h('h2'); const body = h('div'); main.append(title, scopeToolbar(() => refresh()), body); let data = null; const load = latest(() => api('GET', '/api/report' + query({ ...state.filter, period: state.report.period, sort: state.report.sort, }))); function setPeriod(i) { if (!data || i < 0 || i >= data.periods.length || i === data.period) return; state.report.period = data.periods[i].label; refresh(); } function draw() { const period = data.periods[data.period]; title.replaceChildren('Report ', h('span', { class: 'sub' }, `· ${period.name} · ${scopeLabel()}`)); const periods = h('div', { class: 'panel periods' }, h('div', { class: 'head' }, 'Period'), data.periods.map((p, i) => h('button', { type: 'button', class: i === data.period ? 'active' : null, title: p.name, onclick: () => setPeriod(i), }, p.label))); let content; if (!data.rows.length && !data.excluded.length) { // An empty period is not an empty index, and the default window is the // one most likely to be empty: last month's statement may not be in yet. content = h('div', { class: 'panel empty' }, period.bounded && !data.indexEmpty ? `Nothing in ${period.name}.\n\nChoose another period (← →), or import.` : 'Nothing to report yet.\n\nImport some statements first.'); } else { const order = data.orders.find((o) => o.name === data.order); const cols = [['Tag', false], ['Cur', false], ['Out', true], ['In', true], ['Net', true], ['N', true]]; const header = h('tr', {}, cols.map(([c, num]) => { const o = data.orders.find((x) => x.column === c); const marked = order && order.column === c; return h('th', { class: [num ? 'num' : '', o ? 'sortable' : ''].join(' ').trim() || null, title: o ? 'Sort: ' + o.label : null, onclick: o ? () => { state.report.sort = o.name; refresh(); } : null, }, c + (marked ? (order.ascending ? ' ▴' : ' ▾') : '')); })); const row = (cls, tag, r, n) => h('tr', { class: cls }, h('td', {}, tag), h('td', {}, r.currency), amount(r.out), amount(r.in), amount(r.net), h('td', { class: 'num' }, n)); const blank = { text: '', negative: false }; content = h('div', { class: 'panel' }, h('table', {}, h('thead', {}, header), h('tbody', {}, data.rows.map((r) => row(r.tag === '(untagged)' ? 'muted' : null, r.tag, r, r.count)), data.totals.map((r) => row('total', 'TOTAL', r, '')), // What the report held out, so it agrees with the balances: the legs // moved between accounts, and any fee a tolerant definition admitted. data.excluded.map((x, i) => [ row('excluded' + (i === 0 ? ' first' : ''), TRANSFERS_ROW, x, x.legs), x.fee ? row('excluded', FEES_ROW, { currency: x.currency, out: x.fee, in: blank, net: x.feeNet }, x.pairs) : null, ])))); } body.replaceChildren(h('div', { class: 'report' }, periods, content)); } async function refresh() { try { const res = await load(); if (!res) return; data = res; state.report.period = data.periods[data.period].label; draw(); } catch (e) { setError(e); } } refresh(); return { refresh, key(e, typing) { if (typing || !data || e.ctrlKey || e.metaKey || e.altKey) return false; if (e.key === 'ArrowLeft') { setPeriod(data.period - 1); return true; } if (e.key === 'ArrowRight') { setPeriod(data.period + 1); return true; } if (e.key === 's') { const i = data.orders.findIndex((o) => o.name === data.order); state.report.sort = data.orders[(i + 1) % data.orders.length].name; refresh(); return true; } return false; }, }; } // --- form helpers --------------------------------------------------------- function field(label, input, hint) { return h('div', { class: 'field' }, h('label', { for: input.id }, label), input, hint ? h('div', { class: 'hint' }, hint) : null); } function textInput(id, value, placeholder, oninput, list) { return h('input', { type: 'text', id, value, placeholder, list, autocomplete: 'off', spellcheck: 'false', oninput, }); } // --- rule builder --------------------------------------------------------- function mountRuleBuilder(main) { const r = state.rule; const editing = r.editing; // A new rule starts on the account being browsed. if (!editing && !r.account && state.filter.account) r.account = state.filter.account; const summary = h('div', { class: 'summary' }); const preview = h('div', { class: 'panel preview' }); const update = (k) => (e) => { r[k] = e.target.value; refreshPreview(); }; const inputs = { match: textInput('rule-match', r.match, '*LIDL*', update('match')), account: textInput('rule-account', r.account, 'blank = every account', update('account'), 'dl-accounts'), tag: textInput('rule-tag', r.tag, 'groceries', update('tag'), 'dl-tags'), note: textInput('rule-note', r.note, 'optional', update('note')), }; const save = h('button', { type: 'submit', class: 'primary' }, editing ? 'Save changes' : 'Save rule'); const form = h('form', { class: 'panel form', onsubmit: (e) => { e.preventDefault(); submit(); }, }, field('Glob', inputs.match, 'vs. the description · * any run, ? one character'), // The form has no type field, so a rule that sets one says it is kept // rather than looking broader than it is. editing && editing.expect.type ? h('div', { class: 'kept' }, `+ type:${editing.expect.type} · kept`) : null, field('Account', inputs.account, 'blank = all accounts'), field('Tag', inputs.tag, 'applied to matches'), field('Note', inputs.note, 'why this rule exists'), summary, h('div', { class: 'buttons' }, save, editing ? h('button', { type: 'button', onclick: () => { state.rule = blankRule(); go('rules'); } }, 'Cancel') : null)); main.append( h('h2', {}, editing ? `Editing rule ${editing.pos + 1} ` : 'Rule builder ', h('span', { class: 'sub' }, editing ? '· rewrites it in rules.toml' : '· writes to rules.toml')), h('div', { class: 'builder' }, form, preview)); const load = latest(() => api('POST', '/api/rules/preview', { glob: r.match, account: r.account, sort: state.ruleSort, edit: editing ? editing.pos : null, })); async function refreshNow() { try { const res = await load(); if (!res) return; draw(res); } catch (e) { setError(e); } } const refreshPreview = debounce(refreshNow, 120); function draw(res) { let text = `${res.matches} of ${res.candidates} descriptions match`; if (!r.match.trim() && !editing) text = `${res.candidates} untagged descriptions`; if (res.dropped) text += `\n${res.dropped} no longer claimed`; summary.textContent = text; const byCount = state.ruleSort === 'count'; const toggle = () => { state.ruleSort = byCount ? 'name' : 'count'; refreshNow(); }; const descTitle = (editing ? 'Description' : 'Untagged description') + (byCount ? '' : ' ↓'); if (!res.rows.length) { preview.replaceChildren(h('div', { class: 'empty' }, r.match.trim() ? 'The glob matches nothing still waiting for a rule.' : 'Nothing untagged — every transaction has a tag or belongs to a transfer.')); return; } preview.replaceChildren(h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, ''), h('th', { class: 'sortable', onclick: toggle, title: 'Sort by name / by count (ctrl+s)' }, descTitle), h('th', { class: 'num sortable', onclick: toggle, title: 'Sort by name / by count (ctrl+s)' }, 'N' + (byCount ? ' ↓' : '')))), h('tbody', {}, res.rows.map((row) => h('tr', { class: 'clickable', title: 'Use this description as the glob', onclick: () => { // Collapsed as the engine normalises it, or a doubled space on the // statement would make a glob that never matches. inputs.match.value = r.match = '*' + row.description.trim().split(/\s+/).join(' ') + '*'; inputs.match.focus(); refreshNow(); }, }, h('td', { class: 'mark' + (row.marker === '−' ? ' neg' : '') }, row.marker), h('td', { class: 'desc' }, row.description), h('td', { class: 'num' }, row.count)))))); } async function submit() { save.disabled = true; try { const form = { match: r.match, account: r.account, tag: r.tag, note: r.note }; if (editing) { const res = await api('PUT', `/api/rules/${editing.pos}`, { rule: form, expect: editing.expect }); state.rule = blankRule(); setStatus(res.status); await loadOverview(); // An edit is a round trip from the rules screen, so it ends there. go('rules'); return; } const res = await api('POST', '/api/rules', form); // The account stays: the next rule is usually for the same one. Object.assign(r, { match: '', tag: '', note: '' }); inputs.match.value = inputs.tag.value = inputs.note.value = ''; inputs.match.focus(); setStatus(res.status); await loadOverview(); await refreshNow(); } catch (e) { setError(e); } finally { save.disabled = false; } } inputs.match.focus(); refreshNow(); return { refresh: refreshNow, form: true, key(e) { if (e.key === 's' && e.ctrlKey) { state.ruleSort = state.ruleSort === 'count' ? 'name' : 'count'; refreshNow(); return true; } if (e.key === 'Escape' && editing) { state.rule = blankRule(); go('rules'); return true; } return false; }, }; } // --- rules ---------------------------------------------------------------- function ruleExpect(r) { return { match: r.match, type: r.type, account: r.account, tag: r.tag, note: r.note }; } function mountRules(main) { const title = h('h2'); const body = h('div'); const prune = h('button', { type: 'button', class: 'danger' }, 'Prune unused'); main.append(title, h('div', { class: 'toolbar' }, h('button', { type: 'button', onclick: () => go('rule') }, 'New rule…'), prune, h('button', { type: 'button', onclick: () => refresh().then(() => setStatus('counts refreshed')) }, 'Refresh counts')), body); let rules = []; async function del(positions, question) { if (!confirm(question)) return; try { const res = await api('POST', '/api/rules/delete', { positions, expect: positions.map((p) => ruleExpect(rules[p])), }); setStatus(res.status); await refreshAll(); } catch (e) { setError(e); } } function edit(r) { state.rule = { match: r.match, account: r.account, tag: r.tag, note: r.note, editing: { pos: r.pos, expect: ruleExpect(r) }, }; go('rule'); } async function refresh() { try { rules = (await api('GET', '/api/rules')).rules; } catch (e) { setError(e); return; } const unused = rules.filter((r) => r.usage === 0); title.replaceChildren('Rules ', h('span', { class: 'sub' }, unused.length ? `· ${rules.length} rules · ${unused.length} match nothing` : `· ${rules.length} rules, all in use`)); prune.disabled = !unused.length; prune.onclick = () => del(unused.map((r) => r.pos), `Delete all ${unused.length} rules that match nothing?`); if (!rules.length) { body.replaceChildren(h('div', { class: 'panel empty' }, 'No rules yet.\n\nBuild one, or write rules.toml by hand.')); return; } // File order, numbered as in rules.toml. Precedence is not read off this: // the most specific rule wins wherever it sits. body.replaceChildren(h('div', { class: 'panel' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', { class: 'num' }, '#'), h('th', {}, ''), h('th', {}, 'Pattern'), h('th', {}, 'Account'), h('th', {}, 'Tag'), h('th', { class: 'num', title: 'Transactions this rule wins' }, 'Txns'), h('th', {}, 'Note'), h('th', {}, ''))), h('tbody', {}, rules.map((r) => h('tr', {}, h('td', { class: 'num muted' }, r.pos + 1), h('td', { class: 'mark neg', title: r.usage ? null : 'Claims nothing: matches nothing, or a more specific rule wins' }, r.usage ? '' : '✗'), h('td', { class: 'desc', style: 'font-family: var(--mono)' }, r.pattern), h('td', { class: r.account ? null : 'muted' }, r.account || '(all)'), h('td', {}, r.tag), h('td', { class: 'num' }, r.usage), h('td', { class: 'desc muted' }, r.note), h('td', { class: 'actions' }, h('button', { type: 'button', onclick: () => edit(r) }, 'Edit'), ' ', h('button', { type: 'button', class: 'danger', onclick: () => del([r.pos], `Delete rule ${r.pos + 1} (${r.pattern} → ${r.tag}), used by ${r.usage} transactions?`), }, 'Delete')))))))); } refresh(); return { refresh }; } // --- transfer builder ----------------------------------------------------- function mountTransferBuilder(main) { const t = state.transfer; const summary = h('div', { class: 'summary' }); const preview = h('div', { class: 'panel preview' }); const update = (k) => (e) => { t[k] = e.target.value; refreshPreview(); }; const inputs = { fromAccount: textInput('tr-from', t.fromAccount, 'nlb', update('fromAccount'), 'dl-accounts'), fromDesc: textInput('tr-from-desc', t.fromDesc, '*TO REVOLUT*', update('fromDesc')), toAccount: textInput('tr-to', t.toAccount, 'revolut', update('toAccount'), 'dl-accounts'), toDesc: textInput('tr-to-desc', t.toDesc, '*FROM NLB*', update('toDesc')), tolerance: textInput('tr-tol', t.tolerance, '0', update('tolerance')), note: textInput('tr-note', t.note, 'optional', update('note')), }; const save = h('button', { type: 'submit', class: 'primary' }, 'Save transfer'); main.append( h('h2', {}, 'Transfer builder ', h('span', { class: 'sub' }, '· writes to rules.toml')), h('div', { class: 'builder' }, h('form', { class: 'panel form', onsubmit: (e) => { e.preventDefault(); submit(); } }, field('From account', inputs.fromAccount, 'money leaves here'), field('From desc', inputs.fromDesc, 'glob vs. the leaving leg'), field('To account', inputs.toAccount, 'money arrives here'), field('To desc', inputs.toDesc, 'glob vs. the arriving leg'), field('Tolerance %', inputs.tolerance, '0 = amounts must match exactly; only for a route that charges a fee'), field('Note', inputs.note, 'why this transfer exists'), summary, h('div', { class: 'buttons' }, save)), preview)); const load = latest(() => api('POST', '/api/transfers/preview', t)); async function refreshNow() { try { const res = await load(); if (!res) return; draw(res); } catch (e) { setError(e); } } const refreshPreview = debounce(refreshNow, 120); function draw(res) { let text = res.pairs || res.unpaired ? `${res.pairs} pairs · ${res.unpaired} unpaired` : 'nothing matches yet'; // Fees are why the tolerance exists, so the preview says what it admits. if (res.fees) text += ` · ${res.fees} in fees`; summary.textContent = text; if (!res.rows.length) { preview.replaceChildren(h('div', { class: 'empty' }, 'Fill in either side to see which legs it catches.\n▸ a matched pair · ⚠ a leg with no other side')); return; } preview.replaceChildren(h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, ''), h('th', {}, 'Date'), h('th', { class: 'num' }, 'Amount'), h('th', {}, 'Movement'), h('th', {}, 'Description'))), h('tbody', {}, res.rows.map((row) => h('tr', {}, h('td', { class: 'mark' + (row.marker === '⚠' ? ' warn' : '') }, row.marker), h('td', {}, row.date), h('td', { class: 'num' }, row.amount), h('td', {}, row.movement), h('td', { class: 'desc' }, row.description)))))); } async function submit() { save.disabled = true; try { const res = await api('POST', '/api/transfers', t); // The accounts stay — the next definition is usually the same route the // other way. The tolerance goes with the globs: carried over silently it // would loosen a route that never asked for one. for (const k of ['fromDesc', 'toDesc', 'tolerance', 'note']) { t[k] = ''; inputs[k].value = ''; } inputs.fromDesc.focus(); setStatus(res.status); await loadOverview(); await refreshNow(); } catch (e) { setError(e); } finally { save.disabled = false; } } inputs.fromAccount.focus(); refreshNow(); return { refresh: refreshNow, form: true }; } // --- transfers ------------------------------------------------------------ function transferExpect(t) { return { fromAccount: t.fromAccount, fromDesc: t.fromDesc, toAccount: t.toAccount, toDesc: t.toDesc, tolerancePct: t.tolerancePct, note: t.note, }; } function mountTransfers(main) { const title = h('h2'); const body = h('div'); const prune = h('button', { type: 'button', class: 'danger' }, 'Prune unmatched'); main.append(title, h('div', { class: 'toolbar' }, h('button', { type: 'button', onclick: () => go('transfer') }, 'New transfer…'), prune, h('button', { type: 'button', onclick: () => refresh().then(() => setStatus('pairing refreshed')) }, 'Refresh pairing')), body); let defs = []; async function del(positions, question) { if (!confirm(question)) return; try { const res = await api('POST', '/api/transfers/delete', { positions, expect: positions.map((p) => transferExpect(defs[p])), }); setStatus(res.status); await refreshAll(); } catch (e) { setError(e); } } async function refresh() { let res; try { res = await api('GET', '/api/transfers'); } catch (e) { setError(e); return; } defs = res.transfers; title.replaceChildren('Transfers ', h('span', { class: 'sub' }, res.unpaired ? `· ${defs.length} definitions · ${res.unpaired} leg(s) unpaired` : `· ${defs.length} definitions · every leg paired`)); // Only definitions catching nothing at all are pruned. One with unpaired // legs is doing something, and deleting it would hide the problem. const unused = defs.filter((d) => d.paired === 0 && d.orphaned === 0); prune.disabled = !unused.length; prune.onclick = () => del(unused.map((d) => d.pos), `Delete all ${unused.length} transfers that match nothing?`); if (!defs.length) { body.replaceChildren(h('div', { class: 'panel empty' }, 'No transfers yet.\n\nA transfer names both legs of money moved between your own accounts,\n' + 'so the report can leave the pair out instead of counting it as spending.')); return; } body.replaceChildren(h('div', { class: 'panel' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', { class: 'num' }, '#'), h('th', {}, ''), h('th', {}, 'From'), h('th', {}, 'To'), h('th', { class: 'num' }, 'Pairs'), h('th', { class: 'num' }, 'Unpaired'), h('th', { class: 'num' }, 'Tol'), h('th', {}, 'Note'), h('th', {}, ''))), h('tbody', {}, defs.map((d) => { let mark = '', cls = 'mark', tip = null; if (d.orphaned > 0) [mark, cls, tip] = ['⚠', 'mark warn', 'Catches legs it cannot pair']; else if (d.paired === 0) [mark, cls, tip] = ['✗', 'mark neg', 'Matches nothing at all']; return h('tr', {}, h('td', { class: 'num muted' }, d.pos + 1), h('td', { class: cls, title: tip }, mark), h('td', {}, d.fromAccount, ' ', h('span', { style: 'font-family: var(--mono)' }, d.fromDesc)), h('td', {}, d.toAccount, ' ', h('span', { style: 'font-family: var(--mono)' }, d.toDesc)), h('td', { class: 'num' }, d.paired), h('td', { class: 'num' + (d.orphaned ? ' warn' : '') }, d.orphaned), h('td', { class: 'num' }, d.tolerance), h('td', { class: 'desc muted' }, d.note), h('td', { class: 'actions' }, h('button', { type: 'button', class: 'danger', onclick: () => del([d.pos], `Delete transfer ${d.pos + 1} (${d.fromAccount} → ${d.toAccount}), ${d.paired} matched?`), }, 'Delete'))); }))))); } refresh(); return { refresh }; } // --- keyboard ------------------------------------------------------------- // Single-key shortcuts, where they do not fight the browser. A builder is a // form, so outside of chords its keys belong to the field being typed in. document.addEventListener('keydown', (e) => { const t = e.target; const typing = t instanceof HTMLInputElement || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement; if (current && current.key && current.key(e, typing)) { e.preventDefault(); return; } if (typing || e.ctrlKey || e.metaKey || e.altKey) return; if (e.key >= '1' && e.key <= String(VIEWS.length)) { go(VIEWS[Number(e.key) - 1].id); } else if (e.key === '/') { if (state.view !== 'txns' && state.view !== 'report') go('txns'); setTimeout(() => { const s = document.getElementById('search'); if (s) s.focus(); }); } else if (e.key === 'u') { state.filter.untagged = !state.filter.untagged; if (state.view === 'txns' || state.view === 'report') show(); else go('txns'); } else if (e.key === 'a') { state.filter.account = ''; if (state.view === 'txns' || state.view === 'report') show(); } else if (e.key === 'i') { runImport(false); } else if (e.key === 'r') { retag(); } else { return; } e.preventDefault(); }); document.getElementById('import').addEventListener('click', (e) => runImport(e.shiftKey)); document.getElementById('import').title += ' (shift-click re-parses unchanged files too)'; document.getElementById('retag').addEventListener('click', retag); window.addEventListener('hashchange', show); loadOverview().catch(setError).finally(show);