money serve covers every screen the TUI had, so keeping both meant every behaviour change landing twice. internal/tui goes, and with it bubbletea, bubbles and lipgloss. `money` with no command now runs serve, the way it used to open the TUI, and `money tui` is an unknown command. Three invariants in CLAUDE.md were covered only by TUI tests. Two already had web counterparts; TestPairedLegsAreNotUntagged is ported to the API: a paired leg is neither listed as untagged nor offered to the rule builder, while an unpaired one still is. README's screen sections now describe the browser, which kept the behaviour and changed only the controls. CLAUDE.md names the web equivalents of the TUI functions its invariants pointed at, drops the tab completion rule that only bubbles' textinput needed, and says how to test the web app instead of how to drive a terminal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
899 lines
35 KiB
JavaScript
899 lines
35 KiB
JavaScript
// 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.
|
||
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()) {
|
||
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 -----------------------------------------------------
|
||
|
||
async function runImport(force) {
|
||
if (state.busy) return;
|
||
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 api('POST', '/api/import', { force: !!force });
|
||
state.status = res.status;
|
||
state.error = res.failed ? `${res.failed} file(s) failed to import` : '';
|
||
renderBanners();
|
||
showImportDetails(res);
|
||
await refreshAll();
|
||
} catch (e) {
|
||
setError(e);
|
||
} 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');
|
||
main.append(h('h2', {}, 'Accounts'), body);
|
||
|
||
async function refresh() {
|
||
if (!state.overview) await loadOverview();
|
||
const o = state.overview;
|
||
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.configured
|
||
? `No accounts imported yet.\n\n${o.configured} account folder(s) configured in ${o.root}.\nPress Import to read their statements.`
|
||
: `No account folders found in ${o.root}.\n\nAn account is a folder containing an account.toml.\nCreate one, drop statements in, then press Import.`));
|
||
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 };
|
||
}
|
||
|
||
// --- 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);
|