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:
@@ -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
|
||||
|
||||
-2483
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user