mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
454 lines
11 KiB
Go
454 lines
11 KiB
Go
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
|
|
drivers map[int]models.Driver // driver_number -> Driver
|
|
|
|
view standingsView
|
|
loading bool
|
|
err error
|
|
spinner spinner.Model
|
|
|
|
cursor int
|
|
scroll int
|
|
|
|
year int
|
|
width int
|
|
height int
|
|
}
|
|
|
|
func NewStandingsModel(client *api.OpenF1Client, year int) StandingsModel {
|
|
s := spinner.New()
|
|
s.Spinner = spinner.MiniDot
|
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
|
|
|
return StandingsModel{
|
|
client: client,
|
|
view: standingsViewDriver,
|
|
loading: true,
|
|
spinner: s,
|
|
drivers: make(map[int]models.Driver),
|
|
year: year,
|
|
}
|
|
}
|
|
|
|
func (m StandingsModel) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
m.spinner.Tick,
|
|
fetchDriverChampionship(m.client, m.year),
|
|
fetchTeamChampionship(m.client, m.year),
|
|
)
|
|
}
|
|
|
|
func fetchDriverChampionship(client *api.OpenF1Client, year int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
standings, err := client.GetDriverChampionshipForYear(year)
|
|
return driverChampionshipLoadedMsg{standings: standings, err: err}
|
|
}
|
|
}
|
|
|
|
func fetchTeamChampionship(client *api.OpenF1Client, year int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
standings, err := client.GetTeamChampionshipForYear(year)
|
|
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) {
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
|
|
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
|
|
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
|
|
|
|
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 {
|
|
case matchKey(msg, GlobalKeys.Retry):
|
|
if m.err != nil {
|
|
m.err = nil
|
|
m.loading = true
|
|
return m, m.Init()
|
|
}
|
|
case matchKey(msg, StandingsKeys.DriverView):
|
|
m.view = standingsViewDriver
|
|
m.cursor = 0
|
|
m.scroll = 0
|
|
case matchKey(msg, StandingsKeys.ConstructorView):
|
|
m.view = standingsViewConstructor
|
|
m.cursor = 0
|
|
m.scroll = 0
|
|
case matchKey(msg, GlobalKeys.Up):
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
if m.cursor < m.scroll {
|
|
m.scroll = m.cursor
|
|
}
|
|
}
|
|
case matchKey(msg, GlobalKeys.Down):
|
|
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
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
func (m StandingsModel) View() string {
|
|
if m.loading {
|
|
return fmt.Sprintf("\n %s Loading %d championship standings...", m.spinner.View(), m.year)
|
|
}
|
|
if m.err != nil {
|
|
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) +
|
|
helpBar("r retry", "q quit")
|
|
}
|
|
|
|
var sb strings.Builder
|
|
|
|
// 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")
|
|
|
|
// Toggle bar
|
|
dLabel, cLabel := " d Drivers ", " c Constructors "
|
|
if m.view == standingsViewDriver {
|
|
sb.WriteString(styleActiveTab.Render(dLabel))
|
|
sb.WriteString(styleInactiveTab.Render(cLabel))
|
|
} else {
|
|
sb.WriteString(styleInactiveTab.Render(dLabel))
|
|
sb.WriteString(styleActiveTab.Render(cLabel))
|
|
}
|
|
sb.WriteString("\n\n")
|
|
|
|
if m.view == standingsViewDriver {
|
|
sb.WriteString(m.renderDriverStandings())
|
|
} else {
|
|
sb.WriteString(m.renderTeamStandings())
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
sb.WriteString(helpBar("y season", "d drivers", "c constructors", "j/k navigate", "g/G top/bottom", "^d/^u page", "q quit"))
|
|
return sb.String()
|
|
}
|
|
|
|
func (m StandingsModel) renderDriverStandings() string {
|
|
if len(m.driverStandings) == 0 {
|
|
return styleMuted.Render(" No standings data available.\n")
|
|
}
|
|
|
|
var sb strings.Builder
|
|
maxPoints := m.driverStandings[0].PointsCurrent
|
|
w := m.width
|
|
if w < 40 {
|
|
w = 40
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
visible := m.visibleRows()
|
|
endIdx := m.scroll + visible
|
|
if endIdx > len(m.driverStandings) {
|
|
endIdx = len(m.driverStandings)
|
|
}
|
|
|
|
// 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]
|
|
d, ok := m.drivers[s.DriverNumber]
|
|
acronym, name, team, teamColor := "---", "Unknown", "Unknown", colorMuted
|
|
if ok {
|
|
acronym = d.NameAcronym
|
|
name = d.FullName
|
|
team = d.TeamName
|
|
if d.TeamColour != "" {
|
|
teamColor = "#" + d.TeamColour
|
|
} else {
|
|
teamColor = teamColorFromName(d.TeamName)
|
|
}
|
|
}
|
|
|
|
delta := renderDelta(s.PositionCurrent, s.PositionStart)
|
|
pos := renderPosition(s.PositionCurrent)
|
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
|
pointsBar := renderPointsBar(s.PointsCurrent, maxPoints, barWidth, teamColor)
|
|
|
|
var row string
|
|
if compact {
|
|
row = fmt.Sprintf(" %s %s %s %s %s %s",
|
|
padRightVisible(pos, 3),
|
|
delta,
|
|
colorBar,
|
|
padRight(acronym, 4),
|
|
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,
|
|
padRight(acronym, 4),
|
|
padRight(truncate(name, nameWidth), nameWidth),
|
|
padRight(truncate(team, teamWidth), teamWidth),
|
|
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6),
|
|
pointsBar,
|
|
)
|
|
}
|
|
|
|
if i == m.cursor {
|
|
row = styleSelected.Render(row)
|
|
}
|
|
|
|
sb.WriteString(row + "\n")
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
func (m StandingsModel) renderTeamStandings() string {
|
|
if len(m.teamStandings) == 0 {
|
|
return styleMuted.Render(" No standings data available.\n")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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),
|
|
)
|
|
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.teamStandings[i]
|
|
teamColor := teamColorFromName(s.TeamName)
|
|
|
|
delta := renderDelta(s.PositionCurrent, s.PositionStart)
|
|
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),
|
|
delta,
|
|
colorBar,
|
|
padRight(truncate(s.TeamName, teamWidth), teamWidth),
|
|
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6),
|
|
pointsBar,
|
|
)
|
|
|
|
if i == m.cursor {
|
|
row = styleSelected.Render(row)
|
|
}
|
|
|
|
sb.WriteString(row + "\n")
|
|
}
|
|
|
|
if len(m.teamStandings) > visible {
|
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" Showing %d-%d of %d", m.scroll+1, endIdx, len(m.teamStandings))) + "\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|