Files
box-box/internal/ui/app.go

704 lines
19 KiB
Go

package ui
import (
"fmt"
"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 tabIndex int
const (
tabDashboard tabIndex = 0
tabStandings tabIndex = 1
tabCalendar tabIndex = 2
tabRaceDetail tabIndex = 3
tabDriver tabIndex = 4
tabLive tabIndex = 5
tabTrackMap tabIndex = 6
)
const numTabs = 7
var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live", "Map"}
var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴", "🗺"}
// splashDoneMsg is sent after the splash screen duration has elapsed.
type splashDoneMsg struct{}
// trackPrefetchDoneMsg is sent (and silently ignored) when the background
// track-outline pre-fetch finishes. It carries no data — its only purpose is
// to satisfy the tea.Cmd contract.
type trackPrefetchDoneMsg struct{}
// AppModel is the root Bubble Tea model.
type AppModel struct {
client *api.OpenF1Client
activeTab tabIndex
year int
width int
height int
standings StandingsModel
calendar CalendarModel
raceDetail RaceDetailModel
driver DriverModel
dashboard DashboardModel
live OfficialLiveModel
trackMap TrackMapModel
meetings []models.Meeting
nextInfo *statusBarTarget
// Splash screen state
showSplash bool
splashSpinner spinner.Model
}
type statusBarTarget struct {
meeting *models.Meeting
current *models.Session
next *models.Session
loading bool
stale bool
err error
}
func NewAppModel(client *api.OpenF1Client) AppModel {
year := time.Now().Year()
sp := spinner.New()
sp.Spinner = spinner.Points
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return AppModel{
client: client,
activeTab: tabDashboard,
year: year,
standings: NewStandingsModel(client, year),
calendar: NewCalendarModel(client, year),
raceDetail: NewRaceDetailModel(client),
driver: NewDriverModel(client),
dashboard: NewDashboardModel(client, year),
live: NewOfficialLiveModel(),
trackMap: NewTrackMapModel(client),
showSplash: true,
splashSpinner: sp,
}
}
func splashTimer() tea.Cmd {
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return splashDoneMsg{}
})
}
// prefetchTrackOutlines fetches the season calendar and then pre-populates
// the track outline cache for every circuit. This runs as a background command
// so the UI is never blocked. Errors are silently discarded — this is a
// best-effort operation.
func prefetchTrackOutlines(client *api.OpenF1Client, year int) tea.Cmd {
return func() tea.Msg {
meetings, err := client.GetMeetingsForYear(year)
if err != nil || len(meetings) == 0 {
return trackPrefetchDoneMsg{}
}
client.PrefetchTrackOutlines(meetings)
return trackPrefetchDoneMsg{}
}
}
type statusBarScheduleLoadedMsg struct {
target *statusBarTarget
}
func fetchStatusBarSchedule(client *api.OpenF1Client, meetings []models.Meeting) tea.Cmd {
meetingsCopy := append([]models.Meeting(nil), meetings...)
return func() tea.Msg {
now := time.Now()
target := &statusBarTarget{}
meeting := currentMeeting(meetingsCopy, now)
if meeting == nil {
meeting = nextUpcomingMeeting(meetingsCopy, now)
}
if meeting == nil {
return statusBarScheduleLoadedMsg{target: target}
}
target.meeting = meeting
target.loading = true
sessions, err := client.GetSessionsForMeeting(int(meeting.MeetingKey))
if err != nil {
target.loading = false
target.err = err
target.stale = client.LastResponseWasStale()
return statusBarScheduleLoadedMsg{target: target}
}
sortSessionsByStart(sessions)
target.current, target.next = currentAndNextSession(sessions, now)
target.loading = false
target.stale = client.LastResponseWasStale()
return statusBarScheduleLoadedMsg{target: target}
}
}
func (m AppModel) Init() tea.Cmd {
return tea.Batch(
m.dashboard.Init(),
m.live.Init(),
m.standings.Init(),
m.calendar.Init(),
m.raceDetail.Init(),
m.driver.Init(),
m.trackMap.Init(),
m.splashSpinner.Tick,
splashTimer(),
// Background: pre-fetch track outlines for all circuits this season
// so the track map works during live sessions when the API is locked.
prefetchTrackOutlines(m.client, m.year),
)
}
func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing
m.raceDetail.SetSize(m.width-4, contentHeight)
m.live.SetSize(m.width, contentHeight)
var cmd1, cmd2, cmd3, cmd4, cmd5, cmd6, cmd7 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)
m.trackMap, cmd7 = m.trackMap.Update(msg)
return m, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6, cmd7)
case splashDoneMsg:
m.showSplash = false
return m, nil
case trackPrefetchDoneMsg:
// Background track pre-fetch finished — nothing to display.
return m, nil
case tea.KeyMsg:
// Any key press during splash dismisses it
if m.showSplash {
m.showSplash = false
return m, nil
}
// Global quit
if matchKey(msg, GlobalKeys.Quit) {
return m, tea.Quit
}
// Tab switching
switch {
case matchKey(msg, GlobalKeys.Tab1):
m.activeTab = tabDashboard
return m, nil
case matchKey(msg, GlobalKeys.Tab2):
m.activeTab = tabStandings
return m, nil
case matchKey(msg, GlobalKeys.Tab3):
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.Tab7):
m.activeTab = tabTrackMap
if !m.trackMap.HasSession() {
var cmd tea.Cmd
m.trackMap, cmd = m.trackMap.FetchActiveSession(m.client)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
case matchKey(msg, GlobalKeys.NextTab):
m.activeTab = (m.activeTab + 1) % numTabs
if 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.PrevTab):
m.activeTab = (m.activeTab - 1 + numTabs) % numTabs
if 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.Back):
// Back from race detail -> calendar
if m.activeTab == tabRaceDetail {
m.activeTab = tabCalendar
return m, nil
}
case matchKey(msg, GlobalKeys.Year):
// Cycle years: up to current year, wrap to 2023
if m.year >= time.Now().Year() {
m.year = 2023
} else {
m.year++
}
m.calendar.year = m.year
m.calendar.loading = true
m.standings.year = m.year
m.standings.loading = true
m.standings.err = nil
m.standings.stale = false
m.standings.driverStandings = nil
m.standings.teamStandings = nil
m.standings.drivers = make(map[int]models.Driver)
m.standings.cursor = 0
m.standings.scroll = 0
return m, tea.Batch(
m.calendar.Init(),
m.standings.Init(),
)
}
case meetingSelectedMsg:
m.activeTab = tabRaceDetail
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
m.driver, _ = m.driver.Update(msg)
return m, tea.Batch(cmds...)
case sessionsLoadedMsg:
var cmd1, cmd2 tea.Cmd
m.raceDetail, cmd1 = m.raceDetail.Update(msg)
m.driver, cmd2 = m.driver.Update(msg)
cmds = append(cmds, cmd1, cmd2)
return m, tea.Batch(cmds...)
case driverChampionshipLoadedMsg:
var cmd tea.Cmd
m.standings, cmd = m.standings.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case teamChampionshipLoadedMsg:
var cmd tea.Cmd
m.standings, cmd = m.standings.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case standingsDriversLoadedMsg:
var cmd tea.Cmd
m.standings, cmd = m.standings.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case meetingsLoadedMsg:
if msg.err == nil {
m.meetings = msg.meetings
m.nextInfo = &statusBarTarget{loading: true}
cmds = append(cmds, fetchStatusBarSchedule(m.client, msg.meetings))
}
var cmd tea.Cmd
m.calendar, cmd = m.calendar.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case statusBarScheduleLoadedMsg:
m.nextInfo = msg.target
return m, nil
case sessionResultsLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case sessionDriversLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case raceControlLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case weatherLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case overtakesLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case startingGridLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case loadSecondaryDataMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverListLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverStintsLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverLapsLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverPitsLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverPositionsLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverTeamRadioLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
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)
// Forward driver info to track map for car-marker colouring
m.trackMap.InjectDriverInfo(msg.DriverInfo)
return m, tea.Batch(cmds...)
case trackOutlineLoadedMsg, trackCarsLoadedMsg, trackMapTickMsg:
var cmd tea.Cmd
m.trackMap, cmd = m.trackMap.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case spinner.TickMsg:
if m.showSplash {
var cmd tea.Cmd
m.splashSpinner, cmd = m.splashSpinner.Update(msg)
cmds = append(cmds, cmd)
}
var cmd1, cmd2, cmd3, cmd4, cmd5 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)
m.trackMap, cmd5 = m.trackMap.Update(msg)
cmds = append(cmds, cmd1, cmd2, cmd3, cmd4, cmd5)
return m, tea.Batch(cmds...)
}
// 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)
cmds = append(cmds, cmd)
case tabCalendar:
var cmd tea.Cmd
m.calendar, cmd = m.calendar.Update(msg)
cmds = append(cmds, cmd)
case tabRaceDetail:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
case tabDriver:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
case tabTrackMap:
var cmd tea.Cmd
m.trackMap, cmd = m.trackMap.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m AppModel) View() string {
if m.showSplash {
return m.renderSplash()
}
w := m.width
if w < 40 {
w = 40
}
// Red accent stripe at the very top (F1 style)
stripe := styleTabStripe.Render(strings.Repeat("▔", w))
// Tab bar
tabs := renderTabBar(m.activeTab, m.year, w)
// 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:
content = m.calendar.View()
case tabRaceDetail:
content = m.raceDetail.View()
case tabDriver:
content = m.driver.View()
case tabTrackMap:
content = m.trackMap.View()
}
statusBar := m.renderStatusBar(w)
// Calculate how much vertical space is available for content
usedLines := 1 + 1 + 1 + 1 // stripe + tab bar + gap + status bar
contentHeight := m.height - usedLines
if contentHeight < 5 {
contentHeight = 5
}
// Trim content to fit available height
contentLines := strings.Split(content, "\n")
if len(contentLines) > contentHeight {
contentLines = contentLines[:contentHeight]
}
content = strings.Join(contentLines, "\n")
return stripe + "\n" + tabs + "\n" + content + "\n" + statusBar
}
func renderTabBar(active tabIndex, year int, width int) string {
// Build the logo
logo := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorF1Red)).
Background(lipgloss.Color(colorSurface0)).
Padding(0, 1).
Render("F1")
// Build tabs
var tabs []string
for i, name := range tabNames {
label := fmt.Sprintf(" %s %d %s ", tabIcons[i], i+1, name)
if tabIndex(i) == active {
tabs = append(tabs, styleActiveTab.Render(label))
} else {
tabs = append(tabs, styleInactiveTab.Render(label))
}
}
// Year badge
yearBadge := styleYearBadge.Render(fmt.Sprintf(" %d ", year))
// Assemble: logo + tabs + spacer + year
left := logo + strings.Join(tabs, "")
leftWidth := lipgloss.Width(left)
yearWidth := lipgloss.Width(yearBadge)
spacerWidth := width - leftWidth - yearWidth
if spacerWidth < 0 {
spacerWidth = 0
}
spacer := lipgloss.NewStyle().
Background(lipgloss.Color(colorSurface0)).
Render(strings.Repeat(" ", spacerWidth))
bar := left + spacer + yearBadge
return styleTabBar.Render(bar)
}
func (m AppModel) renderStatusBar(width int) string {
now := time.Now()
// Left side: brand
leftParts := []string{
lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Bold(true).
Render("BOX-BOX"),
}
if m.nextInfo != nil && m.nextInfo.meeting != nil {
flag := countryFlag(m.nextInfo.meeting.CountryCode)
raceName := styleStatusValue.Render(m.nextInfo.meeting.MeetingName)
leftParts = append(leftParts, styleStatusLabel.Render("│"))
switch {
case m.nextInfo.current != nil:
leftParts = append(leftParts,
styleStatusLabel.Render("LIVE"),
flag+" "+raceName,
styleStatusLabel.Render("·"),
styleStatusValue.Render(m.nextInfo.current.SessionName),
)
case m.nextInfo.next != nil:
if start, err := sessionStartTime(*m.nextInfo.next); err == nil {
diff := start.Sub(now)
days := int(diff.Hours() / 24)
hours := int(diff.Hours()) % 24
leftParts = append(leftParts,
styleStatusLabel.Render("NEXT"),
flag+" "+raceName,
styleStatusLabel.Render("·"),
styleStatusValue.Render(m.nextInfo.next.SessionName),
styleStatusLabel.Render("in"),
styleCountdown.Render(fmt.Sprintf("%dd %dh", days, hours)),
)
}
case meetingHasStarted(*m.nextInfo.meeting, now):
leftParts = append(leftParts,
styleStatusLabel.Render("DONE"),
flag+" "+raceName,
)
default:
if start, err := meetingStartTime(*m.nextInfo.meeting); err == nil {
diff := start.Sub(now)
days := int(diff.Hours() / 24)
hours := int(diff.Hours()) % 24
leftParts = append(leftParts,
styleStatusLabel.Render("NEXT"),
flag+" "+raceName,
styleStatusLabel.Render("in"),
styleCountdown.Render(fmt.Sprintf("%dd %dh", days, hours)),
)
}
}
}
left := strings.Join(leftParts, " ")
// Right side: cache stats + navigation hints
cacheStats := m.client.CacheStats()
cacheInfo := styleMuted.Render(fmt.Sprintf("cache %d/%d", cacheStats.Hits, cacheStats.Hits+cacheStats.Misses))
right := cacheInfo + " " + styleMuted.Render("1-7 tabs · y year · q quit")
leftW := lipgloss.Width(left)
rightW := lipgloss.Width(right)
spacerW := width - leftW - rightW - 2
if spacerW < 0 {
spacerW = 0
}
bar := " " + left + strings.Repeat(" ", spacerW) + right + " "
return styleStatusBar.Width(width).Render(bar)
}
func (m AppModel) renderSplash() string {
w := m.width
h := m.height
if w == 0 {
w = 80
}
if h == 0 {
h = 24
}
// F1-themed ASCII logo
logo := []string{
"██████╗ ██████╗ ██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗",
"██╔══██╗██╔═══██╗╚██╗██╔╝ ██╔══██╗██╔═══██╗╚██╗██╔╝",
"██████╔╝██║ ██║ ╚███╔╝█████╗██████╔╝██║ ██║ ╚███╔╝ ",
"██╔══██╗██║ ██║ ██╔██╗╚════╝██╔══██╗██║ ██║ ██╔██╗ ",
"██████╔╝╚██████╔╝██╔╝ ██╗ ██████╔╝╚██████╔╝██╔╝ ██╗",
"╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
}
redStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Bold(true)
var logoBlock strings.Builder
for _, line := range logo {
logoBlock.WriteString(redStyle.Render(line) + "\n")
}
subtitle := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Render("Formula 1 Terminal Dashboard")
loadingLine := fmt.Sprintf("%s %s",
m.splashSpinner.View(),
styleMuted.Render("Loading data..."))
hint := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorSurface2)).
Render("press any key to skip")
content := lipgloss.JoinVertical(lipgloss.Center,
logoBlock.String(),
"",
subtitle,
"",
loadingLine,
"",
hint,
)
// Center the splash on screen
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content)
}