Files
money/internal/web/static/app.js
T
nikolaandClaude Opus 5.5 7ec9976b80 Upload statements from the web app
The Accounts screen gets an Add statements panel: pick an account, drop
files on it or choose them, and they are saved into that account's folder
and imported. The folder stays the source of truth -- an upload only puts
a file where `money import` looks, then runs the same import as the
Import button, so deleting index.db and re-importing still loses nothing.

Files travel base64 inside JSON rather than as multipart. There is no
auth, and the JSON-only rule is what keeps another site's form from
posting here; multipart is exactly what such a form can send.

An upload never replaces a statement: identical contents are a no-op and
different ones are refused with 409. Names import would not read back --
not a plain file name, dotfiles, account.toml, outside the account's
include patterns -- are refused, and a batch is checked whole before any
of it is written. Files are written through a dotfile and renamed, so a
concurrent import never reads half of one.

The overview now lists the account folders on disk, read fresh so one
created after startup is a valid target; it replaces the configured
count the empty accounts screen used.

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

991 lines
39 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.
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 -----------------------------------------------------
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);