mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
UI Refresh
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user