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

2
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/AmanTahiliani/box-box
go 1.25.6 go 1.25.6
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect
@@ -23,6 +24,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.3.8 // indirect

4
go.sum
View File

@@ -1,3 +1,5 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
@@ -38,6 +40,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -5,12 +5,33 @@ import (
"encoding/hex" "encoding/hex"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync/atomic"
"time"
) )
type FileCache struct { // CacheStats tracks cache hit/miss statistics.
dir string type CacheStats struct {
Hits int64
Misses int64
} }
// FileCache implements a file-backed HTTP response cache with TTL expiry.
type FileCache struct {
dir string
stats CacheStats
}
// Default TTL values.
const (
// CacheTTLShort is for current-season, frequently changing data (meetings, sessions, results).
CacheTTLShort = 15 * time.Minute
// CacheTTLMedium is for semi-stable data (championship standings, driver lists).
CacheTTLMedium = 1 * time.Hour
// CacheTTLLong is for historical data that rarely changes (past season data).
CacheTTLLong = 24 * time.Hour
)
func NewFileCache() *FileCache { func NewFileCache() *FileCache {
var cacheDir string var cacheDir string
userCacheDir, err := os.UserCacheDir() userCacheDir, err := os.UserCacheDir()
@@ -35,13 +56,56 @@ func (c *FileCache) getCachePath(key string) string {
return filepath.Join(c.dir, filename) return filepath.Join(c.dir, filename)
} }
// Get retrieves data from the cache. Returns nil, false if not found. // ttlForURL determines the appropriate TTL based on the URL pattern.
func ttlForURL(url string) time.Duration {
// Historical data (specific year queries for past years)
if strings.Contains(url, "year=2023") || strings.Contains(url, "year=2024") {
return CacheTTLLong
}
// Frequently changing endpoints
if strings.Contains(url, "/position") ||
strings.Contains(url, "/intervals") ||
strings.Contains(url, "/car_data") ||
strings.Contains(url, "/location") {
return CacheTTLShort
}
// Semi-stable data
if strings.Contains(url, "/championship") ||
strings.Contains(url, "/drivers") {
return CacheTTLMedium
}
// Default: medium TTL for everything else
return CacheTTLMedium
}
// Get retrieves data from the cache. Returns nil, false if not found or expired.
func (c *FileCache) Get(key string) ([]byte, bool) { func (c *FileCache) Get(key string) ([]byte, bool) {
path := c.getCachePath(key) path := c.getCachePath(key)
data, err := os.ReadFile(path) info, err := os.Stat(path)
if err != nil { if err != nil {
atomic.AddInt64(&c.stats.Misses, 1)
return nil, false return nil, false
} }
// Check TTL based on file modification time
ttl := ttlForURL(key)
if time.Since(info.ModTime()) > ttl {
// Expired — remove the stale file
_ = os.Remove(path)
atomic.AddInt64(&c.stats.Misses, 1)
return nil, false
}
data, err := os.ReadFile(path)
if err != nil {
atomic.AddInt64(&c.stats.Misses, 1)
return nil, false
}
atomic.AddInt64(&c.stats.Hits, 1)
return data, true return data, true
} }
@@ -50,3 +114,45 @@ func (c *FileCache) Set(key string, data []byte) error {
path := c.getCachePath(key) path := c.getCachePath(key)
return os.WriteFile(path, data, 0644) return os.WriteFile(path, data, 0644)
} }
// Stats returns current cache hit/miss stats.
func (c *FileCache) Stats() CacheStats {
return CacheStats{
Hits: atomic.LoadInt64(&c.stats.Hits),
Misses: atomic.LoadInt64(&c.stats.Misses),
}
}
// Clear removes all cached files.
func (c *FileCache) Clear() error {
entries, err := os.ReadDir(c.dir)
if err != nil {
return err
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".json") {
_ = os.Remove(filepath.Join(c.dir, entry.Name()))
}
}
return nil
}
// Size returns the number of cached files and total size in bytes.
func (c *FileCache) Size() (int, int64) {
entries, err := os.ReadDir(c.dir)
if err != nil {
return 0, 0
}
count := 0
var totalSize int64
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".json") {
count++
info, err := entry.Info()
if err == nil {
totalSize += info.Size()
}
}
}
return count, totalSize
}

View File

@@ -18,3 +18,13 @@ func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
cache: NewFileCache(), cache: NewFileCache(),
} }
} }
// CacheStats returns the cache hit/miss statistics.
func (c *OpenF1Client) CacheStats() CacheStats {
return c.cache.Stats()
}
// CacheSize returns the number of cached entries and total size in bytes.
func (c *OpenF1Client) CacheSize() (int, int64) {
return c.cache.Size()
}

View File

@@ -3,8 +3,10 @@ package ui
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api" "github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -19,7 +21,11 @@ const (
tabDriver tabIndex = 3 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. // AppModel is the root Bubble Tea model.
type AppModel struct { type AppModel struct {
@@ -34,28 +40,48 @@ type AppModel struct {
calendar CalendarModel calendar CalendarModel
raceDetail RaceDetailModel raceDetail RaceDetailModel
driver DriverModel 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 { func NewAppModel(client *api.OpenF1Client) AppModel {
year := 2025 year := 2025
sp := spinner.New()
sp.Spinner = spinner.Points
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return AppModel{ return AppModel{
client: client, client: client,
activeTab: tabStandings, activeTab: tabStandings,
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),
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 { func (m AppModel) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
m.standings.Init(), m.standings.Init(),
m.calendar.Init(), m.calendar.Init(),
m.raceDetail.Init(), m.raceDetail.Init(),
m.driver.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: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height 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) 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 return m, nil
case tea.KeyMsg: case tea.KeyMsg:
// Any key press during splash dismisses it
if m.showSplash {
m.showSplash = false
return m, nil
}
// Global quit // Global quit
if matchKey(msg, GlobalKeys.Quit) { if matchKey(msg, GlobalKeys.Quit) {
return m, tea.Quit return m, tea.Quit
} }
// Tab switching // Tab switching
switch msg.String() { switch {
case "1": case matchKey(msg, GlobalKeys.Tab1):
m.activeTab = tabStandings m.activeTab = tabStandings
return m, nil return m, nil
case "2": case matchKey(msg, GlobalKeys.Tab2):
m.activeTab = tabCalendar m.activeTab = tabCalendar
return m, nil return m, nil
case "3": case matchKey(msg, GlobalKeys.Tab3):
m.activeTab = tabRaceDetail m.activeTab = tabRaceDetail
return m, nil return m, nil
case "4": case matchKey(msg, GlobalKeys.Tab4):
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 "b": case matchKey(msg, GlobalKeys.NextTab):
// Back from race detail → calendar 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 { if m.activeTab == tabRaceDetail {
m.activeTab = tabCalendar m.activeTab = tabCalendar
return m, nil return m, nil
} }
case "y": case matchKey(msg, GlobalKeys.Year):
// Cycle years: 2025 -> 2023 -> 2024 -> 2025 // Cycle years: 2025 -> 2023 -> 2024 -> 2025
if m.year == 2025 { if m.year == 2025 {
m.year = 2023 m.year = 2023
} else { } else {
m.year++ m.year++
} }
// Update sub-models and re-trigger fetches
m.calendar.year = m.year m.calendar.year = m.year
m.calendar.loading = true m.calendar.loading = true
m.standings.year = m.year m.standings.year = m.year
@@ -118,24 +174,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case meetingSelectedMsg: case meetingSelectedMsg:
// Switch to race detail tab and forward the message
m.activeTab = tabRaceDetail m.activeTab = tabRaceDetail
var cmd tea.Cmd var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg) m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
// Also forward to driver model for session key tracking
m.driver, _ = m.driver.Update(msg) m.driver, _ = m.driver.Update(msg)
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
case sessionsLoadedMsg: case sessionsLoadedMsg:
// Forward to raceDetail and driver
var cmd1, cmd2 tea.Cmd var cmd1, cmd2 tea.Cmd
m.raceDetail, cmd1 = m.raceDetail.Update(msg) m.raceDetail, cmd1 = m.raceDetail.Update(msg)
m.driver, cmd2 = m.driver.Update(msg) m.driver, cmd2 = m.driver.Update(msg)
cmds = append(cmds, cmd1, cmd2) cmds = append(cmds, cmd1, cmd2)
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
// Route all loaded messages to the appropriate sub-models
case driverChampionshipLoadedMsg: case driverChampionshipLoadedMsg:
var cmd tea.Cmd var cmd tea.Cmd
m.standings, cmd = m.standings.Update(msg) 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...) return m, tea.Batch(cmds...)
case meetingsLoadedMsg: case meetingsLoadedMsg:
if msg.err == nil {
m.meetings = msg.meetings
}
var cmd tea.Cmd var cmd tea.Cmd
m.calendar, cmd = m.calendar.Update(msg) m.calendar, cmd = m.calendar.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
@@ -184,6 +239,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 overtakesLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverListLoadedMsg: case driverListLoadedMsg:
var cmd tea.Cmd var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg) 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) cmds = append(cmds, cmd)
return m, tea.Batch(cmds...) 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: 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 var cmd1, cmd2, cmd3, cmd4 tea.Cmd
m.standings, cmd1 = m.standings.Update(msg) m.standings, cmd1 = m.standings.Update(msg)
m.calendar, cmd2 = m.calendar.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 { 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 // Tab bar
tabs := renderTabBar(m.activeTab, m.width) tabs := renderTabBar(m.activeTab, m.year, w)
// Content area // Content area
var content string var content string
@@ -259,24 +348,178 @@ func (m AppModel) View() string {
content = m.driver.View() 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 var tabs []string
for i, name := range tabNames { for i, name := range tabNames {
label := fmt.Sprintf(" %s %d %s ", tabIcons[i], i+1, name)
if tabIndex(i) == active { if tabIndex(i) == active {
tabs = append(tabs, styleActiveTab.Render(name)) tabs = append(tabs, styleActiveTab.Render(label))
} else { } else {
tabs = append(tabs, styleInactiveTab.Render(name)) tabs = append(tabs, styleInactiveTab.Render(label))
} }
} }
bar := strings.Join(tabs, "") // Year badge
// Pad remaining width yearBadge := styleYearBadge.Render(fmt.Sprintf(" %d ", year))
barWidth := lipgloss.Width(bar)
if barWidth < width { // Assemble: logo + tabs + spacer + year
bar += strings.Repeat(" ", width-barWidth) 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)
} }

View File

@@ -18,16 +18,19 @@ type CalendarModel struct {
loading bool loading bool
err error err error
spinner spinner.Model spinner spinner.Model
cursor int
year int year int
width int width int
height int height int
cursor int
scroll int
} }
func NewCalendarModel(client *api.OpenF1Client, year int) CalendarModel { func NewCalendarModel(client *api.OpenF1Client, year int) CalendarModel {
s := spinner.New() s := spinner.New()
s.Spinner = spinner.MiniDot s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return CalendarModel{ return CalendarModel{
client: client, client: client,
loading: true, loading: true,
@@ -51,7 +54,13 @@ func (m CalendarModel) Init() tea.Cmd {
} }
func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) { func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case spinner.TickMsg: case spinner.TickMsg:
if m.loading { if m.loading {
var cmd tea.Cmd var cmd tea.Cmd
@@ -69,26 +78,75 @@ func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) {
m.loading = false m.loading = false
// Auto-scroll to next upcoming race // Auto-scroll to next upcoming race
m.cursor = m.findNextRaceIndex() m.cursor = m.findNextRaceIndex()
m.ensureCursorVisible()
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
case matchKey(msg, GlobalKeys.Retry):
if m.err != nil {
m.err = nil
m.loading = true
return m, tea.Batch(m.spinner.Tick, fetchMeetings(m.client, m.year))
}
case matchKey(msg, GlobalKeys.Up): case matchKey(msg, GlobalKeys.Up):
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
m.ensureCursorVisible()
} }
case matchKey(msg, GlobalKeys.Down): case matchKey(msg, GlobalKeys.Down):
if m.cursor < len(m.meetings)-1 { if m.cursor < len(m.meetings)-1 {
m.cursor++ m.cursor++
m.ensureCursorVisible()
} }
case matchKey(msg, GlobalKeys.GoTop):
m.cursor = 0
m.scroll = 0
case matchKey(msg, GlobalKeys.GoBottom):
if len(m.meetings) > 0 {
m.cursor = len(m.meetings) - 1
m.ensureCursorVisible()
}
case matchKey(msg, GlobalKeys.HalfUp):
half := m.visibleRows() / 2
m.cursor -= half
if m.cursor < 0 {
m.cursor = 0
}
m.ensureCursorVisible()
case matchKey(msg, GlobalKeys.HalfDown):
half := m.visibleRows() / 2
m.cursor += half
if m.cursor >= len(m.meetings) {
m.cursor = len(m.meetings) - 1
}
m.ensureCursorVisible()
case matchKey(msg, GlobalKeys.Enter): case matchKey(msg, GlobalKeys.Enter):
if len(m.meetings) > 0 && m.cursor < len(m.meetings) { if len(m.meetings) > 0 && m.cursor >= 0 && m.cursor < len(m.meetings) {
return m, func() tea.Msg { return m, func() tea.Msg {
return meetingSelectedMsg{meeting: m.meetings[m.cursor]} return meetingSelectedMsg{meeting: m.meetings[m.cursor]}
} }
} }
} }
} }
return m, nil return m, tea.Batch(cmds...)
}
func (m CalendarModel) visibleRows() int {
rows := m.height - 10
if rows < 5 {
rows = 5
}
return rows
}
func (m *CalendarModel) ensureCursorVisible() {
visible := m.visibleRows()
if m.cursor < m.scroll {
m.scroll = m.cursor
}
if m.cursor >= m.scroll+visible {
m.scroll = m.cursor - visible + 1
}
} }
func (m CalendarModel) findNextRaceIndex() int { func (m CalendarModel) findNextRaceIndex() int {
@@ -110,81 +168,145 @@ func (m CalendarModel) findNextRaceIndex() int {
func (m CalendarModel) View() string { func (m CalendarModel) View() string {
if m.loading { if m.loading {
return fmt.Sprintf("\n %s Loading %d calendar", m.spinner.View(), m.year) return fmt.Sprintf("\n %s Loading %d calendar...", m.spinner.View(), m.year)
} }
if m.err != nil { if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err)) return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) +
helpBar("r retry", "q quit")
} }
if len(m.meetings) == 0 { if len(m.meetings) == 0 {
return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.", m.year)) return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.\n", m.year))
}
const (
wRound = 3
wName = 28
wCircuit = 20
wCountry = 16
wDates = 20
wStatus = 3
)
header := styleBold.Render(
padRight("Rd", wRound) + " " +
padRight("Grand Prix", wName) + " " +
padRight("Circuit", wCircuit) + " " +
padRight("Country", wCountry) + " " +
padRight("Dates", wDates) + " " +
padRight("", wStatus),
)
var rows []string
rows = append(rows, header)
now := time.Now()
nextIdx := m.findNextRaceIndex()
for i, meeting := range m.meetings {
isNext := (i == nextIdx)
status := meetingStatus(meeting, now, isNext)
dates := formatMeetingDates(meeting)
flag := countryFlag(meeting.CountryCode)
country := flag + " " + truncate(meeting.CountryName, wCountry-3)
row := fmt.Sprintf("%s %s %s %s %s %s",
padLeft(fmt.Sprintf("%d", i+1), wRound),
padRight(truncate(meeting.MeetingName, wName), wName),
padRight(truncate(meeting.CircuitShortName, wCircuit), wCircuit),
padRight(country, wCountry),
padRight(dates, wDates),
status,
)
if i == m.cursor {
row = styleSelected.Render(row)
} else if isNext {
row = styleNext.Render(row)
} else {
// Mute past races
end, err := time.Parse(time.RFC3339, meeting.DateEnd)
if err != nil {
end, _ = time.Parse("2006-01-02", meeting.DateEnd[:min(len(meeting.DateEnd), 10)])
end = end.Add(24 * time.Hour)
}
if end.Before(now) && i != m.cursor {
row = stylePast.Render(row)
}
}
rows = append(rows, row)
} }
var sb strings.Builder var sb strings.Builder
sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n")
sb.WriteString(strings.Join(rows, "\n")) // Title
sb.WriteString("\n\n") title := lipgloss.NewStyle().
sb.WriteString(helpBar("y season", "j/k navigate", "enter select race", "q quit")) Bold(true).
Foreground(lipgloss.Color(colorF1Red)).
Render(fmt.Sprintf(" FORMULA 1 %d RACE CALENDAR", m.year))
sb.WriteString(title + "\n\n")
// Custom rendered list (no bubbles/table - gives us more control)
now := time.Now()
nextIdx := m.findNextRaceIndex()
visible := m.visibleRows()
endIdx := m.scroll + visible
if endIdx > len(m.meetings) {
endIdx = len(m.meetings)
}
w := m.width
if w < 40 {
w = 40
}
compact := w < 90
wide := w >= 120
// Responsive column widths
gpWidth := 30
circuitWidth := 22
countryWidth := 18
if compact {
gpWidth = min(24, w-30)
circuitWidth = 0 // hide circuit in compact mode
countryWidth = 14
} else if wide {
gpWidth = 34
circuitWidth = 26
}
// Header
var header string
if compact {
header = fmt.Sprintf(" %s %s %s %s %s",
padRight("RD", 3),
padRight("S", 1),
padRight("GRAND PRIX", gpWidth),
padRight("COUNTRY", countryWidth),
padRight("DATES", 14),
)
} else {
header = fmt.Sprintf(" %s %s %s %s %s %s",
padRight("RD", 3),
padRight("S", 1),
padRight("GRAND PRIX", gpWidth),
padRight("CIRCUIT", circuitWidth),
padRight("COUNTRY", countryWidth),
padRight("DATES", 14),
)
}
sb.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Bold(true).
Render(header) + "\n")
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
for i := m.scroll; i < endIdx; i++ {
meeting := m.meetings[i]
isNext := (i == nextIdx)
status := meetingStatus(meeting, now, isNext)
dates := formatMeetingDates(meeting)
flag := countryFlag(meeting.CountryCode)
// Round number with special styling
roundNum := fmt.Sprintf("R%d", i+1)
if i+1 < 10 {
roundNum = fmt.Sprintf("R%d ", i+1)
}
// Style round number based on status
var roundStyle lipgloss.Style
if isNext {
roundStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Bold(true)
} else {
roundStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
}
// flag is 2 regional-indicator runes but renders as 2 terminal columns (double-width).
// Reserve 3 columns for "🇬🇧 " (2 cols for emoji + 1 for space) so country name width
// is countryWidth-3 runes, keeping the whole field at countryWidth visible columns.
countryField := padRight(flag+" "+truncate(meeting.CountryName, countryWidth-3), countryWidth)
var row string
if compact {
row = fmt.Sprintf(" %s %s %s %s %s",
roundStyle.Render(padRight(roundNum, 3)),
padRightVisible(status, 1), // status icon is 1 visible column; no extra padding needed
padRight(truncate(meeting.MeetingName, gpWidth), gpWidth),
countryField,
styleMuted.Render(dates),
)
} else {
row = fmt.Sprintf(" %s %s %s %s %s %s",
roundStyle.Render(padRight(roundNum, 3)),
padRightVisible(status, 1), // status icon is 1 visible column; no extra padding needed
padRight(truncate(meeting.MeetingName, gpWidth), gpWidth),
padRight(truncate(meeting.CircuitShortName, circuitWidth), circuitWidth),
countryField,
styleMuted.Render(dates),
)
}
if i == m.cursor {
// Highlight the entire row
row = styleSelected.Render(row)
} else if isNext {
// Subtle highlight for next race
row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true).Render(row)
}
sb.WriteString(row + "\n")
}
// Scroll indicator
if len(m.meetings) > visible {
sb.WriteString(styleMuted.Render(fmt.Sprintf("\n Showing %d-%d of %d races", m.scroll+1, endIdx, len(m.meetings))) + "\n")
}
sb.WriteString("\n")
sb.WriteString(helpBar("y season", "j/k navigate", "g/G top/bottom", "^d/^u page", "enter select", "q quit"))
return sb.String() return sb.String()
} }
@@ -203,7 +325,7 @@ func formatMeetingDates(m models.Meeting) string {
} }
if start.Month() == end.Month() { if start.Month() == end.Month() {
return fmt.Sprintf("%s %d%d", start.Format("Jan"), start.Day(), end.Day()) return fmt.Sprintf("%s %d-%d", start.Format("Jan"), start.Day(), end.Day())
} }
return fmt.Sprintf("%s %d %s %d", start.Format("Jan"), start.Day(), end.Format("Jan"), end.Day()) return fmt.Sprintf("%s %d - %s %d", start.Format("Jan"), start.Day(), end.Format("Jan"), end.Day())
} }

