Files
box-box/internal/ui/racedetail.go
2026-03-27 19:27:38 -04:00

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()
}