package ui import ( "context" "time" tea "github.com/charmbracelet/bubbletea" "github.com/humdrum-tiv/sportsball/internal/espn" "github.com/humdrum-tiv/sportsball/internal/model" ) // --- Messages --------------------------------------------------------------- // gamesMsg carries a finished scoreboard fetch for one league. type gamesMsg struct { League model.LeagueID Games []model.Game Err error } // detailMsg carries a finished summary fetch for one open game, keyed by ID. type detailMsg struct { ID string Data model.GameDetail Err error } // standingsMsg carries a finished standings fetch for one league. type standingsMsg struct { League model.LeagueID Data model.Standings Err error } // scheduleMsg carries a finished team-schedule fetch, keyed by team ID. type scheduleMsg struct { TeamID string Data model.TeamSchedule Err error } // seasonMsg reports whether a league has any games within its in-season span, // from the periodic season scan (TASK-019). type seasonMsg struct { League model.LeagueID InSeason bool } // seasonScanMsg fires on the slow season-scan interval to re-probe which // leagues are in season (season status changes on the scale of days). type seasonScanMsg struct{} // pollMsg fires on the data-refresh interval to trigger a new fetch round. type pollMsg struct{} // animMsg fires on the fast animation tick to advance pulses/transitions. type animMsg time.Time // Intervals tuned so live scores feel fresh without hammering ESPN. const ( pollInterval = 15 * time.Second animInterval = 80 * time.Millisecond // ~12.5fps, plenty for terminal flair // seasonScanInterval is slow: in-season status only changes day to day, so // re-probing every several minutes is plenty (and cheap — one scoreboard // call per catalog league). seasonScanInterval = 10 * time.Minute ) // --- Commands --------------------------------------------------------------- // fetchLeague returns a command that fetches one league's games across the // Past→Upcoming window. func fetchLeague(c *espn.Client, l model.League) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() now := time.Now() back, forward := l.Window() start := now.AddDate(0, 0, -back) end := now.AddDate(0, 0, forward) games, err := c.ScoreboardRange(ctx, l, start, end) return gamesMsg{League: l.ID, Games: games, Err: err} } } // fetchDetail returns a command that pulls the summary (key events / box // score) for one game. Keyed by event ID so the detail view can refresh the // open game without re-fetching everything. func fetchDetail(c *espn.Client, l model.League, id string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() data, err := c.Summary(ctx, l, id) return detailMsg{ID: id, Data: data, Err: err} } } // fetchAll fans out a fetch across the given (enabled) leagues concurrently. // Each league resolves into its own gamesMsg as it completes. func fetchAll(c *espn.Client, leagues []model.League) tea.Cmd { cmds := make([]tea.Cmd, len(leagues)) for i, l := range leagues { cmds[i] = fetchLeague(c, l) } return tea.Batch(cmds...) } // fetchStandings returns a command that pulls one league's standings table(s). func fetchStandings(c *espn.Client, l model.League) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() s, err := c.Standings(ctx, l) return standingsMsg{League: l.ID, Data: s, Err: err} } } // fetchTeamSchedule returns a command that pulls a team's full season schedule. func fetchTeamSchedule(c *espn.Client, l model.League, teamID string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() s, err := c.TeamSchedule(ctx, l, teamID) return scheduleMsg{TeamID: teamID, Data: s, Err: err} } } // seasonScan fans out a wide scoreboard probe across all catalog leagues to // learn which are currently in season. Each resolves into its own seasonMsg. func seasonScan(c *espn.Client, leagues []model.League) tea.Cmd { cmds := make([]tea.Cmd, len(leagues)) for i, l := range leagues { cmds[i] = fetchSeason(c, l) } return tea.Batch(cmds...) } // fetchSeason reports whether a league has any games within its ±SeasonWindow, // the signal behind auto-hiding off-season leagues. func fetchSeason(c *espn.Client, l model.League) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() d := l.SeasonWindow() now := time.Now() games, err := c.ScoreboardRange(ctx, l, now.AddDate(0, 0, -d), now.AddDate(0, 0, d)) // On error, treat as in-season so a transient failure never hides a league. return seasonMsg{League: l.ID, InSeason: err != nil || len(games) > 0} } } // seasonScanTick schedules the next season re-probe. func seasonScanTick() tea.Cmd { return tea.Tick(seasonScanInterval, func(time.Time) tea.Msg { return seasonScanMsg{} }) } // pollTick schedules the next data refresh. func pollTick() tea.Cmd { return tea.Tick(pollInterval, func(time.Time) tea.Msg { return pollMsg{} }) } // animTick schedules the next animation frame. func animTick() tea.Cmd { return tea.Tick(animInterval, func(t time.Time) tea.Msg { return animMsg(t) }) }