Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions internal/api/worldcup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package api

// WCFotMobLeagueID is the FotMob league ID for the FIFA World Cup.
const WCFotMobLeagueID = 77

// WCGroup represents a single World Cup group with its standings.
type WCGroup struct {
ID int
Letter string // "A", "B", "C", etc.
Name string // "Group A", "Group B", etc.
Teams []LeagueTableEntry
}

// WCMatchup represents a single knockout stage matchup.
type WCMatchup struct {
HomeTeam string
HomeTeamID int
HomeShort string
AwayTeam string
AwayTeamID int
AwayShort string
HomeScore *int
AwayScore *int
WinnerID *int
IsPenalties bool
TBDHome bool
TBDAway bool
}

// WCKnockoutRound represents a round in the knockout stage.
type WCKnockoutRound struct {
Stage string // FotMob stage key: "1/16", "1/8", "1/4", "1/2", "final"
Label string // Human-readable: "Round of 32", "Round of 16", etc.
Matchups []WCMatchup
}

// WorldCupData contains all World Cup tournament data.
type WorldCupData struct {
Season string // "2022", "2026"
Name string // "FIFA World Cup 2022"
Groups []WCGroup
KnockoutRounds []WCKnockoutRound // ordered R32/R16 → QF → SF → Final (bronze excluded)
BronzeFinal *WCMatchup
Champion *Team
RunnerUp *Team
}
19 changes: 18 additions & 1 deletion internal/app/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "j", "down":
if m.selected < 2 && !m.mainViewLoading { // 3 menu items: 0, 1, 2
if m.selected < 3 && !m.mainViewLoading { // 4 menu items: 0, 1, 2, 3
m.selected++
}
case "k", "up":
Expand All @@ -36,6 +36,23 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}

// Handle World Cup view (immediate switch, loads data async)
if m.selected == 3 {
if m.loadCancel != nil {
m.loadCancel()
}
m.loadCtx, m.loadCancel = context.WithCancel(context.Background())
m.wcData = nil
m.wcLoading = true
m.wcSubView = wcSubViewGroups
m.wcLastError = ""
m.currentView = viewWorldCup
if m.useMockData {
return m, fetchWorldCupMockData()
}
return m, fetchWorldCupData(m.loadCtx, m.fotmobClient)
}

m.mainViewLoading = true
m.pendingSelection = m.selected

Expand Down
6 changes: 6 additions & 0 deletions internal/app/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,9 @@ type standingsMsg struct {
homeTeamID int
awayTeamID int
}

// wcDataMsg contains World Cup data fetched from FotMob or mock.
type wcDataMsg struct {
data *api.WorldCupData
err error
}
22 changes: 22 additions & 0 deletions internal/app/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
viewLiveMatches
viewStats
viewSettings
viewWorldCup
)

