mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -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/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // 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/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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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=
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ import (
|
|||||||
type tabIndex int
|
type tabIndex int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tabStandings tabIndex = 0
|
tabDashboard tabIndex = 0
|
||||||
tabCalendar tabIndex = 1
|
tabStandings tabIndex = 1
|
||||||
tabRaceDetail tabIndex = 2
|
tabCalendar tabIndex = 2
|
||||||
tabDriver tabIndex = 3
|
tabRaceDetail tabIndex = 3
|
||||||
|
tabDriver tabIndex = 4
|
||||||
|
tabLive tabIndex = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
var tabNames = []string{"Standings", "Calendar", "Race", "Drivers"}
|
var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live"}
|
||||||
var tabIcons = []string{"🏆", "📅", "🏁", "👤"}
|
var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴"}
|
||||||
|
|
||||||
// splashDoneMsg is sent after the splash screen duration has elapsed.
|
// splashDoneMsg is sent after the splash screen duration has elapsed.
|
||||||
type splashDoneMsg struct{}
|
type splashDoneMsg struct{}
|
||||||
@@ -40,6 +42,8 @@ type AppModel struct {
|
|||||||
calendar CalendarModel
|
calendar CalendarModel
|
||||||
raceDetail RaceDetailModel
|
raceDetail RaceDetailModel
|
||||||
driver DriverModel
|
driver DriverModel
|
||||||
|
dashboard DashboardModel
|
||||||
|
live OfficialLiveModel
|
||||||
|
|
||||||
meetings []models.Meeting
|
meetings []models.Meeting
|
||||||
|
|
||||||
@@ -49,7 +53,7 @@ type AppModel struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewAppModel(client *api.OpenF1Client) AppModel {
|
func NewAppModel(client *api.OpenF1Client) AppModel {
|
||||||
year := 2025
|
year := time.Now().Year()
|
||||||
|
|
||||||
sp := spinner.New()
|
sp := spinner.New()
|
||||||
sp.Spinner = spinner.Points
|
sp.Spinner = spinner.Points
|
||||||
@@ -57,12 +61,14 @@ func NewAppModel(client *api.OpenF1Client) AppModel {
|
|||||||
|
|
||||||
return AppModel{
|
return AppModel{
|
||||||
client: client,
|
client: client,
|
||||||
activeTab: tabStandings,
|
activeTab: tabDashboard,
|
||||||
year: year,
|
year: year,
|
||||||
standings: NewStandingsModel(client, year),
|
standings: NewStandingsModel(client, year),
|
||||||
calendar: NewCalendarModel(client, year),
|
calendar: NewCalendarModel(client, year),
|
||||||
raceDetail: NewRaceDetailModel(client),
|
raceDetail: NewRaceDetailModel(client),
|
||||||
driver: NewDriverModel(client),
|
driver: NewDriverModel(client),
|
||||||
|
dashboard: NewDashboardModel(client, year),
|
||||||
|
live: NewOfficialLiveModel(),
|
||||||
showSplash: true,
|
showSplash: true,
|
||||||
splashSpinner: sp,
|
splashSpinner: sp,
|
||||||
}
|
}
|
||||||
@@ -76,6 +82,8 @@ func splashTimer() tea.Cmd {
|
|||||||
|
|
||||||
func (m AppModel) Init() tea.Cmd {
|
func (m AppModel) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
|
m.dashboard.Init(),
|
||||||
|
m.live.Init(),
|
||||||
m.standings.Init(),
|
m.standings.Init(),
|
||||||
m.calendar.Init(),
|
m.calendar.Init(),
|
||||||
m.raceDetail.Init(),
|
m.raceDetail.Init(),
|
||||||
@@ -94,12 +102,14 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing
|
contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing
|
||||||
m.raceDetail.SetSize(m.width-4, contentHeight)
|
m.raceDetail.SetSize(m.width-4, contentHeight)
|
||||||
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
|
var cmd1, cmd2, cmd3, cmd4, cmd5, cmd6 tea.Cmd
|
||||||
m.standings, cmd1 = m.standings.Update(msg)
|
m.dashboard, cmd1 = m.dashboard.Update(msg)
|
||||||
m.calendar, cmd2 = m.calendar.Update(msg)
|
m.live, cmd2 = m.live.Update(msg)
|
||||||
m.raceDetail, cmd3 = m.raceDetail.Update(msg)
|
m.standings, cmd3 = m.standings.Update(msg)
|
||||||
m.driver, cmd4 = m.driver.Update(msg)
|
m.calendar, cmd4 = m.calendar.Update(msg)
|
||||||
return m, tea.Batch(cmd1, cmd2, cmd3, cmd4)
|
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:
|
case splashDoneMsg:
|
||||||
m.showSplash = false
|
m.showSplash = false
|
||||||
@@ -119,22 +129,28 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
// Tab switching
|
// Tab switching
|
||||||
switch {
|
switch {
|
||||||
case matchKey(msg, GlobalKeys.Tab1):
|
case matchKey(msg, GlobalKeys.Tab1):
|
||||||
m.activeTab = tabStandings
|
m.activeTab = tabDashboard
|
||||||
return m, nil
|
return m, nil
|
||||||
case matchKey(msg, GlobalKeys.Tab2):
|
case matchKey(msg, GlobalKeys.Tab2):
|
||||||
m.activeTab = tabCalendar
|
m.activeTab = tabStandings
|
||||||
return m, nil
|
return m, nil
|
||||||
case matchKey(msg, GlobalKeys.Tab3):
|
case matchKey(msg, GlobalKeys.Tab3):
|
||||||
m.activeTab = tabRaceDetail
|
m.activeTab = tabCalendar
|
||||||
return m, nil
|
return m, nil
|
||||||
case matchKey(msg, GlobalKeys.Tab4):
|
case matchKey(msg, GlobalKeys.Tab4):
|
||||||
|
m.activeTab = tabRaceDetail
|
||||||
|
return m, nil
|
||||||
|
case matchKey(msg, GlobalKeys.Tab5):
|
||||||
m.activeTab = tabDriver
|
m.activeTab = tabDriver
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.driver, cmd = m.driver.TriggerLoad()
|
m.driver, cmd = m.driver.TriggerLoad()
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
|
case matchKey(msg, GlobalKeys.Tab6):
|
||||||
|
m.activeTab = tabLive
|
||||||
|
return m, nil
|
||||||
case matchKey(msg, GlobalKeys.NextTab):
|
case matchKey(msg, GlobalKeys.NextTab):
|
||||||
m.activeTab = (m.activeTab + 1) % 4
|
m.activeTab = (m.activeTab + 1) % 6
|
||||||
if m.activeTab == tabDriver {
|
if m.activeTab == tabDriver {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.driver, cmd = m.driver.TriggerLoad()
|
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...)
|
return m, tea.Batch(cmds...)
|
||||||
case matchKey(msg, GlobalKeys.PrevTab):
|
case matchKey(msg, GlobalKeys.PrevTab):
|
||||||
m.activeTab = (m.activeTab - 1 + 4) % 4
|
m.activeTab = (m.activeTab - 1 + 6) % 6
|
||||||
if m.activeTab == tabDriver {
|
if m.activeTab == tabDriver {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.driver, cmd = m.driver.TriggerLoad()
|
m.driver, cmd = m.driver.TriggerLoad()
|
||||||
@@ -156,8 +172,8 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case matchKey(msg, GlobalKeys.Year):
|
case matchKey(msg, GlobalKeys.Year):
|
||||||
// Cycle years: 2025 -> 2023 -> 2024 -> 2025
|
// Cycle years: up to current year, wrap to 2023
|
||||||
if m.year == 2025 {
|
if m.year >= time.Now().Year() {
|
||||||
m.year = 2023
|
m.year = 2023
|
||||||
} else {
|
} else {
|
||||||
m.year++
|
m.year++
|
||||||
@@ -281,6 +297,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
return m, tea.Batch(cmds...)
|
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:
|
case spinner.TickMsg:
|
||||||
if m.showSplash {
|
if m.showSplash {
|
||||||
var cmd tea.Cmd
|
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
|
// Route keyboard input to active tab
|
||||||
switch m.activeTab {
|
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:
|
case tabStandings:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.standings, cmd = m.standings.Update(msg)
|
m.standings, cmd = m.standings.Update(msg)
|
||||||
@@ -338,6 +368,10 @@ func (m AppModel) View() string {
|
|||||||
// Content area
|
// Content area
|
||||||
var content string
|
var content string
|
||||||
switch m.activeTab {
|
switch m.activeTab {
|
||||||
|
case tabDashboard:
|
||||||
|
content = m.dashboard.View()
|
||||||
|
case tabLive:
|
||||||
|
content = m.live.View()
|
||||||
case tabStandings:
|
case tabStandings:
|
||||||
content = m.standings.View()
|
content = m.standings.View()
|
||||||
case tabCalendar:
|
case tabCalendar:
|
||||||
|
|||||||
@@ -317,11 +317,15 @@ func formatMeetingDates(m models.Meeting) string {
|
|||||||
if len(m.DateStart) >= 10 {
|
if len(m.DateStart) >= 10 {
|
||||||
start, _ = time.Parse("2006-01-02", m.DateStart[:10])
|
start, _ = time.Parse("2006-01-02", m.DateStart[:10])
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
start = start.Local()
|
||||||
}
|
}
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
if len(m.DateEnd) >= 10 {
|
if len(m.DateEnd) >= 10 {
|
||||||
end, _ = time.Parse("2006-01-02", m.DateEnd[:10])
|
end, _ = time.Parse("2006-01-02", m.DateEnd[:10])
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
end = end.Local()
|
||||||
}
|
}
|
||||||
|
|
||||||
if start.Month() == end.Month() {
|
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
|
pits []models.Pit
|
||||||
positions []models.Position
|
positions []models.Position
|
||||||
teamRadios []models.TeamRadio
|
teamRadios []models.TeamRadio
|
||||||
|
raceControl []models.RaceControl
|
||||||
|
|
||||||
view driverView
|
view driverView
|
||||||
loading bool
|
loading bool
|
||||||
@@ -44,7 +45,6 @@ type DriverModel struct {
|
|||||||
scroll int
|
scroll int
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
|
|
||||||
// Detail view viewport
|
|
||||||
detailView viewport.Model
|
detailView viewport.Model
|
||||||
detailViewReady bool
|
detailViewReady bool
|
||||||
|
|
||||||
@@ -884,11 +884,10 @@ func (m DriverModel) renderTeamRadio() string {
|
|||||||
|
|
||||||
var sb strings.Builder
|
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")
|
sb.WriteString(countStr + "\n")
|
||||||
|
|
||||||
for i, radio := range m.teamRadios {
|
for i, radio := range m.teamRadios {
|
||||||
// Parse timestamp
|
|
||||||
t := "--:--:--"
|
t := "--:--:--"
|
||||||
if len(radio.Date) >= 19 {
|
if len(radio.Date) >= 19 {
|
||||||
pt, err := time.Parse(time.RFC3339, radio.Date)
|
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))
|
sb.WriteString(fmt.Sprintf(" %s %s %s\n", icon, timeStr, urlStyled))
|
||||||
|
|
||||||
// Limit display to 15 entries
|
|
||||||
if i >= 14 && i < len(m.teamRadios)-1 {
|
if i >= 14 && i < len(m.teamRadios)-1 {
|
||||||
remaining := len(m.teamRadios) - i - 1
|
remaining := len(m.teamRadios) - i - 1
|
||||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... and %d more messages\n", remaining)))
|
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... and %d more messages\n", remaining)))
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ type GlobalKeyMap struct {
|
|||||||
Tab2 key.Binding
|
Tab2 key.Binding
|
||||||
Tab3 key.Binding
|
Tab3 key.Binding
|
||||||
Tab4 key.Binding
|
Tab4 key.Binding
|
||||||
|
Tab5 key.Binding
|
||||||
|
Tab6 key.Binding
|
||||||
NextTab key.Binding
|
NextTab key.Binding
|
||||||
PrevTab key.Binding
|
PrevTab key.Binding
|
||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
@@ -27,19 +29,27 @@ type GlobalKeyMap struct {
|
|||||||
var GlobalKeys = GlobalKeyMap{
|
var GlobalKeys = GlobalKeyMap{
|
||||||
Tab1: key.NewBinding(
|
Tab1: key.NewBinding(
|
||||||
key.WithKeys("1"),
|
key.WithKeys("1"),
|
||||||
key.WithHelp("1", "standings"),
|
key.WithHelp("1", "home"),
|
||||||
),
|
),
|
||||||
Tab2: key.NewBinding(
|
Tab2: key.NewBinding(
|
||||||
key.WithKeys("2"),
|
key.WithKeys("2"),
|
||||||
key.WithHelp("2", "calendar"),
|
key.WithHelp("2", "standings"),
|
||||||
),
|
),
|
||||||
Tab3: key.NewBinding(
|
Tab3: key.NewBinding(
|
||||||
key.WithKeys("3"),
|
key.WithKeys("3"),
|
||||||
key.WithHelp("3", "race detail"),
|
key.WithHelp("3", "calendar"),
|
||||||
),
|
),
|
||||||
Tab4: key.NewBinding(
|
Tab4: key.NewBinding(
|
||||||
key.WithKeys("4"),
|
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(
|
NextTab: key.NewBinding(
|
||||||
key.WithKeys("tab", "right"),
|
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
|
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.
|
// overtakesLoadedMsg carries overtake data for a session.
|
||||||
type overtakesLoadedMsg struct {
|
type overtakesLoadedMsg struct {
|
||||||
overtakes []models.Overtake
|
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 {
|
if len(sess.DateStart) >= 10 {
|
||||||
t, err := time.Parse(time.RFC3339, sess.DateStart)
|
t, err := time.Parse(time.RFC3339, sess.DateStart)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
dateStr = t.Format("Mon 2")
|
dateStr = t.Local().Format("Mon 2")
|
||||||
} else {
|
} else {
|
||||||
dateStr = sess.DateStart[:10]
|
dateStr = sess.DateStart[:10]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user