mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Live telemetry
This commit is contained in:
BIN
box-box-bin
Executable file
BIN
box-box-bin
Executable file
Binary file not shown.
1
go.mod
1
go.mod
@@ -16,6 +16,7 @@ require (
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -24,6 +24,8 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
|
||||
@@ -305,7 +305,11 @@ func (c *OpenF1Client) GetOvertakesForSession(sessionKey int) ([]models.Overtake
|
||||
}
|
||||
|
||||
func (c *OpenF1Client) GetPositions(sessionKey, driverNumber int) ([]models.Position, error) {
|
||||
body, err := c.get(fmt.Sprintf("%s/v1/position?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
|
||||
url := fmt.Sprintf("%s/v1/position?session_key=%d", c.url, sessionKey)
|
||||
if driverNumber != 0 {
|
||||
url += fmt.Sprintf("&driver_number=%d", driverNumber)
|
||||
}
|
||||
body, err := c.get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -319,7 +323,11 @@ func (c *OpenF1Client) GetPositions(sessionKey, driverNumber int) ([]models.Posi
|
||||
}
|
||||
|
||||
func (c *OpenF1Client) GetIntervals(sessionKey, driverNumber int) ([]models.Interval, error) {
|
||||
body, err := c.get(fmt.Sprintf("%s/v1/intervals?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
|
||||
url := fmt.Sprintf("%s/v1/intervals?session_key=%d", c.url, sessionKey)
|
||||
if driverNumber != 0 {
|
||||
url += fmt.Sprintf("&driver_number=%d", driverNumber)
|
||||
}
|
||||
body, err := c.get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -15,14 +15,16 @@ import (
|
||||
type tabIndex int
|
||||
|
||||
const (
|
||||
tabStandings tabIndex = 0
|
||||
tabCalendar tabIndex = 1
|
||||
tabRaceDetail tabIndex = 2
|
||||
tabDriver tabIndex = 3
|
||||
tabDashboard tabIndex = 0
|
||||
tabStandings tabIndex = 1
|
||||
tabCalendar tabIndex = 2
|
||||
tabRaceDetail tabIndex = 3
|
||||
tabDriver tabIndex = 4
|
||||
tabLive tabIndex = 5
|
||||
)
|
||||
|
||||
var tabNames = []string{"Standings", "Calendar", "Race", "Drivers"}
|
||||
var tabIcons = []string{"🏆", "📅", "🏁", "👤"}
|
||||
var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live"}
|
||||
var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴"}
|
||||
|
||||
// splashDoneMsg is sent after the splash screen duration has elapsed.
|
||||
type splashDoneMsg struct{}
|
||||
@@ -40,6 +42,8 @@ type AppModel struct {
|
||||
calendar CalendarModel
|
||||
raceDetail RaceDetailModel
|
||||
driver DriverModel
|
||||
dashboard DashboardModel
|
||||
live OfficialLiveModel
|
||||
|
||||
meetings []models.Meeting
|
||||
|
||||
@@ -49,7 +53,7 @@ type AppModel struct {
|
||||
}
|
||||
|
||||
func NewAppModel(client *api.OpenF1Client) AppModel {
|
||||
year := 2025
|
||||
year := time.Now().Year()
|
||||
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Points
|
||||
@@ -57,12 +61,14 @@ func NewAppModel(client *api.OpenF1Client) AppModel {
|
||||
|
||||
return AppModel{
|
||||
client: client,
|
||||
activeTab: tabStandings,
|
||||
activeTab: tabDashboard,
|
||||
year: year,
|
||||
standings: NewStandingsModel(client, year),
|
||||
calendar: NewCalendarModel(client, year),
|
||||
raceDetail: NewRaceDetailModel(client),
|
||||
driver: NewDriverModel(client),
|
||||
dashboard: NewDashboardModel(client, year),
|
||||
live: NewOfficialLiveModel(),
|
||||
showSplash: true,
|
||||
splashSpinner: sp,
|
||||
}
|
||||
@@ -76,6 +82,8 @@ func splashTimer() tea.Cmd {
|
||||
|
||||
func (m AppModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.dashboard.Init(),
|
||||
m.live.Init(),
|
||||
m.standings.Init(),
|
||||
m.calendar.Init(),
|
||||
m.raceDetail.Init(),
|
||||
@@ -94,12 +102,14 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.height = msg.Height
|
||||
contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing
|
||||
m.raceDetail.SetSize(m.width-4, contentHeight)
|
||||
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
|
||||
m.standings, cmd1 = m.standings.Update(msg)
|
||||
m.calendar, cmd2 = m.calendar.Update(msg)
|
||||
m.raceDetail, cmd3 = m.raceDetail.Update(msg)
|
||||
m.driver, cmd4 = m.driver.Update(msg)
|
||||
return m, tea.Batch(cmd1, cmd2, cmd3, cmd4)
|
||||
var cmd1, cmd2, cmd3, cmd4, cmd5, cmd6 tea.Cmd
|
||||
m.dashboard, cmd1 = m.dashboard.Update(msg)
|
||||
m.live, cmd2 = m.live.Update(msg)
|
||||
m.standings, cmd3 = m.standings.Update(msg)
|
||||
m.calendar, cmd4 = m.calendar.Update(msg)
|
||||
m.raceDetail, cmd5 = m.raceDetail.Update(msg)
|
||||
m.driver, cmd6 = m.driver.Update(msg)
|
||||
return m, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6)
|
||||
|
||||
case splashDoneMsg:
|
||||
m.showSplash = false
|
||||
@@ -119,22 +129,28 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Tab switching
|
||||
switch {
|
||||
case matchKey(msg, GlobalKeys.Tab1):
|
||||
m.activeTab = tabStandings
|
||||
m.activeTab = tabDashboard
|
||||
return m, nil
|
||||
case matchKey(msg, GlobalKeys.Tab2):
|
||||
m.activeTab = tabCalendar
|
||||
m.activeTab = tabStandings
|
||||
return m, nil
|
||||
case matchKey(msg, GlobalKeys.Tab3):
|
||||
m.activeTab = tabRaceDetail
|
||||
m.activeTab = tabCalendar
|
||||
return m, nil
|
||||
case matchKey(msg, GlobalKeys.Tab4):
|
||||
m.activeTab = tabRaceDetail
|
||||
return m, nil
|
||||
case matchKey(msg, GlobalKeys.Tab5):
|
||||
m.activeTab = tabDriver
|
||||
var cmd tea.Cmd
|
||||
m.driver, cmd = m.driver.TriggerLoad()
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
case matchKey(msg, GlobalKeys.Tab6):
|
||||
m.activeTab = tabLive
|
||||
return m, nil
|
||||
case matchKey(msg, GlobalKeys.NextTab):
|
||||
m.activeTab = (m.activeTab + 1) % 4
|
||||
m.activeTab = (m.activeTab + 1) % 6
|
||||
if m.activeTab == tabDriver {
|
||||
var cmd tea.Cmd
|
||||
m.driver, cmd = m.driver.TriggerLoad()
|
||||
@@ -142,7 +158,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
case matchKey(msg, GlobalKeys.PrevTab):
|
||||
m.activeTab = (m.activeTab - 1 + 4) % 4
|
||||
m.activeTab = (m.activeTab - 1 + 6) % 6
|
||||
if m.activeTab == tabDriver {
|
||||
var cmd tea.Cmd
|
||||
m.driver, cmd = m.driver.TriggerLoad()
|
||||
@@ -156,8 +172,8 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.Year):
|
||||
// Cycle years: 2025 -> 2023 -> 2024 -> 2025
|
||||
if m.year == 2025 {
|
||||
// Cycle years: up to current year, wrap to 2023
|
||||
if m.year >= time.Now().Year() {
|
||||
m.year = 2023
|
||||
} else {
|
||||
m.year++
|
||||
@@ -281,6 +297,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case wsDataMsg:
|
||||
var cmd tea.Cmd
|
||||
m.live, cmd = m.live.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.showSplash {
|
||||
var cmd tea.Cmd
|
||||
@@ -298,6 +320,14 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
// Route keyboard input to active tab
|
||||
switch m.activeTab {
|
||||
case tabDashboard:
|
||||
var cmd tea.Cmd
|
||||
m.dashboard, cmd = m.dashboard.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
case tabLive:
|
||||
var cmd tea.Cmd
|
||||
m.live, cmd = m.live.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
case tabStandings:
|
||||
var cmd tea.Cmd
|
||||
m.standings, cmd = m.standings.Update(msg)
|
||||
@@ -338,6 +368,10 @@ func (m AppModel) View() string {
|
||||
// Content area
|
||||
var content string
|
||||
switch m.activeTab {
|
||||
case tabDashboard:
|
||||
content = m.dashboard.View()
|
||||
case tabLive:
|
||||
content = m.live.View()
|
||||
case tabStandings:
|
||||
content = m.standings.View()
|
||||
case tabCalendar:
|
||||
|
||||
@@ -317,11 +317,15 @@ func formatMeetingDates(m models.Meeting) string {
|
||||
if len(m.DateStart) >= 10 {
|
||||
start, _ = time.Parse("2006-01-02", m.DateStart[:10])
|
||||
}
|
||||
} else {
|
||||
start = start.Local()
|
||||
}
|
||||
if err2 != nil {
|
||||
if len(m.DateEnd) >= 10 {
|
||||
end, _ = time.Parse("2006-01-02", m.DateEnd[:10])
|
||||
}
|
||||
} else {
|
||||
end = end.Local()
|
||||
}
|
||||
|
||||
if start.Month() == end.Month() {
|
||||
|
||||
225
internal/ui/dashboard.go
Normal file
225
internal/ui/dashboard.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/api"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type DashboardModel struct {
|
||||
client *api.OpenF1Client
|
||||
year int
|
||||
width int
|
||||
height int
|
||||
loading bool
|
||||
spinner spinner.Model
|
||||
meetings []models.Meeting
|
||||
next *models.Meeting
|
||||
sessions []models.Session
|
||||
err error
|
||||
}
|
||||
|
||||
func NewDashboardModel(client *api.OpenF1Client, year int) DashboardModel {
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Points
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
|
||||
return DashboardModel{
|
||||
client: client,
|
||||
year: year,
|
||||
loading: true,
|
||||
spinner: sp,
|
||||
}
|
||||
}
|
||||
|
||||
func (m DashboardModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Tick,
|
||||
fetchDashboardMeetings(m.client, m.year),
|
||||
tickCountdown(),
|
||||
)
|
||||
}
|
||||
|
||||
func fetchDashboardMeetings(client *api.OpenF1Client, year int) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
meetings, err := client.GetMeetingsForYear(year)
|
||||
if err != nil {
|
||||
return dashboardMeetingsLoadedMsg{err: err}
|
||||
}
|
||||
return dashboardMeetingsLoadedMsg{meetings: meetings}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchDashboardSessions(client *api.OpenF1Client, meetingKey int) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
sessions, err := client.GetSessionsForMeeting(meetingKey)
|
||||
if err != nil {
|
||||
return dashboardSessionsLoadedMsg{err: err}
|
||||
}
|
||||
// Sort by DateStart
|
||||
sort.Slice(sessions, func(i, j int) bool {
|
||||
return sessions[i].DateStart < sessions[j].DateStart
|
||||
})
|
||||
return dashboardSessionsLoadedMsg{sessions: sessions}
|
||||
}
|
||||
}
|
||||
|
||||
type dashboardMeetingsLoadedMsg struct {
|
||||
meetings []models.Meeting
|
||||
err error
|
||||
}
|
||||
|
||||
type dashboardSessionsLoadedMsg struct {
|
||||
sessions []models.Session
|
||||
err error
|
||||
}
|
||||
|
||||
type tickCountdownMsg time.Time
|
||||
|
||||
func tickCountdown() tea.Cmd {
|
||||
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||
return tickCountdownMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case spinner.TickMsg:
|
||||
if m.loading {
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
case dashboardMeetingsLoadedMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
m.loading = false
|
||||
return m, nil
|
||||
}
|
||||
m.meetings = msg.meetings
|
||||
now := time.Now()
|
||||
|
||||
for i := range m.meetings {
|
||||
mtg := m.meetings[i]
|
||||
end, _ := time.Parse(time.RFC3339, mtg.DateEnd)
|
||||
if now.Before(end.Local()) || now.Sub(end.Local()) < 24*time.Hour { // also keep showing for 24h after
|
||||
m.next = &mtg
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if m.next != nil {
|
||||
return m, fetchDashboardSessions(m.client, int(m.next.MeetingKey))
|
||||
}
|
||||
m.loading = false
|
||||
return m, nil
|
||||
|
||||
case dashboardSessionsLoadedMsg:
|
||||
m.loading = false
|
||||
if msg.err == nil {
|
||||
m.sessions = msg.sessions
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickCountdownMsg:
|
||||
return m, tickCountdown()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m DashboardModel) View() string {
|
||||
if m.loading {
|
||||
return fmt.Sprintf("\n %s Loading dashboard...", m.spinner.View())
|
||||
}
|
||||
if m.err != nil {
|
||||
return styleError.Render(fmt.Sprintf("\n Error: %v\n", m.err))
|
||||
}
|
||||
|
||||
if m.next == nil {
|
||||
return styleMuted.Render(fmt.Sprintf("\n No upcoming races found for %d.\n", m.year))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
w := m.width
|
||||
if w < 40 {
|
||||
w = 40
|
||||
}
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
Bold(true)
|
||||
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(titleStyle.Render(fmt.Sprintf(" NEXT RACE: %s", m.next.MeetingOfficialName)) + "\n")
|
||||
sb.WriteString(fmt.Sprintf(" %s • %s\n", countryFlag(m.next.CountryCode), m.next.Location))
|
||||
|
||||
now := time.Now()
|
||||
end, _ := time.Parse(time.RFC3339, m.next.DateEnd)
|
||||
endLocal := end.Local()
|
||||
|
||||
// Show countdown to first session if available, else use start date
|
||||
var nextStart time.Time
|
||||
startFound := false
|
||||
for _, s := range m.sessions {
|
||||
st, _ := time.Parse(time.RFC3339, s.DateStart)
|
||||
if now.Before(st.Local()) {
|
||||
nextStart = st.Local()
|
||||
startFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !startFound {
|
||||
nextStart, _ = time.Parse(time.RFC3339, m.next.DateStart)
|
||||
nextStart = nextStart.Local()
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
if now.After(nextStart) && now.Before(endLocal) {
|
||||
liveBadge := lipgloss.NewStyle().Background(lipgloss.Color(colorSoft)).Foreground(lipgloss.Color(colorSurface0)).Bold(true).Padding(0, 1).Render("LIVE")
|
||||
sb.WriteString(" " + liveBadge + "\n")
|
||||
} else if now.Before(nextStart) {
|
||||
diff := nextStart.Sub(now)
|
||||
days := int(diff.Hours() / 24)
|
||||
hours := int(diff.Hours()) % 24
|
||||
mins := int(diff.Minutes()) % 60
|
||||
secs := int(diff.Seconds()) % 60
|
||||
sb.WriteString(fmt.Sprintf(" Starts in: %dd %02dh %02dm %02ds\n", days, hours, mins, secs))
|
||||
} else {
|
||||
sb.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Render("Weekend Finished") + "\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Weekend Schedule
|
||||
if len(m.sessions) > 0 {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true).Render(" WEEKEND SCHEDULE (Local Time)") + "\n")
|
||||
for _, s := range m.sessions {
|
||||
st, _ := time.Parse(time.RFC3339, s.DateStart)
|
||||
stLocal := st.Local()
|
||||
day := stLocal.Format("Mon 02 Jan")
|
||||
tStr := stLocal.Format("15:04")
|
||||
|
||||
rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
|
||||
if now.After(stLocal) {
|
||||
rowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
|
||||
}
|
||||
|
||||
sb.WriteString(rowStyle.Render(fmt.Sprintf(" %-15s %-12s %s", s.SessionName, day, tStr)) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + helpBar("1-6 tabs", "q quit"))
|
||||
return sb.String()
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type DriverModel struct {
|
||||
pits []models.Pit
|
||||
positions []models.Position
|
||||
teamRadios []models.TeamRadio
|
||||
raceControl []models.RaceControl
|
||||
|
||||
view driverView
|
||||
loading bool
|
||||
@@ -44,7 +45,6 @@ type DriverModel struct {
|
||||
scroll int
|
||||
input textinput.Model
|
||||
|
||||
// Detail view viewport
|
||||
detailView viewport.Model
|
||||
detailViewReady bool
|
||||
|
||||
@@ -884,11 +884,10 @@ func (m DriverModel) renderTeamRadio() string {
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
countStr := styleMuted.Render(fmt.Sprintf(" %d messages during session", len(m.teamRadios)))
|
||||
countStr := styleMuted.Render(fmt.Sprintf(" %d audio messages (open URL in browser to listen)", len(m.teamRadios)))
|
||||
sb.WriteString(countStr + "\n")
|
||||
|
||||
for i, radio := range m.teamRadios {
|
||||
// Parse timestamp
|
||||
t := "--:--:--"
|
||||
if len(radio.Date) >= 19 {
|
||||
pt, err := time.Parse(time.RFC3339, radio.Date)
|
||||
@@ -909,7 +908,6 @@ func (m DriverModel) renderTeamRadio() string {
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s %s %s\n", icon, timeStr, urlStyled))
|
||||
|
||||
// Limit display to 15 entries
|
||||
if i >= 14 && i < len(m.teamRadios)-1 {
|
||||
remaining := len(m.teamRadios) - i - 1
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... and %d more messages\n", remaining)))
|
||||
|
||||
@@ -8,6 +8,8 @@ type GlobalKeyMap struct {
|
||||
Tab2 key.Binding
|
||||
Tab3 key.Binding
|
||||
Tab4 key.Binding
|
||||
Tab5 key.Binding
|
||||
Tab6 key.Binding
|
||||
NextTab key.Binding
|
||||
PrevTab key.Binding
|
||||
Quit key.Binding
|
||||
@@ -27,19 +29,27 @@ type GlobalKeyMap struct {
|
||||
var GlobalKeys = GlobalKeyMap{
|
||||
Tab1: key.NewBinding(
|
||||
key.WithKeys("1"),
|
||||
key.WithHelp("1", "standings"),
|
||||
key.WithHelp("1", "home"),
|
||||
),
|
||||
Tab2: key.NewBinding(
|
||||
key.WithKeys("2"),
|
||||
key.WithHelp("2", "calendar"),
|
||||
key.WithHelp("2", "standings"),
|
||||
),
|
||||
Tab3: key.NewBinding(
|
||||
key.WithKeys("3"),
|
||||
key.WithHelp("3", "race detail"),
|
||||
key.WithHelp("3", "calendar"),
|
||||
),
|
||||
Tab4: key.NewBinding(
|
||||
key.WithKeys("4"),
|
||||
key.WithHelp("4", "drivers"),
|
||||
key.WithHelp("4", "race detail"),
|
||||
),
|
||||
Tab5: key.NewBinding(
|
||||
key.WithKeys("5"),
|
||||
key.WithHelp("5", "drivers"),
|
||||
),
|
||||
Tab6: key.NewBinding(
|
||||
key.WithKeys("6"),
|
||||
key.WithHelp("6", "live"),
|
||||
),
|
||||
NextTab: key.NewBinding(
|
||||
key.WithKeys("tab", "right"),
|
||||
|
||||
243
internal/ui/live.go
Normal file
243
internal/ui/live.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/api"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type LiveModel struct {
|
||||
client *api.OpenF1Client
|
||||
width int
|
||||
height int
|
||||
loading bool
|
||||
spinner spinner.Model
|
||||
session *models.Session
|
||||
positions map[int]models.Position
|
||||
intervals map[int]models.Interval
|
||||
err error
|
||||
}
|
||||
|
||||
func NewLiveModel(client *api.OpenF1Client) LiveModel {
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Points
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
|
||||
return LiveModel{
|
||||
client: client,
|
||||
loading: true,
|
||||
spinner: sp,
|
||||
positions: make(map[int]models.Position),
|
||||
intervals: make(map[int]models.Interval),
|
||||
}
|
||||
}
|
||||
|
||||
func (m LiveModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Tick,
|
||||
fetchActiveSession(m.client),
|
||||
tickLiveTelemetry(),
|
||||
)
|
||||
}
|
||||
|
||||
type liveSessionLoadedMsg struct {
|
||||
session *models.Session
|
||||
err error
|
||||
}
|
||||
|
||||
func fetchActiveSession(client *api.OpenF1Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
year := time.Now().Year()
|
||||
meetings, err := client.GetMeetingsForYear(year)
|
||||
if err != nil {
|
||||
return liveSessionLoadedMsg{err: err}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var currentMtg *models.Meeting
|
||||
for i := range meetings {
|
||||
end, _ := time.Parse(time.RFC3339, meetings[i].DateEnd)
|
||||
if now.Before(end.Local()) || now.Sub(end.Local()) < 24*time.Hour {
|
||||
currentMtg = &meetings[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if currentMtg == nil {
|
||||
return liveSessionLoadedMsg{err: fmt.Errorf("no active weekend found")}
|
||||
}
|
||||
|
||||
sessions, err := client.GetSessionsForMeeting(int(currentMtg.MeetingKey))
|
||||
if err != nil {
|
||||
return liveSessionLoadedMsg{err: err}
|
||||
}
|
||||
|
||||
var activeSess *models.Session
|
||||
for i := range sessions {
|
||||
st, _ := time.Parse(time.RFC3339, sessions[i].DateStart)
|
||||
en, _ := time.Parse(time.RFC3339, sessions[i].DateEnd)
|
||||
if now.After(st.Local()) && now.Before(en.Local().Add(2*time.Hour)) {
|
||||
activeSess = &sessions[i]
|
||||
}
|
||||
}
|
||||
|
||||
if activeSess == nil && len(sessions) > 0 {
|
||||
activeSess = &sessions[len(sessions)-1]
|
||||
}
|
||||
|
||||
return liveSessionLoadedMsg{session: activeSess}
|
||||
}
|
||||
}
|
||||
|
||||
type tickLiveTelemetryMsg time.Time
|
||||
|
||||
func tickLiveTelemetry() tea.Cmd {
|
||||
return tea.Tick(10*time.Second, func(t time.Time) tea.Msg {
|
||||
return tickLiveTelemetryMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
type liveTelemetryLoadedMsg struct {
|
||||
positions []models.Position
|
||||
intervals []models.Interval
|
||||
err error
|
||||
}
|
||||
|
||||
func fetchLiveTelemetry(client *api.OpenF1Client, sessionKey int) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
positions, err := client.GetPositions(sessionKey, 0)
|
||||
if err != nil {
|
||||
return liveTelemetryLoadedMsg{err: err}
|
||||
}
|
||||
intervals, err := client.GetIntervals(sessionKey, 0)
|
||||
if err != nil {
|
||||
return liveTelemetryLoadedMsg{err: err}
|
||||
}
|
||||
return liveTelemetryLoadedMsg{positions: positions, intervals: intervals}
|
||||
}
|
||||
}
|
||||
|
||||
func (m LiveModel) Update(msg tea.Msg) (LiveModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case spinner.TickMsg:
|
||||
if m.loading {
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
case liveSessionLoadedMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
m.loading = false
|
||||
return m, nil
|
||||
}
|
||||
m.session = msg.session
|
||||
if m.session != nil {
|
||||
return m, fetchLiveTelemetry(m.client, m.session.SessionKey)
|
||||
}
|
||||
m.loading = false
|
||||
return m, nil
|
||||
case tickLiveTelemetryMsg:
|
||||
if m.session != nil {
|
||||
return m, tea.Batch(fetchLiveTelemetry(m.client, m.session.SessionKey), tickLiveTelemetry())
|
||||
}
|
||||
return m, tickLiveTelemetry()
|
||||
case liveTelemetryLoadedMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
// Don't overwrite the screen on transient errors, just log to error field
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.err = nil
|
||||
|
||||
// Get latest positions and intervals per driver
|
||||
for _, p := range msg.positions {
|
||||
current, exists := m.positions[p.DriverNumber]
|
||||
if !exists || p.Date > current.Date {
|
||||
m.positions[p.DriverNumber] = p
|
||||
}
|
||||
}
|
||||
for _, i := range msg.intervals {
|
||||
current, exists := m.intervals[i.DriverNumber]
|
||||
if !exists || i.Date > current.Date {
|
||||
m.intervals[i.DriverNumber] = i
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m LiveModel) View() string {
|
||||
if m.loading {
|
||||
return fmt.Sprintf("\n %s Loading live telemetry...", m.spinner.View())
|
||||
}
|
||||
if m.err != nil && len(m.positions) == 0 {
|
||||
return styleError.Render(fmt.Sprintf("\n Error: %v\n", m.err))
|
||||
}
|
||||
if m.session == nil {
|
||||
return styleMuted.Render("\n No active session found.\n")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Bold(true)
|
||||
|
||||
sb.WriteString("\n " + titleStyle.Render(fmt.Sprintf("LIVE: %s", m.session.SessionName)) + "\n\n")
|
||||
|
||||
if len(m.positions) == 0 {
|
||||
sb.WriteString(styleMuted.Render(" Waiting for telemetry data...\n"))
|
||||
sb.WriteString("\n\n" + helpBar("1-6 tabs", "q quit"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Sort drivers by position
|
||||
var drivers []int
|
||||
for d := range m.positions {
|
||||
drivers = append(drivers, d)
|
||||
}
|
||||
sort.Slice(drivers, func(i, j int) bool {
|
||||
return m.positions[drivers[i]].Position < m.positions[drivers[j]].Position
|
||||
})
|
||||
|
||||
sb.WriteString(styleMuted.Render(" POS NO GAP INT") + "\n")
|
||||
sb.WriteString(" " + strings.Repeat("─", 30) + "\n")
|
||||
|
||||
for _, d := range drivers {
|
||||
pos := m.positions[d].Position
|
||||
interval := m.intervals[d]
|
||||
|
||||
gapToLeader := "LAP"
|
||||
if interval.GapToLeader != nil {
|
||||
gapToLeader = fmt.Sprintf("+%.3fs", *interval.GapToLeader)
|
||||
}
|
||||
if pos == 1 {
|
||||
gapToLeader = "Leader"
|
||||
}
|
||||
|
||||
gapToFront := "LAP"
|
||||
if interval.Interval != nil {
|
||||
gapToFront = fmt.Sprintf("+%.3fs", *interval.Interval)
|
||||
}
|
||||
if pos == 1 {
|
||||
gapToFront = "-"
|
||||
}
|
||||
|
||||
row := fmt.Sprintf(" %-4d %-4d %-10s %-10s", pos, d, gapToLeader, gapToFront)
|
||||
sb.WriteString(row + "\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + helpBar("1-6 tabs", "q quit"))
|
||||
return sb.String()
|
||||
}
|
||||
@@ -92,6 +92,12 @@ type driverTeamRadioLoadedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// driverRaceControlLoadedMsg carries race control messages for a selected driver.
|
||||
type driverRaceControlLoadedMsg struct {
|
||||
messages []models.RaceControl
|
||||
err error
|
||||
}
|
||||
|
||||
// overtakesLoadedMsg carries overtake data for a session.
|
||||
type overtakesLoadedMsg struct {
|
||||
overtakes []models.Overtake
|
||||
|
||||
355
internal/ui/official_live.go
Normal file
355
internal/ui/official_live.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type F1SignalRMessage struct {
|
||||
M []struct {
|
||||
A []json.RawMessage `json:"A"`
|
||||
} `json:"M"`
|
||||
R json.RawMessage `json:"R"`
|
||||
}
|
||||
|
||||
type F1TimingLine struct {
|
||||
GapToLeader interface{} `json:"GapToLeader"`
|
||||
IntervalToPositionAhead struct {
|
||||
Value interface{} `json:"Value"`
|
||||
} `json:"IntervalToPositionAhead"`
|
||||
Position string `json:"Position"`
|
||||
RacingNumber string `json:"RacingNumber"`
|
||||
}
|
||||
|
||||
type F1DriverListEntry struct {
|
||||
RacingNumber string `json:"RacingNumber"`
|
||||
BroadcastName string `json:"BroadcastName"`
|
||||
Tla string `json:"Tla"`
|
||||
TeamName string `json:"TeamName"`
|
||||
TeamColour string `json:"TeamColour"`
|
||||
FirstName string `json:"FirstName"`
|
||||
LastName string `json:"LastName"`
|
||||
}
|
||||
|
||||
type LiveStreamData struct {
|
||||
Drivers map[string]LiveDriverData
|
||||
DriverInfo map[string]F1DriverListEntry
|
||||
}
|
||||
|
||||
type LiveDriverData struct {
|
||||
RacingNumber string
|
||||
Position int
|
||||
GapToLeader string
|
||||
Interval string
|
||||
}
|
||||
|
||||
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
|
||||
hubName := `[{"name":"Streaming"}]`
|
||||
negotiateURL := fmt.Sprintf("https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData=%s", url.QueryEscape(hubName))
|
||||
|
||||
req, err := http.NewRequest("GET", negotiateURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cookies := resp.Cookies()
|
||||
defer resp.Body.Close()
|
||||
|
||||
var neg struct {
|
||||
ConnectionToken string `json:"ConnectionToken"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&neg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wsURL := fmt.Sprintf("wss://livetiming.formula1.com/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken=%s&connectionData=%s",
|
||||
url.QueryEscape(neg.ConnectionToken),
|
||||
url.QueryEscape(hubName),
|
||||
)
|
||||
|
||||
header := http.Header{}
|
||||
for _, cookie := range cookies {
|
||||
header.Add("Cookie", cookie.String())
|
||||
}
|
||||
header.Add("User-Agent", "BestHTTP")
|
||||
|
||||
c, _, err := websocket.DefaultDialer.Dial(wsURL, header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","DriverList"]],"I":1}`)
|
||||
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer c.Close()
|
||||
drivers := make(map[string]LiveDriverData)
|
||||
driverInfo := make(map[string]F1DriverListEntry)
|
||||
|
||||
for {
|
||||
_, message, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
log.Println("WS Read Error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
var parsed F1SignalRMessage
|
||||
if err := json.Unmarshal(message, &parsed); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
updated := false
|
||||
|
||||
// Check full state payload (R)
|
||||
if len(parsed.R) > 2 {
|
||||
var rMap map[string]json.RawMessage
|
||||
if err := json.Unmarshal(parsed.R, &rMap); err == nil {
|
||||
if tdRaw, ok := rMap["TimingData"]; ok {
|
||||
var td struct {
|
||||
Lines map[string]json.RawMessage `json:"Lines"`
|
||||
}
|
||||
if json.Unmarshal(tdRaw, &td) == nil {
|
||||
for num, lineRaw := range td.Lines {
|
||||
var line F1TimingLine
|
||||
if json.Unmarshal(lineRaw, &line) == nil {
|
||||
updateDriver(drivers, num, line)
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if dlRaw, ok := rMap["DriverList"]; ok {
|
||||
var dlMap map[string]json.RawMessage
|
||||
if json.Unmarshal(dlRaw, &dlMap) == nil {
|
||||
for num, entryRaw := range dlMap {
|
||||
var entry F1DriverListEntry
|
||||
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
|
||||
driverInfo[num] = entry
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check incremental feed (M)
|
||||
for _, m := range parsed.M {
|
||||
if len(m.A) > 1 {
|
||||
var topic string
|
||||
json.Unmarshal(m.A[0], &topic)
|
||||
switch topic {
|
||||
case "TimingData":
|
||||
var td struct {
|
||||
Lines map[string]json.RawMessage `json:"Lines"`
|
||||
}
|
||||
if err := json.Unmarshal(m.A[1], &td); err == nil {
|
||||
for num, lineRaw := range td.Lines {
|
||||
var line F1TimingLine
|
||||
if json.Unmarshal(lineRaw, &line) == nil {
|
||||
updateDriver(drivers, num, line)
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case "DriverList":
|
||||
var dlMap map[string]json.RawMessage
|
||||
if err := json.Unmarshal(m.A[1], &dlMap); err == nil {
|
||||
for num, entryRaw := range dlMap {
|
||||
var entry F1DriverListEntry
|
||||
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
|
||||
driverInfo[num] = entry
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
// Send copies to avoid race conditions
|
||||
cpyDrivers := make(map[string]LiveDriverData)
|
||||
for k, v := range drivers {
|
||||
cpyDrivers[k] = v
|
||||
}
|
||||
cpyInfo := make(map[string]F1DriverListEntry)
|
||||
for k, v := range driverInfo {
|
||||
cpyInfo[k] = v
|
||||
}
|
||||
select {
|
||||
case dataChan <- LiveStreamData{Drivers: cpyDrivers, DriverInfo: cpyInfo}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
|
||||
d, exists := drivers[num]
|
||||
if !exists {
|
||||
d = LiveDriverData{RacingNumber: num}
|
||||
if line.RacingNumber != "" {
|
||||
d.RacingNumber = line.RacingNumber
|
||||
}
|
||||
}
|
||||
|
||||
if line.Position != "" {
|
||||
fmt.Sscanf(line.Position, "%d", &d.Position)
|
||||
}
|
||||
if line.GapToLeader != nil {
|
||||
d.GapToLeader = fmt.Sprintf("%v", line.GapToLeader)
|
||||
}
|
||||
if line.IntervalToPositionAhead.Value != nil {
|
||||
d.Interval = fmt.Sprintf("%v", line.IntervalToPositionAhead.Value)
|
||||
}
|
||||
|
||||
drivers[num] = d
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Model wrapper
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type wsDataMsg LiveStreamData
|
||||
|
||||
func listenForWSData(sub chan LiveStreamData) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return wsDataMsg(<-sub)
|
||||
}
|
||||
}
|
||||
|
||||
func parseGap(val string) string {
|
||||
if val == "" {
|
||||
return ""
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
type OfficialLiveModel struct {
|
||||
width int
|
||||
height int
|
||||
dataChan chan LiveStreamData
|
||||
drivers map[string]LiveDriverData
|
||||
driverInfo map[string]F1DriverListEntry
|
||||
err error
|
||||
}
|
||||
|
||||
func NewOfficialLiveModel() OfficialLiveModel {
|
||||
return OfficialLiveModel{
|
||||
dataChan: make(chan LiveStreamData, 10),
|
||||
drivers: make(map[string]LiveDriverData),
|
||||
driverInfo: make(map[string]F1DriverListEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (m OfficialLiveModel) Init() tea.Cmd {
|
||||
err := ConnectToF1LiveTiming(m.dataChan)
|
||||
if err != nil {
|
||||
return func() tea.Msg { return err }
|
||||
}
|
||||
return listenForWSData(m.dataChan)
|
||||
}
|
||||
|
||||
func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
case error:
|
||||
m.err = msg
|
||||
return m, nil
|
||||
case wsDataMsg:
|
||||
m.drivers = msg.Drivers
|
||||
m.driverInfo = msg.DriverInfo
|
||||
return m, listenForWSData(m.dataChan)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m OfficialLiveModel) View() string {
|
||||
if m.err != nil {
|
||||
return fmt.Sprintf("\n Error connecting to F1 live stream: %v", m.err)
|
||||
}
|
||||
if len(m.drivers) == 0 {
|
||||
return "\n Connecting to Official F1 Live Timing Stream...\n"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Bold(true)
|
||||
sb.WriteString("\n " + titleStyle.Render("LIVE TIMING") + "\n\n")
|
||||
|
||||
var drivers []LiveDriverData
|
||||
for _, d := range m.drivers {
|
||||
if d.Position > 0 {
|
||||
drivers = append(drivers, d)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(drivers, func(i, j int) bool {
|
||||
return drivers[i].Position < drivers[j].Position
|
||||
})
|
||||
|
||||
// Header
|
||||
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
|
||||
sb.WriteString(headerStyle.Render(fmt.Sprintf(" %-4s %-4s %-4s %-16s %-12s %-12s", "POS", "NO", "TLA", "DRIVER", "GAP", "INT")) + "\n")
|
||||
sb.WriteString(" " + strings.Repeat("─", 58) + "\n")
|
||||
|
||||
for _, d := range drivers {
|
||||
gap := parseGap(d.GapToLeader)
|
||||
intv := parseGap(d.Interval)
|
||||
if d.Position == 1 {
|
||||
gap = "LEADER"
|
||||
intv = ""
|
||||
}
|
||||
|
||||
// Look up driver info
|
||||
info, hasInfo := m.driverInfo[d.RacingNumber]
|
||||
tla := d.RacingNumber
|
||||
name := ""
|
||||
teamColor := colorMuted
|
||||
if hasInfo {
|
||||
tla = info.Tla
|
||||
name = info.BroadcastName
|
||||
if info.TeamColour != "" {
|
||||
teamColor = "#" + info.TeamColour
|
||||
} else {
|
||||
teamColor = teamColorFromName(info.TeamName)
|
||||
}
|
||||
}
|
||||
|
||||
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||
posStr := fmt.Sprintf("%-4d", d.Position)
|
||||
noStr := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Bold(true).Render(fmt.Sprintf("%-4s", d.RacingNumber))
|
||||
tlaStr := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("%-4s", tla))
|
||||
|
||||
nameStr := styleMuted.Render(fmt.Sprintf("%-16s", name))
|
||||
gapStr := fmt.Sprintf("%-12s", gap)
|
||||
intvStr := fmt.Sprintf("%-12s", intv)
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s %s\n", colorBar, posStr, noStr, tlaStr, nameStr, gapStr, intvStr))
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + helpBar("1-6 tabs", "q quit"))
|
||||
return sb.String()
|
||||
}
|
||||
@@ -414,7 +414,7 @@ func (m RaceDetailModel) renderSessionPills() string {
|
||||
if len(sess.DateStart) >= 10 {
|
||||
t, err := time.Parse(time.RFC3339, sess.DateStart)
|
||||
if err == nil {
|
||||
dateStr = t.Format("Mon 2")
|
||||
dateStr = t.Local().Format("Mon 2")
|
||||
} else {
|
||||
dateStr = sess.DateStart[:10]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user