2026-03-03 00:48:05 -05:00
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
2026-03-03 02:29:34 -05:00
|
|
|
"time"
|
2026-03-03 00:48:05 -05:00
|
|
|
|
|
|
|
|
"github.com/AmanTahiliani/box-box/internal/api"
|
|
|
|
|
"github.com/AmanTahiliani/box-box/internal/models"
|
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
2026-03-03 02:29:34 -05:00
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
2026-03-03 00:48:05 -05:00
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
2026-03-03 02:29:34 -05:00
|
|
|
"github.com/sahilm/fuzzy"
|
2026-03-03 00:48:05 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type driverView int
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
driverViewList driverView = iota
|
|
|
|
|
driverViewDetail
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type DriverModel struct {
|
|
|
|
|
client *api.OpenF1Client
|
|
|
|
|
sessionKey int
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
drivers []models.Driver
|
|
|
|
|
filteredDrivers []models.Driver
|
|
|
|
|
selectedDriver *models.Driver
|
|
|
|
|
stints []models.Stint
|
|
|
|
|
laps []models.Lap
|
|
|
|
|
pits []models.Pit
|
|
|
|
|
positions []models.Position
|
|
|
|
|
teamRadios []models.TeamRadio
|
2026-03-07 23:53:16 -05:00
|
|
|
raceControl []models.RaceControl
|
2026-03-03 00:48:05 -05:00
|
|
|
|
|
|
|
|
view driverView
|
|
|
|
|
loading bool
|
|
|
|
|
err error
|
|
|
|
|
spinner spinner.Model
|
|
|
|
|
|
|
|
|
|
cursor int
|
2026-03-03 02:29:34 -05:00
|
|
|
scroll int
|
|
|
|
|
input textinput.Model
|
|
|
|
|
|
|
|
|
|
detailView viewport.Model
|
|
|
|
|
detailViewReady bool
|
|
|
|
|
|
2026-03-03 00:48:05 -05:00
|
|
|
width int
|
|
|
|
|
height int
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
type driverSource []models.Driver
|
|
|
|
|
|
|
|
|
|
func (d driverSource) String(i int) string {
|
|
|
|
|
return d[i].FullName + " " + d[i].NameAcronym + " " + d[i].TeamName
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (d driverSource) Len() int {
|
|
|
|
|
return len(d)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 00:48:05 -05:00
|
|
|
func NewDriverModel(client *api.OpenF1Client) DriverModel {
|
|
|
|
|
s := spinner.New()
|
|
|
|
|
s.Spinner = spinner.MiniDot
|
|
|
|
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
2026-03-03 02:29:34 -05:00
|
|
|
|
|
|
|
|
ti := textinput.New()
|
|
|
|
|
ti.Placeholder = "Search drivers..."
|
|
|
|
|
ti.Focus()
|
|
|
|
|
ti.CharLimit = 50
|
|
|
|
|
ti.Width = 30
|
|
|
|
|
ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
|
|
|
|
ti.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
|
|
|
|
|
|
2026-03-03 00:48:05 -05:00
|
|
|
return DriverModel{
|
|
|
|
|
client: client,
|
2026-03-03 02:29:34 -05:00
|
|
|
loading: false,
|
2026-03-03 00:48:05 -05:00
|
|
|
spinner: s,
|
2026-03-03 02:29:34 -05:00
|
|
|
input: ti,
|
2026-03-03 00:48:05 -05:00
|
|
|
view: driverViewList,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fetchDriverList(client *api.OpenF1Client) tea.Cmd {
|
|
|
|
|
return func() tea.Msg {
|
2026-03-03 02:29:34 -05:00
|
|
|
drivers, err := client.GetDriversForSession(9999)
|
2026-03-03 00:48:05 -05:00
|
|
|
if err != nil || len(drivers) == 0 {
|
|
|
|
|
champ, champErr := client.GetLatestDriverChampionship()
|
|
|
|
|
if champErr != nil || len(champ) == 0 {
|
|
|
|
|
return driverListLoadedMsg{err: err}
|
|
|
|
|
}
|
|
|
|
|
drivers, err = client.GetDriversForSession(champ[0].SessionKey)
|
|
|
|
|
}
|
|
|
|
|
return driverListLoadedMsg{drivers: drivers, err: err}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fetchDriverListForSession(client *api.OpenF1Client, sessionKey int) tea.Cmd {
|
|
|
|
|
return func() tea.Msg {
|
|
|
|
|
drivers, err := client.GetDriversForSession(sessionKey)
|
|
|
|
|
return driverListLoadedMsg{drivers: drivers, err: err}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func fetchDriverDetail(client *api.OpenF1Client, sessionKey, driverNumber int) tea.Cmd {
|
|
|
|
|
return tea.Batch(
|
|
|
|
|
func() tea.Msg {
|
|
|
|
|
stints, err := client.GetStintsForSession(sessionKey)
|
|
|
|
|
var driverStints []models.Stint
|
|
|
|
|
for _, s := range stints {
|
|
|
|
|
if s.DriverNumber == driverNumber {
|
|
|
|
|
driverStints = append(driverStints, s)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return driverStintsLoadedMsg{stints: driverStints, err: err}
|
|
|
|
|
},
|
|
|
|
|
func() tea.Msg {
|
|
|
|
|
laps, err := client.GetLapsForDriver(sessionKey, driverNumber)
|
|
|
|
|
return driverLapsLoadedMsg{laps: laps, err: err}
|
|
|
|
|
},
|
|
|
|
|
func() tea.Msg {
|
|
|
|
|
pits, err := client.GetPitStopsForSession(sessionKey)
|
|
|
|
|
var driverPits []models.Pit
|
|
|
|
|
for _, p := range pits {
|
|
|
|
|
if p.DriverNumber == driverNumber {
|
|
|
|
|
driverPits = append(driverPits, p)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return driverPitsLoadedMsg{pits: driverPits, err: err}
|
|
|
|
|
},
|
2026-03-03 02:29:34 -05:00
|
|
|
func() tea.Msg {
|
|
|
|
|
positions, err := client.GetPositions(sessionKey, driverNumber)
|
|
|
|
|
return driverPositionsLoadedMsg{positions: positions, err: err}
|
|
|
|
|
},
|
|
|
|
|
func() tea.Msg {
|
|
|
|
|
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
|
|
|
|
|
return driverTeamRadioLoadedMsg{radios: radios, err: err}
|
|
|
|
|
},
|
2026-03-03 00:48:05 -05:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DriverModel) Init() tea.Cmd {
|
|
|
|
|
return m.spinner.Tick
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DriverModel) Update(msg tea.Msg) (DriverModel, 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 driverListLoadedMsg:
|
|
|
|
|
m.loading = false
|
|
|
|
|
if msg.err != nil {
|
|
|
|
|
m.err = msg.err
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
m.drivers = msg.drivers
|
2026-03-03 02:29:34 -05:00
|
|
|
m.filterDrivers()
|
2026-03-03 00:48:05 -05:00
|
|
|
|
|
|
|
|
case driverStintsLoadedMsg:
|
|
|
|
|
if msg.err == nil {
|
|
|
|
|
m.stints = msg.stints
|
|
|
|
|
}
|
|
|
|
|
m.loading = false
|
2026-03-03 02:29:34 -05:00
|
|
|
m.updateDetailViewport()
|
2026-03-03 00:48:05 -05:00
|
|
|
|
|
|
|
|
case driverLapsLoadedMsg:
|
|
|
|
|
if msg.err == nil {
|
|
|
|
|
m.laps = msg.laps
|
|
|
|
|
}
|
2026-03-03 02:29:34 -05:00
|
|
|
m.updateDetailViewport()
|
2026-03-03 00:48:05 -05:00
|
|
|
|
|
|
|
|
case driverPitsLoadedMsg:
|
|
|
|
|
if msg.err == nil {
|
|
|
|
|
m.pits = msg.pits
|
|
|
|
|
}
|
2026-03-03 02:29:34 -05:00
|
|
|
m.updateDetailViewport()
|
|
|
|
|
|
|
|
|
|
case driverPositionsLoadedMsg:
|
|
|
|
|
if msg.err == nil {
|
|
|
|
|
m.positions = msg.positions
|
|
|
|
|
}
|
|
|
|
|
m.updateDetailViewport()
|
|
|
|
|
|
|
|
|
|
case driverTeamRadioLoadedMsg:
|
|
|
|
|
if msg.err == nil {
|
|
|
|
|
m.teamRadios = msg.radios
|
|
|
|
|
}
|
|
|
|
|
m.updateDetailViewport()
|
2026-03-03 00:48:05 -05:00
|
|
|
|
|
|
|
|
case meetingSelectedMsg:
|
|
|
|
|
m.drivers = nil
|
|
|
|
|
|
|
|
|
|
case sessionsLoadedMsg:
|
|
|
|
|
if msg.err == nil && len(msg.sessions) > 0 {
|
|
|
|
|
for _, s := range msg.sessions {
|
|
|
|
|
if s.SessionName == "Race" {
|
|
|
|
|
m.sessionKey = s.SessionKey
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
m.sessionKey = msg.sessions[len(msg.sessions)-1].SessionKey
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case tea.KeyMsg:
|
|
|
|
|
switch m.view {
|
|
|
|
|
case driverViewList:
|
|
|
|
|
switch {
|
2026-03-03 02:29:34 -05:00
|
|
|
case matchKey(msg, GlobalKeys.Retry):
|
|
|
|
|
if m.err != nil {
|
|
|
|
|
m.err = nil
|
|
|
|
|
m.loading = true
|
|
|
|
|
var cmd tea.Cmd
|
|
|
|
|
m, cmd = m.TriggerLoad()
|
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
|
}
|
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
|
|
|
m.ensureCursorVisible()
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
case matchKey(msg, GlobalKeys.Down):
|
2026-03-03 02:29:34 -05:00
|
|
|
if m.cursor < len(m.filteredDrivers)-1 {
|
2026-03-03 00:48:05 -05:00
|
|
|
m.cursor++
|
2026-03-03 02:29:34 -05:00
|
|
|
m.ensureCursorVisible()
|
|
|
|
|
}
|
|
|
|
|
case matchKey(msg, GlobalKeys.GoTop):
|
|
|
|
|
if m.input.Value() == "" {
|
|
|
|
|
m.cursor = 0
|
|
|
|
|
m.scroll = 0
|
|
|
|
|
} else {
|
|
|
|
|
var cmd tea.Cmd
|
|
|
|
|
m.input, cmd = m.input.Update(msg)
|
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
|
m.filterDrivers()
|
|
|
|
|
}
|
|
|
|
|
case matchKey(msg, GlobalKeys.GoBottom):
|
|
|
|
|
if m.input.Value() == "" {
|
|
|
|
|
if len(m.filteredDrivers) > 0 {
|
|
|
|
|
m.cursor = len(m.filteredDrivers) - 1
|
|
|
|
|
m.ensureCursorVisible()
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
var cmd tea.Cmd
|
|
|
|
|
m.input, cmd = m.input.Update(msg)
|
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
|
m.filterDrivers()
|
|
|
|
|
}
|
|
|
|
|
case matchKey(msg, GlobalKeys.HalfUp):
|
|
|
|
|
half := m.visibleRows() / 2
|
|
|
|
|
m.cursor -= half
|
|
|
|
|
if m.cursor < 0 {
|
|
|
|
|
m.cursor = 0
|
|
|
|
|
}
|
|
|
|
|
m.ensureCursorVisible()
|
|
|
|
|
case matchKey(msg, GlobalKeys.HalfDown):
|
|
|
|
|
half := m.visibleRows() / 2
|
|
|
|
|
m.cursor += half
|
|
|
|
|
if m.cursor >= len(m.filteredDrivers) {
|
|
|
|
|
m.cursor = len(m.filteredDrivers) - 1
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
2026-03-03 02:29:34 -05:00
|
|
|
if m.cursor < 0 {
|
|
|
|
|
m.cursor = 0
|
|
|
|
|
}
|
|
|
|
|
m.ensureCursorVisible()
|
2026-03-03 00:48:05 -05:00
|
|
|
case matchKey(msg, GlobalKeys.Enter):
|
2026-03-03 02:29:34 -05:00
|
|
|
if len(m.filteredDrivers) > 0 && m.cursor >= 0 && m.cursor < len(m.filteredDrivers) {
|
|
|
|
|
d := m.filteredDrivers[m.cursor]
|
2026-03-03 00:48:05 -05:00
|
|
|
m.selectedDriver = &d
|
|
|
|
|
m.stints = nil
|
|
|
|
|
m.laps = nil
|
|
|
|
|
m.pits = nil
|
2026-03-03 02:29:34 -05:00
|
|
|
m.positions = nil
|
|
|
|
|
m.teamRadios = nil
|
2026-03-03 00:48:05 -05:00
|
|
|
m.view = driverViewDetail
|
|
|
|
|
m.loading = true
|
2026-03-03 02:29:34 -05:00
|
|
|
m.detailViewReady = false
|
2026-03-03 00:48:05 -05:00
|
|
|
sessionKey := m.sessionKey
|
|
|
|
|
if sessionKey == 0 && d.SessionKey != 0 {
|
|
|
|
|
sessionKey = d.SessionKey
|
|
|
|
|
}
|
2026-03-03 02:29:34 -05:00
|
|
|
cmds = append(cmds, m.spinner.Tick, fetchDriverDetail(m.client, sessionKey, d.DriverNumber))
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
2026-03-03 02:29:34 -05:00
|
|
|
default:
|
|
|
|
|
var cmd tea.Cmd
|
|
|
|
|
m.input, cmd = m.input.Update(msg)
|
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
|
m.filterDrivers()
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
case driverViewDetail:
|
|
|
|
|
switch {
|
|
|
|
|
case matchKey(msg, GlobalKeys.Back):
|
|
|
|
|
m.view = driverViewList
|
|
|
|
|
m.selectedDriver = nil
|
|
|
|
|
m.stints = nil
|
|
|
|
|
m.laps = nil
|
|
|
|
|
m.pits = nil
|
2026-03-03 02:29:34 -05:00
|
|
|
m.positions = nil
|
|
|
|
|
m.teamRadios = nil
|
|
|
|
|
m.detailViewReady = false
|
|
|
|
|
case matchKey(msg, GlobalKeys.Up):
|
|
|
|
|
if m.detailViewReady {
|
|
|
|
|
m.detailView.LineUp(1)
|
|
|
|
|
}
|
|
|
|
|
case matchKey(msg, GlobalKeys.Down):
|
|
|
|
|
if m.detailViewReady {
|
|
|
|
|
m.detailView.LineDown(1)
|
|
|
|
|
}
|
|
|
|
|
case matchKey(msg, GlobalKeys.GoTop):
|
|
|
|
|
if m.detailViewReady {
|
|
|
|
|
m.detailView.GotoTop()
|
|
|
|
|
}
|
|
|
|
|
case matchKey(msg, GlobalKeys.GoBottom):
|
|
|
|
|
if m.detailViewReady {
|
|
|
|
|
m.detailView.GotoBottom()
|
|
|
|
|
}
|
|
|
|
|
case matchKey(msg, GlobalKeys.HalfUp):
|
|
|
|
|
if m.detailViewReady {
|
|
|
|
|
m.detailView.HalfViewUp()
|
|
|
|
|
}
|
|
|
|
|
case matchKey(msg, GlobalKeys.HalfDown):
|
|
|
|
|
if m.detailViewReady {
|
|
|
|
|
m.detailView.HalfViewDown()
|
|
|
|
|
}
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
return m, tea.Batch(cmds...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DriverModel) visibleRows() int {
|
|
|
|
|
rows := m.height - 12
|
|
|
|
|
if rows < 5 {
|
|
|
|
|
rows = 5
|
|
|
|
|
}
|
|
|
|
|
return rows
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *DriverModel) ensureCursorVisible() {
|
|
|
|
|
visible := m.visibleRows()
|
|
|
|
|
if m.cursor < m.scroll {
|
|
|
|
|
m.scroll = m.cursor
|
|
|
|
|
}
|
|
|
|
|
if m.cursor >= m.scroll+visible {
|
|
|
|
|
m.scroll = m.cursor - visible + 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *DriverModel) filterDrivers() {
|
|
|
|
|
query := m.input.Value()
|
|
|
|
|
if query == "" {
|
|
|
|
|
m.filteredDrivers = m.drivers
|
|
|
|
|
} else {
|
|
|
|
|
matches := fuzzy.FindFrom(query, driverSource(m.drivers))
|
|
|
|
|
m.filteredDrivers = make([]models.Driver, 0, len(matches))
|
|
|
|
|
for _, match := range matches {
|
|
|
|
|
m.filteredDrivers = append(m.filteredDrivers, m.drivers[match.Index])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
m.cursor = 0
|
|
|
|
|
m.scroll = 0
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) {
|
|
|
|
|
if m.drivers != nil || m.loading {
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
m.loading = true
|
|
|
|
|
return m, tea.Batch(m.spinner.Tick, fetchDriverList(m.client))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DriverModel) View() string {
|
2026-03-03 02:29:34 -05:00
|
|
|
if m.loading && m.view == driverViewList {
|
|
|
|
|
return fmt.Sprintf("\n %s Loading drivers...", m.spinner.View())
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch m.view {
|
|
|
|
|
case driverViewList:
|
|
|
|
|
return m.renderDriverList()
|
|
|
|
|
case driverViewDetail:
|
|
|
|
|
return m.renderDriverDetail()
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DriverModel) renderDriverList() string {
|
2026-03-03 02:29:34 -05:00
|
|
|
if len(m.drivers) == 0 && !m.loading {
|
|
|
|
|
return styleMuted.Render("\n No driver data. Select a race from Calendar first.\n\n") +
|
|
|
|
|
helpBar("2 calendar", "r retry", "q quit")
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
var sb strings.Builder
|
|
|
|
|
w := m.width
|
|
|
|
|
if w < 40 {
|
|
|
|
|
w = 40
|
|
|
|
|
}
|
|
|
|
|
compact := w < 80
|
|
|
|
|
|
|
|
|
|
title := lipgloss.NewStyle().
|
|
|
|
|
Bold(true).
|
|
|
|
|
Foreground(lipgloss.Color(colorF1Red)).
|
|
|
|
|
Render(" DRIVER LOOKUP")
|
|
|
|
|
sb.WriteString(title + "\n\n")
|
|
|
|
|
|
|
|
|
|
// Search input
|
|
|
|
|
sb.WriteString(" " + m.input.View() + "\n\n")
|
|
|
|
|
|
|
|
|
|
// Responsive columns
|
|
|
|
|
nameWidth := 24
|
|
|
|
|
teamWidth := 22
|
|
|
|
|
if compact {
|
|
|
|
|
nameWidth = 16
|
|
|
|
|
teamWidth = 0 // hide team in compact mode
|
|
|
|
|
} else if w >= 120 {
|
|
|
|
|
nameWidth = 28
|
|
|
|
|
teamWidth = 26
|
|
|
|
|
}
|
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",
|
|
|
|
|
padRight("#", 3),
|
|
|
|
|
padRight("", 1),
|
|
|
|
|
padRight("DRV", 4),
|
|
|
|
|
padRight("NAME", nameWidth),
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
header = fmt.Sprintf(" %s %s %s %s %s",
|
|
|
|
|
padRight("#", 3),
|
|
|
|
|
padRight("", 1),
|
|
|
|
|
padRight("DRV", 4),
|
|
|
|
|
padRight("NAME", nameWidth),
|
|
|
|
|
padRight("TEAM", teamWidth),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
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
|
|
|
visible := m.visibleRows()
|
|
|
|
|
endIdx := m.scroll + visible
|
|
|
|
|
if endIdx > len(m.filteredDrivers) {
|
|
|
|
|
endIdx = len(m.filteredDrivers)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := m.scroll; i < endIdx; i++ {
|
|
|
|
|
d := m.filteredDrivers[i]
|
|
|
|
|
|
|
|
|
|
teamColor := colorMuted
|
|
|
|
|
if d.TeamColour != "" {
|
|
|
|
|
teamColor = "#" + d.TeamColour
|
|
|
|
|
} else {
|
|
|
|
|
teamColor = teamColorFromName(d.TeamName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
|
|
|
|
|
|
|
|
|
var row string
|
|
|
|
|
if compact {
|
|
|
|
|
row = fmt.Sprintf(" %s %s %s %s",
|
|
|
|
|
padRight(fmt.Sprintf("%d", d.DriverNumber), 3),
|
|
|
|
|
colorBar,
|
|
|
|
|
padRight(d.NameAcronym, 4),
|
|
|
|
|
padRight(truncate(d.FullName, nameWidth), nameWidth),
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
row = fmt.Sprintf(" %s %s %s %s %s",
|
|
|
|
|
padRight(fmt.Sprintf("%d", d.DriverNumber), 3),
|
|
|
|
|
colorBar,
|
|
|
|
|
padRight(d.NameAcronym, 4),
|
|
|
|
|
padRight(truncate(d.FullName, nameWidth), nameWidth),
|
|
|
|
|
padRight(truncate(d.TeamName, teamWidth), teamWidth),
|
|
|
|
|
)
|
|
|
|
|
}
|
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.filteredDrivers) > visible {
|
|
|
|
|
sb.WriteString(styleMuted.Render(fmt.Sprintf("\n %d of %d drivers", len(m.filteredDrivers), len(m.drivers))) + "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
sb.WriteString(helpBar("↑/↓ navigate", "g/G top/bottom", "^d/^u page", "enter view", "type to search", "q quit"))
|
2026-03-03 00:48:05 -05:00
|
|
|
return sb.String()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
func (m *DriverModel) updateDetailViewport() {
|
|
|
|
|
if m.selectedDriver == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
content := m.renderDetailContent()
|
|
|
|
|
vpWidth := m.width - 2
|
|
|
|
|
if vpWidth < 40 {
|
|
|
|
|
vpWidth = 40
|
|
|
|
|
}
|
|
|
|
|
vpHeight := m.height - 5 // room for header + help bar
|
|
|
|
|
if vpHeight < 5 {
|
|
|
|
|
vpHeight = 5
|
|
|
|
|
}
|
|
|
|
|
if !m.detailViewReady {
|
|
|
|
|
m.detailView = viewport.New(vpWidth, vpHeight)
|
|
|
|
|
m.detailViewReady = true
|
|
|
|
|
} else {
|
|
|
|
|
m.detailView.Width = vpWidth
|
|
|
|
|
m.detailView.Height = vpHeight
|
|
|
|
|
}
|
|
|
|
|
m.detailView.SetContent(content)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 00:48:05 -05:00
|
|
|
func (m DriverModel) renderDriverDetail() string {
|
|
|
|
|
if m.selectedDriver == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
d := m.selectedDriver
|
|
|
|
|
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
teamColor := colorMuted
|
|
|
|
|
if d.TeamColour != "" {
|
|
|
|
|
teamColor = "#" + d.TeamColour
|
|
|
|
|
} else {
|
|
|
|
|
teamColor = teamColorFromName(d.TeamName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Driver card header (fixed, not scrollable)
|
|
|
|
|
numberBg := lipgloss.NewStyle().
|
|
|
|
|
Background(lipgloss.Color(teamColor)).
|
|
|
|
|
Foreground(lipgloss.Color(colorWhite)).
|
|
|
|
|
Bold(true).
|
|
|
|
|
Padding(0, 1).
|
|
|
|
|
Render(fmt.Sprintf(" #%d ", d.DriverNumber))
|
|
|
|
|
|
|
|
|
|
nameStyled := lipgloss.NewStyle().
|
|
|
|
|
Foreground(lipgloss.Color(teamColor)).
|
|
|
|
|
Bold(true).
|
|
|
|
|
Render(d.FullName)
|
2026-03-03 00:48:05 -05:00
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
acronymStyled := lipgloss.NewStyle().
|
|
|
|
|
Foreground(lipgloss.Color(colorWhite)).
|
|
|
|
|
Bold(true).
|
|
|
|
|
Render(d.NameAcronym)
|
|
|
|
|
|
|
|
|
|
teamStyled := styleTeamName.Render(d.TeamName)
|
|
|
|
|
|
|
|
|
|
sb.WriteString(fmt.Sprintf("\n %s %s %s %s\n", numberBg, nameStyled, acronymStyled, teamStyled))
|
|
|
|
|
|
|
|
|
|
stripeWidth := min(m.width-6, 60)
|
|
|
|
|
stripe := lipgloss.NewStyle().
|
|
|
|
|
Foreground(lipgloss.Color(teamColor)).
|
|
|
|
|
Render(strings.Repeat("━", stripeWidth))
|
|
|
|
|
sb.WriteString(" " + stripe + "\n")
|
|
|
|
|
|
|
|
|
|
if m.loading {
|
|
|
|
|
sb.WriteString(fmt.Sprintf("\n %s Loading driver data...\n", m.spinner.View()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Scrollable content via viewport
|
|
|
|
|
if m.detailViewReady {
|
|
|
|
|
sb.WriteString(m.detailView.View())
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
// Scroll indicator
|
|
|
|
|
pct := m.detailView.ScrollPercent()
|
|
|
|
|
scrollInfo := styleMuted.Render(fmt.Sprintf(" %.0f%%", pct*100))
|
|
|
|
|
sb.WriteString(scrollInfo + "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sb.WriteString(helpBar("↑/↓ scroll", "g/G top/bottom", "^d/^u page", "b back", "q quit"))
|
|
|
|
|
return sb.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderDetailContent generates the full content for the driver detail viewport.
|
|
|
|
|
func (m DriverModel) renderDetailContent() string {
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
|
|
|
|
|
// ── POSITION HISTORY ────────────────────────────────
|
|
|
|
|
sb.WriteString("\n " + styleSectionTitle.Render("POSITION HISTORY") + "\n")
|
|
|
|
|
sb.WriteString(m.renderPositionChart())
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
|
|
|
|
|
// ── RACE STRATEGY ───────────────────────────────────
|
|
|
|
|
sb.WriteString(" " + styleSectionTitle.Render("RACE STRATEGY") + "\n")
|
2026-03-03 00:48:05 -05:00
|
|
|
sb.WriteString(m.renderStintBar())
|
|
|
|
|
sb.WriteString("\n\n")
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// ── LAP TIMES ───────────────────────────────────────
|
|
|
|
|
sb.WriteString(" " + styleSectionTitle.Render("LAP TIMES") + "\n")
|
|
|
|
|
sparkWidth := min(m.width-6, 70)
|
2026-03-03 00:48:05 -05:00
|
|
|
if sparkWidth < 10 {
|
|
|
|
|
sparkWidth = 40
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString(" " + sparkline(m.laps, sparkWidth) + "\n")
|
2026-03-03 02:29:34 -05:00
|
|
|
legend := fmt.Sprintf(" %s fast %s mid %s slow %s pit",
|
|
|
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render("█"),
|
|
|
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Render("█"),
|
|
|
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render("█"),
|
|
|
|
|
styleMuted.Render("·"),
|
|
|
|
|
)
|
|
|
|
|
sb.WriteString(legend)
|
|
|
|
|
|
|
|
|
|
if len(m.laps) > 0 {
|
|
|
|
|
var bestLap, worstLap float64
|
|
|
|
|
var bestLapNum, worstLapNum int
|
|
|
|
|
bestLap = 999999
|
|
|
|
|
for _, lap := range m.laps {
|
|
|
|
|
if lap.LapDuration != nil && *lap.LapDuration > 0 {
|
|
|
|
|
if *lap.LapDuration < bestLap {
|
|
|
|
|
bestLap = *lap.LapDuration
|
|
|
|
|
bestLapNum = lap.LapNumber
|
|
|
|
|
}
|
|
|
|
|
if *lap.LapDuration > worstLap && !lap.IsPitOutLap {
|
|
|
|
|
worstLap = *lap.LapDuration
|
|
|
|
|
worstLapNum = lap.LapNumber
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if bestLap < 999999 {
|
|
|
|
|
sb.WriteString(fmt.Sprintf(" %s %s (Lap %d)",
|
|
|
|
|
styleMuted.Render("Best:"),
|
|
|
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true).Render(formatSeconds(bestLap)),
|
|
|
|
|
bestLapNum,
|
|
|
|
|
))
|
|
|
|
|
if worstLap > 0 {
|
|
|
|
|
sb.WriteString(fmt.Sprintf(" %s %s (Lap %d)",
|
|
|
|
|
styleMuted.Render("Slowest:"),
|
|
|
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render(formatSeconds(worstLap)),
|
|
|
|
|
worstLapNum,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString("\n\n")
|
2026-03-03 00:48:05 -05:00
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// ── PIT STOPS ───────────────────────────────────────
|
|
|
|
|
sb.WriteString(" " + styleSectionTitle.Render("PIT STOPS") + "\n")
|
2026-03-03 00:48:05 -05:00
|
|
|
sb.WriteString(m.renderPitStops())
|
|
|
|
|
sb.WriteString("\n\n")
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// ── TEAM RADIO ──────────────────────────────────────
|
|
|
|
|
sb.WriteString(" " + styleSectionTitle.Render("TEAM RADIO") + "\n")
|
|
|
|
|
sb.WriteString(m.renderTeamRadio())
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
|
2026-03-03 00:48:05 -05:00
|
|
|
return sb.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DriverModel) renderStintBar() string {
|
|
|
|
|
if len(m.stints) == 0 {
|
|
|
|
|
if m.loading {
|
2026-03-03 02:29:34 -05:00
|
|
|
return fmt.Sprintf(" %s Loading...", m.spinner.View())
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
2026-03-03 02:29:34 -05:00
|
|
|
return styleMuted.Render(" No stint data available.")
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var parts []string
|
2026-03-03 02:29:34 -05:00
|
|
|
for i, stint := range m.stints {
|
|
|
|
|
label := fmt.Sprintf(" %s L%d-%d ", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd)
|
|
|
|
|
stintStr := tyreBgStyle(stint.Compound).Render(label)
|
|
|
|
|
|
|
|
|
|
parts = append(parts, stintStr)
|
|
|
|
|
|
|
|
|
|
// Arrow between stints
|
|
|
|
|
if i < len(m.stints)-1 {
|
|
|
|
|
parts = append(parts, styleMuted.Render(" > "))
|
|
|
|
|
}
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
return " " + strings.Join(parts, "")
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m DriverModel) renderPitStops() string {
|
|
|
|
|
if len(m.pits) == 0 {
|
2026-03-03 02:29:34 -05:00
|
|
|
return styleMuted.Render(" No pit stop data available.")
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
var sb strings.Builder
|
|
|
|
|
|
|
|
|
|
// Header
|
|
|
|
|
header := fmt.Sprintf(" %s %s %s %s",
|
|
|
|
|
padRight("STOP", 5),
|
|
|
|
|
padLeft("LAP", 4),
|
|
|
|
|
padLeft("STOP TIME", 10),
|
|
|
|
|
padLeft("PIT LANE", 10),
|
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(35) + "\n")
|
|
|
|
|
|
|
|
|
|
for i, p := range m.pits {
|
|
|
|
|
stopNum := fmt.Sprintf("#%d", i+1)
|
|
|
|
|
|
|
|
|
|
// Color stop duration: green = fast, red = slow
|
|
|
|
|
stopDur := p.StopDuration
|
|
|
|
|
var stopStyle lipgloss.Style
|
|
|
|
|
switch {
|
|
|
|
|
case stopDur < 2.5:
|
|
|
|
|
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true)
|
|
|
|
|
case stopDur < 3.5:
|
|
|
|
|
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
|
|
|
|
|
default:
|
|
|
|
|
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
|
|
|
|
}
|
2026-03-03 00:48:05 -05:00
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
row := fmt.Sprintf(" %s %s %s %s",
|
|
|
|
|
padRight(stopNum, 5),
|
2026-03-03 00:48:05 -05:00
|
|
|
padLeft(fmt.Sprintf("%d", p.LapNumber), 4),
|
2026-03-03 02:29:34 -05:00
|
|
|
padLeftVisible(stopStyle.Render(fmt.Sprintf("%.3fs", p.StopDuration)), 10),
|
|
|
|
|
padLeft(fmt.Sprintf("%.3fs", p.LaneDuration), 10),
|
2026-03-03 00:48:05 -05:00
|
|
|
)
|
2026-03-03 02:29:34 -05:00
|
|
|
sb.WriteString(row + "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sb.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderPositionChart draws a text-based position history chart.
|
|
|
|
|
// Shows position changes over the race using a compact inline format.
|
|
|
|
|
func (m DriverModel) renderPositionChart() string {
|
|
|
|
|
if len(m.positions) == 0 {
|
|
|
|
|
return styleMuted.Render(" No position data available.\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
|
|
|
|
|
// Deduplicate: only keep position changes
|
|
|
|
|
type posChange struct {
|
|
|
|
|
position int
|
|
|
|
|
date string
|
|
|
|
|
}
|
|
|
|
|
var changes []posChange
|
|
|
|
|
lastPos := -1
|
|
|
|
|
for _, p := range m.positions {
|
|
|
|
|
if p.Position != lastPos {
|
|
|
|
|
changes = append(changes, posChange{position: p.Position, date: p.Date})
|
|
|
|
|
lastPos = p.Position
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(changes) == 0 {
|
|
|
|
|
return styleMuted.Render(" No position changes.\n")
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 02:29:34 -05:00
|
|
|
// Start and end positions
|
|
|
|
|
startPos := changes[0].position
|
|
|
|
|
endPos := changes[len(changes)-1].position
|
|
|
|
|
bestPos := startPos
|
|
|
|
|
worstPos := startPos
|
|
|
|
|
for _, c := range changes {
|
|
|
|
|
if c.position < bestPos {
|
|
|
|
|
bestPos = c.position
|
|
|
|
|
}
|
|
|
|
|
if c.position > worstPos {
|
|
|
|
|
worstPos = c.position
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Summary line
|
|
|
|
|
startStyled := renderPosition(startPos)
|
|
|
|
|
endStyled := renderPosition(endPos)
|
|
|
|
|
delta := startPos - endPos // positive = gained
|
|
|
|
|
var deltaStr string
|
|
|
|
|
if delta > 0 {
|
|
|
|
|
deltaStr = styleDeltaUp.Render(fmt.Sprintf("▲%d gained", delta))
|
|
|
|
|
} else if delta < 0 {
|
|
|
|
|
deltaStr = styleDeltaDown.Render(fmt.Sprintf("▼%d lost", -delta))
|
|
|
|
|
} else {
|
|
|
|
|
deltaStr = styleDeltaEqual.Render("─ no change")
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString(fmt.Sprintf(" Start: P%s Finish: P%s %s\n", startStyled, endStyled, deltaStr))
|
|
|
|
|
sb.WriteString(fmt.Sprintf(" %s P%s %s P%s\n",
|
|
|
|
|
styleMuted.Render("Best:"),
|
|
|
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true).Render(fmt.Sprintf("%d", bestPos)),
|
|
|
|
|
styleMuted.Render("Worst:"),
|
|
|
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render(fmt.Sprintf("%d", worstPos)),
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
// Visual position timeline (compact sparkline-style)
|
|
|
|
|
chartWidth := min(m.width-6, 60)
|
|
|
|
|
if chartWidth < 20 {
|
|
|
|
|
chartWidth = 20
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(changes) > 1 {
|
|
|
|
|
posRange := worstPos - bestPos
|
|
|
|
|
if posRange == 0 {
|
|
|
|
|
posRange = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sample positions at regular intervals
|
|
|
|
|
samples := make([]int, chartWidth)
|
|
|
|
|
for i := 0; i < chartWidth; i++ {
|
|
|
|
|
idx := i * (len(changes) - 1) / (chartWidth - 1)
|
|
|
|
|
if idx >= len(changes) {
|
|
|
|
|
idx = len(changes) - 1
|
|
|
|
|
}
|
|
|
|
|
samples[i] = changes[idx].position
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw the chart
|
|
|
|
|
blocks := []rune("▁▂▃▄▅▆▇█")
|
|
|
|
|
var chartLine strings.Builder
|
|
|
|
|
for _, pos := range samples {
|
|
|
|
|
// Invert: lower position (better) = taller bar
|
|
|
|
|
norm := float64(pos-bestPos) / float64(posRange)
|
|
|
|
|
idx := int((1.0-norm)*float64(len(blocks)-1) + 0.5)
|
|
|
|
|
if idx < 0 {
|
|
|
|
|
idx = 0
|
|
|
|
|
}
|
|
|
|
|
if idx >= len(blocks) {
|
|
|
|
|
idx = len(blocks) - 1
|
|
|
|
|
}
|
|
|
|
|
var blockStyle lipgloss.Style
|
|
|
|
|
switch {
|
|
|
|
|
case norm < 0.25:
|
|
|
|
|
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
|
|
|
|
|
case norm < 0.5:
|
|
|
|
|
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
|
|
|
|
|
case norm < 0.75:
|
|
|
|
|
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange))
|
|
|
|
|
default:
|
|
|
|
|
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
|
|
|
|
}
|
|
|
|
|
chartLine.WriteString(blockStyle.Render(string(blocks[idx])))
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString(" " + chartLine.String() + "\n")
|
|
|
|
|
sb.WriteString(fmt.Sprintf(" %s%s%s\n",
|
|
|
|
|
styleMuted.Render(fmt.Sprintf("P%d", bestPos)),
|
|
|
|
|
strings.Repeat(" ", max(chartWidth-8, 1)),
|
|
|
|
|
styleMuted.Render(fmt.Sprintf("P%d", worstPos)),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Position change timeline (textual)
|
|
|
|
|
if len(changes) > 1 && len(changes) <= 20 {
|
|
|
|
|
sb.WriteString(" ")
|
|
|
|
|
for i, c := range changes {
|
|
|
|
|
posStr := fmt.Sprintf("P%d", c.position)
|
|
|
|
|
if c.position <= 3 {
|
|
|
|
|
posStr = renderPosition(c.position)
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString(posStr)
|
|
|
|
|
if i < len(changes)-1 {
|
|
|
|
|
next := changes[i+1].position
|
|
|
|
|
if next < c.position {
|
|
|
|
|
sb.WriteString(styleDeltaUp.Render(" > "))
|
|
|
|
|
} else if next > c.position {
|
|
|
|
|
sb.WriteString(styleDeltaDown.Render(" > "))
|
|
|
|
|
} else {
|
|
|
|
|
sb.WriteString(styleMuted.Render(" > "))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sb.WriteString("\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sb.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderTeamRadio displays team radio messages with timestamps.
|
|
|
|
|
func (m DriverModel) renderTeamRadio() string {
|
|
|
|
|
if len(m.teamRadios) == 0 {
|
|
|
|
|
return styleMuted.Render(" No team radio messages available.\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sb strings.Builder
|
|
|
|
|
|
2026-03-07 23:53:16 -05:00
|
|
|
countStr := styleMuted.Render(fmt.Sprintf(" %d audio messages (open URL in browser to listen)", len(m.teamRadios)))
|
2026-03-03 02:29:34 -05:00
|
|
|
sb.WriteString(countStr + "\n")
|
|
|
|
|
|
|
|
|
|
for i, radio := range m.teamRadios {
|
|
|
|
|
t := "--:--:--"
|
|
|
|
|
if len(radio.Date) >= 19 {
|
|
|
|
|
pt, err := time.Parse(time.RFC3339, radio.Date)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t = pt.Format("15:04:05")
|
|
|
|
|
} else {
|
|
|
|
|
t = radio.Date[11:19]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
icon := lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render("📻")
|
|
|
|
|
timeStr := styleMuted.Render(fmt.Sprintf("[%s]", t))
|
|
|
|
|
|
|
|
|
|
urlStr := radio.RecordingURL
|
|
|
|
|
urlStyled := lipgloss.NewStyle().
|
|
|
|
|
Foreground(lipgloss.Color(colorCyan)).
|
|
|
|
|
Render(truncate(urlStr, min(m.width-20, 50)))
|
|
|
|
|
|
|
|
|
|
sb.WriteString(fmt.Sprintf(" %s %s %s\n", icon, timeStr, urlStyled))
|
|
|
|
|
|
|
|
|
|
if i >= 14 && i < len(m.teamRadios)-1 {
|
|
|
|
|
remaining := len(m.teamRadios) - i - 1
|
|
|
|
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... and %d more messages\n", remaining)))
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sb.String()
|
2026-03-03 00:48:05 -05:00
|
|
|
}
|