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

603 lines
16 KiB
Go
Raw Normal View History

2026-03-03 00:48:05 -05:00
package ui
import (
"fmt"
"strings"
2026-03-03 02:29:34 -05:00
"time"
2026-03-03 00:48:05 -05:00
"github.com/AmanTahiliani/box-box/internal/api"
2026-03-03 02:29:34 -05:00
"github.com/AmanTahiliani/box-box/internal/models"
2026-03-03 00:48:05 -05:00
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type tabIndex int
const (
2026-03-07 23:53:16 -05:00
tabDashboard tabIndex = 0
tabStandings tabIndex = 1
tabCalendar tabIndex = 2
tabRaceDetail tabIndex = 3
tabDriver tabIndex = 4
tabLive tabIndex = 5
2026-03-27 02:54:09 -04:00
tabTrackMap tabIndex = 6
2026-03-03 00:48:05 -05:00
)
2026-03-27 02:54:09 -04:00
const numTabs = 7
var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live", "Map"}
var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴", "🗺"}
2026-03-03 02:29:34 -05:00
// splashDoneMsg is sent after the splash screen duration has elapsed.
type splashDoneMsg struct{}
2026-03-03 00:48:05 -05:00
// AppModel is the root Bubble Tea model.
type AppModel struct {
client *api.OpenF1Client
activeTab tabIndex
2026-03-03 01:16:04 -05:00
year int
2026-03-03 00:48:05 -05:00
width int
height int
standings StandingsModel
calendar CalendarModel
raceDetail RaceDetailModel
driver DriverModel
2026-03-07 23:53:16 -05:00
dashboard DashboardModel
live OfficialLiveModel
2026-03-27 02:54:09 -04:00
trackMap TrackMapModel
2026-03-03 02:29:34 -05:00
meetings []models.Meeting
// Splash screen state
showSplash bool
splashSpinner spinner.Model
2026-03-03 00:48:05 -05:00
}
func NewAppModel(client *api.OpenF1Client) AppModel {
2026-03-07 23:53:16 -05:00
year := time.Now().Year()
2026-03-03 02:29:34 -05:00
sp := spinner.New()
sp.Spinner = spinner.Points
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
2026-03-03 00:48:05 -05:00
return AppModel{
2026-03-03 02:29:34 -05:00
client: client,
2026-03-07 23:53:16 -05:00
activeTab: tabDashboard,
2026-03-03 02:29:34 -05:00
year: year,
standings: NewStandingsModel(client, year),
calendar: NewCalendarModel(client, year),
raceDetail: NewRaceDetailModel(client),
driver: NewDriverModel(client),
2026-03-07 23:53:16 -05:00
dashboard: NewDashboardModel(client, year),
live: NewOfficialLiveModel(),
2026-03-27 02:54:09 -04:00
trackMap: NewTrackMapModel(client),
2026-03-03 02:29:34 -05:00
showSplash: true,
splashSpinner: sp,
2026-03-03 00:48:05 -05:00
}
}
2026-03-03 02:29:34 -05:00
func splashTimer() tea.Cmd {
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return splashDoneMsg{}
})
}
2026-03-03 00:48:05 -05:00
func (m AppModel) Init() tea.Cmd {
return tea.Batch(
2026-03-07 23:53:16 -05:00
m.dashboard.Init(),
m.live.Init(),
2026-03-03 00:48:05 -05:00
m.standings.Init(),
m.calendar.Init(),
m.raceDetail.Init(),
m.driver.Init(),
2026-03-27 02:54:09 -04:00
m.trackMap.Init(),
2026-03-03 02:29:34 -05:00
m.splashSpinner.Tick,
splashTimer(),
2026-03-03 00:48:05 -05:00
)
}
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
2026-03-03 02:29:34 -05:00
contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing
2026-03-03 00:48:05 -05:00
m.raceDetail.SetSize(m.width-4, contentHeight)
2026-03-08 00:32:33 -05:00
m.live.SetSize(m.width, contentHeight)
2026-03-27 02:54:09 -04:00
var cmd1, cmd2, cmd3, cmd4, cmd5, cmd6, cmd7 tea.Cmd
2026-03-07 23:53:16 -05:00
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)
2026-03-27 02:54:09 -04:00
m.trackMap, cmd7 = m.trackMap.Update(msg)
return m, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6, cmd7)
2026-03-03 02:29:34 -05:00
case splashDoneMsg:
m.showSplash = false
2026-03-03 00:48:05 -05:00
return m, nil
case tea.KeyMsg:
2026-03-03 02:29:34 -05:00
// Any key press during splash dismisses it
if m.showSplash {
m.showSplash = false
return m, nil
}
2026-03-03 00:48:05 -05:00
// Global quit
if matchKey(msg, GlobalKeys.Quit) {
return m, tea.Quit
}
// Tab switching
2026-03-03 02:29:34 -05:00
switch {
case matchKey(msg, GlobalKeys.Tab1):
2026-03-07 23:53:16 -05:00
m.activeTab = tabDashboard
2026-03-03 00:48:05 -05:00
return m, nil
2026-03-03 02:29:34 -05:00
case matchKey(msg, GlobalKeys.Tab2):
2026-03-07 23:53:16 -05:00
m.activeTab = tabStandings
2026-03-03 00:48:05 -05:00
return m, nil
2026-03-03 02:29:34 -05:00
case matchKey(msg, GlobalKeys.Tab3):
2026-03-07 23:53:16 -05:00
m.activeTab = tabCalendar
2026-03-03 00:48:05 -05:00
return m, nil
2026-03-03 02:29:34 -05:00
case matchKey(msg, GlobalKeys.Tab4):
2026-03-07 23:53:16 -05:00
m.activeTab = tabRaceDetail
return m, nil
case matchKey(msg, GlobalKeys.Tab5):
2026-03-03 00:48:05 -05:00
m.activeTab = tabDriver
var cmd tea.Cmd
m.driver, cmd = m.driver.TriggerLoad()
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
2026-03-07 23:53:16 -05:00
case matchKey(msg, GlobalKeys.Tab6):
m.activeTab = tabLive
return m, nil
2026-03-27 02:54:09 -04:00
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...)
2026-03-03 02:29:34 -05:00
case matchKey(msg, GlobalKeys.NextTab):
2026-03-27 02:54:09 -04:00
m.activeTab = (m.activeTab + 1) % numTabs
2026-03-03 02:29:34 -05:00
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):
2026-03-27 02:54:09 -04:00
m.activeTab = (m.activeTab - 1 + numTabs) % numTabs
2026-03-03 02:29:34 -05:00
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
2026-03-03 00:48:05 -05:00
if m.activeTab == tabRaceDetail {
m.activeTab = tabCalendar
return m, nil
}
2026-03-03 02:29:34 -05:00
case matchKey(msg, GlobalKeys.Year):
2026-03-07 23:53:16 -05:00
// Cycle years: up to current year, wrap to 2023
if m.year >= time.Now().Year() {
2026-03-03 01:16:04 -05:00
m.year = 2023
} else {
m.year++
}
m.calendar.year = m.year
m.calendar.loading = true
m.standings.year = m.year
m.standings.loading = true
return m, tea.Batch(
m.calendar.Init(),
m.standings.Init(),
)
2026-03-03 00:48:05 -05:00
}
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:
2026-03-03 02:29:34 -05:00
if msg.err == nil {
m.meetings = msg.meetings
}
2026-03-03 00:48:05 -05:00
var cmd tea.Cmd
m.calendar, cmd = m.calendar.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
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...)
2026-03-03 02:29:34 -05:00
case overtakesLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
2026-03-27 02:54:09 -04:00
case startingGridLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
2026-03-27 00:30:53 -04:00
case loadSecondaryDataMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
2026-03-03 00:48:05 -05:00
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...)
2026-03-03 02:29:34 -05:00
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...)
2026-03-07 23:53:16 -05:00
case wsDataMsg:
var cmd tea.Cmd
m.live, cmd = m.live.Update(msg)
cmds = append(cmds, cmd)
2026-03-27 02:54:09 -04:00
// 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)
2026-03-07 23:53:16 -05:00
return m, tea.Batch(cmds...)
2026-03-03 00:48:05 -05:00
case spinner.TickMsg:
2026-03-03 02:29:34 -05:00
if m.showSplash {
var cmd tea.Cmd
m.splashSpinner, cmd = m.splashSpinner.Update(msg)
cmds = append(cmds, cmd)
}
2026-03-27 02:54:09 -04:00
var cmd1, cmd2, cmd3, cmd4, cmd5 tea.Cmd
2026-03-03 00:48:05 -05:00
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)
2026-03-27 02:54:09 -04:00
m.trackMap, cmd5 = m.trackMap.Update(msg)
cmds = append(cmds, cmd1, cmd2, cmd3, cmd4, cmd5)
2026-03-03 00:48:05 -05:00
return m, tea.Batch(cmds...)
}
// Route keyboard input to active tab
switch m.activeTab {
2026-03-07 23:53:16 -05:00
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)
2026-03-03 00:48:05 -05:00
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)
2026-03-27 02:54:09 -04:00
case tabTrackMap:
var cmd tea.Cmd
m.trackMap, cmd = m.trackMap.Update(msg)
cmds = append(cmds, cmd)
2026-03-03 00:48:05 -05:00
}
return m, tea.Batch(cmds...)
}
func (m AppModel) View() string {
2026-03-03 02:29:34 -05:00
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))
2026-03-03 00:48:05 -05:00
// Tab bar
2026-03-03 02:29:34 -05:00
tabs := renderTabBar(m.activeTab, m.year, w)
2026-03-03 00:48:05 -05:00
// Content area
var content string
switch m.activeTab {
2026-03-07 23:53:16 -05:00
case tabDashboard:
content = m.dashboard.View()
case tabLive:
content = m.live.View()
2026-03-03 00:48:05 -05:00
case tabStandings:
content = m.standings.View()
case tabCalendar:
content = m.calendar.View()
case tabRaceDetail:
content = m.raceDetail.View()
case tabDriver:
content = m.driver.View()
2026-03-27 02:54:09 -04:00
case tabTrackMap:
content = m.trackMap.View()
2026-03-03 00:48:05 -05:00
}
2026-03-03 02:29:34 -05:00
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
2026-03-03 00:48:05 -05:00
}
2026-03-03 02:29:34 -05:00
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
2026-03-03 00:48:05 -05:00
var tabs []string
for i, name := range tabNames {
2026-03-03 02:29:34 -05:00
label := fmt.Sprintf(" %s %d %s ", tabIcons[i], i+1, name)
2026-03-03 00:48:05 -05:00
if tabIndex(i) == active {
2026-03-03 02:29:34 -05:00
tabs = append(tabs, styleActiveTab.Render(label))
2026-03-03 00:48:05 -05:00
} else {
2026-03-03 02:29:34 -05:00
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"),
}
// Next race countdown
var nextMeeting *models.Meeting
for i := range m.meetings {
start, err := time.Parse(time.RFC3339, m.meetings[i].DateStart)
if err != nil {
start, _ = time.Parse("2006-01-02", m.meetings[i].DateStart[:min(len(m.meetings[i].DateStart), 10)])
2026-03-03 00:48:05 -05:00
}
2026-03-03 02:29:34 -05:00
if start.After(now) {
nextMeeting = &m.meetings[i]
break
}
}
if nextMeeting != nil {
start, err := time.Parse(time.RFC3339, nextMeeting.DateStart)
if err == nil {
diff := start.Sub(now)
days := int(diff.Hours() / 24)
hours := int(diff.Hours()) % 24
flag := countryFlag(nextMeeting.CountryCode)
raceName := styleStatusValue.Render(nextMeeting.MeetingName)
countdown := styleCountdown.Render(fmt.Sprintf("%dd %dh", days, hours))
leftParts = append(leftParts,
styleStatusLabel.Render("│"),
styleStatusLabel.Render("NEXT"),
flag+" "+raceName,
styleStatusLabel.Render("in"),
countdown,
)
}
}
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))
2026-03-27 02:54:09 -04:00
right := cacheInfo + " " + styleMuted.Render("1-7 tabs · y year · q quit")
2026-03-03 02:29:34 -05:00
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{
"██████╗ ██████╗ ██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗",
"██╔══██╗██╔═══██╗╚██╗██╔╝ ██╔══██╗██╔═══██╗╚██╗██╔╝",
"██████╔╝██║ ██║ ╚███╔╝█████╗██████╔╝██║ ██║ ╚███╔╝ ",
"██╔══██╗██║ ██║ ██╔██╗╚════╝██╔══██╗██║ ██║ ██╔██╗ ",
"██████╔╝╚██████╔╝██╔╝ ██╗ ██████╔╝╚██████╔╝██╔╝ ██╗",
"╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
2026-03-03 00:48:05 -05:00
}
2026-03-03 02:29:34 -05:00
redStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Bold(true)
var logoBlock strings.Builder
for _, line := range logo {
logoBlock.WriteString(redStyle.Render(line) + "\n")
2026-03-03 00:48:05 -05:00
}
2026-03-03 02:29:34 -05:00
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)
2026-03-03 00:48:05 -05:00
}