From 18e08bb8609c28fa87ea315027171195417a16fa Mon Sep 17 00:00:00 2001 From: Artur Taranchiev Date: Fri, 16 Jan 2026 23:27:08 +0600 Subject: [PATCH] Deduplication another step, assets expenses revenues liabilities some more more dedup --- cmd/root.go | 4 +- internal/firefly/account.go | 4 + internal/ui/account_list.go | 192 +++++++++++++++++ internal/ui/account_list_config.go | 38 ++++ internal/ui/account_list_items.go | 67 ++++++ internal/ui/assets.go | 189 ++++++---------- internal/ui/assets_test.go | 58 ++--- internal/ui/expenses.go | 200 ++++++----------- internal/ui/expenses_test.go | 65 +++--- internal/ui/keymap.go | 336 ++--------------------------- internal/ui/liabilities.go | 191 ++++++---------- internal/ui/liabilities_test.go | 58 ++--- internal/ui/revenues.go | 197 ++++++----------- internal/ui/revenues_test.go | 65 +++--- internal/ui/ui.go | 24 +-- 15 files changed, 680 insertions(+), 1008 deletions(-) create mode 100644 internal/ui/account_list.go create mode 100644 internal/ui/account_list_config.go create mode 100644 internal/ui/account_list_items.go diff --git a/cmd/root.go b/cmd/root.go index b901836..9e35f34 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -38,6 +38,7 @@ Prerequisites: RunE: func(cmd *cobra.Command, args []string) error { debug := viper.GetBool("logging.debug") logFile := viper.GetString("logging.file") + timeout := viper.GetInt("timeout") if debug { fmt.Println("Debug logging is enabled") @@ -64,7 +65,7 @@ Prerequisites: ff, err := firefly.NewApi(firefly.ApiConfig{ ApiKey: apiKey, ApiUrl: apiUrl, - TimeoutSeconds: 10, + TimeoutSeconds: timeout, }) if err != nil { return fmt.Errorf("failed to connect to Firefly III: %w", err) @@ -120,6 +121,7 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/ffiii-tui/config)") rootCmd.PersistentFlags().StringP("firefly.api_key", "k", "your_firefly_api_key_here", "Firefly III API key") rootCmd.PersistentFlags().StringP("firefly.api_url", "u", "https://your-firefly-iii-instance.com/api/v1", "Firefly III API URL") + rootCmd.PersistentFlags().IntP("timeout", "t", 10, "Connection timeout") rootCmd.Flags().BoolP("logging.debug", "d", false, "Enable debug logging") rootCmd.Flags().StringP("logging.file", "l", "messages.log", "Log file path (if empty, logs to stdout)") diff --git a/internal/firefly/account.go b/internal/firefly/account.go index 4c9dd40..41b9c24 100644 --- a/internal/firefly/account.go +++ b/internal/firefly/account.go @@ -320,3 +320,7 @@ func (a *Account) GetBalance(api *Api) float64 { func (a *Account) IsEmpty() bool { return *a == Account{} } + +func (a Account) GetName() string { + return a.Name +} diff --git a/internal/ui/account_list.go b/internal/ui/account_list.go new file mode 100644 index 0000000..69a12e8 --- /dev/null +++ b/internal/ui/account_list.go @@ -0,0 +1,192 @@ +/* +Copyright © 2025-2026 Artur Taranchiev +SPDX-License-Identifier: Apache-2.0 +*/ +package ui + +import ( + "reflect" + + "ffiii-tui/internal/firefly" + "ffiii-tui/internal/ui/notify" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +// AccountListModel is a generic model for account/category list views +type AccountListModel[T ListEntity] struct { + list list.Model + api any // Specific API interface + focus bool + sorted bool + config *AccountListConfig[T] + styles Styles + keymap AccountKeyMap +} + +// NewAccountListModel creates a new generic account list model +func NewAccountListModel[T ListEntity](api any, config *AccountListConfig[T]) AccountListModel[T] { + items := config.GetItems(api, false) + + m := AccountListModel[T]{ + list: list.New(items, list.NewDefaultDelegate(), 0, 0), + api: api, + config: config, + styles: DefaultStyles(), + keymap: DefaultAccountKeyMap(), + } + m.list.Title = config.Title + m.list.SetShowStatusBar(false) + m.list.SetFilteringEnabled(false) + m.list.SetShowHelp(false) + m.list.DisableQuitKeybindings() + + return m +} + +func (m AccountListModel[T]) Init() tea.Cmd { + return nil +} + +func (m AccountListModel[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + if matchMsgType(msg, m.config.RefreshMsgType) { + return m, func() tea.Msg { + err := m.config.RefreshItems(m.api, m.config.AccountType) + if err != nil { + return notify.NotifyWarn(err.Error())() + } + return m.config.UpdateMsgType + } + } + + if matchMsgType(msg, m.config.UpdateMsgType) { + return m, m.updateItemsCmd() + } + + if msg, ok := msg.(UpdatePositions); ok { + if msg.layout != nil { + h, v := m.styles.Base.GetFrameSize() + var height int + if m.config.HasSummary { + height = msg.layout.Height - v - msg.layout.TopSize - msg.layout.SummarySize + } else { + height = msg.layout.Height - v - msg.layout.TopSize + } + m.list.SetSize(msg.layout.Width-h, height) + } + return m, nil + } + + if !m.focus { + return m, nil + } + + // Common keys + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keymap.Quit): + return m, SetView(transactionsView) + case key.Matches(msg, m.keymap.ViewTransactions): + return m, SetView(transactionsView) + case key.Matches(msg, m.keymap.ViewAssets): + return m, SetView(assetsView) + case key.Matches(msg, m.keymap.ViewCategories): + return m, SetView(categoriesView) + case key.Matches(msg, m.keymap.ViewExpenses): + return m, SetView(expensesView) + case key.Matches(msg, m.keymap.ViewRevenues): + return m, SetView(revenuesView) + case key.Matches(msg, m.keymap.ViewLiabilities): + return m, SetView(liabilitiesView) + case key.Matches(msg, m.keymap.Refresh): + return m, Cmd(m.config.RefreshMsgType) + case key.Matches(msg, m.keymap.ResetFilter): + return m, Cmd(FilterMsg{Reset: true}) + case key.Matches(msg, m.keymap.Sort): + if m.config.HasSort { + m.sorted = !m.sorted + return m, Cmd(m.config.UpdateMsgType) + } + case key.Matches(msg, m.keymap.New): + return m, m.config.PromptNewFunc() + case key.Matches(msg, m.keymap.Filter): + i, ok := m.list.SelectedItem().(accountListItem[T]) + if ok { + if m.config.HasTotalRow && i.Entity.GetName() == "Total" { + return m, nil + } + return m, m.config.FilterFunc(i) + } + return m, nil + case key.Matches(msg, m.keymap.Select): + i, ok := m.list.SelectedItem().(accountListItem[T]) + if ok { + if m.config.HasTotalRow && i.Entity.GetName() == "Total" { + return m, nil + } + return m, m.config.SelectFunc(i) + } + return m, nil + } + } + + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m AccountListModel[T]) View() string { + return m.styles.LeftPanel.Render(m.list.View()) +} + +func (m *AccountListModel[T]) Focus() { + m.list.FilterInput.Focus() + m.focus = true +} + +func (m *AccountListModel[T]) Blur() { + m.list.FilterInput.Blur() + m.focus = false +} + +func (m AccountListModel[T]) createTotalEntity(primary float64) list.Item { + var entity T + + acc := firefly.Account{Name: "Total", CurrencyCode: ""} + if api, ok := m.api.(interface{ PrimaryCurrency() firefly.Currency }); ok { + acc.CurrencyCode = api.PrimaryCurrency().Code + } + entity = any(acc).(T) + return newAccountListItem(entity, "Total", primary) +} + +func (m *AccountListModel[T]) updateItemsCmd() tea.Cmd { + items := m.config.GetItems(m.api, m.sorted) + + if m.config.HasTotalRow && m.config.GetTotalFunc != nil { + primary := m.config.GetTotalFunc(m.api) + totalEntity := m.createTotalEntity(primary) + + cmds := []tea.Cmd{ + m.list.SetItems(items), + m.list.InsertItem(0, totalEntity), + } + + cmds = append(cmds, Cmd(DataLoadCompletedMsg{DataType: m.config.AccountType})) + + return tea.Sequence(cmds...) + } + + return tea.Batch( + m.list.SetItems(items), + Cmd(DataLoadCompletedMsg{DataType: m.config.AccountType}), + ) +} + +func matchMsgType(msg, ty tea.Msg) bool { + return reflect.TypeOf(msg) == reflect.TypeOf(ty) +} diff --git a/internal/ui/account_list_config.go b/internal/ui/account_list_config.go new file mode 100644 index 0000000..08ef702 --- /dev/null +++ b/internal/ui/account_list_config.go @@ -0,0 +1,38 @@ +/* +Copyright © 2025-2026 Artur Taranchiev +SPDX-License-Identifier: Apache-2.0 +*/ +package ui + +import ( + "ffiii-tui/internal/firefly" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +type AccountListConfig[T firefly.Account] struct { + // Data specific + AccountType string + + // Display + Title string + + // Data functions + GetItems func(api any, sorted bool) []list.Item + RefreshItems func(api any, accountType string) error + + // Messages + RefreshMsgType any + UpdateMsgType any + + // UI Behavior + PromptNewFunc func() tea.Cmd + HasSort bool + HasTotalRow bool + HasSummary bool + GetTotalFunc func(api any) float64 // for totals + + FilterFunc func(item list.Item) tea.Cmd + SelectFunc func(item list.Item) tea.Cmd +} diff --git a/internal/ui/account_list_items.go b/internal/ui/account_list_items.go new file mode 100644 index 0000000..c3b4e59 --- /dev/null +++ b/internal/ui/account_list_items.go @@ -0,0 +1,67 @@ +/* +Copyright © 2025-2026 Artur Taranchiev +SPDX-License-Identifier: Apache-2.0 +*/ +package ui + +import ( + "fmt" + + "ffiii-tui/internal/firefly" +) + +// ListEntity is a constraint for types that can be displayed in account lists +type ListEntity interface { + firefly.Account + GetName() string +} + +// accountListItem is a generic list item for accounts and categories +type accountListItem[T ListEntity] struct { + Entity T + PrimaryVal float64 + primaryLabel string +} + +// Accessors for backward compatibility with tests +func (i accountListItem[T]) GetPrimaryVal() float64 { + return i.PrimaryVal +} + +func (i accountListItem[T]) Title() string { + var name string + switch entity := any(i.Entity).(type) { + case firefly.Account: + name = entity.Name + } + return name +} + +func (i accountListItem[T]) Description() string { + var currencyCode string + switch entity := any(i.Entity).(type) { + case firefly.Account: + currencyCode = entity.CurrencyCode + } + + desc := fmt.Sprintf("%s: %.2f %s", i.primaryLabel, i.PrimaryVal, currencyCode) + return desc +} + +func (i accountListItem[T]) FilterValue() string { + var name string + switch entity := any(i.Entity).(type) { + case firefly.Account: + name = entity.Name + } + return name +} + +// newAccountListItem creates a new account list item with a single value +func newAccountListItem[T ListEntity](entity T, primaryLabel string, primaryVal float64) accountListItem[T] { + return accountListItem[T]{ + Entity: entity, + PrimaryVal: primaryVal, + primaryLabel: primaryLabel, + } +} diff --git a/internal/ui/assets.go b/internal/ui/assets.go index 96e816a..22cc805 100644 --- a/internal/ui/assets.go +++ b/internal/ui/assets.go @@ -12,7 +12,6 @@ import ( "ffiii-tui/internal/ui/notify" "ffiii-tui/internal/ui/prompt" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) @@ -20,165 +19,99 @@ import ( type ( RefreshAssetsMsg struct{} AssetsUpdateMsg struct{} + NewAssetMsg struct { + Account string + Currency string + } ) -type NewAssetMsg struct { - Account string - Currency string -} - -type assetItem struct { - account firefly.Account - balance float64 -} - -func (i assetItem) Title() string { return i.account.Name } -func (i assetItem) Description() string { - return fmt.Sprintf("Balance: %.2f %s", i.balance, i.account.CurrencyCode) -} -func (i assetItem) FilterValue() string { return i.account.Name } +type assetItem = accountListItem[firefly.Account] type modelAssets struct { - list list.Model - api AssetAPI - focus bool - keymap AssetKeyMap - styles Styles + AccountListModel[firefly.Account] } func newModelAssets(api AssetAPI) modelAssets { - items := getAssetsItems(api) - - m := modelAssets{ - list: list.New(items, list.NewDefaultDelegate(), 0, 0), - api: api, - keymap: DefaultAssetKeyMap(), - styles: DefaultStyles(), + config := &AccountListConfig[firefly.Account]{ + AccountType: "asset", + Title: "Asset accounts", + GetItems: func(apiInterface any, sorted bool) []list.Item { + return getAssetsItems(apiInterface.(AssetAPI)) + }, + RefreshItems: func(apiInterface any, accountType string) error { + return apiInterface.(AssetAPI).UpdateAccounts(accountType) + }, + RefreshMsgType: RefreshAssetsMsg{}, + UpdateMsgType: AssetsUpdateMsg{}, + PromptNewFunc: func() tea.Cmd { + return CmdPromptNewAsset(SetView(assetsView)) + }, + HasSort: false, + HasTotalRow: false, + HasSummary: true, + FilterFunc: func(item list.Item) tea.Cmd { + i, ok := item.(assetItem) + if ok { + return Cmd(FilterMsg{Account: i.Entity}) + } + return nil + }, + SelectFunc: func(item list.Item) tea.Cmd { + var cmds []tea.Cmd + i, ok := item.(assetItem) + if ok { + cmds = append(cmds, Cmd(FilterMsg{Account: i.Entity})) + } + cmds = append(cmds, SetView(transactionsView)) + return tea.Sequence(cmds...) + }, + } + return modelAssets{ + AccountListModel: NewAccountListModel[firefly.Account](api, config), } - m.list.Title = "Asset accounts" - m.list.SetShowStatusBar(false) - m.list.SetFilteringEnabled(false) - m.list.SetShowHelp(false) - m.list.DisableQuitKeybindings() - - return m } func (m modelAssets) Init() tea.Cmd { - return nil + return m.AccountListModel.Init() } func (m modelAssets) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - var cmds []tea.Cmd - - switch msg := msg.(type) { - case RefreshAssetsMsg: - return m, func() tea.Msg { - err := m.api.UpdateAccounts("asset") - if err != nil { - return notify.NotifyWarn(err.Error())() - } - return AssetsUpdateMsg{} - } - case AssetsUpdateMsg: - return m, tea.Batch( - m.list.SetItems(getAssetsItems(m.api)), - Cmd(DataLoadCompletedMsg{DataType: "assets"}), - ) - case NewAssetMsg: - err := m.api.CreateAssetAccount(msg.Account, msg.Currency) + if newMsg, ok := msg.(NewAssetMsg); ok { + api := m.api.(AssetAPI) + err := api.CreateAssetAccount(newMsg.Account, newMsg.Currency) if err != nil { return m, notify.NotifyWarn(err.Error()) } return m, tea.Batch( Cmd(RefreshAssetsMsg{}), - notify.NotifyLog(fmt.Sprintf("Asset account '%s' created", msg.Account)), + notify.NotifyLog(fmt.Sprintf("Asset account '%s' created", newMsg.Account)), ) - case UpdatePositions: - if msg.layout != nil { - h, v := m.styles.Base.GetFrameSize() - m.list.SetSize( - msg.layout.Width-h, - msg.layout.Height-v-msg.layout.TopSize-msg.layout.SummarySize, - ) - } - } - - if !m.focus { - return m, nil } - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, m.keymap.Quit): - return m, SetView(transactionsView) - case key.Matches(msg, m.keymap.Filter): - i, ok := m.list.SelectedItem().(assetItem) - if ok { - return m, Cmd(FilterMsg{Account: i.account}) - } - return m, nil - case key.Matches(msg, m.keymap.Select): - i, ok := m.list.SelectedItem().(assetItem) - if ok { - cmds = append(cmds, Cmd(FilterMsg{Account: i.account})) - } - cmds = append(cmds, SetView(transactionsView)) - return m, tea.Sequence(cmds...) - case key.Matches(msg, m.keymap.New): - return m, CmdPromptNewAsset(SetView(assetsView)) - case key.Matches(msg, m.keymap.Refresh): + if _, ok := msg.(RefreshAssetsMsg); ok { + updated, cmd := m.AccountListModel.Update(msg) + m.AccountListModel = updated.(AccountListModel[firefly.Account]) + if cmd != nil { return m, tea.Batch( - Cmd(RefreshAssetsMsg{}), - Cmd(RefreshSummaryMsg{})) - case key.Matches(msg, m.keymap.ResetFilter): - return m, Cmd(FilterMsg{Reset: true}) - - case key.Matches(msg, m.keymap.ViewTransactions): - return m, SetView(transactionsView) - case key.Matches(msg, m.keymap.ViewAssets): - return m, SetView(assetsView) - case key.Matches(msg, m.keymap.ViewCategories): - return m, SetView(categoriesView) - case key.Matches(msg, m.keymap.ViewExpenses): - return m, SetView(expensesView) - case key.Matches(msg, m.keymap.ViewRevenues): - return m, SetView(revenuesView) - case key.Matches(msg, m.keymap.ViewLiabilities): - return m, SetView(liabilitiesView) - + cmd, + Cmd(RefreshSummaryMsg{}), + ) } } - - m.list, cmd = m.list.Update(msg) + updated, cmd := m.AccountListModel.Update(msg) + m.AccountListModel = updated.(AccountListModel[firefly.Account]) return m, cmd } -func (m modelAssets) View() string { - return m.styles.LeftPanel.Render(m.list.View()) -} - -func (m *modelAssets) Focus() { - m.list.FilterInput.Focus() - m.focus = true -} - -func (m *modelAssets) Blur() { - m.list.FilterInput.Blur() - m.focus = false -} - func getAssetsItems(api AccountsAPI) []list.Item { items := []list.Item{} for _, account := range api.AccountsByType("asset") { - items = append(items, assetItem{ - account: account, - balance: api.AccountBalance(account.ID), - }) + items = append(items, newAccountListItem( + account, + "Balance", + api.AccountBalance(account.ID), + )) } - return items } diff --git a/internal/ui/assets_test.go b/internal/ui/assets_test.go index 63940f8..69b063e 100644 --- a/internal/ui/assets_test.go +++ b/internal/ui/assets_test.go @@ -143,11 +143,11 @@ func TestGetAssetsItems_UsesBalanceAPI(t *testing.T) { if !ok { t.Fatalf("expected item type assetItem, got %T", items[0]) } - if first.account.ID != "a1" { - t.Errorf("expected first account ID 'a1', got %q", first.account.ID) + if first.Entity.ID != "a1" { + t.Errorf("expected first account ID 'a1', got %q", first.Entity.ID) } - if first.balance != 10.5 { - t.Errorf("expected first balance 10.5, got %v", first.balance) + if first.PrimaryVal != 10.5 { + t.Errorf("expected first balance 10.5, got %v", first.PrimaryVal) } if first.Description() != "Balance: 10.50 USD" { t.Errorf("unexpected description: %q", first.Description()) @@ -166,9 +166,18 @@ func TestModelAssets_RefreshAssets_Success(t *testing.T) { t.Fatal("expected cmd") } - msg := cmd() - if _, ok := msg.(AssetsUpdateMsg); !ok { - t.Fatalf("expected AssetsUpdateMsg, got %T", msg) + msgs := collectMsgsFromCmd(cmd) + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d (%T)", len(msgs), msgs) + } + + _, ok := msgs[0].(AssetsUpdateMsg) + if !ok { + t.Fatalf("expected AssetsUpdateMsg, got %T", msgs[0]) + } + _, ok = msgs[1].(RefreshSummaryMsg) + if !ok { + t.Fatalf("expected RefreshSummaryMsg, got %T", msgs[1]) } if len(api.updateAccountsCalledWith) != 1 || api.updateAccountsCalledWith[0] != "asset" { @@ -190,10 +199,13 @@ func TestModelAssets_RefreshAssets_Error(t *testing.T) { t.Fatal("expected cmd") } - msg := cmd() - notifyMsg, ok := msg.(notify.NotifyMsg) + msgs := collectMsgsFromCmd(cmd) + if len(msgs) != 2 { + t.Fatalf("expected 2 messages, got %d (%T)", len(msgs), msgs) + } + notifyMsg, ok := msgs[0].(notify.NotifyMsg) if !ok { - t.Fatalf("expected notify.NotifyMsg, got %T", msg) + t.Fatalf("expected notify.NotifyMsg, got %T", msgs[0]) } if notifyMsg.Level != notify.Warn { t.Fatalf("expected warn level, got %v", notifyMsg.Level) @@ -313,8 +325,8 @@ func TestModelAssets_AssetsUpdate_EmitsDataLoadCompleted(t *testing.T) { if !ok { t.Fatalf("expected DataLoadCompletedMsg, got %T", msg) } - if loader.DataType != "assets" { - t.Fatalf("expected DataType 'assets', got %q", loader.DataType) + if loader.DataType != "asset" { + t.Fatalf("expected DataType 'asset', got %q", loader.DataType) } listItems := m.list.Items() @@ -398,17 +410,14 @@ func TestModelAssets_KeyRefresh_BatchesAssetsAndSummaryRefresh(t *testing.T) { _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) msgs := collectMsgsFromCmd(cmd) - if len(msgs) != 2 { - t.Fatalf("expected 2 messages, got %d (%T)", len(msgs), msgs) + if len(msgs) != 1 { + t.Fatalf("expected 1 messages, got %d (%T)", len(msgs), msgs) } _, ok := msgs[0].(RefreshAssetsMsg) if !ok { t.Fatalf("expected RefreshAssetsMsg, got %T", msgs[0]) } - if _, ok := msgs[1].(RefreshSummaryMsg); !ok { - t.Fatalf("expected RefreshSummaryMsg, got %T", msgs[1]) - } } func TestModelAssets_KeyResetFilter_EmitsResetFilterMsg(t *testing.T) { @@ -534,7 +543,7 @@ func TestModelAssets_KeyViewNavigation(t *testing.T) { {"revenues", 'i', revenuesView, true, 1}, {"liabilities", 'o', liabilitiesView, true, 1}, {"transactions", 't', transactionsView, true, 1}, - {"assets (self)", 'a', assetsView, false, 0}, + {"assets (self)", 'a', assetsView, true, 1}, } for _, tt := range tests { @@ -772,8 +781,8 @@ func TestModelAssets_BalanceBoundaryValues(t *testing.T) { t.Fatalf("expected assetItem, got %T", items[0]) } - if item.balance != tt.balance { - t.Errorf("expected balance %v, got %v", tt.balance, item.balance) + if item.PrimaryVal != tt.balance { + t.Errorf("expected balance %v, got %v", tt.balance, item.PrimaryVal) } if item.Description() != tt.expectedDisplay { @@ -821,8 +830,8 @@ func TestModelAssets_AccountNameEdgeCases(t *testing.T) { } // Verify the name is preserved (not modified) - if item.account.Name != tt.accountName { - t.Errorf("expected name %q, got %q", tt.accountName, item.account.Name) + if item.Entity.Name != tt.accountName { + t.Errorf("expected name %q, got %q", tt.accountName, item.Entity.Name) } // Verify Title() doesn't panic @@ -887,8 +896,8 @@ func TestModelAssets_MultipleAccountsWithSameID(t *testing.T) { if !ok { t.Fatalf("item %d: expected assetItem, got %T", i, item) } - if assetItem.balance != 100.0 { - t.Errorf("item %d: expected balance 100.0, got %v", i, assetItem.balance) + if assetItem.PrimaryVal != 100.0 { + t.Errorf("item %d: expected balance 100.0, got %v", i, assetItem.PrimaryVal) } } } @@ -953,7 +962,6 @@ func TestModelAssets_UpdatePositions_WithVariousDimensions(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - api := &mockAssetAPI{ accountsByTypeFunc: func(accountType string) []firefly.Account { return nil }, } diff --git a/internal/ui/expenses.go b/internal/ui/expenses.go index 59bb339..fbcb6d4 100644 --- a/internal/ui/expenses.go +++ b/internal/ui/expenses.go @@ -12,13 +12,10 @@ import ( "ffiii-tui/internal/ui/notify" "ffiii-tui/internal/ui/prompt" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) -var totalExpenseAccount = firefly.Account{Name: "Total", CurrencyCode: ""} - type ( RefreshExpensesMsg struct{} RefreshExpenseInsightsMsg struct{} @@ -28,181 +25,104 @@ type ( } ) -type expenseItem struct { - account firefly.Account - spent float64 -} - -func (i expenseItem) Title() string { return i.account.Name } -func (i expenseItem) Description() string { - return fmt.Sprintf("Spent: %.2f %s", i.spent, i.account.CurrencyCode) -} -func (i expenseItem) FilterValue() string { return i.account.Name } +type expenseItem = accountListItem[firefly.Account] type modelExpenses struct { - list list.Model - api ExpenseAPI - focus bool - sorted bool - keymap ExpenseKeyMap - styles Styles + AccountListModel[firefly.Account] } func newModelExpenses(api ExpenseAPI) modelExpenses { - // Set total expense account currency - totalExpenseAccount.CurrencyCode = api.PrimaryCurrency().Code - - items := getExpensesItems(api, false) - - m := modelExpenses{ - list: list.New(items, list.NewDefaultDelegate(), 0, 0), - api: api, - keymap: DefaultExpenseKeyMap(), - styles: DefaultStyles(), + config := &AccountListConfig[firefly.Account]{ + AccountType: "expense", + Title: "Expense accounts", + GetItems: func(apiInterface any, sorted bool) []list.Item { + return getExpensesItems(apiInterface.(ExpenseAPI), sorted) + }, + RefreshItems: func(apiInterface any, accountType string) error { + return apiInterface.(ExpenseAPI).UpdateAccounts(accountType) + }, + RefreshMsgType: RefreshExpensesMsg{}, + UpdateMsgType: ExpensesUpdatedMsg{}, + PromptNewFunc: func() tea.Cmd { + return CmdPromptNewExpense(SetView(expensesView)) + }, + HasSort: true, + HasTotalRow: true, + GetTotalFunc: func(api any) float64 { + return api.(ExpenseAPI).GetTotalExpenseDiff() + }, + FilterFunc: func(item list.Item) tea.Cmd { + i, ok := item.(expenseItem) + if ok { + return Cmd(FilterMsg{Account: i.Entity}) + } + return nil + }, + SelectFunc: func(item list.Item) tea.Cmd { + var cmds []tea.Cmd + i, ok := item.(expenseItem) + if ok { + cmds = append(cmds, Cmd(FilterMsg{Account: i.Entity})) + } + cmds = append(cmds, SetView(transactionsView)) + return tea.Sequence(cmds...) + }, + } + return modelExpenses{ + AccountListModel: NewAccountListModel[firefly.Account](api, config), } - m.list.Title = "Expense accounts" - m.list.Styles.HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) - m.list.SetFilteringEnabled(false) - m.list.SetShowStatusBar(false) - m.list.SetShowHelp(false) - m.list.DisableQuitKeybindings() - - return m } func (m modelExpenses) Init() tea.Cmd { - return nil + return m.AccountListModel.Init() } func (m modelExpenses) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case RefreshExpenseInsightsMsg: - return m, func() tea.Msg { - err := m.api.UpdateExpenseInsights() - if err != nil { - return notify.NotifyWarn(err.Error())() - } - return ExpensesUpdatedMsg{} - } - case RefreshExpensesMsg: - return m, func() tea.Msg { - err := m.api.UpdateAccounts("expense") - if err != nil { - return notify.NotifyWarn(err.Error())() - } - return ExpensesUpdatedMsg{} - } - case ExpensesUpdatedMsg: - return m, tea.Sequence( - m.list.SetItems(getExpensesItems(m.api, m.sorted)), - m.list.InsertItem(0, expenseItem{ - account: totalExpenseAccount, - spent: m.api.GetTotalExpenseDiff(), - }), - Cmd(DataLoadCompletedMsg{DataType: "expenses"}), - ) - case NewExpenseMsg: - err := m.api.CreateExpenseAccount(msg.Account) + if newMsg, ok := msg.(NewExpenseMsg); ok { + api := m.api.(ExpenseAPI) + err := api.CreateExpenseAccount(newMsg.Account) if err != nil { return m, notify.NotifyWarn(err.Error()) } return m, tea.Batch( Cmd(RefreshExpensesMsg{}), - notify.NotifyLog(fmt.Sprintf("Expense account '%s' created", msg.Account)), + notify.NotifyLog(fmt.Sprintf("Expense account '%s' created", newMsg.Account)), ) - case UpdatePositions: - if msg.layout != nil { - h, v := m.styles.Base.GetFrameSize() - m.list.SetSize( - msg.layout.Width-h, - msg.layout.Height-v-msg.layout.TopSize, - ) - } - } - - if !m.focus { - return m, nil } - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, m.keymap.Quit): - return m, SetView(transactionsView) - case key.Matches(msg, m.keymap.New): - return m, CmdPromptNewExpense(SetView(expensesView)) - case key.Matches(msg, m.keymap.Filter): - i, ok := m.list.SelectedItem().(expenseItem) - if ok { - if i.account == totalExpenseAccount { - return m, nil - } - return m, Cmd(FilterMsg{Account: i.account}) + switch msg.(type) { + case RefreshExpenseInsightsMsg: + return m, func() tea.Msg { + err := m.api.(ExpenseAPI).UpdateExpenseInsights() + if err != nil { + return notify.NotifyWarn(err.Error())() } - return m, nil - case key.Matches(msg, m.keymap.Refresh): - return m, Cmd(RefreshExpensesMsg{}) - case key.Matches(msg, m.keymap.Sort): - m.sorted = !m.sorted - return m, Cmd(ExpensesUpdatedMsg{}) - case key.Matches(msg, m.keymap.ResetFilter): - return m, Cmd(FilterMsg{Reset: true}) - case key.Matches(msg, m.keymap.ViewTransactions): - return m, SetView(transactionsView) - case key.Matches(msg, m.keymap.ViewAssets): - return m, SetView(assetsView) - case key.Matches(msg, m.keymap.ViewCategories): - return m, SetView(categoriesView) - case key.Matches(msg, m.keymap.ViewExpenses): - return m, SetView(expensesView) - case key.Matches(msg, m.keymap.ViewRevenues): - return m, SetView(revenuesView) - case key.Matches(msg, m.keymap.ViewLiabilities): - return m, SetView(liabilitiesView) - // case "R": - // return m, Cmd(RefreshExpensesMsg{}) + return ExpensesUpdatedMsg{} } } - - m.list, cmd = m.list.Update(msg) + updated, cmd := m.AccountListModel.Update(msg) + m.AccountListModel = updated.(AccountListModel[firefly.Account]) return m, cmd } -func (m modelExpenses) View() string { - return m.styles.LeftPanel.Render(m.list.View()) -} - -func (m *modelExpenses) Focus() { - m.list.FilterInput.Focus() - m.focus = true -} - -func (m *modelExpenses) Blur() { - m.list.FilterInput.Blur() - m.focus = false -} - func getExpensesItems(api ExpenseAPI, sorted bool) []list.Item { items := []list.Item{} for _, account := range api.AccountsByType("expense") { spent := api.GetExpenseDiff(account.ID) - if sorted && spent == 0 { continue } - items = append(items, expenseItem{ - account: account, - spent: spent, - }) + items = append(items, newAccountListItem( + account, + "Spent", + spent, + )) } if sorted { slices.SortFunc(items, func(a, b list.Item) int { - return int(b.(expenseItem).spent) - int(a.(expenseItem).spent) + return int(b.(expenseItem).PrimaryVal) - int(a.(expenseItem).PrimaryVal) }) } - return items } diff --git a/internal/ui/expenses_test.go b/internal/ui/expenses_test.go index 85e4269..c911c4c 100644 --- a/internal/ui/expenses_test.go +++ b/internal/ui/expenses_test.go @@ -145,11 +145,11 @@ func TestGetExpensesItems_UsesExpenseDiffAPI(t *testing.T) { if !ok { t.Fatalf("expected item type expenseItem, got %T", items[0]) } - if first.account.ID != "e1" { - t.Errorf("expected first account ID 'e1', got %q", first.account.ID) + if first.Entity.ID != "e1" { + t.Errorf("expected first account ID 'e1', got %q", first.Entity.ID) } - if first.spent != 250.75 { - t.Errorf("expected first spent 250.75, got %v", first.spent) + if first.PrimaryVal != 250.75 { + t.Errorf("expected first spent 250.75, got %v", first.PrimaryVal) } if first.Description() != "Spent: 250.75 USD" { t.Errorf("unexpected description: %q", first.Description()) @@ -190,34 +190,16 @@ func TestGetExpensesItems_SortedFiltersZeroAndSorts(t *testing.T) { } first := items[0].(expenseItem) - if first.account.Name != "High" { - t.Errorf("expected first item 'High', got %q", first.account.Name) + if first.Entity.Name != "High" { + t.Errorf("expected first item 'High', got %q", first.Entity.Name) } - if first.spent != 5000 { - t.Errorf("expected first spent 5000, got %v", first.spent) + if first.PrimaryVal != 5000 { + t.Errorf("expected first spent 5000, got %v", first.PrimaryVal) } second := items[1].(expenseItem) - if second.account.Name != "Low" { - t.Errorf("expected second item 'Low', got %q", second.account.Name) - } -} - -func TestNewModelExpenses_SetsPrimaryCurrency(t *testing.T) { - api := &mockExpenseAPI{ - accountsByTypeFunc: func(accountType string) []firefly.Account { - return []firefly.Account{} - }, - primaryCurrencyFunc: func() firefly.Currency { - return firefly.Currency{Code: "GBP", Symbol: "£"} - }, - } - - _ = newModelExpenses(api) - - // Verify totalExpenseAccount got the primary currency - if totalExpenseAccount.CurrencyCode != "GBP" { - t.Errorf("expected totalExpenseAccount currency 'GBP', got %q", totalExpenseAccount.CurrencyCode) + if second.Entity.Name != "Low" { + t.Errorf("expected second item 'Low', got %q", second.Entity.Name) } } @@ -446,7 +428,7 @@ func TestModelExpenses_ExpensesUpdated_EmitsDataLoadCompleted(t *testing.T) { if !ok { t.Fatalf("expected DataLoadCompletedMsg, got %T", msgs[0]) } - if loader.DataType != "expenses" { + if loader.DataType != "expense" { t.Fatalf("expected DataType 'expenses', got %q", loader.DataType) } @@ -461,11 +443,11 @@ func TestModelExpenses_ExpensesUpdated_EmitsDataLoadCompleted(t *testing.T) { if !ok { t.Fatalf("expected first item to be expenseItem, got %T", listItems[0]) } - if totalItem.account.Name != "Total" { - t.Errorf("expected first item name 'Total', got %q", totalItem.account.Name) + if totalItem.Entity.Name != "Total" { + t.Errorf("expected first item name 'Total', got %q", totalItem.Entity.Name) } - if totalItem.spent != 1500 { - t.Errorf("expected total spent 1500, got %v", totalItem.spent) + if totalItem.PrimaryVal != 1500 { + t.Errorf("expected total spent 1500, got %v", totalItem.PrimaryVal) } } @@ -486,9 +468,10 @@ func TestModelExpenses_UpdatePositions_SetsListSize(t *testing.T) { updated, _ := m.Update(UpdatePositions{ layout: &LayoutConfig{ - Width: globalWidth, - Height: globalHeight, - TopSize: topSize, + Width: globalWidth, + Height: globalHeight, + TopSize: topSize, + SummarySize: 10, }, }) m2 := updated.(modelExpenses) @@ -712,7 +695,7 @@ func TestModelExpenses_KeyViewNavigation(t *testing.T) { {"revenues", 'i', revenuesView, false, 1}, {"transactions", 't', transactionsView, false, 1}, {"liabilities", 'o', liabilitiesView, false, 1}, - {"expenses (self)", 'e', expensesView, true, 0}, + {"expenses (self)", 'e', expensesView, false, 1}, {"quit to transactions", 'q', transactionsView, false, 1}, } @@ -902,8 +885,8 @@ func TestModelExpenses_SpentBoundaryValues(t *testing.T) { t.Fatalf("expected expenseItem, got %T", items[0]) } - if item.spent != tt.spent { - t.Errorf("expected spent %v, got %v", tt.spent, item.spent) + if item.PrimaryVal != tt.spent { + t.Errorf("expected spent %v, got %v", tt.spent, item.PrimaryVal) } if item.Description() != tt.expectedDisplay { @@ -949,8 +932,8 @@ func TestModelExpenses_AccountNameEdgeCases(t *testing.T) { t.Fatalf("expected expenseItem, got %T", items[0]) } - if item.account.Name != tt.accountName { - t.Errorf("expected name %q, got %q", tt.accountName, item.account.Name) + if item.Entity.Name != tt.accountName { + t.Errorf("expected name %q, got %q", tt.accountName, item.Entity.Name) } title := item.Title() diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 5e26aba..dcce5bd 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -16,55 +16,21 @@ type UIKeyMap struct { NextPeriod key.Binding } -type AssetKeyMap struct { - ShowFullHelp key.Binding - Quit key.Binding - Filter key.Binding - ResetFilter key.Binding - Select key.Binding - New key.Binding - Refresh key.Binding - - ViewTransactions key.Binding - ViewAssets key.Binding - ViewCategories key.Binding - ViewExpenses key.Binding - ViewRevenues key.Binding - ViewLiabilities key.Binding -} - -type ExpenseKeyMap struct { - ShowFullHelp key.Binding - Quit key.Binding - Filter key.Binding - ResetFilter key.Binding - New key.Binding - Refresh key.Binding - Sort key.Binding - - ViewTransactions key.Binding - ViewAssets key.Binding - ViewCategories key.Binding - ViewExpenses key.Binding - ViewRevenues key.Binding - ViewLiabilities key.Binding -} - -type RevenueKeyMap struct { - ShowFullHelp key.Binding - Quit key.Binding - Filter key.Binding - ResetFilter key.Binding - New key.Binding - Refresh key.Binding - Sort key.Binding - +type AccountKeyMap struct { + ShowFullHelp key.Binding + Quit key.Binding + Refresh key.Binding ViewTransactions key.Binding ViewAssets key.Binding ViewCategories key.Binding ViewExpenses key.Binding ViewRevenues key.Binding ViewLiabilities key.Binding + Filter key.Binding + ResetFilter key.Binding + Sort key.Binding + New key.Binding + Select key.Binding } type CategoryKeyMap struct { @@ -84,22 +50,6 @@ type CategoryKeyMap struct { ViewLiabilities key.Binding } -type LiabilityKeyMap struct { - ShowFullHelp key.Binding - Quit key.Binding - Filter key.Binding - ResetFilter key.Binding - New key.Binding - Refresh key.Binding - - ViewTransactions key.Binding - ViewAssets key.Binding - ViewCategories key.Binding - ViewExpenses key.Binding - ViewRevenues key.Binding - ViewLiabilities key.Binding -} - type TransactionFormKeyMap struct { Reset key.Binding Cancel key.Binding @@ -152,8 +102,8 @@ func DefaultUIKeyMap() UIKeyMap { } } -func DefaultAssetKeyMap() AssetKeyMap { - return AssetKeyMap{ +func DefaultAccountKeyMap() AccountKeyMap { + return AccountKeyMap{ ShowFullHelp: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "toggle help"), @@ -162,25 +112,9 @@ func DefaultAssetKeyMap() AssetKeyMap { key.WithKeys("q", "esc"), key.WithHelp("q/esc", "go back"), ), - Filter: key.NewBinding( - key.WithKeys("f"), - key.WithHelp("f", "filter by asset (press twice for exclusive)"), - ), - ResetFilter: key.NewBinding( - key.WithKeys("ctrl+a"), - key.WithHelp("ctrl+a", "reset filter"), - ), - Select: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select asset"), - ), - New: key.NewBinding( - key.WithKeys("n"), - key.WithHelp("n", "create new asset"), - ), Refresh: key.NewBinding( key.WithKeys("r"), - key.WithHelp("r", "refresh assets"), + key.WithHelp("r", "refresh list"), ), ViewTransactions: key.NewBinding( key.WithKeys("t"), @@ -189,7 +123,6 @@ func DefaultAssetKeyMap() AssetKeyMap { ViewAssets: key.NewBinding( key.WithKeys("a"), key.WithHelp("a", "view assets"), - key.WithDisabled(), ), ViewCategories: key.NewBinding( key.WithKeys("c"), @@ -207,121 +140,25 @@ func DefaultAssetKeyMap() AssetKeyMap { key.WithKeys("o"), key.WithHelp("o", "view liabilities"), ), - } -} - -func DefaultExpenseKeyMap() ExpenseKeyMap { - return ExpenseKeyMap{ - ShowFullHelp: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "esc"), - key.WithHelp("q/esc", "go back"), - ), - Filter: key.NewBinding( - key.WithKeys("f"), - key.WithHelp("f", "filter by expense (press twice for exclusive)"), - ), ResetFilter: key.NewBinding( key.WithKeys("ctrl+a"), key.WithHelp("ctrl+a", "reset filter"), ), - New: key.NewBinding( - key.WithKeys("n"), - key.WithHelp("n", "create new expense"), - ), - Refresh: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "refresh expenses"), - ), Sort: key.NewBinding( key.WithKeys("s"), key.WithHelp("s", "sort expenses"), ), - ViewTransactions: key.NewBinding( - key.WithKeys("t"), - key.WithHelp("t", "view transactions"), - ), - ViewAssets: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "view assets"), - ), - ViewCategories: key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "view categories"), - ), - ViewExpenses: key.NewBinding( - key.WithKeys("e"), - key.WithHelp("e", "view expenses"), - key.WithDisabled(), - ), - ViewRevenues: key.NewBinding( - key.WithKeys("i"), - key.WithHelp("i", "view revenues"), - ), - ViewLiabilities: key.NewBinding( - key.WithKeys("o"), - key.WithHelp("o", "view liabilities"), - ), - } -} - -func DefaultRevenueKeyMap() RevenueKeyMap { - return RevenueKeyMap{ - ShowFullHelp: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "esc"), - key.WithHelp("q/esc", "go back"), - ), - Filter: key.NewBinding( - key.WithKeys("f"), - key.WithHelp("f", "filter by revenue (press twice for exclusive)"), - ), - ResetFilter: key.NewBinding( - key.WithKeys("ctrl+a"), - key.WithHelp("ctrl+a", "reset filter"), - ), New: key.NewBinding( key.WithKeys("n"), - key.WithHelp("n", "create new revenue"), - ), - Refresh: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "refresh revenues"), - ), - Sort: key.NewBinding( - key.WithKeys("s"), - key.WithHelp("s", "sort revenues"), - ), - ViewTransactions: key.NewBinding( - key.WithKeys("t"), - key.WithHelp("t", "view transactions"), - ), - ViewAssets: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "view assets"), - ), - ViewCategories: key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "view categories"), - ), - ViewExpenses: key.NewBinding( - key.WithKeys("e"), - key.WithHelp("e", "view expenses"), + key.WithHelp("n", "create new account"), ), - ViewRevenues: key.NewBinding( - key.WithKeys("i"), - key.WithHelp("i", "view revenues"), - key.WithDisabled(), + Filter: key.NewBinding( + key.WithKeys("f"), + key.WithHelp("f", "filter by account (press twice for exclusive)"), ), - ViewLiabilities: key.NewBinding( - key.WithKeys("o"), - key.WithHelp("o", "view liabilities"), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select asset"), ), } } @@ -384,60 +221,6 @@ func DefaultCategoryKeyMap() CategoryKeyMap { } } -func DefaultLiabilityKeyMap() LiabilityKeyMap { - return LiabilityKeyMap{ - ShowFullHelp: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "esc"), - key.WithHelp("q/esc", "go back"), - ), - Filter: key.NewBinding( - key.WithKeys("f"), - key.WithHelp("f", "filter by liability (press twice for exclusive)"), - ), - ResetFilter: key.NewBinding( - key.WithKeys("ctrl+a"), - key.WithHelp("ctrl+a", "reset filter"), - ), - New: key.NewBinding( - key.WithKeys("n"), - key.WithHelp("n", "create new liability"), - ), - Refresh: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "refresh liabilities"), - ), - ViewTransactions: key.NewBinding( - key.WithKeys("t"), - key.WithHelp("t", "view transactions"), - ), - ViewAssets: key.NewBinding( - key.WithKeys("a"), - key.WithHelp("a", "view assets"), - ), - ViewCategories: key.NewBinding( - key.WithKeys("c"), - key.WithHelp("c", "view categories"), - ), - ViewExpenses: key.NewBinding( - key.WithKeys("e"), - key.WithHelp("e", "view expenses"), - ), - ViewRevenues: key.NewBinding( - key.WithKeys("i"), - key.WithHelp("i", "view revenues"), - ), - ViewLiabilities: key.NewBinding( - key.WithKeys("o"), - key.WithHelp("o", "view liabilities"), - key.WithDisabled(), - ), - } -} - func DefaultTransactionFormKeyMap() TransactionFormKeyMap { return TransactionFormKeyMap{ Reset: key.NewBinding( @@ -553,7 +336,7 @@ func (k UIKeyMap) ShortHelp() []key.Binding { } } -func (k AssetKeyMap) ShortHelp() []key.Binding { +func (k AccountKeyMap) ShortHelp() []key.Binding { return []key.Binding{ k.ShowFullHelp, k.Quit, @@ -565,30 +348,6 @@ func (k AssetKeyMap) ShortHelp() []key.Binding { } } -func (k ExpenseKeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.ShowFullHelp, - k.Quit, - k.Filter, - k.ResetFilter, - k.New, - k.Refresh, - k.Sort, - } -} - -func (k RevenueKeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.ShowFullHelp, - k.Quit, - k.Filter, - k.ResetFilter, - k.New, - k.Refresh, - k.Sort, - } -} - func (k CategoryKeyMap) ShortHelp() []key.Binding { return []key.Binding{ k.ShowFullHelp, @@ -601,17 +360,6 @@ func (k CategoryKeyMap) ShortHelp() []key.Binding { } } -func (k LiabilityKeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.ShowFullHelp, - k.Quit, - k.Filter, - k.ResetFilter, - k.New, - k.Refresh, - } -} - func (k TransactionsKeyMap) ShortHelp() []key.Binding { return []key.Binding{ k.ShowFullHelp, @@ -650,35 +398,7 @@ func (k UIKeyMap) FullHelp() [][]key.Binding { } } -func (k AssetKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - k.ShortHelp(), - { - k.ViewTransactions, - k.ViewAssets, - k.ViewCategories, - k.ViewExpenses, - k.ViewRevenues, - k.ViewLiabilities, - }, - } -} - -func (k ExpenseKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - k.ShortHelp(), - { - k.ViewTransactions, - k.ViewAssets, - k.ViewCategories, - k.ViewExpenses, - k.ViewRevenues, - k.ViewLiabilities, - }, - } -} - -func (k RevenueKeyMap) FullHelp() [][]key.Binding { +func (k AccountKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ k.ShortHelp(), { @@ -706,20 +426,6 @@ func (k CategoryKeyMap) FullHelp() [][]key.Binding { } } -func (k LiabilityKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - k.ShortHelp(), - { - k.ViewTransactions, - k.ViewAssets, - k.ViewCategories, - k.ViewExpenses, - k.ViewRevenues, - k.ViewLiabilities, - }, - } -} - func (k TransactionsKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ k.ShortHelp(), diff --git a/internal/ui/liabilities.go b/internal/ui/liabilities.go index 7e1a06c..adb49a6 100644 --- a/internal/ui/liabilities.go +++ b/internal/ui/liabilities.go @@ -13,7 +13,6 @@ import ( "ffiii-tui/internal/ui/notify" "ffiii-tui/internal/ui/prompt" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) @@ -23,163 +22,95 @@ var promptValue string type ( RefreshLiabilitiesMsg struct{} LiabilitiesUpdateMsg struct{} + NewLiabilityMsg struct { + Account string + Currency string + Type string + Direction string + } ) -type NewLiabilityMsg struct { - Account string - Currency string - Type string - Direction string -} - -type liabilityItem struct { - account firefly.Account - balance float64 -} - -func (i liabilityItem) Title() string { return i.account.Name } -func (i liabilityItem) Description() string { - return fmt.Sprintf("Balance: %.2f %s", i.balance, i.account.CurrencyCode) -} -func (i liabilityItem) FilterValue() string { return i.account.Name } +type liabilityItem = accountListItem[firefly.Account] type modelLiabilities struct { - list list.Model - api LiabilityAPI - focus bool - keymap LiabilityKeyMap - styles Styles + AccountListModel[firefly.Account] } func newModelLiabilities(api LiabilityAPI) modelLiabilities { - items := getLiabilitiesItems(api) - - m := modelLiabilities{ - list: list.New(items, list.NewDefaultDelegate(), 0, 0), - api: api, - keymap: DefaultLiabilityKeyMap(), - styles: DefaultStyles(), + config := &AccountListConfig[firefly.Account]{ + AccountType: "liability", + Title: "Liabilities", + GetItems: func(apiInterface any, sorted bool) []list.Item { + return getLiabilitiesItems(apiInterface.(LiabilityAPI)) + }, + RefreshItems: func(apiInterface any, accountType string) error { + return apiInterface.(LiabilityAPI).UpdateAccounts(accountType) + }, + RefreshMsgType: RefreshLiabilitiesMsg{}, + UpdateMsgType: LiabilitiesUpdateMsg{}, + PromptNewFunc: func() tea.Cmd { + return CmdPromptNewLiability(SetView(liabilitiesView)) + }, + HasSort: false, + HasTotalRow: false, + FilterFunc: func(item list.Item) tea.Cmd { + i, ok := item.(liabilityItem) + if ok { + return Cmd(FilterMsg{Account: i.Entity}) + } + return nil + }, + SelectFunc: func(item list.Item) tea.Cmd { + var cmds []tea.Cmd + i, ok := item.(liabilityItem) + if ok { + cmds = append(cmds, Cmd(FilterMsg{Account: i.Entity})) + } + cmds = append(cmds, SetView(transactionsView)) + return tea.Sequence(cmds...) + }, + } + return modelLiabilities{ + AccountListModel: NewAccountListModel[firefly.Account](api, config), } - m.list.Title = "Liabilities" - m.list.SetShowStatusBar(false) - m.list.SetFilteringEnabled(false) - m.list.SetShowHelp(false) - m.list.DisableQuitKeybindings() - - return m } func (m modelLiabilities) Init() tea.Cmd { - return nil + return m.AccountListModel.Init() } func (m modelLiabilities) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - switch msg := msg.(type) { - case RefreshLiabilitiesMsg: - return m, func() tea.Msg { - err := m.api.UpdateAccounts("liabilities") - if err != nil { - return notify.NotifyWarn(err.Error())() - } - return LiabilitiesUpdateMsg{} - } - case LiabilitiesUpdateMsg: - return m, tea.Batch( - m.list.SetItems(getLiabilitiesItems(m.api)), - Cmd(DataLoadCompletedMsg{DataType: "liabilities"}), - ) - case NewLiabilityMsg: - err := m.api.CreateLiabilityAccount(firefly.NewLiability{ - Name: msg.Account, - CurrencyCode: msg.Currency, - Type: msg.Type, - Direction: msg.Direction, - }) + if newMsg, ok := msg.(NewLiabilityMsg); ok { + api := m.api.(LiabilityAPI) + err := api.CreateLiabilityAccount( + firefly.NewLiability{ + Name: newMsg.Account, + CurrencyCode: newMsg.Currency, + Type: newMsg.Type, + Direction: newMsg.Direction, + }) if err != nil { return m, notify.NotifyWarn(err.Error()) } - promptValue = "" return m, tea.Batch( Cmd(RefreshLiabilitiesMsg{}), - notify.NotifyLog(fmt.Sprintf("Liability account '%s' created", msg.Account)), + notify.NotifyLog(fmt.Sprintf("Liability account '%s' created", newMsg.Account)), ) - case UpdatePositions: - if msg.layout != nil { - h, v := m.styles.Base.GetFrameSize() - m.list.SetSize( - msg.layout.Width-h, - msg.layout.Height-v-msg.layout.TopSize, - ) - } - } - - if !m.focus { - return m, nil } - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, m.keymap.Quit): - return m, SetView(transactionsView) - case key.Matches(msg, m.keymap.Filter): - i, ok := m.list.SelectedItem().(liabilityItem) - if ok { - return m, Cmd(FilterMsg{Account: i.account}) - } - return m, nil - case key.Matches(msg, m.keymap.New): - return m, CmdPromptNewLiability(SetView(liabilitiesView)) - case key.Matches(msg, m.keymap.Refresh): - return m, Cmd(RefreshLiabilitiesMsg{}) - case key.Matches(msg, m.keymap.ResetFilter): - return m, Cmd(FilterMsg{Reset: true}) - - case key.Matches(msg, m.keymap.ViewTransactions): - return m, SetView(transactionsView) - case key.Matches(msg, m.keymap.ViewAssets): - return m, SetView(assetsView) - case key.Matches(msg, m.keymap.ViewCategories): - return m, SetView(categoriesView) - case key.Matches(msg, m.keymap.ViewExpenses): - return m, SetView(expensesView) - case key.Matches(msg, m.keymap.ViewRevenues): - return m, SetView(revenuesView) - case key.Matches(msg, m.keymap.ViewLiabilities): - return m, SetView(liabilitiesView) - - } - } - - m.list, cmd = m.list.Update(msg) + updated, cmd := m.AccountListModel.Update(msg) + m.AccountListModel = updated.(AccountListModel[firefly.Account]) return m, cmd } -func (m modelLiabilities) View() string { - return m.styles.LeftPanel.Render(m.list.View()) -} - -func (m *modelLiabilities) Focus() { - m.list.FilterInput.Focus() - m.focus = true -} - -func (m *modelLiabilities) Blur() { - m.list.FilterInput.Blur() - m.focus = false -} - func getLiabilitiesItems(api AccountsAPI) []list.Item { items := []list.Item{} for _, account := range api.AccountsByType("liabilities") { - items = append(items, liabilityItem{ - account: account, - balance: api.AccountBalance(account.ID), - }) + items = append(items, newAccountListItem( + account, + "Balance", + api.AccountBalance(account.ID), + )) } - return items } diff --git a/internal/ui/liabilities_test.go b/internal/ui/liabilities_test.go index b70bcce..0dd65ec 100644 --- a/internal/ui/liabilities_test.go +++ b/internal/ui/liabilities_test.go @@ -108,44 +108,25 @@ func TestGetLiabilitiesItems_UsesAccountBalanceAPI(t *testing.T) { if !ok { t.Fatalf("expected item type liabilityItem, got %T", items[0]) } - if first.account.ID != "l1" { - t.Errorf("expected first account ID 'l1', got %q", first.account.ID) + if first.Entity.ID != "l1" { + t.Errorf("expected first account ID 'l1', got %q", first.Entity.ID) } - if first.balance != -250000.00 { - t.Errorf("expected first balance -250000.00, got %.2f", first.balance) + if first.PrimaryVal != -250000.00 { + t.Errorf("expected first balance -250000.00, got %.2f", first.PrimaryVal) } second, ok := items[1].(liabilityItem) if !ok { t.Fatalf("expected item type liabilityItem, got %T", items[1]) } - if second.account.ID != "l2" { - t.Errorf("expected second account ID 'l2', got %q", second.account.ID) + if second.Entity.ID != "l2" { + t.Errorf("expected second account ID 'l2', got %q", second.Entity.ID) } - if second.balance != -5000.50 { - t.Errorf("expected second balance -5000.50, got %.2f", second.balance) + if second.PrimaryVal != -5000.50 { + t.Errorf("expected second balance -5000.50, got %.2f", second.PrimaryVal) } } -func TestLiabilityItem_Methods(t *testing.T) { - acc := firefly.Account{ID: "l1", Name: "Mortgage", CurrencyCode: "USD", Type: "liabilities"} - item := liabilityItem{ - account: acc, - balance: -250000.00, - } - - if item.Title() != acc.Name { - t.Errorf("expected title %q, got %q", acc.Name, item.Title()) - } - if item.FilterValue() != acc.Name { - t.Errorf("expected filter value %q, got %q", acc.Name, item.FilterValue()) - } - - expectedDesc := "Balance: -250000.00 USD" - if item.Description() != expectedDesc { - t.Errorf("expected description %q, got %q", expectedDesc, item.Description()) - } -} func TestNewModelLiabilities_InitializesCorrectly(t *testing.T) { api := &mockLiabilityAPI{ @@ -193,7 +174,7 @@ func TestRefreshLiabilitiesMsg_Success(t *testing.T) { if len(api.updateAccountsCalledWith) != 1 { t.Fatalf("expected UpdateAccounts to be called once, got %d", len(api.updateAccountsCalledWith)) } - if api.updateAccountsCalledWith[0] != "liabilities" { + if api.updateAccountsCalledWith[0] != "liability" { t.Errorf("expected UpdateAccounts called with 'liabilities', got %q", api.updateAccountsCalledWith[0]) } } @@ -254,7 +235,7 @@ func TestLiabilitiesUpdateMsg_SetsItems(t *testing.T) { foundDataLoadMsg := false for _, msg := range msgs { if dlMsg, ok := msg.(DataLoadCompletedMsg); ok { - if dlMsg.DataType != "liabilities" { + if dlMsg.DataType != "liability" { t.Errorf("expected DataType 'liabilities', got %q", dlMsg.DataType) } foundDataLoadMsg = true @@ -273,11 +254,11 @@ func TestLiabilitiesUpdateMsg_SetsItems(t *testing.T) { if !ok { t.Fatalf("expected first item to be liabilityItem, got %T", listItems[0]) } - if item.account.Name != "Mortgage" { - t.Errorf("expected item name 'Mortgage', got %q", item.account.Name) + if item.Entity.Name != "Mortgage" { + t.Errorf("expected item name 'Mortgage', got %q", item.Entity.Name) } - if item.balance != -250000.0 { - t.Errorf("expected balance -250000.0, got %.2f", item.balance) + if item.PrimaryVal != -250000.0 { + t.Errorf("expected balance -250000.0, got %.2f", item.PrimaryVal) } } @@ -394,6 +375,7 @@ func TestUpdatePositions_SetsListSize(t *testing.T) { Width: globalWidth, Height: globalHeight, TopSize: topSize, + SummarySize: 10, }, }) m2 := updated.(modelLiabilities) @@ -539,7 +521,7 @@ func TestLiabilities_KeyPresses_NavigateToCorrectViews(t *testing.T) { {"categories", 'c', categoriesView, false, 1}, {"expenses", 'e', expensesView, false, 1}, {"transactions", 't', transactionsView, false, 1}, - {"liabilities (self)", 'o', liabilitiesView, true, 0}, + {"liabilities (self)", 'o', liabilitiesView, false, 1}, {"revenues", 'i', revenuesView, false, 1}, {"quit to transactions", 'q', transactionsView, false, 1}, } @@ -790,8 +772,8 @@ func TestModelLiabilities_LargeBalance(t *testing.T) { } item := items[0].(liabilityItem) - if item.balance != -999999999.99 { - t.Errorf("expected large balance -999999999.99, got %.2f", item.balance) + if item.PrimaryVal != -999999999.99 { + t.Errorf("expected large balance -999999999.99, got %.2f", item.PrimaryVal) } } @@ -811,8 +793,8 @@ func TestModelLiabilities_PositiveBalance(t *testing.T) { } item := items[0].(liabilityItem) - if item.balance != 100.0 { - t.Errorf("expected positive balance 100.0, got %.2f", item.balance) + if item.PrimaryVal != 100.0 { + t.Errorf("expected positive balance 100.0, got %.2f", item.PrimaryVal) } } diff --git a/internal/ui/revenues.go b/internal/ui/revenues.go index dcd232c..8bdc61e 100644 --- a/internal/ui/revenues.go +++ b/internal/ui/revenues.go @@ -12,13 +12,10 @@ import ( "ffiii-tui/internal/ui/notify" "ffiii-tui/internal/ui/prompt" - "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) -var totalRevenueAccount = firefly.Account{Name: "Total", CurrencyCode: ""} - type ( RefreshRevenuesMsg struct{} RefreshRevenueInsightsMsg struct{} @@ -28,161 +25,86 @@ type ( } ) -type revenueItem struct { - account firefly.Account - earned float64 -} - -func (i revenueItem) Title() string { return i.account.Name } -func (i revenueItem) Description() string { - return fmt.Sprintf("Earned: %.2f %s", i.earned, i.account.CurrencyCode) -} -func (i revenueItem) FilterValue() string { return i.account.Name } +type revenueItem = accountListItem[firefly.Account] type modelRevenues struct { - list list.Model - api RevenueAPI - focus bool - sorted bool - keymap RevenueKeyMap - styles Styles + AccountListModel[firefly.Account] } func newModelRevenues(api RevenueAPI) modelRevenues { - // Set total revenue account currency - totalRevenueAccount.CurrencyCode = api.PrimaryCurrency().Code - - items := getRevenuesItems(api, false) - - m := modelRevenues{ - list: list.New(items, list.NewDefaultDelegate(), 0, 0), - api: api, - keymap: DefaultRevenueKeyMap(), - styles: DefaultStyles(), + config := &AccountListConfig[firefly.Account]{ + AccountType: "revenue", + Title: "Revenue accounts", + GetItems: func(apiInterface any, sorted bool) []list.Item { + return getRevenuesItems(apiInterface.(RevenueAPI), sorted) + }, + RefreshItems: func(apiInterface any, accountType string) error { + return apiInterface.(RevenueAPI).UpdateAccounts(accountType) + }, + RefreshMsgType: RefreshRevenuesMsg{}, + UpdateMsgType: RevenuesUpdateMsg{}, + PromptNewFunc: func() tea.Cmd { + return CmdPromptNewRevenue(SetView(revenuesView)) + }, + HasSort: true, + HasTotalRow: true, + GetTotalFunc: func(api any) float64 { + return api.(RevenueAPI).GetTotalRevenueDiff() + }, + FilterFunc: func(item list.Item) tea.Cmd { + i, ok := item.(revenueItem) + if ok { + return Cmd(FilterMsg{Account: i.Entity}) + } + return nil + }, + SelectFunc: func(item list.Item) tea.Cmd { + var cmds []tea.Cmd + i, ok := item.(revenueItem) + if ok { + cmds = append(cmds, Cmd(FilterMsg{Account: i.Entity})) + } + cmds = append(cmds, SetView(transactionsView)) + return tea.Sequence(cmds...) + }, + } + return modelRevenues{ + AccountListModel: NewAccountListModel[firefly.Account](api, config), } - m.list.Title = "Revenue accounts" - m.list.SetFilteringEnabled(false) - m.list.SetShowStatusBar(false) - m.list.SetShowHelp(false) - m.list.DisableQuitKeybindings() - - return m } func (m modelRevenues) Init() tea.Cmd { - return nil + return m.AccountListModel.Init() } func (m modelRevenues) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case RefreshRevenueInsightsMsg: - return m, func() tea.Msg { - err := m.api.UpdateRevenueInsights() - if err != nil { - return notify.NotifyWarn(err.Error())() - } - return RevenuesUpdateMsg{} - } - case RefreshRevenuesMsg: - return m, func() tea.Msg { - err := m.api.UpdateAccounts("revenue") - if err != nil { - return notify.NotifyWarn(err.Error())() - } - return RevenuesUpdateMsg{} - } - case RevenuesUpdateMsg: - return m, tea.Sequence( - m.list.SetItems(getRevenuesItems(m.api, m.sorted)), - m.list.InsertItem(0, revenueItem{ - account: totalRevenueAccount, - earned: m.api.GetTotalRevenueDiff(), - }), - Cmd(DataLoadCompletedMsg{DataType: "revenues"}), - ) - case NewRevenueMsg: - err := m.api.CreateRevenueAccount(msg.Account) + if newMsg, ok := msg.(NewRevenueMsg); ok { + api := m.api.(RevenueAPI) + err := api.CreateRevenueAccount(newMsg.Account) if err != nil { return m, notify.NotifyWarn(err.Error()) } return m, tea.Batch( Cmd(RefreshRevenuesMsg{}), - notify.NotifyLog(fmt.Sprintf("Revenue account '%s' created", msg.Account)), + notify.NotifyLog(fmt.Sprintf("Revenue account '%s' created", newMsg.Account)), ) - case UpdatePositions: - if msg.layout != nil { - h, v := m.styles.Base.GetFrameSize() - m.list.SetSize( - msg.layout.Width-h, - msg.layout.Height-v-msg.layout.TopSize, - ) - } - } - - if !m.focus { - return m, nil } - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.KeyMsg: - switch { - case key.Matches(msg, m.keymap.Quit): - return m, SetView(transactionsView) - case key.Matches(msg, m.keymap.New): - return m, CmdPromptNewRevenue(SetView(revenuesView)) - case key.Matches(msg, m.keymap.Filter): - i, ok := m.list.SelectedItem().(revenueItem) - if ok { - if i.account == totalRevenueAccount { - return m, nil - } - return m, Cmd(FilterMsg{Account: i.account}) + switch msg.(type) { + case RefreshRevenueInsightsMsg: + return m, func() tea.Msg { + err := m.api.(RevenueAPI).UpdateRevenueInsights() + if err != nil { + return notify.NotifyWarn(err.Error())() } - return m, nil - case key.Matches(msg, m.keymap.Refresh): - return m, Cmd(RefreshRevenuesMsg{}) - case key.Matches(msg, m.keymap.Sort): - m.sorted = !m.sorted - return m, Cmd(RevenuesUpdateMsg{}) - case key.Matches(msg, m.keymap.ResetFilter): - return m, Cmd(FilterMsg{Reset: true}) - case key.Matches(msg, m.keymap.ViewTransactions): - return m, SetView(transactionsView) - case key.Matches(msg, m.keymap.ViewAssets): - return m, SetView(assetsView) - case key.Matches(msg, m.keymap.ViewCategories): - return m, SetView(categoriesView) - case key.Matches(msg, m.keymap.ViewExpenses): - return m, SetView(expensesView) - case key.Matches(msg, m.keymap.ViewRevenues): - return m, SetView(revenuesView) - case key.Matches(msg, m.keymap.ViewLiabilities): - return m, SetView(liabilitiesView) - // case "R": - // return m, Cmd(RefreshRevenuesMsg{}) + return RevenuesUpdateMsg{} } } - - m.list, cmd = m.list.Update(msg) + updated, cmd := m.AccountListModel.Update(msg) + m.AccountListModel = updated.(AccountListModel[firefly.Account]) return m, cmd } -func (m modelRevenues) View() string { - return m.styles.LeftPanel.Render(m.list.View()) -} - -func (m *modelRevenues) Focus() { - m.list.FilterInput.Focus() - m.focus = true -} - -func (m *modelRevenues) Blur() { - m.list.FilterInput.Blur() - m.focus = false -} - func getRevenuesItems(api RevenueAPI, sorted bool) []list.Item { items := []list.Item{} for _, account := range api.AccountsByType("revenue") { @@ -190,14 +112,15 @@ func getRevenuesItems(api RevenueAPI, sorted bool) []list.Item { if sorted && earned == 0 { continue } - items = append(items, revenueItem{ - account: account, - earned: earned, - }) + items = append(items, newAccountListItem( + account, + "Earned", + earned, + )) } if sorted { slices.SortFunc(items, func(a, b list.Item) int { - return int(b.(revenueItem).earned) - int(a.(revenueItem).earned) + return int(b.(revenueItem).PrimaryVal) - int(a.(revenueItem).PrimaryVal) }) } return items diff --git a/internal/ui/revenues_test.go b/internal/ui/revenues_test.go index faca6d3..4df65e7 100644 --- a/internal/ui/revenues_test.go +++ b/internal/ui/revenues_test.go @@ -145,11 +145,11 @@ func TestGetRevenuesItems_UsesRevenueDiffAPI(t *testing.T) { if !ok { t.Fatalf("expected item type revenueItem, got %T", items[0]) } - if first.account.ID != "r1" { - t.Errorf("expected first account ID 'r1', got %q", first.account.ID) + if first.Entity.ID != "r1" { + t.Errorf("expected first account ID 'r1', got %q", first.Entity.ID) } - if first.earned != 5000.50 { - t.Errorf("expected first earned 5000.50, got %v", first.earned) + if first.PrimaryVal != 5000.50 { + t.Errorf("expected first earned 5000.50, got %v", first.PrimaryVal) } if first.Description() != "Earned: 5000.50 USD" { t.Errorf("unexpected description: %q", first.Description()) @@ -190,34 +190,16 @@ func TestGetRevenuesItems_SortedFiltersZeroAndSorts(t *testing.T) { } first := items[0].(revenueItem) - if first.account.Name != "High" { - t.Errorf("expected first item 'High', got %q", first.account.Name) + if first.Entity.Name != "High" { + t.Errorf("expected first item 'High', got %q", first.Entity.Name) } - if first.earned != 5000 { - t.Errorf("expected first earned 5000, got %v", first.earned) + if first.PrimaryVal != 5000 { + t.Errorf("expected first earned 5000, got %v", first.PrimaryVal) } second := items[1].(revenueItem) - if second.account.Name != "Low" { - t.Errorf("expected second item 'Low', got %q", second.account.Name) - } -} - -func TestNewModelRevenues_SetsPrimaryCurrency(t *testing.T) { - api := &mockRevenueAPI{ - accountsByTypeFunc: func(accountType string) []firefly.Account { - return []firefly.Account{} - }, - primaryCurrencyFunc: func() firefly.Currency { - return firefly.Currency{Code: "EUR", Symbol: "€"} - }, - } - - _ = newModelRevenues(api) - - // Verify totalRevenueAccount got the primary currency - if totalRevenueAccount.CurrencyCode != "EUR" { - t.Errorf("expected totalRevenueAccount currency 'EUR', got %q", totalRevenueAccount.CurrencyCode) + if second.Entity.Name != "Low" { + t.Errorf("expected second item 'Low', got %q", second.Entity.Name) } } @@ -446,7 +428,7 @@ func TestModelRevenues_RevenuesUpdate_EmitsDataLoadCompleted(t *testing.T) { if !ok { t.Fatalf("expected DataLoadCompletedMsg, got %T", msgs[0]) } - if loader.DataType != "revenues" { + if loader.DataType != "revenue" { t.Fatalf("expected DataType 'revenues', got %q", loader.DataType) } @@ -463,11 +445,11 @@ func TestModelRevenues_RevenuesUpdate_EmitsDataLoadCompleted(t *testing.T) { if !ok { t.Fatalf("expected first item to be revenueItem, got %T", listItems[0]) } - if totalItem.account.Name != "Total" { - t.Errorf("expected first item name 'Total', got %q", totalItem.account.Name) + if totalItem.Entity.Name != "Total" { + t.Errorf("expected first item name 'Total', got %q", totalItem.Entity.Name) } - if totalItem.earned != 2500 { - t.Errorf("expected total earned 2500, got %v", totalItem.earned) + if totalItem.PrimaryVal != 2500 { + t.Errorf("expected total earned 2500, got %v", totalItem.PrimaryVal) } } @@ -488,9 +470,10 @@ func TestModelRevenues_UpdatePositions_SetsListSize(t *testing.T) { updated, _ := m.Update(UpdatePositions{ layout: &LayoutConfig{ - Width: globalWidth, - Height: globalHeight, - TopSize: topSize, + Width: globalWidth, + Height: globalHeight, + TopSize: topSize, + SummarySize: 10, }, }) m2 := updated.(modelRevenues) @@ -714,7 +697,7 @@ func TestModelRevenues_KeyViewNavigation(t *testing.T) { {"expenses", 'e', expensesView, false, 1}, {"transactions", 't', transactionsView, false, 1}, {"liabilities", 'o', liabilitiesView, false, 1}, - {"revenues (self)", 'i', revenuesView, true, 0}, + {"revenues (self)", 'i', revenuesView, false, 1}, {"quit to transactions", 'q', transactionsView, false, 1}, } @@ -904,8 +887,8 @@ func TestModelRevenues_EarnedBoundaryValues(t *testing.T) { t.Fatalf("expected revenueItem, got %T", items[0]) } - if item.earned != tt.earned { - t.Errorf("expected earned %v, got %v", tt.earned, item.earned) + if item.PrimaryVal != tt.earned { + t.Errorf("expected earned %v, got %v", tt.earned, item.PrimaryVal) } if item.Description() != tt.expectedDisplay { @@ -951,8 +934,8 @@ func TestModelRevenues_AccountNameEdgeCases(t *testing.T) { t.Fatalf("expected revenueItem, got %T", items[0]) } - if item.account.Name != tt.accountName { - t.Errorf("expected name %q, got %q", tt.accountName, item.account.Name) + if item.Entity.Name != tt.accountName { + t.Errorf("expected name %q, got %q", tt.accountName, item.Entity.Name) } title := item.Title() diff --git a/internal/ui/ui.go b/internal/ui/ui.go index b15426d..014d300 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -109,11 +109,11 @@ func NewModelUI(api UIAPI) modelUI { Width: 80, layout: lc, loadStatus: map[string]bool{ - "assets": false, - "expenses": false, - "revenues": false, - "liabilities": false, - "categories": false, + "asset": false, + "expense": false, + "revenue": false, + "liability": false, + "categories": false, }, } @@ -281,11 +281,11 @@ func (m modelUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Cmd(RefreshSummaryMsg{})) case RefreshAllMsg: m.loadStatus = map[string]bool{ - "assets": false, - "expenses": false, - "revenues": false, - "liabilities": false, - "categories": false, + "asset": false, + "expense": false, + "revenue": false, + "liability": false, + "categories": false, } return m, tea.Batch( SetView(transactionsView), @@ -443,14 +443,14 @@ func (m *modelUI) HelpView() string { help += m.help.View(m.transactions.keymap) case assetsView: help += m.help.View(m.assets.keymap) - case categoriesView: - help += m.help.View(m.categories.keymap) case expensesView: help += m.help.View(m.expenses.keymap) case revenuesView: help += m.help.View(m.revenues.keymap) case liabilitiesView: help += m.help.View(m.liabilities.keymap) + case categoriesView: + help += m.help.View(m.categories.keymap) case newView: help += m.help.View(m.new.keymap) }