View File

@@ -3,12 +3,16 @@ package ui
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api" "github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
"github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/sahilm/fuzzy"
) )
type driverView int type driverView int
@@ -22,11 +26,14 @@ type DriverModel struct {
client *api.OpenF1Client client *api.OpenF1Client
sessionKey int sessionKey int
drivers []models.Driver drivers []models.Driver
selectedDriver *models.Driver filteredDrivers []models.Driver
stints []models.Stint selectedDriver *models.Driver
laps []models.Lap stints []models.Stint
pits []models.Pit laps []models.Lap
pits []models.Pit
positions []models.Position
teamRadios []models.TeamRadio
view driverView view driverView
loading bool loading bool
@@ -34,28 +41,53 @@ type DriverModel struct {
spinner spinner.Model spinner spinner.Model
cursor int cursor int
scroll int
input textinput.Model
// Detail view viewport
detailView viewport.Model
detailViewReady bool
width int width int
height int height int
} }
type driverSource []models.Driver
func (d driverSource) String(i int) string {
return d[i].FullName + " " + d[i].NameAcronym + " " + d[i].TeamName
}
func (d driverSource) Len() int {
return len(d)
}
func NewDriverModel(client *api.OpenF1Client) DriverModel { func NewDriverModel(client *api.OpenF1Client) DriverModel {
s := spinner.New() s := spinner.New()
s.Spinner = spinner.MiniDot s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
ti := textinput.New()
ti.Placeholder = "Search drivers..."
ti.Focus()
ti.CharLimit = 50
ti.Width = 30
ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
ti.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
return DriverModel{ return DriverModel{
client: client, client: client,
loading: false, // lazy-loaded on first focus loading: false,
spinner: s, spinner: s,
input: ti,
view: driverViewList, view: driverViewList,
} }
} }
func fetchDriverList(client *api.OpenF1Client) tea.Cmd { func fetchDriverList(client *api.OpenF1Client) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
// Use latest session key drivers, err := client.GetDriversForSession(9999)
drivers, err := client.GetDriversForSession(9999) // will use "latest" via a workaround
if err != nil || len(drivers) == 0 { if err != nil || len(drivers) == 0 {
// Fallback: get latest championship drivers' session key
champ, champErr := client.GetLatestDriverChampionship() champ, champErr := client.GetLatestDriverChampionship()
if champErr != nil || len(champ) == 0 { if champErr != nil || len(champ) == 0 {
return driverListLoadedMsg{err: err} return driverListLoadedMsg{err: err}
@@ -99,6 +131,14 @@ func fetchDriverDetail(client *api.OpenF1Client, sessionKey, driverNumber int) t
} }
return driverPitsLoadedMsg{pits: driverPits, err: err} return driverPitsLoadedMsg{pits: driverPits, err: err}
}, },
func() tea.Msg {
positions, err := client.GetPositions(sessionKey, driverNumber)
return driverPositionsLoadedMsg{positions: positions, err: err}
},
func() tea.Msg {
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
return driverTeamRadioLoadedMsg{radios: radios, err: err}
},
) )
} }
@@ -107,7 +147,13 @@ func (m DriverModel) Init() tea.Cmd {
} }
func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) { func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case spinner.TickMsg: case spinner.TickMsg:
if m.loading { if m.loading {
var cmd tea.Cmd var cmd tea.Cmd
@@ -122,32 +168,44 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
return m, nil return m, nil
} }
m.drivers = msg.drivers m.drivers = msg.drivers
m.cursor = 0 m.filterDrivers()
case driverStintsLoadedMsg: case driverStintsLoadedMsg:
if msg.err == nil { if msg.err == nil {
m.stints = msg.stints m.stints = msg.stints
} }
m.loading = false m.loading = false
m.updateDetailViewport()
case driverLapsLoadedMsg: case driverLapsLoadedMsg:
if msg.err == nil { if msg.err == nil {
m.laps = msg.laps m.laps = msg.laps
} }
m.updateDetailViewport()
case driverPitsLoadedMsg: case driverPitsLoadedMsg:
if msg.err == nil { if msg.err == nil {
m.pits = msg.pits m.pits = msg.pits
} }
m.updateDetailViewport()
case driverPositionsLoadedMsg:
if msg.err == nil {
m.positions = msg.positions
}
m.updateDetailViewport()
case driverTeamRadioLoadedMsg:
if msg.err == nil {
m.teamRadios = msg.radios
}
m.updateDetailViewport()
// When a meeting is selected from calendar, update session key for driver lookup
case meetingSelectedMsg: case meetingSelectedMsg:
// We'll pick up session key when sessions are loaded; for now reset
m.drivers = nil m.drivers = nil
case sessionsLoadedMsg: case sessionsLoadedMsg:
if msg.err == nil && len(msg.sessions) > 0 { if msg.err == nil && len(msg.sessions) > 0 {
// Use the Race session key if available
for _, s := range msg.sessions { for _, s := range msg.sessions {
if s.SessionName == "Race" { if s.SessionName == "Race" {
m.sessionKey = s.SessionKey m.sessionKey = s.SessionKey
@@ -161,32 +219,86 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
switch m.view { switch m.view {
case driverViewList: case driverViewList:
switch { switch {
case matchKey(msg, GlobalKeys.Retry):
if m.err != nil {
m.err = nil
m.loading = true
var cmd tea.Cmd
m, cmd = m.TriggerLoad()
cmds = append(cmds, cmd)
}
case matchKey(msg, GlobalKeys.Up): case matchKey(msg, GlobalKeys.Up):
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
m.ensureCursorVisible()
} }
case matchKey(msg, GlobalKeys.Down): case matchKey(msg, GlobalKeys.Down):
if m.cursor < len(m.drivers)-1 { if m.cursor < len(m.filteredDrivers)-1 {
m.cursor++ m.cursor++
m.ensureCursorVisible()
} }
case matchKey(msg, GlobalKeys.GoTop):
if m.input.Value() == "" {
m.cursor = 0
m.scroll = 0
} else {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
m.filterDrivers()
}
case matchKey(msg, GlobalKeys.GoBottom):
if m.input.Value() == "" {
if len(m.filteredDrivers) > 0 {
m.cursor = len(m.filteredDrivers) - 1
m.ensureCursorVisible()
}
} else {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
m.filterDrivers()
}
case matchKey(msg, GlobalKeys.HalfUp):
half := m.visibleRows() / 2
m.cursor -= half
if m.cursor < 0 {
m.cursor = 0
}
m.ensureCursorVisible()
case matchKey(msg, GlobalKeys.HalfDown):
half := m.visibleRows() / 2
m.cursor += half
if m.cursor >= len(m.filteredDrivers) {
m.cursor = len(m.filteredDrivers) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
m.ensureCursorVisible()
case matchKey(msg, GlobalKeys.Enter): case matchKey(msg, GlobalKeys.Enter):
if len(m.drivers) > 0 && m.cursor < len(m.drivers) { if len(m.filteredDrivers) > 0 && m.cursor >= 0 && m.cursor < len(m.filteredDrivers) {
d := m.drivers[m.cursor] d := m.filteredDrivers[m.cursor]
m.selectedDriver = &d m.selectedDriver = &d
m.stints = nil m.stints = nil
m.laps = nil m.laps = nil
m.pits = nil m.pits = nil
m.positions = nil
m.teamRadios = nil
m.view = driverViewDetail m.view = driverViewDetail
m.loading = true m.loading = true
m.detailViewReady = false
sessionKey := m.sessionKey sessionKey := m.sessionKey
if sessionKey == 0 && d.SessionKey != 0 { if sessionKey == 0 && d.SessionKey != 0 {
sessionKey = d.SessionKey sessionKey = d.SessionKey
} }
return m, tea.Batch( cmds = append(cmds, m.spinner.Tick, fetchDriverDetail(m.client, sessionKey, d.DriverNumber))
m.spinner.Tick,
fetchDriverDetail(m.client, sessionKey, d.DriverNumber),
)
} }
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
m.filterDrivers()
} }
case driverViewDetail: case driverViewDetail:
switch { switch {
@@ -196,14 +308,73 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
m.stints = nil m.stints = nil
m.laps = nil m.laps = nil
m.pits = nil m.pits = nil
m.positions = nil
m.teamRadios = nil
m.detailViewReady = false
case matchKey(msg, GlobalKeys.Up):
if m.detailViewReady {
m.detailView.LineUp(1)
}
case matchKey(msg, GlobalKeys.Down):
if m.detailViewReady {
m.detailView.LineDown(1)
}
case matchKey(msg, GlobalKeys.GoTop):
if m.detailViewReady {
m.detailView.GotoTop()
}
case matchKey(msg, GlobalKeys.GoBottom):
if m.detailViewReady {
m.detailView.GotoBottom()
}
case matchKey(msg, GlobalKeys.HalfUp):
if m.detailViewReady {
m.detailView.HalfViewUp()
}
case matchKey(msg, GlobalKeys.HalfDown):
if m.detailViewReady {
m.detailView.HalfViewDown()
}
} }
} }
} }
return m, nil return m, tea.Batch(cmds...)
}
func (m DriverModel) visibleRows() int {
rows := m.height - 12
if rows < 5 {
rows = 5
}
return rows
}
func (m *DriverModel) ensureCursorVisible() {
visible := m.visibleRows()
if m.cursor < m.scroll {
m.scroll = m.cursor
}
if m.cursor >= m.scroll+visible {
m.scroll = m.cursor - visible + 1
}
}
func (m *DriverModel) filterDrivers() {
query := m.input.Value()
if query == "" {
m.filteredDrivers = m.drivers
} else {
matches := fuzzy.FindFrom(query, driverSource(m.drivers))
m.filteredDrivers = make([]models.Driver, 0, len(matches))
for _, match := range matches {
m.filteredDrivers = append(m.filteredDrivers, m.drivers[match.Index])
}
}
m.cursor = 0
m.scroll = 0
} }
// TriggerLoad initiates the driver list load if not already loaded.
func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) { func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) {
if m.drivers != nil || m.loading { if m.drivers != nil || m.loading {
return m, nil return m, nil
@@ -213,11 +384,12 @@ func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) {
} }
func (m DriverModel) View() string { func (m DriverModel) View() string {
if m.loading { if m.loading && m.view == driverViewList {
return fmt.Sprintf("\n %s Loading drivers", m.spinner.View()) return fmt.Sprintf("\n %s Loading drivers...", m.spinner.View())
} }
if m.err != nil { if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err)) return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) +
helpBar("r retry", "q quit")
} }
switch m.view { switch m.view {
@@ -230,49 +402,133 @@ func (m DriverModel) View() string {
} }
func (m DriverModel) renderDriverList() string { func (m DriverModel) renderDriverList() string {
if len(m.drivers) == 0 { if len(m.drivers) == 0 && !m.loading {
return styleMuted.Render("\n No driver data. Select a race from Calendar first.\n\n" + return styleMuted.Render("\n No driver data. Select a race from Calendar first.\n\n") +
helpBar("2 calendar", "q quit")) helpBar("2 calendar", "r retry", "q quit")
}
const (
wNum = 3
wAcronym = 5
wName = 25
wTeam = 22
)
header := styleBold.Render(
padLeft("#", wNum) + " " +
padRight("DRV", wAcronym) + " " +
padRight("Name", wName) + " " +
padRight("Team", wTeam),
)
var rows []string
rows = append(rows, header)
for i, d := range m.drivers {
teamStr := hexToStyle(d.TeamColour).Render(padRight(truncate(d.TeamName, wTeam), wTeam))
row := fmt.Sprintf("%s %s %s %s",
padLeft(fmt.Sprintf("%d", d.DriverNumber), wNum),
padRight(d.NameAcronym, wAcronym),
padRight(truncate(d.FullName, wName), wName),
teamStr,
)
if i == m.cursor {
row = styleSelected.Render(row)
}
rows = append(rows, row)
} }
var sb strings.Builder var sb strings.Builder
sb.WriteString(strings.Join(rows, "\n")) w := m.width
sb.WriteString("\n\n") if w < 40 {
sb.WriteString(helpBar("j/k navigate", "enter view driver", "q quit")) w = 40
}
compact := w < 80
title := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorF1Red)).
Render(" DRIVER LOOKUP")
sb.WriteString(title + "\n\n")
// Search input
sb.WriteString(" " + m.input.View() + "\n\n")
// Responsive columns
nameWidth := 24
teamWidth := 22
if compact {
nameWidth = 16
teamWidth = 0 // hide team in compact mode
} else if w >= 120 {
nameWidth = 28
teamWidth = 26
}
// Header
var header string
if compact {
header = fmt.Sprintf(" %s %s %s %s",
padRight("#", 3),
padRight("", 1),
padRight("DRV", 4),
padRight("NAME", nameWidth),
)
} else {
header = fmt.Sprintf(" %s %s %s %s %s",
padRight("#", 3),
padRight("", 1),
padRight("DRV", 4),
padRight("NAME", nameWidth),
padRight("TEAM", teamWidth),
)
}
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
visible := m.visibleRows()
endIdx := m.scroll + visible
if endIdx > len(m.filteredDrivers) {
endIdx = len(m.filteredDrivers)
}
for i := m.scroll; i < endIdx; i++ {
d := m.filteredDrivers[i]
teamColor := colorMuted
if d.TeamColour != "" {
teamColor = "#" + d.TeamColour
} else {
teamColor = teamColorFromName(d.TeamName)
}
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
var row string
if compact {
row = fmt.Sprintf(" %s %s %s %s",
padRight(fmt.Sprintf("%d", d.DriverNumber), 3),
colorBar,
padRight(d.NameAcronym, 4),
padRight(truncate(d.FullName, nameWidth), nameWidth),
)
} else {
row = fmt.Sprintf(" %s %s %s %s %s",
padRight(fmt.Sprintf("%d", d.DriverNumber), 3),
colorBar,
padRight(d.NameAcronym, 4),
padRight(truncate(d.FullName, nameWidth), nameWidth),
padRight(truncate(d.TeamName, teamWidth), teamWidth),
)
}
if i == m.cursor {
row = styleSelected.Render(row)
}
sb.WriteString(row + "\n")
}
if len(m.filteredDrivers) > visible {
sb.WriteString(styleMuted.Render(fmt.Sprintf("\n %d of %d drivers", len(m.filteredDrivers), len(m.drivers))) + "\n")
}
sb.WriteString("\n")
sb.WriteString(helpBar("↑/↓ navigate", "g/G top/bottom", "^d/^u page", "enter view", "type to search", "q quit"))
return sb.String() return sb.String()
} }
func (m *DriverModel) updateDetailViewport() {
if m.selectedDriver == nil {
return
}
content := m.renderDetailContent()
vpWidth := m.width - 2
if vpWidth < 40 {
vpWidth = 40
}
vpHeight := m.height - 5 // room for header + help bar
if vpHeight < 5 {
vpHeight = 5
}
if !m.detailViewReady {
m.detailView = viewport.New(vpWidth, vpHeight)
m.detailViewReady = true
} else {
m.detailView.Width = vpWidth
m.detailView.Height = vpHeight
}
m.detailView.SetContent(content)
}
func (m DriverModel) renderDriverDetail() string { func (m DriverModel) renderDriverDetail() string {
if m.selectedDriver == nil { if m.selectedDriver == nil {
return "" return ""
@@ -281,75 +537,385 @@ func (m DriverModel) renderDriverDetail() string {
var sb strings.Builder var sb strings.Builder
// Nameplate header teamColor := colorMuted
nameStyle := hexToStyle(d.TeamColour).Bold(true) if d.TeamColour != "" {
sb.WriteString(nameStyle.Render(fmt.Sprintf(" %s %s", d.NameAcronym, d.FullName))) teamColor = "#" + d.TeamColour
sb.WriteString(styleMuted.Render(fmt.Sprintf(" · %s · #%d", d.TeamName, d.DriverNumber))) } else {
sb.WriteString("\n\n") teamColor = teamColorFromName(d.TeamName)
}
// Stint bar // Driver card header (fixed, not scrollable)
sb.WriteString(styleBold.Render("Stints") + "\n") numberBg := lipgloss.NewStyle().
Background(lipgloss.Color(teamColor)).
Foreground(lipgloss.Color(colorWhite)).
Bold(true).
Padding(0, 1).
Render(fmt.Sprintf(" #%d ", d.DriverNumber))
nameStyled := lipgloss.NewStyle().
Foreground(lipgloss.Color(teamColor)).
Bold(true).
Render(d.FullName)
acronymStyled := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorWhite)).
Bold(true).
Render(d.NameAcronym)
teamStyled := styleTeamName.Render(d.TeamName)
sb.WriteString(fmt.Sprintf("\n %s %s %s %s\n", numberBg, nameStyled, acronymStyled, teamStyled))
stripeWidth := min(m.width-6, 60)
stripe := lipgloss.NewStyle().
Foreground(lipgloss.Color(teamColor)).
Render(strings.Repeat("━", stripeWidth))
sb.WriteString(" " + stripe + "\n")
if m.loading {
sb.WriteString(fmt.Sprintf("\n %s Loading driver data...\n", m.spinner.View()))
}
// Scrollable content via viewport
if m.detailViewReady {
sb.WriteString(m.detailView.View())
sb.WriteString("\n")
// Scroll indicator
pct := m.detailView.ScrollPercent()
scrollInfo := styleMuted.Render(fmt.Sprintf(" %.0f%%", pct*100))
sb.WriteString(scrollInfo + "\n")
}
sb.WriteString(helpBar("↑/↓ scroll", "g/G top/bottom", "^d/^u page", "b back", "q quit"))
return sb.String()
}
// renderDetailContent generates the full content for the driver detail viewport.
func (m DriverModel) renderDetailContent() string {
var sb strings.Builder
// ── POSITION HISTORY ────────────────────────────────
sb.WriteString("\n " + styleSectionTitle.Render("POSITION HISTORY") + "\n")
sb.WriteString(m.renderPositionChart())
sb.WriteString("\n")
// ── RACE STRATEGY ───────────────────────────────────
sb.WriteString(" " + styleSectionTitle.Render("RACE STRATEGY") + "\n")
sb.WriteString(m.renderStintBar()) sb.WriteString(m.renderStintBar())
sb.WriteString("\n\n") sb.WriteString("\n\n")
// Lap sparkline // ── LAP TIMES ───────────────────────────────────────
sb.WriteString(styleBold.Render("Lap Times") + "\n") sb.WriteString(" " + styleSectionTitle.Render("LAP TIMES") + "\n")
sparkWidth := min(m.width-4, 80) sparkWidth := min(m.width-6, 70)
if sparkWidth < 10 { if sparkWidth < 10 {
sparkWidth = 40 sparkWidth = 40
} }
sb.WriteString(" " + sparkline(m.laps, sparkWidth) + "\n") sb.WriteString(" " + sparkline(m.laps, sparkWidth) + "\n")
sb.WriteString(styleMuted.Render(fmt.Sprintf(" %d laps (▁=slow, █=fast, space=pit out)\n", len(m.laps)))) legend := fmt.Sprintf(" %s fast %s mid %s slow %s pit",
sb.WriteString("\n") lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(""),
lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Render("█"),
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render("█"),
styleMuted.Render("·"),
)
sb.WriteString(legend)
// Pit stops if len(m.laps) > 0 {
sb.WriteString(styleBold.Render("Pit Stops") + "\n") var bestLap, worstLap float64
var bestLapNum, worstLapNum int
bestLap = 999999
for _, lap := range m.laps {
if lap.LapDuration != nil && *lap.LapDuration > 0 {
if *lap.LapDuration < bestLap {
bestLap = *lap.LapDuration
bestLapNum = lap.LapNumber
}
if *lap.LapDuration > worstLap && !lap.IsPitOutLap {
worstLap = *lap.LapDuration
worstLapNum = lap.LapNumber
}
}
}
if bestLap < 999999 {
sb.WriteString(fmt.Sprintf(" %s %s (Lap %d)",
styleMuted.Render("Best:"),
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true).Render(formatSeconds(bestLap)),
bestLapNum,
))
if worstLap > 0 {
sb.WriteString(fmt.Sprintf(" %s %s (Lap %d)",
styleMuted.Render("Slowest:"),
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render(formatSeconds(worstLap)),
worstLapNum,
))
}
}
}
sb.WriteString("\n\n")
// ── PIT STOPS ───────────────────────────────────────
sb.WriteString(" " + styleSectionTitle.Render("PIT STOPS") + "\n")
sb.WriteString(m.renderPitStops()) sb.WriteString(m.renderPitStops())
sb.WriteString("\n\n") sb.WriteString("\n\n")
sb.WriteString(helpBar("b back to driver list", "q quit")) // ── TEAM RADIO ──────────────────────────────────────
sb.WriteString(" " + styleSectionTitle.Render("TEAM RADIO") + "\n")
sb.WriteString(m.renderTeamRadio())
sb.WriteString("\n")
return sb.String() return sb.String()
} }
func (m DriverModel) renderStintBar() string { func (m DriverModel) renderStintBar() string {
if len(m.stints) == 0 { if len(m.stints) == 0 {
if m.loading { if m.loading {
return fmt.Sprintf(" %s", m.spinner.View()) return fmt.Sprintf(" %s Loading...", m.spinner.View())
} }
return styleMuted.Render(" No stint data.") return styleMuted.Render(" No stint data available.")
} }
var parts []string var parts []string
for _, stint := range m.stints { for i, stint := range m.stints {
label := fmt.Sprintf("%s %d-%d", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd) label := fmt.Sprintf(" %s L%d-%d ", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd)
part := tyreStyle(stint.Compound).Render(fmt.Sprintf("[%s]", label)) stintStr := tyreBgStyle(stint.Compound).Render(label)
parts = append(parts, part)
parts = append(parts, stintStr)
// Arrow between stints
if i < len(m.stints)-1 {
parts = append(parts, styleMuted.Render(" > "))
}
} }
return " " + strings.Join(parts, " ") return " " + strings.Join(parts, "")
} }
func (m DriverModel) renderPitStops() string { func (m DriverModel) renderPitStops() string {
if len(m.pits) == 0 { if len(m.pits) == 0 {
return styleMuted.Render(" No pit stop data.") return styleMuted.Render(" No pit stop data available.")
} }
header := styleBold.Render( var sb strings.Builder
padLeft("Lap", 4) + " " +
padLeft("Stop", 8) + " " + // Header
padLeft("Lane", 8), header := fmt.Sprintf(" %s %s %s %s",
padRight("STOP", 5),
padLeft("LAP", 4),
padLeft("STOP TIME", 10),
padLeft("PIT LANE", 10),
) )
var rows []string sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
rows = append(rows, header) sb.WriteString(" " + divider(35) + "\n")
for _, p := range m.pits { for i, p := range m.pits {
row := fmt.Sprintf("%s %s %s", stopNum := fmt.Sprintf("#%d", i+1)
// Color stop duration: green = fast, red = slow
stopDur := p.StopDuration
var stopStyle lipgloss.Style
switch {
case stopDur < 2.5:
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true)
case stopDur < 3.5:
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
default:
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
}
row := fmt.Sprintf(" %s %s %s %s",
padRight(stopNum, 5),
padLeft(fmt.Sprintf("%d", p.LapNumber), 4), padLeft(fmt.Sprintf("%d", p.LapNumber), 4),
padLeft(fmt.Sprintf("%.3fs", p.StopDuration), 8), padLeftVisible(stopStyle.Render(fmt.Sprintf("%.3fs", p.StopDuration)), 10),
padLeft(fmt.Sprintf("%.3fs", p.LaneDuration), 8), padLeft(fmt.Sprintf("%.3fs", p.LaneDuration), 10),
) )
rows = append(rows, " "+row) sb.WriteString(row + "\n")
} }
return strings.Join(rows, "\n") return sb.String()
}
// renderPositionChart draws a text-based position history chart.
// Shows position changes over the race using a compact inline format.
func (m DriverModel) renderPositionChart() string {
if len(m.positions) == 0 {
return styleMuted.Render(" No position data available.\n")
}
var sb strings.Builder
// Deduplicate: only keep position changes
type posChange struct {
position int
date string
}
var changes []posChange
lastPos := -1
for _, p := range m.positions {
if p.Position != lastPos {
changes = append(changes, posChange{position: p.Position, date: p.Date})
lastPos = p.Position
}
}
if len(changes) == 0 {
return styleMuted.Render(" No position changes.\n")
}
// Start and end positions
startPos := changes[0].position
endPos := changes[len(changes)-1].position
bestPos := startPos
worstPos := startPos
for _, c := range changes {
if c.position < bestPos {
bestPos = c.position
}
if c.position > worstPos {
worstPos = c.position
}
}
// Summary line
startStyled := renderPosition(startPos)
endStyled := renderPosition(endPos)
delta := startPos - endPos // positive = gained
var deltaStr string
if delta > 0 {
deltaStr = styleDeltaUp.Render(fmt.Sprintf("▲%d gained", delta))
} else if delta < 0 {
deltaStr = styleDeltaDown.Render(fmt.Sprintf("▼%d lost", -delta))
} else {
deltaStr = styleDeltaEqual.Render("─ no change")
}
sb.WriteString(fmt.Sprintf(" Start: P%s Finish: P%s %s\n", startStyled, endStyled, deltaStr))
sb.WriteString(fmt.Sprintf(" %s P%s %s P%s\n",
styleMuted.Render("Best:"),
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true).Render(fmt.Sprintf("%d", bestPos)),
styleMuted.Render("Worst:"),
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render(fmt.Sprintf("%d", worstPos)),
))
// Visual position timeline (compact sparkline-style)
chartWidth := min(m.width-6, 60)
if chartWidth < 20 {
chartWidth = 20
}
if len(changes) > 1 {
posRange := worstPos - bestPos
if posRange == 0 {
posRange = 1
}
// Sample positions at regular intervals
samples := make([]int, chartWidth)
for i := 0; i < chartWidth; i++ {
idx := i * (len(changes) - 1) / (chartWidth - 1)
if idx >= len(changes) {
idx = len(changes) - 1
}
samples[i] = changes[idx].position
}
// Draw the chart
blocks := []rune("▁▂▃▄▅▆▇█")
var chartLine strings.Builder
for _, pos := range samples {
// Invert: lower position (better) = taller bar
norm := float64(pos-bestPos) / float64(posRange)
idx := int((1.0-norm)*float64(len(blocks)-1) + 0.5)
if idx < 0 {
idx = 0
}
if idx >= len(blocks) {
idx = len(blocks) - 1
}
var blockStyle lipgloss.Style
switch {
case norm < 0.25:
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
case norm < 0.5:
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
case norm < 0.75:
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange))
default:
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
}
chartLine.WriteString(blockStyle.Render(string(blocks[idx])))
}
sb.WriteString(" " + chartLine.String() + "\n")
sb.WriteString(fmt.Sprintf(" %s%s%s\n",
styleMuted.Render(fmt.Sprintf("P%d", bestPos)),
strings.Repeat(" ", max(chartWidth-8, 1)),
styleMuted.Render(fmt.Sprintf("P%d", worstPos)),
))
}
// Position change timeline (textual)
if len(changes) > 1 && len(changes) <= 20 {
sb.WriteString(" ")
for i, c := range changes {
posStr := fmt.Sprintf("P%d", c.position)
if c.position <= 3 {
posStr = renderPosition(c.position)
}
sb.WriteString(posStr)
if i < len(changes)-1 {
next := changes[i+1].position
if next < c.position {
sb.WriteString(styleDeltaUp.Render(" > "))
} else if next > c.position {
sb.WriteString(styleDeltaDown.Render(" > "))
} else {
sb.WriteString(styleMuted.Render(" > "))
}
}
}
sb.WriteString("\n")
}
return sb.String()
}
// renderTeamRadio displays team radio messages with timestamps.
func (m DriverModel) renderTeamRadio() string {
if len(m.teamRadios) == 0 {
return styleMuted.Render(" No team radio messages available.\n")
}
var sb strings.Builder
countStr := styleMuted.Render(fmt.Sprintf(" %d messages during session", len(m.teamRadios)))
sb.WriteString(countStr + "\n")
for i, radio := range m.teamRadios {
// Parse timestamp
t := "--:--:--"
if len(radio.Date) >= 19 {
pt, err := time.Parse(time.RFC3339, radio.Date)
if err == nil {
t = pt.Format("15:04:05")
} else {
t = radio.Date[11:19]
}
}
icon := lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render("📻")
timeStr := styleMuted.Render(fmt.Sprintf("[%s]", t))
urlStr := radio.RecordingURL
urlStyled := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorCyan)).
Render(truncate(urlStr, min(m.width-20, 50)))
sb.WriteString(fmt.Sprintf(" %s %s %s\n", icon, timeStr, urlStyled))
// Limit display to 15 entries
if i >= 14 && i < len(m.teamRadios)-1 {
remaining := len(m.teamRadios) - i - 1
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... and %d more messages\n", remaining)))
break
}
}
return sb.String()
} }

