From 95a4f755cbe37bf76c53e96623cc3772e1dc9ca1 Mon Sep 17 00:00:00 2001 From: amantahiliani Date: Tue, 3 Mar 2026 02:29:34 -0500 Subject: [PATCH] UI Refresh --- go.mod | 2 + go.sum | 4 + internal/api/cache.go | 114 +++++- internal/api/client.go | 10 + internal/ui/app.go | 313 ++++++++++++++-- internal/ui/calendar.go | 268 ++++++++++---- internal/ui/driver.go | 760 +++++++++++++++++++++++++++++++++----- internal/ui/keys.go | 77 +++- internal/ui/messages.go | 18 + internal/ui/racedetail.go | 727 +++++++++++++++++++++++++++--------- internal/ui/standings.go | 345 ++++++++++++----- internal/ui/styles.go | 215 +++++++++-- internal/ui/util.go | 227 ++++++++++-- 13 files changed, 2541 insertions(+), 539 deletions(-) diff --git a/go.mod b/go.mod index 4629db6..63d6a5c 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/AmanTahiliani/box-box go 1.25.6 require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbles v1.0.0 // 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/termenv v0.16.0 // 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 golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.3.8 // indirect diff --git a/go.sum b/go.sum index ae9cc6e..a1bcdef 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 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/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/api/cache.go b/internal/api/cache.go index 27047bb..76add63 100644 --- a/internal/api/cache.go +++ b/internal/api/cache.go @@ -5,12 +5,33 @@ import ( "encoding/hex" "os" "path/filepath" + "strings" + "sync/atomic" + "time" ) -type FileCache struct { - dir string +// CacheStats tracks cache hit/miss statistics. +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 { var cacheDir string userCacheDir, err := os.UserCacheDir() @@ -35,13 +56,56 @@ func (c *FileCache) getCachePath(key string) string { 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) { path := c.getCachePath(key) - data, err := os.ReadFile(path) + info, err := os.Stat(path) if err != nil { + atomic.AddInt64(&c.stats.Misses, 1) 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 } @@ -50,3 +114,45 @@ func (c *FileCache) Set(key string, data []byte) error { path := c.getCachePath(key) 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 +} diff --git a/internal/api/client.go b/internal/api/client.go index 3d5d8d1..85d897a 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -18,3 +18,13 @@ func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client { 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() +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 1d46a87..0da929f 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -3,8 +3,10 @@ package ui import ( "fmt" "strings" + "time" "github.com/AmanTahiliani/box-box/internal/api" + "github.com/AmanTahiliani/box-box/internal/models" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -19,7 +21,11 @@ const ( tabDriver tabIndex = 3 ) -var tabNames = []string{"1 Standings", "2 Calendar", "3 Race", "4 Drivers"} +var tabNames = []string{"Standings", "Calendar", "Race", "Drivers"} +var tabIcons = []string{"πŸ†", "πŸ“…", "🏁", "πŸ‘€"} + +// splashDoneMsg is sent after the splash screen duration has elapsed. +type splashDoneMsg struct{} // AppModel is the root Bubble Tea model. type AppModel struct { @@ -34,28 +40,48 @@ type AppModel struct { calendar CalendarModel raceDetail RaceDetailModel driver DriverModel + + meetings []models.Meeting + + // Splash screen state + showSplash bool + splashSpinner spinner.Model } -// NewAppModel creates the root model and wires sub-models. func NewAppModel(client *api.OpenF1Client) AppModel { year := 2025 + + sp := spinner.New() + sp.Spinner = spinner.Points + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) + return AppModel{ - client: client, - activeTab: tabStandings, - year: year, - standings: NewStandingsModel(client, year), - calendar: NewCalendarModel(client, year), - raceDetail: NewRaceDetailModel(client), - driver: NewDriverModel(client), + client: client, + activeTab: tabStandings, + year: year, + standings: NewStandingsModel(client, year), + calendar: NewCalendarModel(client, year), + raceDetail: NewRaceDetailModel(client), + driver: NewDriverModel(client), + showSplash: true, + splashSpinner: sp, } } +func splashTimer() tea.Cmd { + return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return splashDoneMsg{} + }) +} + func (m AppModel) Init() tea.Cmd { return tea.Batch( m.standings.Init(), m.calendar.Init(), m.raceDetail.Init(), m.driver.Init(), + m.splashSpinner.Tick, + splashTimer(), ) } @@ -66,46 +92,76 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - contentHeight := m.height - 3 // tab bar + help + contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing m.raceDetail.SetSize(m.width-4, contentHeight) + var cmd1, cmd2, cmd3, cmd4 tea.Cmd + m.standings, cmd1 = m.standings.Update(msg) + m.calendar, cmd2 = m.calendar.Update(msg) + m.raceDetail, cmd3 = m.raceDetail.Update(msg) + m.driver, cmd4 = m.driver.Update(msg) + return m, tea.Batch(cmd1, cmd2, cmd3, cmd4) + + case splashDoneMsg: + m.showSplash = false return m, nil case tea.KeyMsg: + // Any key press during splash dismisses it + if m.showSplash { + m.showSplash = false + return m, nil + } + // Global quit if matchKey(msg, GlobalKeys.Quit) { return m, tea.Quit } // Tab switching - switch msg.String() { - case "1": + switch { + case matchKey(msg, GlobalKeys.Tab1): m.activeTab = tabStandings return m, nil - case "2": + case matchKey(msg, GlobalKeys.Tab2): m.activeTab = tabCalendar return m, nil - case "3": + case matchKey(msg, GlobalKeys.Tab3): m.activeTab = tabRaceDetail return m, nil - case "4": + case matchKey(msg, GlobalKeys.Tab4): m.activeTab = tabDriver var cmd tea.Cmd m.driver, cmd = m.driver.TriggerLoad() cmds = append(cmds, cmd) return m, tea.Batch(cmds...) - case "b": - // Back from race detail β†’ calendar + case matchKey(msg, GlobalKeys.NextTab): + m.activeTab = (m.activeTab + 1) % 4 + if m.activeTab == tabDriver { + var cmd tea.Cmd + m.driver, cmd = m.driver.TriggerLoad() + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + case matchKey(msg, GlobalKeys.PrevTab): + m.activeTab = (m.activeTab - 1 + 4) % 4 + if m.activeTab == tabDriver { + var cmd tea.Cmd + m.driver, cmd = m.driver.TriggerLoad() + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + case matchKey(msg, GlobalKeys.Back): + // Back from race detail -> calendar if m.activeTab == tabRaceDetail { m.activeTab = tabCalendar return m, nil } - case "y": + case matchKey(msg, GlobalKeys.Year): // Cycle years: 2025 -> 2023 -> 2024 -> 2025 if m.year == 2025 { m.year = 2023 } else { m.year++ } - // Update sub-models and re-trigger fetches m.calendar.year = m.year m.calendar.loading = true m.standings.year = m.year @@ -118,24 +174,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case meetingSelectedMsg: - // Switch to race detail tab and forward the message m.activeTab = tabRaceDetail var cmd tea.Cmd m.raceDetail, cmd = m.raceDetail.Update(msg) cmds = append(cmds, cmd) - // Also forward to driver model for session key tracking m.driver, _ = m.driver.Update(msg) return m, tea.Batch(cmds...) case sessionsLoadedMsg: - // Forward to raceDetail and driver var cmd1, cmd2 tea.Cmd m.raceDetail, cmd1 = m.raceDetail.Update(msg) m.driver, cmd2 = m.driver.Update(msg) cmds = append(cmds, cmd1, cmd2) return m, tea.Batch(cmds...) - // Route all loaded messages to the appropriate sub-models case driverChampionshipLoadedMsg: var cmd tea.Cmd m.standings, cmd = m.standings.Update(msg) @@ -155,6 +207,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) case meetingsLoadedMsg: + if msg.err == nil { + m.meetings = msg.meetings + } var cmd tea.Cmd m.calendar, cmd = m.calendar.Update(msg) cmds = append(cmds, cmd) @@ -184,6 +239,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) return m, tea.Batch(cmds...) + case overtakesLoadedMsg: + var cmd tea.Cmd + m.raceDetail, cmd = m.raceDetail.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + case driverListLoadedMsg: var cmd tea.Cmd m.driver, cmd = m.driver.Update(msg) @@ -208,8 +269,24 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) return m, tea.Batch(cmds...) + case driverPositionsLoadedMsg: + var cmd tea.Cmd + m.driver, cmd = m.driver.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + + case driverTeamRadioLoadedMsg: + var cmd tea.Cmd + m.driver, cmd = m.driver.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + case spinner.TickMsg: - // Forward spinner ticks to all sub-models + if m.showSplash { + var cmd tea.Cmd + m.splashSpinner, cmd = m.splashSpinner.Update(msg) + cmds = append(cmds, cmd) + } var cmd1, cmd2, cmd3, cmd4 tea.Cmd m.standings, cmd1 = m.standings.Update(msg) m.calendar, cmd2 = m.calendar.Update(msg) @@ -243,8 +320,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m AppModel) View() string { + if m.showSplash { + return m.renderSplash() + } + + w := m.width + if w < 40 { + w = 40 + } + + // Red accent stripe at the very top (F1 style) + stripe := styleTabStripe.Render(strings.Repeat("β–”", w)) + // Tab bar - tabs := renderTabBar(m.activeTab, m.width) + tabs := renderTabBar(m.activeTab, m.year, w) // Content area var content string @@ -259,24 +348,178 @@ func (m AppModel) View() string { content = m.driver.View() } - return tabs + "\n" + content + statusBar := m.renderStatusBar(w) + + // Calculate how much vertical space is available for content + usedLines := 1 + 1 + 1 + 1 // stripe + tab bar + gap + status bar + contentHeight := m.height - usedLines + if contentHeight < 5 { + contentHeight = 5 + } + + // Trim content to fit available height + contentLines := strings.Split(content, "\n") + if len(contentLines) > contentHeight { + contentLines = contentLines[:contentHeight] + } + content = strings.Join(contentLines, "\n") + + return stripe + "\n" + tabs + "\n" + content + "\n" + statusBar } -func renderTabBar(active tabIndex, width int) string { +func renderTabBar(active tabIndex, year int, width int) string { + // Build the logo + logo := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorF1Red)). + Background(lipgloss.Color(colorSurface0)). + Padding(0, 1). + Render("F1") + + // Build tabs var tabs []string for i, name := range tabNames { + label := fmt.Sprintf(" %s %d %s ", tabIcons[i], i+1, name) if tabIndex(i) == active { - tabs = append(tabs, styleActiveTab.Render(name)) + tabs = append(tabs, styleActiveTab.Render(label)) } else { - tabs = append(tabs, styleInactiveTab.Render(name)) + tabs = append(tabs, styleInactiveTab.Render(label)) } } - bar := strings.Join(tabs, "") - // Pad remaining width - barWidth := lipgloss.Width(bar) - if barWidth < width { - bar += strings.Repeat(" ", width-barWidth) + // Year badge + yearBadge := styleYearBadge.Render(fmt.Sprintf(" %d ", year)) + + // Assemble: logo + tabs + spacer + year + left := logo + strings.Join(tabs, "") + leftWidth := lipgloss.Width(left) + yearWidth := lipgloss.Width(yearBadge) + spacerWidth := width - leftWidth - yearWidth + if spacerWidth < 0 { + spacerWidth = 0 } - return styleTabBar.Render(fmt.Sprintf("%s", bar)) + spacer := lipgloss.NewStyle(). + Background(lipgloss.Color(colorSurface0)). + Render(strings.Repeat(" ", spacerWidth)) + + bar := left + spacer + yearBadge + return styleTabBar.Render(bar) +} + +func (m AppModel) renderStatusBar(width int) string { + now := time.Now() + + // Left side: brand + leftParts := []string{ + lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorF1Red)). + Bold(true). + Render("BOX-BOX"), + } + + // Next race countdown + var nextMeeting *models.Meeting + for i := range m.meetings { + start, err := time.Parse(time.RFC3339, m.meetings[i].DateStart) + if err != nil { + start, _ = time.Parse("2006-01-02", m.meetings[i].DateStart[:min(len(m.meetings[i].DateStart), 10)]) + } + if start.After(now) { + nextMeeting = &m.meetings[i] + break + } + } + + if nextMeeting != nil { + start, err := time.Parse(time.RFC3339, nextMeeting.DateStart) + if err == nil { + diff := start.Sub(now) + days := int(diff.Hours() / 24) + hours := int(diff.Hours()) % 24 + + flag := countryFlag(nextMeeting.CountryCode) + raceName := styleStatusValue.Render(nextMeeting.MeetingName) + countdown := styleCountdown.Render(fmt.Sprintf("%dd %dh", days, hours)) + + leftParts = append(leftParts, + styleStatusLabel.Render("β”‚"), + styleStatusLabel.Render("NEXT"), + flag+" "+raceName, + styleStatusLabel.Render("in"), + countdown, + ) + } + } + + left := strings.Join(leftParts, " ") + + // Right side: cache stats + navigation hints + cacheStats := m.client.CacheStats() + cacheInfo := styleMuted.Render(fmt.Sprintf("cache %d/%d", cacheStats.Hits, cacheStats.Hits+cacheStats.Misses)) + right := cacheInfo + " " + styleMuted.Render("1-4 tabs Β· y year Β· q quit") + + leftW := lipgloss.Width(left) + rightW := lipgloss.Width(right) + spacerW := width - leftW - rightW - 2 + if spacerW < 0 { + spacerW = 0 + } + + bar := " " + left + strings.Repeat(" ", spacerW) + right + " " + return styleStatusBar.Width(width).Render(bar) +} + +func (m AppModel) renderSplash() string { + w := m.width + h := m.height + if w == 0 { + w = 80 + } + if h == 0 { + h = 24 + } + + // F1-themed ASCII logo + logo := []string{ + "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—", + "β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•—β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•—β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•", + "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ•”β• ", + "β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β–ˆβ–ˆβ•—β•šβ•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— ", + "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•—", + "β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β• β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β•", + } + + redStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorF1Red)). + Bold(true) + + var logoBlock strings.Builder + for _, line := range logo { + logoBlock.WriteString(redStyle.Render(line) + "\n") + } + + subtitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Render("Formula 1 Terminal Dashboard") + + loadingLine := fmt.Sprintf("%s %s", + m.splashSpinner.View(), + styleMuted.Render("Loading data...")) + + hint := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSurface2)). + Render("press any key to skip") + + content := lipgloss.JoinVertical(lipgloss.Center, + logoBlock.String(), + "", + subtitle, + "", + loadingLine, + "", + hint, + ) + + // Center the splash on screen + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content) } diff --git a/internal/ui/calendar.go b/internal/ui/calendar.go index 5215d97..1e69eab 100644 --- a/internal/ui/calendar.go +++ b/internal/ui/calendar.go @@ -18,16 +18,19 @@ type CalendarModel struct { loading bool err error spinner spinner.Model - cursor int year int width int height int + + cursor int + scroll int } func NewCalendarModel(client *api.OpenF1Client, year int) CalendarModel { s := spinner.New() s.Spinner = spinner.MiniDot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) + return CalendarModel{ client: client, loading: true, @@ -51,7 +54,13 @@ func (m CalendarModel) Init() tea.Cmd { } func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case spinner.TickMsg: if m.loading { var cmd tea.Cmd @@ -69,26 +78,75 @@ func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) { m.loading = false // Auto-scroll to next upcoming race m.cursor = m.findNextRaceIndex() + m.ensureCursorVisible() case tea.KeyMsg: 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): if m.cursor > 0 { m.cursor-- + m.ensureCursorVisible() } case matchKey(msg, GlobalKeys.Down): if m.cursor < len(m.meetings)-1 { 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): - 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 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 { @@ -110,81 +168,145 @@ func (m CalendarModel) findNextRaceIndex() int { func (m CalendarModel) View() string { 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 { - 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 { - return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.", 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) + return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.\n", m.year)) } var sb strings.Builder - sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n") - sb.WriteString(strings.Join(rows, "\n")) - sb.WriteString("\n\n") - sb.WriteString(helpBar("y season", "j/k navigate", "enter select race", "q quit")) + + // Title + title := lipgloss.NewStyle(). + 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() } @@ -203,7 +325,7 @@ func formatMeetingDates(m models.Meeting) string { } 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()) } diff --git a/internal/ui/driver.go b/internal/ui/driver.go index 5a3fbec..4a71b82 100644 --- a/internal/ui/driver.go +++ b/internal/ui/driver.go @@ -3,12 +3,16 @@ package ui import ( "fmt" "strings" + "time" "github.com/AmanTahiliani/box-box/internal/api" "github.com/AmanTahiliani/box-box/internal/models" "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/sahilm/fuzzy" ) type driverView int @@ -22,11 +26,14 @@ type DriverModel struct { client *api.OpenF1Client sessionKey int - drivers []models.Driver - selectedDriver *models.Driver - stints []models.Stint - laps []models.Lap - pits []models.Pit + drivers []models.Driver + filteredDrivers []models.Driver + selectedDriver *models.Driver + stints []models.Stint + laps []models.Lap + pits []models.Pit + positions []models.Position + teamRadios []models.TeamRadio view driverView loading bool @@ -34,28 +41,53 @@ type DriverModel struct { spinner spinner.Model cursor int + scroll int + input textinput.Model + + // Detail view viewport + detailView viewport.Model + detailViewReady bool + width 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 { s := spinner.New() s.Spinner = spinner.MiniDot 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{ client: client, - loading: false, // lazy-loaded on first focus + loading: false, spinner: s, + input: ti, view: driverViewList, } } func fetchDriverList(client *api.OpenF1Client) tea.Cmd { return func() tea.Msg { - // Use latest session key - drivers, err := client.GetDriversForSession(9999) // will use "latest" via a workaround + drivers, err := client.GetDriversForSession(9999) if err != nil || len(drivers) == 0 { - // Fallback: get latest championship drivers' session key champ, champErr := client.GetLatestDriverChampionship() if champErr != nil || len(champ) == 0 { return driverListLoadedMsg{err: err} @@ -99,6 +131,14 @@ func fetchDriverDetail(client *api.OpenF1Client, sessionKey, driverNumber int) t } 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) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case spinner.TickMsg: if m.loading { var cmd tea.Cmd @@ -122,32 +168,44 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) { return m, nil } m.drivers = msg.drivers - m.cursor = 0 + m.filterDrivers() case driverStintsLoadedMsg: if msg.err == nil { m.stints = msg.stints } m.loading = false + m.updateDetailViewport() case driverLapsLoadedMsg: if msg.err == nil { m.laps = msg.laps } + m.updateDetailViewport() case driverPitsLoadedMsg: if msg.err == nil { 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: - // We'll pick up session key when sessions are loaded; for now reset m.drivers = nil case sessionsLoadedMsg: if msg.err == nil && len(msg.sessions) > 0 { - // Use the Race session key if available for _, s := range msg.sessions { if s.SessionName == "Race" { m.sessionKey = s.SessionKey @@ -161,32 +219,86 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) { switch m.view { case driverViewList: 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): if m.cursor > 0 { m.cursor-- + m.ensureCursorVisible() } case matchKey(msg, GlobalKeys.Down): - if m.cursor < len(m.drivers)-1 { + if m.cursor < len(m.filteredDrivers)-1 { 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): - if len(m.drivers) > 0 && m.cursor < len(m.drivers) { - d := m.drivers[m.cursor] + if len(m.filteredDrivers) > 0 && m.cursor >= 0 && m.cursor < len(m.filteredDrivers) { + d := m.filteredDrivers[m.cursor] m.selectedDriver = &d m.stints = nil m.laps = nil m.pits = nil + m.positions = nil + m.teamRadios = nil m.view = driverViewDetail m.loading = true + m.detailViewReady = false sessionKey := m.sessionKey if sessionKey == 0 && d.SessionKey != 0 { sessionKey = d.SessionKey } - return m, tea.Batch( - m.spinner.Tick, - fetchDriverDetail(m.client, sessionKey, d.DriverNumber), - ) + cmds = append(cmds, 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: switch { @@ -196,14 +308,73 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) { m.stints = nil m.laps = 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) { if m.drivers != nil || m.loading { return m, nil @@ -213,11 +384,12 @@ func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) { } func (m DriverModel) View() string { - if m.loading { - return fmt.Sprintf("\n %s Loading drivers…", m.spinner.View()) + if m.loading && m.view == driverViewList { + return fmt.Sprintf("\n %s Loading drivers...", m.spinner.View()) } 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 { @@ -230,49 +402,133 @@ func (m DriverModel) View() string { } func (m DriverModel) renderDriverList() string { - if len(m.drivers) == 0 { - return styleMuted.Render("\n No driver data. Select a race from Calendar first.\n\n" + - helpBar("2 calendar", "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) + if len(m.drivers) == 0 && !m.loading { + return styleMuted.Render("\n No driver data. Select a race from Calendar first.\n\n") + + helpBar("2 calendar", "r retry", "q quit") } var sb strings.Builder - sb.WriteString(strings.Join(rows, "\n")) - sb.WriteString("\n\n") - sb.WriteString(helpBar("j/k navigate", "enter view driver", "q quit")) + w := m.width + if w < 40 { + 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() } +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 { if m.selectedDriver == nil { return "" @@ -281,75 +537,385 @@ func (m DriverModel) renderDriverDetail() string { var sb strings.Builder - // Nameplate header - nameStyle := hexToStyle(d.TeamColour).Bold(true) - sb.WriteString(nameStyle.Render(fmt.Sprintf(" %s %s", d.NameAcronym, d.FullName))) - sb.WriteString(styleMuted.Render(fmt.Sprintf(" Β· %s Β· #%d", d.TeamName, d.DriverNumber))) - sb.WriteString("\n\n") + teamColor := colorMuted + if d.TeamColour != "" { + teamColor = "#" + d.TeamColour + } else { + teamColor = teamColorFromName(d.TeamName) + } - // Stint bar - sb.WriteString(styleBold.Render("Stints") + "\n") + // Driver card header (fixed, not scrollable) + 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("\n\n") - // Lap sparkline - sb.WriteString(styleBold.Render("Lap Times") + "\n") - sparkWidth := min(m.width-4, 80) + // ── LAP TIMES ─────────────────────────────────────── + sb.WriteString(" " + styleSectionTitle.Render("LAP TIMES") + "\n") + sparkWidth := min(m.width-6, 70) if sparkWidth < 10 { sparkWidth = 40 } sb.WriteString(" " + sparkline(m.laps, sparkWidth) + "\n") - sb.WriteString(styleMuted.Render(fmt.Sprintf(" %d laps (▁=slow, β–ˆ=fast, space=pit out)\n", len(m.laps)))) - sb.WriteString("\n") + legend := fmt.Sprintf(" %s fast %s mid %s slow %s pit", + 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 - sb.WriteString(styleBold.Render("Pit Stops") + "\n") + if len(m.laps) > 0 { + 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("\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() } func (m DriverModel) renderStintBar() string { if len(m.stints) == 0 { 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 - for _, stint := range m.stints { - label := fmt.Sprintf("%s %d-%d", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd) - part := tyreStyle(stint.Compound).Render(fmt.Sprintf("[%s]", label)) - parts = append(parts, part) + for i, stint := range m.stints { + label := fmt.Sprintf(" %s L%d-%d ", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd) + stintStr := tyreBgStyle(stint.Compound).Render(label) + + 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 { if len(m.pits) == 0 { - return styleMuted.Render(" No pit stop data.") + return styleMuted.Render(" No pit stop data available.") } - header := styleBold.Render( - padLeft("Lap", 4) + " " + - padLeft("Stop", 8) + " " + - padLeft("Lane", 8), + var sb strings.Builder + + // Header + header := fmt.Sprintf(" %s %s %s %s", + padRight("STOP", 5), + padLeft("LAP", 4), + padLeft("STOP TIME", 10), + padLeft("PIT LANE", 10), ) - var rows []string - rows = append(rows, header) + sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n") + sb.WriteString(" " + divider(35) + "\n") - for _, p := range m.pits { - row := fmt.Sprintf("%s %s %s", + for i, p := range m.pits { + 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("%.3fs", p.StopDuration), 8), - padLeft(fmt.Sprintf("%.3fs", p.LaneDuration), 8), + padLeftVisible(stopStyle.Render(fmt.Sprintf("%.3fs", p.StopDuration)), 10), + 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() } diff --git a/internal/ui/keys.go b/internal/ui/keys.go index c134471..4cdb4c2 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -4,16 +4,23 @@ import "github.com/charmbracelet/bubbles/key" // GlobalKeyMap holds keybindings that work from any tab. type GlobalKeyMap struct { - Tab1 key.Binding - Tab2 key.Binding - Tab3 key.Binding - Tab4 key.Binding - Quit key.Binding - Up key.Binding - Down key.Binding - Enter key.Binding - Back key.Binding - Year key.Binding + Tab1 key.Binding + Tab2 key.Binding + Tab3 key.Binding + Tab4 key.Binding + NextTab key.Binding + PrevTab key.Binding + Quit key.Binding + Up key.Binding + Down 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. @@ -34,30 +41,58 @@ var GlobalKeys = GlobalKeyMap{ key.WithKeys("4"), 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( key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit"), ), Up: key.NewBinding( key.WithKeys("k", "up"), - key.WithHelp("k/↑", "up"), + key.WithHelp("k", "up"), ), Down: key.NewBinding( key.WithKeys("j", "down"), - key.WithHelp("j/↓", "down"), + key.WithHelp("j", "down"), ), Enter: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "select"), ), Back: key.NewBinding( - key.WithKeys("b"), - key.WithHelp("b", "back"), + key.WithKeys("b", "esc"), + key.WithHelp("b/esc", "back"), ), Year: key.NewBinding( key.WithKeys("y"), 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. @@ -79,8 +114,10 @@ var StandingsKeys = StandingsKeyMap{ // RaceDetailKeyMap holds keybindings for the race detail tab. type RaceDetailKeyMap struct { - ScrollUp key.Binding - ScrollDown key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + PrevSession key.Binding + NextSession key.Binding } var RaceDetailKeys = RaceDetailKeyMap{ @@ -92,4 +129,12 @@ var RaceDetailKeys = RaceDetailKeyMap{ key.WithKeys("J"), 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"), + ), } diff --git a/internal/ui/messages.go b/internal/ui/messages.go index ef2589f..7140604 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -80,6 +80,24 @@ type driverPitsLoadedMsg struct { 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. type meetingSelectedMsg struct { meeting models.Meeting diff --git a/internal/ui/racedetail.go b/internal/ui/racedetail.go index 00e3a42..4d8cb37 100644 --- a/internal/ui/racedetail.go +++ b/internal/ui/racedetail.go @@ -17,23 +17,26 @@ type RaceDetailModel struct { client *api.OpenF1Client meeting *models.Meeting - sessions []models.Session - results []models.SessionResult - drivers map[int]models.Driver - rcMsgs []models.RaceControl - weather []models.Weather + sessions []models.Session + results []models.SessionResult + drivers map[int]models.Driver + rcMsgs []models.RaceControl + weather []models.Weather + overtakes []models.Overtake selectedSession *models.Session sessionCursor int + resultsCursor int + resultsScroll int loadingSessions bool loadingResults bool errSessions error errResults error - spinner spinner.Model - rcView viewport.Model - rcReady bool + spinner spinner.Model + rcView viewport.Model + rcReady bool width int height int @@ -43,6 +46,7 @@ func NewRaceDetailModel(client *api.OpenF1Client) RaceDetailModel { s := spinner.New() s.Spinner = spinner.MiniDot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) + return RaceDetailModel{ client: client, spinner: s, @@ -79,6 +83,10 @@ func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd { weather, err := client.GetWeather(sessionKey) 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 switch msg := msg.(type) { + case tea.WindowSizeMsg: + // handled by SetSize from app.go + case spinner.TickMsg: if m.loadingSessions || m.loadingResults { var cmd tea.Cmd @@ -99,8 +110,11 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.results = nil m.rcMsgs = nil m.weather = nil + m.overtakes = nil m.selectedSession = nil m.sessionCursor = 0 + m.resultsCursor = 0 + m.resultsScroll = 0 m.loadingSessions = true m.loadingResults = false m.errSessions = nil @@ -115,13 +129,34 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { return m, nil } 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 { if s.SessionName == "Race" { - m.sessionCursor = i + raceIdx = i 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: if msg.err != nil { @@ -130,9 +165,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { return m, nil } m.results = msg.results - if !m.loadingResults { - // Both results and drivers may arrive in any order - } + m.resultsCursor = 0 + m.resultsScroll = 0 m.checkResultsLoaded() case sessionDriversLoadedMsg: @@ -154,15 +188,60 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.weather = msg.weather } + case overtakesLoadedMsg: + if msg.err == nil { + m.overtakes = msg.overtakes + } + case tea.KeyMsg: 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): - if m.sessionCursor > 0 { - m.sessionCursor-- + if m.results != nil && m.resultsCursor > 0 { + m.resultsCursor-- + m.ensureResultsVisible() } case matchKey(msg, GlobalKeys.Down): - if m.sessionCursor < len(m.sessions)-1 { - m.sessionCursor++ + if m.results != nil && m.resultsCursor < len(m.results)-1 { + 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): 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.rcMsgs = nil m.weather = nil + m.overtakes = nil + m.resultsCursor = 0 + m.resultsScroll = 0 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): m.rcView.LineUp(3) case matchKey(msg, RaceDetailKeys.ScrollDown): 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() { - // Mark done once results arrive (drivers may still be loading but we show what we have) if m.results != nil { 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() { content := m.renderRaceControlContent() if m.rcReady { @@ -214,177 +321,307 @@ func (m *RaceDetailModel) initViewport(w, h int) { func (m RaceDetailModel) View() string { if m.meeting == nil { - return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n" + - helpBar("2 calendar", "q quit")) + return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n") + + helpBar("2 calendar", "q quit") } - // Title - title := styleBold.Render(m.meeting.MeetingOfficialName) - dates := formatMeetingDates(*m.meeting) - subtitle := styleMuted.Render(fmt.Sprintf("%s Β· %s Β· %s", m.meeting.Location, m.meeting.CountryName, dates)) + w := m.width + if w < 40 { + w = 40 + } + 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 - // Session list - sb.WriteString(styleHeader.Render("Sessions") + "\n") - if m.loadingSessions { - sb.WriteString(fmt.Sprintf(" %s Loading sessions…\n", m.spinner.View())) - } else if m.errSessions != nil { - sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errSessions))) + // Race title header + flag := countryFlag(m.meeting.CountryCode) + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorF1Red)) + + if compact { + sb.WriteString(titleStyle.Render(fmt.Sprintf(" %s %s", flag, m.meeting.MeetingName)) + "\n") } else { - for i, sess := range m.sessions { - var start string - if len(sess.DateStart) >= 10 { - t, err := time.Parse(time.RFC3339, sess.DateStart) - if err == nil { - start = t.Format("Mon Jan 2") - } else { - start = sess.DateStart[:10] - } + sb.WriteString(titleStyle.Render(fmt.Sprintf(" %s %s", flag, m.meeting.MeetingOfficialName)) + "\n") + } + + dates := formatMeetingDates(*m.meeting) + subtitle := fmt.Sprintf(" %s %s %s", + styleMuted.Render(m.meeting.Location), + styleMuted.Render("Β·"), + 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) - if i == m.sessionCursor { - row = styleSelected.Render(row) - } else if m.selectedSession != nil && m.selectedSession.SessionKey == sess.SessionKey { - row = styleDeltaUp.Render(row) + sb.WriteString(strings.Join(lines, "\n") + "\n") + if len(m.rcMsgs) > maxRC { + sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... %d more messages (K/J scroll)", len(m.rcMsgs)-maxRC)) + "\n") } - 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 { - 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() } -func (m RaceDetailModel) renderResults(width int) string { - isRace := m.selectedSession != nil && m.selectedSession.SessionType == "Race" - - const ( - wPos = 3 - wDRV = 4 - 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), - ) +func (m RaceDetailModel) renderSessionPills() string { + if m.loadingSessions { + return fmt.Sprintf(" %s Loading sessions...", m.spinner.View()) + } + if m.errSessions != nil { + return styleError.Render(fmt.Sprintf(" Error: %v", m.errSessions)) } - var rows []string - rows = append(rows, header) + var pills []string + 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] acronym := d.NameAcronym if acronym == "" { 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 - 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 { - row = fmt.Sprintf("%s %s %s %s %s %s", - padLeft(pos, wPos), - padRight(acronym, wDRV), - teamStr, - padLeft(fmt.Sprintf("%d", r.NumberOfLaps), wLaps), - padLeft(formatGap(r.GapToLeader), wGap), - padLeft(fmt.Sprintf("%.0f", r.Points), wPts), - ) + gap := formatGap(r.GapToLeader) + var gapStyled string + if gap == "LEADER" { + gapStyled = styleLeader.Render("LEADER") + } else { + gapStyled = styleGap.Render(gap) + } + + 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 { - row = fmt.Sprintf("%s %s %s %s", - padLeft(pos, wPos), - padRight(acronym, wDRV), - teamStr, - padLeft(formatDuration(r.Duration), wGap), - ) + dur := formatDuration(r.Duration) + if compact { + row = fmt.Sprintf(" %s %s %s %s", + padRightVisible(pos, 3), + 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 { - row = styleMuted.Render(row) + if i == m.resultsCursor { + 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 + // 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 - sb.WriteString(styleHeader.Render("Race Control") + "\n") + sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n") if !m.rcReady || m.selectedSession == nil { sb.WriteString(styleMuted.Render(" No session selected.\n")) } else { sb.WriteString(m.rcView.View() + "\n") } - // Weather strip - sb.WriteString("\n" + styleHeader.Render("Weather") + "\n") - sb.WriteString(m.renderWeather(width)) - return sb.String() } @@ -405,52 +642,107 @@ func (m RaceDetailModel) renderRaceControlContent() string { } } - 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 + // Category-specific icon and styling + var prefix string + switch rc.Category { + case models.CategorySafetyCar: + prefix = styleSafetyCar.Render(fmt.Sprintf(" ⚠ [%s]", t)) + case models.CategoryDRS: + prefix = styleDRS.Render(fmt.Sprintf(" β–Έ [%s]", t)) 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)) } 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 { - 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] - rain := "Dry" + // Weather conditions + var condStr string 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", - w.AirTemperature, w.TrackTemperature, rain, - w.Humidity, windArrow(w.WindDirection), w.WindSpeed) + sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s %s %s %s%.1fm/s\n", + condStr, + 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) { m.width = w m.height = h - // Right panel, minus header/weather/borders - rightWidth := int(float64(w)*0.45) - 6 - rcHeight := h - 12 // approximate: title + sessions + header + weather + help + if w < 40 { + w = 40 + } + 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 { rcHeight = 3 } @@ -461,3 +753,98 @@ func (m *RaceDetailModel) SetSize(w, h int) { 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() +} diff --git a/internal/ui/standings.go b/internal/ui/standings.go index 5871c03..cb5a50e 100644 --- a/internal/ui/standings.go +++ b/internal/ui/standings.go @@ -23,15 +23,17 @@ type StandingsModel struct { driverStandings []models.ChampionshipDriver teamStandings []models.ChampionshipTeam - drivers map[int]models.Driver // driver_number β†’ Driver + drivers map[int]models.Driver // driver_number -> Driver view standingsView loading bool err error spinner spinner.Model - year int cursor int + scroll int + + year int width int height int } @@ -40,6 +42,7 @@ func NewStandingsModel(client *api.OpenF1Client, year int) StandingsModel { s := spinner.New() s.Spinner = spinner.MiniDot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) + return StandingsModel{ client: client, 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) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case spinner.TickMsg: if m.loading { var cmd tea.Cmd @@ -95,7 +104,6 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) { return m, nil } m.driverStandings = msg.standings - // Phase 2: fetch drivers to join names if len(msg.standings) > 0 { 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: 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): m.view = standingsViewDriver m.cursor = 0 + m.scroll = 0 case matchKey(msg, StandingsKeys.ConstructorView): m.view = standingsViewConstructor m.cursor = 0 + m.scroll = 0 case matchKey(msg, GlobalKeys.Up): if m.cursor > 0 { m.cursor-- + if m.cursor < m.scroll { + m.scroll = m.cursor + } } 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 { 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 { - 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 - // Year indicator - sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n") + // Title row with year and toggle + 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 - dStyle, cStyle := styleInactiveTab, styleInactiveTab + dLabel, cLabel := " d Drivers ", " c Constructors " if m.view == standingsViewDriver { - dStyle = styleActiveTab + sb.WriteString(styleActiveTab.Render(dLabel)) + sb.WriteString(styleInactiveTab.Render(cLabel)) } 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") if m.view == standingsViewDriver { @@ -171,122 +248,206 @@ func (m StandingsModel) View() string { } 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() } func (m StandingsModel) renderDriverStandings() string { if len(m.driverStandings) == 0 { - return styleMuted.Render(" No standings data available.") + return styleMuted.Render(" No standings data available.\n") } - // Column widths - const ( - wPos = 4 - wAcronym = 5 - wName = 22 - 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 + var sb strings.Builder + maxPoints := m.driverStandings[0].PointsCurrent + w := m.width + if w < 40 { + w = 40 } - 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] - acronym, name, team, teamColor := "---", "Unknown Driver", "Unknown Team", "" + acronym, name, team, teamColor := "---", "Unknown", "Unknown", colorMuted if ok { acronym = d.NameAcronym name = d.FullName team = d.TeamName - teamColor = d.TeamColour + if d.TeamColour != "" { + teamColor = "#" + d.TeamColour + } else { + teamColor = teamColorFromName(d.TeamName) + } } 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)) - // Pad team to width (lipgloss rendering may shift widths, so use padRight on plain string then style) - teamPlain := padRight(truncate(team, wTeam), wTeam) - teamStr = hexToStyle(teamColor).Render(teamPlain) - - row := fmt.Sprintf("%s %s %s %s %s %s", - padLeft(fmt.Sprintf("%d", s.PositionCurrent), wPos), - padRight(acronym, wAcronym), - padRight(truncate(name, wName), wName), - teamStr, - padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), wPoints), - delta, - ) + var row string + if compact { + row = fmt.Sprintf(" %s %s %s %s %s %s", + padRightVisible(pos, 3), + delta, + colorBar, + padRight(acronym, 4), + padRight(truncate(team, teamWidth), teamWidth), + padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 5), + ) + } else { + 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 { 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 { if len(m.teamStandings) == 0 { - return styleMuted.Render(" No constructor standings available.") + return styleMuted.Render(" No standings data available.\n") } - const ( - wPos = 4 - wTeam = 30 - wPoints = 8 - wDelta = 5 + var sb strings.Builder + maxPoints := m.teamStandings[0].PointsCurrent + w := m.width + if w < 40 { + 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( - padRight("Pos", wPos) + " " + - padRight("Constructor", wTeam) + " " + - padLeft("Pts", wPoints) + " " + - padLeft("Ξ”", wDelta), - ) + for i := m.scroll; i < endIdx; i++ { + s := m.teamStandings[i] + teamColor := teamColorFromName(s.TeamName) - var rows []string - rows = append(rows, header) - - for i, s := range m.teamStandings { delta := renderDelta(s.PositionCurrent, s.PositionStart) - row := fmt.Sprintf("%s %s %s %s", - padLeft(fmt.Sprintf("%d", s.PositionCurrent), wPos), - padRight(truncate(s.TeamName, wTeam), wTeam), - padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), wPoints), + pos := renderPosition(s.PositionCurrent) + colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃") + pointsBar := renderPointsBar(s.PointsCurrent, maxPoints, barWidth, teamColor) + + row := fmt.Sprintf(" %s %s %s %s %s %s", + padRightVisible(pos, 4), delta, + colorBar, + padRight(truncate(s.TeamName, teamWidth), teamWidth), + padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6), + pointsBar, ) + if i == m.cursor { row = styleSelected.Render(row) } - rows = append(rows, row) + + sb.WriteString(row + "\n") } - return strings.Join(rows, "\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 - } + if len(m.teamStandings) > visible { + sb.WriteString(styleMuted.Render(fmt.Sprintf(" Showing %d-%d of %d", m.scroll+1, endIdx, len(m.teamStandings))) + "\n") } - return false + + return sb.String() } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index fca4f38..d067809 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -4,23 +4,44 @@ import "github.com/charmbracelet/lipgloss" // F1 brand colors const ( - colorF1Red = "#E8002D" + colorF1Red = "#E10600" colorF1Black = "#15151E" - colorSubtle = "#3C3C4A" + colorSubtle = "#2A2A3C" colorMuted = "#6B6B7A" colorWhite = "#FFFFFF" - colorGreen = "#39B54A" + colorGreen = "#00D26A" + colorYellow = "#FFD700" + colorOrange = "#FF8700" + colorCyan = "#00BFFF" + + // Surfaces + colorSurface0 = "#1B1B2F" + colorSurface1 = "#222236" + colorSurface2 = "#2D2D44" + colorBorder = "#3C3C54" // Tyre compounds - colorSoft = "#FF1E1E" + colorSoft = "#FF3333" colorMedium = "#FFD700" - colorHard = "#EEEEEE" + colorHard = "#CCCCCC" 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 ( - // Tab bar styles + // ── Tab bar ────────────────────────────────────────── styleActiveTab = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(colorWhite)). @@ -29,18 +50,28 @@ var ( styleInactiveTab = lipgloss.NewStyle(). Foreground(lipgloss.Color(colorMuted)). + Background(lipgloss.Color(colorSurface0)). Padding(0, 2) styleTabBar = lipgloss.NewStyle(). - Background(lipgloss.Color(colorF1Black)). - BorderStyle(lipgloss.NormalBorder()). - BorderBottom(true). - BorderForeground(lipgloss.Color(colorSubtle)) + Background(lipgloss.Color(colorSurface0)) - // 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(). BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color(colorSubtle)). + BorderForeground(lipgloss.Color(colorBorder)). Padding(0, 1) styleActivePanelBorder = lipgloss.NewStyle(). @@ -48,8 +79,8 @@ var ( BorderForeground(lipgloss.Color(colorF1Red)). Padding(0, 1) - // Text styles - styleBold = lipgloss.NewStyle().Bold(true) + // ── Text styles ────────────────────────────────────── + styleBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorWhite)) styleMuted = lipgloss.NewStyle(). Foreground(lipgloss.Color(colorMuted)) @@ -61,10 +92,16 @@ var ( styleHeader = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(colorWhite)). - Background(lipgloss.Color(colorSubtle)). - Padding(0, 1) + Background(lipgloss.Color(colorSurface2)). + Padding(0, 1). + MarginBottom(0) - // Delta styles + styleSectionTitle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorF1Red)). + PaddingLeft(1) + + // ── Delta styles ───────────────────────────────────── styleDeltaUp = lipgloss.NewStyle(). Foreground(lipgloss.Color(colorGreen)). Bold(true) @@ -76,28 +113,154 @@ var ( styleDeltaEqual = lipgloss.NewStyle(). Foreground(lipgloss.Color(colorMuted)) - // Selected row style + // ── Selected row style ─────────────────────────────── styleSelected = lipgloss.NewStyle(). - Background(lipgloss.Color(colorSubtle)). + Background(lipgloss.Color(colorSurface2)). + Foreground(lipgloss.Color(colorWhite)). Bold(true) - // Status indicators + // ── Status indicators ──────────────────────────────── stylePast = lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorMuted)) + Foreground(lipgloss.Color(colorGreen)) styleNext = lipgloss.NewStyle(). Foreground(lipgloss.Color(colorF1Red)). 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)) - styleFlagYellow = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)) + styleFlagYellow = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)) 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)) - // 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(). Foreground(lipgloss.Color(colorMuted)). + Background(lipgloss.Color(colorSurface0)). 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{} diff --git a/internal/ui/util.go b/internal/ui/util.go index d7555b7..9367e53 100644 --- a/internal/ui/util.go +++ b/internal/ui/util.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" "github.com/AmanTahiliani/box-box/internal/models" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -35,7 +36,6 @@ func formatGap(v interface{}) string { case string: return val case []interface{}: - // Qualifying: return best Q time if len(val) == 0 { return "--" } @@ -56,7 +56,6 @@ func formatDuration(v interface{}) string { case float64: return formatSeconds(val) case []interface{}: - // Qualifying: return best Q time if len(val) == 0 { return "--" } @@ -69,7 +68,6 @@ func formatDuration(v interface{}) string { } // 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 { if hex == "" { return lipgloss.NewStyle() @@ -80,6 +78,17 @@ func hexToStyle(hex string) lipgloss.Style { 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. // Pit laps (nil duration) are rendered as spaces. func sparkline(laps []models.Lap, width int) string { @@ -87,7 +96,7 @@ func sparkline(laps []models.Lap, width int) string { blockRunes := []rune(blocks) if len(laps) == 0 { - return strings.Repeat(" ", width) + return styleMuted.Render(strings.Repeat("Β·", width)) } // Collect valid lap durations @@ -120,7 +129,7 @@ func sparkline(laps []models.Lap, width int) string { break } if d < 0 { - sb.WriteRune(' ') + sb.WriteString(styleMuted.Render("Β·")) } else { norm := (d - minDur) / rng // Invert: fast laps = tall bar (higher index) @@ -131,16 +140,26 @@ func sparkline(laps []models.Lap, width int) string { if idx >= len(blockRunes) { 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++ } result := sb.String() - resultLen := utf8.RuneCountInString(result) - if resultLen < width { - result += strings.Repeat(" ", width-resultLen) - } + resultLen := utf8.RuneCountInString(lipgloss.NewStyle().Render(result)) + _ = resultLen 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. func tyreAbbrev(c models.TyreCompound) string { 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 { diff := start - current // positive = gained positions switch { case diff > 0: - return styleDeltaUp.Render(fmt.Sprintf("β–²%d", diff)) + s := fmt.Sprintf("β–²%d", diff) + return padRightVisible(styleDeltaUp.Render(s), 2) case diff < 0: - return styleDeltaDown.Render(fmt.Sprintf("β–Ό%d", -diff)) + s := fmt.Sprintf("β–Ό%d", -diff) + return padRightVisible(styleDeltaDown.Render(s), 2) 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. -// isNext should be true only for the first upcoming meeting. func meetingStatus(m models.Meeting, now time.Time, isNext bool) string { end, err := time.Parse(time.RFC3339, m.DateEnd) if err != nil { - // Fallback: try date-only parsing end, err = time.Parse("2006-01-02", m.DateEnd[:min(len(m.DateEnd), 10)]) if err != nil { return " " @@ -217,9 +301,9 @@ func meetingStatus(m models.Meeting, now time.Time, isNext bool) string { return stylePast.Render("βœ“") } if isNext { - return styleNext.Render("β—Ž") + return styleNext.Render("●") } - return " " + return styleFuture.Render("β—‹") } // 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]) + "…" } -// 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 { runes := []rune(s) if len(runes) >= width { @@ -243,7 +328,8 @@ func padRight(s string, width int) string { 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 { runes := []rune(s) if len(runes) >= width { @@ -252,6 +338,26 @@ func padLeft(s string, width int) string { 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 { if a < b { return a @@ -266,21 +372,90 @@ func max(a, b int) int { return b } -// countryFlag converts a country flag URL or code to an emoji flag. -// OpenF1 provides flag URLs; we do a best-effort mapping from country_code. +// countryFlag converts a country code to an emoji flag. func countryFlag(countryCode string) string { - // Convert ISO 3166-1 alpha-2 to emoji regional indicators code := strings.ToUpper(countryCode) if len(code) != 2 { return " " } - // Each letter maps to a regional indicator symbol (U+1F1E6 = 'A') r1 := rune(0x1F1E6 + int(code[0]-'A')) r2 := rune(0x1F1E6 + int(code[1]-'A')) 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 { - 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("┃") }