package ui import ( "sort" "time" "github.com/humdrum-tiv/sportsball/internal/model" ) // section is a titled group of games in the dashboard. The dashboard renders // sections in order; the global cursor indexes their flattened concatenation. // level selects the header style: a normal section title, a date divider, or // an indented league subheader nested under a date (used by filtered views). type section struct { title string color string // accent hex (no '#') games []model.Game level headerLevel } type headerLevel int const ( lvlNormal headerLevel = iota lvlDate // "# June 16" — date divider, no games of its own lvlLeague // "## WC" — indented league subheader under a date ) // gameFilter narrows the dashboard slate by game state so the user can see just // live games, just recent finals, or just upcoming fixtures without scrolling. type gameFilter int const ( filterAllStates gameFilter = iota filterLive filterRecent filterUpcoming ) func (f gameFilter) String() string { switch f { case filterLive: return "Live" case filterRecent: return "Recent" case filterUpcoming: return "Upcoming" default: return "All" } } // sections builds the dashboard's groups for the current filter: // // - All leagues: one section per league, today's games only — keeps the // overview scannable. // - Single league: Past / Today / Upcoming buckets across the fetched // window, so you can see recent finals and what's next. // // When a state filter (Live/Recent/Upcoming) is active, the slate is narrowed // to that state across the fetched window and favorites are not pinned — the // view is purely "just the live games", etc. func (a App) sections() []section { now := time.Now() if a.stateFilter != filterAllStates { return a.filteredSections(now) } if a.filter == "" { var out []section // Favorites pinned on top: today's games (any league) with a favorite. var fav []model.Game favSeen := map[string]bool{} for _, l := range a.leagues { for _, g := range filterDay(a.games[l.ID], now) { if a.hasFav(g) { fav = append(fav, g) favSeen[g.ID] = true } } } if len(fav) > 0 { out = append(out, section{"★ Favorites", colWarnHex, fav, lvlNormal}) } for _, l := range a.leagues { var today []model.Game for _, g := range filterDay(a.games[l.ID], now) { if !favSeen[g.ID] { // already shown in Favorites today = append(today, g) } } if len(today) > 0 { out = append(out, section{l.Icon + " " + l.Name, l.Color, today, lvlNormal}) } } // Recent finals from before today (across leagues), newest first then // league order, so the all-leagues view ends with the latest scores. if rec := a.recentFinals(now); len(rec) > 0 { out = append(out, section{"◼ Recent", "", rec, lvlNormal}) } return out } l, _ := model.LeagueByID(a.filter) past, today, upcoming := bucket(a.games[a.filter], now) var out []section // Favorites bucket: each favorite team's live + last final + next fixture // from the fetched window, pinned above the regular buckets. if fav := a.favoriteHighlights(a.filter, now); len(fav) > 0 { out = append(out, section{"★ Favorites", colWarnHex, fav, lvlNormal}) } if len(today) > 0 { out = append(out, section{"● Today", l.Color, today, lvlNormal}) } if len(upcoming) > 0 { out = append(out, section{"○ Upcoming", l.Color, upcoming, lvlNormal}) } if len(past) > 0 { out = append(out, section{"◼ Past", l.Color, past, lvlNormal}) } return out } // filteredSections builds the slate when a state filter is active: favorites // matching the filter are pinned on top, then the rest as one flat list. // All-leagues collapses per-league grouping into a single state-ordered list // (live keeps client order; recent newest-first; upcoming chronological), with // league order as the tiebreak so same-time games stay stable. func (a App) filteredSections(now time.Time) []section { want, ok := stateForFilter(a.stateFilter) if !ok { return nil } var leagues []model.LeagueID if a.filter == "" { for _, l := range a.leagues { leagues = append(leagues, l.ID) } } else { leagues = []model.LeagueID{a.filter} } var favs, rest []model.Game for _, lid := range leagues { for _, g := range a.games[lid] { if g.State != want { continue } if a.hasFav(g) { favs = append(favs, g) } else { rest = append(rest, g) } } } // Most-recent-first for Recent; chronological for Upcoming and Live. Order // by day → league → time so each league's games on a day stay contiguous // (raw time order would interleave leagues with different kickoff times), // matching the date/league grouping below. idx := a.leagueIndex() desc := a.stateFilter == filterRecent sortByDayLeagueTime(favs, idx, desc) sortByDayLeagueTime(rest, idx, desc) var out []section if len(favs) > 0 { out = append(out, section{"★ Favorites", colWarnHex, favs, lvlNormal}) } // Rest grouped under date dividers ("# June 16"), then league subheaders // ("## WC") within each date. out = append(out, dateLeagueSections(rest)...) return out } // sortByDayLeagueTime orders games by calendar day, then league order, then // start time. desc=true puts the most recent day first and, within a league, // the later game first (Recent); desc=false is chronological (Upcoming/Live). // Keeping a league's games contiguous within a day is what lets // dateLeagueSections emit one subheader per league per date. func sortByDayLeagueTime(games []model.Game, idx map[model.LeagueID]int, desc bool) { sort.SliceStable(games, func(i, j int) bool { di, dj := dayKey(games[i].Start), dayKey(games[j].Start) if di != dj { if desc { return di > dj } return di < dj } if li, lj := idx[games[i].League], idx[games[j].League]; li != lj { return li < lj } if desc { return games[i].Start.After(games[j].Start) } return games[i].Start.Before(games[j].Start) }) } // dateLeagueSections groups a date-then-league sorted slice into a date divider // section (no games) followed by an indented league subheader section per // league on that date. Games within a (date, league) run stay in input order. func dateLeagueSections(games []model.Game) []section { var out []section i := 0 for i < len(games) { d := dayKey(games[i].Start) out = append(out, section{games[i].Start.Local().Format("January 2"), "", nil, lvlDate}) for i < len(games) && dayKey(games[i].Start) == d { lid := games[i].League l, _ := model.LeagueByID(lid) var gs []model.Game for i < len(games) && dayKey(games[i].Start) == d && games[i].League == lid { gs = append(gs, games[i]) i++ } out = append(out, section{l.Icon + " " + l.Name, l.Color, gs, lvlLeague}) } } return out } // dayKey is a stable per-calendar-day key in local time for date grouping. func dayKey(t time.Time) string { return t.Local().Format("2006-01-02") } // stateForFilter maps a state filter to the game State it selects; ok=false for // filterAllStates (which has no single target state). func stateForFilter(f gameFilter) (model.State, bool) { switch f { case filterLive: return model.StateLive, true case filterRecent: return model.StateFinal, true case filterUpcoming: return model.StatePre, true default: return 0, false } } // sortByStartLeague orders games by start time then league order. desc=true // puts the most recent first (finals/recent); desc=false is chronological. func sortByStartLeague(games []model.Game, idx map[model.LeagueID]int, desc bool) { sort.SliceStable(games, func(i, j int) bool { if !games[i].Start.Equal(games[j].Start) { if desc { return games[i].Start.After(games[j].Start) } return games[i].Start.Before(games[j].Start) } return idx[games[i].League] < idx[games[j].League] }) } // favoriteHighlights returns, for each favorited team in the league, the games // worth pinning: any live game, the most recent final (last result), and the // next scheduled fixture (next), drawn from the fetched window. Games are // de-duplicated and ordered live → next → last. func (a App) favoriteHighlights(league model.LeagueID, now time.Time) []model.Game { games := a.games[league] type pick struct{ live, next, last *model.Game } picks := map[string]*pick{} var order []string // stable team order (first-seen), avoids map-iteration jitter teamKeys := func(g model.Game) []string { var ks []string if a.isFav(g.League, g.Home) { ks = append(ks, favKey(g.League, g.Home)) } if a.isFav(g.League, g.Away) { ks = append(ks, favKey(g.League, g.Away)) } return ks } for i := range games { g := games[i] for _, k := range teamKeys(g) { p := picks[k] if p == nil { p = &pick{} picks[k] = p order = append(order, k) } switch g.State { case model.StateLive: p.live = &games[i] case model.StatePre: if p.next == nil || g.Start.Before(p.next.Start) { p.next = &games[i] } case model.StateFinal: if p.last == nil || g.Start.After(p.last.Start) { p.last = &games[i] } } } } var out []model.Game seen := map[string]bool{} add := func(g *model.Game) { if g != nil && !seen[g.ID] { seen[g.ID] = true out = append(out, *g) } } for _, k := range order { p := picks[k] add(p.live) add(p.next) add(p.last) } return out } // recentFinals returns finals that completed before today (within the fetched // window), most-recent-first then league order. Today's finals already appear // in each league's section, so this surfaces yesterday-and-earlier scores. func (a App) recentFinals(now time.Time) []model.Game { startToday := startOfDay(now) var out []model.Game for _, l := range a.leagues { for _, g := range a.games[l.ID] { if g.State == model.StateFinal && g.Start.Before(startToday) { out = append(out, g) } } } sortByStartLeague(out, a.leagueIndex(), true) return out } // visible is the cursor-addressable flat list, matching sections() order. func (a App) visible() []model.Game { var out []model.Game for _, s := range a.sections() { out = append(out, s.games...) } return out } // bucket splits a league's window into past finals, today's slate, and future // scheduled games. Today keeps the client's live→sched→final ordering; // upcoming is chronological; past is reverse-chronological (most recent first). func bucket(games []model.Game, now time.Time) (past, today, upcoming []model.Game) { startToday := startOfDay(now) startTomorrow := startToday.AddDate(0, 0, 1) for _, g := range games { switch { case !g.Start.Before(startTomorrow): upcoming = append(upcoming, g) case !g.Start.Before(startToday): today = append(today, g) default: past = append(past, g) } } // Reverse past so the most recent final leads. for i, j := 0, len(past)-1; i < j; i, j = i+1, j-1 { past[i], past[j] = past[j], past[i] } return past, today, upcoming } // filterDay returns only the games that start on now's calendar day. func filterDay(games []model.Game, now time.Time) []model.Game { start := startOfDay(now) end := start.AddDate(0, 0, 1) var out []model.Game for _, g := range games { if !g.Start.Before(start) && g.Start.Before(end) { out = append(out, g) } } return out } func startOfDay(t time.Time) time.Time { y, m, d := t.Date() return time.Date(y, m, d, 0, 0, 0, 0, t.Location()) }