View File

@@ -4,16 +4,23 @@ import "github.com/charmbracelet/bubbles/key"
// GlobalKeyMap holds keybindings that work from any tab. // GlobalKeyMap holds keybindings that work from any tab.
type GlobalKeyMap struct { type GlobalKeyMap struct {
Tab1 key.Binding Tab1 key.Binding
Tab2 key.Binding Tab2 key.Binding
Tab3 key.Binding Tab3 key.Binding
Tab4 key.Binding Tab4 key.Binding
Quit key.Binding NextTab key.Binding
Up key.Binding PrevTab key.Binding
Down key.Binding Quit key.Binding
Enter key.Binding Up key.Binding
Back key.Binding Down key.Binding
Year key.Binding Enter key.Binding
Back key.Binding
Year key.Binding
Retry key.Binding
GoTop key.Binding
GoBottom key.Binding
HalfUp key.Binding
HalfDown key.Binding
} }
// GlobalKeys is the singleton global key map. // GlobalKeys is the singleton global key map.
@@ -34,30 +41,58 @@ var GlobalKeys = GlobalKeyMap{
key.WithKeys("4"), key.WithKeys("4"),
key.WithHelp("4", "drivers"), key.WithHelp("4", "drivers"),
), ),
NextTab: key.NewBinding(
key.WithKeys("tab", "right"),
key.WithHelp("tab/->", "next tab"),
),
PrevTab: key.NewBinding(
key.WithKeys("shift+tab", "left"),
key.WithHelp("shift+tab/<-", "prev tab"),
),
Quit: key.NewBinding( Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"), key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"), key.WithHelp("q", "quit"),
), ),
Up: key.NewBinding( Up: key.NewBinding(
key.WithKeys("k", "up"), key.WithKeys("k", "up"),
key.WithHelp("k/↑", "up"), key.WithHelp("k", "up"),
), ),
Down: key.NewBinding( Down: key.NewBinding(
key.WithKeys("j", "down"), key.WithKeys("j", "down"),
key.WithHelp("j/↓", "down"), key.WithHelp("j", "down"),
), ),
Enter: key.NewBinding( Enter: key.NewBinding(
key.WithKeys("enter"), key.WithKeys("enter"),
key.WithHelp("enter", "select"), key.WithHelp("enter", "select"),
), ),
Back: key.NewBinding( Back: key.NewBinding(
key.WithKeys("b"), key.WithKeys("b", "esc"),
key.WithHelp("b", "back"), key.WithHelp("b/esc", "back"),
), ),
Year: key.NewBinding( Year: key.NewBinding(
key.WithKeys("y"), key.WithKeys("y"),
key.WithHelp("y", "switch year"), key.WithHelp("y", "switch year"),
), ),
Retry: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "retry"),
),
GoTop: key.NewBinding(
key.WithKeys("g", "home"),
key.WithHelp("g", "go to top"),
),
GoBottom: key.NewBinding(
key.WithKeys("G", "end"),
key.WithHelp("G", "go to bottom"),
),
HalfUp: key.NewBinding(
key.WithKeys("ctrl+u"),
key.WithHelp("ctrl+u", "half page up"),
),
HalfDown: key.NewBinding(
key.WithKeys("ctrl+d"),
key.WithHelp("ctrl+d", "half page down"),
),
} }
// StandingsKeyMap holds standing-specific keybindings. // StandingsKeyMap holds standing-specific keybindings.
@@ -79,8 +114,10 @@ var StandingsKeys = StandingsKeyMap{
// RaceDetailKeyMap holds keybindings for the race detail tab. // RaceDetailKeyMap holds keybindings for the race detail tab.
type RaceDetailKeyMap struct { type RaceDetailKeyMap struct {
ScrollUp key.Binding ScrollUp key.Binding
ScrollDown key.Binding ScrollDown key.Binding
PrevSession key.Binding
NextSession key.Binding
} }
var RaceDetailKeys = RaceDetailKeyMap{ var RaceDetailKeys = RaceDetailKeyMap{
@@ -92,4 +129,12 @@ var RaceDetailKeys = RaceDetailKeyMap{
key.WithKeys("J"), key.WithKeys("J"),
key.WithHelp("J", "scroll race control down"), key.WithHelp("J", "scroll race control down"),
), ),
PrevSession: key.NewBinding(
key.WithKeys("["),
key.WithHelp("[", "previous session"),
),
NextSession: key.NewBinding(
key.WithKeys("]"),
key.WithHelp("]", "next session"),
),
} }

