package ui import ( "fmt" "sort" "strings" "time" "github.com/charmbracelet/lipgloss" "github.com/humdrum-tiv/sportsball/internal/model" ) // tickerCellW is the inner width of one ticker box (before border). const tickerCellW = 12 // tickerGames is the ordered list of games shown in the detail-view ticker: // live games first (the watched game's league before other leagues), then // today's finals (same ordering). The currently-watched game is excluded so // the strip is always "around the league" context. Built from already-polled // data — no extra fetch. func (a App) tickerGames() []model.Game { now := time.Now() watchLeague := a.detail.League // Collect today's live + final games (excluding the watched one), preserving // league display order so it survives the stable sort as the final tiebreak. var games []model.Game for _, l := range a.leagues { for _, g := range filterDay(a.games[l.ID], now) { if g.ID == a.detail.ID { continue } if g.State == model.StateLive || g.State == model.StateFinal { games = append(games, g) } } } // Rank (low = first): // the game we just navigated away from (back-breadcrumb), then favorites, // then by league (watched before others), then live before final: // fav → same-live → same-final → other-live → other-final. // Stable so same-rank games keep their league/scoreboard order. rank := func(g model.Game) int { if g.ID == a.detailPrev { return -1 } r := 0 if !a.hasFav(g) { r += 100 } if g.League != watchLeague { r += 10 } if g.State != model.StateLive { r++ } return r } sort.SliceStable(games, func(i, j int) bool { return rank(games[i]) < rank(games[j]) }) return games } // tickerStrip renders the selectable game boxes above the watched game's score // box, windowed horizontally so the selected box stays on screen. Returns "" // when there are no other games to show. func (a App) tickerStrip() string { games := a.tickerGames() if len(games) == 0 { return "" } sel := a.tickerCursor if sel >= len(games) { sel = len(games) - 1 } if sel < 0 { sel = 0 } cells := make([]string, len(games)) for i, g := range games { cells[i] = a.tickerCell(g, i == sel) } // Each rendered cell is tickerCellW + 2 (border) wide; +1 for the gap. cellW := tickerCellW + 2 + 1 fit := max(1, a.width/cellW) lo, hi := tickerWindow(len(cells), sel, fit) row := lipgloss.JoinHorizontal(lipgloss.Top, cells[lo:hi]...) left := " " if lo > 0 { left = lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render("‹ ") } right := " " if hi < len(cells) { right = lipgloss.NewStyle().Foreground(colAccent).Bold(true).Render(" ›") } strip := lipgloss.JoinHorizontal(lipgloss.Center, left, row, right) return lipgloss.PlaceHorizontal(a.width, lipgloss.Center, strip, lipgloss.WithWhitespaceChars(" ")) } // tickerWindow returns the [lo,hi) slice of n cells that keeps index sel // visible within a fit-wide window. func tickerWindow(n, sel, fit int) (int, int) { if n <= fit { return 0, n } lo := sel - fit/2 if lo < 0 { lo = 0 } if lo+fit > n { lo = n - fit } return lo, lo + fit } // tickerCell renders one compact game box: a state badge over away/home rows // with scores. Selected gets the accent border; the back-breadcrumb (the game // just navigated away from) is marked with a ↩ and a purple border; others a // faint border. func (a App) tickerCell(g model.Game, selected bool) string { isPrev := g.ID == a.detailPrev l, _ := model.LeagueByID(g.League) tag := lipgloss.NewStyle().Foreground(teamColor(l.Color)).Bold(true).Render(l.Icon) if isPrev { tag = lipgloss.NewStyle().Foreground(colAccent2).Bold(true).Render("↩") + tag } // League glyph on the left, state/clock filling the rest. Truncate the raw // text (not the styled string) so ANSI codes stay intact. avail := max(tickerCellW-lipgloss.Width(tag)-1, 0) var state string switch g.State { case model.StateLive: clock := g.Clock if clock == "" || clock == "0:00" { clock = "LIVE" } state = liveDot(a.pulse()) + " " + lipgloss.NewStyle().Foreground(colLive).Bold(true). Render(truncate(clock, avail-2)) case model.StateFinal: state = styleFinal.Render(truncate("◼ FIN", avail)) default: state = styleSubtle.Render(truncate(startLabel(g), avail)) } badge := lipgloss.NewStyle().Width(tickerCellW).Render(tag + " " + state) row := func(t model.Team) string { star := " " if a.isFav(g.League, t) { star = lipgloss.NewStyle().Foreground(colWarn).Render("★") } abbr := star + lipgloss.NewStyle().Foreground(teamColor(t.Color)).Bold(true). Render(fmt.Sprintf("%-3s", t.Abbr)) score := "-" if g.Started() { score = fmt.Sprintf("%d", t.Score) } ss := styleScore if g.Started() && t.Winner { ss = styleWin } gap := tickerCellW - lipgloss.Width(abbr) - lipgloss.Width(score) if gap < 1 { gap = 1 } return abbr + strings.Repeat(" ", gap) + ss.Render(score) } body := lipgloss.JoinVertical(lipgloss.Left, badge, row(g.Away), row(g.Home)) border := lipgloss.NormalBorder() style := lipgloss.NewStyle().Border(border).BorderForeground(colBorder). Padding(0, 0).Width(tickerCellW) switch { case selected: style = style.BorderForeground(colAccent) case isPrev: style = style.BorderForeground(colAccent2) } return style.Render(body) }