mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
1079 lines
28 KiB
Go
1079 lines
28 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AmanTahiliani/box-box/internal/api"
|
|
"github.com/AmanTahiliani/box-box/internal/models"
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type RaceDetailModel struct {
|
|
client *api.OpenF1Client
|
|
meeting *models.Meeting
|
|
|
|
sessions []models.Session
|
|
results []models.SessionResult
|
|
drivers map[int]models.Driver
|
|
startingGrid []models.StartingGrid
|
|
rcMsgs []models.RaceControl
|
|
weather []models.Weather
|
|
overtakes []models.Overtake
|
|
|
|
selectedSession *models.Session
|
|
sessionCursor int
|
|
resultsCursor int
|
|
resultsScroll int
|
|
|
|
loadingSessions bool
|
|
loadingResults bool
|
|
stale bool
|
|
driversLoaded bool
|
|
secondaryLoading bool
|
|
errSessions error
|
|
errResults error
|
|
|
|
spinner spinner.Model
|
|
rcView viewport.Model
|
|
rcReady bool
|
|
|
|
replay ReplayModel
|
|
|
|
width int
|
|
height int
|
|
}
|
|
|
|
func NewRaceDetailModel(client *api.OpenF1Client) RaceDetailModel {
|
|
s := spinner.New()
|
|
s.Spinner = spinner.MiniDot
|
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
|
|
|
return RaceDetailModel{
|
|
client: client,
|
|
spinner: s,
|
|
drivers: make(map[int]models.Driver),
|
|
replay: NewReplayModel(client),
|
|
}
|
|
}
|
|
|
|
func (m RaceDetailModel) Init() tea.Cmd {
|
|
return m.spinner.Tick
|
|
}
|
|
|
|
func fetchSessions(client *api.OpenF1Client, meetingKey int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
sessions, err := client.GetSessionsForMeeting(meetingKey)
|
|
return sessionsLoadedMsg{sessions: sessions, err: err}
|
|
}
|
|
}
|
|
|
|
// fetchSessionData fetches primary data (results + drivers) for a session.
|
|
// Secondary data (race control, weather, overtakes) is loaded after primary data arrives.
|
|
func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
|
|
return tea.Batch(
|
|
func() tea.Msg {
|
|
results, err := client.GetSessionResult(sessionKey)
|
|
return sessionResultsLoadedMsg{results: results, err: err}
|
|
},
|
|
func() tea.Msg {
|
|
drivers, err := client.GetDriversForSession(sessionKey)
|
|
return sessionDriversLoadedMsg{drivers: drivers, err: err}
|
|
},
|
|
)
|
|
}
|
|
|
|
// fetchSecondaryData fetches lower-priority data (race control, weather, overtakes, starting grid).
|
|
func fetchSecondaryData(client *api.OpenF1Client, sessionKey int, isRace bool) tea.Cmd {
|
|
cmds := []tea.Cmd{
|
|
func() tea.Msg {
|
|
msgs, err := client.GetRaceControl(sessionKey)
|
|
return raceControlLoadedMsg{messages: msgs, err: err}
|
|
},
|
|
func() tea.Msg {
|
|
weather, err := client.GetWeather(sessionKey)
|
|
return weatherLoadedMsg{weather: weather, err: err}
|
|
},
|
|
func() tea.Msg {
|
|
overtakes, err := client.GetOvertakesForSession(sessionKey)
|
|
return overtakesLoadedMsg{overtakes: overtakes, err: err}
|
|
},
|
|
}
|
|
// Starting grid is only relevant for Race sessions — shows qualifying order
|
|
if isRace {
|
|
cmds = append(cmds, func() tea.Msg {
|
|
grid, err := client.GetStartingGrid(sessionKey)
|
|
return startingGridLoadedMsg{grid: grid, err: err}
|
|
})
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
// handled by SetSize from app.go
|
|
|
|
case spinner.TickMsg:
|
|
if m.loadingSessions || m.loadingResults {
|
|
var cmd tea.Cmd
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
// Also forward to replay spinner
|
|
var cmd tea.Cmd
|
|
m.replay, cmd = m.replay.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
case replayDataLoadedMsg:
|
|
var cmd tea.Cmd
|
|
m.replay, cmd = m.replay.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
case meetingSelectedMsg:
|
|
m.meeting = &msg.meeting
|
|
m.sessions = nil
|
|
m.results = nil
|
|
m.startingGrid = nil
|
|
m.rcMsgs = nil
|
|
m.weather = nil
|
|
m.overtakes = nil
|
|
m.selectedSession = nil
|
|
m.sessionCursor = 0
|
|
m.resultsCursor = 0
|
|
m.resultsScroll = 0
|
|
m.loadingSessions = true
|
|
m.loadingResults = false
|
|
m.stale = false
|
|
m.driversLoaded = false
|
|
m.secondaryLoading = false
|
|
m.errSessions = nil
|
|
m.errResults = nil
|
|
m.rcReady = false
|
|
cmds = append(cmds, fetchSessions(m.client, int(msg.meeting.MeetingKey)), m.spinner.Tick)
|
|
|
|
case sessionsLoadedMsg:
|
|
m.loadingSessions = false
|
|
if msg.err != nil {
|
|
m.errSessions = msg.err
|
|
return m, nil
|
|
}
|
|
m.sessions = msg.sessions
|
|
if m.client.LastResponseWasStale() {
|
|
m.stale = true
|
|
}
|
|
|
|
// Auto-select the best session to show:
|
|
// 1. The last session that has already started (ongoing or completed).
|
|
// 2. If no session has started yet, fall back to the first session.
|
|
now := time.Now()
|
|
autoIdx := -1
|
|
for i, s := range m.sessions {
|
|
st, err := time.Parse(time.RFC3339, s.DateStart)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if now.After(st.Local()) {
|
|
autoIdx = i // keep updating — we want the LAST one that has started
|
|
}
|
|
}
|
|
if autoIdx < 0 && len(m.sessions) > 0 {
|
|
autoIdx = 0 // nothing started yet, show first session
|
|
}
|
|
|
|
if autoIdx >= 0 {
|
|
m.sessionCursor = autoIdx
|
|
sess := m.sessions[autoIdx]
|
|
m.selectedSession = &sess
|
|
m.loadingResults = true
|
|
m.driversLoaded = false
|
|
m.secondaryLoading = false
|
|
m.results = nil
|
|
m.startingGrid = nil
|
|
m.rcMsgs = nil
|
|
m.weather = nil
|
|
m.overtakes = nil
|
|
m.drivers = make(map[int]models.Driver)
|
|
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
|
|
}
|
|
|
|
case sessionResultsLoadedMsg:
|
|
if msg.err != nil {
|
|
m.errResults = msg.err
|
|
m.loadingResults = false
|
|
return m, nil
|
|
}
|
|
m.results = msg.results
|
|
m.resultsCursor = 0
|
|
m.resultsScroll = 0
|
|
if m.client.LastResponseWasStale() {
|
|
m.stale = true
|
|
}
|
|
if cmd := m.checkPrimaryLoaded(); cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
case sessionDriversLoadedMsg:
|
|
if msg.err == nil {
|
|
for _, d := range msg.drivers {
|
|
m.drivers[d.DriverNumber] = d
|
|
}
|
|
}
|
|
m.driversLoaded = true
|
|
if cmd := m.checkPrimaryLoaded(); cmd != nil {
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
case loadSecondaryDataMsg:
|
|
// Only load secondary data if it's still for the currently selected session
|
|
if m.selectedSession != nil && m.selectedSession.SessionKey == msg.sessionKey {
|
|
isRace := m.selectedSession.SessionType == "Race"
|
|
cmds = append(cmds, fetchSecondaryData(m.client, msg.sessionKey, isRace))
|
|
}
|
|
|
|
case raceControlLoadedMsg:
|
|
if msg.err == nil {
|
|
m.rcMsgs = msg.messages
|
|
m.updateRCViewport()
|
|
}
|
|
|
|
case weatherLoadedMsg:
|
|
if msg.err == nil {
|
|
m.weather = msg.weather
|
|
}
|
|
|
|
case overtakesLoadedMsg:
|
|
if msg.err == nil {
|
|
m.overtakes = msg.overtakes
|
|
}
|
|
|
|
case startingGridLoadedMsg:
|
|
if msg.err == nil {
|
|
m.startingGrid = msg.grid
|
|
}
|
|
|
|
case tea.KeyMsg:
|
|
// When replay is active, route keys to replay model; only 'b'/'esc' exits.
|
|
if m.replay.IsActive() {
|
|
if matchKey(msg, GlobalKeys.Back) {
|
|
m.replay = m.replay.Exit()
|
|
return m, nil
|
|
}
|
|
var cmd tea.Cmd
|
|
m.replay, cmd = m.replay.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
switch {
|
|
case matchKey(msg, GlobalKeys.Retry):
|
|
if m.errSessions != nil && m.meeting != nil {
|
|
m.errSessions = nil
|
|
m.loadingSessions = true
|
|
cmds = append(cmds, m.spinner.Tick, fetchSessions(m.client, int(m.meeting.MeetingKey)))
|
|
} else if m.errResults != nil && m.selectedSession != nil {
|
|
m.errResults = nil
|
|
m.loadingResults = true
|
|
m.driversLoaded = false
|
|
m.secondaryLoading = false
|
|
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, m.selectedSession.SessionKey))
|
|
}
|
|
case matchKey(msg, GlobalKeys.Up):
|
|
if m.results != nil && m.resultsCursor > 0 {
|
|
m.resultsCursor--
|
|
m.ensureResultsVisible()
|
|
}
|
|
case matchKey(msg, GlobalKeys.Down):
|
|
if m.results != nil && m.resultsCursor < len(m.results)-1 {
|
|
m.resultsCursor++
|
|
m.ensureResultsVisible()
|
|
}
|
|
case matchKey(msg, GlobalKeys.GoTop):
|
|
if m.results != nil {
|
|
m.resultsCursor = 0
|
|
m.resultsScroll = 0
|
|
}
|
|
case matchKey(msg, GlobalKeys.GoBottom):
|
|
if m.results != nil && len(m.results) > 0 {
|
|
m.resultsCursor = len(m.results) - 1
|
|
m.ensureResultsVisible()
|
|
}
|
|
case matchKey(msg, GlobalKeys.HalfUp):
|
|
if m.results != nil {
|
|
half := m.resultsVisibleRows() / 2
|
|
m.resultsCursor -= half
|
|
if m.resultsCursor < 0 {
|
|
m.resultsCursor = 0
|
|
}
|
|
m.ensureResultsVisible()
|
|
}
|
|
case matchKey(msg, GlobalKeys.HalfDown):
|
|
if m.results != nil {
|
|
half := m.resultsVisibleRows() / 2
|
|
m.resultsCursor += half
|
|
if m.resultsCursor >= len(m.results) {
|
|
m.resultsCursor = len(m.results) - 1
|
|
}
|
|
m.ensureResultsVisible()
|
|
}
|
|
case matchKey(msg, GlobalKeys.Enter):
|
|
if len(m.sessions) > 0 && m.sessionCursor < len(m.sessions) {
|
|
sess := m.sessions[m.sessionCursor]
|
|
m.selectedSession = &sess
|
|
m.loadingResults = true
|
|
m.driversLoaded = false
|
|
m.secondaryLoading = false
|
|
m.errResults = nil
|
|
m.results = nil
|
|
m.startingGrid = nil
|
|
m.rcMsgs = nil
|
|
m.weather = nil
|
|
m.overtakes = nil
|
|
m.resultsCursor = 0
|
|
m.resultsScroll = 0
|
|
m.drivers = make(map[int]models.Driver)
|
|
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
|
|
}
|
|
case matchKey(msg, RaceDetailKeys.ScrollUp):
|
|
m.rcView.LineUp(3)
|
|
case matchKey(msg, RaceDetailKeys.ScrollDown):
|
|
m.rcView.LineDown(3)
|
|
case matchKey(msg, RaceDetailKeys.PrevSession):
|
|
if m.sessionCursor > 0 {
|
|
m.sessionCursor--
|
|
sess := m.sessions[m.sessionCursor]
|
|
if m.selectedSession == nil || m.selectedSession.SessionKey != sess.SessionKey {
|
|
m.selectedSession = &sess
|
|
m.loadingResults = true
|
|
m.driversLoaded = false
|
|
m.secondaryLoading = false
|
|
m.errResults = nil
|
|
m.results = nil
|
|
m.startingGrid = nil
|
|
m.rcMsgs = nil
|
|
m.weather = nil
|
|
m.overtakes = nil
|
|
m.resultsCursor = 0
|
|
m.resultsScroll = 0
|
|
m.drivers = make(map[int]models.Driver)
|
|
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
|
|
}
|
|
}
|
|
case matchKey(msg, RaceDetailKeys.NextSession):
|
|
if m.sessionCursor < len(m.sessions)-1 {
|
|
m.sessionCursor++
|
|
sess := m.sessions[m.sessionCursor]
|
|
if m.selectedSession == nil || m.selectedSession.SessionKey != sess.SessionKey {
|
|
m.selectedSession = &sess
|
|
m.loadingResults = true
|
|
m.driversLoaded = false
|
|
m.secondaryLoading = false
|
|
m.errResults = nil
|
|
m.results = nil
|
|
m.startingGrid = nil
|
|
m.rcMsgs = nil
|
|
m.weather = nil
|
|
m.overtakes = nil
|
|
m.resultsCursor = 0
|
|
m.resultsScroll = 0
|
|
m.drivers = make(map[int]models.Driver)
|
|
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
|
|
}
|
|
}
|
|
case matchKey(msg, RaceDetailKeys.Replay):
|
|
// Only enter replay for Race sessions with data loaded
|
|
if m.selectedSession != nil &&
|
|
m.selectedSession.SessionType == "Race" &&
|
|
!m.loadingResults {
|
|
var cmd tea.Cmd
|
|
m.replay, cmd = m.replay.Enter(m.selectedSession.SessionKey, m.selectedSession.SessionName)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
if m.rcReady {
|
|
var cmd tea.Cmd
|
|
m.rcView, cmd = m.rcView.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
// checkPrimaryLoaded checks if both results and drivers have arrived.
|
|
// If so, marks loading complete and returns a command to trigger secondary data loading.
|
|
func (m *RaceDetailModel) checkPrimaryLoaded() tea.Cmd {
|
|
if m.results != nil && m.driversLoaded {
|
|
m.loadingResults = false
|
|
if m.selectedSession != nil && !m.secondaryLoading {
|
|
m.secondaryLoading = true
|
|
return func() tea.Msg {
|
|
return loadSecondaryDataMsg{sessionKey: m.selectedSession.SessionKey}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m RaceDetailModel) resultsVisibleRows() int {
|
|
rows := m.height - 18
|
|
if rows < 5 {
|
|
rows = 5
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func (m *RaceDetailModel) ensureResultsVisible() {
|
|
visible := m.resultsVisibleRows()
|
|
if m.resultsCursor < m.resultsScroll {
|
|
m.resultsScroll = m.resultsCursor
|
|
}
|
|
if m.resultsCursor >= m.resultsScroll+visible {
|
|
m.resultsScroll = m.resultsCursor - visible + 1
|
|
}
|
|
}
|
|
|
|
func (m *RaceDetailModel) updateRCViewport() {
|
|
content := m.renderRaceControlContent()
|
|
if m.rcReady {
|
|
m.rcView.SetContent(content)
|
|
m.rcView.GotoBottom()
|
|
}
|
|
}
|
|
|
|
func (m *RaceDetailModel) initViewport(w, h int) {
|
|
m.rcView = viewport.New(w, h)
|
|
m.rcReady = true
|
|
m.updateRCViewport()
|
|
}
|
|
|
|
func (m RaceDetailModel) View() string {
|
|
if m.meeting == nil {
|
|
return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n") +
|
|
helpBar("2 calendar", "q quit")
|
|
}
|
|
|
|
// When replay is active, show the replay pane instead of normal race detail.
|
|
if m.replay.IsActive() {
|
|
m2 := m.replay
|
|
m2.width = m.width
|
|
m2.height = m.height
|
|
return m2.View()
|
|
}
|
|
|
|
w := m.width
|
|
if w < 40 {
|
|
w = 40
|
|
}
|
|
compact := w < 100
|
|
|
|
var sb strings.Builder
|
|
|
|
if m.stale {
|
|
sb.WriteString(renderStaleBanner())
|
|
}
|
|
|
|
// Race title header
|
|
flag := countryFlag(m.meeting.CountryCode)
|
|
titleStyle := lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color(colorF1Red))
|
|
|
|
if compact {
|
|
sb.WriteString(titleStyle.Render(fmt.Sprintf(" %s %s", flag, m.meeting.MeetingName)) + "\n")
|
|
} else {
|
|
sb.WriteString(titleStyle.Render(fmt.Sprintf(" %s %s", flag, m.meeting.MeetingOfficialName)) + "\n")
|
|
}
|
|
|
|
dates := formatMeetingDates(*m.meeting)
|
|
subtitle := fmt.Sprintf(" %s %s %s",
|
|
styleMuted.Render(m.meeting.Location),
|
|
styleMuted.Render("·"),
|
|
styleMuted.Render(dates))
|
|
sb.WriteString(subtitle + "\n\n")
|
|
|
|
// Session selector pills
|
|
sb.WriteString(m.renderSessionPills())
|
|
sb.WriteString("\n\n")
|
|
|
|
if compact {
|
|
// Single column layout for narrow terminals
|
|
sb.WriteString(m.renderResults(w - 4))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(m.renderWeatherCard(w - 4))
|
|
sb.WriteString("\n")
|
|
if len(m.startingGrid) > 0 {
|
|
sb.WriteString(m.renderStartingGrid(w - 4))
|
|
sb.WriteString("\n")
|
|
}
|
|
sb.WriteString(m.renderOvertakes(w - 4))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
|
|
if !m.rcReady || m.selectedSession == nil {
|
|
sb.WriteString(styleMuted.Render(" No session selected.\n"))
|
|
} else {
|
|
// Show limited RC messages inline
|
|
lines := strings.Split(m.renderRaceControlContent(), "\n")
|
|
maxRC := 8
|
|
if len(lines) > maxRC {
|
|
lines = lines[len(lines)-maxRC:]
|
|
}
|
|
sb.WriteString(strings.Join(lines, "\n") + "\n")
|
|
if len(m.rcMsgs) > maxRC {
|
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... %d more messages (K/J scroll)", len(m.rcMsgs)-maxRC)) + "\n")
|
|
}
|
|
}
|
|
} else {
|
|
// Two-panel layout
|
|
leftWidth := int(float64(w) * 0.55)
|
|
rightWidth := w - leftWidth - 6
|
|
|
|
left := m.renderResults(leftWidth)
|
|
right := m.renderRightPanel(rightWidth)
|
|
|
|
panels := lipgloss.JoinHorizontal(lipgloss.Top,
|
|
stylePanelBorder.Width(leftWidth).Render(left),
|
|
stylePanelBorder.Width(rightWidth).Render(right),
|
|
)
|
|
|
|
sb.WriteString(panels + "\n")
|
|
}
|
|
|
|
sb.WriteString(helpBar("[/] sessions", "j/k results", "g/G top/bottom", "K/J scroll RC", "r replay", "b back", "q quit"))
|
|
return sb.String()
|
|
}
|
|
|
|
func (m RaceDetailModel) renderSessionPills() string {
|
|
if m.loadingSessions {
|
|
return fmt.Sprintf(" %s Loading sessions...", m.spinner.View())
|
|
}
|
|
if m.errSessions != nil {
|
|
return renderErrorView(m.errSessions)
|
|
}
|
|
|
|
var pills []string
|
|
for i, sess := range m.sessions {
|
|
// Format date
|
|
var dateStr string
|
|
if len(sess.DateStart) >= 10 {
|
|
t, err := time.Parse(time.RFC3339, sess.DateStart)
|
|
if err == nil {
|
|
dateStr = t.Local().Format("Mon 2")
|
|
} else {
|
|
dateStr = sess.DateStart[:10]
|
|
}
|
|
}
|
|
|
|
label := fmt.Sprintf("%s %s", sess.SessionName, dateStr)
|
|
|
|
if m.selectedSession != nil && m.selectedSession.SessionKey == sess.SessionKey {
|
|
pills = append(pills, styleSessionActive.Render(label))
|
|
} else if i == m.sessionCursor {
|
|
pills = append(pills, styleSessionCursor.Render(label))
|
|
} else {
|
|
pills = append(pills, styleSessionInactive.Render(label))
|
|
}
|
|
}
|
|
|
|
return " " + strings.Join(pills, " ")
|
|
}
|
|
|
|
func (m RaceDetailModel) renderResults(width int) string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(styleSectionTitle.Render("RESULTS") + "\n")
|
|
|
|
if m.loadingResults {
|
|
sb.WriteString(fmt.Sprintf(" %s Loading results...\n", m.spinner.View()))
|
|
return sb.String()
|
|
}
|
|
if m.errResults != nil {
|
|
sb.WriteString(renderErrorView(m.errResults))
|
|
return sb.String()
|
|
}
|
|
if m.selectedSession == nil {
|
|
sb.WriteString(styleMuted.Render(" Use [ ] to select a session.\n"))
|
|
return sb.String()
|
|
}
|
|
if len(m.results) == 0 {
|
|
sb.WriteString(styleMuted.Render(" No results available.\n"))
|
|
return sb.String()
|
|
}
|
|
|
|
isRace := m.selectedSession.SessionType == "Race"
|
|
compact := width < 55
|
|
|
|
// Responsive team name width
|
|
teamWidth := 16
|
|
if width >= 70 {
|
|
teamWidth = 20
|
|
} else if compact {
|
|
teamWidth = 10
|
|
}
|
|
|
|
// Column header
|
|
var header string
|
|
if isRace {
|
|
if compact {
|
|
header = fmt.Sprintf(" %s %s %s %s %s",
|
|
padRight("P", 3),
|
|
padRight("", 1),
|
|
padRight("DRV", 4),
|
|
padLeft("GAP", 10),
|
|
padLeft("PTS", 4),
|
|
)
|
|
} else {
|
|
header = fmt.Sprintf(" %s %s %s %s %s %s %s",
|
|
padRight("P", 3),
|
|
padRight("", 1),
|
|
padRight("DRV", 4),
|
|
padRight("TEAM", teamWidth),
|
|
padLeft("LAPS", 4),
|
|
padLeft("GAP", 12),
|
|
padLeft("PTS", 4),
|
|
)
|
|
}
|
|
} else {
|
|
if compact {
|
|
header = fmt.Sprintf(" %s %s %s %s",
|
|
padRight("P", 3),
|
|
padRight("", 1),
|
|
padRight("DRV", 4),
|
|
padLeft("TIME", 12),
|
|
)
|
|
} else {
|
|
header = fmt.Sprintf(" %s %s %s %s %s",
|
|
padRight("P", 3),
|
|
padRight("", 1),
|
|
padRight("DRV", 4),
|
|
padRight("TEAM", teamWidth),
|
|
padLeft("TIME", 12),
|
|
)
|
|
}
|
|
}
|
|
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
|
|
sb.WriteString(" " + divider(min(width-4, lipgloss.Width(header))) + "\n")
|
|
|
|
visible := m.resultsVisibleRows()
|
|
endIdx := m.resultsScroll + visible
|
|
if endIdx > len(m.results) {
|
|
endIdx = len(m.results)
|
|
}
|
|
|
|
for i := m.resultsScroll; i < endIdx; i++ {
|
|
r := m.results[i]
|
|
d := m.drivers[r.DriverNumber]
|
|
acronym := d.NameAcronym
|
|
if acronym == "" {
|
|
acronym = fmt.Sprintf("#%d", r.DriverNumber)
|
|
}
|
|
|
|
teamColor := colorMuted
|
|
if d.TeamColour != "" {
|
|
teamColor = "#" + d.TeamColour
|
|
} else if d.TeamName != "" {
|
|
teamColor = teamColorFromName(d.TeamName)
|
|
}
|
|
|
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
|
|
|
// Position with status
|
|
var pos string
|
|
if r.DNF {
|
|
pos = styleDNF.Render("DNF")
|
|
} else if r.DNS {
|
|
pos = styleDNF.Render("DNS")
|
|
} else if r.DSQ {
|
|
pos = styleDNF.Render("DSQ")
|
|
} else {
|
|
pos = renderPosition(r.Position)
|
|
}
|
|
|
|
var row string
|
|
if isRace {
|
|
gap := formatGap(r.GapToLeader)
|
|
var gapStyled string
|
|
if gap == "LEADER" {
|
|
gapStyled = styleLeader.Render("LEADER")
|
|
} else {
|
|
gapStyled = styleGap.Render(gap)
|
|
}
|
|
|
|
if compact {
|
|
row = fmt.Sprintf(" %s %s %s %s %s",
|
|
padRightVisible(pos, 3),
|
|
colorBar,
|
|
padRight(acronym, 4),
|
|
padLeftVisible(gapStyled, 10),
|
|
padLeft(fmt.Sprintf("%.0f", r.Points), 4),
|
|
)
|
|
} else {
|
|
row = fmt.Sprintf(" %s %s %s %s %s %s %s",
|
|
padRightVisible(pos, 3),
|
|
colorBar,
|
|
padRight(acronym, 4),
|
|
padRight(truncate(d.TeamName, teamWidth), teamWidth),
|
|
padLeft(fmt.Sprintf("%d", r.NumberOfLaps), 4),
|
|
padLeftVisible(gapStyled, 12),
|
|
padLeft(fmt.Sprintf("%.0f", r.Points), 4),
|
|
)
|
|
}
|
|
} else {
|
|
dur := formatDuration(r.Duration)
|
|
if compact {
|
|
row = fmt.Sprintf(" %s %s %s %s",
|
|
padRightVisible(pos, 3),
|
|
colorBar,
|
|
padRight(acronym, 4),
|
|
padLeft(dur, 12),
|
|
)
|
|
} else {
|
|
row = fmt.Sprintf(" %s %s %s %s %s",
|
|
padRightVisible(pos, 3),
|
|
colorBar,
|
|
padRight(acronym, 4),
|
|
padRight(truncate(d.TeamName, teamWidth), teamWidth),
|
|
padLeft(dur, 12),
|
|
)
|
|
}
|
|
}
|
|
|
|
if i == m.resultsCursor {
|
|
row = styleSelected.Render(row)
|
|
}
|
|
sb.WriteString(row + "\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m RaceDetailModel) renderRightPanel(width int) string {
|
|
var sb strings.Builder
|
|
|
|
// Weather card at the top
|
|
sb.WriteString(m.renderWeatherCard(width))
|
|
sb.WriteString("\n")
|
|
|
|
// Starting grid (Race sessions only)
|
|
if len(m.startingGrid) > 0 {
|
|
sb.WriteString(m.renderStartingGrid(width))
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Overtakes summary
|
|
sb.WriteString(m.renderOvertakes(width))
|
|
sb.WriteString("\n")
|
|
|
|
// Race control
|
|
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
|
|
if !m.rcReady || m.selectedSession == nil {
|
|
sb.WriteString(styleMuted.Render(" No session selected.\n"))
|
|
} else {
|
|
sb.WriteString(m.rcView.View() + "\n")
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m RaceDetailModel) renderRaceControlContent() string {
|
|
if len(m.rcMsgs) == 0 {
|
|
return styleMuted.Render(" No race control messages.")
|
|
}
|
|
|
|
var lines []string
|
|
for _, rc := range m.rcMsgs {
|
|
t := "--:--"
|
|
if len(rc.Date) >= 16 {
|
|
pt, err := time.Parse(time.RFC3339, rc.Date)
|
|
if err == nil {
|
|
t = pt.Format("15:04")
|
|
} else {
|
|
t = rc.Date[11:16]
|
|
}
|
|
}
|
|
|
|
// Category-specific icon and styling
|
|
var prefix string
|
|
switch rc.Category {
|
|
case models.CategorySafetyCar:
|
|
prefix = styleSafetyCar.Render(fmt.Sprintf(" ⚠ [%s]", t))
|
|
case models.CategoryDRS:
|
|
prefix = styleDRS.Render(fmt.Sprintf(" ▸ [%s]", t))
|
|
default:
|
|
// Flag-based coloring
|
|
var flagStyle lipgloss.Style
|
|
switch rc.Flag {
|
|
case models.FlagGreen:
|
|
flagStyle = styleFlagGreen
|
|
case models.FlagYellow, models.FlagDoubleYellow:
|
|
flagStyle = styleFlagYellow
|
|
case models.FlagRed:
|
|
flagStyle = styleFlagRed
|
|
case models.FlagBlue:
|
|
flagStyle = styleFlagBlue
|
|
case models.FlagChequered:
|
|
flagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true)
|
|
default:
|
|
flagStyle = styleMuted
|
|
}
|
|
|
|
icon := " "
|
|
switch rc.Flag {
|
|
case models.FlagGreen:
|
|
icon = "🟢"
|
|
case models.FlagYellow:
|
|
icon = "🟡"
|
|
case models.FlagDoubleYellow:
|
|
icon = "🟡"
|
|
case models.FlagRed:
|
|
icon = "🔴"
|
|
case models.FlagBlue:
|
|
icon = "🔵"
|
|
case models.FlagChequered:
|
|
icon = "🏁"
|
|
default:
|
|
icon = " "
|
|
}
|
|
prefix = flagStyle.Render(fmt.Sprintf(" %s [%s]", icon, t))
|
|
}
|
|
|
|
lines = append(lines, fmt.Sprintf("%s %s", prefix, rc.Message))
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func (m RaceDetailModel) renderWeatherCard(width int) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(styleSectionTitle.Render("WEATHER") + "\n")
|
|
|
|
if len(m.weather) == 0 {
|
|
sb.WriteString(styleMuted.Render(" No weather data.\n"))
|
|
return sb.String()
|
|
}
|
|
|
|
w := m.weather[len(m.weather)-1]
|
|
|
|
// Weather conditions
|
|
var condStr string
|
|
if w.Rainfall > 0 {
|
|
condStr = styleRain.Render("🌧 Rain")
|
|
} else {
|
|
condStr = styleDry.Render("☀ Dry")
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s %s %s %s%.1fm/s\n",
|
|
condStr,
|
|
styleWeatherLabel.Render("Air:"),
|
|
styleWeatherValue.Render(fmt.Sprintf("%.1f°C", w.AirTemperature)),
|
|
styleWeatherLabel.Render("Track:"),
|
|
styleWeatherValue.Render(fmt.Sprintf("%.1f°C", w.TrackTemperature)),
|
|
styleWeatherLabel.Render("Humidity:"),
|
|
styleWeatherValue.Render(fmt.Sprintf("%.0f%%", w.Humidity)),
|
|
styleWeatherLabel.Render("Wind:"),
|
|
styleWeatherValue.Render(windArrow(w.WindDirection)+" "),
|
|
w.WindSpeed,
|
|
))
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (m *RaceDetailModel) SetSize(w, h int) {
|
|
m.width = w
|
|
m.height = h
|
|
if w < 40 {
|
|
w = 40
|
|
}
|
|
compact := w < 100
|
|
var rightWidth, rcHeight int
|
|
if compact {
|
|
rightWidth = w - 6
|
|
rcHeight = 8
|
|
} else {
|
|
rightWidth = int(float64(w)*0.45) - 8
|
|
rcHeight = h - 14
|
|
}
|
|
if rcHeight < 3 {
|
|
rcHeight = 3
|
|
}
|
|
if !m.rcReady {
|
|
m.initViewport(rightWidth, rcHeight)
|
|
} else {
|
|
m.rcView.Width = rightWidth
|
|
m.rcView.Height = rcHeight
|
|
}
|
|
}
|
|
|
|
// renderOvertakes renders the overtakes summary section.
|
|
func (m RaceDetailModel) renderOvertakes(width int) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(styleSectionTitle.Render("OVERTAKES") + "\n")
|
|
|
|
if len(m.overtakes) == 0 {
|
|
sb.WriteString(styleMuted.Render(" No overtake data.\n"))
|
|
return sb.String()
|
|
}
|
|
|
|
// Total count
|
|
sb.WriteString(fmt.Sprintf(" %s %s\n",
|
|
styleWeatherLabel.Render("Total:"),
|
|
lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Bold(true).Render(fmt.Sprintf("%d", len(m.overtakes))),
|
|
))
|
|
|
|
// Tally: which drivers overtook the most?
|
|
type driverOvertakes struct {
|
|
driverNum int
|
|
overtaking int // times this driver overtook someone
|
|
overtaken int // times this driver was overtaken
|
|
}
|
|
stats := make(map[int]*driverOvertakes)
|
|
for _, o := range m.overtakes {
|
|
if _, ok := stats[o.OvertakingDriverNumber]; !ok {
|
|
stats[o.OvertakingDriverNumber] = &driverOvertakes{driverNum: o.OvertakingDriverNumber}
|
|
}
|
|
stats[o.OvertakingDriverNumber].overtaking++
|
|
|
|
if _, ok := stats[o.OvertakenDriverNumber]; !ok {
|
|
stats[o.OvertakenDriverNumber] = &driverOvertakes{driverNum: o.OvertakenDriverNumber}
|
|
}
|
|
stats[o.OvertakenDriverNumber].overtaken++
|
|
}
|
|
|
|
// Find top overtakers (sorted by most overtakes made)
|
|
type rankedDriver struct {
|
|
driverNum int
|
|
overtaking int
|
|
overtaken int
|
|
}
|
|
var ranked []rankedDriver
|
|
for _, s := range stats {
|
|
ranked = append(ranked, rankedDriver{driverNum: s.driverNum, overtaking: s.overtaking, overtaken: s.overtaken})
|
|
}
|
|
|
|
// Sort by overtaking count descending
|
|
for i := 0; i < len(ranked); i++ {
|
|
for j := i + 1; j < len(ranked); j++ {
|
|
if ranked[j].overtaking > ranked[i].overtaking {
|
|
ranked[i], ranked[j] = ranked[j], ranked[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show top 5 overtakers
|
|
maxShow := 5
|
|
if len(ranked) < maxShow {
|
|
maxShow = len(ranked)
|
|
}
|
|
for i := 0; i < maxShow; i++ {
|
|
r := ranked[i]
|
|
d := m.drivers[r.driverNum]
|
|
acronym := d.NameAcronym
|
|
if acronym == "" {
|
|
acronym = fmt.Sprintf("#%d", r.driverNum)
|
|
}
|
|
|
|
teamColor := colorMuted
|
|
if d.TeamColour != "" {
|
|
teamColor = "#" + d.TeamColour
|
|
} else if d.TeamName != "" {
|
|
teamColor = teamColorFromName(d.TeamName)
|
|
}
|
|
|
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
|
|
|
overtakingStr := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorGreen)).Bold(true).
|
|
Render(fmt.Sprintf("+%d", r.overtaking))
|
|
overtakenStr := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color(colorF1Red)).
|
|
Render(fmt.Sprintf("-%d", r.overtaken))
|
|
|
|
sb.WriteString(fmt.Sprintf(" %s %s %s %s\n",
|
|
colorBar,
|
|
padRight(acronym, 4),
|
|
overtakingStr,
|
|
overtakenStr,
|
|
))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// renderStartingGrid renders the qualifying-based starting grid for a Race session.
|
|
func (m RaceDetailModel) renderStartingGrid(width int) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(styleSectionTitle.Render("STARTING GRID") + "\n")
|
|
|
|
if len(m.startingGrid) == 0 {
|
|
sb.WriteString(styleMuted.Render(" No grid data.\n"))
|
|
return sb.String()
|
|
}
|
|
|
|
// Find the pole time to compute deltas
|
|
var poleTime float64
|
|
for _, g := range m.startingGrid {
|
|
if g.Position == 1 {
|
|
poleTime = g.LapDuration
|
|
break
|
|
}
|
|
}
|
|
|
|
// Show top entries (limit to keep the panel compact)
|
|
maxShow := 10
|
|
if len(m.startingGrid) < maxShow {
|
|
maxShow = len(m.startingGrid)
|
|
}
|
|
|
|
for i := 0; i < maxShow; i++ {
|
|
g := m.startingGrid[i]
|
|
d := m.drivers[g.DriverNumber]
|
|
acronym := d.NameAcronym
|
|
if acronym == "" {
|
|
acronym = fmt.Sprintf("#%d", g.DriverNumber)
|
|
}
|
|
|
|
teamColor := colorMuted
|
|
if d.TeamColour != "" {
|
|
teamColor = "#" + d.TeamColour
|
|
} else if d.TeamName != "" {
|
|
teamColor = teamColorFromName(d.TeamName)
|
|
}
|
|
|
|
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
|
pos := renderPosition(g.Position)
|
|
|
|
// Qualifying time or delta to pole
|
|
var timeStr string
|
|
if g.LapDuration <= 0 {
|
|
timeStr = styleMuted.Render("no time")
|
|
} else if g.Position == 1 {
|
|
timeStr = lipgloss.NewStyle().Foreground(lipgloss.Color(colorPurple)).Bold(true).Render(formatSeconds(g.LapDuration))
|
|
} else if poleTime > 0 {
|
|
delta := g.LapDuration - poleTime
|
|
timeStr = styleGap.Render(fmt.Sprintf("+%.3fs", delta))
|
|
} else {
|
|
timeStr = formatSeconds(g.LapDuration)
|
|
}
|
|
|
|
sb.WriteString(fmt.Sprintf(" %s %s %s %s\n",
|
|
padRightVisible(pos, 3),
|
|
colorBar,
|
|
padRight(acronym, 4),
|
|
timeStr,
|
|
))
|
|
}
|
|
|
|
if len(m.startingGrid) > maxShow {
|
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... %d more\n", len(m.startingGrid)-maxShow)))
|
|
}
|
|
|
|
return sb.String()
|
|
}
|