Skip to content

Commit 3b6802b

Browse files
authored
Merge pull request #109 from 0xjuanma/fix/multi-season-standings
fix[standings]: resolve empty standings for multi-season leagues
2 parents 620ef6d + 4d2b256 commit 3b6802b

File tree

6 files changed

+65
-22
lines changed

6 files changed

+65
-22
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
### Fixed
1818
- **Stats View Focus** - Fixed focus state persisting when navigating away from stats view, ensuring fresh state on re-entry
19+
- **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.)
1920

2021
## [0.20.0] - 2026-02-05
2122

internal/api/types.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import "time"
44

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

1415
// Team represents a football team

internal/app/commands.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,9 @@ func fetchGoalLinks(redditClient *reddit.Client, details *api.MatchDetails) tea.
377377

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

389-
standings, err := client.LeagueTable(ctx, leagueID, leagueName)
391+
standings, err := client.LeagueTableWithParent(ctx, leagueID, leagueName, parentLeagueID)
390392
if err != nil {
391393
return standingsMsg{leagueID: leagueID, standings: nil}
392394
}

internal/app/update.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ func (m model) handleStatsSelection(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
476476
m.fotmobClient,
477477
m.matchDetails.League.ID,
478478
m.matchDetails.League.Name,
479+
m.matchDetails.League.ParentLeagueID,
479480
m.matchDetails.HomeTeam.ID,
480481
m.matchDetails.AwayTeam.ID,
481482
)

internal/fotmob/client.go

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -458,15 +458,37 @@ func getParentLeagueID(leagueName string, leagueID int) int {
458458

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

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

474+
// LeagueTableWithParent retrieves the league table/standings, using the parent league ID
475+
// when available. This is the preferred method when match details provide a parentLeagueId.
476+
// Multi-season leagues (e.g., Liga MX Clausura, Liga Profesional Apertura) return sub-league
477+
// IDs in match details that have no standings — the parentLeagueID points to the main league.
478+
func (c *Client) LeagueTableWithParent(ctx context.Context, leagueID int, leagueName string, parentLeagueID int) ([]api.LeagueTableEntry, error) {
479+
effectiveID := leagueID
480+
481+
// Use parentLeagueID if it differs from leagueID (indicates a sub-season league)
482+
if parentLeagueID > 0 && parentLeagueID != leagueID {
483+
effectiveID = parentLeagueID
484+
} else {
485+
// Fall back to name-based parent league detection for knockout competitions
486+
effectiveID = getParentLeagueID(leagueName, leagueID)
487+
}
488+
489+
return c.fetchLeagueTable(ctx, effectiveID)
490+
}
491+
470492
// fetchLeagueTable fetches the league table for a specific league ID.
471493
func (c *Client) fetchLeagueTable(ctx context.Context, leagueID int) ([]api.LeagueTableEntry, error) {
472494
// Apply rate limiting
@@ -491,21 +513,29 @@ func (c *Client) fetchLeagueTable(ctx context.Context, leagueID int) ([]api.Leag
491513
return nil, fmt.Errorf("unexpected status code %d for league %d table", resp.StatusCode, leagueID)
492514
}
493515

494-
// FotMob returns table at either:
495-
// - Regular leagues: table[0].data.table.all[]
496-
// - Knockout competitions (e.g., Champions League): table[0].data.tables[0].table.all[]
516+
// FotMob returns table data in several formats:
517+
// 1. Regular leagues (EPL, La Liga): table[0].data.table.all[]
518+
// 2. Knockout competitions (Champions League): table[0].data.tables[0].table.all[]
519+
// 3. Multi-season leagues (Liga MX, Liga Profesional): table[0].data.tables[] with
520+
// multiple sub-tables (e.g., Clausura + Apertura, or Group A + Group B).
521+
// The first sub-table is typically the current/most relevant season.
497522
var response struct {
498523
Table []struct {
499524
Data struct {
500525
// Regular league table
501526
Table struct {
502527
All []fotmobTableRow `json:"all"`
503528
} `json:"table"`
504-
// Knockout competition tables (e.g., Champions League)
529+
// Multi-table format: knockout competitions and multi-season leagues
530+
// Examples:
531+
// - Champions League: single table with all teams
532+
// - Liga MX: Clausura + Apertura tables
533+
// - Liga Profesional: Apertura Group A + Group B tables
505534
Tables []struct {
506535
Table struct {
507536
All []fotmobTableRow `json:"all"`
508537
} `json:"table"`
538+
LeagueName string `json:"leagueName"` // e.g., "Clausura", "Apertura - Group A"
509539
} `json:"tables"`
510540
} `json:"data"`
511541
} `json:"table"`
@@ -515,16 +545,22 @@ func (c *Client) fetchLeagueTable(ctx context.Context, leagueID int) ([]api.Leag
515545
return nil, fmt.Errorf("decode league table response for league %d: %w", leagueID, err)
516546
}
517547

518-
// Extract table rows - try regular format first, then knockout format
548+
// Extract table rows - try regular format first, then multi-table format
519549
var tableData []fotmobTableRow
520550
if len(response.Table) > 0 {
521551
data := response.Table[0].Data
522-
// Try regular league format first
552+
// Try regular league format first (single table with all teams)
523553
if len(data.Table.All) > 0 {
524554
tableData = data.Table.All
525-
} else if len(data.Tables) > 0 && len(data.Tables[0].Table.All) > 0 {
526-
// Fall back to knockout competition format
527-
tableData = data.Tables[0].Table.All
555+
} else if len(data.Tables) > 0 {
556+
// Multi-table format: use first sub-table (current/most relevant season)
557+
// This covers both knockout competitions and multi-season leagues
558+
for _, subTable := range data.Tables {
559+
if len(subTable.Table.All) > 0 {
560+
tableData = subTable.Table.All
561+
break
562+
}
563+
}
528564
}
529565
}
530566

internal/fotmob/types.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ type fotmobMatchDetails struct {
140140
ID int `json:"id"`
141141
Name string `json:"name"`
142142
} `json:"awayTeam"`
143-
LeagueID int `json:"leagueId"`
144-
LeagueName string `json:"leagueName"`
143+
LeagueID int `json:"leagueId"`
144+
LeagueName string `json:"leagueName"`
145+
ParentLeagueID int `json:"parentLeagueId"` // Parent league ID for sub-season leagues
145146
} `json:"general"`
146147
Content struct {
147148
MatchFacts struct {
@@ -306,8 +307,9 @@ func (m fotmobMatchDetails) toAPIMatchDetails() *api.MatchDetails {
306307
baseMatch := api.Match{
307308
ID: matchID,
308309
League: api.League{
309-
ID: m.General.LeagueID,
310-
Name: m.General.LeagueName,
310+
ID: m.General.LeagueID,
311+
Name: m.General.LeagueName,
312+
ParentLeagueID: m.General.ParentLeagueID,
311313
},
312314
HomeTeam: api.Team{
313315
ID: m.General.HomeTeam.ID,

0 commit comments

Comments
 (0)