diff --git a/CLAUDE.md b/CLAUDE.md index 14c34a9..b562855 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,8 @@ Statement-driven personal finance tracker. A data directory holds one folder per account; statements dropped into those folders are parsed into a rebuildable -SQLite index, categorised by ordered glob rules, and browsed in a Bubble Tea TUI. +SQLite index, categorised by ordered glob rules, and browsed in a web app +(`money serve`, which is also the default command). Read `README.md` first — it is the user-facing reference for every config key. This file covers what the code assumes and why. @@ -10,7 +11,7 @@ This file covers what the code assumes and why. ## Layout ``` -cmd/money/main.go subcommands; the TUI is the default +cmd/money/main.go subcommands; serve is the default internal/config rules.toml (rules + transfers), account.toml, XDG config, data-root resolution internal/model Account, Transaction, amount formatting, description normalisation internal/glob the `*` / `?` matcher used by rules (two-pointer, no exponential blowup) @@ -20,7 +21,6 @@ internal/importer directory walk, dedupe, balance checks internal/rules applies ordered rules, writing rule_tag internal/transfers pairs the two legs of a movement, writing the transfers table internal/report per-tag aggregation -internal/tui Bubble Tea models internal/web `money serve`: JSON API + embedded single page (static/) ``` @@ -59,7 +59,7 @@ than forgiven.** That is the condition on the tolerance existing at all: the pair leaves the report entirely, so a difference swallowed inside one would be spending that never appears anywhere. `Pair.Fee` is what left less what arrived, and `report.Excluded` carries it out per currency alongside the legs — -named on the `money report` line and on its own row under the TUI's report. +named on the `money report` line and on its own row under the web report. Nothing may pair on a mismatch without that difference reaching `Excluded`. It is a fee and not a tag: no rule produces it, `report.ByTag` never sees it, and it must not be turned into a synthetic transaction to make the total @@ -145,49 +145,41 @@ hand, while a narrower one is meant to take precedence and does. **The report's period narrows the report and nothing else.** The screen opens on last month, and `←`/`→` step along `report.Periods` — the named windows, -then the months the index holds. `reloadReport` therefore runs its *own* query -rather than reusing the rows the transaction list is showing: the two share -`m.filter` (account, search, untagged) and differ only in the date bounds, so -opening on last month must not hide the rest of the index from the list beside -it. Nothing may put the period into `m.filter`, which is exactly what would -make it leak. Covered by `TestReportPeriodLeavesTheTransactionListAlone`. +then the months the index holds. The report and the transaction list share a +scope (account, search, untagged), which `web.filterFrom` reads and +`state.filter` holds in the page; the period is read by the report handler +alone and kept in `state.report`, so opening on last month cannot hide the +rest of the index from the list. Nothing may put the period into that shared +scope, which is exactly what would make it leak. Covered by `TestReportPeriodLeavesTransactionsAlone`. The axis is built from `store.Months` over the *whole* index, not from the rows in view, or it would grow and shrink as the account or search filter changed and -move under the cursor. It is rebuilt on every `reload` (an import can reach -further back) but keeps the window the user was on. The windows are relative to -today, never to the newest statement: "last month" with nothing in it reports -nothing and says so, because silently answering for a month nobody asked for is -worse than an empty screen. That is also why an empty period and an empty index -give different messages — one asks for another period, the other for an import. +move under the cursor. It is rebuilt on every request (an import can reach +further back), and the page names the period by its label rather than its +position, so a grown axis keeps the window the user was on. The windows are +relative to today, never to the newest statement: "last month" with nothing in +it reports nothing and says so, because silently answering for a month nobody +asked for is worse than an empty screen. That is also why an empty period and +an empty index give different messages — one asks for another period, the +other for an import. **The report's sort rearranges rows; it never changes which rows there are.** `report.Order` is passed to `ByTag` and is deliberately *not* part of -`store.Filter` or `m.filter` — the period decides what is counted, the order -only how it is listed, and merging the two would make a sort able to hide a -tag. Currency stays the outer sort key under every order, because there are no +`store.Filter` or the shared scope — the period decides what is counted, the +order only how it is listed, and merging the two would make a sort able to hide +a tag. Currency stays the outer sort key under every order, because there are no exchange rates to compare two currencies by, and every order falls back to the tag so ties keep a fixed position instead of shuffling between reloads. -`Order.Column` is what lets the TUI mark the sorted heading without keeping its +`Order.Column` is what lets the page mark the sorted heading without keeping its own copy of that mapping; a new order needs a column of its own, which `TestOrderColumnsAreDistinct` checks. -**The builders are forms, so the global keymap must not apply there.** -`Update` routes to `updateRules` / `updateTransfers` before `updateNormal` -whenever the view is `viewRules` or `viewTransfers`, or typing `q` would quit -and `i` would start an import. Any new full-screen input needs the same -treatment. It cuts the other way too: a command *inside* a builder has to be a -chord — `ctrl+s` re-sorts the preview — because every printable key belongs to -the field being typed in. - -**In the rule builder, `tab` completes first and moves focus second.** The -account and tag fields use `textinput.ShowSuggestions`, whose own `AcceptSuggestion` -key is `tab` and whose `NextSuggestion`/`PrevSuggestion` are `up`/`down` — all -three already meant something here. So `updateRules` intercepts `tab` and calls -`acceptCompletion` before falling back to `setRuleFocus`, keeps `up`/`down` on -field navigation, and lets `ctrl+n` / `ctrl+p` through to the input for cycling. -`SetValue` does not re-match the suggestion list, so `acceptCompletion` re-sets -it afterwards or `ctrl+n` would offer candidates that no longer fit the value. +**The single-key shortcuts must not fire while typing.** The page's `keydown` +handler ignores them whenever the target is an input, or typing `i` in the tag +field would start an import. A view's own `key` hook runs first and is told +whether the user is typing, so a command *inside* a builder has to be a chord — +`ctrl+s` re-sorts the preview — because every printable key belongs to the +field being typed in. **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 @@ -211,37 +203,36 @@ writer goes through `writeFileAtomic`, and both editors re-parse the result before replacing the file. **An edit must not lose what the builder does not show.** The form has four -fields and a `Rule` has five, so `saveRule` carries `Type` through from the rule -being edited and the form says it is doing so. Nothing may round-trip a rule -through those four fields alone — a pattern the user was never shown is not a -pattern they chose to remove. A new field on `Rule` needs the same treatment or -a field of its own. Covered by `TestRuleEditKeepsTypePattern`. +fields and a `Rule` has five, so `editRule` takes `Type` from the rule on disk — +never from the request — and the form says it is keeping it. Nothing may +round-trip a rule through those four fields alone — a pattern the user was +never shown is not a pattern they chose to remove. A new field on `Rule` needs +the same treatment or a field of its own. Covered by `TestEditKeepsTypePattern`. **The rule builder's preview is what the rule is judged against, which is not always "what is untagged".** For a new rule those are the same thing. For an edit they are not: the rule's own transactions are tagged, so an untagged-only -preview would be empty for a rule that works. `reloadPreviewGroups` adds them -back, and `refreshRulePreview` keeps the ones the new glob stops catching on -screen marked `−` instead of dropping them silently, because giving one up is -the decision being made. +preview would be empty for a rule that works. `previewGroups` adds them back, +and `rulePreview` keeps the ones the new glob stops catching on screen marked +`−` instead of dropping them silently, because giving one up is the decision +being made. Covered by `TestEditPreviewShowsWhatIsLetGo`. -**The web app is a second frontend, not a second implementation.** -`internal/web` ports the TUI's screens over HTTP, and everything that decides -something stays in Go: amounts are formatted server-side (`money` is text plus -a sign, never a number the browser could add up), the rule builder's preview is -matched by `glob.Match` on the server rather than re-implemented in JS, and the -transfer preview runs `transfers.Analyze`. Keep it that way — a JS glob or a -JS sum is a second copy of a rule that would drift from the first. +**The web app is a frontend, not a second implementation.** Everything that +decides something stays in Go: amounts are formatted server-side (`money` is +text plus a sign, never a number the browser could add up), the rule builder's +preview is matched by `glob.Match` on the server rather than re-implemented in +JS, and the transfer preview runs `transfers.Analyze`. Keep it that way — a +JS glob or a JS sum is a second copy of a rule that would drift from the +first. `static/app.js` only lays out what the API returns. -The web server outlives edits to rules.toml in a way the TUI does not, so -`/api/retag` and `/api/import` re-read it first (import also re-reads the +The server outlives edits to rules.toml in a way a one-shot command does not, +so `/api/retag` and `/api/import` re-read it first (import also re-reads the account folders), and `/api/overview` reports `stale` when the file on disk no longer equals what the engines hold. Every edit and delete sends the rule or transfer it showed alongside the position, and is refused with 409 unless rules.toml still holds exactly that there — a position from a stale page can -otherwise name a different rule. An edit takes `Type` from the rule *on disk*, -never from the request, which is the web side of `TestRuleEditKeepsTypePattern`. +otherwise name a different rule. Covered by `TestStalePositionIsRefused`. There is no auth by design (`--addr` defaults to loopback). Non-GET requests must be `application/json`, which is what keeps a cross-site form from posting @@ -306,20 +297,18 @@ migrates it, so a demo root left over from an earlier build either carries dead columns or fails with `no such column`, depending on which way the schema moved. -### Driving the TUI in tests +### Testing the web app -Prefer feeding `tea.Msg` values to `Model.Update` directly — that is how every -existing TUI test works, and it covers the keymap without a terminal. +Drive the API through `Server.Handler()` with `httptest` — `newTestServer` in +`internal/web/server_test.go` builds a data root and an index tagged and paired +as an import would leave them, with "today" fixed so the report's default +window is predictable. That is how every web test works, and since the page +only lays out what the API returns, it is where behaviour gets tested. -If a real terminal is genuinely needed, note that piping into `script` does not -deliver keystrokes. Use a pty and answer the two capability queries Bubble Tea -sends on startup, or the program blocks before its first render: - -- `ESC]11;?` (background colour) → reply `ESC]11;rgb:1e1e/1e1e/1e1e ESC\` -- `ESC[6n` (cursor position) → reply `ESC[1;1R` - -Also delete the index first, or the import you are trying to observe will be -skipped by checksum and finish instantly. +For a look at the real page, run `money --root serve --addr +127.0.0.1:` against a throwaway root. Delete the index first if you want +to watch an import do work, or it will be skipped by checksum and finish +instantly. ## Conventions diff --git a/README.md b/README.md index 3a73f9a..ba8a734 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,7 @@ only if you hand the binary to someone else. ## Usage ``` -money # open the TUI (default) -money serve # the same screens as a web app, on 127.0.0.1:8080 +money # the web app on 127.0.0.1:8080 (same as `money serve`) money serve --addr :8080 # listen on every interface (there is no login) money import # extract new transactions from every statement money import --force # re-parse statements even if unchanged @@ -103,7 +102,7 @@ money config # which data root is in use, and why An account folder appears in the app only once its statements have been imported — creating an `account.toml` is not enough on its own. Run -`money import` (or press `i` in the TUI) after adding one. +`money import` (or press Import in the web app) after adding one. `--uniq` turns `ls` into a list of patterns still to write rather than a list of rows to read: one line per distinct description, since fifty visits to the same @@ -132,20 +131,58 @@ It composes with the other filters (`--account`, `--month`, `--search`), and `--limit` caps the rows printed, saying how many it held back. It cannot be combined with `--wide`, whose columns all belong to a single transaction. -### TUI keys +## The web app + +`money serve`, which is also what `money` runs with no command, puts seven +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. + +``` +money # http://127.0.0.1:8080 +money serve --root /srv/money --addr 0.0.0.0:8080 +``` + +**There is no authentication.** Anyone who can reach the port can read every +transaction, rewrite `rules.toml` and start an import, which is why it listens +on loopback unless `--addr` says otherwise. To use it from elsewhere, put it +behind a reverse proxy that does the logging in, or reach it over SSH / a VPN. +Requests that change anything must be sent as JSON, so another website open in +the same browser cannot post a form to it. + +The server outlives hand edits to `rules.toml`, so it treats the file the way a +fresh command would: + +- **Retag and Import re-read `rules.toml`** (and Import the account folders) + before running, exactly as `money retag` / `money import` would. Until then a + banner says the file on disk no longer matches what the index was derived + from. +- **Edits and deletes check the file first.** They go by rule position, and the + page also sends the rule it showed you; if `rules.toml` no longer holds that + 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. + +Shift-click **Import** for `import --force`. + +### Keys | Key | Action | | --- | --- | -| `1` `2` `3` `4` `5` `6` `7` / `tab` | accounts · transactions · report · rule builder · rules · transfer builder · transfers | -| `enter` | open the selected account (accounts view) | +| `1` – `7` | accounts · transactions · report · rule builder · rules · transfer builder · transfers | | `/` | 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 view) | -| `s` | change how the report is sorted (report view) | -| `i` | import · `r` re-apply rules · `q` quit | +| `←` `→` | 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 | -There is no key that tags a transaction. Tags come from `rules.toml` and +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 nowhere else, so tagging what you are looking at means writing a rule for it on `4` — which is why that screen shows you what a glob catches before you save. @@ -157,34 +194,23 @@ and the month that has just ended is the last one your statements can be complete for. If you have not downloaded that month yet, the screen says the period is empty rather than quietly showing you a different one. -`←` and `→` move along the time axis on the left of the totals, which runs from -the widest window down to the oldest month in the index: - -``` - Period Tag Cur Out In Net N - all time groceries EUR 10.00 0.00 -10.00 1 - this year TOTAL EUR 10.00 0.00 -10.00 - last 12 months - last 3 months - this month -▸ last month - 2026-05 - 2026-04 -``` +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 +months`, `this month`, `last month`, then one entry per month. Click one, or +step along it with `←` and `→`. 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, 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 -named window above already covers are not repeated. The axis is hidden on a -terminal too narrow for it and the totals both, where the title still names the -period. +named window above already covers are not repeated. -`s` cycles how the rows are arranged, and the marked column heading says which -one they are arranged by: `Out ▾` largest spend first (the default question a -spending report answers), then `In ▾`, `Net ▾` lowest first so the biggest -losses lead, `N ▾` most transactions first, and `Tag ▴` A→Z. `money report` -takes the same choice as `--sort out|in|net|count|tag`. +Click a column heading, or press `s` to cycle, to change how the rows are +arranged; the marked heading says which one they are arranged by: `Out ▾` +largest spend first (the default question a spending report answers), then +`In ▾`, `Net ▾` lowest first so the biggest losses lead, `N ▾` most +transactions first, and `Tag ▴` A→Z. `money report` takes the same choice as +`--sort out|in|net|count|tag`. 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 @@ -192,10 +218,9 @@ comparison between numbers that cannot be compared. Rows that tie fall back to the tag, so they keep a fixed position rather than shuffling between reloads. The sort rearranges rows; it never changes which rows there are. -The period narrows the report and only the report. The account filter (`a`, -`enter`), the search (`/`) and the untagged toggle (`u`) are shared with the -transaction list as before, so opening on last month does not hide the rest of -the index from `2`. +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 +month does not hide the rest of the index from `2`. `money report` takes `--month` instead; there is no command-line equivalent of the wider windows. @@ -203,9 +228,10 @@ the wider windows. ### Rule builder (`4`) 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, and on the right the -still-untagged descriptions the glob currently matches, marked `▸`, grouped by -description, under a running "*n* of *m* descriptions match" count. +the answer as you type: the form is on the left — glob, account, tag, note — +and on the right the still-untagged descriptions the glob currently matches, +marked `▸`, grouped by description, under a running "*n* of *m* descriptions +match" count. The list narrows as you type, so what is on screen is what the rule would claim — nothing else is left there to read past. The count keeps the context @@ -214,60 +240,26 @@ glob picked two descriptions out of seven, and `0 of 7` says the glob is wrong. With the glob still empty there is nothing to filter by, and the list is every untagged description in the data — which is the other question this screen answers, and where you go looking for the next thing to write a rule for. +Clicking a description fills the glob with `*THAT DESCRIPTION*`, as a starting +point to narrow down. -``` - glob Untagged description ↓ N -╭────────────────────────────╮ ▸ LIDL SOFIA 4412 2 -│ *LIDL* │ ▸ LIDL VARNA 9911 1 -╰────────────────────────────╯ - vs. the description +`enter` (or **Save rule**) appends the rule to `rules.toml` and retags +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. - account -╭────────────────────────────╮ -│ blank = every account │ -╰────────────────────────────╯ - blank = all accounts - -▸ tag -╭────────────────────────────╮ -│ groceries │ -╰────────────────────────────╯ - tab completes · ctrl+n: 1 more - - note -╭────────────────────────────╮ -│ optional │ -╰────────────────────────────╯ - why this rule exists - - 2 of 7 descriptions match -``` - -Only `gro` was typed in the tag field; `ceries` is the ghosted completion. - -`tab` / `↑↓` move between the glob, account, tag and note fields, `pgup` / -`pgdn` scroll the list, and `enter` appends the rule to `rules.toml` and retags -immediately, so the rows it caught disappear from the list. `esc` goes back. -On a short window the form gives up its spacing and then its hints, so all four -fields stay on screen. - -`ctrl+s` switches the list between by name and by count, most seen first — a -chord rather than a letter, because every printable key belongs to the field you -are typing in. The `↓` in the header says which column the order is read from. +Clicking the column headings, or `ctrl+s`, switches the list between by name and +by count, most seen first; the `↓` says which column the order is read from. The two answer different questions: by name finds the payee you are looking at, by count finds the rule worth writing next, since one pattern claiming forty rows is worth more than the first of forty claiming one. Ties keep the alphabetical order, so the list does not reshuffle under you, and the choice lasts until you change it. -The account and tag fields complete as you type: the rest of the match is -ghosted in grey after the cursor, and `tab` (or `→` at the end of the line) -takes it. When several candidates share the prefix, the hint under the box says -how many, and `ctrl+n` / `ctrl+p` cycle through them. Nothing is committed until -you accept it, so a new tag is still just typed out in full. 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 `rules.toml`, so a tag is completable from the moment a rule mentions it — -which is what stops `groceries` acquiring a `grocery` twin. +which is what stops `groceries` acquiring a `grocery` twin. A new tag is just +typed out in full. Leaving the account blank applies the rule everywhere; filling it in also narrows the preview to that account. The preview lists only transactions no @@ -281,8 +273,8 @@ report leaves them out anyway. #### Editing a rule -`e` on the rules screen (`5`) opens the selected rule in this same form with the -fields filled in, and `enter` rewrites that rule where it sits instead of +**Edit** on the rules screen (`5`) opens the rule in this same form with the +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 between equally specific rules; `rules.toml` is edited textually, so the comments and formatting around it survive, exactly as when deleting. @@ -297,8 +289,8 @@ 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 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, and `esc` -leaves the rule as it was. +kept`. Saving lands back on the rules screen with the new counts; **Cancel**, +`esc`, or leaving the screen leaves the rule as it was. ### Rules (`5`) @@ -307,7 +299,7 @@ are tried in — with the number of transactions it actually claims. Rules that claim none are marked `✗`. ``` -money · rules · 5 rules · 2 match nothing +Rules · 5 rules · 2 match nothing # Pattern Account Tag Txns Note 1 *LIDL SOFIA* (all) groceries 3 the weekly shop @@ -326,80 +318,44 @@ deleting. Note that shadowing has nothing to do with the numbering: rule 2 would be just as dead written above rule 1, because precedence is decided by how specific a rule is and not by where it sits. -`e` opens the selected rule in the builder to edit it, `d` deletes it, and `p` -deletes every rule marked `✗` at once; both deletions ask for a `y` first. `r` -recounts against what is currently in the index, which is what you want after an -import has added rows; `rules.toml` itself is read at startup and whenever you -save a rule from the builder, so an edit made in another window needs a restart. -Editing and deleting both work on `rules.toml` textually, so your comments, -ordering and formatting survive. A comment sitting directly above a deleted rule -goes with 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 and changing its glob rarely changes that. +**Edit** opens a rule in the builder, **Delete** deletes it, and **Prune +unused** deletes every rule marked `✗` at once; both deletions ask first. +**Refresh counts** recounts against what is currently in the index. Editing and +deleting both work on `rules.toml` textually, so your comments, ordering and +formatting survive. A comment sitting directly above a deleted rule goes with +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 +and changing its glob rarely changes that. ### Transfer builder (`6`) -Same idea as the rule builder, for money moved between your own accounts. Both -sides are named, and the preview on the right shows what the pair would be: -`▸` for a movement it matches end to end, `⚠` for a leg it catches on one side -and cannot pair with anything on the other. +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 +optional tolerance and note, and the preview beside it shows what the pair +would be: `▸` for a movement it matches end to end, `⚠` for a leg it catches on +one side and cannot pair with anything on the other. ``` - from account Date Amount Movement Description -╭────────────────────────────╮ ⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS -│ checking │ ▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS -╰────────────────────────────╯ - money leaves here + Date Amount Movement Description +⚠ 2026-04-01 500.00 checking → ? TRANSFER TO SAVINGS +▸ 2026-03-01 500.00 checking → savings TRANSFER TO SAVINGS -▸ from desc -╭────────────────────────────╮ -│ *TO SAVINGS* │ -╰────────────────────────────╯ - glob vs. the leaving leg - - to account -╭────────────────────────────╮ -│ savings │ -╰────────────────────────────╯ - money arrives here - - to desc -╭────────────────────────────╮ -│ *FROM CHECKING* │ -╰────────────────────────────╯ - glob vs. the arriving leg - - tolerance % -╭────────────────────────────╮ -│ 0 │ -╰────────────────────────────╯ - 0 = amounts must match exactly - - note -╭────────────────────────────╮ -│ optional │ -╰────────────────────────────╯ - why this transfer exists - - 1 pairs · 1 unpaired +1 pairs · 1 unpaired ``` -The account fields complete exactly as the rule builder's do. `tab` / `↑↓` move -between fields, `pgup` / `pgdn` scroll the list, `enter` appends the definition -to `rules.toml` and re-pairs immediately, and `esc` goes back. Saving keeps both -account names in place and clears the two globs, since the next transfer you -write is usually the same route in the other direction. The tolerance is cleared -with them: carried over silently it would loosen a route that never asked for -one. Six fields need more room than the rule builder's four, so on a short -window this form gives up its spacing, then its hints, then the borders on the -fields you are not editing — every field stays on screen. +The account fields complete exactly as the rule builder's do. `enter` (or +**Save transfer**) appends the definition to `rules.toml` and re-pairs +immediately. Saving keeps both account names in place and clears the two globs, +since the next transfer you write is usually the same route in the other +direction. The tolerance is cleared with them: carried over silently it would +loosen a route that never asked for one. -`tolerance %` is the one field worth previewing before you save. Type a +`Tolerance %` is the one field worth previewing before you save. Type a percentage and the pairs it buys appear immediately, showing both amounts — `500.00 → 495.00` — with what they cost summarised beside the counts: ``` - 1 pairs · 0 unpaired · 5.00 in fees +1 pairs · 0 unpaired · 5.00 in fees ``` A half-written definition previews too: fill in one side and its legs show up as @@ -411,7 +367,7 @@ written the other half. Every definition in file order, with what it currently pairs. ``` -money · transfers · 4 definitions · 1 leg(s) unpaired +Transfers · 4 definitions · 1 leg(s) unpaired # From To Pairs Unpaired Tol Note 1 ⚠ checking *TO SAVINGS* savings *FROM CHECKING* 11 1 monthly saving @@ -430,51 +386,10 @@ it: row 3's two pairs were matched on slack rather than on the amount agreeing. It is blank for every definition that requires the exact amount — which is the default, so an empty column means nothing here is pairing on a mismatch. -`d` deletes the selected definition and `p` deletes every `✗` one at once, both -after a `y`. Definitions marked `⚠` are never pruned — they are doing something, -just not finishing it, and deleting one would hide the problem rather than fix -it. `r` re-pairs against what is currently in the index. - -## Web app - -`money serve` puts the TUI's seven screens in a browser — accounts, -transactions, report, rule builder, rules, transfer builder, transfers — with -the same behaviour, because it runs the same code: 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 serve # http://127.0.0.1:8080 -money --root /srv/money serve --addr 0.0.0.0:8080 -``` - -**There is no authentication.** Anyone who can reach the port can read every -transaction, rewrite `rules.toml` and start an import, which is why it listens -on loopback unless `--addr` says otherwise. To use it from elsewhere, put it -behind a reverse proxy that does the logging in, or reach it over SSH / a VPN. -Requests that change anything must be sent as JSON, so another website open in -the same browser cannot post a form to it. - -What differs from the TUI: - -- **Retag and Import re-read `rules.toml`** (and Import the account folders) - before running, exactly as a fresh `money retag` / `money import` would — the - server outlives hand edits to both. Until then a banner says the file on disk - no longer matches what the index was derived from. -- **Edits and deletes check the file first.** They go by rule position, as in - the TUI, but the page also sends the rule it showed you; if `rules.toml` no - longer holds that 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. -- Clicking a description in the rule builder's preview fills the glob with - `*THAT DESCRIPTION*`, as a starting point to narrow down. -- Shift-click **Import** for `import --force`. - -The keys still work where they do not fight the browser: `1`–`7` switch -screens, `/` searches, `u` toggles untagged, `a` clears the account filter, `i` -imports and `r` retags. On the report `←`/`→` move the period and `s` cycles -the sort (or click a column heading); in the rule builder `ctrl+s` re-sorts the -preview. Inside a form every printable key belongs to the field, as in the TUI. +**Delete** deletes a definition and **Prune unmatched** deletes every `✗` one at +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. ## rules.toml @@ -597,7 +512,7 @@ amount wins. **The difference is not forgiven, it is reported.** A pair leaves the report entirely, so a fee hidden inside one would be spending that appears nowhere at -all. `money report` names it on the excluded line and the TUI's report gives it +all. `money report` names it on the excluded line and the web app's report gives it its own row (see below). Across currencies there is no fee to compute — the two numbers are in different units — so a tolerance there means nothing and is ignored. diff --git a/cmd/money/main.go b/cmd/money/main.go index 11ad965..d5e6f8a 100644 --- a/cmd/money/main.go +++ b/cmd/money/main.go @@ -2,7 +2,7 @@ // // The data root holds one folder per account. Drop statements into those // folders, run `money import`, and tag what comes out with glob rules in -// rules.toml. Running it with no subcommand opens the TUI. +// rules.toml. Running it with no subcommand serves the web app. package main import ( @@ -28,7 +28,6 @@ import ( "git.petrovv.com/nikola/money/internal/rules" "git.petrovv.com/nikola/money/internal/store" "git.petrovv.com/nikola/money/internal/transfers" - "git.petrovv.com/nikola/money/internal/tui" "git.petrovv.com/nikola/money/internal/web" ) @@ -37,9 +36,9 @@ const usage = `money - statement-driven personal finance tracker usage: money [--root DIR] [flags] (--root may also follow the command) commands: - tui browse transactions, build the rules that tag them and the - transfers that pair them across accounts (default) - serve the same screens as a web app (--addr, default 127.0.0.1:8080) + serve browse transactions, build the rules that tag them and the + transfers that pair them across accounts, in a browser + (--addr, default 127.0.0.1:8080; the default command) import extract transactions from every statement into the index retag re-apply rules.toml: retag everything and re-pair transfers ls list transactions @@ -83,7 +82,7 @@ func run(args []string) error { } } - cmd := "tui" + cmd := "serve" if len(args) > 0 { cmd, args = args[0], args[1:] } @@ -120,8 +119,6 @@ func run(args []string) error { case "parsers": fmt.Println(strings.Join(parser.Names(), "\n")) return nil - case "tui": - return cmdTUI(abs, args) case "serve": return cmdServe(abs, args) case "help", "-h", "--help": @@ -488,15 +485,6 @@ func cmdAccounts(root string, _ []string) error { return w.Flush() } -func cmdTUI(root string, _ []string) error { - o, err := open(root) - if err != nil { - return err - } - defer o.db.Close() - return tui.Run(root, o.db, o.accounts, o.engine, o.links) -} - // takeRoot removes --root/-root (as `--root DIR` or `--root=DIR`) from a // subcommand's arguments, returning the last value given. It stops at `--`, // after which everything is positional. @@ -529,7 +517,7 @@ func takeRoot(args []string) (root string, rest []string, err error) { return root, rest, nil } -// cmdServe serves the TUI's screens over HTTP until interrupted. There is no +// cmdServe serves the web app until interrupted. There is no // authentication, so it listens on loopback unless told otherwise: anyone who // can reach the port can rewrite rules.toml and start an import. func cmdServe(root string, args []string) error { diff --git a/go.mod b/go.mod index 0972697..84c7d75 100644 --- a/go.mod +++ b/go.mod @@ -4,38 +4,16 @@ go 1.25.6 require ( github.com/BurntSushi/toml v1.6.0 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 modernc.org/sqlite v1.56.0 ) require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.3.8 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 984d6cc..9a25e49 100644 --- a/go.sum +++ b/go.sum @@ -1,76 +1,25 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= diff --git a/internal/store/store.go b/internal/store/store.go index e34f12c..55378cd 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -393,7 +393,7 @@ func (d *DB) Months() ([]string, error) { return out, rows.Err() } -// Tags lists every tag in use, for completion in the TUI. +// Tags lists every tag in use, for completion in the rule builder. func (d *DB) Tags() ([]string, error) { rows, err := d.sql.Query(` SELECT DISTINCT rule_tag FROM transactions diff --git a/internal/tui/tui.go b/internal/tui/tui.go deleted file mode 100644 index e8b168d..0000000 --- a/internal/tui/tui.go +++ /dev/null @@ -1,2483 +0,0 @@ -// Package tui is the terminal interface: browse accounts, write the rules that -// tag transactions, and see where the money went. -package tui - -import ( - "fmt" - "slices" - "sort" - "strconv" - "strings" - "time" - - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "git.petrovv.com/nikola/money/internal/config" - "git.petrovv.com/nikola/money/internal/glob" - "git.petrovv.com/nikola/money/internal/importer" - "git.petrovv.com/nikola/money/internal/model" - "git.petrovv.com/nikola/money/internal/report" - "git.petrovv.com/nikola/money/internal/rules" - "git.petrovv.com/nikola/money/internal/store" - "git.petrovv.com/nikola/money/internal/transfers" -) - -type view int - -const ( - viewAccounts view = iota - viewTxns - viewReport - viewRules - viewRuleList - viewTransfers - viewTransferList - - viewCount = 7 -) - -// confirmation is a pending destructive action awaiting a y/n answer. -// Deleting rewrites a file the user maintains by hand, so it is never done on -// a single keypress. -type confirmation int - -const ( - confirmNone confirmation = iota - confirmDeleteRule - confirmPruneRules - confirmDeleteTransfer - confirmPruneTransfers -) - -// previewSort is the order the rule builder lists descriptions in. The two -// answer different questions, which is why both are here rather than one being -// chosen for the user: by name is how a payee you are looking at is found, and -// by count is where the rule worth writing next is — one pattern that claims -// forty rows, rather than the first of forty patterns claiming one. -type previewSort int - -const ( - sortByName previewSort = iota - sortByCount -) - -// input is the modal state: the transaction list is read-only until the user -// starts typing a search. -type input int - -const ( - inputNone input = iota - inputSearch -) - -// Model is the root Bubble Tea model. -type Model struct { - root string - db *store.DB - accounts []*config.Account - engine *rules.Engine - links *transfers.Engine - - view view - input input - - accountTable table.Model - txnTable table.Model - reportTable table.Model - text textinput.Model - spinner spinner.Model - - // importing is set while an import runs in the background, so the UI keeps - // redrawing instead of freezing on a slow PDF. - importing bool - - txns []model.Transaction // rows currently shown in txnTable - - // Rule builder: four inputs on the left, and a live preview on the right of - // which descriptions the glob would catch. - ruleGlob textinput.Model - ruleAccount textinput.Model - ruleTag textinput.Model - ruleNote textinput.Model - ruleFocus int // which of the inputs has the cursor - ruleTable table.Model - ruleReturn view // the view to go back to on esc - previewGroups []descGroup - previewSort previewSort - ruleMatches int // previewed descriptions the current glob matches - // ruleCandidates is how many were in view before the glob filtered them, - // which the count needs: the preview now shows only matches, so the rows on - // screen can no longer say what they were chosen out of. - ruleCandidates int - // ruleDropped counts the descriptions an edit would let go of. It is only - // ever non-zero while editing: a new rule has nothing to lose. - ruleDropped int - - // Editing an existing rule reuses the builder rather than a second form: - // the fields start filled in, and enter rewrites the rule where it sits - // instead of appending. ruleEditPos is its file position, which the edit - // must not change — that is what still breaks ties between equally specific - // rules. ruleEditOrig is the rule as it was, so what the form does not show - // (the type pattern) survives the round trip instead of being dropped. - ruleEditing bool - ruleEditPos int - ruleEditOrig config.Rule - - // Rule list: every rule with the number of transactions it actually - // claims, so dead ones can be found and removed. - ruleListTable table.Model - ruleUsage []int - ruleListReturn view // the view to go back to on esc - confirm confirmation - - // Transfer builder: the two sides of a movement on the left, and on the - // right the pairs it would form out of what is already imported, together - // with the legs it would catch but leave unpaired. - transferFrom textinput.Model - transferFromDesc textinput.Model - transferTo textinput.Model - transferToDesc textinput.Model - transferTolerance textinput.Model - transferNote textinput.Model - transferFocus int - transferTable table.Model - transferReturn view - // allTxns is every transaction in the index, held so the preview can pair - // across accounts without going back to the database on each keystroke. - allTxns []model.Transaction - previewPairs int - previewUnmatched int - // previewFees is what the draft's pairs would lose to fees. Every leaving - // leg is on one account, so there is a single currency to render it in. - previewFees int64 - previewFeeDigits int - - // Transfer list: every definition with what it currently pairs. - transferListTable table.Model - transferResult transfers.Result - transferListReturn view // the view to go back to on esc - - filter store.Filter - onlyUntagged bool - - // The report's time axis: the windows ←/→ steps along, and which one it is - // on. It is built from the whole index rather than from the rows on screen, - // so changing account or search does not move the axis under the cursor. - reportPeriods []report.Period - reportPeriod int - // reportOrder is how the breakdown is arranged. It is not part of the - // filter: it changes the order of the rows, never which rows there are. - reportOrder report.Order - // indexEmpty is whether the index holds no transactions at all, which the - // report's empty state has to tell apart from a period that happens to be - // empty: one asks for an import, the other for another period. - indexEmpty bool - - status string - err error - width int - height int -} - -var ( - titleStyle = lipgloss.NewStyle().Bold(true).Padding(0, 1) - statusStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1) - errStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("9")).Padding(0, 1) - helpStyle = lipgloss.NewStyle().Faint(true).Padding(0, 1) - emptyStyle = lipgloss.NewStyle().Padding(1, 2) - - labelStyle = lipgloss.NewStyle().Faint(true) - focusedLabelStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")) - // Both boxes are pinned to the same width: a focused input renders one - // extra cell for the cursor, which would otherwise shift the border as - // the cursor moves between fields. - boxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")).Padding(0, 1).Width(ruleInputWidth + 2) - focusedBoxStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")).Padding(0, 1).Width(ruleInputWidth + 2) - // flatBoxStyle is the same field with its border dropped, for a form too - // tall for the terminal. The padding puts the value in the same column the - // border's own padding would, so the fields still line up beside a boxed one. - flatBoxStyle = lipgloss.NewStyle().PaddingLeft(2).Width(ruleInputWidth + 2) - hintStyle = lipgloss.NewStyle().Faint(true).PaddingLeft(2) - matchCountStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2) - ruleFormStyle = lipgloss.NewStyle().Width(ruleFormWidth) - - // The report's time axis. The header is bold and indented like the table's - // own header beside it, so the two panels read as one grid. - periodHeaderStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(2).Width(periodListWidth) - periodStyle = lipgloss.NewStyle().Faint(true).Width(periodListWidth) - periodSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("62")).Width(periodListWidth) -) - -// Run starts the interface. -func Run(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine, - links *transfers.Engine) error { - - m := New(root, db, accounts, engine, links) - if err := m.reload(); err != nil { - return err - } - _, err := tea.NewProgram(m, tea.WithAltScreen()).Run() - return err -} - -// New builds the root model. -func New(root string, db *store.DB, accounts []*config.Account, engine *rules.Engine, - links *transfers.Engine) *Model { - - ti := textinput.New() - ti.Prompt = "" - ti.CharLimit = 64 - - newInput := func(placeholder string) textinput.Model { - in := textinput.New() - in.Prompt = "" - in.CharLimit = 80 - in.Width = ruleInputWidth - in.Placeholder = placeholder - return in - } - - // Account slugs and tags are typed over and over, and a typo in either - // silently produces a rule that catches nothing, so both fields complete - // against what already exists. - completing := func(in textinput.Model) textinput.Model { - in.ShowSuggestions = true - return in - } - - sp := spinner.New() - sp.Spinner = spinner.Dot - sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62")) - - styles := table.DefaultStyles() - styles.Header = styles.Header.Bold(true) - styles.Selected = styles.Selected.Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("62")) - - newTable := func(cols []table.Column) table.Model { - return table.New(table.WithColumns(cols), table.WithFocused(true), - table.WithHeight(15), table.WithStyles(styles)) - } - - m := &Model{ - root: root, - db: db, - accounts: accounts, - engine: engine, - links: links, - view: viewAccounts, - text: ti, - spinner: sp, - accountTable: newTable([]table.Column{ - {Title: "Account", Width: 20}, - {Title: "Balance", Width: 14}, - {Title: "Cur", Width: 4}, - {Title: "Txns", Width: 6}, - }), - txnTable: newTable([]table.Column{ - {Title: "Date", Width: 10}, - {Title: "Account", Width: 12}, - {Title: "Amount", Width: 12}, - {Title: "Tag", Width: 14}, - {Title: "Description", Width: 40}, - }), - ruleGlob: newInput("*LIDL*"), - ruleAccount: completing(newInput("blank = every account")), - ruleTag: completing(newInput("groceries")), - ruleNote: newInput("optional"), - ruleTable: newTable([]table.Column{ - {Title: " ", Width: 1}, - {Title: "Untagged description", Width: 44}, - {Title: "N", Width: 4}, - }), - ruleListTable: newTable([]table.Column{ - {Title: "#", Width: 3}, - {Title: " ", Width: 1}, - {Title: "Pattern", Width: 34}, - {Title: "Account", Width: 12}, - {Title: "Tag", Width: 14}, - {Title: "Txns", Width: 6}, - {Title: "Note", Width: 24}, - }), - transferFrom: completing(newInput("nlb")), - transferFromDesc: newInput("*TO REVOLUT*"), - transferTo: completing(newInput("revolut")), - transferToDesc: newInput("*FROM NLB*"), - transferTolerance: newInput("0"), - transferNote: newInput("optional"), - transferTable: newTable([]table.Column{ - {Title: " ", Width: 1}, - {Title: "Date", Width: 10}, - // Wide enough for both sides of an exchange, e.g. "500.00 → 977.90". - {Title: "Amount", Width: 20}, - {Title: "Movement", Width: 22}, - {Title: "Description", Width: 30}, - }), - transferListTable: newTable([]table.Column{ - {Title: "#", Width: 3}, - {Title: " ", Width: 1}, - {Title: "From", Width: 26}, - {Title: "To", Width: 26}, - {Title: "Pairs", Width: 6}, - {Title: "Unpaired", Width: 9}, - {Title: "Tol", Width: 6}, - {Title: "Note", Width: 20}, - }), - reportTable: newTable(slices.Clone(reportColumns)), - } - // The default order needs its marker as much as a chosen one does. - m.markSortedColumn() - return m -} - -// reportColumns are the report's headings before the sort marker is added. -// They live here rather than being written into the table once, because -// markSortedColumn rebuilds them every time the order changes. -var reportColumns = []table.Column{ - {Title: "Tag", Width: 20}, - {Title: "Cur", Width: 4}, - {Title: "Out", Width: 14}, - {Title: "In", Width: 14}, - {Title: "Net", Width: 14}, - {Title: "N", Width: 5}, -} - -// markSortedColumn moves the marker onto the heading the rows are ordered by, -// so the sort is visible where it is acting rather than only in the key list. -func (m *Model) markSortedColumn() { - marker := " ▾" - if m.reportOrder.Ascending() { - marker = " ▴" - } - cols := slices.Clone(reportColumns) - for i := range cols { - if cols[i].Title == m.reportOrder.Column() { - cols[i].Title += marker - break - } - } - m.reportTable.SetColumns(cols) -} - -// setOrder cycles the report to the next arrangement of the same rows. -func (m *Model) setOrder(o report.Order) { - m.reportOrder = o - m.markSortedColumn() - m.status = "sort: " + o.Label() - m.reportTable.SetCursor(0) - m.err = m.reloadReport() -} - -// Init implements tea.Model. -func (m *Model) Init() tea.Cmd { return nil } - -// reload refreshes whichever view is active from the index. -func (m *Model) reload() error { - if err := m.reloadAccounts(); err != nil { - return err - } - if err := m.reloadPeriods(); err != nil { - return err - } - return m.reloadTxns() -} - -// reloadPeriods rebuilds the report's time axis from the months the index now -// holds, staying on the window the user was looking at if it is still there. -// An import that reaches further back grows the axis, so it cannot simply be -// built once at startup; but it must not throw the user back to the default -// either, since the import they just ran is usually the reason they are looking. -func (m *Model) reloadPeriods() error { - months, err := m.db.Months() - if err != nil { - return err - } - var was string - if p, ok := m.currentPeriod(); ok { - was = p.Label - } - m.indexEmpty = len(months) == 0 - m.reportPeriods = report.Periods(time.Now(), months) - m.reportPeriod = report.DefaultIndex(m.reportPeriods) - for i, p := range m.reportPeriods { - if p.Label == was { - m.reportPeriod = i - break - } - } - return nil -} - -// currentPeriod is the window the report is on, if the axis has been built. -func (m *Model) currentPeriod() (report.Period, bool) { - if m.reportPeriod < 0 || m.reportPeriod >= len(m.reportPeriods) { - return report.Period{}, false - } - return m.reportPeriods[m.reportPeriod], true -} - -// setPeriod moves the report along its axis, clamping at both ends: the axis -// runs from all time down to the oldest month, and wrapping round from one to -// the other would land somewhere nobody aimed for. -func (m *Model) setPeriod(i int) { - if i < 0 || i >= len(m.reportPeriods) || i == m.reportPeriod { - return - } - m.reportPeriod = i - m.status = "period: " + m.reportPeriods[i].String() - m.reportTable.SetCursor(0) - m.err = m.reloadReport() -} - -func (m *Model) reloadAccounts() error { - accounts, err := m.db.Accounts() - if err != nil { - return err - } - rows := make([]table.Row, 0, len(accounts)) - for _, a := range accounts { - bal, err := m.db.Balance(a.ID) - if err != nil { - return err - } - n, err := m.db.Count(a.ID) - if err != nil { - return err - } - rows = append(rows, table.Row{ - a.Name, model.FormatMinor(bal, a.MinorDigits), a.Currency, fmt.Sprintf("%d", n), - }) - } - m.accountTable.SetRows(rows) - return nil -} - -func (m *Model) reloadTxns() error { - f := m.filter - f.Untagged = m.onlyUntagged - txns, err := m.db.Transactions(f) - if err != nil { - return err - } - m.txns = txns - - rows := make([]table.Row, 0, len(txns)) - for _, t := range txns { - rows = append(rows, table.Row{ - t.Date, t.AccountSlug, t.FormatAmount(), t.DisplayTag(), t.Description, - }) - } - // Keep the cursor in range after the row count shrinks (e.g. a new rule - // tagging the last untagged row while the untagged filter is on). - cursor := m.txnTable.Cursor() - m.txnTable.SetRows(rows) - if cursor >= len(rows) { - cursor = len(rows) - 1 - } - if cursor < 0 { - cursor = 0 - } - m.txnTable.SetCursor(cursor) - - return m.reloadReport() -} - -// The two rows the report grows below TOTAL. They are bracketed like -// report.Untagged and model.TransferTag because they are the same kind of -// thing: a label the tool supplies for something no rule named. Neither is a -// tag, and neither can be matched or completed against. -const ( - transfersRow = "(transfers)" - feesRow = " ⤷ fees" -) - -// reloadReport re-queries under the report's own time window. -// -// It cannot reuse the rows the transaction list is showing: the period narrows -// the report and only the report, so that opening on last month does not also -// hide the rest of the index from the list beside it. Everything else about the -// scope — the account, the search, the untagged toggle — is shared, so the two -// screens still answer for the same selection of transactions. -func (m *Model) reloadReport() error { - f := m.filter - f.Untagged = m.onlyUntagged - if p, ok := m.currentPeriod(); ok { - f.From, f.To = p.From, p.To - } - txns, err := m.db.Transactions(f) - if err != nil { - return err - } - - rows := report.ByTag(txns, m.reportOrder) - out := make([]table.Row, 0, len(rows)+2) - for _, r := range rows { - out = append(out, table.Row{ - r.Tag, r.Currency, - model.FormatMinor(r.Out, r.Digits), - model.FormatMinor(r.In, r.Digits), - model.FormatMinor(r.Net(), r.Digits), - fmt.Sprintf("%d", r.Count), - }) - } - for _, c := range report.Totals(rows) { - out = append(out, table.Row{ - "TOTAL", c.Currency, - model.FormatMinor(c.Out, c.Digits), - model.FormatMinor(c.In, c.Digits), - model.FormatMinor(c.Net(), c.Digits), - "", - }) - } - // Below the total, what ByTag held out — otherwise the report quietly - // disagrees with the account balances by the amount moved between accounts, - // and by any fee a tolerant definition swallowed along with it. - for _, x := range report.Excluded(txns) { - out = append(out, table.Row{ - transfersRow, x.Currency, - model.FormatMinor(x.Out, x.Digits), - model.FormatMinor(x.In, x.Digits), - model.FormatMinor(x.In-x.Out, x.Digits), - fmt.Sprintf("%d", x.Legs), - }) - if x.Fee != 0 { - out = append(out, table.Row{ - feesRow, x.Currency, - model.FormatMinor(x.Fee, x.Digits), "", - model.FormatMinor(-x.Fee, x.Digits), - fmt.Sprintf("%d", x.Pairs), - }) - } - } - m.reportTable.SetRows(out) - return nil -} - -// ruleFormWidth is the width of the rule builder's left-hand form, and -// ruleInputWidth the editable area inside its boxes. The form must stay wider -// than a box plus its border, padding and the block cursor, or lipgloss wraps -// the border and the panel falls apart. -const ( - ruleInputWidth = 26 - ruleFormWidth = ruleInputWidth + 12 -) - -// descGroup is one distinct description the builder previews against, and how -// often it occurs. Grouping matters: a statement holds the same payee dozens of -// times, and a rule is written against the description, not against individual -// rows. -type descGroup struct { - Description string - Accounts map[string]bool - Count int - // Claimed marks a description the rule being edited currently tags. Nothing - // sets it for a new rule, where every candidate is untagged by definition. - Claimed bool -} - -// reloadPreviewGroups rebuilds the alphabetical list of descriptions the rule -// builder previews against: everything still waiting for a rule, plus — when a -// rule is being edited — what that rule already claims. -// -// The addition is what makes editing legible. Those rows are tagged, so none of -// them is in the untagged list, and the preview for a rule that works perfectly -// would otherwise be empty. They are also exactly what an edit is judged -// against: narrowing a glob is a decision about which of them to let go. -func (m *Model) reloadPreviewGroups() error { - txns, err := m.db.Transactions(store.Filter{Untagged: true}) - if err != nil { - return err - } - claimedFrom := len(txns) // everything appended below is claimed by the edited rule - if m.ruleEditing { - seen := make(map[int64]bool, len(txns)) - for _, t := range txns { - seen[t.ID] = true - } - all, err := m.db.Transactions(store.Filter{}) - if err != nil { - return err - } - for _, t := range all { - // An index written before rules.toml last changed can hold a row - // that is untagged on disk and claimed by the engine in memory; - // counting it twice would overstate the group. - if seen[t.ID] || m.engine.MatchIndex(t.AccountSlug, t) != m.ruleEditPos { - continue - } - txns = append(txns, t) - } - } - - byDesc := map[string]*descGroup{} - for i, t := range txns { - key := model.NormalizeDescription(t.Description) - g, ok := byDesc[key] - if !ok { - g = &descGroup{Description: t.Description, Accounts: map[string]bool{}} - byDesc[key] = g - } - g.Accounts[t.AccountSlug] = true - g.Count++ - g.Claimed = g.Claimed || i >= claimedFrom - } - - m.previewGroups = make([]descGroup, 0, len(byDesc)) - for _, g := range byDesc { - m.previewGroups = append(m.previewGroups, *g) - } - m.sortPreviewGroups() - return nil -} - -// sortPreviewGroups puts the preview in the order the user last chose, marks -// the column it is sorted by, and re-renders it. -func (m *Model) sortPreviewGroups() { - sort.Slice(m.previewGroups, func(i, j int) bool { - a, b := m.previewGroups[i], m.previewGroups[j] - // Count first when asked, but ties still fall back to the alphabetical - // order rather than to map order, or the list would reshuffle under the - // cursor every time it is rebuilt. - if m.previewSort == sortByCount && a.Count != b.Count { - return a.Count > b.Count - } - x, y := model.NormalizeDescription(a.Description), model.NormalizeDescription(b.Description) - if x != y { - return x < y - } - return a.Description < b.Description - }) - - // Each column says what is in it. The description changes with the mode — - // an edit's rows are on screen precisely because they are *not* waiting for - // a rule — and the ↓ names the column the order is read from, since two - // sorts of one list are otherwise told apart only by squinting at it. - cols := m.ruleTable.Columns() - cols[1].Title = "Untagged description" - if m.ruleEditing { - cols[1].Title = "Description" - } - cols[2].Title = "N" - if m.previewSort == sortByCount { - cols[2].Title += " ↓" - } else { - cols[1].Title += " ↓" - } - m.ruleTable.SetColumns(cols) - - m.refreshRulePreview() -} - -// refreshRulePreview re-filters the preview against whatever is typed right -// now. It runs on every keystroke, so the glob is checked against descriptions -// only, never against the database. -// -// Once a glob is typed the list is exactly what the rule would claim: the -// non-matching rows go, rather than staying on as unmarked context. With the -// glob still empty there is nothing to filter by, so the list is everything -// still waiting for a rule — which is the other question this screen answers. -// Either way the count beside the form is measured against everything in view, -// so a glob that has narrowed the list to three of forty still says so. -// -// A description the rule being edited currently tags is the one exception to -// the rows going: losing one is the thing worth seeing before saving, so it -// stays on screen marked “−” instead of vanishing silently with the rest. -func (m *Model) refreshRulePreview() { - var ( - pattern = strings.TrimSpace(m.ruleGlob.Value()) - account = strings.TrimSpace(m.ruleAccount.Value()) - rows = make([]table.Row, 0, len(m.previewGroups)) - ) - m.ruleMatches, m.ruleCandidates, m.ruleDropped = 0, 0, 0 - - for _, g := range m.previewGroups { - // An account filter narrows the preview the same way the saved rule - // will narrow its matching. - inAccount := account == "" || g.Accounts[account] - if inAccount { - m.ruleCandidates++ - } - matched := inAccount && - (pattern == "" || glob.Match(pattern, model.NormalizeDescription(g.Description))) - - marker := " " - switch { - case matched && pattern != "": - marker = "▸" - m.ruleMatches++ - case matched: - // No glob yet, so the row is context rather than an answer. - case g.Claimed: - marker = "−" - m.ruleDropped++ - default: - continue - } - rows = append(rows, table.Row{marker, g.Description, fmt.Sprintf("%d", g.Count)}) - } - - cursor := m.ruleTable.Cursor() - m.ruleTable.SetRows(rows) - if cursor >= len(rows) { - cursor = len(rows) - 1 - } - if cursor < 0 { - cursor = 0 - } - m.ruleTable.SetCursor(cursor) -} - -// saveRule writes the composed rule to rules.toml — appending it, or rewriting -// the rule being edited in place — then reloads the engine and retags, so the -// effect is visible immediately. -func (m *Model) saveRule() error { - r := config.Rule{ - Match: strings.TrimSpace(m.ruleGlob.Value()), - Account: strings.TrimSpace(m.ruleAccount.Value()), - Tag: strings.TrimSpace(m.ruleTag.Value()), - Note: strings.TrimSpace(m.ruleNote.Value()), - } - if m.ruleEditing { - // The form has no type field, so an edit carries the rule's type - // through untouched rather than quietly dropping a pattern it never - // showed the user. It is also why a type rule can have no glob at all. - r.Type = m.ruleEditOrig.Type - } - if r.Match == "" && r.Type == "" { - return fmt.Errorf("enter a glob first, e.g. *LIDL*") - } - if r.Tag == "" { - return fmt.Errorf("enter a tag to apply") - } - if r.Account != "" && !m.knownAccount(r.Account) { - return fmt.Errorf("no account called %q; leave it blank to apply to every account", r.Account) - } - - if m.ruleEditing { - if err := config.ReplaceRule(m.root, m.ruleEditPos, r); err != nil { - return err - } - } else if err := config.AppendRule(m.root, r); err != nil { - return err - } - if err := m.reloadConfig(); err != nil { - return fmt.Errorf("rule saved, but re-reading rules.toml failed: %w", err) - } - - n, err := m.engine.Retag(m.db) - if err != nil { - return err - } - - if m.ruleEditing { - m.status = fmt.Sprintf("rule %d is now %s → %s, %d transactions retagged", - m.ruleEditPos+1, rulePattern(r), r.Tag, n) - // The account came from the rule rather than from the user, so it goes - // with the rest; an appended rule keeps it, since the next one written - // is usually for the same account. - m.ruleEditing = false - m.ruleEditOrig = config.Rule{} - m.ruleAccount.SetValue("") - } else { - m.status = fmt.Sprintf("saved rule %s → %s, %d transactions retagged", r.Match, r.Tag, n) - } - m.ruleGlob.SetValue("") - m.ruleTag.SetValue("") - m.ruleNote.SetValue("") - m.setRuleFocus(0) - if err := m.reloadSuggestions(); err != nil { - return err - } - return m.reload() -} - -// reloadConfig re-reads rules.toml and rebuilds both engines from it, rather -// than patching the in-memory ones, so what runs is exactly what is now on -// disk. Both are rebuilt together because both come out of the same file. -func (m *Model) reloadConfig() error { - loaded, err := config.LoadRules(m.root) - if err != nil { - return err - } - m.engine = rules.New(loaded) - m.links = transfers.New(loaded) - return nil -} - -func (m *Model) knownAccount(slug string) bool { - return slices.Contains(m.accountSlugs(), slug) -} - -// accountSlugs lists every account slug worth completing: those configured on -// disk, plus those the index already holds so accounts still resolve when the -// caller passed no config (as the tests do). -func (m *Model) accountSlugs() []string { - configured := make([]string, 0, len(m.accounts)) - for _, a := range m.accounts { - configured = append(configured, a.Slug) - } - var imported []string - if accounts, err := m.db.Accounts(); err == nil { - for _, a := range accounts { - imported = append(imported, a.Slug) - } - } - return sortedSet(configured, imported) -} - -// knownTags lists the tags in use in the index plus those only named by a rule. -// A rule that has not caught anything yet is exactly where a tag lives before -// it appears on a transaction, and those are the ones most likely to be -// mistyped into a second, near-identical tag. -func (m *Model) knownTags() ([]string, error) { - tagged, err := m.db.Tags() - if err != nil { - return nil, err - } - rs := m.engine.Rules() - fromRules := make([]string, 0, len(rs)) - for _, r := range rs { - fromRules = append(fromRules, r.Tag) - } - return sortedSet(tagged, fromRules), nil -} - -// sortedSet merges the groups into one sorted list with blanks and duplicates -// removed. Completion cycles in this order, so it has to be stable. -func sortedSet(groups ...[]string) []string { - seen := map[string]bool{} - var out []string - for _, g := range groups { - for _, v := range g { - if v == "" || seen[v] { - continue - } - seen[v] = true - out = append(out, v) - } - } - sort.Strings(out) - return out -} - -// reloadSuggestions refreshes what the account and tag fields complete against. -// It runs whenever the builder is opened and after every save, since saving a -// rule can introduce a tag that did not exist a moment ago. -func (m *Model) reloadSuggestions() error { - m.ruleAccount.SetSuggestions(m.accountSlugs()) - tags, err := m.knownTags() - if err != nil { - return err - } - m.ruleTag.SetSuggestions(tags) - return nil -} - -// pendingCompletion returns the suggestion an input is currently offering, or -// "" when there is nothing left to accept. Matching is case-insensitive, so a -// suggestion differing only in case is not worth rewriting what was typed. -func pendingCompletion(in *textinput.Model) string { - s := in.CurrentSuggestion() - if s == "" || strings.EqualFold(s, in.Value()) { - return "" - } - return s -} - -// acceptCompletion takes the offered completion into the field, reporting -// whether there was one. The suggestion list is re-set afterwards because -// SetValue does not re-match it, which would otherwise leave ctrl+n cycling -// through candidates that no longer share the new prefix. -func acceptCompletion(in *textinput.Model) bool { - s := pendingCompletion(in) - if s == "" { - return false - } - in.SetValue(s) - in.CursorEnd() - in.SetSuggestions(in.AvailableSuggestions()) - return true -} - -// reloadRuleList counts, for every rule, how many transactions it actually -// claims. A rule can match nothing because no description fits it, or because -// a more specific rule already took everything it would have caught; both show -// up here as a zero. The rows stay in file order — that is the number the user -// can find in rules.toml, and precedence is not read off it any more. -func (m *Model) reloadRuleList() error { - txns, err := m.db.Transactions(store.Filter{}) - if err != nil { - return err - } - rs := m.engine.Rules() - m.ruleUsage = m.engine.Usage(txns) - - rows := make([]table.Row, 0, len(rs)) - for i, r := range rs { - marker := " " - if m.ruleUsage[i] == 0 { - marker = "✗" - } - account := r.Account - if account == "" { - account = "(all)" - } - rows = append(rows, table.Row{ - fmt.Sprintf("%d", i+1), marker, rulePattern(r), account, r.Tag, - fmt.Sprintf("%d", m.ruleUsage[i]), r.Note, - }) - } - - cursor := m.ruleListTable.Cursor() - m.ruleListTable.SetRows(rows) - if cursor >= len(rows) { - cursor = len(rows) - 1 - } - if cursor < 0 { - cursor = 0 - } - m.ruleListTable.SetCursor(cursor) - return 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 { - var parts []string - if r.Match != "" { - parts = append(parts, r.Match) - } - if r.Type != "" { - parts = append(parts, "type:"+r.Type) - } - return strings.Join(parts, " + ") -} - -// unusedRules lists the positions of every rule claiming no transactions. -func (m *Model) unusedRules() []int { - var out []int - for i, n := range m.ruleUsage { - if n == 0 { - out = append(out, i) - } - } - return out -} - -// deleteRules removes rules from rules.toml, then reloads and retags so the -// counts on screen reflect the new file. -func (m *Model) deleteRules(positions []int) error { - n, err := config.DeleteRules(m.root, positions) - if err != nil { - return err - } - if err := m.reloadConfig(); err != nil { - return fmt.Errorf("rules deleted, but re-reading rules.toml failed: %w", err) - } - - retagged, err := m.engine.Retag(m.db) - if err != nil { - return err - } - m.status = fmt.Sprintf("deleted %d rule(s), %d transactions retagged", n, retagged) - - if err := m.reload(); err != nil { - return err - } - return m.reloadRuleList() -} - -// openRuleList switches to the rule list, remembering where to return to. -func (m *Model) openRuleList() { - if m.view != viewRuleList && m.view != viewRules { - m.ruleListReturn = m.view - } - m.showRuleList() -} - -// showRuleList switches to the rule list without recording where esc goes. -// Coming back from the builder must not record it as the way out, or esc from -// the list would bounce back into the form the user just left. -func (m *Model) showRuleList() { - m.view = viewRuleList - m.confirm = confirmNone - if err := m.reloadRuleList(); err != nil { - m.err = err - } -} - -// updateRuleList drives the rule list, including the delete confirmations. -func (m *Model) updateRuleList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.confirm != confirmNone { - pending := m.confirm - m.confirm = confirmNone - if msg.String() != "y" { - m.status = "cancelled" - return m, nil - } - m.err = nil - switch pending { - case confirmDeleteRule: - if i := m.ruleListTable.Cursor(); i >= 0 && i < len(m.ruleUsage) { - if err := m.deleteRules([]int{i}); err != nil { - m.err = err - } - } - case confirmPruneRules: - if err := m.deleteRules(m.unusedRules()); err != nil { - m.err = err - } - } - return m, nil - } - - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "esc": - m.view = m.ruleListReturn - return m, nil - case "1": - m.view = viewAccounts - return m, nil - case "2": - m.view = viewTxns - return m, nil - case "3": - m.view = viewReport - return m, nil - case "4": - return m, m.openRuleBuilder() - case "6": - return m, m.openTransferBuilder() - case "7": - m.openTransferList() - return m, nil - - case "e": - return m, m.openRuleEditor(m.ruleListTable.Cursor()) - - case "d": - i := m.ruleListTable.Cursor() - rs := m.engine.Rules() - if i < 0 || i >= len(rs) { - return m, nil - } - m.confirm = confirmDeleteRule - m.status = fmt.Sprintf("delete rule %d (%s → %s), used by %d transactions? y/n", - i+1, rulePattern(rs[i]), rs[i].Tag, m.ruleUsage[i]) - return m, nil - - case "p": - unused := m.unusedRules() - if len(unused) == 0 { - m.status = "no unused rules to prune" - return m, nil - } - m.confirm = confirmPruneRules - m.status = fmt.Sprintf("delete all %d rules that match nothing? y/n", len(unused)) - return m, nil - - case "r": - m.err = m.reloadRuleList() - m.status = "counts refreshed" - return m, nil - } - - var cmd tea.Cmd - m.ruleListTable, cmd = m.ruleListTable.Update(msg) - return m, cmd -} - -// openRuleBuilder switches to the rule builder on a new rule, seeding the -// account field from whatever account is being browsed. -func (m *Model) openRuleBuilder() tea.Cmd { - // Leaving an edit, the fields still hold that rule. A new rule starting out - // as a copy of the one just edited would be saved as a second, almost - // identical rule, so the form is emptied on the way out of edit mode. - if m.ruleEditing { - m.ruleEditing = false - m.ruleEditOrig = config.Rule{} - for _, in := range m.ruleInputs() { - in.SetValue("") - } - } - if m.ruleAccount.Value() == "" && m.filter.AccountSlug != "" { - m.ruleAccount.SetValue(m.filter.AccountSlug) - } - return m.showRuleBuilder() -} - -// openRuleEditor switches to the rule builder on the rule at a file position: -// the fields start filled in from it, and saving rewrites it where it sits -// rather than appending a near-duplicate. -func (m *Model) openRuleEditor(pos int) tea.Cmd { - rs := m.engine.Rules() - if pos < 0 || pos >= len(rs) { - return nil - } - r := rs[pos] - m.ruleEditing, m.ruleEditPos, m.ruleEditOrig = true, pos, r - m.ruleGlob.SetValue(r.Match) - m.ruleAccount.SetValue(r.Account) - m.ruleTag.SetValue(r.Tag) - m.ruleNote.SetValue(r.Note) - return m.showRuleBuilder() -} - -// showRuleBuilder switches to the builder and loads what the preview needs, -// remembering where to return to on esc. -func (m *Model) showRuleBuilder() tea.Cmd { - if m.view != viewRules { - m.ruleReturn = m.view - } - m.view = viewRules - m.setRuleFocus(0) - if err := m.reloadSuggestions(); err != nil { - m.err = err - } - // Last: what the preview lists depends on whether a rule is being edited. - if err := m.reloadPreviewGroups(); err != nil { - m.err = err - } - return textinput.Blink -} - -// ruleInputs lists the form fields in tab order. -func (m *Model) ruleInputs() []*textinput.Model { - return []*textinput.Model{&m.ruleGlob, &m.ruleAccount, &m.ruleTag, &m.ruleNote} -} - -// setRuleFocus moves the cursor between the form fields, wrapping around. -func (m *Model) setRuleFocus(i int) { m.ruleFocus = focusField(m.ruleInputs(), i) } - -// focusField gives the cursor to one field of a form and takes it from the -// rest, wrapping the index so tabbing past either end comes back round. -func focusField(inputs []*textinput.Model, i int) int { - n := len(inputs) - focus := ((i % n) + n) % n - for j, in := range inputs { - if j == focus { - in.Focus() - in.CursorEnd() - continue - } - in.Blur() - } - return focus -} - -// openTransferBuilder switches to the transfer builder, remembering where to -// return to and loading the transactions the preview pairs against. -func (m *Model) openTransferBuilder() tea.Cmd { - if m.view != viewTransfers { - m.transferReturn = m.view - } - m.view = viewTransfers - m.setTransferFocus(0) - - slugs := m.accountSlugs() - m.transferFrom.SetSuggestions(slugs) - m.transferTo.SetSuggestions(slugs) - - // The preview pairs across accounts, so it needs everything, not the - // filtered view the transaction list is showing. - txns, err := m.db.Transactions(store.Filter{}) - if err != nil { - m.err = err - } - m.allTxns = txns - m.refreshTransferPreview() - return textinput.Blink -} - -// transferInputs lists the form fields in tab order: the two sides in the -// order the money travels, then the tolerance and the note. -func (m *Model) transferInputs() []*textinput.Model { - return []*textinput.Model{ - &m.transferFrom, &m.transferFromDesc, - &m.transferTo, &m.transferToDesc, - &m.transferTolerance, &m.transferNote, - } -} - -// setTransferFocus moves the cursor between the form fields, wrapping around. -func (m *Model) setTransferFocus(i int) { - m.transferFocus = focusField(m.transferInputs(), i) -} - -// draftTransfer is the definition the form currently describes. A tolerance -// that does not parse yet previews as none at all — the field is retyped a -// character at a time, and "1." must not stop the preview from updating. -// saveTransfer is where a bad value is refused. -func (m *Model) draftTransfer() config.Transfer { - pct, _ := m.draftTolerance() - return config.Transfer{ - FromAccount: strings.TrimSpace(m.transferFrom.Value()), - FromDesc: strings.TrimSpace(m.transferFromDesc.Value()), - ToAccount: strings.TrimSpace(m.transferTo.Value()), - ToDesc: strings.TrimSpace(m.transferToDesc.Value()), - TolerancePct: pct, - Note: strings.TrimSpace(m.transferNote.Value()), - } -} - -// draftTolerance reads the tolerance field. Empty means none, which is what a -// definition without the key means too. -func (m *Model) draftTolerance() (float64, error) { - s := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(m.transferTolerance.Value()), "%")) - if s == "" { - return 0, nil - } - pct, err := strconv.ParseFloat(s, 64) - if err != nil { - return 0, fmt.Errorf("tolerance %q is not a number", s) - } - return pct, nil -} - -// refreshTransferPreview re-pairs against whatever is typed right now. -// -// The draft is analysed *after* the definitions already on disk, exactly where -// saving would put it, so the preview cannot promise pairs that an existing -// definition would claim first. A half-written definition still previews: the -// side that is filled in shows its legs as unpaired, which is the fastest way -// to see that a glob is wrong. -func (m *Model) refreshTransferPreview() { - draft := m.draftTransfer() - defs := append(append([]config.Transfer(nil), m.links.Transfers()...), draft) - res := transfers.New(&config.Rules{Transfer: defs}).Analyze(m.allTxns) - mine := len(defs) - 1 - - type entry struct { - date string - row table.Row - } - var entries []entry - m.previewFees, m.previewFeeDigits = 0, 0 - for _, p := range res.Pairs { - if p.Def != mine { - continue - } - m.previewFees += p.Fee() - m.previewFeeDigits = p.Out.MinorDigits - // Whenever the two legs disagree, both numbers are worth seeing: across - // currencies they are the only place the rate the bank used shows up, - // and within one they are the fee a tolerance let through, which is - // exactly the thing to eyeball before saving the definition. - amount := model.FormatMinor(-p.Out.AmountMinor, p.Out.MinorDigits) - if p.In.Currency != p.Out.Currency || p.Fee() != 0 { - amount += " → " + model.FormatMinor(p.In.AmountMinor, p.In.MinorDigits) - } - entries = append(entries, entry{p.Out.Date, table.Row{ - "▸", p.Out.Date, amount, - p.Out.AccountSlug + " → " + p.In.AccountSlug, p.Out.Description, - }}) - } - for _, l := range res.Unmatched { - if l.Def != mine { - continue - } - amount, movement := l.Txn.AmountMinor, "? → "+l.Txn.AccountSlug - if l.Out { - amount, movement = -amount, l.Txn.AccountSlug+" → ?" - } - entries = append(entries, entry{l.Txn.Date, table.Row{ - "⚠", l.Txn.Date, model.FormatMinor(amount, l.Txn.MinorDigits), - movement, l.Txn.Description, - }}) - } - // Newest first, like the transaction list: the movement worth checking is - // usually the one that just came in. - sort.SliceStable(entries, func(i, j int) bool { return entries[i].date > entries[j].date }) - - rows := make([]table.Row, 0, len(entries)) - for _, e := range entries { - rows = append(rows, e.row) - } - m.previewPairs = res.Paired[mine] - m.previewUnmatched = res.Orphaned[mine] - - cursor := m.transferTable.Cursor() - m.transferTable.SetRows(rows) - if cursor >= len(rows) { - cursor = len(rows) - 1 - } - if cursor < 0 { - cursor = 0 - } - m.transferTable.SetCursor(cursor) -} - -// saveTransfer appends the composed definition to rules.toml, re-pairs, and -// leaves the two account fields filled in, since the next transfer written is -// usually the same route in the other direction. -func (m *Model) saveTransfer() error { - t := m.draftTransfer() - switch { - case t.FromAccount == "": - return fmt.Errorf("name the account the money leaves") - case t.FromDesc == "": - return fmt.Errorf("enter a glob for the leaving leg, e.g. *TO REVOLUT*") - case t.ToAccount == "": - return fmt.Errorf("name the account the money arrives in") - case t.ToDesc == "": - return fmt.Errorf("enter a glob for the arriving leg, e.g. *FROM NLB*") - } - for _, slug := range []string{t.FromAccount, t.ToAccount} { - if !m.knownAccount(slug) { - return fmt.Errorf("no account called %q", slug) - } - } - // draftTransfer swallowed this so the preview could keep up with typing; - // saving is where a value that never became a number has to be refused, - // rather than written out as a silent 0. - if _, err := m.draftTolerance(); err != nil { - return err - } - - if err := config.AppendTransfer(m.root, t); err != nil { - return err - } - if err := m.reloadConfig(); err != nil { - return fmt.Errorf("transfer saved, but re-reading rules.toml failed: %w", err) - } - - paired, unpaired, err := m.links.Link(m.db) - if err != nil { - return err - } - m.status = fmt.Sprintf("saved transfer %s → %s, %d matched, %d leg(s) unpaired", - t.FromAccount, t.ToAccount, paired, unpaired) - - m.transferFromDesc.SetValue("") - m.transferToDesc.SetValue("") - // Cleared with the globs rather than kept with the accounts: a tolerance - // carried silently into the next definition would loosen a route that never - // asked for one, and the reverse direction rarely charges the same fee. - m.transferTolerance.SetValue("") - m.transferNote.SetValue("") - // The accounts are kept, so land on the first field that was cleared. - m.setTransferFocus(1) - - if err := m.reload(); err != nil { - return err - } - txns, err := m.db.Transactions(store.Filter{}) - if err != nil { - return err - } - m.allTxns = txns - m.refreshTransferPreview() - return nil -} - -// openTransferList switches to the transfer list, remembering where to return. -// As on the rule list, the builder is not somewhere to return to: it is reached -// from here, and esc would bounce between the two. -func (m *Model) openTransferList() { - if m.view != viewTransferList && m.view != viewTransfers { - m.transferListReturn = m.view - } - m.view = viewTransferList - m.confirm = confirmNone - if err := m.reloadTransferList(); err != nil { - m.err = err - } -} - -// reloadTransferList recomputes what every definition currently pairs. -// -// Two counts are shown because they mean different things: a definition with -// no pairs and no legs matches nothing at all and can go, while one with -// unpaired legs is catching money leaving that never arrives — a wrong glob on -// the other side, a statement not imported yet, or a movement that genuinely -// went missing. -func (m *Model) reloadTransferList() error { - txns, err := m.db.Transactions(store.Filter{}) - if err != nil { - return err - } - defs := m.links.Transfers() - m.transferResult = m.links.Analyze(txns) - - rows := make([]table.Row, 0, len(defs)) - for i, t := range defs { - marker := " " - switch { - case m.transferResult.Orphaned[i] > 0: - marker = "⚠" - case m.transferResult.Paired[i] == 0: - marker = "✗" - } - rows = append(rows, table.Row{ - fmt.Sprintf("%d", i+1), marker, - t.FromAccount + " " + t.FromDesc, - t.ToAccount + " " + t.ToDesc, - fmt.Sprintf("%d", m.transferResult.Paired[i]), - fmt.Sprintf("%d", m.transferResult.Orphaned[i]), - formatTolerance(t.TolerancePct), - t.Note, - }) - } - - cursor := m.transferListTable.Cursor() - m.transferListTable.SetRows(rows) - if cursor >= len(rows) { - cursor = len(rows) - 1 - } - if cursor < 0 { - cursor = 0 - } - m.transferListTable.SetCursor(cursor) - return nil -} - -// formatTolerance renders a definition's tolerance for the list. The default -// is blank rather than "0%": every definition has it, so printing it down the -// whole column would bury the one or two rows where the amounts are actually -// allowed to disagree. -func formatTolerance(pct float64) string { - if pct == 0 { - return "" - } - return strconv.FormatFloat(pct, 'f', -1, 64) + "%" -} - -// unusedTransfers lists the positions of every definition that catches nothing -// at all. A definition with unpaired legs is deliberately not in here: it is -// doing something, just not completing it, and deleting it would hide the -// problem instead of fixing it. -func (m *Model) unusedTransfers() []int { - var out []int - for i, n := range m.transferResult.Paired { - if n == 0 && m.transferResult.Orphaned[i] == 0 { - out = append(out, i) - } - } - return out -} - -// unpairedLegs counts the legs no definition could complete. -func (m *Model) unpairedLegs() int { return len(m.transferResult.Unmatched) } - -// deleteTransfers removes definitions from rules.toml, then re-pairs so the -// counts on screen reflect the new file. -func (m *Model) deleteTransfers(positions []int) error { - n, err := config.DeleteTransfers(m.root, positions) - if err != nil { - return err - } - if err := m.reloadConfig(); err != nil { - return fmt.Errorf("transfers deleted, but re-reading rules.toml failed: %w", err) - } - - paired, unpaired, err := m.links.Link(m.db) - if err != nil { - return err - } - m.status = fmt.Sprintf("deleted %d transfer(s), %d matched, %d leg(s) unpaired", - n, paired, unpaired) - - if err := m.reload(); err != nil { - return err - } - return m.reloadTransferList() -} - -// updateTransferList drives the transfer list, including the confirmations. -func (m *Model) updateTransferList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.confirm != confirmNone { - pending := m.confirm - m.confirm = confirmNone - if msg.String() != "y" { - m.status = "cancelled" - return m, nil - } - m.err = nil - switch pending { - case confirmDeleteTransfer: - if i := m.transferListTable.Cursor(); i >= 0 && i < len(m.links.Transfers()) { - if err := m.deleteTransfers([]int{i}); err != nil { - m.err = err - } - } - case confirmPruneTransfers: - if err := m.deleteTransfers(m.unusedTransfers()); err != nil { - m.err = err - } - } - return m, nil - } - - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "esc": - m.view = m.transferListReturn - return m, nil - case "1": - m.view = viewAccounts - return m, nil - case "2": - m.view = viewTxns - return m, nil - case "3": - m.view = viewReport - return m, nil - case "4": - return m, m.openRuleBuilder() - case "5": - m.openRuleList() - return m, nil - case "6": - return m, m.openTransferBuilder() - - case "d": - i := m.transferListTable.Cursor() - defs := m.links.Transfers() - if i < 0 || i >= len(defs) { - return m, nil - } - m.confirm = confirmDeleteTransfer - m.status = fmt.Sprintf("delete transfer %d (%s → %s), %d matched? y/n", - i+1, defs[i].FromAccount, defs[i].ToAccount, m.transferResult.Paired[i]) - return m, nil - - case "p": - unused := m.unusedTransfers() - if len(unused) == 0 { - m.status = "no transfers to prune; only ones matching nothing at all are pruned" - return m, nil - } - m.confirm = confirmPruneTransfers - m.status = fmt.Sprintf("delete all %d transfers that match nothing? y/n", len(unused)) - return m, nil - - case "r": - m.err = m.reloadTransferList() - m.status = "pairing refreshed" - return m, nil - } - - var cmd tea.Cmd - m.transferListTable, cmd = m.transferListTable.Update(msg) - return m, cmd -} - -// updateTransfers drives the transfer builder form. Like the rule builder it -// owns every printable key, so the global keymap must not reach it. -func (m *Model) updateTransfers(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - case "esc": - m.view = m.transferReturn - return m, nil - case "tab": - if acceptCompletion(m.transferInputs()[m.transferFocus]) { - m.refreshTransferPreview() - return m, nil - } - m.setTransferFocus(m.transferFocus + 1) - return m, nil - case "down": - m.setTransferFocus(m.transferFocus + 1) - return m, nil - case "shift+tab", "up": - m.setTransferFocus(m.transferFocus - 1) - return m, nil - case "right": - in := m.transferInputs()[m.transferFocus] - if in.Position() == len([]rune(in.Value())) && acceptCompletion(in) { - m.refreshTransferPreview() - return m, nil - } - case "pgdown": - m.transferTable.MoveDown(10) - return m, nil - case "pgup": - m.transferTable.MoveUp(10) - return m, nil - case "enter": - m.err = nil - if err := m.saveTransfer(); err != nil { - m.err = err - } - return m, nil - } - - var cmd tea.Cmd - inputs := m.transferInputs() - *inputs[m.transferFocus], cmd = inputs[m.transferFocus].Update(msg) - m.refreshTransferPreview() - return m, cmd -} - -// transfersView puts the form on the left and the live pairing on the right. -func (m *Model) transfersView() string { - return lipgloss.JoinHorizontal(lipgloss.Top, m.transferFormView(), m.transferTable.View()) -} - -func (m *Model) transferFormView() string { - // Six fields need more room than the rule builder's four, so the form gives - // ground in three stages: the blank lines first, then the hints on unfocused - // fields, then their borders. Labels and values never go — six anonymous - // boxes would be worse than a form that scrolls. - room := m.height - 6 - spaced := m.height <= 0 || room >= 37 - hints := m.height <= 0 || room >= 31 - boxed := m.height <= 0 || room >= 25 - - inputs := m.transferInputs() - field := func(i int, label, help string) string { - name := labelStyle.Render(" " + label) - box := flatBoxStyle.Render(inputs[i].View()) - if boxed { - box = boxStyle.Render(inputs[i].View()) - } - if i == m.transferFocus { - name = focusedLabelStyle.Render("▸ " + label) - box = focusedBoxStyle.Render(inputs[i].View()) - } - out := name + "\n" + box + "\n" - if hints || i == m.transferFocus { - out += hintStyle.Render(help) + "\n" - } - if spaced { - out += "\n" - } - return out - } - - var b strings.Builder - b.WriteString(field(0, "from account", completionHint(inputs[0], m.transferFocus == 0, "money leaves here"))) - b.WriteString(field(1, "from desc", "glob vs. the leaving leg")) - b.WriteString(field(2, "to account", completionHint(inputs[2], m.transferFocus == 2, "money arrives here"))) - b.WriteString(field(3, "to desc", "glob vs. the arriving leg")) - b.WriteString(field(4, "tolerance %", "0 = amounts must match exactly")) - b.WriteString(field(5, "note", "why this transfer exists")) - - summary := fmt.Sprintf("%d pairs · %d unpaired", m.previewPairs, m.previewUnmatched) - if m.previewPairs == 0 && m.previewUnmatched == 0 { - summary = "nothing matches yet" - } - // Fees are the reason the tolerance field exists, so the preview says what - // it is admitting rather than only how many pairs it bought. - if fee := m.previewFees; fee != 0 { - summary += fmt.Sprintf(" · %s in fees", model.FormatMinor(fee, m.previewFeeDigits)) - } - b.WriteString(matchCountStyle.Render(summary)) - - return ruleFormStyle.Render(b.String()) -} - -// selected returns the transaction under the cursor, if any. -func (m *Model) selected() (model.Transaction, bool) { - i := m.txnTable.Cursor() - if i < 0 || i >= len(m.txns) { - return model.Transaction{}, false - } - return m.txns[i], true -} - -// importDoneMsg carries the outcome of a background import back to the model. -type importDoneMsg struct { - res importer.Result - err error -} - -// importCmd runs the import off the event loop. Only values are captured, and -// the model is left untouched until the result comes back as a message. -func (m *Model) importCmd() tea.Cmd { - root, db, accounts, engine, links := m.root, m.db, m.accounts, m.engine, m.links - return func() tea.Msg { - res, err := importer.Run(root, db, accounts, engine, links, importer.Options{}) - return importDoneMsg{res: res, err: err} - } -} - -// Update implements tea.Model. -func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width, m.height = msg.Width, msg.Height - m.resize() - return m, nil - - case spinner.TickMsg: - if !m.importing { - return m, nil // a stale tick from a finished import - } - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - - case importDoneMsg: - return m.finishImport(msg) - - case tea.KeyMsg: - if m.input != inputNone { - return m.updateInput(msg) - } - // The builders are forms: every printable key belongs to the focused - // input, so the global single-letter keymap cannot apply. - switch m.view { - case viewRules: - return m.updateRules(msg) - case viewTransfers: - return m.updateTransfers(msg) - case viewRuleList: - return m.updateRuleList(msg) - case viewTransferList: - return m.updateTransferList(msg) - } - return m.updateNormal(msg) - } - return m, nil -} - -// updateRules drives the rule builder form. -func (m *Model) updateRules(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - case "esc": - m.view = m.ruleReturn - return m, nil - case "tab": - // Tab is overloaded on purpose: with a completion on offer it takes - // it, and moving on is then one more tab. Without one it does what it - // always did and moves to the next field. - if acceptCompletion(m.ruleInputs()[m.ruleFocus]) { - m.refreshRulePreview() - return m, nil - } - m.setRuleFocus(m.ruleFocus + 1) - return m, nil - case "down": - m.setRuleFocus(m.ruleFocus + 1) - return m, nil - case "shift+tab", "up": - m.setRuleFocus(m.ruleFocus - 1) - return m, nil - case "right": - // At the end of the line → has nothing else to do, so it accepts the - // completion the way a shell does. Anywhere else it falls through and - // moves the cursor. - in := m.ruleInputs()[m.ruleFocus] - if in.Position() == len([]rune(in.Value())) && acceptCompletion(in) { - m.refreshRulePreview() - return m, nil - } - case "pgdown": - m.ruleTable.MoveDown(10) - return m, nil - case "pgup": - m.ruleTable.MoveUp(10) - return m, nil - case "ctrl+s": - // A form owns every printable key, so re-sorting has to be a chord: - // "s" belongs to whichever glob is being typed. - if m.previewSort == sortByName { - m.previewSort = sortByCount - } else { - m.previewSort = sortByName - } - m.sortPreviewGroups() - // The rows under the cursor are not the rows that were there a moment - // ago, so start from the top rather than somewhere arbitrary. - m.ruleTable.SetCursor(0) - return m, nil - case "enter": - m.err = nil - editing := m.ruleEditing // saveRule clears it on the way through - if err := m.saveRule(); err != nil { - m.err = err - return m, nil - } - if editing { - // An edit is a round trip from the rules screen, so it ends there - // with the new counts rather than in an empty form. - m.showRuleList() - return m, nil - } - if err := m.reloadPreviewGroups(); err != nil { - m.err = err - } - return m, nil - } - - var cmd tea.Cmd - inputs := m.ruleInputs() - *inputs[m.ruleFocus], cmd = inputs[m.ruleFocus].Update(msg) - m.refreshRulePreview() - return m, cmd -} - -func (m *Model) finishImport(msg importDoneMsg) (tea.Model, tea.Cmd) { - m.importing = false - if msg.err != nil { - m.err = msg.err - m.status = "import failed" - return m, nil - } - - _, added, skipped := msg.res.Total() - m.status = fmt.Sprintf("imported: %d new, %d duplicate", added, skipped) - if msg.res.Unpaired > 0 { - m.status += fmt.Sprintf(" · %d transfer leg(s) unpaired", msg.res.Unpaired) - } - - // A failed file and a warning are both worth surfacing, but the status - // line only has room for the first thing that went wrong. - if failures := msg.res.Errs(); len(failures) > 0 { - m.err = fmt.Errorf("%s: %w", failures[0].Path, failures[0].Err) - } else if warnings := firstWarning(msg.res); warnings != "" { - m.status += " · " + warnings - } - - if err := m.reload(); err != nil { - m.err = err - } - return m, nil -} - -func firstWarning(res importer.Result) string { - total := 0 - first := "" - for _, f := range res.Files { - for _, w := range f.Warnings { - if first == "" { - first = fmt.Sprintf("%s: %s", f.Path, w) - } - total++ - } - } - if total > 1 { - return fmt.Sprintf("%s (+%d more warnings)", first, total-1) - } - return first -} - -func (m *Model) resize() { - h := m.height - 6 // title, status, help, padding - if h < 3 { - h = 3 - } - m.accountTable.SetHeight(h) - m.txnTable.SetHeight(h) - m.reportTable.SetHeight(h) - m.ruleTable.SetHeight(h) - m.ruleListTable.SetHeight(h) - m.transferTable.SetHeight(h) - m.transferListTable.SetHeight(h) - - // The preview list gets whatever the form does not use. - if m.width > 0 { - cols := m.ruleTable.Columns() - desc := m.width - ruleFormWidth - cols[0].Width - cols[2].Width - 10 - if desc < 20 { - desc = 20 - } - cols[1].Width = desc - m.ruleTable.SetColumns(cols) - - cols = m.transferTable.Columns() - fixed := 0 - for _, c := range cols[:len(cols)-1] { - fixed += c.Width + 2 - } - desc = m.width - ruleFormWidth - fixed - 6 - if desc < 16 { - desc = 16 - } - cols[len(cols)-1].Width = desc - m.transferTable.SetColumns(cols) - } - - // The free-text columns come last and get whatever is left over. - m.stretchLastColumn(&m.txnTable, 20) - m.stretchLastColumn(&m.ruleListTable, 12) - m.stretchLastColumn(&m.transferListTable, 12) -} - -// stretchLastColumn widens a table's final column to fill the window, down to -// a floor below which truncation is worse than letting the row overflow. -func (m *Model) stretchLastColumn(t *table.Model, min int) { - if m.width <= 0 { - return - } - cols := t.Columns() - fixed := 0 - for _, c := range cols[:len(cols)-1] { - fixed += c.Width + 2 - } - last := m.width - fixed - 4 - if last < min { - last = min - } - cols[len(cols)-1].Width = last - t.SetColumns(cols) -} - -// updateInput handles typing into the tag or search prompt. -func (m *Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEsc: - m.input = inputNone - m.text.Blur() - m.text.SetValue("") - return m, nil - case tea.KeyEnter: - value := strings.TrimSpace(m.text.Value()) - mode := m.input - m.input = inputNone - m.text.Blur() - m.text.SetValue("") - - switch mode { - case inputSearch: - m.filter.Search = value - m.status = "search: " + value - if value == "" { - m.status = "search cleared" - } - } - m.err = m.reloadTxns() - return m, nil - } - - var cmd tea.Cmd - m.text, cmd = m.text.Update(msg) - return m, cmd -} - -// updateNormal handles the main keymap. -func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - - case "1": - m.view = viewAccounts - return m, nil - case "2": - m.view = viewTxns - return m, nil - case "3": - m.view = viewReport - return m, nil - case "4": - return m, m.openRuleBuilder() - case "5": - m.openRuleList() - return m, nil - case "6": - return m, m.openTransferBuilder() - case "7": - m.openTransferList() - return m, nil - case "s": - // Re-sorting only means anything where there is a breakdown to sort. - if m.view != viewReport { - break - } - m.setOrder(m.reportOrder.Next()) - return m, nil - - case "left", "right": - // The report's time axis. Every other view leaves these to its table, - // which is what breaking out of the switch here does. - if m.view != viewReport { - break - } - step := 1 - if msg.String() == "left" { - step = -1 - } - m.setPeriod(m.reportPeriod + step) - return m, nil - - case "tab": - m.view = (m.view + 1) % viewCount - switch m.view { - case viewRules: - return m, m.openRuleBuilder() - case viewRuleList: - m.openRuleList() - case viewTransfers: - return m, m.openTransferBuilder() - case viewTransferList: - m.openTransferList() - } - return m, nil - - case "enter": - // From the account list, drill into that account's transactions. - if m.view == viewAccounts { - accounts, err := m.db.Accounts() - if err != nil { - m.err = err - return m, nil - } - i := m.accountTable.Cursor() - if i >= 0 && i < len(accounts) { - m.filter.AccountSlug = accounts[i].Slug - m.status = "account: " + accounts[i].Slug - m.view = viewTxns - m.err = m.reloadTxns() - } - } - return m, nil - - case "a": - m.filter.AccountSlug = "" - m.status = "all accounts" - m.err = m.reloadTxns() - return m, nil - - case "u": - m.onlyUntagged = !m.onlyUntagged - if m.onlyUntagged { - m.status = "showing untagged only" - m.view = viewTxns - } else { - m.status = "showing all transactions" - } - m.err = m.reloadTxns() - return m, nil - - case "/": - m.view = viewTxns - m.input = inputSearch - m.text.SetValue(m.filter.Search) - m.text.CursorEnd() - m.text.Focus() - return m, nil - - case "r": - if m.importing { - m.status = "import in progress…" - return m, nil - } - n, err := m.engine.Retag(m.db) - if err != nil { - m.err = err - return m, nil - } - // Tags and transfers are both derived from rules.toml, so one key - // re-derives both; leaving the pairing stale would quietly change what - // the report holds out. - paired, unpaired, err := m.links.Link(m.db) - if err != nil { - m.err = err - return m, nil - } - m.status = fmt.Sprintf("rules re-applied, %d rows changed, %d transfers matched", n, paired) - if unpaired > 0 { - m.status += fmt.Sprintf(", %d leg(s) unpaired", unpaired) - } - m.err = m.reload() - return m, nil - - case "i": - if m.importing { - return m, nil // already running; ignore the repeat press - } - m.importing = true - m.err = nil - m.status = "" - return m, tea.Batch(m.spinner.Tick, m.importCmd()) - } - - var cmd tea.Cmd - switch m.view { - case viewAccounts: - m.accountTable, cmd = m.accountTable.Update(msg) - case viewTxns: - m.txnTable, cmd = m.txnTable.Update(msg) - case viewReport: - m.reportTable, cmd = m.reportTable.Update(msg) - } - return m, cmd -} - -// View implements tea.Model. -func (m *Model) View() string { - var b strings.Builder - - b.WriteString(titleStyle.Render(m.title())) - b.WriteString("\n") - - // An empty table is just a bare header, which says nothing about why it is - // empty or what to do next. - if empty := m.emptyMessage(); empty != "" { - b.WriteString(emptyStyle.Render(empty)) - } else { - switch m.view { - case viewAccounts: - b.WriteString(m.accountTable.View()) - case viewTxns: - b.WriteString(m.txnTable.View()) - case viewReport: - b.WriteString(m.reportView()) - case viewRules: - b.WriteString(m.rulesView()) - case viewRuleList: - b.WriteString(m.ruleListTable.View()) - case viewTransfers: - b.WriteString(m.transfersView()) - case viewTransferList: - b.WriteString(m.transferListTable.View()) - } - } - b.WriteString("\n") - - switch { - case m.importing: - b.WriteString(statusStyle.Render(m.spinner.View() + m.importingLabel())) - case m.input == inputSearch: - b.WriteString(statusStyle.Render("search: ") + m.text.View()) - case m.err != nil: - b.WriteString(errStyle.Render("error: " + m.err.Error())) - default: - b.WriteString(statusStyle.Render(m.status)) - } - b.WriteString("\n") - // Wrap the key list rather than letting the terminal cut it off: the keys - // at the end are as important as the ones at the start, and "q quit" was - // the first casualty on a narrow window. - help := helpStyle - if m.width > 0 { - help = help.Width(m.width) - } - b.WriteString(help.Render(m.help())) - return b.String() -} - -// periodListWidth is the width of the report's time axis, wide enough for the -// longest window name plus its cursor. -const periodListWidth = 18 - -// reportView puts the time axis to the left of the totals, so the window the -// numbers are for is visible without reading the title and ←/→ has something to -// point at. On a window too narrow for both, the totals win and the axis is -// dropped: the title still names the period, and truncated money columns would -// be worse than a hidden list of periods. -func (m *Model) reportView() string { - axis := m.periodList() - if axis == "" { - return m.reportTable.View() - } - return lipgloss.JoinHorizontal(lipgloss.Top, axis, m.reportTable.View()) -} - -// periodList renders the axis, scrolled to keep the selected window on screen. -// Its header stands in for the table's own, which is a single unruled line, so -// the two panels line up row for row without a separator of their own. -func (m *Model) periodList() string { - if len(m.reportPeriods) == 0 { - return "" - } - if m.width > 0 && m.width < tableWidth(m.reportTable)+periodListWidth { - return "" - } - - h := m.reportTable.Height() - if h <= 0 || h > len(m.reportPeriods) { - h = len(m.reportPeriods) - } - // Centre the cursor where there is room, so the windows either side of the - // one being read are visible and stepping has somewhere obvious to go. - start := m.reportPeriod - h/2 - if start > len(m.reportPeriods)-h { - start = len(m.reportPeriods) - h - } - if start < 0 { - start = 0 - } - - var b strings.Builder - b.WriteString(periodHeaderStyle.Render("Period")) - for i := start; i < start+h; i++ { - line := " " + m.reportPeriods[i].Label - style := periodStyle - if i == m.reportPeriod { - line = "▸ " + m.reportPeriods[i].Label - style = periodSelectedStyle - } - b.WriteString("\n" + style.Render(line)) - } - return b.String() -} - -// tableWidth is how many cells a table renders across, the cell style's padding -// on either side of every column included. -func tableWidth(t table.Model) int { - w := 0 - for _, c := range t.Columns() { - w += c.Width + 2 - } - return w -} - -// rulesView puts the form on the left and the live preview on the right. -func (m *Model) rulesView() string { - return lipgloss.JoinHorizontal(lipgloss.Top, m.ruleFormView(), m.ruleTable.View()) -} - -func (m *Model) ruleFormView() string { - // Spaced out, the four fields come to 25 lines, which is more than a short - // window has left once the same allowance the tables get is taken off. - // Give up the blank lines between fields first (21 lines) and the hints on - // unfocused fields second (18), rather than letting the last field run off - // the bottom. Below that the box borders are the floor. - // An edit carrying a type pattern renders one line more, and two once the - // fields are spaced out, so it asks for that much more room before either. - typed := 0 - if m.ruleEditing && m.ruleEditOrig.Type != "" { - typed = 1 - } - room := m.height - 6 - spaced := m.height <= 0 || room >= 25+2*typed - hints := m.height <= 0 || room >= 21+typed - - field := func(i int, label, help string) string { - name := labelStyle.Render(" " + label) - box := boxStyle.Render(m.ruleInputs()[i].View()) - if i == m.ruleFocus { - name = focusedLabelStyle.Render("▸ " + label) - box = focusedBoxStyle.Render(m.ruleInputs()[i].View()) - } - out := name + "\n" + box + "\n" - if hints || i == m.ruleFocus { - out += hintStyle.Render(help) + "\n" - } - if spaced { - out += "\n" - } - return out - } - - var b strings.Builder - inputs := m.ruleInputs() - b.WriteString(field(0, "glob", "vs. the description")) - // A type pattern has no field of its own, so an edit carrying one says so - // rather than leaving the rule looking broader than it is. - if m.ruleEditing && m.ruleEditOrig.Type != "" { - b.WriteString(hintStyle.Render("+ type:"+m.ruleEditOrig.Type+" · kept") + "\n") - if spaced { - b.WriteString("\n") - } - } - b.WriteString(field(1, "account", completionHint(inputs[1], m.ruleFocus == 1, "blank = all accounts"))) - b.WriteString(field(2, "tag", completionHint(inputs[2], m.ruleFocus == 2, "applied to matches"))) - b.WriteString(field(3, "note", "why this rule exists")) - - // The count is the whole point of the preview: it says what the rule will - // do before it is written to disk. - summary := fmt.Sprintf("%d of %d descriptions match", m.ruleMatches, m.ruleCandidates) - if strings.TrimSpace(m.ruleGlob.Value()) == "" && !m.ruleEditing { - summary = fmt.Sprintf("%d untagged descriptions", m.ruleCandidates) - } - // What an edit gives up is not visible in a count of what it keeps. It goes - // on its own line because the two together overflow the form's width, and - // lipgloss would wrap it mid-phrase. - if m.ruleDropped > 0 { - summary += fmt.Sprintf("\n%d no longer claimed", m.ruleDropped) - } - b.WriteString(matchCountStyle.Render(summary)) - - return ruleFormStyle.Render(b.String()) -} - -// completionHint names the key that accepts the ghosted completion, replacing -// the field's usual hint while one is on offer. The hint sits directly under -// the box the ghost text appears in, which is where the question is asked. -func completionHint(in *textinput.Model, focused bool, fallback string) string { - if !focused { - return fallback - } - if pendingCompletion(in) == "" { - return fallback - } - if n := len(in.MatchedSuggestions()); n > 1 { - return fmt.Sprintf("tab completes · ctrl+n: %d more", n-1) - } - return "tab completes" -} - -// emptyMessage explains an empty view and names the key that fixes it, or -// returns "" when there is something to show. -func (m *Model) emptyMessage() string { - switch m.view { - case viewAccounts: - if len(m.accountTable.Rows()) > 0 { - return "" - } - // An account.toml on disk is not enough: folders are only registered - // in the index by an import, so say that rather than showing nothing. - if n := len(m.accounts); n > 0 { - return fmt.Sprintf( - "No accounts imported yet.\n\n"+ - "%d account folder(s) configured in %s.\n"+ - "Press i to import their statements (same as running `money import`).", - n, m.root) - } - return fmt.Sprintf( - "No account folders found in %s.\n\n"+ - "An account is a folder containing an account.toml.\n"+ - "Create one, drop statements in, then press i to import.", - m.root) - - case viewTxns: - if len(m.txns) > 0 { - return "" - } - switch { - case m.filter.Search != "": - return fmt.Sprintf("No transactions match /%s.\n\nPress / to change the search.", m.filter.Search) - case m.onlyUntagged: - return "Nothing untagged.\n\n" + - "Every transaction here has a tag or belongs to a transfer.\n" + - "Press u to see them all." - case m.filter.AccountSlug != "": - return fmt.Sprintf( - "No transactions in %s.\n\nPress i to import, or a to see every account.", - m.filter.AccountSlug) - } - return "No transactions yet.\n\nPress i to import (same as running `money import`)." - - case viewReport: - if len(m.reportTable.Rows()) > 0 { - return "" - } - // A period with nothing in it is not the same as an empty index, and - // saying so matters most in the default window: statements for the month - // that has just ended often have not been downloaded yet. - if p, ok := m.currentPeriod(); ok && p.From != "" && !m.indexEmpty { - return fmt.Sprintf("Nothing in %s.\n\nPress ←/→ for another period, or i to import.", p.String()) - } - return "Nothing to report yet.\n\nPress i to import some statements first." - - case viewRuleList: - if len(m.ruleListTable.Rows()) > 0 { - return "" - } - return "No rules yet.\n\nPress 4 to build one, or write rules.toml by hand." - - case viewTransferList: - if len(m.transferListTable.Rows()) > 0 { - return "" - } - return "No transfers yet.\n\n" + - "A transfer names both legs of money moved between your own accounts,\n" + - "so the report can leave the pair out instead of counting it as spending.\n" + - "Press 6 to build one, or write rules.toml by hand." - } - return "" -} - -func (m *Model) title() string { - scope := "all accounts" - if m.filter.AccountSlug != "" { - scope = m.filter.AccountSlug - } - if m.onlyUntagged { - scope += " · untagged" - } - if m.filter.Search != "" { - scope += " · /" + m.filter.Search - } - - switch m.view { - case viewAccounts: - return "money · accounts" - case viewRules: - if m.ruleEditing { - return fmt.Sprintf("money · editing rule %d · rewrites it in rules.toml", m.ruleEditPos+1) - } - return "money · rule builder · writes to rules.toml" - case viewRuleList: - unused := len(m.unusedRules()) - if unused == 0 { - return fmt.Sprintf("money · rules · %d rules, all in use", len(m.engine.Rules())) - } - return fmt.Sprintf("money · rules · %d rules · %d match nothing", len(m.engine.Rules()), unused) - case viewTransfers: - return "money · transfer builder · writes to rules.toml" - case viewTransferList: - n := len(m.links.Transfers()) - if legs := m.unpairedLegs(); legs > 0 { - return fmt.Sprintf("money · transfers · %d definitions · %d leg(s) unpaired", n, legs) - } - return fmt.Sprintf("money · transfers · %d definitions · every leg paired", n) - case viewReport: - if p, ok := m.currentPeriod(); ok { - return "money · report · " + p.String() + " · " + scope - } - return "money · report · " + scope - default: - return fmt.Sprintf("money · transactions · %s · %d rows", scope, len(m.txns)) - } -} - -// importingLabel names what the import is working through, since extracting -// text from PDFs is where the wait actually comes from. -func (m *Model) importingLabel() string { - if n := len(m.accounts); n > 0 { - return fmt.Sprintf("importing %d account(s)… parsing statements can take a while for PDFs", n) - } - return "importing…" -} - -func (m *Model) help() string { - if m.importing { - return "importing… · q quit" - } - if m.input != inputNone { - return "enter confirm · esc cancel" - } - switch m.view { - case viewAccounts: - return "enter open · 2 transactions · 3 report · 4 new rule · 5 rules · 6 new transfer · 7 transfers · i import · r retag · q quit" - case viewRules: - save := "enter save rule" - if m.ruleEditing { - save = "enter save changes" - } - // The key is named by what pressing it does now, not by the order the - // list is already in. - order := "ctrl+s sort by count" - if m.previewSort == sortByCount { - order = "ctrl+s sort by name" - } - return "tab complete/next field · ↑↓ field · ctrl+n/p other completions · pgup/pgdn scroll list · " + - order + " · " + save + " · esc back · ctrl+c quit" - case viewRuleList: - if m.confirm != confirmNone { - return "y confirm · any other key cancels" - } - return "e edit rule · d delete rule · p prune all unused · r refresh counts · 4 new rule · 7 transfers · 1 accounts · esc back · q quit" - case viewTransfers: - return "tab complete/next field · ↑↓ field · pgup/pgdn scroll pairs · enter save transfer · esc back · ctrl+c quit" - case viewTransferList: - if m.confirm != confirmNone { - return "y confirm · any other key cancels" - } - return "d delete transfer · p prune all unmatched · r refresh pairing · 6 new transfer · 5 rules · 1 accounts · esc back · q quit" - case viewReport: - // The key is named by what pressing it does now, not by the order the - // rows are already in. - return "←/→ period · s sort by " + m.reportOrder.Next().String() + - " · 1 accounts · 2 transactions · 4 new rule · 5 rules · 7 transfers · u untagged · a all accounts · q quit" - default: - return "4 new rule · / search · u untagged · a all · i import · r retag · 5 rules · 7 transfers · q quit" - } -} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go deleted file mode 100644 index a3cf605..0000000 --- a/internal/tui/tui_test.go +++ /dev/null @@ -1,2584 +0,0 @@ -package tui - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - "testing" - "time" - - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - - "git.petrovv.com/nikola/money/internal/config" - "git.petrovv.com/nikola/money/internal/importer" - "git.petrovv.com/nikola/money/internal/model" - "git.petrovv.com/nikola/money/internal/report" - "git.petrovv.com/nikola/money/internal/rules" - "git.petrovv.com/nikola/money/internal/store" - "git.petrovv.com/nikola/money/internal/transfers" -) - -// newTestModel builds a model over an index holding two transactions, one of -// which the rules tag as groceries. -func newTestModel(t *testing.T) (*Model, *store.DB) { - t.Helper() - - db, err := store.Open(filepath.Join(t.TempDir(), "index.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - accountID, err := db.UpsertAccount(model.Account{ - Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, - }) - if err != nil { - t.Fatal(err) - } - sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - for i, d := range []string{"LIDL SOFIA", "SOME RANDOM SHOP"} { - if _, err := db.InsertTransaction(model.Transaction{ - AccountID: accountID, - SourceFileID: sourceID, - Fingerprint: d, - Date: "2026-01-0" + string(rune('1'+i)), - Description: d, - AmountMinor: -1000, - }); err != nil { - t.Fatal(err) - } - } - - engine := rules.New(&config.Rules{Rule: []config.Rule{{Match: "*LIDL*", Tag: "groceries"}}}) - if _, err := engine.Retag(db); err != nil { - t.Fatal(err) - } - - m := New(t.TempDir(), db, nil, engine, transfers.New(&config.Rules{})) - if err := m.reload(); err != nil { - t.Fatal(err) - } - m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) - return m, db -} - -// allTime moves the report off its default window. That window is last month, -// while the fixtures are dated whenever they were written, so a test that wants -// to see every transaction in the report has to say so. -func allTime(t *testing.T, m *Model) { - t.Helper() - m.setPeriod(0) - if p, _ := m.currentPeriod(); p.Label != "all time" { - t.Fatalf("period = %q, want all time", p.Label) - } - if m.err != nil { - t.Fatal(m.err) - } -} - -// key sends a single keypress to the model. -func key(t *testing.T, m *Model, s string) { - t.Helper() - var msg tea.KeyMsg - switch s { - case "enter": - msg = tea.KeyMsg{Type: tea.KeyEnter} - case "esc": - msg = tea.KeyMsg{Type: tea.KeyEsc} - case "ctrl+s": - msg = tea.KeyMsg{Type: tea.KeyCtrlS} - default: - msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} - } - m.Update(msg) - if m.err != nil { - t.Fatalf("key %q produced an error: %v", s, m.err) - } -} - -// typeText sends each character as its own keypress, as a terminal would. -func typeText(t *testing.T, m *Model, s string) { - t.Helper() - for _, r := range s { - m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) - } -} - -// cursorTo moves the transaction cursor onto the row with the given description. -func cursorTo(t *testing.T, m *Model, desc string) { - t.Helper() - for i, txn := range m.txns { - if txn.Description == desc { - m.txnTable.SetCursor(i) - return - } - } - t.Fatalf("no visible transaction with description %q", desc) -} - -func TestUntaggedToggle(t *testing.T) { - m, _ := newTestModel(t) - - key(t, m, "u") - if !m.onlyUntagged { - t.Fatal("expected u to enable the untagged filter") - } - if len(m.txns) != 1 || m.txns[0].Description != "SOME RANDOM SHOP" { - t.Fatalf("untagged view shows %+v, want only SOME RANDOM SHOP", m.txns) - } - - // A rule that claims the last untagged row empties the view; the cursor - // must stay valid. Saving a rule is the only way to tag anything now. - // - // Saving reloads the rules from rules.toml, so the fixture's rule has to - // exist there too or retagging would drop the tag it granted. - if err := config.AppendRule(m.root, config.Rule{Match: "*LIDL*", Tag: "groceries"}); err != nil { - t.Fatal(err) - } - cursorTo(t, m, "SOME RANDOM SHOP") - key(t, m, "4") - m.ruleGlob.SetValue("*RANDOM*") - m.setRuleFocus(2) - m.ruleTag.SetValue("hobby") - key(t, m, "enter") - key(t, m, "esc") // back to the transaction list, where u applies again - if len(m.txns) != 0 { - t.Errorf("expected the untagged view to be empty, got %d rows", len(m.txns)) - } - if _, ok := m.selected(); ok { - t.Error("expected no selection in an empty view") - } - m.View() // must not panic on an empty table - - key(t, m, "u") - if len(m.txns) != 2 { - t.Errorf("expected all rows back after toggling off, got %d", len(m.txns)) - } -} - -func TestSearchFilter(t *testing.T) { - m, _ := newTestModel(t) - - key(t, m, "/") - if m.input != inputSearch { - t.Fatal("expected the search prompt to open") - } - typeText(t, m, "lidl") - key(t, m, "enter") - - if len(m.txns) != 1 || m.txns[0].Description != "LIDL SOFIA" { - t.Fatalf("search results = %+v, want only LIDL SOFIA", m.txns) - } - - // Reopening the prompt pre-fills the active query so it can be edited. - key(t, m, "/") - if got := m.text.Value(); got != "lidl" { - t.Errorf("search prompt pre-filled with %q, want %q", got, "lidl") - } - for range len("lidl") { - m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) - } - key(t, m, "enter") // an emptied query clears the filter - if len(m.txns) != 2 { - t.Errorf("expected the filter to clear, got %d rows", len(m.txns)) - } -} - -// Every transaction reaches the report: the tagged ones under their tag, the -// rest under (untagged). -func TestReportViewCoversEveryTransaction(t *testing.T) { - m, _ := newTestModel(t) - - key(t, m, "3") // report view - allTime(t, m) - view := m.View() - for _, want := range []string{"groceries", report.Untagged, "TOTAL"} { - if !strings.Contains(view, want) { - t.Errorf("report is missing %q:\n%s", want, view) - } - } -} - -// Import must not block the event loop: pressing i starts a background command -// and puts the view into a spinning state that keeps redrawing. -func TestImportShowsSpinnerAndDoesNotBlock(t *testing.T) { - m, _ := newTestModel(t) - - _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) - if !m.importing { - t.Fatal("expected the model to be in the importing state") - } - if cmd == nil { - t.Fatal("expected a command to run the import in the background") - } - if view := m.View(); !strings.Contains(view, "importing") { - t.Errorf("expected the status line to say it is importing:\n%s", view) - } - - // A tick advances the spinner and schedules the next frame. - _, tickCmd := m.Update(spinner.TickMsg{Time: time.Now()}) - if tickCmd == nil { - t.Error("expected the spinner to schedule another tick while importing") - } - - // Pressing i again must not start a second concurrent import. - if _, again := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}); again != nil { - t.Error("expected a repeat i press to be ignored while importing") - } - // Nor may retag run against the database mid-import. - m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) - if !strings.Contains(m.status, "import in progress") { - t.Errorf("status = %q, want retag to be refused during an import", m.status) - } - - // The result arriving clears the spinner and reports what happened. - m.Update(importDoneMsg{res: importer.Result{ - Files: []importer.FileResult{{Path: "checking/st.csv", New: 3, Skipped: 1}}, - }}) - if m.importing { - t.Error("expected the importing state to clear") - } - if !strings.Contains(m.status, "3 new") || !strings.Contains(m.status, "1 duplicate") { - t.Errorf("status = %q, want the import counts", m.status) - } - // A stale tick after the import finished must not restart the spinner. - if _, cmd := m.Update(spinner.TickMsg{Time: time.Now()}); cmd != nil { - t.Error("expected a tick after the import to be ignored") - } -} - -// The spinner must keep ticking for as long as the import runs: one frame and -// then silence looks exactly like the freeze it is meant to rule out. -func TestSpinnerKeepsTicking(t *testing.T) { - m, _ := newTestModel(t) - m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) - - // Drive the loop the way Bubble Tea does: run the command, feed the - // message it produces back into Update, and repeat. - var frames []string - cmd := tea.Cmd(m.spinner.Tick) - for i := 0; i < 4; i++ { - if cmd == nil { - t.Fatalf("tick %d: no command scheduled, the spinner stopped", i) - } - msg := cmd() - if msg == nil { - t.Fatalf("tick %d: command produced no message", i) - } - _, cmd = m.Update(msg) - frames = append(frames, m.spinner.View()) - } - - if len(unique(frames)) < 2 { - t.Errorf("spinner rendered %v; want the frame to advance between ticks", frames) - } -} - -func unique(in []string) []string { - seen := map[string]bool{} - var out []string - for _, s := range in { - if !seen[s] { - seen[s] = true - out = append(out, s) - } - } - return out -} - -func TestImportFailureIsReported(t *testing.T) { - m, _ := newTestModel(t) - m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) - - m.Update(importDoneMsg{err: errors.New("index is locked")}) - if m.importing { - t.Error("expected the importing state to clear on failure") - } - if m.err == nil || !strings.Contains(m.err.Error(), "index is locked") { - t.Errorf("err = %v, want the failure surfaced", m.err) - } -} - -// A per-file warning is worth seeing without leaving the TUI. -func TestImportWarningsSurface(t *testing.T) { - m, _ := newTestModel(t) - m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) - - m.Update(importDoneMsg{res: importer.Result{Files: []importer.FileResult{{ - Path: "revolut/statement.csv", - New: 2, - Warnings: []string{"skipped 1 PENDING transactions", "skipped 3 rows in JPY"}, - }}}}) - - if !strings.Contains(m.status, "skipped 1 PENDING") { - t.Errorf("status = %q, want the first warning", m.status) - } - if !strings.Contains(m.status, "+1 more") { - t.Errorf("status = %q, want a count of the remaining warnings", m.status) - } -} - -// newRuleModel builds a model whose data root is a real directory, so the rule -// builder can actually write rules.toml. -func newRuleModel(t *testing.T) (*Model, *store.DB, string) { - t.Helper() - root := t.TempDir() - - db, err := store.Open(config.IndexPath(root)) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - accountID, err := db.UpsertAccount(model.Account{ - Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, - }) - if err != nil { - t.Fatal(err) - } - savingsID, err := db.UpsertAccount(model.Account{ - Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2, - }) - if err != nil { - t.Fatal(err) - } - sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - // Deliberately unsorted, with LIDL appearing twice so the preview has - // something to group. - seed := []struct { - account int64 - desc string - }{ - {accountID, "ZZZ LAST ALPHABETICALLY"}, - {accountID, "LIDL SOFIA 4412"}, - {accountID, "LIDL SOFIA 4412"}, - {accountID, "AAA FIRST ALPHABETICALLY"}, - {savingsID, "LIDL VARNA 9911"}, - } - for i, s := range seed { - if _, err := db.InsertTransaction(model.Transaction{ - AccountID: s.account, - SourceFileID: sourceID, - Fingerprint: fmt.Sprintf("fp-%d", i), - Date: "2026-01-01", - Description: s.desc, - AmountMinor: -1000, - }); err != nil { - t.Fatal(err) - } - } - - m := New(root, db, []*config.Account{ - {Slug: "checking", Currency: "EUR", Parser: "revolut"}, - {Slug: "savings", Currency: "EUR", Parser: "revolut"}, - }, rules.New(&config.Rules{}), transfers.New(&config.Rules{})) - if err := m.reload(); err != nil { - t.Fatal(err) - } - m.Update(tea.WindowSizeMsg{Width: 140, Height: 30}) - return m, db, root -} - -// descriptions returns the preview list as it is rendered, in order. -func previewRows(m *Model) (all []string, matched []string) { - for _, row := range m.ruleTable.Rows() { - all = append(all, row[1]) - if row[0] == "▸" { - matched = append(matched, row[1]) - } - } - return all, matched -} - -func TestRuleBuilderListsUntaggedAlphabetically(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "4") - - if m.view != viewRules { - t.Fatal("expected 4 to open the rule builder") - } - - all, _ := previewRows(m) - want := []string{ - "AAA FIRST ALPHABETICALLY", - "LIDL SOFIA 4412", - "LIDL VARNA 9911", - "ZZZ LAST ALPHABETICALLY", - } - if len(all) != len(want) { - t.Fatalf("preview = %v, want %v (identical descriptions must be grouped)", all, want) - } - for i := range want { - if all[i] != want[i] { - t.Errorf("row %d = %q, want %q", i, all[i], want[i]) - } - } - - // The repeated LIDL row carries its occurrence count. - for _, row := range m.ruleTable.Rows() { - if row[1] == "LIDL SOFIA 4412" && row[2] != "2" { - t.Errorf("count for the repeated description = %q, want 2", row[2]) - } - } -} - -func TestRuleBuilderPreviewsGlobAsYouType(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "4") - - if _, matched := previewRows(m); len(matched) != 0 { - t.Errorf("nothing should match before a glob is typed, got %v", matched) - } - - typeText(t, m, "*LIDL*") - _, matched := previewRows(m) - if len(matched) != 2 || m.ruleMatches != 2 { - t.Fatalf("matched = %v (count %d), want both LIDL rows", matched, m.ruleMatches) - } - - // Narrowing the glob narrows the preview. - typeText(t, m, "\b") // not a backspace key; replace the value instead - m.ruleGlob.SetValue("*SOFIA*") - m.refreshRulePreview() - if _, matched := previewRows(m); len(matched) != 1 || matched[0] != "LIDL SOFIA 4412" { - t.Errorf("matched = %v, want only the Sofia row", matched) - } -} - -// The account field scopes the preview the same way it will scope the rule. -func TestRuleBuilderAccountFiltersPreview(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "4") - - m.ruleGlob.SetValue("*LIDL*") - m.ruleAccount.SetValue("savings") - m.refreshRulePreview() - - all, matched := previewRows(m) - if len(all) != 1 || all[0] != "LIDL VARNA 9911" { - t.Errorf("preview = %v, want only the savings row", all) - } - if len(matched) != 1 { - t.Errorf("matched = %v, want the savings row to match", matched) - } -} - -// Typing must never reach the global keymap: q, i and 1 are ordinary text here. -func TestRuleBuilderSwallowsGlobalKeys(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "4") - - typeText(t, m, "q1i") - if m.view != viewRules { - t.Fatal("typing must not switch views") - } - if got := m.ruleGlob.Value(); got != "q1i" { - t.Errorf("glob = %q, want the typed characters", got) - } - if m.importing { - t.Error("typing i must not start an import") - } -} - -func TestRuleBuilderSavesRuleAndRetags(t *testing.T) { - m, db, root := newRuleModel(t) - key(t, m, "4") - - m.ruleGlob.SetValue("*LIDL*") - m.setRuleFocus(2) - m.ruleTag.SetValue("groceries") - key(t, m, "enter") - - if m.err != nil { - t.Fatalf("saving failed: %v", m.err) - } - - // It landed in rules.toml... - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - if len(loaded.Rule) != 1 || loaded.Rule[0].Match != "*LIDL*" || loaded.Rule[0].Tag != "groceries" { - t.Fatalf("rules.toml holds %+v", loaded.Rule) - } - - // ...and was applied straight away. - txns, err := db.Transactions(store.Filter{}) - if err != nil { - t.Fatal(err) - } - tagged := 0 - for _, txn := range txns { - if txn.RuleTag == "groceries" { - tagged++ - } - } - if tagged != 3 { - t.Errorf("%d transactions tagged, want 3", tagged) - } - if !strings.Contains(m.status, "3 transactions retagged") { - t.Errorf("status = %q, want the retag count", m.status) - } - - // Those rows are no longer untagged, so the preview drops them. - all, _ := previewRows(m) - for _, d := range all { - if strings.Contains(d, "LIDL") { - t.Errorf("preview still lists %q after it was tagged", d) - } - } - // The glob and tag are cleared, ready for the next rule. - if m.ruleGlob.Value() != "" || m.ruleTag.Value() != "" { - t.Errorf("form not reset: glob=%q tag=%q", m.ruleGlob.Value(), m.ruleTag.Value()) - } -} - -func TestRuleBuilderRejectsIncompleteRule(t *testing.T) { - m, _, root := newRuleModel(t) - key(t, m, "4") - - // No glob, no tag. - m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - if m.err == nil { - t.Error("expected an error when saving an empty form") - } - - // Glob but no tag. - m.err = nil - m.ruleGlob.SetValue("*LIDL*") - m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - if m.err == nil || !strings.Contains(m.err.Error(), "tag") { - t.Errorf("err = %v, want a complaint about the missing tag", m.err) - } - - // An account that does not exist is caught before anything is written. - m.err = nil - m.ruleTag.SetValue("groceries") - m.ruleAccount.SetValue("nosuchaccount") - m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - if m.err == nil || !strings.Contains(m.err.Error(), "nosuchaccount") { - t.Errorf("err = %v, want a complaint about the unknown account", m.err) - } - - if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) { - t.Error("a rejected rule must not write rules.toml") - } -} - -func TestRuleBuilderTabCyclesFieldsAndEscLeaves(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "2") // come from the transactions view - key(t, m, "4") - - for i, want := range []int{1, 2, 3, 0} { - m.Update(tea.KeyMsg{Type: tea.KeyTab}) - if m.ruleFocus != want { - t.Errorf("tab %d moved focus to %d, want %d", i+1, m.ruleFocus, want) - } - } - if !m.ruleGlob.Focused() { - t.Error("expected the glob input to be focused after wrapping around") - } - - m.Update(tea.KeyMsg{Type: tea.KeyEsc}) - if m.view != viewTxns { - t.Errorf("esc returned to view %d, want the transactions view it came from", m.view) - } -} - -// The account field completes against the accounts that exist, because a slug -// typed by hand only has to be slightly wrong to produce a rule that silently -// catches nothing. -func TestRuleBuilderCompletesAccount(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "4") - - m.Update(tea.KeyMsg{Type: tea.KeyTab}) // onto the account field - typeText(t, m, "sa") - - // Nothing is committed until it is accepted: the value is still what was - // typed, with the rest offered as a ghost. - if m.ruleAccount.Value() != "sa" { - t.Fatalf("account = %q, want the typed text to stand until accepted", m.ruleAccount.Value()) - } - if got := m.ruleAccount.CurrentSuggestion(); got != "savings" { - t.Fatalf("suggestion = %q, want savings", got) - } - - m.Update(tea.KeyMsg{Type: tea.KeyTab}) - if m.ruleAccount.Value() != "savings" { - t.Fatalf("account = %q after tab, want savings", m.ruleAccount.Value()) - } - // Tab took the completion rather than the focus; the next one moves on. - if m.ruleFocus != 1 { - t.Errorf("focus = %d, want the account field to keep it while completing", m.ruleFocus) - } - m.Update(tea.KeyMsg{Type: tea.KeyTab}) - if m.ruleFocus != 2 { - t.Errorf("focus = %d, want the tag field once there is nothing left to complete", m.ruleFocus) - } - - // Accepting an account narrows the preview, exactly as typing it in full does. - if all, _ := previewRows(m); len(all) != 1 || all[0] != "LIDL VARNA 9911" { - t.Errorf("preview = %v, want only the savings row", all) - } -} - -// → accepts too, at the end of the line where it has nothing else to do. -func TestRuleBuilderRightArrowAcceptsCompletion(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "4") - - m.Update(tea.KeyMsg{Type: tea.KeyTab}) - typeText(t, m, "che") - m.Update(tea.KeyMsg{Type: tea.KeyRight}) - if m.ruleAccount.Value() != "checking" { - t.Errorf("account = %q after →, want checking", m.ruleAccount.Value()) - } - - // With no completion pending it is an ordinary cursor key again. - m.ruleAccount.SetCursor(0) - m.Update(tea.KeyMsg{Type: tea.KeyRight}) - if m.ruleAccount.Position() != 1 { - t.Errorf("cursor = %d, want → to move within the text once there is nothing to accept", - m.ruleAccount.Position()) - } -} - -// Tags come from the index and from rules.toml, so a tag is completable from -// the moment it is named in a rule, not only once something matches it. -func TestRuleBuilderCompletesTag(t *testing.T) { - m, _, _ := newRuleModel(t) - m.engine = rules.New(&config.Rules{Rule: []config.Rule{ - {Match: "*NOTHING*", Tag: "groceries"}, - {Match: "*ALSO NOTHING*", Tag: "grooming"}, - }}) - key(t, m, "4") - - m.setRuleFocus(2) - typeText(t, m, "gro") - if got := m.ruleTag.CurrentSuggestion(); got != "groceries" { - t.Fatalf("suggestion = %q, want the first of the two alphabetically", got) - } - if n := len(m.ruleTag.MatchedSuggestions()); n != 2 { - t.Fatalf("%d matched suggestions, want both gro* tags", n) - } - - // An ambiguous prefix is cycled, not guessed. - m.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) - if got := m.ruleTag.CurrentSuggestion(); got != "grooming" { - t.Fatalf("suggestion = %q after ctrl+n, want grooming", got) - } - m.Update(tea.KeyMsg{Type: tea.KeyTab}) - if m.ruleTag.Value() != "grooming" { - t.Errorf("tag = %q, want the cycled-to suggestion", m.ruleTag.Value()) - } - - // The hint under the box names the key that accepts, and says how many - // other candidates there are. - m.ruleTag.SetValue("gro") - m.ruleTag.SetSuggestions(m.ruleTag.AvailableSuggestions()) - if hint := completionHint(&m.ruleTag, true, "applied to matches"); !strings.Contains(hint, "tab") || - !strings.Contains(hint, "1 more") { - t.Errorf("hint = %q, want it to name tab and the remaining candidate", hint) - } -} - -// A tag invented in the builder is completable next time round. -func TestSavingARuleAddsItsTagToTheSuggestions(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "4") - - m.ruleGlob.SetValue("*LIDL*") - m.setRuleFocus(2) - m.ruleTag.SetValue("groceries") - key(t, m, "enter") - - m.setRuleFocus(2) // saving resets the form to the glob field - typeText(t, m, "gro") - if got := m.ruleTag.CurrentSuggestion(); got != "groceries" { - t.Errorf("suggestion = %q, want the tag just saved", got) - } -} - -// A prefix nothing matches must stay exactly as typed: the completion never -// invents a tag, and tab goes back to moving between fields. -func TestRuleBuilderKeepsUnmatchedInput(t *testing.T) { - m, _, _ := newRuleModel(t) - m.engine = rules.New(&config.Rules{Rule: []config.Rule{{Match: "*X*", Tag: "groceries"}}}) - key(t, m, "4") - - m.setRuleFocus(2) - typeText(t, m, "zzz") - m.Update(tea.KeyMsg{Type: tea.KeyTab}) - - if m.ruleTag.Value() != "zzz" { - t.Errorf("tag = %q, want the typed text untouched", m.ruleTag.Value()) - } - if m.ruleFocus != 3 { - t.Errorf("focus = %d, want tab to move on to the note field", m.ruleFocus) - } -} - -// A note is free text that rides along with the rule: written to rules.toml, -// read back, and shown on the rules screen so the reason survives longer than -// the memory of writing it. -func TestRuleBuilderSavesNote(t *testing.T) { - m, _, root := newRuleModel(t) - key(t, m, "4") - - m.ruleGlob.SetValue("*LIDL*") - m.setRuleFocus(2) - m.ruleTag.SetValue("groceries") - m.setRuleFocus(3) - typeText(t, m, "the weekly shop") - key(t, m, "enter") - - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - if len(loaded.Rule) != 1 || loaded.Rule[0].Note != "the weekly shop" { - t.Fatalf("rules.toml holds %+v, want the note round-tripped", loaded.Rule) - } - // The note is documentation, not a pattern: it must not narrow what the - // rule catches. - if loaded.Rule[0].Match != "*LIDL*" || loaded.Rule[0].Tag != "groceries" { - t.Errorf("rule = %+v, want the glob and tag untouched", loaded.Rule[0]) - } - if m.ruleNote.Value() != "" { - t.Errorf("note = %q, want the field cleared for the next rule", m.ruleNote.Value()) - } - - m.openRuleList() - row := m.ruleListTable.Rows()[0] - if row[len(row)-1] != "the weekly shop" { - t.Errorf("rules screen row = %v, want the note in the last column", row) - } -} - -// A rule without a note is still a rule; nothing about saving changes. -func TestRuleBuilderNoteIsOptional(t *testing.T) { - m, _, root := newRuleModel(t) - key(t, m, "4") - - m.ruleGlob.SetValue("*LIDL*") - m.ruleTag.SetValue("groceries") - key(t, m, "enter") - - raw, err := os.ReadFile(filepath.Join(root, config.RulesFile)) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), "note") { - t.Errorf("rules.toml = %q, want no empty note key", raw) - } -} - -// The form has to survive a short window: four fields spaced out are taller -// than the room a 24-line terminal leaves. -func TestRuleFormFitsShortTerminals(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "4") - - for _, height := range []int{40, 32, 30, 26, 24} { - m.Update(tea.WindowSizeMsg{Width: 140, Height: height}) - form := m.ruleFormView() - // The same budget the tables get: the title, status and a help line - // that may wrap onto a second row. - if lines := strings.Count(form, "\n") + 1; lines > height-6 { - t.Errorf("at height %d the form is %d lines, want at most %d", height, lines, height-6) - } - // However tight it gets, every field keeps its label and its box. - for _, label := range []string{"glob", "account", "tag", "note"} { - if !strings.Contains(form, label) { - t.Errorf("at height %d the %s field disappeared", height, label) - } - } - if boxes := strings.Count(form, "╭"); boxes != 4 { - t.Errorf("at height %d there are %d input boxes, want 4", height, boxes) - } - } -} - -// newRuleListModel seeds a data root whose rules.toml holds one useful rule, -// one shadowed rule and one that matches nothing. -func newRuleListModel(t *testing.T) (*Model, string) { - t.Helper() - m, _, root := newRuleModel(t) - - rulesTOML := `# My rules. - -# The weekly shop. -[[rule]] -match = "*LIDL*" -tag = "groceries" - -# Dead: the rule above is just as specific and gets there first, so this one -# never claims anything. A narrower pattern would have won instead. -[[rule]] -match = "*LIDL*" -tag = "shadowed" - -[[rule]] -match = "*NEVER MATCHES ANYTHING*" -tag = "dead" - -[[rule]] -match = "*ZARA*" -tag = "clothes" -` - if err := os.WriteFile(filepath.Join(root, config.RulesFile), []byte(rulesTOML), 0o644); err != nil { - t.Fatal(err) - } - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - m.engine = rules.New(loaded) - if _, err := m.engine.Retag(m.db); err != nil { - t.Fatal(err) - } - key(t, m, "5") - return m, root -} - -func TestRuleListCountsUsage(t *testing.T) { - m, _ := newRuleListModel(t) - - if m.view != viewRuleList { - t.Fatal("expected 5 to open the rule list") - } - - // LIDL SOFIA x2 plus LIDL VARNA; the shadowed and dead rules claim nothing. - want := []int{3, 0, 0, 0} - if len(m.ruleUsage) != len(want) { - t.Fatalf("usage = %v, want %d entries", m.ruleUsage, len(want)) - } - for i := range want { - if m.ruleUsage[i] != want[i] { - t.Errorf("rule %d used by %d, want %d", i+1, m.ruleUsage[i], want[i]) - } - } - - // Unused rules are marked so they can be picked out at a glance. - rows := m.ruleListTable.Rows() - if rows[0][1] != " " { - t.Errorf("the used rule is marked %q, want no marker", rows[0][1]) - } - for _, i := range []int{1, 2, 3} { - if rows[i][1] != "✗" { - t.Errorf("rule %d marker = %q, want ✗", i+1, rows[i][1]) - } - } - - if !strings.Contains(m.View(), "3 match nothing") { - t.Errorf("expected the title to count the dead rules:\n%s", m.View()) - } -} - -// Deleting rewrites a hand-maintained file, so it takes a confirmation. -func TestRuleListDeleteNeedsConfirmation(t *testing.T) { - m, root := newRuleListModel(t) - - m.ruleListTable.SetCursor(2) // the dead rule - key(t, m, "d") - if m.confirm != confirmDeleteRule { - t.Fatal("expected d to ask for confirmation") - } - if !strings.Contains(m.status, "delete rule 3") { - t.Errorf("status = %q, want the rule identified", m.status) - } - - key(t, m, "n") // anything but y cancels - if m.confirm != confirmNone { - t.Error("expected the confirmation to clear") - } - loaded, _ := config.LoadRules(root) - if len(loaded.Rule) != 4 { - t.Errorf("cancelling deleted something: %d rules left", len(loaded.Rule)) - } -} - -func TestRuleListDeleteSelected(t *testing.T) { - m, root := newRuleListModel(t) - - m.ruleListTable.SetCursor(1) // the shadowed rule - key(t, m, "d") - key(t, m, "y") - - if m.err != nil { - t.Fatalf("delete failed: %v", m.err) - } - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - if len(loaded.Rule) != 3 { - t.Fatalf("got %d rules, want 3: %+v", len(loaded.Rule), loaded.Rule) - } - for _, r := range loaded.Rule { - if r.Tag == "shadowed" { - t.Error("the selected rule is still in rules.toml") - } - } - // The on-screen counts are rebuilt from the new file. - if len(m.ruleUsage) != 3 { - t.Errorf("usage still has %d entries, want 3", len(m.ruleUsage)) - } -} - -func TestRuleListPruneUnused(t *testing.T) { - m, root := newRuleListModel(t) - - key(t, m, "p") - if m.confirm != confirmPruneRules { - t.Fatal("expected p to ask for confirmation") - } - if !strings.Contains(m.status, "all 3 rules") { - t.Errorf("status = %q, want the count of dead rules", m.status) - } - key(t, m, "y") - - if m.err != nil { - t.Fatalf("prune failed: %v", m.err) - } - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - if len(loaded.Rule) != 1 || loaded.Rule[0].Tag != "groceries" { - t.Fatalf("remaining rules = %+v, want only the groceries rule", loaded.Rule) - } - - // The rule that was doing work still tags what it did before. - txns, err := m.db.Transactions(store.Filter{}) - if err != nil { - t.Fatal(err) - } - tagged := 0 - for _, txn := range txns { - if txn.RuleTag == "groceries" { - tagged++ - } - } - if tagged != 3 { - t.Errorf("%d transactions tagged after pruning, want 3", tagged) - } - if !strings.Contains(m.View(), "all in use") { - t.Errorf("expected the title to report no dead rules left:\n%s", m.View()) - } -} - -func TestRuleListPruneWithNothingToDo(t *testing.T) { - m, root := newRuleListModel(t) - key(t, m, "p") - key(t, m, "y") - - // Second prune: nothing left to remove. - key(t, m, "p") - if m.confirm != confirmNone { - t.Error("expected no confirmation when there is nothing to prune") - } - if !strings.Contains(m.status, "no unused rules") { - t.Errorf("status = %q, want a note that there is nothing to prune", m.status) - } - loaded, _ := config.LoadRules(root) - if len(loaded.Rule) != 1 { - t.Errorf("got %d rules, want the 1 survivor untouched", len(loaded.Rule)) - } -} - -// Every screen must advertise how to reach the others and how to quit. These -// lines drift as screens are added, and a line that outgrows the terminal -// loses its tail silently. -func TestHelpLinesMentionEveryScreen(t *testing.T) { - m, _ := newTestModel(t) - - for _, tc := range []struct { - name string - key string - want []string - }{ - {"accounts", "1", []string{"4 new rule", "5 rules", "q quit"}}, - {"transactions", "2", []string{"4 new rule", "5 rules", "q quit"}}, - {"report", "3", []string{"4 new rule", "5 rules", "q quit"}}, - {"rules", "5", []string{"d delete rule", "p prune all unused", "4 new rule", "q quit"}}, - {"transfers", "7", []string{"d delete transfer", "r refresh pairing", "6 new transfer", "q quit"}}, - } { - key(t, m, tc.key) - help := m.help() - for _, want := range tc.want { - if !strings.Contains(help, want) { - t.Errorf("%s help = %q, missing %q", tc.name, help, want) - } - } - } - - // The builders are forms, so they advertise their own keys instead. - key(t, m, "4") - if help := m.help(); !strings.Contains(help, "enter save rule") || !strings.Contains(help, "esc back") { - t.Errorf("rule builder help = %q", help) - } - key(t, m, "esc") - key(t, m, "6") - if help := m.help(); !strings.Contains(help, "enter save transfer") || !strings.Contains(help, "esc back") { - t.Errorf("transfer builder help = %q", help) - } -} - -// A help line longer than the window must wrap, not be cut off. -func TestHelpWrapsOnNarrowTerminals(t *testing.T) { - m, _ := newTestModel(t) - key(t, m, "2") - m.Update(tea.WindowSizeMsg{Width: 90, Height: 30}) - - view := m.View() - if !strings.Contains(view, "q quit") { - t.Errorf("the end of the help line was lost at 90 columns:\n%s", view) - } - for _, line := range strings.Split(view, "\n") { - if len([]rune(line)) > 90 { - t.Errorf("line is %d columns wide, wider than the window:\n%q", len([]rune(line)), line) - } - } -} - -// newEmptyModel builds a model over an index with nothing in it. -func newEmptyModel(t *testing.T, accounts []*config.Account) *Model { - t.Helper() - db, err := store.Open(filepath.Join(t.TempDir(), "index.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - m := New("/data/root", db, accounts, rules.New(&config.Rules{}), transfers.New(&config.Rules{})) - if err := m.reload(); err != nil { - t.Fatal(err) - } - m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) - return m -} - -// Configured but never imported is the confusing case: the folders exist on -// disk, but nothing is registered in the index until an import runs. -func TestEmptyAccountsWithConfiguredFolders(t *testing.T) { - m := newEmptyModel(t, []*config.Account{ - {Slug: "checking", Currency: "EUR", Parser: "revolut"}, - {Slug: "savings", Currency: "EUR", Parser: "revolut"}, - }) - - view := m.View() - for _, want := range []string{"No accounts imported yet", "2 account folder(s)", "/data/root", "Press i to import"} { - if !strings.Contains(view, want) { - t.Errorf("accounts view missing %q:\n%s", want, view) - } - } -} - -func TestEmptyAccountsWithNoFolders(t *testing.T) { - m := newEmptyModel(t, nil) - - view := m.View() - for _, want := range []string{"No account folders found", "/data/root", "account.toml"} { - if !strings.Contains(view, want) { - t.Errorf("accounts view missing %q:\n%s", want, view) - } - } -} - -func TestEmptyTransactionsMessages(t *testing.T) { - m := newEmptyModel(t, nil) - key(t, m, "2") - - if view := m.View(); !strings.Contains(view, "No transactions yet") { - t.Errorf("expected an empty-transactions message:\n%s", view) - } - - // Each way of emptying the view explains itself differently. - m.filter.AccountSlug = "checking" - if err := m.reloadTxns(); err != nil { - t.Fatal(err) - } - if view := m.View(); !strings.Contains(view, "No transactions in checking") { - t.Errorf("expected an account-scoped message:\n%s", view) - } - - m.filter.AccountSlug = "" - m.filter.Search = "lidl" - if err := m.reloadTxns(); err != nil { - t.Fatal(err) - } - if view := m.View(); !strings.Contains(view, "No transactions match /lidl") { - t.Errorf("expected a search message:\n%s", view) - } - - m.filter.Search = "" - m.onlyUntagged = true - if err := m.reloadTxns(); err != nil { - t.Fatal(err) - } - if view := m.View(); !strings.Contains(view, "Nothing untagged") { - t.Errorf("expected an untagged message:\n%s", view) - } -} - -func TestEmptyReportMessage(t *testing.T) { - m := newEmptyModel(t, nil) - key(t, m, "3") - if view := m.View(); !strings.Contains(view, "Nothing to report yet") { - t.Errorf("expected an empty-report message:\n%s", view) - } -} - -// With data present, the tables render instead of the empty-state text. -func TestNoEmptyMessageWhenPopulated(t *testing.T) { - m, _ := newTestModel(t) - allTime(t, m) - for _, k := range []string{"1", "2", "3"} { - key(t, m, k) - if got := m.emptyMessage(); got != "" { - t.Errorf("view %s showed an empty message with data present: %q", k, got) - } - } - key(t, m, "2") - if !strings.Contains(m.View(), "LIDL SOFIA") { - t.Error("expected the transactions table to render") - } -} - -func findTxn(t *testing.T, db *store.DB, desc string) model.Transaction { - t.Helper() - txns, err := db.Transactions(store.Filter{}) - if err != nil { - t.Fatal(err) - } - for _, txn := range txns { - if txn.Description == desc { - return txn - } - } - t.Fatalf("no transaction with description %q", desc) - return model.Transaction{} -} - -// newTransferModel builds a model over a real data root holding one complete -// movement between two accounts, one leg whose counterpart never arrived, and -// ordinary spending that is neither. -func newTransferModel(t *testing.T) (*Model, *store.DB, string) { - t.Helper() - root := t.TempDir() - - db, err := store.Open(config.IndexPath(root)) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - checking, err := db.UpsertAccount(model.Account{ - Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, - }) - if err != nil { - t.Fatal(err) - } - savings, err := db.UpsertAccount(model.Account{ - Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2, - }) - if err != nil { - t.Fatal(err) - } - sourceID, err := db.SourceFile(checking, "checking/st.csv", "sha", "2026-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - - seed := []struct { - account int64 - date string - desc string - amount int64 - }{ - {checking, "2026-03-01", "TRANSFER TO SAVINGS", -50000}, - {savings, "2026-03-02", "TRANSFER FROM CHECKING", 50000}, - {checking, "2026-04-01", "TRANSFER TO SAVINGS", -50000}, // never arrived - {checking, "2026-03-05", "LIDL SOFIA 4412", -2000}, - } - for i, s := range seed { - if _, err := db.InsertTransaction(model.Transaction{ - AccountID: s.account, - SourceFileID: sourceID, - Fingerprint: fmt.Sprintf("xfer-%d", i), - Date: s.date, - Description: s.desc, - AmountMinor: s.amount, - }); err != nil { - t.Fatal(err) - } - } - - m := New(root, db, []*config.Account{ - {Slug: "checking", Currency: "EUR", Parser: "revolut"}, - {Slug: "savings", Currency: "EUR", Parser: "revolut"}, - }, rules.New(&config.Rules{}), transfers.New(&config.Rules{})) - if err := m.reload(); err != nil { - t.Fatal(err) - } - m.Update(tea.WindowSizeMsg{Width: 140, Height: 40}) - return m, db, root -} - -// fillTransfer types a whole definition into the builder's fields. -func fillTransfer(m *Model, from, fromDesc, to, toDesc string) { - m.transferFrom.SetValue(from) - m.transferFromDesc.SetValue(fromDesc) - m.transferTo.SetValue(to) - m.transferToDesc.SetValue(toDesc) - m.refreshTransferPreview() -} - -// The preview answers the question the form is asking: what would this pair, -// and what would it catch and fail to pair? -func TestTransferBuilderPreviewsPairsAndOrphans(t *testing.T) { - m, _, _ := newTransferModel(t) - key(t, m, "6") - - if m.view != viewTransfers { - t.Fatal("expected 6 to open the transfer builder") - } - - fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") - if m.previewPairs != 1 || m.previewUnmatched != 1 { - t.Fatalf("preview = %d pairs, %d unpaired; want 1 and 1", m.previewPairs, m.previewUnmatched) - } - - var paired, orphaned int - for _, row := range m.transferTable.Rows() { - switch row[0] { - case "▸": - paired++ - if row[3] != "checking → savings" { - t.Errorf("movement = %q, want the direction spelled out", row[3]) - } - case "⚠": - orphaned++ - if row[1] != "2026-04-01" { - t.Errorf("unpaired row = %v, want the April leg", row) - } - } - } - if paired != 1 || orphaned != 1 { - t.Errorf("rows = %d paired, %d unpaired; want 1 and 1", paired, orphaned) - } - - // A glob that catches nothing pairs nothing, rather than pairing loosely. - fillTransfer(m, "checking", "*NOTHING LIKE THIS*", "savings", "*FROM CHECKING*") - if m.previewPairs != 0 { - t.Errorf("pairs = %d, want none", m.previewPairs) - } -} - -// Saving writes the definition and pairs immediately, and the report stops -// counting the pair as spending. -func TestTransferBuilderSavesAndPairs(t *testing.T) { - m, db, root := newTransferModel(t) - key(t, m, "6") - - fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") - m.setTransferFocus(4) - m.transferNote.SetValue("monthly saving") - key(t, m, "enter") - - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - if len(loaded.Transfer) != 1 { - t.Fatalf("rules.toml holds %+v", loaded.Transfer) - } - if got := loaded.Transfer[0]; got.FromAccount != "checking" || got.ToDesc != "*FROM CHECKING*" || - got.Note != "monthly saving" { - t.Errorf("saved transfer = %+v", got) - } - - txns, err := db.Transactions(store.Filter{}) - if err != nil { - t.Fatal(err) - } - legs := 0 - for _, txn := range txns { - if txn.IsTransferLeg() { - legs++ - } - // The April leg never arrived, so it is not part of a transfer. - if txn.Date == "2026-04-01" && txn.IsTransferLeg() { - t.Error("an unpaired leg must not be recorded as a transfer") - } - } - if legs != 2 { - t.Errorf("%d legs paired in the index, want 2", legs) - } - - // The paired movement is out of the report; the unpaired leg is not. - var out int64 - for _, r := range report.ByTag(txns, report.OrderOut) { - out += r.Out - } - if want := int64(52000); out != want { - t.Errorf("outflow in the report = %d, want %d: the pair excluded, the orphan kept", out, want) - } - - // The two accounts were kept for the next definition, the globs cleared. - if m.transferFrom.Value() != "checking" || m.transferFromDesc.Value() != "" { - t.Errorf("after saving: from = %q, from_desc = %q", - m.transferFrom.Value(), m.transferFromDesc.Value()) - } - if !strings.Contains(m.status, "1 leg(s) unpaired") { - t.Errorf("status = %q, want the unpaired leg reported", m.status) - } -} - -// A half-written definition is refused, with the missing side named. -func TestTransferBuilderRejectsIncomplete(t *testing.T) { - m, _, root := newTransferModel(t) - key(t, m, "6") - - m.transferFrom.SetValue("checking") - m.transferFromDesc.SetValue("*TO SAVINGS*") - m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - if m.err == nil { - t.Fatal("expected saving half a transfer to fail") - } - if !strings.Contains(m.err.Error(), "arrives") { - t.Errorf("error = %v, want it to name the missing side", m.err) - } - - // An unknown account is refused too, before anything is written. - m.err = nil - fillTransfer(m, "checking", "*TO SAVINGS*", "nosuchaccount", "*FROM CHECKING*") - m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - if m.err == nil || !strings.Contains(m.err.Error(), "nosuchaccount") { - t.Errorf("error = %v, want the unknown account named", m.err) - } - if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) { - t.Error("a rejected transfer must not write rules.toml") - } -} - -// The builder is a form, so single-letter global keys are ordinary text. -func TestTransferBuilderSwallowsGlobalKeys(t *testing.T) { - m, _, _ := newTransferModel(t) - key(t, m, "6") - - typeText(t, m, "q1i") - if m.view != viewTransfers { - t.Fatal("typing must not switch views") - } - if got := m.transferFrom.Value(); got != "q1i" { - t.Errorf("from account = %q, want the typed characters", got) - } - if m.importing { - t.Error("typing i must not start an import") - } -} - -// The list is the screen that finds the problems: what pairs, what does not, -// and what matches nothing at all. -func TestTransferListShowsPairingAndUnpaired(t *testing.T) { - m, _, root := newTransferModel(t) - if err := config.AppendTransfer(root, config.Transfer{ - FromAccount: "checking", FromDesc: "*TO SAVINGS*", - ToAccount: "savings", ToDesc: "*FROM CHECKING*", - }); err != nil { - t.Fatal(err) - } - if err := config.AppendTransfer(root, config.Transfer{ - FromAccount: "checking", FromDesc: "*TO NOWHERE*", - ToAccount: "savings", ToDesc: "*FROM NOWHERE*", Note: "dead", - }); err != nil { - t.Fatal(err) - } - if err := m.reloadConfig(); err != nil { - t.Fatal(err) - } - - key(t, m, "7") - if m.view != viewTransferList { - t.Fatal("expected 7 to open the transfer list") - } - - rows := m.transferListTable.Rows() - if len(rows) != 2 { - t.Fatalf("rows = %v, want one per definition", rows) - } - if rows[0][1] != "⚠" || rows[0][4] != "1" || rows[0][5] != "1" { - t.Errorf("first row = %v, want 1 pair, 1 unpaired and a warning marker", rows[0]) - } - if rows[1][1] != "✗" || rows[1][4] != "0" || rows[1][5] != "0" { - t.Errorf("second row = %v, want it marked as matching nothing", rows[1]) - } - if !strings.Contains(m.title(), "1 leg(s) unpaired") { - t.Errorf("title = %q, want the unpaired leg counted", m.title()) - } - - // Pruning takes the definition that matches nothing, and leaves the one - // that is merely unpaired: that one is doing something. - if got := m.unusedTransfers(); len(got) != 1 || got[0] != 1 { - t.Fatalf("unused = %v, want only the dead definition", got) - } - key(t, m, "p") - if m.confirm != confirmPruneTransfers { - t.Fatal("prune must ask first") - } - key(t, m, "y") - - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - if len(loaded.Transfer) != 1 || loaded.Transfer[0].FromDesc != "*TO SAVINGS*" { - t.Errorf("transfers = %+v, want the working one kept", loaded.Transfer) - } -} - -// Deleting a definition takes its pairing with it, so the legs count again. -func TestTransferListDeleteUnpairs(t *testing.T) { - m, db, root := newTransferModel(t) - key(t, m, "6") - fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") - key(t, m, "enter") - - // The builder is a form, so leaving it takes esc rather than a view key. - key(t, m, "esc") - key(t, m, "7") - key(t, m, "d") - if m.confirm != confirmDeleteTransfer { - t.Fatal("delete must ask first") - } - if !strings.Contains(m.status, "checking → savings") { - t.Errorf("prompt = %q, want it to name the transfer", m.status) - } - key(t, m, "y") - - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - if len(loaded.Transfer) != 0 { - t.Fatalf("transfers = %+v, want it gone", loaded.Transfer) - } - - txns, err := db.Transactions(store.Filter{}) - if err != nil { - t.Fatal(err) - } - for _, txn := range txns { - if txn.IsTransferLeg() { - t.Fatalf("%s is still recorded as a transfer leg", txn.Description) - } - } -} - -// Retagging re-derives both halves of what rules.toml decides. -func TestRetagAlsoRepairsThePairing(t *testing.T) { - m, db, root := newTransferModel(t) - if err := config.AppendTransfer(root, config.Transfer{ - FromAccount: "checking", FromDesc: "*TO SAVINGS*", - ToAccount: "savings", ToDesc: "*FROM CHECKING*", - }); err != nil { - t.Fatal(err) - } - if err := m.reloadConfig(); err != nil { - t.Fatal(err) - } - - key(t, m, "r") - if !strings.Contains(m.status, "1 transfers matched") { - t.Errorf("status = %q, want the pairing reported", m.status) - } - txns, err := db.Transactions(store.Filter{}) - if err != nil { - t.Fatal(err) - } - legs := 0 - for _, txn := range txns { - if txn.IsTransferLeg() { - legs++ - } - } - if legs != 2 { - t.Errorf("%d legs paired after retag, want 2", legs) - } -} - -// Six fields need more room than four, so the transfer form gives up its -// spacing, then its hints, then the borders on the fields not being edited. No -// field may disappear at any height, and the one with the cursor in it keeps -// its box however tight things get. -func TestTransferFormFitsShortTerminals(t *testing.T) { - m, _, _ := newTransferModel(t) - key(t, m, "6") - - for _, height := range []int{50, 44, 38, 34, 30, 28} { - m.Update(tea.WindowSizeMsg{Width: 140, Height: height}) - form := m.transferFormView() - if lines := strings.Count(form, "\n") + 1; lines > height-6 { - t.Errorf("at height %d the form is %d lines, want at most %d", height, lines, height-6) - } - for _, label := range []string{ - "from account", "from desc", "to account", "to desc", "tolerance %", "note", - } { - if !strings.Contains(form, label) { - t.Errorf("at height %d the %q field disappeared", height, label) - } - } - boxes := strings.Count(form, "╭") - if boxes != 6 && boxes != 1 { - t.Errorf("at height %d there are %d input boxes, want 6 or just the focused 1", height, boxes) - } - } -} - -// A paired leg has been accounted for, so it is not waiting for a tag: it must -// not turn up in the untagged view, nor in the rule builder's preview, which is -// the list of things still asking to be tagged. -func TestPairedLegsAreNotUntagged(t *testing.T) { - m, _, _ := newTransferModel(t) - key(t, m, "6") - fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") - key(t, m, "enter") - key(t, m, "esc") - - key(t, m, "u") - var seen []string - for _, txn := range m.txns { - seen = append(seen, txn.Date+" "+txn.Description) - } - want := []string{"2026-04-01 TRANSFER TO SAVINGS", "2026-03-05 LIDL SOFIA 4412"} - if len(seen) != len(want) { - t.Fatalf("untagged = %v, want %v: only the unpaired leg and the shopping", seen, want) - } - for i, w := range want { - if seen[i] != w { - t.Errorf("untagged[%d] = %q, want %q", i, seen[i], w) - } - } - - // The rule builder previews the same set. - key(t, m, "4") - all, _ := previewRows(m) - if len(all) != 2 { - t.Errorf("preview = %v, want the paired legs left out", all) - } - for _, desc := range all { - if desc == "TRANSFER FROM CHECKING" { - t.Error("the arriving leg of a matched transfer is still offered for tagging") - } - } -} - -// An exchange between two of your own accounts pairs on the dates alone, and -// the preview shows both amounts: they are the only place the rate appears. -func TestTransferBuilderShowsBothSidesOfAnExchange(t *testing.T) { - m, db, _ := newTransferModel(t) - - bgn, err := db.UpsertAccount(model.Account{ - Slug: "revolut", Name: "Revolut BGN", Currency: "BGN", MinorDigits: 2, - }) - if err != nil { - t.Fatal(err) - } - source, err := db.SourceFile(bgn, "revolut/st.csv", "sha", "2026-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - accounts, err := db.Accounts() - if err != nil { - t.Fatal(err) - } - var checking int64 - for _, a := range accounts { - if a.Slug == "checking" { - checking = a.ID - } - } - for _, leg := range []struct { - account int64 - fp string - date string - desc string - amount int64 - }{ - {checking, "x-out", "2026-05-01", "TRANSFER TO REVOLUT", -50000}, // EUR - {bgn, "x-in", "2026-05-02", "TOP-UP FROM CHECKING", 97790}, - } { - if _, err := db.InsertTransaction(model.Transaction{ - AccountID: leg.account, - SourceFileID: source, - Fingerprint: leg.fp, - Date: leg.date, - Description: leg.desc, - AmountMinor: leg.amount, - }); err != nil { - t.Fatal(err) - } - } - - key(t, m, "6") - fillTransfer(m, "checking", "*TO REVOLUT*", "revolut", "*FROM CHECKING*") - - if m.previewPairs != 1 || m.previewUnmatched != 0 { - t.Fatalf("preview = %d pairs, %d unpaired; want the exchange paired", - m.previewPairs, m.previewUnmatched) - } - var amount string - for _, row := range m.transferTable.Rows() { - if row[0] == "▸" { - amount = row[2] - } - } - if amount != "500.00 → 977.90" { - t.Errorf("amount = %q, want both sides of the exchange", amount) - } - - // Saving it keeps both legs out of the report, each in its own currency. - key(t, m, "enter") - txns, err := db.Transactions(store.Filter{}) - if err != nil { - t.Fatal(err) - } - excluded := report.Excluded(txns) - if len(excluded) != 2 { - t.Fatalf("excluded = %+v, want a row per currency", excluded) - } - for _, x := range excluded { - if x.Legs != 1 { - t.Errorf("%s row = %+v, want the one leg it saw", x.Currency, x) - } - } -} - -// A paired leg reads as accounted for in the tag column rather than as a blank, -// but the label is display only: rule_tag stays what rules.toml made it, so -// retagging is still safe to run at any time. -func TestTransferLegsDisplayAsTags(t *testing.T) { - m, db, _ := newTransferModel(t) - key(t, m, "6") - fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") - key(t, m, "enter") - key(t, m, "esc") - - var tags []string - for _, row := range m.txnTable.Rows() { - tags = append(tags, row[3]) - } - // Newest first: the unpaired April leg, the shopping, then the two legs of - // the March movement. - want := []string{"", "", model.TransferTag, model.TransferTag} - if len(tags) != len(want) { - t.Fatalf("tags = %v, want %v", tags, want) - } - for i, w := range want { - if tags[i] != w { - t.Errorf("row %d (%s) tag = %q, want %q", i, m.txns[i].Description, tags[i], w) - } - } - - // Nothing was written: the index still holds no tag for either leg, and the - // label is not offered as a tag to complete against. - txns, err := db.Transactions(store.Filter{}) - if err != nil { - t.Fatal(err) - } - for _, txn := range txns { - if txn.RuleTag != "" { - t.Errorf("%s has rule_tag %q; a transfer must never write one", - txn.Description, txn.RuleTag) - } - } - known, err := m.knownTags() - if err != nil { - t.Fatal(err) - } - for _, tag := range known { - if tag == model.TransferTag { - t.Error("the transfer label must not be offered as a tag to write rules with") - } - } - - // A rule tag wins where there is one, since it is the user's own word. - key(t, m, "4") - m.ruleGlob.SetValue("*TO SAVINGS*") - m.setRuleFocus(2) - m.ruleTag.SetValue("saving") - key(t, m, "enter") - key(t, m, "esc") - for i, txn := range m.txns { - if txn.Description == "TRANSFER TO SAVINGS" && m.txnTable.Rows()[i][3] != "saving" { - t.Errorf("tag = %q, want the rule's own tag", m.txnTable.Rows()[i][3]) - } - } -} - -// A route where the bank takes a fee is the reason tolerance_pct exists: the -// builder must write it, pair on it, and say what it is admitting -- and the -// report must then account for the difference, since the pair leaves it. -func TestTransferBuilderTolerance(t *testing.T) { - m, db, root := newTransferModel(t) - - checking, err := db.UpsertAccount(model.Account{ - Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, - }) - if err != nil { - t.Fatal(err) - } - savings, err := db.UpsertAccount(model.Account{ - Slug: "savings", Name: "Savings", Currency: "EUR", MinorDigits: 2, - }) - if err != nil { - t.Fatal(err) - } - sourceID, err := db.SourceFile(checking, "checking/wire.csv", "sha2", "2026-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - // 500.00 leaves, 495.00 arrives: the bank kept 5.00 on the way. - fee := []struct { - account int64 - date string - desc string - amount int64 - }{ - {checking, "2026-05-01", "WIRE TO SAVINGS", -50000}, - {savings, "2026-05-02", "WIRE FROM CHECKING", 49500}, - } - for i, s := range fee { - if _, err := db.InsertTransaction(model.Transaction{ - AccountID: s.account, - SourceFileID: sourceID, - Fingerprint: fmt.Sprintf("wire-%d", i), - Date: s.date, - Description: s.desc, - AmountMinor: s.amount, - }); err != nil { - t.Fatal(err) - } - } - if err := m.reload(); err != nil { - t.Fatal(err) - } - - key(t, m, "6") - fillTransfer(m, "checking", "*WIRE TO SAVINGS*", "savings", "*WIRE FROM CHECKING*") - if m.previewPairs != 0 { - t.Fatalf("previewPairs = %d, want 0 before a tolerance is given", m.previewPairs) - } - - m.transferTolerance.SetValue("1") - m.refreshTransferPreview() - if m.previewPairs != 1 || m.previewFees != 500 { - t.Fatalf("preview = %d pairs, %d in fees; want 1 and 500", - m.previewPairs, m.previewFees) - } - // Both amounts show, exactly as they do for an exchange: the fee is the - // thing to eyeball before saving. - if want := "500.00 → 495.00"; !strings.Contains(m.transferTable.View(), want) { - t.Errorf("preview table does not show %q:\n%s", want, m.transferTable.View()) - } - - key(t, m, "enter") - if m.err != nil { - t.Fatalf("saving: %v", m.err) - } - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - if len(loaded.Transfer) != 1 || loaded.Transfer[0].TolerancePct != 1 { - t.Fatalf("transfers = %+v, want the tolerance written", loaded.Transfer) - } - // Saving clears it: a tolerance carried into the next definition would - // loosen a route that never asked for one. - if v := m.transferTolerance.Value(); v != "" { - t.Errorf("tolerance field = %q after saving, want it cleared", v) - } - - // The pair is gone from the report, so the fee it took has to be named. - allTime(t, m) - var rows []string - for _, r := range m.reportTable.Rows() { - rows = append(rows, strings.Join(r, " ")) - } - joined := strings.Join(rows, "\n") - if !strings.Contains(joined, transfersRow) { - t.Errorf("report has no transfers row:\n%s", joined) - } - if !strings.Contains(joined, feesRow) || !strings.Contains(joined, "5.00") { - t.Errorf("report does not account for the 5.00 fee:\n%s", joined) - } -} - -// A tolerance that is not a number is refused on save rather than written out -// as a silent zero -- but it must not stop the preview updating as it is typed. -func TestTransferBuilderRejectsABadTolerance(t *testing.T) { - m, _, root := newTransferModel(t) - key(t, m, "6") - fillTransfer(m, "checking", "*TO SAVINGS*", "savings", "*FROM CHECKING*") - - m.transferTolerance.SetValue("1.") - m.refreshTransferPreview() // must not panic or wipe the preview - if m.previewPairs != 1 { - t.Errorf("previewPairs = %d, want the preview to survive a half-typed number", m.previewPairs) - } - - m.transferTolerance.SetValue("a lot") - m.Update(tea.KeyMsg{Type: tea.KeyEnter}) // not key(), which fails on m.err - if m.err == nil { - t.Fatal("expected saving to be refused") - } - if _, err := os.Stat(filepath.Join(root, config.RulesFile)); !os.IsNotExist(err) { - t.Error("a rejected transfer must not be written") - } -} - -// The list is where a definition is judged, and a tolerance changes what its -// counts mean: those pairs were matched on slack rather than on the amount, so -// the column has to say which ones. -func TestTransferListShowsTheTolerance(t *testing.T) { - m, _, root := newTransferModel(t) - if err := config.AppendTransfer(root, config.Transfer{ - FromAccount: "checking", FromDesc: "*TO SAVINGS*", - ToAccount: "savings", ToDesc: "*FROM CHECKING*", - }); err != nil { - t.Fatal(err) - } - if err := config.AppendTransfer(root, config.Transfer{ - FromAccount: "checking", FromDesc: "*WIRE TO SAVINGS*", - ToAccount: "savings", ToDesc: "*WIRE FROM CHECKING*", - TolerancePct: 1.5, - }); err != nil { - t.Fatal(err) - } - if err := m.reloadConfig(); err != nil { - t.Fatal(err) - } - - key(t, m, "7") - rows := m.transferListTable.Rows() - if len(rows) != 2 { - t.Fatalf("rows = %v, want one per definition", rows) - } - // Blank, not "0%": every definition has the default, and printing it down - // the column would bury the row where amounts may actually disagree. - if rows[0][6] != "" { - t.Errorf("strict row shows tolerance %q, want it blank", rows[0][6]) - } - if rows[1][6] != "1.5%" { - t.Errorf("tolerant row shows %q, want 1.5%%", rows[1][6]) - } -} - -// Once a glob is typed the preview is the rule's answer, not a list to search -// by eye: the rows that do not match go, and the count says what they were -// chosen out of, since the rows on screen can no longer say it themselves. -func TestRuleBuilderPreviewShowsOnlyMatches(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "4") - - // With no glob there is nothing to filter by, so the screen answers its - // other question: everything still waiting for a rule. - if all, _ := previewRows(m); len(all) != 4 { - t.Fatalf("preview = %v, want every untagged description before a glob", all) - } - if !strings.Contains(m.ruleFormView(), "4 untagged descriptions") { - t.Errorf("summary = %q, want the untagged count", m.ruleFormView()) - } - - typeText(t, m, "*LIDL*") - all, matched := previewRows(m) - if len(all) != 2 || len(matched) != 2 { - t.Fatalf("preview = %v (matched %v), want only the two LIDL rows", all, matched) - } - for _, desc := range all { - if !strings.Contains(desc, "LIDL") { - t.Errorf("preview kept a non-matching row %q", desc) - } - } - // Two of the four still in view, not two of the two left on screen. - if m.ruleCandidates != 4 { - t.Errorf("candidates = %d, want 4", m.ruleCandidates) - } - if !strings.Contains(m.ruleFormView(), "2 of 4 descriptions match") { - t.Errorf("summary = %q, want the glob's selectivity", m.ruleFormView()) - } - - // A glob that matches nothing empties the list, and says so rather than - // leaving rows on screen that the rule would not claim. - m.ruleGlob.SetValue("*NOTHING*") - m.refreshRulePreview() - if all, _ := previewRows(m); len(all) != 0 { - t.Errorf("preview = %v, want nothing", all) - } - if !strings.Contains(m.ruleFormView(), "0 of 4 descriptions match") { - t.Errorf("summary = %q, want 0 of 4", m.ruleFormView()) - } -} - -// loadRulesFile puts a rules.toml under the model's root and makes the model -// read it, as though it had been there all along. -func loadRulesFile(t *testing.T, m *Model, body string) { - t.Helper() - if err := os.WriteFile(filepath.Join(m.root, config.RulesFile), []byte(body), 0o644); err != nil { - t.Fatal(err) - } - if err := m.reloadConfig(); err != nil { - t.Fatal(err) - } - if _, err := m.engine.Retag(m.db); err != nil { - t.Fatal(err) - } - if err := m.reload(); err != nil { - t.Fatal(err) - } -} - -const editableRules = `[[rule]] -match = "*ZZZ*" -tag = "misc" - -# The weekly shop. -[[rule]] -match = "*LIDL*" -tag = "groceries" -note = "both branches" -` - -// Editing rewrites the rule where it sits rather than appending a second, -// almost identical one, and lands back on the list it started from. -func TestRuleListEditsRuleInPlace(t *testing.T) { - m, db, root := newRuleModel(t) - loadRulesFile(t, m, editableRules) - - key(t, m, "5") - m.ruleListTable.SetCursor(1) - key(t, m, "e") - - if m.view != viewRules || !m.ruleEditing { - t.Fatalf("e opened view %v (editing %v), want the builder on the rule", m.view, m.ruleEditing) - } - if m.ruleGlob.Value() != "*LIDL*" || m.ruleTag.Value() != "groceries" || m.ruleNote.Value() != "both branches" { - t.Fatalf("form = %q / %q / %q, want the rule filled in", - m.ruleGlob.Value(), m.ruleTag.Value(), m.ruleNote.Value()) - } - - m.ruleTag.SetValue("food") - key(t, m, "enter") - - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - want := config.Rule{Match: "*LIDL*", Tag: "food", Note: "both branches"} - if len(loaded.Rule) != 2 { - t.Fatalf("rules = %+v, want the same 2: an edit must not append", loaded.Rule) - } - if loaded.Rule[0].Match != "*ZZZ*" { - t.Errorf("rule 1 = %+v, want it where it was", loaded.Rule[0]) - } - if loaded.Rule[1] != want { - t.Errorf("rule 2 = %+v, want %+v", loaded.Rule[1], want) - } - - // The file is edited textually, so the comment documenting the rule stays. - raw, err := os.ReadFile(filepath.Join(root, config.RulesFile)) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(raw), "# The weekly shop.") { - t.Errorf("the rule's comment was lost:\n%s", raw) - } - - // Saving retags immediately, as it does for a new rule. - txns, err := db.Transactions(store.Filter{}) - if err != nil { - t.Fatal(err) - } - tagged := 0 - for _, txn := range txns { - if txn.RuleTag == "food" { - tagged++ - } - } - if tagged != 3 { - t.Errorf("%d transactions tagged food, want 3", tagged) - } - - // An edit is a round trip: it ends on the list, with the new counts. - if m.view != viewRuleList { - t.Fatalf("view = %v after saving, want the rules list", m.view) - } - if rows := m.ruleListTable.Rows(); len(rows) != 2 || rows[1][4] != "food" { - t.Errorf("rows = %v, want the edited tag on the list", rows) - } - if !strings.Contains(m.status, "rule 2 is now") { - t.Errorf("status = %q, want it to name the rule that changed", m.status) - } -} - -// The preview lists what the rule already claims, not only what is untagged: -// for a rule that works those are the same rows, and an empty preview would say -// nothing about the edit. Narrowing the glob then shows what it gives up. -func TestRuleEditPreviewsWhatItDrops(t *testing.T) { - m, _, _ := newRuleModel(t) - loadRulesFile(t, m, editableRules) - - key(t, m, "5") - m.ruleListTable.SetCursor(1) - key(t, m, "e") - - all, matched := previewRows(m) - if len(all) != 2 || len(matched) != 2 { - t.Fatalf("preview = %v (matched %v), want the two descriptions the rule claims", all, matched) - } - - m.ruleGlob.SetValue("*SOFIA*") - m.refreshRulePreview() - - all, matched = previewRows(m) - if len(matched) != 1 || matched[0] != "LIDL SOFIA 4412" { - t.Errorf("matched = %v, want only the Sofia branch", matched) - } - // The row it stops claiming stays on screen: losing one is the thing worth - // seeing before saving, so it is marked rather than dropped silently. - if len(all) != 2 || m.ruleDropped != 1 { - t.Fatalf("preview = %v, dropped = %d; want the Varna row kept and counted", all, m.ruleDropped) - } - var dropped []string - for _, row := range m.ruleTable.Rows() { - if row[0] == "−" { - dropped = append(dropped, row[1]) - } - } - if len(dropped) != 1 || dropped[0] != "LIDL VARNA 9911" { - t.Errorf("dropped rows = %v, want the Varna branch", dropped) - } - if !strings.Contains(m.ruleFormView(), "1 no longer claimed") { - t.Errorf("summary = %q, want the dropped count", m.ruleFormView()) - } -} - -// The form has no type field, so an edit carries the pattern through instead of -// silently widening the rule to every transaction the glob matches. -func TestRuleEditKeepsTypePattern(t *testing.T) { - m, _, root := newRuleModel(t) - loadRulesFile(t, m, "[[rule]]\nmatch = \"*ZZZ*\"\ntype = \"CARD_PAYMENT\"\ntag = \"misc\"\n") - - key(t, m, "5") - key(t, m, "e") - - if !strings.Contains(m.ruleFormView(), "type:CARD_PAYMENT") { - t.Errorf("form = %q, want it to name the type it is carrying", m.ruleFormView()) - } - - m.ruleTag.SetValue("cards") - key(t, m, "enter") - - loaded, err := config.LoadRules(root) - if err != nil { - t.Fatal(err) - } - want := config.Rule{Match: "*ZZZ*", Type: "CARD_PAYMENT", Tag: "cards"} - if len(loaded.Rule) != 1 || loaded.Rule[0] != want { - t.Errorf("rules = %+v, want %+v", loaded.Rule, want) - } -} - -// Leaving an edit must not leave the form holding that rule, or the next thing -// saved would be a near-duplicate of the rule just edited. -func TestNewRuleAfterAnEditStartsEmpty(t *testing.T) { - m, _, _ := newRuleModel(t) - loadRulesFile(t, m, editableRules) - - key(t, m, "5") - m.ruleListTable.SetCursor(1) - key(t, m, "e") - key(t, m, "esc") - key(t, m, "4") - - if m.ruleEditing { - t.Error("4 opened the builder still in edit mode") - } - for _, in := range m.ruleInputs() { - if in.Value() != "" { - t.Errorf("form field = %q, want a blank form for a new rule", in.Value()) - } - } -} - -// The builder is reached from the list, so it is not somewhere the list can -// return to: esc must walk back out, not bounce between the two screens. -func TestEscFromRuleListDoesNotBounceIntoTheBuilder(t *testing.T) { - m, _, _ := newRuleModel(t) - loadRulesFile(t, m, editableRules) - - key(t, m, "2") - key(t, m, "5") - key(t, m, "e") - key(t, m, "esc") - if m.view != viewRuleList { - t.Fatalf("esc from the builder went to %v, want the list it was opened from", m.view) - } - key(t, m, "esc") - if m.view != viewTxns { - t.Errorf("esc from the list went to %v, want the transactions it was opened from", m.view) - } -} - -// Two orders, because the preview answers two questions: by name finds the -// payee you are looking at, by count finds the rule worth writing next. -func TestRuleBuilderSortsByCount(t *testing.T) { - m, _, _ := newRuleModel(t) - key(t, m, "4") - - alphabetical := []string{ - "AAA FIRST ALPHABETICALLY", - "LIDL SOFIA 4412", - "LIDL VARNA 9911", - "ZZZ LAST ALPHABETICALLY", - } - if all, _ := previewRows(m); !slices.Equal(all, alphabetical) { - t.Fatalf("preview = %v, want %v by default", all, alphabetical) - } - - key(t, m, "ctrl+s") - - // The twice-seen description leads; the rest tie on one and keep the - // alphabetical order between them, so the list does not reshuffle. - byCount := []string{ - "LIDL SOFIA 4412", - "AAA FIRST ALPHABETICALLY", - "LIDL VARNA 9911", - "ZZZ LAST ALPHABETICALLY", - } - if all, _ := previewRows(m); !slices.Equal(all, byCount) { - t.Fatalf("preview = %v, want %v after sorting by count", all, byCount) - } - // The header names the column the order is read from, and the help names - // what the key does next rather than where the list already is. - if cols := m.ruleTable.Columns(); cols[2].Title != "N ↓" || strings.Contains(cols[1].Title, "↓") { - t.Errorf("headers = %q / %q, want the count column marked", cols[1].Title, cols[2].Title) - } - if !strings.Contains(m.help(), "ctrl+s sort by name") { - t.Errorf("help = %q, want the key to offer the way back", m.help()) - } - - key(t, m, "ctrl+s") - if all, _ := previewRows(m); !slices.Equal(all, alphabetical) { - t.Errorf("preview = %v, want %v back", all, alphabetical) - } - - // The choice is the user's, so it survives a glob narrowing the list and - // the builder being left and reopened. - key(t, m, "ctrl+s") - typeText(t, m, "*LIDL*") - key(t, m, "esc") - key(t, m, "4") - if m.previewSort != sortByCount { - t.Fatal("the sort was reset by leaving the builder") - } - want := []string{"LIDL SOFIA 4412", "LIDL VARNA 9911"} - if all, _ := previewRows(m); !slices.Equal(all, want) { - t.Errorf("preview = %v, want %v: the glob still filters, in count order", all, want) - } -} - -// monthsAgo is the 15th of the month n before this one. Mid-month on purpose: -// a fixture on the 31st would land in a different month than intended as soon -// as the month before it is shorter. -func monthsAgo(n int) time.Time { - now := time.Now() - return time.Date(now.Year(), now.Month(), 15, 0, 0, 0, 0, time.UTC).AddDate(0, -n, 0) -} - -// newDatedModel builds a model whose transactions are dated relative to today: -// 10.00 of groceries last month and 25.00 three months back. -func newDatedModel(t *testing.T) *Model { - t.Helper() - - db, err := store.Open(filepath.Join(t.TempDir(), "index.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - accountID, err := db.UpsertAccount(model.Account{ - Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, - }) - if err != nil { - t.Fatal(err) - } - sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - when time.Time - desc string - amount int64 - }{ - {monthsAgo(1), "LIDL SOFIA", -1000}, - {monthsAgo(3), "LIDL PLOVDIV", -2500}, - } { - if _, err := db.InsertTransaction(model.Transaction{ - AccountID: accountID, - SourceFileID: sourceID, - Fingerprint: tc.desc, - Date: tc.when.Format("2006-01-02"), - Description: tc.desc, - AmountMinor: tc.amount, - }); err != nil { - t.Fatal(err) - } - } - - engine := rules.New(&config.Rules{Rule: []config.Rule{{Match: "*LIDL*", Tag: "groceries"}}}) - if _, err := engine.Retag(db); err != nil { - t.Fatal(err) - } - - m := New(t.TempDir(), db, nil, engine, transfers.New(&config.Rules{})) - if err := m.reload(); err != nil { - t.Fatal(err) - } - m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) - return m -} - -// reportOut is what the report says one tag spent, or "" if it has no row. -func reportOut(m *Model, tag string) string { - for _, r := range m.reportTable.Rows() { - if r[0] == tag { - return r[2] - } - } - return "" -} - -// The report opens on last month, not on everything: an all-time total is the -// one number a spending report is least often asked for. -func TestReportOpensOnLastMonth(t *testing.T) { - m := newDatedModel(t) - - p, ok := m.currentPeriod() - if !ok || p.Label != report.LastMonth { - t.Fatalf("period = %+v, want %q", p, report.LastMonth) - } - if got := reportOut(m, "groceries"); got != "10.00" { - t.Errorf("groceries = %q, want only last month's 10.00", got) - } - key(t, m, "3") - if view := m.View(); !strings.Contains(view, "last month") { - t.Errorf("title does not name the period:\n%s", view) - } -} - -// The period narrows the report and only the report. Opening on last month -// must not quietly hide the rest of the index from the transaction list. -func TestReportPeriodLeavesTheTransactionListAlone(t *testing.T) { - m := newDatedModel(t) - - if len(m.txns) != 2 { - t.Fatalf("transaction list has %d rows, want both", len(m.txns)) - } - key(t, m, "3") - key(t, m, "right") - if len(m.txns) != 2 { - t.Errorf("stepping the period changed the transaction list to %d rows", len(m.txns)) - } - if m.filter.From != "" || m.filter.To != "" { - t.Errorf("the period leaked into the shared filter: %+v", m.filter) - } -} - -// →Steps to the next window along, which past last month is the months the -// index actually holds. -func TestReportPeriodSteps(t *testing.T) { - m := newDatedModel(t) - key(t, m, "3") - - key(t, m, "right") - want := monthsAgo(3).Format("2006-01") - if p, _ := m.currentPeriod(); p.Label != want { - t.Fatalf("period = %q, want %q", p.Label, want) - } - if got := reportOut(m, "groceries"); got != "25.00" { - t.Errorf("groceries = %q, want that month's 25.00", got) - } - - key(t, m, "left") - if p, _ := m.currentPeriod(); p.Label != report.LastMonth { - t.Errorf("stepping back landed on %q, want %q", p.Label, report.LastMonth) - } -} - -// The axis clamps at both ends rather than wrapping: all time and the oldest -// month are as far as the two directions go. -func TestReportPeriodClampsAtBothEnds(t *testing.T) { - m := newDatedModel(t) - key(t, m, "3") - - for range 10 { - key(t, m, "left") - } - if p, _ := m.currentPeriod(); p.Label != "all time" { - t.Errorf("period = %q, want all time", p.Label) - } - if got := reportOut(m, "groceries"); got != "35.00" { - t.Errorf("groceries over all time = %q, want 35.00", got) - } - - last := len(m.reportPeriods) - 1 - for range 20 { - key(t, m, "right") - } - if m.reportPeriod != last { - t.Errorf("period index = %d, want it to stop at %d", m.reportPeriod, last) - } -} - -// The axis is on screen beside the totals, so the window the numbers are for -// does not have to be read out of the title. -func TestReportShowsThePeriodAxis(t *testing.T) { - m := newDatedModel(t) - key(t, m, "3") - - view := m.View() - for _, want := range []string{"Period", "▸ " + report.LastMonth, "all time", "←/→ period"} { - if !strings.Contains(view, want) { - t.Errorf("report view is missing %q:\n%s", want, view) - } - } -} - -// A window with nothing in it is not an empty index, and the two want different -// answers: one is fixed by stepping the period, the other by importing. -func TestEmptyPeriodIsNotAnEmptyIndex(t *testing.T) { - m := newDatedModel(t) - key(t, m, "3") - - // Two months back holds neither fixture. - for _, p := range m.reportPeriods { - if p.Label == monthsAgo(2).Format("2006-01") { - t.Fatalf("did not expect a window for an empty month: %+v", p) - } - } - m.filter.Search = "NOTHING MATCHES THIS" - if err := m.reloadReport(); err != nil { - t.Fatal(err) - } - if got := m.emptyMessage(); !strings.Contains(got, "period") { - t.Errorf("empty period message = %q, want it to point at the period", got) - } - if strings.Contains(m.emptyMessage(), "import some statements") { - t.Error("an empty period asked for an import, but the index has statements") - } -} - -// An import that reaches further back grows the axis without moving the window -// the user is reading. -func TestPeriodAxisSurvivesAReload(t *testing.T) { - m := newDatedModel(t) - key(t, m, "3") - key(t, m, "right") - was, _ := m.currentPeriod() - - if err := m.reload(); err != nil { - t.Fatal(err) - } - if p, _ := m.currentPeriod(); p.Label != was.Label { - t.Errorf("reload moved the period from %q to %q", was.Label, p.Label) - } -} - -// newSortModel builds a report worth sorting: three tags that come out in a -// different order under each arrangement. -func newSortModel(t *testing.T) *Model { - t.Helper() - - db, err := store.Open(filepath.Join(t.TempDir(), "index.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - accountID, err := db.UpsertAccount(model.Account{ - Slug: "checking", Name: "Checking", Currency: "EUR", MinorDigits: 2, - }) - if err != nil { - t.Fatal(err) - } - sourceID, err := db.SourceFile(accountID, "checking/st.csv", "sha", "2026-01-01T00:00:00Z") - if err != nil { - t.Fatal(err) - } - // One big outflow, three small ones, and an inflow bigger than any of them. - rows := []struct { - desc string - amount int64 - }{ - {"RENT", -100000}, - {"COFFEE 1", -500}, - {"COFFEE 2", -500}, - {"COFFEE 3", -500}, - {"SALARY", 200000}, - } - for _, r := range rows { - if _, err := db.InsertTransaction(model.Transaction{ - AccountID: accountID, - SourceFileID: sourceID, - Fingerprint: r.desc, - Date: monthsAgo(1).Format("2006-01-02"), - Description: r.desc, - AmountMinor: r.amount, - }); err != nil { - t.Fatal(err) - } - } - - engine := rules.New(&config.Rules{Rule: []config.Rule{ - {Match: "RENT", Tag: "rent"}, - {Match: "COFFEE*", Tag: "coffee"}, - {Match: "SALARY", Tag: "salary"}, - }}) - if _, err := engine.Retag(db); err != nil { - t.Fatal(err) - } - - m := New(t.TempDir(), db, nil, engine, transfers.New(&config.Rules{})) - if err := m.reload(); err != nil { - t.Fatal(err) - } - m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) - key(t, m, "3") - return m -} - -// reportTags is the order the report's tag rows came out in, without the -// TOTAL and transfer rows the table grows below them. -func reportTags(m *Model) []string { - var out []string - for _, r := range m.reportTable.Rows() { - if r[0] == "TOTAL" || r[0] == transfersRow || r[0] == feesRow { - continue - } - out = append(out, r[0]) - } - return out -} - -// s cycles the arrangement, and every one of them is reachable that way. -func TestReportSortCyclesThroughEveryOrder(t *testing.T) { - m := newSortModel(t) - - want := map[report.Order][]string{ - report.OrderOut: {"rent", "coffee", "salary"}, - report.OrderIn: {"salary", "coffee", "rent"}, - report.OrderNet: {"rent", "coffee", "salary"}, - report.OrderCount: {"coffee", "rent", "salary"}, - report.OrderTag: {"coffee", "rent", "salary"}, - } - if m.reportOrder != report.OrderOut { - t.Fatalf("report opened on %s, want %s", m.reportOrder, report.OrderOut) - } - seen := map[report.Order]bool{} - for range len(want) { - seen[m.reportOrder] = true - if got := reportTags(m); !slices.Equal(got, want[m.reportOrder]) { - t.Errorf("%s: rows = %v, want %v", m.reportOrder, got, want[m.reportOrder]) - } - key(t, m, "s") - } - if len(seen) != len(want) { - t.Errorf("cycling reached %d of %d orders", len(seen), len(want)) - } - if m.reportOrder != report.OrderOut { - t.Errorf("the cycle ended on %s, want it back where it started", m.reportOrder) - } -} - -// The sort rearranges the rows and nothing else: the same tags are there, for -// the same period, and the transaction list is untouched. -func TestReportSortChangesOnlyTheOrder(t *testing.T) { - m := newSortModel(t) - - before := slices.Clone(reportTags(m)) - txns := len(m.txns) - period, _ := m.currentPeriod() - - key(t, m, "s") - after := slices.Clone(reportTags(m)) - slices.Sort(before) - slices.Sort(after) - if !slices.Equal(before, after) { - t.Errorf("sorting changed which rows there are: %v then %v", before, after) - } - if len(m.txns) != txns { - t.Errorf("sorting the report changed the transaction list to %d rows", len(m.txns)) - } - if p, _ := m.currentPeriod(); p.Label != period.Label { - t.Errorf("sorting moved the period to %q", p.Label) - } -} - -// The marker sits on the column being sorted, so the order is visible in the -// table rather than only in the key list. -func TestReportMarksTheSortedColumn(t *testing.T) { - m := newSortModel(t) - - for _, want := range []string{"Out ▾", "In ▾", "Net ▾", "N ▾", "Tag ▴"} { - view := m.View() - if !strings.Contains(view, want) { - t.Errorf("%s: header does not mark %q:\n%s", m.reportOrder, want, view) - } - // Exactly one column carries it, or the header would lie about which - // column the rows are arranged by. - if n := strings.Count(view, "▾") + strings.Count(view, "▴"); n != 1 { - t.Errorf("%s: %d columns marked, want 1", m.reportOrder, n) - } - key(t, m, "s") - } -} - -// The key list names what pressing s does next, not the order already in force. -func TestReportSortHelpNamesTheNextOrder(t *testing.T) { - m := newSortModel(t) - if got := m.help(); !strings.Contains(got, "s sort by in") { - t.Errorf("help = %q, want it to offer the next order", got) - } - key(t, m, "s") - if got := m.help(); !strings.Contains(got, "s sort by net") { - t.Errorf("help = %q, want it to offer the next order", got) - } -} - -// s belongs to the report. Elsewhere it goes to the table like any other key, -// so a view that later wants it is not fighting a global binding. -func TestSortKeyIsReportOnly(t *testing.T) { - m := newSortModel(t) - key(t, m, "2") - key(t, m, "s") - if m.reportOrder != report.OrderOut { - t.Errorf("s re-sorted the report from the transaction list: %s", m.reportOrder) - } -} diff --git a/internal/web/server.go b/internal/web/server.go index 5d0eae3..4445fa4 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1,8 +1,8 @@ -// Package web serves the TUI's screens over HTTP: a JSON API over the index +// Package web is money's interactive frontend: a JSON API over the index // and rules.toml, and a single page that drives it. It holds no logic of its // own about money — every number is formatted, every glob matched and every -// pair decided by the same packages the TUI and the CLI use, so the browser -// never does arithmetic on an amount or re-implements the matcher. +// pair decided by the same packages the CLI uses, so the browser never does +// arithmetic on an amount or re-implements the matcher. package web import ( @@ -32,9 +32,9 @@ import ( //go:embed static var static embed.FS -// Server is the long-lived counterpart of the TUI's Model. +// Server holds the index and the engines derived from rules.toml. // -// Unlike the TUI it outlives any one edit of rules.toml by hand, so retag and +// It outlives any one edit of rules.toml by hand, so retag and // import re-read that file (and import the account folders) before running, // exactly as a fresh `money retag` or `money import` would. Between those, the // engines are what the index was last derived from, which is what every screen @@ -576,7 +576,7 @@ type descGroup struct { Claimed bool } -// rulePreview is the TUI builder's right-hand panel: what the glob would claim +// rulePreview is the rule builder's right-hand panel: what the glob would claim // among everything still waiting for a rule, plus — when editing — what the // rule claims already, since those are tagged and an untagged-only preview // would be empty for a rule that works. Matching runs here rather than in the diff --git a/internal/web/server_test.go b/internal/web/server_test.go index feef663..f6deb9d 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -386,3 +386,43 @@ func TestPageIsServed(t *testing.T) { } } } + +// A paired leg is spoken for, so it is neither listed as untagged nor offered +// to the rule builder; an unpaired leg is untagged like anything else, which +// is how it gets noticed. +func TestPairedLegsAreNotUntagged(t *testing.T) { + _, h := newTestServer(t, ` +[[transfer]] +from_account = "checking" +from_desc = "*TO SAVINGS*" +to_account = "savings" +to_desc = "*FROM CHECKING*" +`, + fixtureTxn{"checking", "2026-02-01", "TRANSFER TO SAVINGS", -1000}, + fixtureTxn{"savings", "2026-02-01", "TRANSFER FROM CHECKING", 1000}, + fixtureTxn{"checking", "2026-02-20", "TRANSFER TO SAVINGS", -2000}, + fixtureTxn{"checking", "2026-02-05", "LIDL SOFIA", -500}, + ) + + var txns struct{ Rows []txnRow } + call(t, h, "GET", "/api/transactions?untagged=1", nil, http.StatusOK, &txns) + var seen []string + for _, r := range txns.Rows { + seen = append(seen, r.Date+" "+r.Description) + } + want := "2026-02-20 TRANSFER TO SAVINGS|2026-02-05 LIDL SOFIA" + if strings.Join(seen, "|") != want { + t.Errorf("untagged = %v, want only the unpaired leg and the shopping", seen) + } + + var p rulePreviewJSON + call(t, h, "POST", "/api/rules/preview", rulePreviewReq{Glob: "*"}, http.StatusOK, &p) + for _, r := range p.Rows { + if r.Description == "TRANSFER FROM CHECKING" { + t.Error("the arriving leg of a matched transfer is still offered for tagging") + } + if r.Description == "TRANSFER TO SAVINGS" && r.Count != 1 { + t.Errorf("departing leg offered %d times, want only the unpaired one", r.Count) + } + } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 38bc920..d02f355 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -140,8 +140,8 @@ async function loadOverview() { fillDatalists(); } -// The account and tag fields complete natively, from the lists the TUI -// completes against. (transfer) is never among the tags: it is a label. +// The account and tag fields complete natively, from lists the server +// builds. (transfer) is never among the tags: it is a label. function fillDatalists() { const o = state.overview; if (!o) return; @@ -186,8 +186,8 @@ function go(id) { function show() { const id = location.hash.slice(1) || 'accounts'; const view = VIEWS.find((v) => v.id === id) || VIEWS[0]; - // Leaving the builder mid-edit abandons the edit, as esc does in the TUI: a - // new rule must not start out as a copy of the one being edited. + // Leaving the builder mid-edit abandons the edit, as esc does: a new rule + // must not start out as a copy of the one being edited. if (view.id !== 'rule' && state.rule.editing) state.rule = blankRule(); state.view = view.id; renderNav(); @@ -481,7 +481,7 @@ function textInput(id, value, placeholder, oninput, list) { function mountRuleBuilder(main) { const r = state.rule; const editing = r.editing; - // A new rule starts on the account being browsed, as in the TUI. + // A new rule starts on the account being browsed. if (!editing && !r.account && state.filter.account) r.account = state.filter.account; const summary = h('div', { class: 'summary' }); @@ -857,9 +857,8 @@ function mountTransfers(main) { // --- keyboard ------------------------------------------------------------- -// The TUI's keys, where they do not fight the browser. A builder is a form, -// so outside of chords its keys belong to the field being typed in — exactly -// the rule the TUI keeps. +// Single-key shortcuts, where they do not fight the browser. A builder is a +// form, so outside of chords its keys belong to the field being typed in. document.addEventListener('keydown', (e) => { const t = e.target; const typing = t instanceof HTMLInputElement || t instanceof HTMLSelectElement || t instanceof HTMLTextAreaElement;