UI Refresh

This commit is contained in:
2026-03-03 02:29:34 -05:00
parent cb77df0eb6
commit 95a4f755cb
13 changed files with 2541 additions and 539 deletions

View File

@@ -3,8 +3,10 @@ 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"
@@ -19,7 +21,11 @@ const (
tabDriver tabIndex = 3
)
var tabNames = []string{"1 Standings", "2 Calendar", "3 Race", "4 Drivers"}
var tabNames = []string{"Standings", "Calendar", "Race", "Drivers"}
var tabIcons = []string{"🏆", "📅", "🏁", "👤"}
// splashDoneMsg is sent after the splash screen duration has elapsed.
type splashDoneMsg struct{}
// AppModel is the root Bubble Tea model.
type AppModel struct {
@@ -34,28 +40,48 @@ type AppModel struct {
calendar CalendarModel
raceDetail RaceDetailModel
driver DriverModel
meetings []models.Meeting
// Splash screen state
showSplash bool
splashSpinner spinner.Model
}
// NewAppModel creates the root model and wires sub-models.
func NewAppModel(client *api.OpenF1Client) AppModel {
year := 2025
sp := spinner.New()
sp.Spinner = spinner.Points
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return AppModel{
client: client,
activeTab: tabStandings,
year: year,
standings: NewStandingsModel(client, year),
calendar: NewCalendarModel(client, year),
raceDetail: NewRaceDetailModel(client),
driver: NewDriverModel(client),
client: client,
activeTab: tabStandings,
year: year,
standings: NewStandingsModel(client, year),
calendar: NewCalendarModel(client, year),
raceDetail: NewRaceDetailModel(client),
driver: NewDriverModel(client),
showSplash: true,
splashSpinner: sp,
}
}
func splashTimer() tea.Cmd {
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return splashDoneMsg{}
})
}
func (m AppModel) Init() tea.Cmd {
return tea.Batch(
m.standings.Init(),
m.calendar.Init(),
m.raceDetail.Init(),
m.driver.Init(),
m.splashSpinner.Tick,
splashTimer(),
)
}
@@ -66,46 +92,76 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
contentHeight := m.height - 3 // tab bar + help
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)
case splashDoneMsg:
m.showSplash = false
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 msg.String() {
case "1":
switch {
case matchKey(msg, GlobalKeys.Tab1):
m.activeTab = tabStandings
return m, nil
case "2":
case matchKey(msg, GlobalKeys.Tab2):
m.activeTab = tabCalendar
return m, nil
case "3":
case matchKey(msg, GlobalKeys.Tab3):
m.activeTab = tabRaceDetail
return m, nil
case "4":
case matchKey(msg, GlobalKeys.Tab4):
m.activeTab = tabDriver
var cmd tea.Cmd
m.driver, cmd = m.driver.TriggerLoad()
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case "b":
// Back from race detail → calendar
case matchKey(msg, GlobalKeys.NextTab):
m.activeTab = (m.activeTab + 1) % 4
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 + 4) % 4
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 "y":
case matchKey(msg, GlobalKeys.Year):
// Cycle years: 2025 -> 2023 -> 2024 -> 2025
if m.year == 2025 {
m.year = 2023
} else {
m.year++
}
// Update sub-models and re-trigger fetches
m.calendar.year = m.year
m.calendar.loading = true
m.standings.year = m.year
@@ -118,24 +174,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case meetingSelectedMsg:
// Switch to race detail tab and forward the message
m.activeTab = tabRaceDetail
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
// Also forward to driver model for session key tracking
m.driver, _ = m.driver.Update(msg)
return m, tea.Batch(cmds...)
case sessionsLoadedMsg:
// Forward to raceDetail and driver
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...)
// Route all loaded messages to the appropriate sub-models
case driverChampionshipLoadedMsg:
var cmd tea.Cmd
m.standings, cmd = m.standings.Update(msg)
@@ -155,6 +207,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
case meetingsLoadedMsg:
if msg.err == nil {
m.meetings = msg.meetings
}
var cmd tea.Cmd
m.calendar, cmd = m.calendar.Update(msg)
cmds = append(cmds, cmd)
@@ -184,6 +239,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 driverListLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
@@ -208,8 +269,24 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 spinner.TickMsg:
// Forward spinner ticks to all sub-models
if m.showSplash {
var cmd tea.Cmd
m.splashSpinner, cmd = m.splashSpinner.Update(msg)
cmds = append(cmds, cmd)
}
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
m.standings, cmd1 = m.standings.Update(msg)
m.calendar, cmd2 = m.calendar.Update(msg)
@@ -243,8 +320,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
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.width)
tabs := renderTabBar(m.activeTab, m.year, w)
// Content area
var content string
@@ -259,24 +348,178 @@ func (m AppModel) View() string {
content = m.driver.View()
}
return tabs + "\n" + content
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, width int) string {
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(name))
tabs = append(tabs, styleActiveTab.Render(label))
} else {
tabs = append(tabs, styleInactiveTab.Render(name))
tabs = append(tabs, styleInactiveTab.Render(label))
}
}
bar := strings.Join(tabs, "")
// Pad remaining width
barWidth := lipgloss.Width(bar)
if barWidth < width {
bar += strings.Repeat(" ", width-barWidth)
// 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
}
return styleTabBar.Render(fmt.Sprintf("%s", bar))
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)])
}
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))
right := cacheInfo + " " + styleMuted.Render("1-4 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)
}