View File

@@ -80,6 +80,24 @@ type driverPitsLoadedMsg struct {
err error err error
} }
// driverPositionsLoadedMsg carries position history for a selected driver.
type driverPositionsLoadedMsg struct {
positions []models.Position
err error
}
// driverTeamRadioLoadedMsg carries team radio messages for a selected driver.
type driverTeamRadioLoadedMsg struct {
radios []models.TeamRadio
err error
}
// overtakesLoadedMsg carries overtake data for a session.
type overtakesLoadedMsg struct {
overtakes []models.Overtake
err error
}
// meetingSelectedMsg is emitted when the user selects a meeting in the calendar. // meetingSelectedMsg is emitted when the user selects a meeting in the calendar.
type meetingSelectedMsg struct { type meetingSelectedMsg struct {
meeting models.Meeting meeting models.Meeting

View File

@@ -17,23 +17,26 @@ type RaceDetailModel struct {
client *api.OpenF1Client client *api.OpenF1Client
meeting *models.Meeting meeting *models.Meeting
sessions []models.Session sessions []models.Session
results []models.SessionResult results []models.SessionResult
drivers map[int]models.Driver drivers map[int]models.Driver
rcMsgs []models.RaceControl rcMsgs []models.RaceControl
weather []models.Weather weather []models.Weather
overtakes []models.Overtake
selectedSession *models.Session selectedSession *models.Session
sessionCursor int sessionCursor int
resultsCursor int
resultsScroll int
loadingSessions bool loadingSessions bool
loadingResults bool loadingResults bool
errSessions error errSessions error
errResults error errResults error
spinner spinner.Model spinner spinner.Model
rcView viewport.Model rcView viewport.Model
rcReady bool rcReady bool
width int width int
height int height int
@@ -43,6 +46,7 @@ func NewRaceDetailModel(client *api.OpenF1Client) RaceDetailModel {
s := spinner.New() s := spinner.New()
s.Spinner = spinner.MiniDot s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return RaceDetailModel{ return RaceDetailModel{
client: client, client: client,
spinner: s, spinner: s,
@@ -79,6 +83,10 @@ func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
weather, err := client.GetWeather(sessionKey) weather, err := client.GetWeather(sessionKey)
return weatherLoadedMsg{weather: weather, err: err} return weatherLoadedMsg{weather: weather, err: err}
}, },
func() tea.Msg {
overtakes, err := client.GetOvertakesForSession(sessionKey)
return overtakesLoadedMsg{overtakes: overtakes, err: err}
},
) )
} }
@@ -86,6 +94,9 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg:
// handled by SetSize from app.go
case spinner.TickMsg: case spinner.TickMsg:
if m.loadingSessions || m.loadingResults { if m.loadingSessions || m.loadingResults {
var cmd tea.Cmd var cmd tea.Cmd
@@ -99,8 +110,11 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.results = nil m.results = nil
m.rcMsgs = nil m.rcMsgs = nil
m.weather = nil m.weather = nil
m.overtakes = nil
m.selectedSession = nil m.selectedSession = nil
m.sessionCursor = 0 m.sessionCursor = 0
m.resultsCursor = 0
m.resultsScroll = 0
m.loadingSessions = true m.loadingSessions = true
m.loadingResults = false m.loadingResults = false
m.errSessions = nil m.errSessions = nil
@@ -115,13 +129,34 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
return m, nil return m, nil
} }
m.sessions = msg.sessions m.sessions = msg.sessions
// Auto-select Race session if available // Auto-select the Race session and load its data
raceIdx := -1
for i, s := range m.sessions { for i, s := range m.sessions {
if s.SessionName == "Race" { if s.SessionName == "Race" {
m.sessionCursor = i raceIdx = i
break break
} }
} }
if raceIdx >= 0 {
m.sessionCursor = raceIdx
sess := m.sessions[raceIdx]
m.selectedSession = &sess
m.loadingResults = true
m.results = nil
m.rcMsgs = nil
m.weather = nil
m.drivers = make(map[int]models.Driver)
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
} else if len(m.sessions) > 0 {
// Fallback: select last session
lastIdx := len(m.sessions) - 1
m.sessionCursor = lastIdx
sess := m.sessions[lastIdx]
m.selectedSession = &sess
m.loadingResults = true
m.drivers = make(map[int]models.Driver)
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
}
case sessionResultsLoadedMsg: case sessionResultsLoadedMsg:
if msg.err != nil { if msg.err != nil {
@@ -130,9 +165,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
return m, nil return m, nil
} }
m.results = msg.results m.results = msg.results
if !m.loadingResults { m.resultsCursor = 0
// Both results and drivers may arrive in any order m.resultsScroll = 0
}
m.checkResultsLoaded() m.checkResultsLoaded()
case sessionDriversLoadedMsg: case sessionDriversLoadedMsg:
@@ -154,15 +188,60 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.weather = msg.weather m.weather = msg.weather
} }
case overtakesLoadedMsg:
if msg.err == nil {
m.overtakes = msg.overtakes
}
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
case matchKey(msg, GlobalKeys.Retry):
if m.errSessions != nil && m.meeting != nil {
m.errSessions = nil
m.loadingSessions = true
cmds = append(cmds, m.spinner.Tick, fetchSessions(m.client, int(m.meeting.MeetingKey)))
} else if m.errResults != nil && m.selectedSession != nil {
m.errResults = nil
m.loadingResults = true
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, m.selectedSession.SessionKey))
}
case matchKey(msg, GlobalKeys.Up): case matchKey(msg, GlobalKeys.Up):
if m.sessionCursor > 0 { if m.results != nil && m.resultsCursor > 0 {
m.sessionCursor-- m.resultsCursor--
m.ensureResultsVisible()
} }
case matchKey(msg, GlobalKeys.Down): case matchKey(msg, GlobalKeys.Down):
if m.sessionCursor < len(m.sessions)-1 { if m.results != nil && m.resultsCursor < len(m.results)-1 {
m.sessionCursor++ m.resultsCursor++
m.ensureResultsVisible()
}
case matchKey(msg, GlobalKeys.GoTop):
if m.results != nil {
m.resultsCursor = 0
m.resultsScroll = 0
}
case matchKey(msg, GlobalKeys.GoBottom):
if m.results != nil && len(m.results) > 0 {
m.resultsCursor = len(m.results) - 1
m.ensureResultsVisible()
}
case matchKey(msg, GlobalKeys.HalfUp):
if m.results != nil {
half := m.resultsVisibleRows() / 2
m.resultsCursor -= half
if m.resultsCursor < 0 {
m.resultsCursor = 0
}
m.ensureResultsVisible()
}
case matchKey(msg, GlobalKeys.HalfDown):
if m.results != nil {
half := m.resultsVisibleRows() / 2
m.resultsCursor += half
if m.resultsCursor >= len(m.results) {
m.resultsCursor = len(m.results) - 1
}
m.ensureResultsVisible()
} }
case matchKey(msg, GlobalKeys.Enter): case matchKey(msg, GlobalKeys.Enter):
if len(m.sessions) > 0 && m.sessionCursor < len(m.sessions) { if len(m.sessions) > 0 && m.sessionCursor < len(m.sessions) {
@@ -172,13 +251,24 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.results = nil m.results = nil
m.rcMsgs = nil m.rcMsgs = nil
m.weather = nil m.weather = nil
m.overtakes = nil
m.resultsCursor = 0
m.resultsScroll = 0
m.drivers = make(map[int]models.Driver) m.drivers = make(map[int]models.Driver)
cmds = append(cmds, fetchSessionData(m.client, sess.SessionKey)) cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
} }
case matchKey(msg, RaceDetailKeys.ScrollUp): case matchKey(msg, RaceDetailKeys.ScrollUp):
m.rcView.LineUp(3) m.rcView.LineUp(3)
case matchKey(msg, RaceDetailKeys.ScrollDown): case matchKey(msg, RaceDetailKeys.ScrollDown):
m.rcView.LineDown(3) m.rcView.LineDown(3)
case matchKey(msg, RaceDetailKeys.PrevSession):
if m.sessionCursor > 0 {
m.sessionCursor--
}
case matchKey(msg, RaceDetailKeys.NextSession):
if m.sessionCursor < len(m.sessions)-1 {
m.sessionCursor++
}
} }
} }
@@ -192,12 +282,29 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
} }
func (m *RaceDetailModel) checkResultsLoaded() { func (m *RaceDetailModel) checkResultsLoaded() {
// Mark done once results arrive (drivers may still be loading but we show what we have)
if m.results != nil { if m.results != nil {
m.loadingResults = false m.loadingResults = false
} }
} }
func (m RaceDetailModel) resultsVisibleRows() int {
rows := m.height - 18
if rows < 5 {
rows = 5
}
return rows
}
func (m *RaceDetailModel) ensureResultsVisible() {
visible := m.resultsVisibleRows()
if m.resultsCursor < m.resultsScroll {
m.resultsScroll = m.resultsCursor
}
if m.resultsCursor >= m.resultsScroll+visible {
m.resultsScroll = m.resultsCursor - visible + 1
}
}
func (m *RaceDetailModel) updateRCViewport() { func (m *RaceDetailModel) updateRCViewport() {
content := m.renderRaceControlContent() content := m.renderRaceControlContent()
if m.rcReady { if m.rcReady {
@@ -214,177 +321,307 @@ func (m *RaceDetailModel) initViewport(w, h int) {
func (m RaceDetailModel) View() string { func (m RaceDetailModel) View() string {
if m.meeting == nil { if m.meeting == nil {
return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n" + return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n") +
helpBar("2 calendar", "q quit")) helpBar("2 calendar", "q quit")
} }
// Title w := m.width
title := styleBold.Render(m.meeting.MeetingOfficialName) if w < 40 {
dates := formatMeetingDates(*m.meeting) w = 40
subtitle := styleMuted.Render(fmt.Sprintf("%s · %s · %s", m.meeting.Location, m.meeting.CountryName, dates)) }
compact := w < 100
header := lipgloss.JoinVertical(lipgloss.Left, title, subtitle) + "\n\n"
// Two-panel layout
leftWidth := int(float64(m.width) * 0.55)
rightWidth := m.width - leftWidth - 4
left := m.renderLeft(leftWidth)
right := m.renderRight(rightWidth)
panels := lipgloss.JoinHorizontal(lipgloss.Top,
stylePanelBorder.Width(leftWidth).Render(left),
stylePanelBorder.Width(rightWidth).Render(right),
)
help := helpBar("j/k sessions", "enter load session", "K/J scroll RC", "b back to calendar", "q quit")
return header + panels + "\n" + help
}
func (m RaceDetailModel) renderLeft(width int) string {
var sb strings.Builder var sb strings.Builder
// Session list // Race title header
sb.WriteString(styleHeader.Render("Sessions") + "\n") flag := countryFlag(m.meeting.CountryCode)
if m.loadingSessions { titleStyle := lipgloss.NewStyle().
sb.WriteString(fmt.Sprintf(" %s Loading sessions…\n", m.spinner.View())) Bold(true).
} else if m.errSessions != nil { Foreground(lipgloss.Color(colorF1Red))
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errSessions)))
if compact {
sb.WriteString(titleStyle.Render(fmt.Sprintf(" %s %s", flag, m.meeting.MeetingName)) + "\n")
} else { } else {
for i, sess := range m.sessions { sb.WriteString(titleStyle.Render(fmt.Sprintf(" %s %s", flag, m.meeting.MeetingOfficialName)) + "\n")
var start string }
if len(sess.DateStart) >= 10 {
t, err := time.Parse(time.RFC3339, sess.DateStart) dates := formatMeetingDates(*m.meeting)
if err == nil { subtitle := fmt.Sprintf(" %s %s %s",
start = t.Format("Mon Jan 2") styleMuted.Render(m.meeting.Location),
} else { styleMuted.Render("·"),
start = sess.DateStart[:10] styleMuted.Render(dates))
} sb.WriteString(subtitle + "\n\n")
// Session selector pills
sb.WriteString(m.renderSessionPills())
sb.WriteString("\n\n")
if compact {
// Single column layout for narrow terminals
sb.WriteString(m.renderResults(w - 4))
sb.WriteString("\n")
sb.WriteString(m.renderWeatherCard(w - 4))
sb.WriteString("\n")
sb.WriteString(m.renderOvertakes(w - 4))
sb.WriteString("\n")
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
if !m.rcReady || m.selectedSession == nil {
sb.WriteString(styleMuted.Render(" No session selected.\n"))
} else {
// Show limited RC messages inline
lines := strings.Split(m.renderRaceControlContent(), "\n")
maxRC := 8
if len(lines) > maxRC {
lines = lines[len(lines)-maxRC:]
} }
row := fmt.Sprintf(" %-12s %s", sess.SessionName, start) sb.WriteString(strings.Join(lines, "\n") + "\n")
if i == m.sessionCursor { if len(m.rcMsgs) > maxRC {
row = styleSelected.Render(row) sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... %d more messages (K/J scroll)", len(m.rcMsgs)-maxRC)) + "\n")
} else if m.selectedSession != nil && m.selectedSession.SessionKey == sess.SessionKey {
row = styleDeltaUp.Render(row)
} }
sb.WriteString(row + "\n")
} }
}
sb.WriteString("\n" + styleHeader.Render("Results") + "\n")
if m.loadingResults {
sb.WriteString(fmt.Sprintf(" %s Loading results…\n", m.spinner.View()))
} else if m.errResults != nil {
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults)))
} else if m.selectedSession == nil {
sb.WriteString(styleMuted.Render(" Press Enter to load session results.\n"))
} else if len(m.results) == 0 {
sb.WriteString(styleMuted.Render(" No results available.\n"))
} else { } else {
sb.WriteString(m.renderResults(width - 4)) // Two-panel layout
leftWidth := int(float64(w) * 0.55)
rightWidth := w - leftWidth - 6
left := m.renderResults(leftWidth)
right := m.renderRightPanel(rightWidth)
panels := lipgloss.JoinHorizontal(lipgloss.Top,
stylePanelBorder.Width(leftWidth).Render(left),
stylePanelBorder.Width(rightWidth).Render(right),
)
sb.WriteString(panels + "\n")
} }
sb.WriteString(helpBar("[/] sessions", "enter load", "j/k results", "g/G top/bottom", "K/J scroll RC", "b back", "q quit"))
return sb.String() return sb.String()
} }
func (m RaceDetailModel) renderResults(width int) string { func (m RaceDetailModel) renderSessionPills() string {
isRace := m.selectedSession != nil && m.selectedSession.SessionType == "Race" if m.loadingSessions {
return fmt.Sprintf(" %s Loading sessions...", m.spinner.View())
const ( }
wPos = 3 if m.errSessions != nil {
wDRV = 4 return styleError.Render(fmt.Sprintf(" Error: %v", m.errSessions))
wTeam = 16
wLaps = 4
wGap = 12
wPts = 4
)
var header string
if isRace {
header = styleBold.Render(
padLeft("Pos", wPos) + " " +
padRight("DRV", wDRV) + " " +
padRight("Team", wTeam) + " " +
padLeft("Laps", wLaps) + " " +
padLeft("Gap", wGap) + " " +
padLeft("Pts", wPts),
)
} else {
header = styleBold.Render(
padLeft("Pos", wPos) + " " +
padRight("DRV", wDRV) + " " +
padRight("Team", wTeam) + " " +
padLeft("Time", wGap),
)
} }
var rows []string var pills []string
rows = append(rows, header) for i, sess := range m.sessions {
// Format date
var dateStr string
if len(sess.DateStart) >= 10 {
t, err := time.Parse(time.RFC3339, sess.DateStart)
if err == nil {
dateStr = t.Format("Mon 2")
} else {
dateStr = sess.DateStart[:10]
}
}
for _, r := range m.results { label := fmt.Sprintf("%s %s", sess.SessionName, dateStr)
if m.selectedSession != nil && m.selectedSession.SessionKey == sess.SessionKey {
pills = append(pills, styleSessionActive.Render(label))
} else if i == m.sessionCursor {
pills = append(pills, styleSessionCursor.Render(label))
} else {
pills = append(pills, styleSessionInactive.Render(label))
}
}
return " " + strings.Join(pills, " ")
}
func (m RaceDetailModel) renderResults(width int) string {
var sb strings.Builder
sb.WriteString(styleSectionTitle.Render("RESULTS") + "\n")
if m.loadingResults {
sb.WriteString(fmt.Sprintf(" %s Loading results...\n", m.spinner.View()))
return sb.String()
}
if m.errResults != nil {
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults)))
return sb.String()
}
if m.selectedSession == nil {
sb.WriteString(styleMuted.Render(" Press Enter to load session results.\n"))
return sb.String()
}
if len(m.results) == 0 {
sb.WriteString(styleMuted.Render(" No results available.\n"))
return sb.String()
}
isRace := m.selectedSession.SessionType == "Race"
compact := width < 55
// Responsive team name width
teamWidth := 16
if width >= 70 {
teamWidth = 20
} else if compact {
teamWidth = 10
}
// Column header
var header string
if isRace {
if compact {
header = fmt.Sprintf(" %s %s %s %s %s",
padRight("P", 3),
padRight("", 1),
padRight("DRV", 4),
padLeft("GAP", 10),
padLeft("PTS", 4),
)
} else {
header = fmt.Sprintf(" %s %s %s %s %s %s %s",
padRight("P", 3),
padRight("", 1),
padRight("DRV", 4),
padRight("TEAM", teamWidth),
padLeft("LAPS", 4),
padLeft("GAP", 12),
padLeft("PTS", 4),
)
}
} else {
if compact {
header = fmt.Sprintf(" %s %s %s %s",
padRight("P", 3),
padRight("", 1),
padRight("DRV", 4),
padLeft("TIME", 12),
)
} else {
header = fmt.Sprintf(" %s %s %s %s %s",
padRight("P", 3),
padRight("", 1),
padRight("DRV", 4),
padRight("TEAM", teamWidth),
padLeft("TIME", 12),
)
}
}
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
sb.WriteString(" " + divider(min(width-4, lipgloss.Width(header))) + "\n")
visible := m.resultsVisibleRows()
endIdx := m.resultsScroll + visible
if endIdx > len(m.results) {
endIdx = len(m.results)
}
for i := m.resultsScroll; i < endIdx; i++ {
r := m.results[i]
d := m.drivers[r.DriverNumber] d := m.drivers[r.DriverNumber]
acronym := d.NameAcronym acronym := d.NameAcronym
if acronym == "" { if acronym == "" {
acronym = fmt.Sprintf("#%d", r.DriverNumber) acronym = fmt.Sprintf("#%d", r.DriverNumber)
} }
teamName := d.TeamName
teamColor := d.TeamColour
teamStr := hexToStyle(teamColor).Render(padRight(truncate(teamName, wTeam), wTeam)) teamColor := colorMuted
if d.TeamColour != "" {
teamColor = "#" + d.TeamColour
} else if d.TeamName != "" {
teamColor = teamColorFromName(d.TeamName)
}
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
// Position with status
var pos string
if r.DNF {
pos = styleDNF.Render("DNF")
} else if r.DNS {
pos = styleDNF.Render("DNS")
} else if r.DSQ {
pos = styleDNF.Render("DSQ")
} else {
pos = renderPosition(r.Position)
}
var row string var row string
pos := fmt.Sprintf("%d", r.Position)
if r.DNF {
pos = "DNF"
} else if r.DNS {
pos = "DNS"
} else if r.DSQ {
pos = "DSQ"
}
if isRace { if isRace {
row = fmt.Sprintf("%s %s %s %s %s %s", gap := formatGap(r.GapToLeader)
padLeft(pos, wPos), var gapStyled string
padRight(acronym, wDRV), if gap == "LEADER" {
teamStr, gapStyled = styleLeader.Render("LEADER")
padLeft(fmt.Sprintf("%d", r.NumberOfLaps), wLaps), } else {
padLeft(formatGap(r.GapToLeader), wGap), gapStyled = styleGap.Render(gap)
padLeft(fmt.Sprintf("%.0f", r.Points), wPts), }
)
if compact {
row = fmt.Sprintf(" %s %s %s %s %s",
padRightVisible(pos, 3),
colorBar,
padRight(acronym, 4),
padLeftVisible(gapStyled, 10),
padLeft(fmt.Sprintf("%.0f", r.Points), 4),
)
} else {
row = fmt.Sprintf(" %s %s %s %s %s %s %s",
padRightVisible(pos, 3),
colorBar,
padRight(acronym, 4),
padRight(truncate(d.TeamName, teamWidth), teamWidth),
padLeft(fmt.Sprintf("%d", r.NumberOfLaps), 4),
padLeftVisible(gapStyled, 12),
padLeft(fmt.Sprintf("%.0f", r.Points), 4),
)
}
} else { } else {
row = fmt.Sprintf("%s %s %s %s", dur := formatDuration(r.Duration)
padLeft(pos, wPos), if compact {
padRight(acronym, wDRV), row = fmt.Sprintf(" %s %s %s %s",
teamStr, padRightVisible(pos, 3),
padLeft(formatDuration(r.Duration), wGap), colorBar,
) padRight(acronym, 4),
padLeft(dur, 12),
)
} else {
row = fmt.Sprintf(" %s %s %s %s %s",
padRightVisible(pos, 3),
colorBar,
padRight(acronym, 4),
padRight(truncate(d.TeamName, teamWidth), teamWidth),
padLeft(dur, 12),
)
}
} }
if r.DNF || r.DNS || r.DSQ { if i == m.resultsCursor {
row = styleMuted.Render(row) row = styleSelected.Render(row)
} }
rows = append(rows, row) sb.WriteString(row + "\n")
} }
return strings.Join(rows, "\n") return sb.String()
} }
func (m RaceDetailModel) renderRight(width int) string { func (m RaceDetailModel) renderRightPanel(width int) string {
var sb strings.Builder var sb strings.Builder
// Weather card at the top
sb.WriteString(m.renderWeatherCard(width))
sb.WriteString("\n")
// Overtakes summary
sb.WriteString(m.renderOvertakes(width))
sb.WriteString("\n")
// Race control // Race control
sb.WriteString(styleHeader.Render("Race Control") + "\n") sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
if !m.rcReady || m.selectedSession == nil { if !m.rcReady || m.selectedSession == nil {
sb.WriteString(styleMuted.Render(" No session selected.\n")) sb.WriteString(styleMuted.Render(" No session selected.\n"))
} else { } else {
sb.WriteString(m.rcView.View() + "\n") sb.WriteString(m.rcView.View() + "\n")
} }
// Weather strip
sb.WriteString("\n" + styleHeader.Render("Weather") + "\n")
sb.WriteString(m.renderWeather(width))
return sb.String() return sb.String()
} }
@@ -405,52 +642,107 @@ func (m RaceDetailModel) renderRaceControlContent() string {
} }
} }
var flagStyle lipgloss.Style // Category-specific icon and styling
switch rc.Flag { var prefix string
case models.FlagGreen: switch rc.Category {
flagStyle = styleFlagGreen case models.CategorySafetyCar:
case models.FlagYellow, models.FlagDoubleYellow: prefix = styleSafetyCar.Render(fmt.Sprintf(" ⚠ [%s]", t))
flagStyle = styleFlagYellow case models.CategoryDRS:
case models.FlagRed: prefix = styleDRS.Render(fmt.Sprintf(" ▸ [%s]", t))
flagStyle = styleFlagRed
case models.FlagBlue:
flagStyle = styleFlagBlue
default: default:
flagStyle = styleMuted // Flag-based coloring
var flagStyle lipgloss.Style
switch rc.Flag {
case models.FlagGreen:
flagStyle = styleFlagGreen
case models.FlagYellow, models.FlagDoubleYellow:
flagStyle = styleFlagYellow
case models.FlagRed:
flagStyle = styleFlagRed
case models.FlagBlue:
flagStyle = styleFlagBlue
case models.FlagChequered:
flagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true)
default:
flagStyle = styleMuted
}
icon := " "
switch rc.Flag {
case models.FlagGreen:
icon = "🟢"
case models.FlagYellow:
icon = "🟡"
case models.FlagDoubleYellow:
icon = "🟡"
case models.FlagRed:
icon = "🔴"
case models.FlagBlue:
icon = "🔵"
case models.FlagChequered:
icon = "🏁"
default:
icon = " "
}
prefix = flagStyle.Render(fmt.Sprintf(" %s [%s]", icon, t))
} }
prefix := flagStyle.Render(fmt.Sprintf("[%s]", t))
lines = append(lines, fmt.Sprintf("%s %s", prefix, rc.Message)) lines = append(lines, fmt.Sprintf("%s %s", prefix, rc.Message))
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func (m RaceDetailModel) renderWeather(width int) string { func (m RaceDetailModel) renderWeatherCard(width int) string {
var sb strings.Builder
sb.WriteString(styleSectionTitle.Render("WEATHER") + "\n")
if len(m.weather) == 0 { if len(m.weather) == 0 {
return styleMuted.Render(" No weather data.") sb.WriteString(styleMuted.Render(" No weather data.\n"))
return sb.String()
} }
// Use the latest weather snapshot
w := m.weather[len(m.weather)-1] w := m.weather[len(m.weather)-1]
rain := "Dry" // Weather conditions
var condStr string
if w.Rainfall > 0 { if w.Rainfall > 0 {
rain = styleFlagBlue.Render("Rain") condStr = styleRain.Render("🌧 Rain")
} else {
condStr = styleDry.Render("☀ Dry")
} }
return fmt.Sprintf(" Air: %.1f°C Track: %.1f°C %s Humidity: %.0f%% Wind: %s %.1fm/s", sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s %s %s %s%.1fm/s\n",
w.AirTemperature, w.TrackTemperature, rain, condStr,
w.Humidity, windArrow(w.WindDirection), w.WindSpeed) styleWeatherLabel.Render("Air:"),
styleWeatherValue.Render(fmt.Sprintf("%.1f°C", w.AirTemperature)),
styleWeatherLabel.Render("Track:"),
styleWeatherValue.Render(fmt.Sprintf("%.1f°C", w.TrackTemperature)),
styleWeatherLabel.Render("Humidity:"),
styleWeatherValue.Render(fmt.Sprintf("%.0f%%", w.Humidity)),
styleWeatherLabel.Render("Wind:"),
styleWeatherValue.Render(windArrow(w.WindDirection)+" "),
w.WindSpeed,
))
return sb.String()
} }
// SetSize updates the model's dimensions and initialises the race control viewport.
func (m *RaceDetailModel) SetSize(w, h int) { func (m *RaceDetailModel) SetSize(w, h int) {
m.width = w m.width = w
m.height = h m.height = h
// Right panel, minus header/weather/borders if w < 40 {
rightWidth := int(float64(w)*0.45) - 6 w = 40
rcHeight := h - 12 // approximate: title + sessions + header + weather + help }
compact := w < 100
var rightWidth, rcHeight int
if compact {
rightWidth = w - 6
rcHeight = 8
} else {
rightWidth = int(float64(w)*0.45) - 8
rcHeight = h - 14
}
if rcHeight < 3 { if rcHeight < 3 {
rcHeight = 3 rcHeight = 3
} }
@@ -461,3 +753,98 @@ func (m *RaceDetailModel) SetSize(w, h int) {
m.rcView.Height = rcHeight m.rcView.Height = rcHeight
} }
} }
// renderOvertakes renders the overtakes summary section.
func (m RaceDetailModel) renderOvertakes(width int) string {
var sb strings.Builder
sb.WriteString(styleSectionTitle.Render("OVERTAKES") + "\n")
if len(m.overtakes) == 0 {
sb.WriteString(styleMuted.Render(" No overtake data.\n"))
return sb.String()
}
// Total count
sb.WriteString(fmt.Sprintf(" %s %s\n",
styleWeatherLabel.Render("Total:"),
lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Bold(true).Render(fmt.Sprintf("%d", len(m.overtakes))),
))
// Tally: which drivers overtook the most?
type driverOvertakes struct {
driverNum int
overtaking int // times this driver overtook someone
overtaken int // times this driver was overtaken
}
stats := make(map[int]*driverOvertakes)
for _, o := range m.overtakes {
if _, ok := stats[o.OvertakingDriverNumber]; !ok {
stats[o.OvertakingDriverNumber] = &driverOvertakes{driverNum: o.OvertakingDriverNumber}
}
stats[o.OvertakingDriverNumber].overtaking++
if _, ok := stats[o.OvertakenDriverNumber]; !ok {
stats[o.OvertakenDriverNumber] = &driverOvertakes{driverNum: o.OvertakenDriverNumber}
}
stats[o.OvertakenDriverNumber].overtaken++
}
// Find top overtakers (sorted by most overtakes made)
type rankedDriver struct {
driverNum int
overtaking int
overtaken int
}
var ranked []rankedDriver
for _, s := range stats {
ranked = append(ranked, rankedDriver{driverNum: s.driverNum, overtaking: s.overtaking, overtaken: s.overtaken})
}
// Sort by overtaking count descending
for i := 0; i < len(ranked); i++ {
for j := i + 1; j < len(ranked); j++ {
if ranked[j].overtaking > ranked[i].overtaking {
ranked[i], ranked[j] = ranked[j], ranked[i]
}
}
}
// Show top 5 overtakers
maxShow := 5
if len(ranked) < maxShow {
maxShow = len(ranked)
}
for i := 0; i < maxShow; i++ {
r := ranked[i]
d := m.drivers[r.driverNum]
acronym := d.NameAcronym
if acronym == "" {
acronym = fmt.Sprintf("#%d", r.driverNum)
}
teamColor := colorMuted
if d.TeamColour != "" {
teamColor = "#" + d.TeamColour
} else if d.TeamName != "" {
teamColor = teamColorFromName(d.TeamName)
}
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
overtakingStr := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorGreen)).Bold(true).
Render(fmt.Sprintf("+%d", r.overtaking))
overtakenStr := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Render(fmt.Sprintf("-%d", r.overtaken))
sb.WriteString(fmt.Sprintf(" %s %s %s %s\n",
colorBar,
padRight(acronym, 4),
overtakingStr,
overtakenStr,
))
}
return sb.String()
}

