diff --git a/CLAUDE.md b/CLAUDE.md index ceb2330..5f0657a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,12 +174,12 @@ tag so ties keep a fixed position instead of shuffling between reloads. own copy of that mapping; a new order needs a column of its own, which `TestOrderColumnsAreDistinct` checks. -**The single-key shortcuts must not fire while typing.** The page's `keydown` -handler ignores them whenever the target is an input, or typing `i` in the tag -field would start an import. A view's own `key` hook runs first and is told -whether the user is typing, so a command *inside* a builder has to be a chord — -`ctrl+s` re-sorts the preview — because every printable key belongs to the -field being typed in. +**There are no keyboard shortcuts.** The page used to carry the TUI's keymap — +screens on `1`–`8`, `u`, `/`, `i`, `r`, and chords inside the builders — and it +was removed on purpose: every action is a link or a button. Do not add a global +`keydown` handler back without being asked; a single-letter key fires while +typing in a field unless every one of them is guarded, which is what the old one +existed to do. **A rule's usage count is how many transactions it wins, not how many its glob could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by a diff --git a/README.md b/README.md index 67554bc..3b8aeda 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,8 @@ LIDL SOFIA 4412 The account is not part of what makes a row distinct. A rule matches on the description and only optionally narrows to an account, so one payee seen on two -accounts is still one pattern to write — and it is one row in the rule builder -on `4`, which groups the same way. Pass `--account` to scope the listing -instead. +accounts is still one pattern to write — and it is one row in the rule builder, +which groups the same way. Pass `--account` to scope the listing instead. It composes with the other filters (`--account`, `--month`, `--search`), and `--limit` caps the rows printed, saying how many it held back. It cannot be @@ -164,11 +163,14 @@ fresh command would: rule there (another tab, a hand edit) the change is refused and you are asked to reload, rather than editing whichever rule moved into its place. -Shift-click **Import** for `import --force`. +**Re-read all** is `import --force`: every statement is parsed again, including +the ones an Import skips because they have not changed. Use it after updating +money, when a parser has learned to read something it used to get wrong; rows +already in the index are recognised and not duplicated. ### Statements -The Accounts screen (`1`) also lists every statement file in every account +The Accounts screen also lists every statement file in every account folder, with what the index made of it: | Status | Meaning | @@ -200,7 +202,7 @@ button says **Forget** and only clears the index. ### Adding statements from the browser -The Accounts screen (`1`) has an **Add statements** panel: pick the account, +The Accounts screen has an **Add statements** panel: pick the account, drop files on it (or click to choose them) and press **Upload**. The files are saved into that account's folder, exactly where you would have copied them by hand — the folder stays the source of truth, so deleting `index.db` and @@ -228,28 +230,18 @@ same name (a reissue) is numbered `_2`, `_3` rather than refused. A statement with no date it can find keeps its own name. Only uploads are named — files already in a folder are never renamed, since the index records them by path. -### Keys +### Navigating -| Key | Action | -| --- | --- | -| `1` – `8` | accounts · transactions · report · rule builder · rules · transfer builder · transfers · tags | -| `/` | filter by description | -| `u` | show only untagged transactions (matched transfer legs are not among them) | -| `a` | clear the account filter | -| `←` `→` | move the report's period (report) | -| `s` | change how the report is sorted (report) | -| `ctrl+s` | sort the rule builder's preview by name / by count | -| `i` | import · `r` retag | - -Inside a form every printable key belongs to the field you are typing in, so -the single-letter keys only work outside one; that is why the builder's sort is -a chord. Clicking an account on `1` filters the transaction list to it. +Every screen is a link in the bar along the top, and every action is a button or +a click; there are no keyboard shortcuts. Clicking an account on the Accounts +screen filters the transaction list to it. There is no control that tags a transaction. Tags come from `rules.toml` and -nowhere else, so tagging what you are looking at means writing a rule for it on -`4` — which is why that screen shows you what a glob catches before you save. +nowhere else, so tagging what you are looking at means writing a rule for it in +the rule builder — which is why it shows you what a glob catches before you +save. -### Report (`3`) +### Report The report opens on **last month**, not on everything you have ever imported: an all-time total is the one number a spending report is least often asked for, @@ -259,8 +251,8 @@ period is empty rather than quietly showing you a different one. The time axis beside the totals runs from the widest window down to the oldest month in the index — `all time`, `this year`, `last 12 months`, `last 3 -months`, `this month`, `last month`, then one entry per month. Click one, or -step along it with `←` and `→`. +months`, `this month`, `last month`, then one entry per month. Click one to +report on it. The three rolling windows run to the end of *this* month rather than to the last complete one — you ask for "last 3 months" to see what is happening now, @@ -268,12 +260,11 @@ and leaving out the days since the 1st would answer a different question. Each month below `last month` is a month the index actually holds; months that a named window above already covers are not repeated. -Click a column heading, or press `s` to cycle, to change how the rows are -arranged; the marked heading says which one they are arranged by: `Out ▾` -largest spend first (the default question a spending report answers), then -`In ▾`, `Net ▾` lowest first so the biggest losses lead, `N ▾` most -transactions first, and `Tag ▴` A→Z. `money report` takes the same choice as -`--sort out|in|net|count|tag`. +Click a column heading to change how the rows are arranged; the marked heading +says which one they are arranged by: `Out ▾` largest spend first (the default +question a spending report answers), then `In ▾`, `Net ▾` lowest first so the +biggest losses lead, `N ▾` most transactions first, and `Tag ▴` A→Z. `money +report` takes the same choice as `--sort out|in|net|count|tag`. Currency is always the outer grouping and no sort changes that — there are no exchange rates here, so two currencies interleaved by amount would invite a @@ -283,12 +274,12 @@ The sort rearranges rows; it never changes which rows there are. The period narrows the report and only the report. The account, the search and the untagged toggle are shared with the transaction list, so opening on last -month does not hide the rest of the index from `2`. +month does not hide the rest of the index from the transaction list. `money report` takes `--month` instead; there is no command-line equivalent of the wider windows. -### Rule builder (`4`) +### Rule builder Writing rules by hand means guessing what a glob will catch. This screen shows the answer as you type: the form is on the left — glob, account, tag, note — @@ -310,13 +301,13 @@ point to narrow down. immediately, so the rows it caught disappear from the list. The account stays filled in, since the next rule is usually for the same one. -Clicking the column headings, or `ctrl+s`, switches the list between by name and -by count, most seen first; the `↓` says which column the order is read from. -The two answer different questions: by name finds the payee you are looking at, -by count finds the rule worth writing next, since one pattern claiming forty -rows is worth more than the first of forty claiming one. Ties keep the -alphabetical order, so the list does not reshuffle under you, and the choice -lasts until you change it. +Clicking the column headings switches the list between by name and by count, +most seen first; the `↓` says which column the order is read from. The two +answer different questions: by name finds the payee you are looking at, by count +finds the rule worth writing next, since one pattern claiming forty rows is +worth more than the first of forty claiming one. Ties keep the alphabetical +order, so the list does not reshuffle under you, and the choice lasts until you +change it. The account and tag fields offer completions as you type. Accounts come from the folders on disk and the index; tags from every tag in use plus any named in @@ -336,7 +327,7 @@ report leaves them out anyway. #### Editing a rule -**Edit** on the rules screen (`5`) opens the rule in this same form with the +**Edit** on the rules screen opens the rule in this same form with the fields filled in, and saving rewrites that rule where it sits instead of appending a new one. It keeps its position, since position still breaks ties between equally specific rules; `rules.toml` is edited textually, so the @@ -352,10 +343,10 @@ you are about to hand back to whatever rule catches it next. The form has no `type` field, so a rule that sets one carries it through unchanged rather than losing it; it is shown under the glob as `+ type:… · -kept`. Saving lands back on the rules screen with the new counts; **Cancel**, -`esc`, or leaving the screen leaves the rule as it was. +kept`. Saving lands back on the rules screen with the new counts; **Cancel**, or +leaving the screen leaves the rule as it was. -### Rules (`5`) +### Rules Every rule in file order — the order they are written in, not the order they are tried in — with the number of transactions it actually claims. Rules that @@ -390,7 +381,7 @@ it, while one separated by a blank line is treated as a section heading and left alone; an edited rule keeps its comments, since they say why it is there and changing its glob rarely changes that. -### Transfer builder (`6`) +### Transfer builder Same idea as the rule builder, for money moved between your own accounts. The form names both sides — from account, from desc, to account, to desc — plus an @@ -425,7 +416,7 @@ A half-written definition previews too: fill in one side and its legs show up as unpaired, which is the quickest way to see that a glob is wrong before you have written the other half. -### Transfers (`7`) +### Transfers Every definition in file order, with what it currently pairs. @@ -471,7 +462,7 @@ once, both after asking. Definitions marked `⚠` are never pruned — they are doing something, just not finishing it, and deleting one would hide the problem rather than fix it. **Refresh pairing** re-reads what is currently in the index. -### Tags (`8`) +### Tags Every distinct tag, A→Z: the ones your transactions carry and any `rules.toml` names, with how many transactions carry each and the rules that write it. @@ -547,7 +538,7 @@ tag = "groceries" Every tag comes from this file, so a tag is never something you have to keep safe: `money retag` recomputes all of them from the current rules and is safe to run whenever you change it. A transaction no rule matches simply stays -untagged, and shows up under `money ls --untagged`, the `u` view, and +untagged, and shows up under `money ls --untagged`, **Untagged only**, and `(untagged)` in the report — unless it is one leg of a matched transfer, which is already spoken for and is left out of all three. @@ -649,7 +640,7 @@ that is the number your spending is short by: transfers excluded: 2 legs in EUR, 500.00 out, 495.00 in, 5.00 in fees ``` -The report screen (`3`) shows the same thing under its `TOTAL`, the fee on its +The report screen shows the same thing under its `TOTAL`, the fee on its own row (for whichever period it is on): ``` @@ -675,7 +666,7 @@ An *unpaired* leg is not a transfer and still counts, tagged like anything else. That is deliberate: money that left an account and cannot be shown to have arrived is exactly what you want to see, not something to quietly drop. `money import` and `money retag` report unpaired legs on stderr, and the -transfers screen (`7`) shows which definition they belong to. +transfers screen shows which definition they belong to. The pairing is derived state, like the tags: it lives in the index, is recomputed wholesale by `money retag` and after every import, and disappears diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 9071dec..b073803 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -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); diff --git a/internal/web/static/index.html b/internal/web/static/index.html index f2bd568..808af8b 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -12,6 +12,7 @@
+
diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 85d7d11..3783632 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -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 {