▍ humdrum codex / sportsball v0.1.0
license AGPL-3.0
5.3 KB raw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
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) })
}