Compare commits

...
3 Commits
Author SHA1 Message Date
nikolaandClaude Opus 5.5 a086f9242d Give the page an icon
An SVG favicon in the page's accent colour, three bars like report rows,
embedded with the rest of static/ so there is still nothing else to deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:04:47 +02:00
nikolaandClaude Opus 5.5 c271cf2c8f 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>
2026-10-02 23:36:59 +02:00
nikolaandClaude Opus 5.5 3d72a54be1 Add a tags screen
Screen 8 lists every distinct tag -- the ones the index holds and the
ones rules.toml names -- with how many transactions carry it and the
rules that write it, each by file position with the transactions it
wins. It is where near-duplicate tags and tags several rules feed show
up, and a rule beaten by a more specific one reads (0) there as it does
on the rules screen. A tag no rule names any more says the next retag
clears it. The transfer label is not a tag and is never listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:30:38 +02:00
8 changed files with 244 additions and 141 deletions
+6 -6
View File
@@ -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 own copy of that mapping; a new order needs a column of its own, which
`TestOrderColumnsAreDistinct` checks. `TestOrderColumnsAreDistinct` checks.
**The single-key shortcuts must not fire while typing.** The page's `keydown` **There are no keyboard shortcuts.** The page used to carry the TUI's keymap —
handler ignores them whenever the target is an input, or typing `i` in the tag screens on `1`–`8`, `u`, `/`, `i`, `r`, and chords inside the builders — and it
field would start an import. A view's own `key` hook runs first and is told was removed on purpose: every action is a link or a button. Do not add a global
whether the user is typing, so a command *inside* a builder has to be a chord — `keydown` handler back without being asked; a single-letter key fires while
`ctrl+s` re-sorts the preview — because every printable key belongs to the typing in a field unless every one of them is guarded, which is what the old one
field being typed in. existed to do.
**A rule's usage count is how many transactions it wins, not how many its glob **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 could match** — `Engine.Usage` counts by `MatchIndex`, so a rule shadowed by a
+67 -54
View File
@@ -123,9 +123,8 @@ LIDL SOFIA 4412
The account is not part of what makes a row distinct. A rule matches on the 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 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 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 which groups the same way. Pass `--account` to scope the listing instead.
instead.
It composes with the other filters (`--account`, `--month`, `--search`), and It composes with the other filters (`--account`, `--month`, `--search`), and
`--limit` caps the rows printed, saying how many it held back. It cannot be `--limit` caps the rows printed, saying how many it held back. It cannot be
@@ -133,11 +132,12 @@ combined with `--wide`, whose columns all belong to a single transaction.
## The web app ## The web app
`money serve`, which is also what `money` runs with no command, puts seven `money serve`, which is also what `money` runs with no command, puts eight
screens in a browser: accounts, transactions, report, rule builder, rules, screens in a browser: accounts, transactions, report, rule builder, rules,
transfer builder and transfers. It runs the same code as the CLI: amounts are formatted, transfer builder, transfers and tags. It runs the same code as the CLI: amounts
globs matched and transfers paired on the server, and the page only shows the are formatted, globs matched and transfers paired on the server, and the page
answers. It is one binary with the page built in; nothing else to deploy. only shows the answers. It is one binary with the page built in; nothing else to
deploy.
``` ```
money # http://127.0.0.1:8080 money # http://127.0.0.1:8080
@@ -163,11 +163,14 @@ fresh command would:
rule there (another tab, a hand edit) the change is refused and you are asked 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. 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 ### 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: folder, with what the index made of it:
| Status | Meaning | | Status | Meaning |
@@ -199,7 +202,7 @@ button says **Forget** and only clears the index.
### Adding statements from the browser ### 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 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 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 hand — the folder stays the source of truth, so deleting `index.db` and
@@ -227,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 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. already in a folder are never renamed, since the index records them by path.
### Keys ### Navigating
| Key | Action | 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
| `1` – `7` | accounts · transactions · report · rule builder · rules · transfer builder · transfers | screen filters the transaction list to it.
| `/` | 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.
There is no control that tags a transaction. Tags come from `rules.toml` and 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 nowhere else, so tagging what you are looking at means writing a rule for it in
`4` — which is why that screen shows you what a glob catches before you save. 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: 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, an all-time total is the one number a spending report is least often asked for,
@@ -258,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 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 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 months`, `this month`, `last month`, then one entry per month. Click one to
step along it with `←` and `→`. report on it.
The three rolling windows run to the end of *this* month rather than to the 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, last complete one — you ask for "last 3 months" to see what is happening now,
@@ -267,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 month below `last month` is a month the index actually holds; months that a
named window above already covers are not repeated. named window above already covers are not repeated.
Click a column heading, or press `s` to cycle, to change how the rows are Click a column heading to change how the rows are arranged; the marked heading
arranged; the marked heading says which one they are arranged by: `Out ▾` says which one they are arranged by: `Out ▾` largest spend first (the default
largest spend first (the default question a spending report answers), then question a spending report answers), then `In ▾`, `Net ▾` lowest first so the
`In ▾`, `Net ▾` lowest first so the biggest losses lead, `N ▾` most biggest losses lead, `N ▾` most transactions first, and `Tag ▴` A→Z. `money
transactions first, and `Tag ▴` A→Z. `money report` takes the same choice as report` takes the same choice as `--sort out|in|net|count|tag`.
`--sort out|in|net|count|tag`.
Currency is always the outer grouping and no sort changes that — there are no 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 exchange rates here, so two currencies interleaved by amount would invite a
@@ -282,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 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 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 `money report` takes `--month` instead; there is no command-line equivalent of
the wider windows. the wider windows.
### Rule builder (`4`) ### Rule builder
Writing rules by hand means guessing what a glob will catch. This screen shows 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 — the answer as you type: the form is on the left — glob, account, tag, note —
@@ -309,13 +301,13 @@ point to narrow down.
immediately, so the rows it caught disappear from the list. The account stays 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. 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 Clicking the column headings switches the list between by name and by count,
by count, most seen first; the `↓` says which column the order is read from. most seen first; the `↓` says which column the order is read from. The two
The two answer different questions: by name finds the payee you are looking at, answer different questions: by name finds the payee you are looking at, by count
by count finds the rule worth writing next, since one pattern claiming forty finds the rule worth writing next, since one pattern claiming forty rows is
rows is worth more than the first of forty claiming one. Ties keep the worth more than the first of forty claiming one. Ties keep the alphabetical
alphabetical order, so the list does not reshuffle under you, and the choice order, so the list does not reshuffle under you, and the choice lasts until you
lasts until you change it. change it.
The account and tag fields offer completions as you type. Accounts come from 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 the folders on disk and the index; tags from every tag in use plus any named in
@@ -335,7 +327,7 @@ report leaves them out anyway.
#### Editing a rule #### 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 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 appending a new one. It keeps its position, since position still breaks ties
between equally specific rules; `rules.toml` is edited textually, so the between equally specific rules; `rules.toml` is edited textually, so the
@@ -351,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 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:… · 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**, kept`. Saving lands back on the rules screen with the new counts; **Cancel**, or
`esc`, or leaving the screen leaves the rule as it was. 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 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 are tried in — with the number of transactions it actually claims. Rules that
@@ -389,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 left alone; an edited rule keeps its comments, since they say why it is there
and changing its glob rarely changes that. 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 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 form names both sides — from account, from desc, to account, to desc — plus an
@@ -424,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 unpaired, which is the quickest way to see that a glob is wrong before you have
written the other half. written the other half.
### Transfers (`7`) ### Transfers
Every definition in file order, with what it currently pairs. Every definition in file order, with what it currently pairs.
@@ -470,6 +462,27 @@ once, both after asking. Definitions marked `⚠` are never pruned — they are
doing something, just not finishing it, and deleting one would hide the problem 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. rather than fix it. **Refresh pairing** re-reads what is currently in the index.
### 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.
```
Tags · 2 tags
Tag Txns Rules
clothes 0 #4 *ZARA* (0)
groceries 2 #1 *LIDL* (0) #2 *KAUFLAND* (1) #3 *LIDL SOFIA* (1)
```
Rules are numbered by their position in `rules.toml`, as on the rules screen,
and the count after each is how many transactions it wins — so a tag's rules
add up to its transactions, and a rule at `(0)` is matching nothing or being
beaten by a more specific one. It is the place to spot near-duplicate tags
(`grocery` beside `groceries`) and tags that several rules feed. A tag no rule
names any more — `rules.toml` was edited since the last retag — says so; the
next Retag clears it. The transfer label is not a tag and never appears here.
## rules.toml ## rules.toml
**The most specific rule wins**, so `*NIKOLA*` claims what it names even with a **The most specific rule wins**, so `*NIKOLA*` claims what it names even with a
@@ -525,7 +538,7 @@ tag = "groceries"
Every tag comes from this file, so a tag is never something you have to keep 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 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 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 `(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. is already spoken for and is left out of all three.
@@ -627,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 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): own row (for whichever period it is on):
``` ```
@@ -653,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 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. 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 `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 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 recomputed wholesale by `money retag` and after every import, and disappears
+50
View File
@@ -78,6 +78,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/report", s.read(s.report)) mux.HandleFunc("GET /api/report", s.read(s.report))
mux.HandleFunc("GET /api/rules", s.read(s.ruleList)) mux.HandleFunc("GET /api/rules", s.read(s.ruleList))
mux.HandleFunc("GET /api/tags", s.read(s.tagList))
mux.HandleFunc("POST /api/rules/preview", s.read(s.rulePreview)) mux.HandleFunc("POST /api/rules/preview", s.read(s.rulePreview))
mux.HandleFunc("POST /api/rules", s.write(s.createRule)) mux.HandleFunc("POST /api/rules", s.write(s.createRule))
mux.HandleFunc("PUT /api/rules/{pos}", s.write(s.editRule)) mux.HandleFunc("PUT /api/rules/{pos}", s.write(s.editRule))
@@ -556,6 +557,55 @@ func (s *Server) ruleList(*http.Request) (any, error) {
return map[string]any{"rules": rows}, nil return map[string]any{"rules": rows}, nil
} }
type tagRule struct {
Pos int `json:"pos"` // file position, as the rules screen numbers it
Pattern string `json:"pattern"`
Account string `json:"account"`
Usage int `json:"usage"`
}
type tagEntry struct {
Tag string `json:"tag"`
Transactions int `json:"transactions"`
Rules []tagRule `json:"rules"`
}
// tagList is every distinct tag — the ones the index holds and the ones
// rules.toml names — with how many transactions carry it and the rules that
// write it. A tag is only ever what a rule wrote, so the transfer label is
// never among them, and a tag no rule names any more is one a retag will
// clear.
func (s *Server) tagList(*http.Request) (any, error) {
txns, err := s.db.Transactions(store.Filter{})
if err != nil {
return nil, err
}
counts := map[string]int{}
for _, t := range txns {
if t.RuleTag != "" {
counts[t.RuleTag]++
}
}
rules := map[string][]tagRule{}
usage := s.engine.Usage(txns)
for i, r := range s.engine.Rules() {
rules[r.Tag] = append(rules[r.Tag], tagRule{Pos: i, Pattern: rulePattern(r), Account: r.Account, Usage: usage[i]})
}
tags, err := s.knownTags()
if err != nil {
return nil, err
}
rows := make([]tagEntry, 0, len(tags))
for _, tag := range tags {
rs := rules[tag]
if rs == nil {
rs = []tagRule{}
}
rows = append(rows, tagEntry{Tag: tag, Transactions: counts[tag], Rules: rs})
}
return map[string]any{"tags": rows}, nil
}
// rulePattern renders whichever patterns a rule sets, labelled so a type rule // rulePattern renders whichever patterns a rule sets, labelled so a type rule
// is not mistaken for a description one. // is not mistaken for a description one.
func rulePattern(r config.Rule) string { func rulePattern(r config.Rule) string {
+53
View File
@@ -461,3 +461,56 @@ to_desc = "*FROM CHECKING*"
t.Errorf("oldest leg = %+v", oldest) t.Errorf("oldest leg = %+v", oldest)
} }
} }
// The tags screen lists each tag once, with the transactions carrying it and
// every rule that writes it — a shadowed one at zero — and never the
// transfer label, which no rule wrote.
func TestTagListGroupsRulesByTag(t *testing.T) {
_, h := newTestServer(t, `
[[rule]]
match = "*LIDL*"
tag = "groceries"
[[rule]]
match = "*KAUFLAND*"
tag = "groceries"
[[rule]]
match = "*LIDL SOFIA*"
tag = "groceries"
[[rule]]
match = "*ZARA*"
tag = "clothes"
[[transfer]]
from_account = "checking"
from_desc = "*TO SAVINGS*"
to_account = "savings"
to_desc = "*FROM CHECKING*"
`,
fixtureTxn{"checking", "2026-02-01", "LIDL SOFIA", -1000},
fixtureTxn{"checking", "2026-02-02", "KAUFLAND", -500},
fixtureTxn{"checking", "2026-02-03", "TO SAVINGS", -100},
fixtureTxn{"savings", "2026-02-03", "FROM CHECKING", 100},
)
var res struct{ Tags []tagEntry }
call(t, h, "GET", "/api/tags", nil, http.StatusOK, &res)
if len(res.Tags) != 2 || res.Tags[0].Tag != "clothes" || res.Tags[1].Tag != "groceries" {
t.Fatalf("tags = %+v, want clothes and groceries only", res.Tags)
}
clothes, groceries := res.Tags[0], res.Tags[1]
if clothes.Transactions != 0 || len(clothes.Rules) != 1 {
t.Errorf("clothes = %+v, want its rule and no transactions", clothes)
}
if groceries.Transactions != 2 || len(groceries.Rules) != 3 {
t.Fatalf("groceries = %+v, want 2 transactions from 3 rules", groceries)
}
usage := map[string]int{}
for _, r := range groceries.Rules {
usage[r.Pattern] = r.Usage
}
if usage["*LIDL*"] != 0 || usage["*LIDL SOFIA*"] != 1 || usage["*KAUFLAND*"] != 1 {
t.Errorf("usage = %v, want *LIDL* shadowed by *LIDL SOFIA*", usage)
}
}
+56 -80
View File
@@ -12,6 +12,7 @@ const VIEWS = [
{ id: 'rules', title: 'Rules', mount: mountRules }, { id: 'rules', title: 'Rules', mount: mountRules },
{ id: 'transfer', title: 'Transfer builder', mount: mountTransferBuilder }, { id: 'transfer', title: 'Transfer builder', mount: mountTransferBuilder },
{ id: 'transfers', title: 'Transfers', mount: mountTransfers }, { id: 'transfers', title: 'Transfers', mount: mountTransfers },
{ id: 'tags', title: 'Tags', mount: mountTags },
]; ];
const state = { const state = {
@@ -174,7 +175,7 @@ function renderNav() {
nav.replaceChildren(...VIEWS.map((v, i) => h('a', { nav.replaceChildren(...VIEWS.map((v, i) => h('a', {
href: '#' + v.id, href: '#' + v.id,
class: v.id === state.view ? 'active' : null, class: v.id === state.view ? 'active' : null,
}, h('kbd', {}, String(i + 1)), v.title))); }, v.title)));
} }
function go(id) { function go(id) {
@@ -188,8 +189,8 @@ function go(id) {
function show() { function show() {
const id = location.hash.slice(1) || 'accounts'; const id = location.hash.slice(1) || 'accounts';
const view = VIEWS.find((v) => v.id === id) || VIEWS[0]; const view = VIEWS.find((v) => v.id === id) || VIEWS[0];
// Leaving the builder mid-edit abandons the edit, as esc does: a new rule // Leaving the builder mid-edit abandons the edit, as Cancel does: a new
// must not start out as a copy of the one being edited. // rule must not start out as a copy of the one being edited.
if (view.id !== 'rule' && state.rule.editing) state.rule = blankRule(); if (view.id !== 'rule' && state.rule.editing) state.rule = blankRule();
state.view = view.id; state.view = view.id;
renderNav(); renderNav();
@@ -205,12 +206,16 @@ async function refreshAll() {
// --- import and retag ----------------------------------------------------- // --- 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) { async function runImport(force) {
if (state.busy) return; if (state.busy) return;
state.busy = true; state.busy = true;
const btn = document.getElementById('import'); const buttons = ['import', 'reread'].map((id) => document.getElementById(id));
btn.disabled = true; const btn = buttons[force ? 1 : 0];
btn.textContent = 'Importing…'; 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'); setStatus('importing… parsing PDF statements can take a while');
try { try {
const res = await api('POST', '/api/import', { force: !!force }); const res = await api('POST', '/api/import', { force: !!force });
@@ -223,8 +228,8 @@ async function runImport(force) {
setError(e); setError(e);
} finally { } finally {
state.busy = false; state.busy = false;
btn.disabled = false; for (const b of buttons) b.disabled = false;
btn.textContent = 'Import'; btn.textContent = label;
} }
} }
@@ -458,7 +463,7 @@ function scopeToolbar(onChange) {
select.value = state.filter.account; select.value = state.filter.account;
const search = h('input', { const search = h('input', {
type: 'search', id: 'search', placeholder: 'Filter by description ( / )', type: 'search', id: 'search', placeholder: 'Filter by description',
value: state.filter.search, value: state.filter.search,
oninput: debounce((e) => { state.filter.search = e.target.value.trim(); onChange(); }, 200), oninput: debounce((e) => { state.filter.search = e.target.value.trim(); onChange(); }, 200),
}); });
@@ -467,7 +472,7 @@ function scopeToolbar(onChange) {
onchange: (e) => { state.filter.untagged = e.target.checked; onChange(); }, onchange: (e) => { state.filter.untagged = e.target.checked; onChange(); },
}); });
return h('div', { class: 'toolbar' }, select, search, 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 --------------------------------------------------------- // --- transactions ---------------------------------------------------------
@@ -570,7 +575,7 @@ function mountReport(main) {
// An empty period is not an empty index, and the default window is the // 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. // 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 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.'); : 'Nothing to report yet.\n\nImport some statements first.');
} else { } else {
const order = data.orders.find((o) => o.name === data.order); const order = data.orders.find((o) => o.name === data.order);
@@ -610,21 +615,7 @@ function mountReport(main) {
} catch (e) { setError(e); } } catch (e) { setError(e); }
} }
refresh(); refresh();
return { return { refresh };
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 --------------------------------------------------------- // --- form helpers ---------------------------------------------------------
@@ -711,8 +702,8 @@ function mountRuleBuilder(main) {
} }
preview.replaceChildren(h('table', {}, preview.replaceChildren(h('table', {},
h('thead', {}, h('tr', {}, h('th', {}, ''), h('thead', {}, h('tr', {}, h('th', {}, ''),
h('th', { class: 'sortable', onclick: toggle, title: 'Sort by name / by count (ctrl+s)' }, descTitle), 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 (ctrl+s)' }, 'N' + (byCount ? ' ↓' : '')))), h('th', { class: 'num sortable', onclick: toggle, title: 'Sort by name / by count' }, 'N' + (byCount ? ' ↓' : '')))),
h('tbody', {}, res.rows.map((row) => h('tr', { h('tbody', {}, res.rows.map((row) => h('tr', {
class: 'clickable', class: 'clickable',
title: 'Use this description as the glob', title: 'Use this description as the glob',
@@ -757,23 +748,7 @@ function mountRuleBuilder(main) {
inputs.match.focus(); inputs.match.focus();
refreshNow(); refreshNow();
return { return { refresh: refreshNow };
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 ---------------------------------------------------------------- // --- rules ----------------------------------------------------------------
@@ -937,7 +912,7 @@ function mountTransferBuilder(main) {
inputs.fromAccount.focus(); inputs.fromAccount.focus();
refreshNow(); refreshNow();
return { refresh: refreshNow, form: true }; return { refresh: refreshNow };
} }
// --- transfers ------------------------------------------------------------ // --- transfers ------------------------------------------------------------
@@ -1049,43 +1024,44 @@ function mountTransfers(main) {
return { refresh }; return { refresh };
} }
// --- keyboard ------------------------------------------------------------- // --- tags -----------------------------------------------------------------
// Single-key shortcuts, where they do not fight the browser. A builder is a // mountTags lists every distinct tag with how many transactions carry it and
// form, so outside of chords its keys belong to the field being typed in. // the rules that write it, numbered by file position as on the rules screen.
document.addEventListener('keydown', (e) => { function mountTags(main) {
const t = e.target; const title = h('h2');
const typing = t instanceof HTMLInputElement || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement; const body = h('div');
if (current && current.key && current.key(e, typing)) { main.append(title, body);
e.preventDefault();
return; async function refresh() {
let tags;
try {
tags = (await api('GET', '/api/tags')).tags;
} catch (e) { setError(e); return; }
title.replaceChildren('Tags ', h('span', { class: 'sub' }, `· ${tags.length} tags`));
if (!tags.length) {
body.replaceChildren(h('div', { class: 'panel empty' }, 'No tags yet.\n\nA tag comes from a rule: build one on the rule builder.'));
return;
}
body.replaceChildren(h('div', { class: 'panel' }, h('table', {},
h('thead', {}, h('tr', {}, h('th', {}, 'Tag'), h('th', { class: 'num' }, 'Txns'), h('th', {}, 'Rules'))),
h('tbody', {}, tags.map((t) => h('tr', {},
h('td', { class: t.transactions ? null : 'muted' }, t.tag),
h('td', { class: 'num' + (t.transactions ? '' : ' muted') }, t.transactions),
h('td', { class: 'desc' }, t.rules.length
? t.rules.map((r) => h('span', { class: 'tag-rule' + (r.usage ? '' : ' muted') },
h('span', { class: 'muted' }, `#${r.pos + 1} `),
h('span', { class: 'mono' }, r.pattern),
r.account ? h('span', { class: 'muted' }, ` on ${r.account}`) : null,
` (${r.usage})`))
: h('span', { class: 'warn' }, 'no rule writes it — Retag clears it'))))))));
} }
if (typing || e.ctrlKey || e.metaKey || e.altKey) return; refresh();
return { refresh };
}
if (e.key >= '1' && e.key <= String(VIEWS.length)) { document.getElementById('import').addEventListener('click', () => runImport(false));
go(VIEWS[Number(e.key) - 1].id); document.getElementById('reread').addEventListener('click', () => runImport(true));
} 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); document.getElementById('retag').addEventListener('click', retag);
window.addEventListener('hashchange', show); window.addEventListener('hashchange', show);
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#4b46c8"/>
<g fill="#fff">
<rect x="7" y="8" width="18" height="4" rx="2"/>
<rect x="7" y="14" width="11" height="4" rx="2"/>
<rect x="7" y="20" width="15" height="4" rx="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 309 B

+2
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>money</title> <title>money</title>
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
@@ -12,6 +13,7 @@
<nav id="nav" aria-label="Screens"></nav> <nav id="nav" aria-label="Screens"></nav>
<div class="actions"> <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="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> <button type="button" id="import" class="primary" title="Import new statements from every account folder">Import</button>
</div> </div>
</header> </header>
+2 -1
View File
@@ -68,7 +68,6 @@ button, input, select { font: inherit; color: inherit; }
} }
#nav a:hover { color: var(--text); background: var(--row-hover); } #nav a:hover { color: var(--text); background: var(--row-hover); }
#nav a.active { color: var(--accent); background: var(--accent-soft); font-weight: 600; } #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; } .actions { display: flex; gap: 8px; }
button { button {
@@ -145,6 +144,8 @@ tr.total td { font-weight: 600; border-top: 1px solid var(--border); }
tr.excluded td { color: var(--muted); } tr.excluded td { color: var(--muted); }
tr.excluded.first td { border-top: 1px dashed var(--border); } tr.excluded.first td { border-top: 1px dashed var(--border); }
td.actions { text-align: right; } td.actions { text-align: right; }
.mono { font-family: var(--mono); }
.tag-rule { display: inline-block; margin-right: 14px; white-space: nowrap; }
tr.legs > td { padding: 0 10px 10px 36px; background: var(--bg); } tr.legs > td { padding: 0 10px 10px 36px; background: var(--bg); }
tr.legs:hover { background: none; } tr.legs:hover { background: none; }
table.sub th { background: var(--bg); font-weight: 500; color: var(--muted); } table.sub th { background: var(--bg); font-weight: 500; color: var(--muted); }