From e78dfe0882d553b865fdbd08e5ed02c80c9e8512 Mon Sep 17 00:00:00 2001 From: Artur Taranchiev Date: Thu, 12 Feb 2026 23:09:05 +0600 Subject: [PATCH] adding date-picker --- internal/firefly/firefly.go | 5 + internal/ui/api.go | 1 + internal/ui/keymap.go | 19 +- internal/ui/period/period.go | 268 ++++++++++++++++++ internal/ui/period/period_test.go | 442 ++++++++++++++++++++++++++++++ internal/ui/period/styles.go | 29 ++ internal/ui/ui.go | 46 ++-- internal/ui/ui_test.go | 94 ++++--- 8 files changed, 836 insertions(+), 68 deletions(-) create mode 100644 internal/ui/period/period.go create mode 100644 internal/ui/period/period_test.go create mode 100644 internal/ui/period/styles.go diff --git a/internal/firefly/firefly.go b/internal/firefly/firefly.go index 3c134ee..af6a9e6 100644 --- a/internal/firefly/firefly.go +++ b/internal/firefly/firefly.go @@ -86,6 +86,11 @@ func (api *Api) NextPeriod() { api.EndDate = api.StartDate.AddDate(0, 1, 0).Add(-time.Nanosecond) } +func (api *Api) SetPeriod(year int, month time.Month) { + api.StartDate = time.Date(year, month, 1, 0, 0, 0, 0, api.StartDate.Location()) + api.EndDate = api.StartDate.AddDate(0, 1, 0).Add(-time.Nanosecond) +} + func (api *Api) TimeoutSeconds() int { return api.Config.TimeoutSeconds } diff --git a/internal/ui/api.go b/internal/ui/api.go index bc14334..e83e24e 100644 --- a/internal/ui/api.go +++ b/internal/ui/api.go @@ -17,6 +17,7 @@ import ( type PeriodAPI interface { PreviousPeriod() NextPeriod() + SetPeriod(year int, month time.Month) } // CurrencyAPI provides access to currency configuration used in UI. diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 87761ff..c7765d2 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -12,8 +12,7 @@ type UIKeyMap struct { Quit key.Binding ShowShortHelp key.Binding - PreviousPeriod key.Binding - NextPeriod key.Binding + PeriodPicker key.Binding } type AccountKeyMap struct { @@ -92,13 +91,9 @@ func DefaultUIKeyMap() UIKeyMap { key.WithKeys("?"), key.WithHelp("?", "toggle help"), ), - PreviousPeriod: key.NewBinding( - key.WithKeys("["), - key.WithHelp("[", "previous period"), - ), - NextPeriod: key.NewBinding( - key.WithKeys("]"), - key.WithHelp("]", "next period"), + PeriodPicker: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "period picker"), ), } } @@ -336,8 +331,7 @@ func (k UIKeyMap) ShortHelp() []key.Binding { return []key.Binding{ k.ShowShortHelp, k.Quit, - k.PreviousPeriod, - k.NextPeriod, + k.PeriodPicker, } } @@ -399,8 +393,7 @@ func (k TransactionFormKeyMap) ShortHelp() []key.Binding { func (k UIKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ { - k.PreviousPeriod, - k.NextPeriod, + k.PeriodPicker, }, } } diff --git a/internal/ui/period/period.go b/internal/ui/period/period.go new file mode 100644 index 0000000..cfa2bc5 --- /dev/null +++ b/internal/ui/period/period.go @@ -0,0 +1,268 @@ +/* +Copyright © 2025-2026 Artur Taranchiev +SPDX-License-Identifier: Apache-2.0 +*/ +package period + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + yearsRange = 5 +) + +type OpenMsg struct { + Year int + Month time.Month +} + +type SelectedMsg struct { + Year int + Month time.Month +} + +type CloseMsg struct{} + +type monthEntry struct { + year int + month time.Month +} + +func (e monthEntry) label() string { + return fmt.Sprintf("%s %d", e.month.String(), e.year) +} + +type Model struct { + items []monthEntry + cursor int + current int + focus bool + styles Styles + Width int +} + +func New() Model { + return Model{ + styles: DefaultStyles(), + Width: 80, + } +} + +func generateItems(year int, month time.Month) []monthEntry { + start := time.Date(year-yearsRange, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(year+yearsRange, 12, 1, 0, 0, 0, 0, time.UTC) + + var items []monthEntry + for d := start; !d.After(end); d = d.AddDate(0, 1, 0) { + items = append(items, monthEntry{ + year: d.Year(), + month: d.Month(), + }) + } + return items +} + +func findIndex(items []monthEntry, year int, month time.Month) int { + for i, e := range items { + if e.year == year && e.month == month { + return i + } + } + return 0 +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case OpenMsg: + m.items = generateItems(msg.Year, msg.Month) + idx := findIndex(m.items, msg.Year, msg.Month) + m.cursor = idx + m.current = idx + m.Focus() + return m, nil + case CloseMsg: + m.Blur() + return m, nil + } + + if !m.focus { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "left", "h", "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "right", "l", "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case "enter": + selected := m.items[m.cursor] + m.Blur() + return m, func() tea.Msg { + return SelectedMsg{ + Year: selected.year, + Month: selected.month, + } + } + case "esc": + m.Blur() + return m, func() tea.Msg { + return CloseMsg{} + } + } + } + + return m, nil +} + +func (m Model) View() string { + if !m.focus || len(m.items) == 0 { + return "" + } + + prefix := " Period: " + separator := " | " + arrowLeft := "<< " + arrowRight := " >>" + borderOverhead := 4 + + available := m.Width - borderOverhead - lipgloss.Width(prefix) - + lipgloss.Width(arrowLeft) - lipgloss.Width(arrowRight) + + visibleLabels := m.buildVisibleLabels(available, separator) + + var line strings.Builder + line.WriteString(prefix) + + if m.cursor > 0 { + line.WriteString(arrowLeft) + } else { + line.WriteString(" ") + } + + line.WriteString(visibleLabels) + + if m.cursor < len(m.items)-1 { + line.WriteString(arrowRight) + } else { + line.WriteString(" ") + } + + return m.styles.Border.Width(m.Width).Render(line.String()) +} + +func (m Model) buildVisibleLabels(maxWidth int, separator string) string { + sepWidth := lipgloss.Width(separator) + + cursorLabel := m.renderLabel(m.cursor) + cursorWidth := lipgloss.Width(cursorLabel) + remaining := maxWidth - cursorWidth + + var leftLabels []string + var rightLabels []string + li := m.cursor - 1 + ri := m.cursor + 1 + + for remaining > 0 { + addedAny := false + + if li >= 0 { + label := m.renderLabel(li) + w := lipgloss.Width(label) + sepWidth + if w <= remaining { + leftLabels = append(leftLabels, label) + remaining -= w + li-- + addedAny = true + } else { + li = -1 + } + } + + if ri < len(m.items) { + label := m.renderLabel(ri) + w := lipgloss.Width(label) + sepWidth + if w <= remaining { + rightLabels = append(rightLabels, label) + remaining -= w + ri++ + addedAny = true + } else { + ri = len(m.items) + } + } + + if !addedAny { + break + } + } + + var parts []string + for i := len(leftLabels) - 1; i >= 0; i-- { + parts = append(parts, leftLabels[i]) + } + parts = append(parts, cursorLabel) + parts = append(parts, rightLabels...) + + return strings.Join(parts, separator) +} + +func (m Model) renderLabel(i int) string { + entry := m.items[i] + label := entry.label() + + switch i { + case m.cursor: + return m.styles.Selected.Render("> " + label + " <") + case m.current: + return m.styles.Current.Render(label) + default: + return m.styles.Item.Render(label) + } +} + +func (m *Model) Focus() { + m.focus = true +} + +func (m *Model) Blur() { + m.focus = false +} + +func (m *Model) Focused() bool { + return m.focus +} + +func (m *Model) WithWidth(width int) *Model { + m.Width = width + return m +} + +func (m *Model) WithStyles(styles Styles) *Model { + m.styles = styles + return m +} + +func Open(year int, month time.Month) tea.Cmd { + return func() tea.Msg { + return OpenMsg{ + Year: year, + Month: month, + } + } +} diff --git a/internal/ui/period/period_test.go b/internal/ui/period/period_test.go new file mode 100644 index 0000000..4f261e9 --- /dev/null +++ b/internal/ui/period/period_test.go @@ -0,0 +1,442 @@ +/* +Copyright © 2025-2026 Artur Taranchiev +SPDX-License-Identifier: Apache-2.0 +*/ +package period + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func TestNew(t *testing.T) { + m := New() + + if m.Width != 80 { + t.Errorf("Expected default width 80, got %d", m.Width) + } + if m.focus { + t.Error("Expected new model to be unfocused") + } + if len(m.items) != 0 { + t.Errorf("Expected empty items, got %d", len(m.items)) + } +} + +func TestGenerateItems(t *testing.T) { + items := generateItems(2026, time.February) + + expectedStart := monthEntry{year: 2021, month: time.January} + expectedEnd := monthEntry{year: 2031, month: time.December} + + if len(items) == 0 { + t.Fatal("Expected non-empty items") + } + if items[0] != expectedStart { + t.Errorf("Expected first item %v, got %v", expectedStart, items[0]) + } + if items[len(items)-1] != expectedEnd { + t.Errorf("Expected last item %v, got %v", expectedEnd, items[len(items)-1]) + } + + expectedCount := (yearsRange*2 + 1) * 12 + if len(items) != expectedCount { + t.Errorf("Expected %d items, got %d", expectedCount, len(items)) + } + + for i := 1; i < len(items); i++ { + prev := time.Date(items[i-1].year, items[i-1].month, 1, 0, 0, 0, 0, time.UTC) + curr := time.Date(items[i].year, items[i].month, 1, 0, 0, 0, 0, time.UTC) + if !curr.After(prev) { + t.Errorf("Items not in order at index %d: %v >= %v", i, prev, curr) + } + } +} + +func TestFindIndex(t *testing.T) { + items := generateItems(2026, time.February) + + idx := findIndex(items, 2026, time.February) + if items[idx].year != 2026 || items[idx].month != time.February { + t.Errorf("Expected 2026-Feb at index %d, got %v", idx, items[idx]) + } + + idx = findIndex(items, 2021, time.January) + if idx != 0 { + t.Errorf("Expected index 0 for first item, got %d", idx) + } + + idx = findIndex(items, 1900, time.January) + if idx != 0 { + t.Errorf("Expected index 0 for non-existing item, got %d", idx) + } +} + +func TestMonthEntryLabel(t *testing.T) { + e := monthEntry{year: 2026, month: time.March} + label := e.label() + + if label != "March 2026" { + t.Errorf("Expected 'March 2026', got '%s'", label) + } +} + +func TestInit(t *testing.T) { + m := New() + cmd := m.Init() + if cmd != nil { + t.Error("Expected Init to return nil") + } +} + +func TestUpdate_OpenMsg(t *testing.T) { + m := New() + + updated, cmd := m.Update(OpenMsg{Year: 2026, Month: time.March}) + m = updated.(Model) + + if cmd != nil { + t.Error("Expected nil command from OpenMsg") + } + if !m.focus { + t.Error("Expected model to be focused after OpenMsg") + } + if len(m.items) == 0 { + t.Error("Expected items to be generated") + } + if m.cursor != m.current { + t.Error("Expected cursor and current to be the same") + } + if m.items[m.cursor].year != 2026 || m.items[m.cursor].month != time.March { + t.Errorf("Expected cursor at 2026-March, got %v", m.items[m.cursor]) + } +} + +func TestUpdate_CloseMsg(t *testing.T) { + m := New() + m.Focus() + + updated, cmd := m.Update(CloseMsg{}) + m = updated.(Model) + + if cmd != nil { + t.Error("Expected nil command from CloseMsg") + } + if m.focus { + t.Error("Expected model to be blurred after CloseMsg") + } +} + +func TestUpdate_KeyNavigation(t *testing.T) { + m := New() + updated, _ := m.Update(OpenMsg{Year: 2026, Month: time.June}) + m = updated.(Model) + + startCursor := m.cursor + + tests := []struct { + name string + key string + expected int + }{ + {"right", "right", startCursor + 1}, + {"l", "l", startCursor + 2}, + {"down", "down", startCursor + 3}, + {"j", "j", startCursor + 4}, + {"left", "left", startCursor + 3}, + {"h", "h", startCursor + 2}, + {"up", "up", startCursor + 1}, + {"k", "k", startCursor}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)}) + m = updated.(Model) + + if m.cursor != tt.expected { + t.Errorf("After %s: expected cursor %d, got %d", tt.name, tt.expected, m.cursor) + } + }) + } +} + +func TestUpdate_KeyNavigationBounds(t *testing.T) { + m := New() + updated, _ := m.Update(OpenMsg{Year: 2026, Month: time.June}) + m = updated.(Model) + + m.cursor = 0 + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}) + m = updated.(Model) + if m.cursor != 0 { + t.Errorf("Expected cursor to stay at 0, got %d", m.cursor) + } + + m.cursor = len(m.items) - 1 + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) + m = updated.(Model) + if m.cursor != len(m.items)-1 { + t.Errorf("Expected cursor to stay at %d, got %d", len(m.items)-1, m.cursor) + } +} + +func TestUpdate_EnterKey(t *testing.T) { + m := New() + updated, _ := m.Update(OpenMsg{Year: 2026, Month: time.March}) + m = updated.(Model) + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) + m = updated.(Model) + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + + if m.focus { + t.Error("Expected model to be blurred after Enter") + } + if cmd == nil { + t.Fatal("Expected command from Enter key") + } + + msg := cmd() + selected, ok := msg.(SelectedMsg) + if !ok { + t.Fatalf("Expected SelectedMsg, got %T", msg) + } + if selected.Year != 2026 || selected.Month != time.April { + t.Errorf("Expected 2026-April, got %d-%v", selected.Year, selected.Month) + } +} + +func TestUpdate_EscKey(t *testing.T) { + m := New() + updated, _ := m.Update(OpenMsg{Year: 2026, Month: time.March}) + m = updated.(Model) + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + + if m.focus { + t.Error("Expected model to be blurred after Esc") + } + if cmd == nil { + t.Fatal("Expected command from Esc key") + } + + msg := cmd() + if _, ok := msg.(CloseMsg); !ok { + t.Errorf("Expected CloseMsg, got %T", msg) + } +} + +func TestUpdate_UnfocusedIgnoresKeys(t *testing.T) { + m := New() + updated, _ := m.Update(OpenMsg{Year: 2026, Month: time.March}) + m = updated.(Model) + + cursor := m.cursor + m.Blur() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) + m = updated.(Model) + + if m.cursor != cursor { + t.Error("Expected cursor not to change when unfocused") + } + if cmd != nil { + t.Error("Expected nil command when unfocused") + } +} + +func TestView_Unfocused(t *testing.T) { + m := New() + view := m.View() + + if view != "" { + t.Errorf("Expected empty view when unfocused, got '%s'", view) + } +} + +func TestView_EmptyItems(t *testing.T) { + m := New() + m.Focus() + view := m.View() + + if view != "" { + t.Errorf("Expected empty view with no items, got '%s'", view) + } +} + +func TestView_Focused(t *testing.T) { + m := New() + m.Width = 120 + updated, _ := m.Update(OpenMsg{Year: 2026, Month: time.March}) + m = updated.(Model) + + view := m.View() + + if view == "" { + t.Error("Expected non-empty view when focused") + } + if !strings.Contains(view, "Period:") { + t.Error("Expected view to contain 'Period:'") + } + if !strings.Contains(view, "March 2026") { + t.Error("Expected view to contain 'March 2026'") + } +} + +func TestView_ArrowsDisplay(t *testing.T) { + m := New() + m.Width = 120 + updated, _ := m.Update(OpenMsg{Year: 2026, Month: time.June}) + m = updated.(Model) + + view := m.View() + if !strings.Contains(view, "<<") { + t.Error("Expected left arrow when cursor > 0") + } + if !strings.Contains(view, ">>") { + t.Error("Expected right arrow when cursor < last") + } + + m.cursor = 0 + view = m.View() + if strings.Contains(view, "<<") { + t.Error("Expected no left arrow when cursor is 0") + } + + m.cursor = len(m.items) - 1 + view = m.View() + if strings.Contains(view, ">>") { + t.Error("Expected no right arrow when cursor is at last item") + } +} + +func TestView_CursorMarkers(t *testing.T) { + m := New() + m.Width = 120 + updated, _ := m.Update(OpenMsg{Year: 2026, Month: time.March}) + m = updated.(Model) + + view := m.View() + if !strings.Contains(view, "> March 2026 <") { + t.Error("Expected cursor markers around selected month") + } +} + +func TestFocusBlurFocused(t *testing.T) { + m := New() + + if m.Focused() { + t.Error("Expected unfocused initially") + } + + m.Focus() + if !m.Focused() { + t.Error("Expected focused after Focus()") + } + + m.Blur() + if m.Focused() { + t.Error("Expected unfocused after Blur()") + } +} + +func TestWithWidth(t *testing.T) { + m := New() + m.WithWidth(200) + + if m.Width != 200 { + t.Errorf("Expected width 200, got %d", m.Width) + } +} + +func TestWithStyles(t *testing.T) { + m := New() + custom := Styles{ + Border: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()), + } + m.WithStyles(custom) + + if m.styles.Border.GetBorderStyle() != lipgloss.NormalBorder() { + t.Error("Expected custom border style") + } +} + +func TestOpen(t *testing.T) { + cmd := Open(2026, time.March) + if cmd == nil { + t.Fatal("Expected command from Open") + } + + msg := cmd() + openMsg, ok := msg.(OpenMsg) + if !ok { + t.Fatalf("Expected OpenMsg, got %T", msg) + } + if openMsg.Year != 2026 || openMsg.Month != time.March { + t.Errorf("Expected 2026-March, got %d-%v", openMsg.Year, openMsg.Month) + } +} + +func TestBuildVisibleLabels_NarrowWidth(t *testing.T) { + m := New() + m.Width = 40 + updated, _ := m.Update(OpenMsg{Year: 2026, Month: time.June}) + m = updated.(Model) + + view := m.View() + if view == "" { + t.Error("Expected non-empty view even with narrow width") + } + if !strings.Contains(view, "June 2026") { + t.Error("Expected at least the cursor month to be visible") + } +} + +func TestRenderLabel_Styles(t *testing.T) { + m := New() + updated, _ := m.Update(OpenMsg{Year: 2026, Month: time.June}) + m = updated.(Model) + + cursorLabel := m.renderLabel(m.cursor) + if !strings.Contains(cursorLabel, "> ") || !strings.Contains(cursorLabel, " <") { + t.Error("Expected cursor label to have > < markers") + } + + if m.cursor > 0 { + otherLabel := m.renderLabel(m.cursor - 1) + if strings.Contains(otherLabel, "> ") { + t.Error("Expected non-cursor label without markers") + } + } +} + +func TestUpdate_OpenMsg_DifferentMonths(t *testing.T) { + tests := []struct { + year int + month time.Month + }{ + {2021, time.January}, + {2026, time.December}, + {2030, time.July}, + } + + for _, tt := range tests { + t.Run(tt.month.String(), func(t *testing.T) { + m := New() + updated, _ := m.Update(OpenMsg{Year: tt.year, Month: tt.month}) + m = updated.(Model) + + if m.items[m.cursor].year != tt.year || m.items[m.cursor].month != tt.month { + t.Errorf("Expected cursor at %d-%v, got %v", tt.year, tt.month, m.items[m.cursor]) + } + }) + } +} diff --git a/internal/ui/period/styles.go b/internal/ui/period/styles.go new file mode 100644 index 0000000..76fa73d --- /dev/null +++ b/internal/ui/period/styles.go @@ -0,0 +1,29 @@ +/* +Copyright © 2025-2026 Artur Taranchiev +SPDX-License-Identifier: Apache-2.0 +*/ +package period + +import "github.com/charmbracelet/lipgloss" + +type Styles struct { + Border lipgloss.Style + Item lipgloss.Style + Selected lipgloss.Style + Current lipgloss.Style +} + +func DefaultStyles() Styles { + return Styles{ + Border: lipgloss.NewStyle(). + BorderStyle(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color("#5F5FD7")), + Item: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#585858")), + Selected: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#D75F87")), + Current: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#5F5FD7")), + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 96e3790..4f355ff 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -13,6 +13,7 @@ import ( "time" "ffiii-tui/internal/ui/notify" + "ffiii-tui/internal/ui/period" "ffiii-tui/internal/ui/prompt" "github.com/charmbracelet/bubbles/help" @@ -74,6 +75,7 @@ type modelUI struct { revenues modelRevenues liabilities modelLiabilities prompt prompt.Model + periodPicker period.Model notify notify.Model summary modelSummary spinner spinner.Model @@ -114,6 +116,7 @@ func NewModelUI(api UIAPI) modelUI { revenues: newModelRevenues(api), liabilities: newModelLiabilities(api), prompt: prompt.New(), + periodPicker: period.New(), notify: notify.New(), summary: newModelSummary(api), spinner: sp, @@ -171,27 +174,23 @@ func (m modelUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.expenses.list.SetShowHelp(m.help.ShowAll) m.revenues.list.SetShowHelp(m.help.ShowAll) return m, tea.WindowSize() - case key.Matches(msg, m.keymap.PreviousPeriod): - m.transactions.currentSearch = "" - m.api.PreviousPeriod() - return m, tea.Batch( - Cmd(RefreshTransactionsMsg{}), - Cmd(RefreshSummaryMsg{}), - Cmd(RefreshCategoryInsightsMsg{}), - Cmd(RefreshRevenueInsightsMsg{}), - Cmd(RefreshExpenseInsightsMsg{}), - ) - case key.Matches(msg, m.keymap.NextPeriod): - m.transactions.currentSearch = "" - m.api.NextPeriod() - return m, tea.Batch( - Cmd(RefreshTransactionsMsg{}), - Cmd(RefreshSummaryMsg{}), - Cmd(RefreshCategoryInsightsMsg{}), - Cmd(RefreshRevenueInsightsMsg{}), - Cmd(RefreshExpenseInsightsMsg{}), + case key.Matches(msg, m.keymap.PeriodPicker): + return m, period.Open( + m.api.PeriodStart().Year(), + m.api.PeriodStart().Month(), ) } + case period.SelectedMsg: + m.transactions.currentSearch = "" + m.api.SetPeriod(msg.Year, msg.Month) + return m, tea.Batch( + Cmd(RefreshTransactionsMsg{}), + Cmd(RefreshSummaryMsg{}), + Cmd(RefreshCategoryInsightsMsg{}), + Cmd(RefreshRevenueInsightsMsg{}), + Cmd(RefreshExpenseInsightsMsg{}), + ) + case period.CloseMsg: case UpdatePositions: // TODO: Refactor, bad design // Use current layout @@ -330,6 +329,13 @@ func (m modelUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } + periodPickerWasFocused := m.periodPicker.Focused() + m.periodPicker, cmd = updateModel(m.periodPicker, msg) + cmds = append(cmds, cmd) + if periodPickerWasFocused { + return m, tea.Batch(cmds...) + } + m.notify, cmd = updateModel(m.notify, msg) cmds = append(cmds, cmd) @@ -370,6 +376,8 @@ func (m modelUI) View() string { // TODO: Move to model if m.prompt.Focused() { s.WriteString(m.prompt.WithWidth(m.layout.GetWidth()).View() + "\n") + } else if m.periodPicker.Focused() { + s.WriteString(m.periodPicker.WithWidth(m.layout.GetWidth()).View() + "\n") } else { header := " ffiii-tui" diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 4aa6d52..ff6f905 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -11,6 +11,7 @@ import ( "time" "ffiii-tui/internal/firefly" + "ffiii-tui/internal/ui/period" "ffiii-tui/internal/ui/prompt" tea "github.com/charmbracelet/bubbletea" @@ -21,6 +22,9 @@ type mockUIAPI struct { // PeriodAPI previousPeriodCalled int nextPeriodCalled int + setPeriodCalled int + setPeriodYear int + setPeriodMonth time.Month // SummaryAPI updateSummaryCalled int @@ -96,6 +100,14 @@ func (m *mockUIAPI) NextPeriod() { m.periodEnd = m.periodEnd.AddDate(0, 1, 0) } +func (m *mockUIAPI) SetPeriod(year int, month time.Month) { + m.setPeriodCalled++ + m.setPeriodYear = year + m.setPeriodMonth = month + m.periodStart = time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + m.periodEnd = m.periodStart.AddDate(0, 1, 0).Add(-time.Nanosecond) +} + func (m *mockUIAPI) PeriodStart() time.Time { return m.periodStart } func (m *mockUIAPI) PeriodEnd() time.Time { return m.periodEnd } func (m *mockUIAPI) TimeoutSeconds() int { return m.timeoutSeconds } @@ -398,7 +410,7 @@ func TestUI_KeyToggleHelp(t *testing.T) { } } -func TestUI_KeyPreviousPeriod(t *testing.T) { +func TestUI_KeyPeriodPicker(t *testing.T) { api := newTestUIAPI() m := modelUI{ api: api, @@ -413,48 +425,27 @@ func TestUI_KeyPreviousPeriod(t *testing.T) { keymap: DefaultUIKeyMap(), styles: DefaultStyles(), } - m.transactions.currentSearch = "test" - - _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'['}}) + // Pressing 'p' should emit a period.Open command + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) if cmd == nil { - t.Fatal("Expected command from previous period") + t.Fatal("Expected command from period picker key") } - if api.previousPeriodCalled != 1 { - t.Errorf("Expected PreviousPeriod to be called once, got %d", api.previousPeriodCalled) - } - - // Should clear search - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'['}}) - m2 := updated.(modelUI) - if m2.transactions.currentSearch != "" { - t.Error("Expected search to be cleared") - } - - // Check that refresh commands are sent + // The command should produce a period.OpenMsg msgs := collectMsgsFromCmd(cmd) - foundRefreshTransactions := false - foundRefreshSummary := false - + foundOpen := false for _, msg := range msgs { - switch msg.(type) { - case RefreshTransactionsMsg: - foundRefreshTransactions = true - case RefreshSummaryMsg: - foundRefreshSummary = true + if _, ok := msg.(period.OpenMsg); ok { + foundOpen = true } } - - if !foundRefreshTransactions { - t.Error("Expected RefreshTransactionsMsg in batch") - } - if !foundRefreshSummary { - t.Error("Expected RefreshSummaryMsg in batch") + if !foundOpen { + t.Error("Expected period.OpenMsg from 'p' key") } } -func TestUI_KeyNextPeriod(t *testing.T) { +func TestUI_PeriodSelectedMsg(t *testing.T) { api := newTestUIAPI() m := modelUI{ api: api, @@ -469,15 +460,46 @@ func TestUI_KeyNextPeriod(t *testing.T) { keymap: DefaultUIKeyMap(), styles: DefaultStyles(), } + m.transactions.currentSearch = "test" - _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{']'}}) + // Sending a period.SelectedMsg should call SetPeriod and clear search + updated, cmd := m.Update(period.SelectedMsg{Year: 2025, Month: time.March}) + m2 := updated.(modelUI) + + if api.setPeriodCalled != 1 { + t.Errorf("Expected SetPeriod to be called once, got %d", api.setPeriodCalled) + } + if api.setPeriodYear != 2025 { + t.Errorf("Expected year 2025, got %d", api.setPeriodYear) + } + if api.setPeriodMonth != time.March { + t.Errorf("Expected month March, got %v", api.setPeriodMonth) + } + if m2.transactions.currentSearch != "" { + t.Error("Expected search to be cleared") + } if cmd == nil { - t.Fatal("Expected command from next period") + t.Fatal("Expected refresh commands") } - if api.nextPeriodCalled != 1 { - t.Errorf("Expected NextPeriod to be called once, got %d", api.nextPeriodCalled) + // Check that refresh commands are sent + msgs := collectMsgsFromCmd(cmd) + foundRefreshTransactions := false + foundRefreshSummary := false + for _, msg := range msgs { + switch msg.(type) { + case RefreshTransactionsMsg: + foundRefreshTransactions = true + case RefreshSummaryMsg: + foundRefreshSummary = true + } + } + if !foundRefreshTransactions { + t.Error("Expected RefreshTransactionsMsg in batch") + } + if !foundRefreshSummary { + t.Error("Expected RefreshSummaryMsg in batch") } }