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:
+22
-83
@@ -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);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<nav id="nav" aria-label="Screens"></nav>
|
||||
<div class="actions">
|
||||
<button type="button" id="retag" title="Re-apply rules.toml: retag everything and re-pair transfers">Retag</button>
|
||||
<button type="button" id="reread" title="Re-read every statement, including ones unchanged since they were imported">Re-read all</button>
|
||||
<button type="button" id="import" class="primary" title="Import new statements from every account folder">Import</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -68,7 +68,6 @@ button, input, select { font: inherit; color: inherit; }
|
||||
}
|
||||
#nav a:hover { color: var(--text); background: var(--row-hover); }
|
||||
#nav a.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; }
|
||||
#nav kbd { font-family: var(--mono); font-size: 11px; opacity: .6; margin-right: 4px; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
|
||||
button {
|
||||
|
||||
Reference in New Issue
Block a user