Drop the keyboard shortcuts; add a Re-read all button

The page carried the TUI's keymap -- screens on 1-8, / u a i r, the
report's arrows and s, the rule builder's ctrl+s and esc. Every one of
them already had a link, button or click, so they are gone, along with
the key numbers in the nav and the key hints in placeholders and
tooltips. The upload box still answers Enter and Space, which is what
makes it a button rather than a shortcut.

The forced import was reachable only by shift-clicking Import. It is now
a Re-read all button: every statement parsed again, including the ones
an import skips as unchanged -- what to run after a parser fix. Both
buttons disable while either runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 23:36:59 +02:00
co-authored by Claude Opus 5.5
parent 3d72a54be1
commit c271cf2c8f
5 changed files with 71 additions and 141 deletions
+22 -83
View File
@@ -175,7 +175,7 @@ function renderNav() {
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)));
}, v.title)));
}
function go(id) {
@@ -189,8 +189,8 @@ function go(id) {
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.
// Leaving the builder mid-edit abandons the edit, as Cancel 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();
@@ -206,12 +206,16 @@ async function refreshAll() {
// --- import and retag -----------------------------------------------------
// runImport is `money import`, or with force `money import --force`: every
// statement re-read, including the ones the checksum would skip.
async function runImport(force) {
if (state.busy) return;
state.busy = true;
const btn = document.getElementById('import');
btn.disabled = true;
btn.textContent = 'Importing…';
const buttons = ['import', 'reread'].map((id) => document.getElementById(id));
const btn = buttons[force ? 1 : 0];
const label = btn.textContent;
for (const b of buttons) b.disabled = true;
btn.textContent = force ? 'Re-reading…' : 'Importing…';
setStatus('importing… parsing PDF statements can take a while');
try {
const res = await api('POST', '/api/import', { force: !!force });
@@ -224,8 +228,8 @@ async function runImport(force) {
setError(e);
} finally {
state.busy = false;
btn.disabled = false;
btn.textContent = 'Import';
for (const b of buttons) b.disabled = false;
btn.textContent = label;
}
}
@@ -459,7 +463,7 @@ function scopeToolbar(onChange) {
select.value = state.filter.account;
const search = h('input', {
type: 'search', id: 'search', placeholder: 'Filter by description ( / )',
type: 'search', id: 'search', placeholder: 'Filter by description',
value: state.filter.search,
oninput: debounce((e) => { state.filter.search = e.target.value.trim(); onChange(); }, 200),
});
@@ -468,7 +472,7 @@ function scopeToolbar(onChange) {
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'));
h('label', { class: 'check', title: 'No tag and not a matched transfer leg' }, untagged, 'Untagged only'));
}
// --- transactions ---------------------------------------------------------
@@ -571,7 +575,7 @@ function mountReport(main) {
// 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 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);
@@ -611,21 +615,7 @@ function mountReport(main) {
} 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;
},
};
return { refresh };
}
// --- form helpers ---------------------------------------------------------
@@ -712,8 +702,8 @@ function mountRuleBuilder(main) {
}
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('th', { class: 'sortable', onclick: toggle, title: 'Sort by name / by count' }, descTitle),
h('th', { class: 'num sortable', onclick: toggle, title: 'Sort by name / by count' }, 'N' + (byCount ? ' ↓' : '')))),
h('tbody', {}, res.rows.map((row) => h('tr', {
class: 'clickable',
title: 'Use this description as the glob',
@@ -758,23 +748,7 @@ function mountRuleBuilder(main) {
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;
},
};
return { refresh: refreshNow };
}
// --- rules ----------------------------------------------------------------
@@ -938,7 +912,7 @@ function mountTransferBuilder(main) {
inputs.fromAccount.focus();
refreshNow();
return { refresh: refreshNow, form: true };
return { refresh: refreshNow };
}
// --- transfers ------------------------------------------------------------
@@ -1086,43 +1060,8 @@ function mountTags(main) {
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('import').addEventListener('click', () => runImport(false));
document.getElementById('reread').addEventListener('click', () => runImport(true));
document.getElementById('retag').addEventListener('click', retag);
window.addEventListener('hashchange', show);