diff --git a/README.md b/README.md index 9a457f3..67554bc 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,12 @@ combined with `--wide`, whose columns all belong to a single transaction. ## 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, -transfer builder and transfers. It runs the same code as the CLI: amounts are formatted, -globs matched and transfers paired on the server, and the page only shows the -answers. It is one binary with the page built in; nothing else to deploy. +transfer builder, transfers and tags. It runs the same code as the CLI: amounts +are formatted, globs matched and transfers paired on the server, and the page +only shows the answers. It is one binary with the page built in; nothing else to +deploy. ``` 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 | | --- | --- | -| `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 | | `u` | show only untagged transactions (matched transfer legs are not among them) | | `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 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 **The most specific rule wins**, so `*NIKOLA*` claims what it names even with a diff --git a/internal/web/server.go b/internal/web/server.go index c5e121f..d9ce96e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -78,6 +78,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/report", s.read(s.report)) 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", s.write(s.createRule)) 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 } +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 // is not mistaken for a description one. func rulePattern(r config.Rule) string { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 8532d65..bdb98ed 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -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) + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 298cdd9..9071dec 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -12,6 +12,7 @@ const VIEWS = [ { id: 'rules', title: 'Rules', mount: mountRules }, { id: 'transfer', title: 'Transfer builder', mount: mountTransferBuilder }, { id: 'transfers', title: 'Transfers', mount: mountTransfers }, + { id: 'tags', title: 'Tags', mount: mountTags }, ]; const state = { @@ -1049,6 +1050,42 @@ function mountTransfers(main) { 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 ------------------------------------------------------------- // Single-key shortcuts, where they do not fight the browser. A builder is a diff --git a/internal/web/static/style.css b/internal/web/static/style.css index d560b6b..85d7d11 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -145,6 +145,8 @@ tr.total td { font-weight: 600; border-top: 1px solid var(--border); } tr.excluded td { color: var(--muted); } tr.excluded.first td { border-top: 1px dashed var(--border); } 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:hover { background: none; } table.sub th { background: var(--bg); font-weight: 500; color: var(--muted); }