The report builds its excluded rows as a group per currency -- the transfer legs and, when a tolerance admitted one, the fee -- so its tbody children nest two arrays deep. h flattened only one level, so each group was stringified and the page showed "[object HTMLTableRowElement]" wherever a period held a matched transfer. Children now flatten all the way down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
993 lines
39 KiB
JavaScript
993 lines
39 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. 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);
|