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>
This commit is contained in:
2026-10-02 23:30:38 +02:00
co-authored by Claude Opus 5.5
parent ba7e0e2b42
commit 3d72a54be1
5 changed files with 169 additions and 5 deletions
+27 -5
View File
@@ -133,11 +133,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
@@ -231,7 +232,7 @@ already in a folder are never renamed, since the index records them by path.
| Key | Action | | Key | Action |
| --- | --- | | --- | --- |
| `1` – `7` | accounts · transactions · report · rule builder · rules · transfer builder · transfers | | `1` – `8` | accounts · transactions · report · rule builder · rules · transfer builder · transfers · tags |
| `/` | filter by description | | `/` | filter by description |
| `u` | show only untagged transactions (matched transfer legs are not among them) | | `u` | show only untagged transactions (matched transfer legs are not among them) |
| `a` | clear the account filter | | `a` | clear the account filter |
@@ -470,6 +471,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 (`8`)
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
+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)
}
}
+37
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 = {
@@ -1049,6 +1050,42 @@ function mountTransfers(main) {
return { refresh }; return { refresh };
} }
// --- tags -----------------------------------------------------------------
// mountTags lists every distinct tag with how many transactions carry it and
// the rules that write it, numbered by file position as on the rules screen.
function mountTags(main) {
const title = h('h2');
const body = h('div');
main.append(title, body);
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'))))))));
}
refresh();
return { refresh };
}
// --- keyboard ------------------------------------------------------------- // --- keyboard -------------------------------------------------------------
// Single-key shortcuts, where they do not fight the browser. A builder is a // Single-key shortcuts, where they do not fight the browser. A builder is a
+2
View File
@@ -145,6 +145,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); }