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