diff --git a/README.md b/README.md index bac4fd4..00748b3 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ money parsers # available statement parsers 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. + ### TUI keys | Key | Action | diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 827c036..0ecff76 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -68,6 +68,7 @@ var ( 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) ) // Run starts the interface. @@ -477,15 +478,22 @@ func (m *Model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) View() string { var b strings.Builder - b.WriteString(titleStyle.Render(m.title()) + "\n") + b.WriteString(titleStyle.Render(m.title())) + b.WriteString("\n") - switch m.view { - case viewAccounts: - b.WriteString(m.accountTable.View()) - case viewTxns: - b.WriteString(m.txnTable.View()) - case viewReport: - b.WriteString(m.reportTable.View()) + // 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.reportTable.View()) + } } b.WriteString("\n") @@ -504,6 +512,54 @@ func (m *Model) View() string { return b.String() } +// 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\nEvery transaction here has a tag. 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 "" + } + return "Nothing to report yet.\n\nPress i to import some statements first." + } + return "" +} + func (m *Model) title() string { scope := "all accounts" if m.filter.AccountSlug != "" { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 9c25102..4fefae7 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -257,6 +257,109 @@ func TestReportViewExcludesTransfers(t *testing.T) { } } +// 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{})) + 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: "csv"}, + {Slug: "savings", Currency: "EUR", Parser: "csv"}, + }) + + 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) + 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{}) diff --git a/main b/main new file mode 100755 index 0000000..5292f1e Binary files /dev/null and b/main differ