Live telemetry

This commit is contained in:
2026-03-07 23:53:16 -05:00
parent 95a4f755cb
commit b1b601304a
15 changed files with 918 additions and 32 deletions

BIN
box-box Executable file

Binary file not shown.

BIN
box-box-bin Executable file

Binary file not shown.

1
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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:

View File

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

View File

@@ -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)))

View File

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

View File

@@ -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

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

View File

@@ -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]
}

BIN
main Executable file

Binary file not shown.