View File

@@ -23,15 +23,17 @@ type StandingsModel struct {
driverStandings []models.ChampionshipDriver driverStandings []models.ChampionshipDriver
teamStandings []models.ChampionshipTeam teamStandings []models.ChampionshipTeam
drivers map[int]models.Driver // driver_number Driver drivers map[int]models.Driver // driver_number -> Driver
view standingsView view standingsView
loading bool loading bool
err error err error
spinner spinner.Model spinner spinner.Model
year int
cursor int cursor int
scroll int
year int
width int width int
height int height int
} }
@@ -40,6 +42,7 @@ func NewStandingsModel(client *api.OpenF1Client, year int) StandingsModel {
s := spinner.New() s := spinner.New()
s.Spinner = spinner.MiniDot s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return StandingsModel{ return StandingsModel{
client: client, client: client,
view: standingsViewDriver, view: standingsViewDriver,
@@ -80,7 +83,13 @@ func fetchStandingsDrivers(client *api.OpenF1Client, sessionKey int) tea.Cmd {
} }
func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) { func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case spinner.TickMsg: case spinner.TickMsg:
if m.loading { if m.loading {
var cmd tea.Cmd var cmd tea.Cmd
@@ -95,7 +104,6 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
return m, nil return m, nil
} }
m.driverStandings = msg.standings m.driverStandings = msg.standings
// Phase 2: fetch drivers to join names
if len(msg.standings) > 0 { if len(msg.standings) > 0 {
return m, fetchStandingsDrivers(m.client, msg.standings[0].SessionKey) return m, fetchStandingsDrivers(m.client, msg.standings[0].SessionKey)
} }
@@ -121,47 +129,116 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
switch { switch {
case matchKey(msg, GlobalKeys.Retry):
if m.err != nil {
m.err = nil
m.loading = true
return m, m.Init()
}
case matchKey(msg, StandingsKeys.DriverView): case matchKey(msg, StandingsKeys.DriverView):
m.view = standingsViewDriver m.view = standingsViewDriver
m.cursor = 0 m.cursor = 0
m.scroll = 0
case matchKey(msg, StandingsKeys.ConstructorView): case matchKey(msg, StandingsKeys.ConstructorView):
m.view = standingsViewConstructor m.view = standingsViewConstructor
m.cursor = 0 m.cursor = 0
m.scroll = 0
case matchKey(msg, GlobalKeys.Up): case matchKey(msg, GlobalKeys.Up):
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
if m.cursor < m.scroll {
m.scroll = m.cursor
}
} }
case matchKey(msg, GlobalKeys.Down): case matchKey(msg, GlobalKeys.Down):
m.cursor++ maxIdx := m.itemCount() - 1
if m.cursor < maxIdx {
m.cursor++
visibleRows := m.visibleRows()
if m.cursor >= m.scroll+visibleRows {
m.scroll = m.cursor - visibleRows + 1
}
}
case matchKey(msg, GlobalKeys.GoTop):
m.cursor = 0
m.scroll = 0
case matchKey(msg, GlobalKeys.GoBottom):
maxIdx := m.itemCount() - 1
if maxIdx >= 0 {
m.cursor = maxIdx
visibleRows := m.visibleRows()
if m.cursor >= visibleRows {
m.scroll = m.cursor - visibleRows + 1
}
}
case matchKey(msg, GlobalKeys.HalfUp):
half := m.visibleRows() / 2
m.cursor -= half
if m.cursor < 0 {
m.cursor = 0
}
if m.cursor < m.scroll {
m.scroll = m.cursor
}
case matchKey(msg, GlobalKeys.HalfDown):
half := m.visibleRows() / 2
maxIdx := m.itemCount() - 1
m.cursor += half
if m.cursor > maxIdx {
m.cursor = maxIdx
}
visibleRows := m.visibleRows()
if m.cursor >= m.scroll+visibleRows {
m.scroll = m.cursor - visibleRows + 1
}
} }
} }
return m, nil return m, tea.Batch(cmds...)
}
func (m StandingsModel) itemCount() int {
if m.view == standingsViewDriver {
return len(m.driverStandings)
}
return len(m.teamStandings)
}
func (m StandingsModel) visibleRows() int {
rows := m.height - 12 // header + toggle + help + padding
if rows < 5 {
rows = 5
}
return rows
} }
func (m StandingsModel) View() string { func (m StandingsModel) View() string {
if m.loading { if m.loading {
return fmt.Sprintf("\n %s Loading %d championship standings", m.spinner.View(), m.year) return fmt.Sprintf("\n %s Loading %d championship standings...", m.spinner.View(), m.year)
} }
if m.err != nil { if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err)) return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) +
helpBar("r retry", "q quit")
} }
var sb strings.Builder var sb strings.Builder
// Year indicator // Title row with year and toggle
sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n") title := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorF1Red)).
Render(fmt.Sprintf(" FORMULA 1 %d CHAMPIONSHIP", m.year))
sb.WriteString(title + "\n\n")
// Toggle bar // Toggle bar
dStyle, cStyle := styleInactiveTab, styleInactiveTab dLabel, cLabel := " d Drivers ", " c Constructors "
if m.view == standingsViewDriver { if m.view == standingsViewDriver {
dStyle = styleActiveTab sb.WriteString(styleActiveTab.Render(dLabel))
sb.WriteString(styleInactiveTab.Render(cLabel))
} else { } else {
cStyle = styleActiveTab sb.WriteString(styleInactiveTab.Render(dLabel))
sb.WriteString(styleActiveTab.Render(cLabel))
} }
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top,
dStyle.Render("d Drivers"),
cStyle.Render("c Constructors"),
))
sb.WriteString("\n\n") sb.WriteString("\n\n")
if m.view == standingsViewDriver { if m.view == standingsViewDriver {
@@ -171,122 +248,206 @@ func (m StandingsModel) View() string {
} }
sb.WriteString("\n") sb.WriteString("\n")
sb.WriteString(helpBar("y season", "d drivers", "c constructors", "j/k navigate", "q quit")) sb.WriteString(helpBar("y season", "d drivers", "c constructors", "j/k navigate", "g/G top/bottom", "^d/^u page", "q quit"))
return sb.String() return sb.String()
} }
func (m StandingsModel) renderDriverStandings() string { func (m StandingsModel) renderDriverStandings() string {
if len(m.driverStandings) == 0 { if len(m.driverStandings) == 0 {
return styleMuted.Render(" No standings data available.") return styleMuted.Render(" No standings data available.\n")
} }
// Column widths var sb strings.Builder
const ( maxPoints := m.driverStandings[0].PointsCurrent
wPos = 4 w := m.width
wAcronym = 5 if w < 40 {
wName = 22 w = 40
wTeam = 22
wPoints = 8
wDelta = 5
)
header := styleBold.Render(
padRight("Pos", wPos) + " " +
padRight("DRV", wAcronym) + " " +
padRight("Name", wName) + " " +
padRight("Team", wTeam) + " " +
padLeft("Pts", wPoints) + " " +
padLeft("Δ", wDelta),
)
var rows []string
rows = append(rows, header)
maxCursor := len(m.driverStandings) - 1
if m.cursor > maxCursor {
_ = maxCursor // cursor clamping happens in Update
} }
for i, s := range m.driverStandings { // Responsive column widths
compact := w < 80
nameWidth := 20
teamWidth := 20
barWidth := 20
if w >= 130 {
nameWidth = 24
teamWidth = 24
barWidth = 35
} else if w >= 100 {
barWidth = 25
} else if compact {
nameWidth = 0 // hide full name in compact mode
teamWidth = 14
barWidth = 12
}
visible := m.visibleRows()
endIdx := m.scroll + visible
if endIdx > len(m.driverStandings) {
endIdx = len(m.driverStandings)
}
// Header
var header string
if compact {
header = fmt.Sprintf(" %s %s %s %s %s %s",
padRight("POS", 3),
padRight("", 2),
padRight("", 1),
padRight("DRV", 4),
padRight("TEAM", teamWidth),
padLeft("PTS", 5),
)
} else {
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s",
padRight("POS", 4),
padRight("", 2),
padRight("", 1),
padRight("DRV", 4),
padRight("DRIVER", nameWidth),
padRight("TEAM", teamWidth),
padLeft("PTS", 6),
padRight("", barWidth),
)
}
sb.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Bold(true).
Render(header) + "\n")
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
for i := m.scroll; i < endIdx; i++ {
s := m.driverStandings[i]
d, ok := m.drivers[s.DriverNumber] d, ok := m.drivers[s.DriverNumber]
acronym, name, team, teamColor := "---", "Unknown Driver", "Unknown Team", "" acronym, name, team, teamColor := "---", "Unknown", "Unknown", colorMuted
if ok { if ok {
acronym = d.NameAcronym acronym = d.NameAcronym
name = d.FullName name = d.FullName
team = d.TeamName team = d.TeamName
teamColor = d.TeamColour if d.TeamColour != "" {
teamColor = "#" + d.TeamColour
} else {
teamColor = teamColorFromName(d.TeamName)
}
} }
delta := renderDelta(s.PositionCurrent, s.PositionStart) delta := renderDelta(s.PositionCurrent, s.PositionStart)
pos := renderPosition(s.PositionCurrent)
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
pointsBar := renderPointsBar(s.PointsCurrent, maxPoints, barWidth, teamColor)
teamStr := hexToStyle(teamColor).Render(truncate(team, wTeam)) var row string
// Pad team to width (lipgloss rendering may shift widths, so use padRight on plain string then style) if compact {
teamPlain := padRight(truncate(team, wTeam), wTeam) row = fmt.Sprintf(" %s %s %s %s %s %s",
teamStr = hexToStyle(teamColor).Render(teamPlain) padRightVisible(pos, 3),
delta,
row := fmt.Sprintf("%s %s %s %s %s %s", colorBar,
padLeft(fmt.Sprintf("%d", s.PositionCurrent), wPos), padRight(acronym, 4),
padRight(acronym, wAcronym), padRight(truncate(team, teamWidth), teamWidth),
padRight(truncate(name, wName), wName), padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 5),
teamStr, )
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), wPoints), } else {
delta, row = fmt.Sprintf(" %s %s %s %s %s %s %s %s",
) padRightVisible(pos, 4),
delta,
colorBar,
padRight(acronym, 4),
padRight(truncate(name, nameWidth), nameWidth),
padRight(truncate(team, teamWidth), teamWidth),
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6),
pointsBar,
)
}
if i == m.cursor { if i == m.cursor {
row = styleSelected.Render(row) row = styleSelected.Render(row)
} }
rows = append(rows, row)
sb.WriteString(row + "\n")
} }
return strings.Join(rows, "\n") // Scroll indicator
if len(m.driverStandings) > visible {
sb.WriteString(styleMuted.Render(fmt.Sprintf(" Showing %d-%d of %d", m.scroll+1, endIdx, len(m.driverStandings))) + "\n")
}
return sb.String()
} }
func (m StandingsModel) renderTeamStandings() string { func (m StandingsModel) renderTeamStandings() string {
if len(m.teamStandings) == 0 { if len(m.teamStandings) == 0 {
return styleMuted.Render(" No constructor standings available.") return styleMuted.Render(" No standings data available.\n")
} }
const ( var sb strings.Builder
wPos = 4 maxPoints := m.teamStandings[0].PointsCurrent
wTeam = 30 w := m.width
wPoints = 8 if w < 40 {
wDelta = 5 w = 40
}
// Responsive
compact := w < 80
teamWidth := 28
barWidth := 30
if w >= 130 {
barWidth = 50
} else if w >= 100 {
barWidth = 40
} else if compact {
teamWidth = 18
barWidth = 15
}
visible := m.visibleRows()
endIdx := m.scroll + visible
if endIdx > len(m.teamStandings) {
endIdx = len(m.teamStandings)
}
// Header
header := fmt.Sprintf(" %s %s %s %s %s %s",
padRight("POS", 4),
padRight("", 2),
padRight("", 1),
padRight("CONSTRUCTOR", teamWidth),
padLeft("PTS", 6),
padRight("", barWidth),
) )
sb.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Bold(true).
Render(header) + "\n")
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
header := styleBold.Render( for i := m.scroll; i < endIdx; i++ {
padRight("Pos", wPos) + " " + s := m.teamStandings[i]
padRight("Constructor", wTeam) + " " + teamColor := teamColorFromName(s.TeamName)
padLeft("Pts", wPoints) + " " +
padLeft("Δ", wDelta),
)
var rows []string
rows = append(rows, header)
for i, s := range m.teamStandings {
delta := renderDelta(s.PositionCurrent, s.PositionStart) delta := renderDelta(s.PositionCurrent, s.PositionStart)
row := fmt.Sprintf("%s %s %s %s", pos := renderPosition(s.PositionCurrent)
padLeft(fmt.Sprintf("%d", s.PositionCurrent), wPos), colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
padRight(truncate(s.TeamName, wTeam), wTeam), pointsBar := renderPointsBar(s.PointsCurrent, maxPoints, barWidth, teamColor)
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), wPoints),
row := fmt.Sprintf(" %s %s %s %s %s %s",
padRightVisible(pos, 4),
delta, delta,
colorBar,
padRight(truncate(s.TeamName, teamWidth), teamWidth),
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6),
pointsBar,
) )
if i == m.cursor { if i == m.cursor {
row = styleSelected.Render(row) row = styleSelected.Render(row)
} }
rows = append(rows, row)
sb.WriteString(row + "\n")
} }
return strings.Join(rows, "\n") if len(m.teamStandings) > visible {
} sb.WriteString(styleMuted.Render(fmt.Sprintf(" Showing %d-%d of %d", m.scroll+1, endIdx, len(m.teamStandings))) + "\n")
// matchKey checks if a KeyMsg matches a binding.
func matchKey(msg tea.KeyMsg, binding interface{ Keys() []string }) bool {
for _, k := range binding.Keys() {
if msg.String() == k {
return true
}
} }
return false
return sb.String()
} }

