UI Refresh

This commit is contained in:
2026-03-03 02:29:34 -05:00
parent cb77df0eb6
commit 95a4f755cb
13 changed files with 2541 additions and 539 deletions

View File

@@ -17,23 +17,26 @@ type RaceDetailModel struct {
client *api.OpenF1Client
meeting *models.Meeting
sessions []models.Session
results []models.SessionResult
drivers map[int]models.Driver
rcMsgs []models.RaceControl
weather []models.Weather
sessions []models.Session
results []models.SessionResult
drivers map[int]models.Driver
rcMsgs []models.RaceControl
weather []models.Weather
overtakes []models.Overtake
selectedSession *models.Session
sessionCursor int
resultsCursor int
resultsScroll int
loadingSessions bool
loadingResults bool
errSessions error
errResults error
spinner spinner.Model
rcView viewport.Model
rcReady bool
spinner spinner.Model
rcView viewport.Model
rcReady bool
width int
height int
@@ -43,6 +46,7 @@ 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,
@@ -79,6 +83,10 @@ func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
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}
},
)
}
@@ -86,6 +94,9 @@ 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
@@ -99,8 +110,11 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.results = 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.errSessions = nil
@@ -115,13 +129,34 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
return m, nil
}
m.sessions = msg.sessions
// Auto-select Race session if available
// Auto-select the Race session and load its data
raceIdx := -1
for i, s := range m.sessions {
if s.SessionName == "Race" {
m.sessionCursor = i
raceIdx = i
break
}
}
if raceIdx >= 0 {
m.sessionCursor = raceIdx
sess := m.sessions[raceIdx]
m.selectedSession = &sess
m.loadingResults = true
m.results = nil
m.rcMsgs = nil
m.weather = nil
m.drivers = make(map[int]models.Driver)
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
} else if len(m.sessions) > 0 {
// Fallback: select last session
lastIdx := len(m.sessions) - 1
m.sessionCursor = lastIdx
sess := m.sessions[lastIdx]
m.selectedSession = &sess
m.loadingResults = true
m.drivers = make(map[int]models.Driver)
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
}
case sessionResultsLoadedMsg:
if msg.err != nil {
@@ -130,9 +165,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
return m, nil
}
m.results = msg.results
if !m.loadingResults {
// Both results and drivers may arrive in any order
}
m.resultsCursor = 0
m.resultsScroll = 0
m.checkResultsLoaded()
case sessionDriversLoadedMsg:
@@ -154,15 +188,60 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.weather = msg.weather
}
case overtakesLoadedMsg:
if msg.err == nil {
m.overtakes = msg.overtakes
}
case tea.KeyMsg:
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
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, m.selectedSession.SessionKey))
}
case matchKey(msg, GlobalKeys.Up):
if m.sessionCursor > 0 {
m.sessionCursor--
if m.results != nil && m.resultsCursor > 0 {
m.resultsCursor--
m.ensureResultsVisible()
}
case matchKey(msg, GlobalKeys.Down):
if m.sessionCursor < len(m.sessions)-1 {
m.sessionCursor++
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) {
@@ -172,13 +251,24 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.results = 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, fetchSessionData(m.client, sess.SessionKey))
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--
}
case matchKey(msg, RaceDetailKeys.NextSession):
if m.sessionCursor < len(m.sessions)-1 {
m.sessionCursor++
}
}
}
@@ -192,12 +282,29 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
}
func (m *RaceDetailModel) checkResultsLoaded() {
// Mark done once results arrive (drivers may still be loading but we show what we have)
if m.results != nil {
m.loadingResults = false
}
}
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 {
@@ -214,177 +321,307 @@ func (m *RaceDetailModel) initViewport(w, h int) {
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"))
return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n") +
helpBar("2 calendar", "q quit")
}
// Title
title := styleBold.Render(m.meeting.MeetingOfficialName)
dates := formatMeetingDates(*m.meeting)
subtitle := styleMuted.Render(fmt.Sprintf("%s · %s · %s", m.meeting.Location, m.meeting.CountryName, dates))
w := m.width
if w < 40 {
w = 40
}
compact := w < 100
header := lipgloss.JoinVertical(lipgloss.Left, title, subtitle) + "\n\n"
// Two-panel layout
leftWidth := int(float64(m.width) * 0.55)
rightWidth := m.width - leftWidth - 4
left := m.renderLeft(leftWidth)
right := m.renderRight(rightWidth)
panels := lipgloss.JoinHorizontal(lipgloss.Top,
stylePanelBorder.Width(leftWidth).Render(left),
stylePanelBorder.Width(rightWidth).Render(right),
)
help := helpBar("j/k sessions", "enter load session", "K/J scroll RC", "b back to calendar", "q quit")
return header + panels + "\n" + help
}
func (m RaceDetailModel) renderLeft(width int) string {
var sb strings.Builder
// Session list
sb.WriteString(styleHeader.Render("Sessions") + "\n")
if m.loadingSessions {
sb.WriteString(fmt.Sprintf(" %s Loading sessions…\n", m.spinner.View()))
} else if m.errSessions != nil {
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errSessions)))
// 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 {
for i, sess := range m.sessions {
var start string
if len(sess.DateStart) >= 10 {
t, err := time.Parse(time.RFC3339, sess.DateStart)
if err == nil {
start = t.Format("Mon Jan 2")
} else {
start = sess.DateStart[:10]
}
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")
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:]
}
row := fmt.Sprintf(" %-12s %s", sess.SessionName, start)
if i == m.sessionCursor {
row = styleSelected.Render(row)
} else if m.selectedSession != nil && m.selectedSession.SessionKey == sess.SessionKey {
row = styleDeltaUp.Render(row)
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")
}
sb.WriteString(row + "\n")
}
}
sb.WriteString("\n" + styleHeader.Render("Results") + "\n")
if m.loadingResults {
sb.WriteString(fmt.Sprintf(" %s Loading results…\n", m.spinner.View()))
} else if m.errResults != nil {
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults)))
} else if m.selectedSession == nil {
sb.WriteString(styleMuted.Render(" Press Enter to load session results.\n"))
} else if len(m.results) == 0 {
sb.WriteString(styleMuted.Render(" No results available.\n"))
} else {
sb.WriteString(m.renderResults(width - 4))
// 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", "enter load", "j/k results", "g/G top/bottom", "K/J scroll RC", "b back", "q quit"))
return sb.String()
}
func (m RaceDetailModel) renderResults(width int) string {
isRace := m.selectedSession != nil && m.selectedSession.SessionType == "Race"
const (
wPos = 3
wDRV = 4
wTeam = 16
wLaps = 4
wGap = 12
wPts = 4
)
var header string
if isRace {
header = styleBold.Render(
padLeft("Pos", wPos) + " " +
padRight("DRV", wDRV) + " " +
padRight("Team", wTeam) + " " +
padLeft("Laps", wLaps) + " " +
padLeft("Gap", wGap) + " " +
padLeft("Pts", wPts),
)
} else {
header = styleBold.Render(
padLeft("Pos", wPos) + " " +
padRight("DRV", wDRV) + " " +
padRight("Team", wTeam) + " " +
padLeft("Time", wGap),
)
func (m RaceDetailModel) renderSessionPills() string {
if m.loadingSessions {
return fmt.Sprintf(" %s Loading sessions...", m.spinner.View())
}
if m.errSessions != nil {
return styleError.Render(fmt.Sprintf(" Error: %v", m.errSessions))
}
var rows []string
rows = append(rows, header)
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.Format("Mon 2")
} else {
dateStr = sess.DateStart[:10]
}
}
for _, r := range m.results {
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(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults)))
return sb.String()
}
if m.selectedSession == nil {
sb.WriteString(styleMuted.Render(" Press Enter to load session results.\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)
}
teamName := d.TeamName
teamColor := d.TeamColour
teamStr := hexToStyle(teamColor).Render(padRight(truncate(teamName, wTeam), wTeam))
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
pos := fmt.Sprintf("%d", r.Position)
if r.DNF {
pos = "DNF"
} else if r.DNS {
pos = "DNS"
} else if r.DSQ {
pos = "DSQ"
}
if isRace {
row = fmt.Sprintf("%s %s %s %s %s %s",
padLeft(pos, wPos),
padRight(acronym, wDRV),
teamStr,
padLeft(fmt.Sprintf("%d", r.NumberOfLaps), wLaps),
padLeft(formatGap(r.GapToLeader), wGap),
padLeft(fmt.Sprintf("%.0f", r.Points), wPts),
)
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 {
row = fmt.Sprintf("%s %s %s %s",
padLeft(pos, wPos),
padRight(acronym, wDRV),
teamStr,
padLeft(formatDuration(r.Duration), wGap),
)
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 r.DNF || r.DNS || r.DSQ {
row = styleMuted.Render(row)
if i == m.resultsCursor {
row = styleSelected.Render(row)
}
rows = append(rows, row)
sb.WriteString(row + "\n")
}
return strings.Join(rows, "\n")
return sb.String()
}
func (m RaceDetailModel) renderRight(width int) 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")
// Overtakes summary
sb.WriteString(m.renderOvertakes(width))
sb.WriteString("\n")
// Race control
sb.WriteString(styleHeader.Render("Race Control") + "\n")
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")
}
// Weather strip
sb.WriteString("\n" + styleHeader.Render("Weather") + "\n")
sb.WriteString(m.renderWeather(width))
return sb.String()
}
@@ -405,52 +642,107 @@ func (m RaceDetailModel) renderRaceControlContent() string {
}
}
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
// 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:
flagStyle = styleMuted
// 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))
}
prefix := flagStyle.Render(fmt.Sprintf("[%s]", t))
lines = append(lines, fmt.Sprintf("%s %s", prefix, rc.Message))
}
return strings.Join(lines, "\n")
}
func (m RaceDetailModel) renderWeather(width int) string {
func (m RaceDetailModel) renderWeatherCard(width int) string {
var sb strings.Builder
sb.WriteString(styleSectionTitle.Render("WEATHER") + "\n")
if len(m.weather) == 0 {
return styleMuted.Render(" No weather data.")
sb.WriteString(styleMuted.Render(" No weather data.\n"))
return sb.String()
}
// Use the latest weather snapshot
w := m.weather[len(m.weather)-1]
rain := "Dry"
// Weather conditions
var condStr string
if w.Rainfall > 0 {
rain = styleFlagBlue.Render("Rain")
condStr = styleRain.Render("🌧 Rain")
} else {
condStr = styleDry.Render("☀ Dry")
}
return fmt.Sprintf(" Air: %.1f°C Track: %.1f°C %s Humidity: %.0f%% Wind: %s %.1fm/s",
w.AirTemperature, w.TrackTemperature, rain,
w.Humidity, windArrow(w.WindDirection), w.WindSpeed)
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()
}
// SetSize updates the model's dimensions and initialises the race control viewport.
func (m *RaceDetailModel) SetSize(w, h int) {
m.width = w
m.height = h
// Right panel, minus header/weather/borders
rightWidth := int(float64(w)*0.45) - 6
rcHeight := h - 12 // approximate: title + sessions + header + weather + help
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
}
@@ -461,3 +753,98 @@ func (m *RaceDetailModel) SetSize(w, h int) {
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()
}