Files
box-box/internal/ui/standings.go

475 lines
11 KiB
Go
Raw Normal View History

2026-03-03 00:48:05 -05:00
package ui
import (
"fmt"
"strings"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type standingsView int
const (
standingsViewDriver standingsView = iota
standingsViewConstructor
)
type StandingsModel struct {
client *api.OpenF1Client
driverStandings []models.ChampionshipDriver
teamStandings []models.ChampionshipTeam
2026-03-03 02:29:34 -05:00
drivers map[int]models.Driver // driver_number -> Driver
2026-03-03 00:48:05 -05:00
view standingsView
loading bool
2026-03-27 19:27:38 -04:00
stale bool
2026-03-03 00:48:05 -05:00
err error
spinner spinner.Model
cursor int
2026-03-03 02:29:34 -05:00
scroll int
year int
2026-03-03 00:48:05 -05:00
width int
height int
}
2026-03-03 01:16:04 -05:00
func NewStandingsModel(client *api.OpenF1Client, year int) StandingsModel {
2026-03-03 00:48:05 -05:00
s := spinner.New()
s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
2026-03-03 02:29:34 -05:00
2026-03-03 00:48:05 -05:00
return StandingsModel{
client: client,
view: standingsViewDriver,
loading: true,
spinner: s,
drivers: make(map[int]models.Driver),
2026-03-03 01:16:04 -05:00
year: year,
2026-03-03 00:48:05 -05:00
}
}
func (m StandingsModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
2026-03-03 01:16:04 -05:00
fetchDriverChampionship(m.client, m.year),
fetchTeamChampionship(m.client, m.year),
2026-03-03 00:48:05 -05:00
)
}
2026-03-03 01:16:04 -05:00
func fetchDriverChampionship(client *api.OpenF1Client, year int) tea.Cmd {
2026-03-03 00:48:05 -05:00
return func() tea.Msg {
2026-03-03 01:16:04 -05:00
standings, err := client.GetDriverChampionshipForYear(year)
2026-03-03 00:48:05 -05:00
return driverChampionshipLoadedMsg{standings: standings, err: err}
}
}
2026-03-03 01:16:04 -05:00
func fetchTeamChampionship(client *api.OpenF1Client, year int) tea.Cmd {
2026-03-03 00:48:05 -05:00
return func() tea.Msg {
2026-03-03 01:16:04 -05:00
standings, err := client.GetTeamChampionshipForYear(year)
2026-03-03 00:48:05 -05:00
return teamChampionshipLoadedMsg{standings: standings, err: err}
}
}
func fetchStandingsDrivers(client *api.OpenF1Client, sessionKey int) tea.Cmd {
return func() tea.Msg {
drivers, err := client.GetDriversForSession(sessionKey)
return standingsDriversLoadedMsg{drivers: drivers, err: err}
}
}
func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
2026-03-03 02:29:34 -05:00
var cmds []tea.Cmd
2026-03-03 00:48:05 -05:00
switch msg := msg.(type) {
2026-03-03 02:29:34 -05:00
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
2026-03-03 00:48:05 -05:00
case spinner.TickMsg:
if m.loading {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
case driverChampionshipLoadedMsg:
if msg.err != nil {
m.err = msg.err
m.loading = false
return m, nil
}
m.driverStandings = msg.standings
2026-03-27 19:27:38 -04:00
if m.client.LastResponseWasStale() {
m.stale = true
}
2026-03-03 00:48:05 -05:00
if len(msg.standings) > 0 {
return m, fetchStandingsDrivers(m.client, msg.standings[0].SessionKey)
}
m.loading = false
case teamChampionshipLoadedMsg:
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.teamStandings = msg.standings
2026-03-27 19:27:38 -04:00
if m.client.LastResponseWasStale() {
m.stale = true
}
2026-03-03 00:48:05 -05:00
case standingsDriversLoadedMsg:
if msg.err != nil {
m.err = msg.err
m.loading = false
return m, nil
}
for _, d := range msg.drivers {
m.drivers[d.DriverNumber] = d
}
m.loading = false
case tea.KeyMsg:
switch {
2026-03-03 02:29:34 -05:00
case matchKey(msg, GlobalKeys.Retry):
if m.err != nil {
m.err = nil
2026-03-27 19:27:38 -04:00
m.stale = false
2026-03-03 02:29:34 -05:00
m.loading = true
return m, m.Init()
}
2026-03-03 00:48:05 -05:00
case matchKey(msg, StandingsKeys.DriverView):
m.view = standingsViewDriver
m.cursor = 0
2026-03-03 02:29:34 -05:00
m.scroll = 0
2026-03-03 00:48:05 -05:00
case matchKey(msg, StandingsKeys.ConstructorView):
m.view = standingsViewConstructor
m.cursor = 0
2026-03-03 02:29:34 -05:00
m.scroll = 0
2026-03-03 00:48:05 -05:00
case matchKey(msg, GlobalKeys.Up):
if m.cursor > 0 {
m.cursor--
2026-03-03 02:29:34 -05:00
if m.cursor < m.scroll {
m.scroll = m.cursor
}
2026-03-03 00:48:05 -05:00
}
case matchKey(msg, GlobalKeys.Down):
2026-03-03 02:29:34 -05:00
maxIdx := m.itemCount() - 1
if m.cursor < maxIdx {
m.cursor++
visibleRows := m.visibleRows()
if m.cursor >= m.scroll+visibleRows {
m.scroll = m.cursor - visibleRows + 1
}
}
case matchKey(msg, GlobalKeys.GoTop):
m.cursor = 0
m.scroll = 0
case matchKey(msg, GlobalKeys.GoBottom):
maxIdx := m.itemCount() - 1
if maxIdx >= 0 {
m.cursor = maxIdx
visibleRows := m.visibleRows()
if m.cursor >= visibleRows {
m.scroll = m.cursor - visibleRows + 1
}
}
case matchKey(msg, GlobalKeys.HalfUp):
half := m.visibleRows() / 2
m.cursor -= half
if m.cursor < 0 {
m.cursor = 0
}
if m.cursor < m.scroll {
m.scroll = m.cursor
}
case matchKey(msg, GlobalKeys.HalfDown):
half := m.visibleRows() / 2
maxIdx := m.itemCount() - 1
m.cursor += half
if m.cursor > maxIdx {
m.cursor = maxIdx
}
visibleRows := m.visibleRows()
if m.cursor >= m.scroll+visibleRows {
m.scroll = m.cursor - visibleRows + 1
}
2026-03-03 00:48:05 -05:00
}
}
2026-03-03 02:29:34 -05:00
return m, tea.Batch(cmds...)
}
func (m StandingsModel) itemCount() int {
if m.view == standingsViewDriver {
return len(m.driverStandings)
}
return len(m.teamStandings)
}
func (m StandingsModel) visibleRows() int {
rows := m.height - 12 // header + toggle + help + padding
if rows < 5 {
rows = 5
}
return rows
2026-03-03 00:48:05 -05:00
}
func (m StandingsModel) View() string {
if m.loading {
2026-03-03 02:29:34 -05:00
return fmt.Sprintf("\n %s Loading %d championship standings...", m.spinner.View(), m.year)
2026-03-03 00:48:05 -05:00
}
if m.err != nil {
2026-03-27 00:30:53 -04:00
return renderErrorView(m.err)
2026-03-03 00:48:05 -05:00
}
var sb strings.Builder
2026-03-27 19:27:38 -04:00
if m.stale {
sb.WriteString(renderStaleBanner())
}
2026-03-03 02:29:34 -05:00
// Title row with year and toggle
title := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorF1Red)).
Render(fmt.Sprintf(" FORMULA 1 %d CHAMPIONSHIP", m.year))
sb.WriteString(title + "\n\n")
2026-03-03 01:16:04 -05:00
2026-03-03 00:48:05 -05:00
// Toggle bar
2026-03-03 02:29:34 -05:00
dLabel, cLabel := " d Drivers ", " c Constructors "
2026-03-03 00:48:05 -05:00
if m.view == standingsViewDriver {
2026-03-03 02:29:34 -05:00
sb.WriteString(styleActiveTab.Render(dLabel))
sb.WriteString(styleInactiveTab.Render(cLabel))
2026-03-03 00:48:05 -05:00
} else {
2026-03-03 02:29:34 -05:00
sb.WriteString(styleInactiveTab.Render(dLabel))
sb.WriteString(styleActiveTab.Render(cLabel))
2026-03-03 00:48:05 -05:00
}
sb.WriteString("\n\n")
if m.view == standingsViewDriver {
sb.WriteString(m.renderDriverStandings())
} else {
sb.WriteString(m.renderTeamStandings())
}
sb.WriteString("\n")
2026-03-03 02:29:34 -05:00
sb.WriteString(helpBar("y season", "d drivers", "c constructors", "j/k navigate", "g/G top/bottom", "^d/^u page", "q quit"))
2026-03-03 00:48:05 -05:00
return sb.String()
}
func (m StandingsModel) renderDriverStandings() string {
if len(m.driverStandings) == 0 {
2026-03-03 02:29:34 -05:00
return styleMuted.Render(" No standings data available.\n")
2026-03-03 00:48:05 -05:00
}
2026-03-03 02:29:34 -05:00
var sb strings.Builder
maxPoints := m.driverStandings[0].PointsCurrent
w := m.width
if w < 40 {
w = 40
}
2026-03-03 00:48:05 -05:00
2026-03-03 02:29:34 -05:00
// Responsive column widths
compact := w < 80
nameWidth := 20
teamWidth := 20
barWidth := 20
if w >= 130 {
nameWidth = 24
teamWidth = 24
barWidth = 35
} else if w >= 100 {
barWidth = 25
} else if compact {
nameWidth = 0 // hide full name in compact mode
teamWidth = 14
barWidth = 12
}
2026-03-03 00:48:05 -05:00
2026-03-03 02:29:34 -05:00
visible := m.visibleRows()
endIdx := m.scroll + visible
if endIdx > len(m.driverStandings) {
endIdx = len(m.driverStandings)
2026-03-03 00:48:05 -05:00
}
2026-03-03 02:29:34 -05:00
// Header
var header string
if compact {
header = fmt.Sprintf(" %s %s %s %s %s %s",
padRight("POS", 3),
padRight("", 2),
padRight("", 1),
padRight("DRV", 4),
padRight("TEAM", teamWidth),
padLeft("PTS", 5),
)
} else {
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s",
padRight("POS", 4),
padRight("", 2),
padRight("", 1),
padRight("DRV", 4),
padRight("DRIVER", nameWidth),
padRight("TEAM", teamWidth),
padLeft("PTS", 6),
padRight("", barWidth),
)
}
sb.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Bold(true).
Render(header) + "\n")
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
for i := m.scroll; i < endIdx; i++ {
s := m.driverStandings[i]
2026-03-03 00:48:05 -05:00
d, ok := m.drivers[s.DriverNumber]
2026-03-03 02:29:34 -05:00
acronym, name, team, teamColor := "---", "Unknown", "Unknown", colorMuted
2026-03-03 00:48:05 -05:00
if ok {
acronym = d.NameAcronym
name = d.FullName
team = d.TeamName
2026-03-03 02:29:34 -05:00
if d.TeamColour != "" {
teamColor = "#" + d.TeamColour
} else {
teamColor = teamColorFromName(d.TeamName)
}
2026-03-03 00:48:05 -05:00
}
delta := renderDelta(s.PositionCurrent, s.PositionStart)
2026-03-03 02:29:34 -05:00
pos := renderPosition(s.PositionCurrent)
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
pointsBar := renderPointsBar(s.PointsCurrent, maxPoints, barWidth, teamColor)
2026-03-27 20:53:06 -04:00
// Styling for top 3
nameStyle := lipgloss.NewStyle()
if i == 0 {
nameStyle = stylePositionFirst
} else if i == 1 {
nameStyle = stylePositionSecond
} else if i == 2 {
nameStyle = stylePositionThird
}
2026-03-03 02:29:34 -05:00
var row string
if compact {
row = fmt.Sprintf(" %s %s %s %s %s %s",
padRightVisible(pos, 3),
delta,
colorBar,
2026-03-27 20:53:06 -04:00
nameStyle.Render(padRight(acronym, 4)),
2026-03-03 02:29:34 -05:00
padRight(truncate(team, teamWidth), teamWidth),
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 5),
)
} else {
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s",
padRightVisible(pos, 4),
delta,
colorBar,
2026-03-27 20:53:06 -04:00
nameStyle.Render(padRight(acronym, 4)),
nameStyle.Render(padRight(truncate(name, nameWidth), nameWidth)),
2026-03-03 02:29:34 -05:00
padRight(truncate(team, teamWidth), teamWidth),
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6),
pointsBar,
)
}
2026-03-03 00:48:05 -05:00
if i == m.cursor {
row = styleSelected.Render(row)
}
2026-03-03 02:29:34 -05:00
sb.WriteString(row + "\n")
2026-03-03 00:48:05 -05:00
}
2026-03-03 02:29:34 -05:00
// Scroll indicator
if len(m.driverStandings) > visible {
sb.WriteString(styleMuted.Render(fmt.Sprintf(" Showing %d-%d of %d", m.scroll+1, endIdx, len(m.driverStandings))) + "\n")
}
return sb.String()
2026-03-03 00:48:05 -05:00
}
func (m StandingsModel) renderTeamStandings() string {
if len(m.teamStandings) == 0 {
2026-03-03 02:29:34 -05:00
return styleMuted.Render(" No standings data available.\n")
2026-03-03 00:48:05 -05:00
}
2026-03-03 02:29:34 -05:00
var sb strings.Builder
maxPoints := m.teamStandings[0].PointsCurrent
w := m.width
if w < 40 {
w = 40
}
// Responsive
compact := w < 80
teamWidth := 28
barWidth := 30
if w >= 130 {
barWidth = 50
} else if w >= 100 {
barWidth = 40
} else if compact {
teamWidth = 18
barWidth = 15
}
2026-03-03 00:48:05 -05:00
2026-03-03 02:29:34 -05:00
visible := m.visibleRows()
endIdx := m.scroll + visible
if endIdx > len(m.teamStandings) {
endIdx = len(m.teamStandings)
}
// Header
header := fmt.Sprintf(" %s %s %s %s %s %s",
padRight("POS", 4),
padRight("", 2),
padRight("", 1),
padRight("CONSTRUCTOR", teamWidth),
padLeft("PTS", 6),
padRight("", barWidth),
2026-03-03 00:48:05 -05:00
)
2026-03-03 02:29:34 -05:00
sb.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Bold(true).
Render(header) + "\n")
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
2026-03-03 00:48:05 -05:00
2026-03-03 02:29:34 -05:00
for i := m.scroll; i < endIdx; i++ {
s := m.teamStandings[i]
teamColor := teamColorFromName(s.TeamName)
2026-03-03 00:48:05 -05:00
delta := renderDelta(s.PositionCurrent, s.PositionStart)
2026-03-03 02:29:34 -05:00
pos := renderPosition(s.PositionCurrent)
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
pointsBar := renderPointsBar(s.PointsCurrent, maxPoints, barWidth, teamColor)
row := fmt.Sprintf(" %s %s %s %s %s %s",
padRightVisible(pos, 4),
2026-03-03 00:48:05 -05:00
delta,
2026-03-03 02:29:34 -05:00
colorBar,
padRight(truncate(s.TeamName, teamWidth), teamWidth),
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6),
pointsBar,
2026-03-03 00:48:05 -05:00
)
2026-03-03 02:29:34 -05:00
2026-03-03 00:48:05 -05:00
if i == m.cursor {
row = styleSelected.Render(row)
}
2026-03-03 02:29:34 -05:00
sb.WriteString(row + "\n")
}
2026-03-03 00:48:05 -05:00
2026-03-03 02:29:34 -05:00
if len(m.teamStandings) > visible {
sb.WriteString(styleMuted.Render(fmt.Sprintf(" Showing %d-%d of %d", m.scroll+1, endIdx, len(m.teamStandings))) + "\n")
2026-03-03 00:48:05 -05:00
}
2026-03-03 02:29:34 -05:00
return sb.String()
2026-03-03 00:48:05 -05:00
}