Skip to content

Commit 3590dea

Browse files
authored
Merge pull request #11 from 0xjuanma/settings-and-filter
feat[golazo]: new league customization and result filtering
2 parents 069871a + 482f8fb commit 3590dea

17 files changed

Lines changed: 610 additions & 81 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **League Selection** - New settings customization to select and persist league preferences
12+
- **Result List Filtering** - New / filtering command for all result lists
1113

1214
### Changed
1315

assets/social-preview.png

2.49 MB
Loading

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/charmbracelet/lipgloss v1.1.0
99
github.com/lucasb-eyer/go-colorful v1.2.0
1010
github.com/spf13/cobra v1.8.0
11+
gopkg.in/yaml.v3 v3.0.1
1112
)
1213

1314
require (

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,7 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
6565
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
6666
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
6767
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
68+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
6869
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
70+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
6971
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/app/handlers.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
1515
switch msg.String() {
1616
case "j", "down":
17-
if m.selected < 1 && !m.mainViewLoading {
17+
if m.selected < 2 && !m.mainViewLoading { // 3 menu items: 0, 1, 2
1818
m.selected++
1919
}
2020
case "k", "up":
@@ -25,6 +25,14 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
2525
if m.mainViewLoading {
2626
return m, nil
2727
}
28+
29+
// Handle Settings view separately (no API calls needed)
30+
if m.selected == 2 {
31+
m.settingsState = ui.NewSettingsState()
32+
m.currentView = viewSettings
33+
return m, nil
34+
}
35+
2836
m.mainViewLoading = true
2937
m.pendingSelection = m.selected
3038

@@ -162,3 +170,33 @@ func (m model) loadStatsMatchDetails(matchID int) (tea.Model, tea.Cmd) {
162170
m.statsViewLoading = true
163171
return m, tea.Batch(m.spinner.Tick, ui.SpinnerTick(), fetchStatsMatchDetailsFotmob(m.fotmobClient, matchID, m.useMockData))
164172
}
173+
174+
// handleSettingsViewKeys processes keyboard input for the settings view.
175+
// Handles navigation (up/down), selection toggle (space), save (enter), and cancel (esc).
176+
func (m model) handleSettingsViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
177+
if m.settingsState == nil {
178+
return m, nil
179+
}
180+
181+
switch msg.String() {
182+
case "j", "down":
183+
m.settingsState.MoveDown()
184+
case "k", "up":
185+
m.settingsState.MoveUp()
186+
case " ": // Space to toggle
187+
m.settingsState.Toggle()
188+
case "enter":
189+
// Save settings and return to main menu
190+
_ = m.settingsState.Save() // Best-effort save
191+
m.settingsState = nil
192+
m.currentView = viewMain
193+
m.selected = 0
194+
case "esc":
195+
// Cancel and return to main menu without saving
196+
m.settingsState = nil
197+
m.currentView = viewMain
198+
m.selected = 0
199+
}
200+
201+
return m, nil
202+
}

internal/app/model.go

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
viewMain view = iota
1818
viewLiveMatches
1919
viewStats
20+
viewSettings
2021
)
2122

2223
// model holds the application state.
@@ -73,6 +74,9 @@ type model struct {
7374
useMockData bool
7475
statsDateRange int // 1, 3, or 5 days (default: 1)
7576

77+
// Settings view state
78+
settingsState *ui.SettingsState
79+
7680
// API clients
7781
fotmobClient *fotmob.Client
7882
parser *fotmob.LiveUpdateParser
@@ -98,20 +102,38 @@ func New(useMockData bool) model {
98102
// Initialize list models with custom delegate
99103
delegate := ui.NewMatchListDelegate()
100104

105+
// Filter input styles matching neon theme
106+
filterCursorStyle, filterPromptStyle := ui.FilterInputStyles()
107+
101108
liveList := list.New([]list.Item{}, delegate, 0, 0)
102109
liveList.SetShowTitle(false)
103-
liveList.SetShowStatusBar(false)
104-
liveList.SetFilteringEnabled(false)
110+
liveList.SetShowStatusBar(true)
111+
liveList.SetFilteringEnabled(true)
112+
liveList.SetShowFilter(true)
113+
liveList.Filter = list.DefaultFilter // Required for filtering to work
114+
liveList.Styles.FilterCursor = filterCursorStyle
115+
liveList.FilterInput.PromptStyle = filterPromptStyle
116+
liveList.FilterInput.Cursor.Style = filterCursorStyle
105117

106118
statsList := list.New([]list.Item{}, delegate, 0, 0)
107119
statsList.SetShowTitle(false)
108-
statsList.SetShowStatusBar(false)
109-
statsList.SetFilteringEnabled(false)
120+
statsList.SetShowStatusBar(true)
121+
statsList.SetFilteringEnabled(true)
122+
statsList.SetShowFilter(true)
123+
statsList.Filter = list.DefaultFilter // Required for filtering to work
124+
statsList.Styles.FilterCursor = filterCursorStyle
125+
statsList.FilterInput.PromptStyle = filterPromptStyle
126+
statsList.FilterInput.Cursor.Style = filterCursorStyle
110127

111128
upcomingList := list.New([]list.Item{}, delegate, 0, 0)
112129
upcomingList.SetShowTitle(false)
113-
upcomingList.SetShowStatusBar(false)
114-
upcomingList.SetFilteringEnabled(false)
130+
upcomingList.SetShowStatusBar(true)
131+
upcomingList.SetFilteringEnabled(true)
132+
upcomingList.SetShowFilter(true)
133+
upcomingList.Filter = list.DefaultFilter // Required for filtering to work
134+
upcomingList.Styles.FilterCursor = filterCursorStyle
135+
upcomingList.FilterInput.PromptStyle = filterPromptStyle
136+
upcomingList.FilterInput.Cursor.Style = filterCursorStyle
115137

116138
return model{
117139
currentView: viewMain,

internal/app/update.go

Lines changed: 126 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/0xjuanma/golazo/internal/api"
77
"github.com/0xjuanma/golazo/internal/fotmob"
88
"github.com/0xjuanma/golazo/internal/ui"
9+
"github.com/charmbracelet/bubbles/list"
910
"github.com/charmbracelet/bubbles/spinner"
1011
tea "github.com/charmbracelet/bubbletea"
1112
)
@@ -57,6 +58,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5758
case pollDisplayCompleteMsg:
5859
return m.handlePollDisplayComplete()
5960

61+
case list.FilterMatchesMsg:
62+
// Route filter matches message to the appropriate list based on current view
63+
return m.handleFilterMatches(msg)
64+
6065
default:
6166
// Fallback handler for ui.TickMsg type assertion
6267
if _, ok := msg.(ui.TickMsg); ok {
@@ -198,6 +203,23 @@ func (m model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
198203
case "q", "ctrl+c":
199204
return m, tea.Quit
200205
case "esc":
206+
// Check if any list is in filtering mode - if so, let the list handle Esc
207+
// to cancel the filter instead of navigating back
208+
isFiltering := false
209+
switch m.currentView {
210+
case viewLiveMatches:
211+
isFiltering = m.liveMatchesList.FilterState() == list.Filtering ||
212+
m.liveMatchesList.FilterState() == list.FilterApplied
213+
case viewStats:
214+
isFiltering = m.statsMatchesList.FilterState() == list.Filtering ||
215+
m.statsMatchesList.FilterState() == list.FilterApplied
216+
}
217+
218+
if isFiltering {
219+
// Let the view-specific handler pass Esc to the list to cancel filter
220+
break
221+
}
222+
201223
if m.currentView != viewMain {
202224
return m.resetToMainView()
203225
}
@@ -211,6 +233,8 @@ func (m model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
211233
return m.handleLiveMatchesSelection(msg)
212234
case viewStats:
213235
return m.handleStatsSelection(msg)
236+
case viewSettings:
237+
return m.handleSettingsViewKeys(msg)
214238
}
215239

216240
return m, nil
@@ -233,43 +257,106 @@ func (m model) resetToMainView() (tea.Model, tea.Cmd) {
233257

234258
// handleLiveMatchesSelection handles list navigation in live matches view.
235259
func (m model) handleLiveMatchesSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
260+
// Capture selected item BEFORE Update (critical for filter mode - selection changes after filter clears)
261+
var preUpdateMatchID int
262+
if preItem := m.liveMatchesList.SelectedItem(); preItem != nil {
263+
if item, ok := preItem.(ui.MatchListItem); ok {
264+
preUpdateMatchID = item.Match.ID
265+
}
266+
}
267+
236268
var listCmd tea.Cmd
237269
m.liveMatchesList, listCmd = m.liveMatchesList.Update(msg)
238270

239-
if selectedItem := m.liveMatchesList.SelectedItem(); selectedItem != nil {
240-
if item, ok := selectedItem.(ui.MatchListItem); ok {
241-
for i, match := range m.matches {
242-
if match.ID == item.Match.ID && i != m.selected {
243-
m.selected = i
244-
return m.loadMatchDetails(m.matches[m.selected].ID)
245-
}
271+
// Get currently displayed match ID
272+
currentMatchID := 0
273+
if m.matchDetails != nil {
274+
currentMatchID = m.matchDetails.ID
275+
}
276+
277+
// Check post-update selection
278+
var postUpdateMatchID int
279+
if postItem := m.liveMatchesList.SelectedItem(); postItem != nil {
280+
if item, ok := postItem.(ui.MatchListItem); ok {
281+
postUpdateMatchID = item.Match.ID
282+
}
283+
}
284+
285+
// Use pre-update selection if it was valid and different from current
286+
// This handles the filter case where Enter clears the filter
287+
targetMatchID := postUpdateMatchID
288+
if msg.String() == "enter" && preUpdateMatchID != 0 {
289+
targetMatchID = preUpdateMatchID
290+
}
291+
292+
// Load match details if selection changed
293+
if targetMatchID != 0 && targetMatchID != currentMatchID {
294+
for i, match := range m.matches {
295+
if match.ID == targetMatchID {
296+
m.selected = i
297+
break
246298
}
247299
}
300+
return m.loadMatchDetails(targetMatchID)
248301
}
249302

250303
return m, listCmd
251304
}
252305

253306
// handleStatsSelection handles list navigation and date range changes in stats view.
254307
func (m model) handleStatsSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
255-
// Handle date range navigation
256-
if msg.String() == "h" || msg.String() == "left" || msg.String() == "l" || msg.String() == "right" {
257-
return m.handleStatsViewKeys(msg)
308+
// Check if list is in filtering mode - if so, let list handle ALL keys
309+
isFiltering := m.statsMatchesList.FilterState() == list.Filtering
310+
311+
// Only handle date range navigation when NOT filtering
312+
if !isFiltering {
313+
if msg.String() == "h" || msg.String() == "left" || msg.String() == "l" || msg.String() == "right" {
314+
return m.handleStatsViewKeys(msg)
315+
}
316+
}
317+
318+
// Capture selected item BEFORE Update (critical for filter mode - selection changes after filter clears)
319+
var preUpdateMatchID int
320+
if preItem := m.statsMatchesList.SelectedItem(); preItem != nil {
321+
if item, ok := preItem.(ui.MatchListItem); ok {
322+
preUpdateMatchID = item.Match.ID
323+
}
258324
}
259325

260326
// Handle list navigation
261327
var listCmd tea.Cmd
262328
m.statsMatchesList, listCmd = m.statsMatchesList.Update(msg)
263329

264-
if selectedItem := m.statsMatchesList.SelectedItem(); selectedItem != nil {
265-
if item, ok := selectedItem.(ui.MatchListItem); ok {
266-
for i, match := range m.matches {
267-
if match.ID == item.Match.ID && i != m.selected {
268-
m.selected = i
269-
return m.loadStatsMatchDetails(m.matches[m.selected].ID)
270-
}
330+
// Get currently displayed match ID
331+
currentMatchID := 0
332+
if m.matchDetails != nil {
333+
currentMatchID = m.matchDetails.ID
334+
}
335+
336+
// Check post-update selection
337+
var postUpdateMatchID int
338+
if postItem := m.statsMatchesList.SelectedItem(); postItem != nil {
339+
if item, ok := postItem.(ui.MatchListItem); ok {
340+
postUpdateMatchID = item.Match.ID
341+
}
342+
}
343+
344+
// Use pre-update selection if it was valid and different from current
345+
// This handles the filter case where Enter clears the filter
346+
targetMatchID := postUpdateMatchID
347+
if msg.String() == "enter" && preUpdateMatchID != 0 {
348+
targetMatchID = preUpdateMatchID
349+
}
350+
351+
// Load match details if selection changed
352+
if targetMatchID != 0 && targetMatchID != currentMatchID {
353+
for i, match := range m.matches {
354+
if match.ID == targetMatchID {
355+
m.selected = i
356+
break
271357
}
272358
}
359+
return m.loadStatsMatchDetails(targetMatchID)
273360
}
274361

275362
return m, listCmd
@@ -738,6 +825,28 @@ func (m model) handlePollDisplayComplete() (tea.Model, tea.Cmd) {
738825
return m, nil
739826
}
740827

828+
// handleFilterMatches routes filter matches messages to the appropriate list.
829+
// This is required for the bubbles list filter to work - it fires async matching
830+
// and sends results via FilterMatchesMsg which must be routed back to the list.
831+
func (m model) handleFilterMatches(msg list.FilterMatchesMsg) (tea.Model, tea.Cmd) {
832+
var cmd tea.Cmd
833+
834+
switch m.currentView {
835+
case viewLiveMatches:
836+
m.liveMatchesList, cmd = m.liveMatchesList.Update(msg)
837+
case viewStats:
838+
m.statsMatchesList, cmd = m.statsMatchesList.Update(msg)
839+
// Also update upcoming list in case it's being filtered
840+
var upCmd tea.Cmd
841+
m.upcomingMatchesList, upCmd = m.upcomingMatchesList.Update(msg)
842+
if upCmd != nil {
843+
cmd = tea.Batch(cmd, upCmd)
844+
}
845+
}
846+
847+
return m, cmd
848+
}
849+
741850
// max returns the larger of two integers.
742851
func max(a, b int) int {
743852
if a > b {

internal/app/view.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ func (m model) View() string {
4040
m.statsTotalDays,
4141
)
4242

43+
case viewSettings:
44+
return ui.RenderSettingsView(m.width, m.height, m.settingsState)
45+
4346
default:
4447
return ui.RenderMainMenu(m.width, m.height, m.selected, m.spinner, m.randomSpinner, m.mainViewLoading)
4548
}

internal/constants/strings.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package constants
44
const (
55
MenuStats = "Stats"
66
MenuLiveMatches = "Live Matches"
7+
MenuSettings = "Settings"
78
)
89

910
// Panel titles
@@ -26,8 +27,9 @@ const (
2627

2728
// Help text
2829
const (
29-
HelpMainMenu = "↑/↓: navigate Enter: select q: quit"
30-
HelpMatchesView = "↑/↓: navigate Esc: back q: quit"
30+
HelpMainMenu = "↑/↓: navigate Enter: select q: quit"
31+
HelpMatchesView = "↑/↓: navigate /: filter Esc: back q: quit"
32+
HelpSettingsView = "↑/↓: navigate Space: toggle Enter: save Esc: cancel"
3133
)
3234

3335
// Status text

0 commit comments

Comments
 (0)