// Package ui is the Bubble Tea front end: a root App model that owns all // game data and switches between the dashboard grid and a single-game // detail view. Data flows in via async commands (see commands.go). package ui import ( "math" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/humdrum-tiv/sportsball/internal/config" "github.com/humdrum-tiv/sportsball/internal/espn" "github.com/humdrum-tiv/sportsball/internal/model" ) type viewMode int const ( viewDashboard viewMode = iota viewDetail viewSettings viewStandings viewSchedule ) // App is the root Bubble Tea model. type App struct { client *espn.Client width, height int games map[model.LeagueID][]model.Game errs map[model.LeagueID]error loaded map[model.LeagueID]bool loading bool // leagues is the user's effective league set: enabled leagues in display // order (see config.Leagues / resolveLeagues). Everything — fetch, tabs, // sections, sort order — reads this instead of model.Leagues. leagues []model.League // League visibility (TASK-002 / TASK-019): leagues auto-show by season, // with per-league overrides. See leagues.go. `leagues` is the resolved // ordered visible set; order/hide/show are the inputs, inSeason the scan // result. order []model.LeagueID hide map[model.LeagueID]bool show map[model.LeagueID]bool inSeason map[model.LeagueID]bool filter model.LeagueID // "" means all leagues stateFilter gameFilter // All/Live/Recent/Upcoming slate filter (see sections.go) cursor int // index into the current visible() slice mode viewMode detail model.Game // game shown in detail view settingsCursor int // selected row in the leagues settings screen settingsDirty bool // user changed leagues in settings → persist as manual detailData map[string]model.GameDetail // summary data keyed by event ID detailErr error // last summary fetch error for open game detailScroll int // vertical scroll offset in detail view tickerCursor int // selected box in the detail-view ticker strip detailPrev string // ID of the game viewed before this one (ticker back-breadcrumb) // Standings view (TASK-016). standings model.Standings standingsErr error standingsLeague model.LeagueID // league whose table is shown standingsCursor int // selected team row (index into the flattened rows) // Schedule view (TASK-017). schedule model.TeamSchedule scheduleErr error scheduleScroll int scheduleLeague model.LeagueID // league of the team whose schedule is shown prevMode viewMode // view to return to when leaving the schedule cfg config.Config // persisted preferences favs map[string]bool // favorite team keys (see favKey) theme int // active theme index into themes (see theme.go) dark bool // terminal appearance — restricts theme cycling to matching set spinner spinner.Model phase float64 // animation phase accumulator (radians) } // New builds the initial App. func New() App { sp := spinner.New() sp.Spinner = spinner.Dot cfg := config.Load() dark := lipgloss.HasDarkBackground() theme := resolveTheme(cfg.Theme, dark) applyTheme(themes[theme]) a := App{ client: espn.New(), games: map[model.LeagueID][]model.Game{}, errs: map[model.LeagueID]error{}, loaded: map[model.LeagueID]bool{}, detailData: map[string]model.GameDetail{}, order: leagueOrderFromCfg(cfg.LeagueOrder), hide: idSet(cfg.HideLeagues), show: idSet(cfg.ShowLeagues), inSeason: map[model.LeagueID]bool{}, cfg: cfg, favs: loadFavorites(cfg), theme: theme, dark: dark, loading: true, spinner: sp, } a.applyAutoLeagues() // resolve the initial visible set (all until the scan narrows) return a } // Init kicks off the first fetch and starts the poll + animation loops. func (a App) Init() tea.Cmd { return tea.Batch( fetchAll(a.client, a.leagues), seasonScan(a.client, model.Leagues), seasonScanTick(), pollTick(), animTick(), a.spinner.Tick, ) } func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: a.width, a.height = msg.Width, msg.Height return a, nil case tea.KeyMsg: return a.handleKey(msg) case gamesMsg: a.loaded[msg.League] = true if msg.Err != nil { a.errs[msg.League] = msg.Err } else { delete(a.errs, msg.League) a.games[msg.League] = msg.Games } if len(a.loaded) >= len(a.leagues) { a.loading = false } a.clampCursor() return a, nil case detailMsg: if msg.Err != nil { a.detailErr = msg.Err } else { a.detailErr = nil a.detailData[msg.ID] = msg.Data } return a, nil case standingsMsg: // Ignore a stale fetch if the user switched leagues meanwhile. if msg.League != a.standingsLeague { return a, nil } if msg.Err != nil { a.standingsErr = msg.Err } else { a.standingsErr = nil a.standings = msg.Data } a.clampStandingsCursor() return a, nil case scheduleMsg: if msg.Err != nil { a.scheduleErr = msg.Err } else { a.scheduleErr = nil a.schedule = msg.Data // Open the list at the next game rather than the season's start. if _, top := a.scheduleLines(); top > 0 { a.scheduleScroll = min(top, a.scheduleScrollMax()) } } return a, nil case seasonMsg: a.inSeason[msg.League] = msg.InSeason if a.applyAutoLeagues() { // League set changed — fetch any newly-shown leagues' games now. return a, fetchAll(a.client, a.leagues) } return a, nil case seasonScanMsg: return a, tea.Batch(seasonScan(a.client, model.Leagues), seasonScanTick()) case pollMsg: cmds := []tea.Cmd{fetchAll(a.client, a.leagues), pollTick()} // Refresh the open game's summary in step with the poll loop so the // detail view stays live without its own ticker. if a.mode == viewDetail { if l, ok := model.LeagueByID(a.detail.League); ok { cmds = append(cmds, fetchDetail(a.client, l, a.detail.ID)) } } return a, tea.Batch(cmds...) case animMsg: a.phase += 0.18 if a.phase > 2*math.Pi { a.phase -= 2 * math.Pi } return a, animTick() case spinner.TickMsg: var cmd tea.Cmd a.spinner, cmd = a.spinner.Update(msg) return a, cmd } return a, nil } func (a App) View() string { var frame string switch a.mode { case viewDetail: frame = a.detailView() case viewSettings: frame = a.settingsView() case viewStandings: frame = a.standingsView() case viewSchedule: frame = a.scheduleView() default: frame = a.dashboardView() } // Paint the theme background across the whole frame so a light theme on a // dark terminal (or vice-versa) shows the theme's paper, not the terminal's. return paintBackground(frame, a.width) } // pulse is the current live-indicator brightness in [0,1]. func (a App) pulse() float64 { return (math.Sin(a.phase) + 1) / 2 } func (a *App) clampCursor() { n := len(a.visible()) if a.cursor >= n { a.cursor = n - 1 } if a.cursor < 0 { a.cursor = 0 } } func (a *App) clampStandingsCursor() { n := a.standings.RowCount() if a.standingsCursor >= n { a.standingsCursor = n - 1 } if a.standingsCursor < 0 { a.standingsCursor = 0 } }