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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user