// model holds the application state.
Expand Down Expand Up @@ -106,6 +107,15 @@ type model struct {
// Settings view state
settingsState *ui.SettingsState

// World Cup view state
wcData *api.WorldCupData
wcLoading bool
wcSubView wcSubView
wcSelectedGroup int
wcGroupsList list.Model // bubbles list for the groups overview
wcBracketScroll int
wcLastError string

// Dialog overlay for modal dialogs
dialogOverlay *ui.DialogOverlay

Expand Down Expand Up @@ -214,6 +224,17 @@ func New(useMockData bool, debugMode bool, isDevBuild bool, newVersionAvailable
// Initialize animated logo for main view
animatedLogo := logo.NewAnimatedLogoWithType(appVersion, false, logo.DefaultOpts(), 1200, 1, logo.AnimationWave)

// Initialize World Cup groups list with neon-themed delegate
wcList := list.New([]list.Item{}, ui.NewWCGroupDelegate(), 0, 0)
wcList.SetShowTitle(false)
wcList.SetShowStatusBar(true)
wcList.SetFilteringEnabled(true)
wcList.SetShowFilter(true)
wcList.Filter = list.DefaultFilter
wcList.Styles.FilterCursor = filterCursorStyle
wcList.FilterInput.PromptStyle = filterPromptStyle
wcList.FilterInput.Cursor.Style = filterCursorStyle

return model{
currentView: viewMain,
matchDetailsCache: make(map[int]*api.MatchDetails),
Expand Down Expand Up @@ -243,6 +264,7 @@ func New(useMockData bool, debugMode bool, isDevBuild bool, newVersionAvailable
pendingSelection: -1, // No pending selection
dialogOverlay: ui.NewDialogOverlay(), // Initialize dialog overlay
animatedLogo: animatedLogo, // Initialize animated logo
wcGroupsList: wcList, // Initialize World Cup groups list
}
}

Expand Down
19 changes: 19 additions & 0 deletions internal/app/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case standingsMsg:
return m.handleStandings(msg)

case wcDataMsg:
return m.handleWCData(msg)

default:
// Fallback handler for ui.TickMsg type assertion
if _, ok := msg.(ui.TickMsg); ok {
Expand Down Expand Up @@ -323,13 +326,23 @@ func (m model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
isFiltering = m.settingsState.List.FilterState() == list.Filtering ||
m.settingsState.List.FilterState() == list.FilterApplied
}
case viewWorldCup:
if m.wcSubView == wcSubViewGroups {
isFiltering = m.wcGroupsList.FilterState() == list.Filtering ||
m.wcGroupsList.FilterState() == list.FilterApplied
}
}

if isFiltering {
// Let the view-specific handler pass Esc to the list to cancel filter
break
}

// In World Cup sub-views, let the view handle esc for internal navigation
if m.currentView == viewWorldCup && m.wcSubView != wcSubViewGroups {
break
}

if m.currentView != viewMain {
return m.resetToMainView()
}
Expand All @@ -345,6 +358,8 @@ func (m model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleStatsSelection(msg)
case viewSettings:
return m.handleSettingsViewKeys(msg)
case viewWorldCup:
return m.handleWorldCupKeys(msg)
}

return m, nil
Expand Down Expand Up @@ -1143,6 +1158,10 @@ func (m model) handleFilterMatches(msg list.FilterMatchesMsg) (tea.Model, tea.Cm
if m.settingsState != nil {
m.settingsState.List, cmd = m.settingsState.List.Update(msg)
}
case viewWorldCup:
if m.wcSubView == wcSubViewGroups {
m.wcGroupsList, cmd = m.wcGroupsList.Update(msg)
}
}

return m, cmd
Expand Down
10 changes: 10 additions & 0 deletions internal/app/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ func (m model) View() string {
case viewSettings:
return ui.RenderSettingsView(m.width, m.height, m.settingsState, m.getStatusBannerType())

case viewWorldCup:
switch m.wcSubView {
case wcSubViewGroupDetail:
return ui.RenderWorldCupGroupDetail(m.width, m.height, m.wcData, m.wcSelectedGroup, m.getStatusBannerType())
case wcSubViewBracket:
return ui.RenderWorldCupBracket(m.width, m.height, m.wcData, m.wcBracketScroll, m.getStatusBannerType())
default:
return ui.RenderWorldCupGroups(m.width, m.height, m.wcData, m.wcGroupsList, m.wcLoading, m.wcLastError, m.getStatusBannerType())
}

default:
return ui.RenderMainMenu(m.width, m.height, m.selected, m.spinner, m.randomSpinner, m.mainViewLoading, m.getStatusBannerType(), m.animatedLogo)
}
Expand Down
36 changes: 36 additions & 0 deletions internal/app/wc_commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package app

import (
"context"
"time"

"github.com/0xjuanma/golazo/internal/data"
"github.com/0xjuanma/golazo/internal/fotmob"
tea "github.com/charmbracelet/bubbletea"
)

// fetchWorldCupMockData returns the hardcoded Qatar 2022 World Cup data immediately.
func fetchWorldCupMockData() tea.Cmd {
return func() tea.Msg {
return wcDataMsg{data: data.MockWorldCupData()}
}
}

// fetchWorldCupData fetches live World Cup data from FotMob.
// Uses the current/latest season (2026).
func fetchWorldCupData(parentCtx context.Context, client *fotmob.Client) tea.Cmd {
return func() tea.Msg {
if client == nil {
return wcDataMsg{data: data.MockWorldCupData()}
}

ctx, cancel := context.WithTimeout(parentCtx, 20*time.Second)
defer cancel()

wcData, err := client.WorldCupData(ctx, "")
if err != nil {
return wcDataMsg{err: err}
}
return wcDataMsg{data: wcData}
}
}
110 changes: 110 additions & 0 deletions internal/app/wc_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package app

import (
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"

"github.com/0xjuanma/golazo/internal/ui"
)

// wcSubView represents the current sub-view within the World Cup view.
type wcSubView int

const (
wcSubViewGroups wcSubView = iota // scrollable group list
wcSubViewGroupDetail // single group expanded detail
wcSubViewBracket // knockout bracket
)

// handleWorldCupKeys routes keyboard input to the active WC sub-view handler.
func (m model) handleWorldCupKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.wcLoading {
return m, nil
}
switch m.wcSubView {
case wcSubViewGroups:
return m.handleWCGroupsKeys(msg)
case wcSubViewGroupDetail:
return m.handleWCGroupDetailKeys(msg)
case wcSubViewBracket:
return m.handleWCBracketKeys(msg)
}
return m, nil
}

// handleWCGroupsKeys handles input on the groups list.
// Enter navigates to group detail; b opens the bracket; all other keys are
// delegated to the bubbles/list component for built-in navigation and filtering.
func (m model) handleWCGroupsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.wcData == nil {
return m, nil
}

switch msg.String() {
case "enter":
if item, ok := m.wcGroupsList.SelectedItem().(ui.WCGroupItem); ok {
for i, g := range m.wcData.Groups {
if g.Letter == item.Group.Letter {
m.wcSelectedGroup = i
break
}
}
m.wcSubView = wcSubViewGroupDetail
}
return m, nil

case "b":
if len(m.wcData.KnockoutRounds) > 0 {
m.wcBracketScroll = 0
m.wcSubView = wcSubViewBracket
}
return m, nil

default:
var cmd tea.Cmd
m.wcGroupsList, cmd = m.wcGroupsList.Update(msg)
return m, cmd
}
}

