package espn import ( "context" "encoding/json" "fmt" "net/http" "sort" "strconv" "strings" "github.com/humdrum-tiv/sportsball/internal/model" ) // standingsBase is ESPN's standings host, which lives under apis/v2 (NOT the // apis/site/v2 path the scoreboard/summary endpoints use). const standingsBase = "https://site.api.espn.com/apis/v2/sports" // colSpec maps a display column header to the ESPN stat `type` it pulls from. type colSpec struct{ label, typ string } // standingsCols is the per-sport column layout: which stats to show and in what // order. Soccer is a points table; the stick-and-ball sports show records. var standingsCols = map[string][]colSpec{ "soccer": {{"P", "gamesplayed"}, {"W", "wins"}, {"D", "ties"}, {"L", "losses"}, {"GD", "pointdifferential"}, {"Pts", "points"}}, "baseball": {{"W", "wins"}, {"L", "losses"}, {"PCT", "winpercent"}, {"GB", "gamesbehind"}}, "basketball": {{"W", "wins"}, {"L", "losses"}, {"PCT", "winpercent"}, {"GB", "gamesbehind"}}, "football": {{"W", "wins"}, {"L", "losses"}, {"T", "ties"}, {"PCT", "winpercent"}}, "hockey": {{"W", "wins"}, {"L", "losses"}, {"OTL", "otlosses"}, {"PTS", "points"}}, } var defaultCols = []colSpec{{"W", "wins"}, {"L", "losses"}} // rankStat is the ESPN stat each sport ranks its table by — ESPN returns the // entries unsorted (or alphabetized), so we sort on this ourselves. Soccer // ships an explicit "rank"; the stick-and-ball sports use "playoffseed". var rankStat = map[string]string{ "soccer": "rank", "baseball": "playoffseed", "basketball": "playoffseed", "hockey": "playoffseed", "football": "playoffseed", } // Standings fetches and normalizes a league's standings table(s). Groups are // flattened from ESPN's nested children (league/conference → entries) so each // table that actually has teams becomes one model.StandingsGroup. func (c *Client) Standings(ctx context.Context, l model.League) (model.Standings, error) { url := fmt.Sprintf("%s/%s/%s/standings", standingsBase, l.Sport, l.Path) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return model.Standings{}, err } resp, err := c.HTTP.Do(req) if err != nil { return model.Standings{}, fmt.Errorf("standings %s: %w", l.ID, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return model.Standings{}, fmt.Errorf("standings %s: status %d", l.ID, resp.StatusCode) } var r standingsNode if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return model.Standings{}, fmt.Errorf("decode standings %s: %w", l.ID, err) } return mapStandings(l.ID, l.Sport, r), nil } func mapStandings(id model.LeagueID, sport string, root standingsNode) model.Standings { cols := standingsCols[sport] if cols == nil { cols = defaultCols } labels := make([]string, len(cols)) for i, c := range cols { labels[i] = c.label } rankKey := rankStat[sport] if rankKey == "" { rankKey = "playoffseed" } out := model.Standings{League: id} // Walk the tree: a node with entries is a table; recurse into children. var walk func(n standingsNode) walk = func(n standingsNode) { if len(n.Standings.Entries) > 0 { out.Groups = append(out.Groups, mapGroup(n.Name, n.Standings.Entries, cols, labels, rankKey)) } for _, ch := range n.Children { walk(ch) } } walk(root) return out } func mapGroup(name string, entries []standingsEntry, cols []colSpec, labels []string, rankKey string) model.StandingsGroup { // ESPN returns entries unsorted; order them by the sport's rank stat. Entries // missing it sink to the bottom while keeping their relative order. sort.SliceStable(entries, func(i, j int) bool { return entryRank(entries[i], rankKey) < entryRank(entries[j], rankKey) }) g := model.StandingsGroup{Name: name, Columns: labels} for i, e := range entries { byType := make(map[string]string, len(e.Stats)) for _, s := range e.Stats { byType[s.Type] = s.DisplayValue } row := model.StandingsRow{Rank: i + 1, Team: teamFromJSON(e.Team)} for _, c := range cols { row.Values = append(row.Values, byType[c.typ]) } g.Rows = append(g.Rows, row) } return g } // entryRank reads the rank stat as an int, returning a large sentinel when it's // absent or unparseable so those entries sort last. func entryRank(e standingsEntry, rankKey string) int { for _, s := range e.Stats { if s.Type == rankKey { if n, err := strconv.Atoi(strings.TrimSpace(s.DisplayValue)); err == nil { return n } } } return 1 << 30 } // --- standings JSON --------------------------------------------------------- // standingsNode is recursive: the root and each child share this shape — a name, // an optional standings block (entries), and optional nested children. type standingsNode struct { Name string `json:"name"` Standings standingsBlock `json:"standings"` Children []standingsNode `json:"children"` } type standingsBlock struct { Entries []standingsEntry `json:"entries"` } type standingsEntry struct { Team teamJSON `json:"team"` Stats []standingStat `json:"stats"` } type standingStat struct { Type string `json:"type"` DisplayValue string `json:"displayValue"` }