View File

@@ -4,23 +4,44 @@ import "github.com/charmbracelet/lipgloss"
// F1 brand colors // F1 brand colors
const ( const (
colorF1Red = "#E8002D" colorF1Red = "#E10600"
colorF1Black = "#15151E" colorF1Black = "#15151E"
colorSubtle = "#3C3C4A" colorSubtle = "#2A2A3C"
colorMuted = "#6B6B7A" colorMuted = "#6B6B7A"
colorWhite = "#FFFFFF" colorWhite = "#FFFFFF"
colorGreen = "#39B54A" colorGreen = "#00D26A"
colorYellow = "#FFD700"
colorOrange = "#FF8700"
colorCyan = "#00BFFF"
// Surfaces
colorSurface0 = "#1B1B2F"
colorSurface1 = "#222236"
colorSurface2 = "#2D2D44"
colorBorder = "#3C3C54"
// Tyre compounds // Tyre compounds
colorSoft = "#FF1E1E" colorSoft = "#FF3333"
colorMedium = "#FFD700" colorMedium = "#FFD700"
colorHard = "#EEEEEE" colorHard = "#CCCCCC"
colorInter = "#39B54A" colorInter = "#39B54A"
colorWet = "#0057FF" colorWet = "#0080FF"
// F1 team colors (2024/2025 season)
colorRedbull = "#3671C6"
colorMercedes = "#27F4D2"
colorFerrari = "#E8002D"
colorMclaren = "#FF8000"
colorAstonMartin = "#229971"
colorAlpine = "#FF87BC"
colorWilliams = "#64C4FF"
colorHaas = "#B6BABD"
colorRB = "#6692FF"
colorSauber = "#52E252"
) )
var ( var (
// Tab bar styles // ── Tab bar ──────────────────────────────────────────
styleActiveTab = lipgloss.NewStyle(). styleActiveTab = lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(lipgloss.Color(colorWhite)). Foreground(lipgloss.Color(colorWhite)).
@@ -29,18 +50,28 @@ var (
styleInactiveTab = lipgloss.NewStyle(). styleInactiveTab = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)). Foreground(lipgloss.Color(colorMuted)).
Background(lipgloss.Color(colorSurface0)).
Padding(0, 2) Padding(0, 2)
styleTabBar = lipgloss.NewStyle(). styleTabBar = lipgloss.NewStyle().
Background(lipgloss.Color(colorF1Black)). Background(lipgloss.Color(colorSurface0))
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true).
BorderForeground(lipgloss.Color(colorSubtle))
// Panel / border styles // Tab bar accent stripe
styleTabStripe = lipgloss.NewStyle().
Background(lipgloss.Color(colorF1Red)).
Foreground(lipgloss.Color(colorF1Red))
// Year badge in tab bar
styleYearBadge = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorYellow)).
Background(lipgloss.Color(colorSurface0)).
Padding(0, 1)
// ── Panel / border styles ────────────────────────────
stylePanelBorder = lipgloss.NewStyle(). stylePanelBorder = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(colorSubtle)). BorderForeground(lipgloss.Color(colorBorder)).
Padding(0, 1) Padding(0, 1)
styleActivePanelBorder = lipgloss.NewStyle(). styleActivePanelBorder = lipgloss.NewStyle().
@@ -48,8 +79,8 @@ var (
BorderForeground(lipgloss.Color(colorF1Red)). BorderForeground(lipgloss.Color(colorF1Red)).
Padding(0, 1) Padding(0, 1)
// Text styles // ── Text styles ──────────────────────────────────────
styleBold = lipgloss.NewStyle().Bold(true) styleBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorWhite))
styleMuted = lipgloss.NewStyle(). styleMuted = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)) Foreground(lipgloss.Color(colorMuted))
@@ -61,10 +92,16 @@ var (
styleHeader = lipgloss.NewStyle(). styleHeader = lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(lipgloss.Color(colorWhite)). Foreground(lipgloss.Color(colorWhite)).
Background(lipgloss.Color(colorSubtle)). Background(lipgloss.Color(colorSurface2)).
Padding(0, 1) Padding(0, 1).
MarginBottom(0)
// Delta styles styleSectionTitle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorF1Red)).
PaddingLeft(1)
// ── Delta styles ─────────────────────────────────────
styleDeltaUp = lipgloss.NewStyle(). styleDeltaUp = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorGreen)). Foreground(lipgloss.Color(colorGreen)).
Bold(true) Bold(true)
@@ -76,28 +113,154 @@ var (
styleDeltaEqual = lipgloss.NewStyle(). styleDeltaEqual = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)) Foreground(lipgloss.Color(colorMuted))
// Selected row style // ── Selected row style ───────────────────────────────
styleSelected = lipgloss.NewStyle(). styleSelected = lipgloss.NewStyle().
Background(lipgloss.Color(colorSubtle)). Background(lipgloss.Color(colorSurface2)).
Foreground(lipgloss.Color(colorWhite)).
Bold(true) Bold(true)
// Status indicators // ── Status indicators ────────────────────────────────
stylePast = lipgloss.NewStyle(). stylePast = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)) Foreground(lipgloss.Color(colorGreen))
styleNext = lipgloss.NewStyle(). styleNext = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)). Foreground(lipgloss.Color(colorF1Red)).
Bold(true) Bold(true)
// Flag colors for race control styleFuture = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted))
// ── Flag colors for race control ─────────────────────
styleFlagGreen = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)) styleFlagGreen = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
styleFlagYellow = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)) styleFlagYellow = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
styleFlagRed = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) styleFlagRed = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
styleFlagBlue = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet)) styleFlagBlue = lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan))
styleFlagWhite = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)) styleFlagWhite = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
// Help bar // Race control category styles
styleSafetyCar = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorOrange)).
Bold(true)
styleDRS = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorCyan))
// ── Help bar ─────────────────────────────────────────
styleHelp = lipgloss.NewStyle(). styleHelp = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)). Foreground(lipgloss.Color(colorMuted)).
Background(lipgloss.Color(colorSurface0)).
Padding(0, 1) Padding(0, 1)
styleHelpKey = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorWhite)).
Bold(true)
styleHelpDesc = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted))
// ── Status bar ───────────────────────────────────────
styleStatusBar = lipgloss.NewStyle().
Background(lipgloss.Color(colorSurface0)).
Foreground(lipgloss.Color(colorWhite)).
Padding(0, 1)
styleStatusLabel = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted))
styleStatusValue = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorWhite)).
Bold(true)
styleCountdown = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorYellow)).
Bold(true)
// ── Points bar ───────────────────────────────────────
stylePointsBarFilled = lipgloss.NewStyle().
Background(lipgloss.Color(colorF1Red)).
Foreground(lipgloss.Color(colorF1Red))
stylePointsBarEmpty = lipgloss.NewStyle().
Background(lipgloss.Color(colorSurface2)).
Foreground(lipgloss.Color(colorSurface2))
// ── Session pill ─────────────────────────────────────
styleSessionActive = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorWhite)).
Background(lipgloss.Color(colorF1Red)).
Bold(true).
Padding(0, 1)
styleSessionInactive = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Background(lipgloss.Color(colorSurface1)).
Padding(0, 1)
styleSessionCursor = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorWhite)).
Background(lipgloss.Color(colorSurface2)).
Bold(true).
Padding(0, 1)
// ── Driver card ──────────────────────────────────────
styleDriverNumber = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorWhite)).
Padding(0, 1)
styleDriverName = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorWhite))
styleTeamName = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted))
// ── Misc ─────────────────────────────────────────────
stylePositionFirst = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorYellow)).
Bold(true)
stylePositionSecond = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorHard))
stylePositionThird = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorOrange))
styleDNF = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Bold(true)
styleLeader = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorYellow)).
Bold(true)
styleGap = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted))
// Base Table styles
styleTableBase = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color(colorBorder))
// Weather card
styleWeatherLabel = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted))
styleWeatherValue = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorWhite)).
Bold(true)
styleRain = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorCyan)).
Bold(true)
styleDry = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorGreen))
) )
// DefaultTableStyles returns a base style map for bubbles/table.
func DefaultTableStyles() tableStyles {
return tableStyles{}
}
type tableStyles struct{}

