package espn import ( "context" "encoding/json" "fmt" "net/http" "strings" "github.com/humdrum-tiv/sportsball/internal/model" ) // Summary fetches the deeper per-game data (key events for soccer, box score // for the stick-and-ball sports) from ESPN's summary endpoint and maps it into // a league-agnostic model.GameDetail. This is the richer fetch behind the // detail view, keyed by ESPN event ID. func (c *Client) Summary(ctx context.Context, l model.League, eventID string) (model.GameDetail, error) { url := fmt.Sprintf("%s/%s/%s/summary?event=%s", baseURL, l.Sport, l.Path, eventID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return model.GameDetail{}, err } resp, err := c.HTTP.Do(req) if err != nil { return model.GameDetail{}, fmt.Errorf("summary %s: %w", eventID, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return model.GameDetail{}, fmt.Errorf("summary %s: status %d", eventID, resp.StatusCode) } var s summary if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { return model.GameDetail{}, fmt.Errorf("decode summary %s: %w", eventID, err) } return mapSummary(s), nil } func mapSummary(s summary) model.GameDetail { d := model.GameDetail{} for _, e := range s.KeyEvents { d.Events = append(d.Events, mapKeyEvent(e)) } // Scoring plays back the baseball timeline (soccer uses keyEvents above). // They're mutually exclusive per sport, so both feed model.Events. for _, p := range s.Plays { if p.ScoringPlay { d.Events = append(d.Events, mapScoringPlay(p)) } } for _, tb := range s.Boxscore.Players { d.BoxScore = append(d.BoxScore, mapTeamBox(tb)) } d.TeamStats = mapTeamStats(s.Boxscore.Teams) return d } // soccerTeamStatOrder is the curated subset of team stats shown as comparison // bars, in display order. ESPN ships many more; these are the legible ones. var soccerTeamStatOrder = []string{ "possessionPct", "totalShots", "shotsOnTarget", "wonCorners", "foulsCommitted", "saves", } // mapTeamStats pairs the two teams' flat stat lists into away-vs-home rows for // the curated stats present. Returns nil unless both home and away are found // (the stick-and-ball sports nest team stats differently and yield nothing). func mapTeamStats(teams []teamStatBox) []model.TeamStat { var home, away map[string]teamStatJSON for _, t := range teams { m := make(map[string]teamStatJSON, len(t.Statistics)) for _, s := range t.Statistics { m[s.Name] = s } if t.HomeAway == "home" { home = m } else { away = m } } if home == nil || away == nil { return nil } var out []model.TeamStat for _, key := range soccerTeamStatOrder { h, okh := home[key] a, oka := away[key] if !okh && !oka { continue } out = append(out, model.TeamStat{ Label: firstNonEmpty(h.Label, a.Label, key), Key: key, Away: a.DisplayValue, Home: h.DisplayValue, }) } return out } // mapScoringPlay converts a baseball scoring play into a MatchEvent. Clock is a // compact half-inning marker (▲ top / ▼ bottom + inning); Team is matched by // ID since the plays array only carries the team id. func mapScoringPlay(p play) model.MatchEvent { mark := "" switch p.Period.Type { case "Top": mark = "▲" case "Bottom": mark = "▼" } clock := mark + fmt.Sprintf("%d", p.Period.Number) text := strings.TrimSpace(p.Text) return model.MatchEvent{ Clock: clock, Period: p.Period.Number, Type: strings.TrimSpace(p.AlternativeType.Text), Text: text, ShortText: text, TeamID: p.Team.ID, Scoring: true, } } func mapKeyEvent(e keyEvent) model.MatchEvent { me := model.MatchEvent{ Clock: e.Clock.DisplayValue, Period: e.Period.Number, Type: strings.TrimSpace(e.Type.Text), Text: strings.TrimSpace(e.Text), ShortText: strings.TrimSpace(e.ShortText), Team: e.Team.DisplayName, TeamID: e.Team.ID, Scoring: e.ScoringPlay, } for _, p := range e.Participants { if n := p.Athlete.DisplayName; n != "" { me.Athletes = append(me.Athletes, n) } } return me } func mapTeamBox(tb playerBox) model.TeamBox { box := model.TeamBox{ Abbr: tb.Team.Abbreviation, Name: tb.Team.DisplayName, } for _, st := range tb.Statistics { g := model.StatGroup{ Name: firstNonEmpty(st.Name, st.Type, st.DisplayName), Labels: st.Labels, } for _, a := range st.Athletes { name := a.Athlete.ShortName if name == "" { name = a.Athlete.DisplayName } g.Rows = append(g.Rows, model.PlayerRow{Athlete: name, Stats: a.Stats}) } box.Groups = append(box.Groups, g) } return box } func firstNonEmpty(ss ...string) string { for _, s := range ss { if s != "" { return s } } return "" }