Remove the TUI; the web app is the frontend

money serve covers every screen the TUI had, so keeping both meant every
behaviour change landing twice. internal/tui goes, and with it bubbletea,
bubbles and lipgloss. `money` with no command now runs serve, the way it
used to open the TUI, and `money tui` is an unknown command.

Three invariants in CLAUDE.md were covered only by TUI tests. Two already
had web counterparts; TestPairedLegsAreNotUntagged is ported to the API:
a paired leg is neither listed as untagged nor offered to the rule
builder, while an unpaired one still is.

README's screen sections now describe the browser, which kept the
behaviour and changed only the controls. CLAUDE.md names the web
equivalents of the TUI functions its invariants pointed at, drops the tab
completion rule that only bubbles' textinput needed, and says how to test
the web app instead of how to drive a terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 18:51:54 +02:00
co-authored by Claude Opus 5.5
parent 8e4df08f43
commit ec8a844441
11 changed files with 228 additions and 5437 deletions
+57 -68
View File
@@ -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 <demo> serve --addr
127.0.0.1:<port>` 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