View File

@@ -8,6 +8,7 @@ import (
"unicode/utf8" "unicode/utf8"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -35,7 +36,6 @@ func formatGap(v interface{}) string {
case string: case string:
return val return val
case []interface{}: case []interface{}:
// Qualifying: return best Q time
if len(val) == 0 { if len(val) == 0 {
return "--" return "--"
} }
@@ -56,7 +56,6 @@ func formatDuration(v interface{}) string {
case float64: case float64:
return formatSeconds(val) return formatSeconds(val)
case []interface{}: case []interface{}:
// Qualifying: return best Q time
if len(val) == 0 { if len(val) == 0 {
return "--" return "--"
} }
@@ -69,7 +68,6 @@ func formatDuration(v interface{}) string {
} }
// hexToStyle creates a lipgloss.Style with the given hex color as the foreground. // hexToStyle creates a lipgloss.Style with the given hex color as the foreground.
// The hex string may or may not have a leading '#'.
func hexToStyle(hex string) lipgloss.Style { func hexToStyle(hex string) lipgloss.Style {
if hex == "" { if hex == "" {
return lipgloss.NewStyle() return lipgloss.NewStyle()
@@ -80,6 +78,17 @@ func hexToStyle(hex string) lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color(hex)) return lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
} }
// hexToBgStyle creates a lipgloss.Style with the given hex color as the background.
func hexToBgStyle(hex string) lipgloss.Style {
if hex == "" {
return lipgloss.NewStyle()
}
if !strings.HasPrefix(hex, "#") {
hex = "#" + hex
}
return lipgloss.NewStyle().Background(lipgloss.Color(hex)).Foreground(lipgloss.Color(colorWhite))
}
// sparkline generates a unicode block chart for lap times. // sparkline generates a unicode block chart for lap times.
// Pit laps (nil duration) are rendered as spaces. // Pit laps (nil duration) are rendered as spaces.
func sparkline(laps []models.Lap, width int) string { func sparkline(laps []models.Lap, width int) string {
@@ -87,7 +96,7 @@ func sparkline(laps []models.Lap, width int) string {
blockRunes := []rune(blocks) blockRunes := []rune(blocks)
if len(laps) == 0 { if len(laps) == 0 {
return strings.Repeat(" ", width) return styleMuted.Render(strings.Repeat("·", width))
} }
// Collect valid lap durations // Collect valid lap durations
@@ -120,7 +129,7 @@ func sparkline(laps []models.Lap, width int) string {
break break
} }
if d < 0 { if d < 0 {
sb.WriteRune(' ') sb.WriteString(styleMuted.Render("·"))
} else { } else {
norm := (d - minDur) / rng norm := (d - minDur) / rng
// Invert: fast laps = tall bar (higher index) // Invert: fast laps = tall bar (higher index)
@@ -131,16 +140,26 @@ func sparkline(laps []models.Lap, width int) string {
if idx >= len(blockRunes) { if idx >= len(blockRunes) {
idx = len(blockRunes) - 1 idx = len(blockRunes) - 1
} }
sb.WriteRune(blockRunes[idx]) // Color based on performance: fast=green, mid=yellow, slow=red
var blockStyle lipgloss.Style
switch {
case norm < 0.25:
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
case norm < 0.5:
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
case norm < 0.75:
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange))
default:
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
}
sb.WriteString(blockStyle.Render(string(blockRunes[idx])))
} }
count++ count++
} }
result := sb.String() result := sb.String()
resultLen := utf8.RuneCountInString(result) resultLen := utf8.RuneCountInString(lipgloss.NewStyle().Render(result))
if resultLen < width { _ = resultLen
result += strings.Repeat(" ", width-resultLen)
}
return result return result
} }
@@ -169,6 +188,24 @@ func tyreStyle(c models.TyreCompound) lipgloss.Style {
} }
} }
// tyreBgStyle returns a lipgloss.Style with background for tyre compound.
func tyreBgStyle(c models.TyreCompound) lipgloss.Style {
switch c {
case models.CompoundSoft:
return lipgloss.NewStyle().Background(lipgloss.Color(colorSoft)).Foreground(lipgloss.Color("#000000")).Bold(true)
case models.CompoundMedium:
return lipgloss.NewStyle().Background(lipgloss.Color(colorMedium)).Foreground(lipgloss.Color("#000000")).Bold(true)
case models.CompoundHard:
return lipgloss.NewStyle().Background(lipgloss.Color(colorHard)).Foreground(lipgloss.Color("#000000")).Bold(true)
case models.CompoundIntermediate:
return lipgloss.NewStyle().Background(lipgloss.Color(colorInter)).Foreground(lipgloss.Color("#000000")).Bold(true)
case models.CompoundWet:
return lipgloss.NewStyle().Background(lipgloss.Color(colorWet)).Foreground(lipgloss.Color(colorWhite)).Bold(true)
default:
return lipgloss.NewStyle().Background(lipgloss.Color(colorSurface2)).Foreground(lipgloss.Color(colorMuted))
}
}
// tyreAbbrev returns a single-letter abbreviation for a tyre compound. // tyreAbbrev returns a single-letter abbreviation for a tyre compound.
func tyreAbbrev(c models.TyreCompound) string { func tyreAbbrev(c models.TyreCompound) string {
switch c { switch c {
@@ -187,25 +224,72 @@ func tyreAbbrev(c models.TyreCompound) string {
} }
} }
// renderDelta returns a colored ▲N/▼N/= string for position change. // renderDelta returns a colored position change indicator, always 2 visible columns wide.
func renderDelta(current, start int) string { func renderDelta(current, start int) string {
diff := start - current // positive = gained positions diff := start - current // positive = gained positions
switch { switch {
case diff > 0: case diff > 0:
return styleDeltaUp.Render(fmt.Sprintf("▲%d", diff)) s := fmt.Sprintf("▲%d", diff)
return padRightVisible(styleDeltaUp.Render(s), 2)
case diff < 0: case diff < 0:
return styleDeltaDown.Render(fmt.Sprintf("▼%d", -diff)) s := fmt.Sprintf("▼%d", -diff)
return padRightVisible(styleDeltaDown.Render(s), 2)
default: default:
return styleDeltaEqual.Render("=") return padRightVisible(styleDeltaEqual.Render("─"), 2)
} }
} }
// renderPosition formats a position with podium coloring.
func renderPosition(pos int) string {
s := fmt.Sprintf("%d", pos)
switch pos {
case 1:
return stylePositionFirst.Render(s)
case 2:
return stylePositionSecond.Render(s)
case 3:
return stylePositionThird.Render(s)
default:
return s
}
}
// renderPointsBar draws a horizontal progress bar for points.
func renderPointsBar(points, maxPoints float64, width int, color string) string {
if maxPoints == 0 || width <= 0 {
return ""
}
ratio := points / maxPoints
filled := int(ratio * float64(width))
if filled > width {
filled = width
}
if filled < 0 {
filled = 0
}
barStyle := lipgloss.NewStyle().
Background(lipgloss.Color(color)).
Foreground(lipgloss.Color(color))
emptyStyle := lipgloss.NewStyle().
Background(lipgloss.Color(colorSurface2)).
Foreground(lipgloss.Color(colorSurface2))
bar := ""
if filled > 0 {
bar += barStyle.Render(strings.Repeat("━", filled))
}
remaining := width - filled
if remaining > 0 {
bar += emptyStyle.Render(strings.Repeat("━", remaining))
}
return bar
}
// meetingStatus returns a status indicator for a meeting. // meetingStatus returns a status indicator for a meeting.
// isNext should be true only for the first upcoming meeting.
func meetingStatus(m models.Meeting, now time.Time, isNext bool) string { func meetingStatus(m models.Meeting, now time.Time, isNext bool) string {
end, err := time.Parse(time.RFC3339, m.DateEnd) end, err := time.Parse(time.RFC3339, m.DateEnd)
if err != nil { if err != nil {
// Fallback: try date-only parsing
end, err = time.Parse("2006-01-02", m.DateEnd[:min(len(m.DateEnd), 10)]) end, err = time.Parse("2006-01-02", m.DateEnd[:min(len(m.DateEnd), 10)])
if err != nil { if err != nil {
return " " return " "
@@ -217,9 +301,9 @@ func meetingStatus(m models.Meeting, now time.Time, isNext bool) string {
return stylePast.Render("✓") return stylePast.Render("✓")
} }
if isNext { if isNext {
return styleNext.Render("") return styleNext.Render("")
} }
return " " return styleFuture.Render("○")
} }
// truncate shortens a string to max runes, appending "…" if truncated. // truncate shortens a string to max runes, appending "…" if truncated.
@@ -234,7 +318,8 @@ func truncate(s string, max int) string {
return string(runes[:max-1]) + "…" return string(runes[:max-1]) + "…"
} }
// padRight pads or truncates a string to exactly width runes. // padRight pads or truncates a plain string to exactly width runes.
// Only use this for strings that contain no ANSI escape codes.
func padRight(s string, width int) string { func padRight(s string, width int) string {
runes := []rune(s) runes := []rune(s)
if len(runes) >= width { if len(runes) >= width {
@@ -243,7 +328,8 @@ func padRight(s string, width int) string {
return s + strings.Repeat(" ", width-len(runes)) return s + strings.Repeat(" ", width-len(runes))
} }
// padLeft left-pads a string to exactly width runes. // padLeft left-pads a plain string to exactly width runes.
// Only use this for strings that contain no ANSI escape codes.
func padLeft(s string, width int) string { func padLeft(s string, width int) string {
runes := []rune(s) runes := []rune(s)
if len(runes) >= width { if len(runes) >= width {
@@ -252,6 +338,26 @@ func padLeft(s string, width int) string {
return strings.Repeat(" ", width-len(runes)) + s return strings.Repeat(" ", width-len(runes)) + s
} }
// padRightVisible pads an ANSI-styled string to exactly width visible columns.
// Uses lipgloss.Width to measure the visible display width, ignoring escape codes.
func padRightVisible(s string, width int) string {
vis := lipgloss.Width(s)
if vis >= width {
return s
}
return s + strings.Repeat(" ", width-vis)
}
// padLeftVisible left-pads an ANSI-styled string to exactly width visible columns.
// Uses lipgloss.Width to measure the visible display width, ignoring escape codes.
func padLeftVisible(s string, width int) string {
vis := lipgloss.Width(s)
if vis >= width {
return s
}
return strings.Repeat(" ", width-vis) + s
}
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a
@@ -266,21 +372,90 @@ func max(a, b int) int {
return b return b
} }
// countryFlag converts a country flag URL or code to an emoji flag. // countryFlag converts a country code to an emoji flag.
// OpenF1 provides flag URLs; we do a best-effort mapping from country_code.
func countryFlag(countryCode string) string { func countryFlag(countryCode string) string {
// Convert ISO 3166-1 alpha-2 to emoji regional indicators
code := strings.ToUpper(countryCode) code := strings.ToUpper(countryCode)
if len(code) != 2 { if len(code) != 2 {
return " " return " "
} }
// Each letter maps to a regional indicator symbol (U+1F1E6 = 'A')
r1 := rune(0x1F1E6 + int(code[0]-'A')) r1 := rune(0x1F1E6 + int(code[0]-'A'))
r2 := rune(0x1F1E6 + int(code[1]-'A')) r2 := rune(0x1F1E6 + int(code[1]-'A'))
return string(r1) + string(r2) return string(r1) + string(r2)
} }
// helpBar renders a horizontal help bar for a set of key hints. // helpBar renders a styled help bar with key/description pairs.
// Format: "key:description" pairs separated by spaces in the output.
func helpBar(hints ...string) string { func helpBar(hints ...string) string {
return styleHelp.Render(strings.Join(hints, " ")) var parts []string
for _, h := range hints {
// Split on first space: "key description"
idx := strings.Index(h, " ")
if idx > 0 {
key := h[:idx]
desc := h[idx+1:]
parts = append(parts, styleHelpKey.Render(key)+" "+styleHelpDesc.Render(desc))
} else {
parts = append(parts, styleHelpDesc.Render(h))
}
}
bar := strings.Join(parts, styleMuted.Render(" │ "))
return styleHelp.Render(bar)
}
// divider renders a subtle horizontal divider line.
func divider(width int) string {
if width <= 0 {
width = 40
}
return styleMuted.Render(strings.Repeat("─", width))
}
// matchKey checks if a KeyMsg matches a binding.
func matchKey(msg tea.KeyMsg, binding interface{ Keys() []string }) bool {
for _, k := range binding.Keys() {
if msg.String() == k {
return true
}
}
return false
}
// teamColorFromName returns the known team color hex for a team name, or a fallback.
func teamColorFromName(teamName string) string {
name := strings.ToLower(teamName)
switch {
case strings.Contains(name, "red bull") && !strings.Contains(name, "racing bulls"):
return colorRedbull
case strings.Contains(name, "mercedes"):
return colorMercedes
case strings.Contains(name, "ferrari"):
return colorFerrari
case strings.Contains(name, "mclaren"):
return colorMclaren
case strings.Contains(name, "aston martin"):
return colorAstonMartin
case strings.Contains(name, "alpine"):
return colorAlpine
case strings.Contains(name, "williams"):
return colorWilliams
case strings.Contains(name, "haas"):
return colorHaas
case strings.Contains(name, "racing bulls"), strings.Contains(name, "alphatauri"), strings.Contains(name, "rb "):
return colorRB
case strings.Contains(name, "sauber"), strings.Contains(name, "alfa romeo"), strings.Contains(name, "stake"):
return colorSauber
default:
return colorMuted
}
}
// teamColorBar renders a thin colored bar (e.g., "█") for team identity.
func teamColorBar(teamColor string) string {
if teamColor == "" {
teamColor = colorMuted
}
if !strings.HasPrefix(teamColor, "#") {
teamColor = "#" + teamColor
}
return lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
} }