Files
money/internal/web/static/app.js
T
nikolaandClaude Opus 5.5 9ad6c05f9a List each account's statement files, and open them
The Accounts screen gets a Statements list: every file in every account
folder beside what the index made of it -- imported, changed since,
not imported yet (or failed), or gone from disk while its rows remain --
with its size, modified and import times, and how many transactions it
brought in. Clicking a name opens the file.

The list is read from the folders, not the index, through
importer.StatementFiles, so a file shows exactly when import would read
it; store.SourceFiles and importer.Checksum then say how far each has
got. A file the index remembers but the disk lost is listed as missing
rather than vanishing, since its rows would not survive a rebuild.

A file is served only by finding it in that list, never by joining the
requested name onto a path. Statements come from outside and are served
from the app's origin, so none is rendered as a page: text is text/plain
under CSP sandbox, anything not text or PDF is a sandboxed download, and
PDFs -- whose viewers refuse a sandbox -- open in the browser's own
isolated viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:00:03 +02:00

1082 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 files = h('div');
const upload = h('div');
main.append(h('h2', {}, 'Accounts'), body, files, upload);
async function refresh() {
if (!state.overview) await loadOverview();
const o = state.overview;
upload.replaceChildren(uploader(o));
api('GET', '/api/files').then((res) => files.replaceChildren(fileList(res.files))).catch(setError);
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 };
}
const FILE_STATUS = {
imported: ['✓', '', 'imported'],
changed: ['⚠', 'warn', 'changed since it was imported — Import re-reads it'],
new: ['•', 'warn', 'not imported — press Import (a file that fails to parse stays here)'],
missing: ['✗', 'neg', 'gone from disk — its rows stay only until the index is rebuilt'],
};
// fileList shows the statements in every account folder beside what the index
// recorded about each, and opens one on click. The folders decide what is
// listed; the index only says how far each file has got.
function fileList(list) {
const panel = h('div', { class: 'panel files' });
if (!list.length) {
panel.append(h('div', { class: 'empty' }, 'No statement files yet.'));
return h('div', {}, h('h3', { class: 'section' }, 'Statements'), panel);
}
const pending = list.filter((f) => f.status !== 'imported').length;
panel.append(h('table', {},
h('thead', {}, h('tr', {}, h('th', {}, 'Account'), h('th', {}, 'File'), h('th', { class: 'num' }, 'Size'),
h('th', {}, 'Modified'), h('th', {}, ''), h('th', {}, 'Status'),
h('th', { class: 'num', title: 'Transactions this file brought into the index; rows already brought by an overlapping statement count there' }, 'Added'),
h('th', {}, 'Imported'))),
h('tbody', {}, list.map((f) => {
const [mark, cls, text] = FILE_STATUS[f.status] || ['', '', f.status];
return h('tr', {},
h('td', {}, f.account),
h('td', { class: 'desc' }, f.status === 'missing' ? h('span', { class: 'muted' }, f.name) : h('a', {
href: `/api/files/${encodeURIComponent(f.account)}/${encodeURIComponent(f.name)}`,
target: '_blank', rel: 'noopener', title: 'Open this statement',
}, f.name)),
h('td', { class: 'num' }, f.status === 'missing' ? '' : formatSize(f.size)),
h('td', { class: 'muted' }, f.modified),
h('td', { class: 'mark ' + cls }, mark),
h('td', { class: cls || 'muted' }, text),
h('td', { class: 'num' }, f.status === 'new' ? '' : f.added),
h('td', { class: 'muted' }, localTime(f.importedAt)));
}))));
return h('div', {}, h('h3', { class: 'section' }, 'Statements ',
h('span', { class: 'sub' }, `· ${list.length} file(s)` + (pending ? ` · ${pending} need attention` : ''))), panel);
}
// localTime shows the index's UTC import time in the same local
// YYYY-MM-DD HH:MM that the server gives the files' modified times.
function localTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return iso;
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// 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 = [];
let unpaired = 0;
// Which ⚠ rows are open, by position, so a refresh does not fold them away.
const open = new Set();
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);
open.clear(); // positions shift under a delete
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;
unpaired = res.unpaired;
draw();
}
function draw() {
title.replaceChildren('Transfers ', h('span', { class: 'sub' }, unpaired
? `· ${defs.length} definitions · ${unpaired} leg(s) unpaired — open a ⚠ row to see them`
: `· ${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] = [open.has(d.pos) ? '▾' : '⚠', 'mark warn', 'Show the legs it cannot pair'];
else if (d.paired === 0) [mark, cls, tip] = ['✗', 'mark neg', 'Matches nothing at all'];
const toggle = () => {
if (open.has(d.pos)) open.delete(d.pos); else open.add(d.pos);
draw();
};
return [h('tr', {
class: d.orphaned > 0 ? 'clickable' : null,
title: d.orphaned > 0 ? 'Show the legs it cannot pair' : null,
onclick: d.orphaned > 0 ? (e) => { if (!e.target.closest('button')) toggle(); } : null,
},
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'))),
open.has(d.pos) && d.orphaned > 0 ? h('tr', { class: 'legs' }, h('td', { colspan: 9 }, legsTable(d))) : null];
})))));
}
// legsTable lists a definition's unpaired legs: which side each is missing,
// and what that side would have had to look like to pair.
function legsTable(d) {
return h('table', { class: 'sub' },
h('thead', {}, h('tr', {}, h('th', {}, 'Date'), h('th', { class: 'num' }, 'Amount'), h('th', {}, 'Movement'),
h('th', {}, 'Description'), h('th', {}, 'Missing'), h('th', {}, 'Would pair with'))),
h('tbody', {}, d.legs.map((l) => h('tr', {},
h('td', {}, l.date), h('td', { class: 'num' }, l.amount), h('td', {}, l.movement),
h('td', { class: 'desc' }, l.description), h('td', { class: 'warn' }, l.missing + ' leg'),
h('td', { class: 'desc muted' }, l.want)))));
}
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);