Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed
- **Stats View Focus** - Fixed focus state persisting when navigating away from stats view, ensuring fresh state on re-entry
- **Standings for Multi-Season Leagues** - Fixed standings dialog returning empty results for leagues with multiple seasons per year (i.e, Liga MX, Liga Profesional, Liga 1, Primera A, etc.)

## [0.20.0] - 2026-02-05

Expand Down
11 changes: 6 additions & 5 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import "time"

// League represents a football league
type League struct {
ID int `json:"id"`
Name string `json:"name"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
Logo string `json:"logo,omitempty"`
ID int `json:"id"`
Name string `json:"name"`
Country string `json:"country"`
CountryCode string `json:"country_code"`
Logo string `json:"logo,omitempty"`
ParentLeagueID int `json:"parent_league_id,omitempty"` // Parent league ID for sub-season leagues (e.g., Liga MX Clausura -> Liga MX)
}

// Team represents a football team
Expand Down
6 changes: 4 additions & 2 deletions internal/app/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,9 @@ func fetchGoalLinks(redditClient *reddit.Client, details *api.MatchDetails) tea.

// fetchStandings fetches league standings for a specific league.
// Used to populate the standings dialog.
func fetchStandings(client *fotmob.Client, leagueID int, leagueName string, homeTeamID, awayTeamID int) tea.Cmd {
// parentLeagueID is used for multi-season leagues (e.g., Liga MX Clausura -> Liga MX)
// where the sub-league ID has no standings but the parent league does.
func fetchStandings(client *fotmob.Client, leagueID int, leagueName string, parentLeagueID int, homeTeamID, awayTeamID int) tea.Cmd {
return func() tea.Msg {
if client == nil {
return standingsMsg{leagueID: leagueID, standings: nil}
Expand All @@ -386,7 +388,7 @@ func fetchStandings(client *fotmob.Client, leagueID int, leagueName string, home
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

standings, err := client.LeagueTable(ctx, leagueID, leagueName)
standings, err := client.LeagueTableWithParent(ctx, leagueID, leagueName, parentLeagueID)
if err != nil {
return standingsMsg{leagueID: leagueID, standings: nil}
}
Expand Down
1 change: 1 addition & 0 deletions internal/app/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ func (m model) handleStatsSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.fotmobClient,
m.matchDetails.League.ID,
m.matchDetails.League.Name,
m.matchDetails.League.ParentLeagueID,
m.matchDetails.HomeTeam.ID,
m.matchDetails.AwayTeam.ID,
)
Expand Down
58 changes: 47 additions & 11 deletions internal/fotmob/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,15 +458,37 @@ func getParentLeagueID(leagueName string, leagueID int) int {

// LeagueTable retrieves the league table/standings for a specific league.
// Handles both regular league tables and knockout competition tables (e.g., Champions League).
// Uses league name to detect parent leagues for knockout competitions.
// Uses parentLeagueID (from FotMob match details) when available, then falls back to
// league name pattern matching for knockout competitions.
// Multi-season leagues (e.g., Liga MX, Liga Profesional) have sub-league IDs per season
// that don't have standings — the parentLeagueID points to the main league that does.
func (c *Client) LeagueTable(ctx context.Context, leagueID int, leagueName string) ([]api.LeagueTableEntry, error) {
// First, determine the effective league ID (may be parent for knockout competitions)
// Determine the effective league ID for standings lookup.
// Priority: parentLeagueID from match details > name pattern matching > original ID
effectiveID := getParentLeagueID(leagueName, leagueID)

// Fetch standings using the effective league ID
return c.fetchLeagueTable(ctx, effectiveID)
}

// LeagueTableWithParent retrieves the league table/standings, using the parent league ID
// when available. This is the preferred method when match details provide a parentLeagueId.
// Multi-season leagues (e.g., Liga MX Clausura, Liga Profesional Apertura) return sub-league
// IDs in match details that have no standings — the parentLeagueID points to the main league.
func (c *Client) LeagueTableWithParent(ctx context.Context, leagueID int, leagueName string, parentLeagueID int) ([]api.LeagueTableEntry, error) {
effectiveID := leagueID

// Use parentLeagueID if it differs from leagueID (indicates a sub-season league)
if parentLeagueID > 0 && parentLeagueID != leagueID {
effectiveID = parentLeagueID
} else {
// Fall back to name-based parent league detection for knockout competitions
effectiveID = getParentLeagueID(leagueName, leagueID)
}

return c.fetchLeagueTable(ctx, effectiveID)
}

// fetchLeagueTable fetches the league table for a specific league ID.
func (c *Client) fetchLeagueTable(ctx context.Context, leagueID int) ([]api.LeagueTableEntry, error) {
// Apply rate limiting
Expand All @@ -491,21 +513,29 @@ func (c *Client) fetchLeagueTable(ctx context.Context, leagueID int) ([]api.Leag
return nil, fmt.Errorf("unexpected status code %d for league %d table", resp.StatusCode, leagueID)
}

// FotMob returns table at either:
// - Regular leagues: table[0].data.table.all[]
// - Knockout competitions (e.g., Champions League): table[0].data.tables[0].table.all[]
// FotMob returns table data in several formats:
// 1. Regular leagues (EPL, La Liga): table[0].data.table.all[]
// 2. Knockout competitions (Champions League): table[0].data.tables[0].table.all[]
// 3. Multi-season leagues (Liga MX, Liga Profesional): table[0].data.tables[] with
// multiple sub-tables (e.g., Clausura + Apertura, or Group A + Group B).
// The first sub-table is typically the current/most relevant season.
var response struct {
Table []struct {
Data struct {
// Regular league table
Table struct {
All []fotmobTableRow `json:"all"`
} `json:"table"`
// Knockout competition tables (e.g., Champions League)
// Multi-table format: knockout competitions and multi-season leagues
// Examples:
// - Champions League: single table with all teams
// - Liga MX: Clausura + Apertura tables
// - Liga Profesional: Apertura Group A + Group B tables
Tables []struct {
Table struct {
All []fotmobTableRow `json:"all"`
} `json:"table"`
LeagueName string `json:"leagueName"` // e.g., "Clausura", "Apertura - Group A"
} `json:"tables"`
} `json:"data"`
} `json:"table"`
Expand All @@ -515,16 +545,22 @@ func (c *Client) fetchLeagueTable(ctx context.Context, leagueID int) ([]api.Leag
return nil, fmt.Errorf("decode league table response for league %d: %w", leagueID, err)
}

// Extract table rows - try regular format first, then knockout format
// Extract table rows - try regular format first, then multi-table format
var tableData []fotmobTableRow
if len(response.Table) > 0 {
data := response.Table[0].Data
// Try regular league format first
// Try regular league format first (single table with all teams)
if len(data.Table.All) > 0 {
tableData = data.Table.All
} else if len(data.Tables) > 0 && len(data.Tables[0].Table.All) > 0 {
// Fall back to knockout competition format
tableData = data.Tables[0].Table.All
} else if len(data.Tables) > 0 {
// Multi-table format: use first sub-table (current/most relevant season)
// This covers both knockout competitions and multi-season leagues
for _, subTable := range data.Tables {
if len(subTable.Table.All) > 0 {
tableData = subTable.Table.All
break
}
}
}
}

Expand Down
10 changes: 6 additions & 4 deletions internal/fotmob/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,9 @@ type fotmobMatchDetails struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"awayTeam"`
LeagueID int `json:"leagueId"`
LeagueName string `json:"leagueName"`
LeagueID int `json:"leagueId"`
LeagueName string `json:"leagueName"`
ParentLeagueID int `json:"parentLeagueId"` // Parent league ID for sub-season leagues
} `json:"general"`
Content struct {
MatchFacts struct {
Expand Down Expand Up @@ -306,8 +307,9 @@ func (m fotmobMatchDetails) toAPIMatchDetails() *api.MatchDetails {
baseMatch := api.Match{
ID: matchID,
League: api.League{
ID: m.General.LeagueID,
Name: m.General.LeagueName,
ID: m.General.LeagueID,
Name: m.General.LeagueName,
ParentLeagueID: m.General.ParentLeagueID,
},
HomeTeam: api.Team{
ID: m.General.HomeTeam.ID,
Expand Down