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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user