// handleWCGroupDetailKeys handles input on the group detail view.
func (m model) handleWCGroupDetailKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.wcSubView = wcSubViewGroups
}
return m, nil
}

// handleWCBracketKeys handles input on the bracket view.
func (m model) handleWCBracketKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.wcSubView = wcSubViewGroups
case "j", "down":
m.wcBracketScroll++
case "k", "up":
if m.wcBracketScroll > 0 {
m.wcBracketScroll--
}
}
return m, nil
}

// handleWCData processes the World Cup data message and populates the groups list.
func (m model) handleWCData(msg wcDataMsg) (tea.Model, tea.Cmd) {
m.wcLoading = false
if msg.err != nil {
m.wcLastError = "Failed to load World Cup data"
return m, nil
}
m.wcData = msg.data
m.wcLastError = ""

items := make([]list.Item, len(msg.data.Groups))
for i, g := range msg.data.Groups {
items[i] = ui.WCGroupItem{Group: g}
}
m.wcGroupsList.SetItems(items)
return m, nil
}
1 change: 1 addition & 0 deletions internal/constants/strings.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const (
MenuStats = "Finished Matches"
MenuLiveMatches = "Live Matches"
MenuSettings = "Settings"
MenuWorldCup = "World Cup 2026"
)

// Panel titles
Expand Down
Loading