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
+53
View File
@@ -461,3 +461,56 @@ to_desc = "*FROM CHECKING*"
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)
}
}