package ui import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" "github.com/humdrum-tiv/sportsball/internal/model" ) const cardHeight = 5 // 3 content rows + 2 border rows // dashboardView composes a fixed header + league tabs, a scrollable card area // sized to the terminal (so the header never scrolls off — the earlier // top-cutoff bug), and a fixed footer. func (a App) dashboardView() string { header := a.header() tabs := a.leagueTabs() states := a.stateTabs() footer := a.footer() if a.loading && len(a.visible()) == 0 { body := styleSubtle.Render(a.spinner.View() + " fetching scores…") return styleApp.Render(strings.Join([]string{header, tabs, states, "", body, "", footer}, "\n")) } // Reserve rows for header, league tabs, state tabs, two blank spacers, footer. const chrome = 6 avail := a.height - chrome if avail < 3 { avail = 3 } lines, selTop := a.bodyLines() lines = scrollWindow(lines, selTop, avail) parts := []string{header, tabs, states, "", strings.Join(lines, "\n"), "", footer} return styleApp.Render(strings.Join(parts, "\n")) } // bodyLines renders all sections to individual lines and reports the first // line index of the currently selected card, for scroll-follow. func (a App) bodyLines() (lines []string, selTop int) { cols := a.columns() idx := 0 // global game index, matches visible() selTop = 0 // default to top secs := a.sections() if len(secs) == 0 { return []string{styleSubtle.Render("No games in this window. Press r to refresh.")}, 0 } for _, s := range secs { lines = append(lines, sectionHeader(s)) for i := 0; i < len(s.games); i += cols { var cells []string for j := i; j < i+cols && j < len(s.games); j++ { selected := idx+j == a.cursor if selected { selTop = len(lines) } gm := s.games[j] cells = append(cells, renderCard(gm, selected, a.pulse(), a.isFav(gm.League, gm.Away), a.isFav(gm.League, gm.Home))) } row := lipgloss.JoinHorizontal(lipgloss.Top, cells...) lines = append(lines, strings.Split(row, "\n")...) } idx += len(s.games) lines = append(lines, "") } return lines, selTop } // scrollWindow returns at most avail lines, scrolled so the line at selTop // (and the rest of its card) stays visible. func scrollWindow(lines []string, selTop, avail int) []string { if len(lines) <= avail { return lines } offset := 0 if selTop+cardHeight > avail { offset = selTop + cardHeight - avail } if offset > len(lines)-avail { offset = len(lines) - avail } if offset < 0 { offset = 0 } end := offset + avail if end > len(lines) { end = len(lines) } return lines[offset:end] } func (a App) columns() int { if a.width <= 0 { return 2 } c := a.width / (cardWidth + 3) switch { case c < 1: return 1 case c > 4: return 4 default: return c } } // header is the title bar with a live-game count, padded full width. func (a App) header() string { title := styleTitle.Render(" SPORTSBALL! ") sub := lipgloss.NewStyle().Foreground(colAccent2).Bold(true).Render(" live scores ") live := a.liveCount() var status string switch { case a.loading: status = styleSubtle.Render(a.spinner.View() + " updating") case live > 0: status = liveDot(a.pulse()) + lipgloss.NewStyle().Foreground(colLive). Render(fmt.Sprintf(" %d live", live)) default: status = styleSubtle.Render("no live games") } left := title + sub gap := a.width - lipgloss.Width(left) - lipgloss.Width(status) if gap < 1 { gap = 1 } return left + strings.Repeat(" ", gap) + status } func (a App) leagueTabs() string { tabs := []string{tab("ALL", a.filter == "")} for _, l := range a.leagues { tabs = append(tabs, tab(l.Icon+" "+l.Abbr, a.filter == l.ID)) } return strings.Join(tabs, styleFaint.Render("│")) } func tab(label string, active bool) string { if active { return styleLeagueSel.Render(label) } return styleLeagueTab.Render(label) } // stateTabs is the chip row for the game-state filter (All/Live/Recent/Upcoming), // the active one highlighted like the league tabs. func (a App) stateTabs() string { items := []struct { f gameFilter label string }{ {filterAllStates, "All"}, {filterLive, "● Live"}, {filterRecent, "◼ Recent"}, {filterUpcoming, "○ Upcoming"}, } chips := make([]string, len(items)) for i, it := range items { chips[i] = tab(it.label, a.stateFilter == it.f) } return strings.Join(chips, styleFaint.Render("│")) } func sectionHeader(s section) string { switch s.level { case lvlDate: // Date divider: muted, uppercased, with a faint rule tail for separation. t := lipgloss.NewStyle().Foreground(colMuted).Bold(true).Render(strings.ToUpper(s.title)) return t + " " + styleFaint.Render(strings.Repeat("─", 4)+" ▓▒░") case lvlLeague: // Indented league subheader nested under a date divider. return " " + lipgloss.NewStyle().Foreground(teamColor(s.color)).Bold(true).Render(s.title) default: return lipgloss.NewStyle().Foreground(teamColor(s.color)).Bold(true).Render(s.title) } } func (a App) footer() string { parts := []string{ keys.Up.Help().Key + " move", keys.Enter.Help().Key + " open", keys.NextLg.Help().Key + " league", keys.Refresh.Help().Key + " refresh", keys.State.Help().Key + " filter", keys.Standings.Help().Key + " standings", "f/F fav", "g/G sched", keys.Theme.Help().Key + " theme", keys.Leagues.Help().Key + " leagues", keys.Quit.Help().Key + " quit", } help := styleHelp.Render(strings.Join(parts, styleFaint.Render(" · "))) name := styleFaint.Render(activeTheme.Name) gap := a.width - lipgloss.Width(help) - lipgloss.Width(name) if gap < 2 { return help } return help + strings.Repeat(" ", gap) + name } func (a App) liveCount() int { n := 0 for _, l := range a.leagues { for _, g := range a.games[l.ID] { if g.State == model.StateLive { n++ } } } return n }