Compare commits
3
Commits
ba7e0e2b42
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a086f9242d | ||
|
|
c271cf2c8f | ||
|
|
3d72a54be1 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+54
-78
@@ -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();
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (typing || e.ctrlKey || e.metaKey || e.altKey) return;
|
body.replaceChildren(h('div', { class: 'panel' }, h('table', {},
|
||||||
|
h('thead', {}, h('tr', {}, h('th', {}, 'Tag'), h('th', { class: 'num' }, 'Txns'), h('th', {}, 'Rules'))),
|
||||||
if (e.key >= '1' && e.key <= String(VIEWS.length)) {
|
h('tbody', {}, tags.map((t) => h('tr', {},
|
||||||
go(VIEWS[Number(e.key) - 1].id);
|
h('td', { class: t.transactions ? null : 'muted' }, t.tag),
|
||||||
} else if (e.key === '/') {
|
h('td', { class: 'num' + (t.transactions ? '' : ' muted') }, t.transactions),
|
||||||
if (state.view !== 'txns' && state.view !== 'report') go('txns');
|
h('td', { class: 'desc' }, t.rules.length
|
||||||
setTimeout(() => { const s = document.getElementById('search'); if (s) s.focus(); });
|
? t.rules.map((r) => h('span', { class: 'tag-rule' + (r.usage ? '' : ' muted') },
|
||||||
} else if (e.key === 'u') {
|
h('span', { class: 'muted' }, `#${r.pos + 1} `),
|
||||||
state.filter.untagged = !state.filter.untagged;
|
h('span', { class: 'mono' }, r.pattern),
|
||||||
if (state.view === 'txns' || state.view === 'report') show();
|
r.account ? h('span', { class: 'muted' }, ` on ${r.account}`) : null,
|
||||||
else go('txns');
|
` (${r.usage})`))
|
||||||
} else if (e.key === 'a') {
|
: h('span', { class: 'warn' }, 'no rule writes it — Retag clears it'))))))));
|
||||||
state.filter.account = '';
|
}
|
||||||
if (state.view === 'txns' || state.view === 'report') show();
|
refresh();
|
||||||
} else if (e.key === 'i') {
|
return { refresh };
|
||||||
runImport(false);
|
|
||||||
} else if (e.key === 'r') {
|
|
||||||
retag();
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('import').addEventListener('click', (e) => runImport(e.shiftKey));
|
document.getElementById('import').addEventListener('click', () => runImport(false));
|
||||||
document.getElementById('import').title += ' (shift-click re-parses unchanged files too)';
|
document.getElementById('reread').addEventListener('click', () => runImport(true));
|
||||||
document.getElementById('retag').addEventListener('click', retag);
|
document.getElementById('retag').addEventListener('click', retag);
|
||||||
window.addEventListener('hashchange', show);
|
window.addEventListener('hashchange', show);
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user