UI Refresh

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

2
go.mod
View File

@@ -3,6 +3,7 @@ module github.com/AmanTahiliani/box-box
go 1.25.6
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

4
go.sum
View File

@@ -1,3 +1,5 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/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=

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -3,8 +3,10 @@ package ui
import (
"fmt"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -19,7 +21,11 @@ const (
tabDriver tabIndex = 3
)
var tabNames = []string{"1 Standings", "2 Calendar", "3 Race", "4 Drivers"}
var tabNames = []string{"Standings", "Calendar", "Race", "Drivers"}
var tabIcons = []string{"🏆", "📅", "🏁", "👤"}
// splashDoneMsg is sent after the splash screen duration has elapsed.
type splashDoneMsg struct{}
// AppModel is the root Bubble Tea model.
type AppModel struct {
@@ -34,28 +40,48 @@ type AppModel struct {
calendar CalendarModel
raceDetail RaceDetailModel
driver DriverModel
meetings []models.Meeting
// Splash screen state
showSplash bool
splashSpinner spinner.Model
}
// NewAppModel creates the root model and wires sub-models.
func NewAppModel(client *api.OpenF1Client) AppModel {
year := 2025
sp := spinner.New()
sp.Spinner = spinner.Points
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return AppModel{
client: client,
activeTab: tabStandings,
year: year,
standings: NewStandingsModel(client, year),
calendar: NewCalendarModel(client, year),
raceDetail: NewRaceDetailModel(client),
driver: NewDriverModel(client),
client: client,
activeTab: tabStandings,
year: year,
standings: NewStandingsModel(client, year),
calendar: NewCalendarModel(client, year),
raceDetail: NewRaceDetailModel(client),
driver: NewDriverModel(client),
showSplash: true,
splashSpinner: sp,
}
}
func splashTimer() tea.Cmd {
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
return splashDoneMsg{}
})
}
func (m AppModel) Init() tea.Cmd {
return tea.Batch(
m.standings.Init(),
m.calendar.Init(),
m.raceDetail.Init(),
m.driver.Init(),
m.splashSpinner.Tick,
splashTimer(),
)
}
@@ -66,46 +92,76 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
contentHeight := m.height - 3 // tab bar + help
contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing
m.raceDetail.SetSize(m.width-4, contentHeight)
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
m.standings, cmd1 = m.standings.Update(msg)
m.calendar, cmd2 = m.calendar.Update(msg)
m.raceDetail, cmd3 = m.raceDetail.Update(msg)
m.driver, cmd4 = m.driver.Update(msg)
return m, tea.Batch(cmd1, cmd2, cmd3, cmd4)
case splashDoneMsg:
m.showSplash = false
return m, nil
case tea.KeyMsg:
// Any key press during splash dismisses it
if m.showSplash {
m.showSplash = false
return m, nil
}
// Global quit
if matchKey(msg, GlobalKeys.Quit) {
return m, tea.Quit
}
// Tab switching
switch msg.String() {
case "1":
switch {
case matchKey(msg, GlobalKeys.Tab1):
m.activeTab = tabStandings
return m, nil
case "2":
case matchKey(msg, GlobalKeys.Tab2):
m.activeTab = tabCalendar
return m, nil
case "3":
case matchKey(msg, GlobalKeys.Tab3):
m.activeTab = tabRaceDetail
return m, nil
case "4":
case matchKey(msg, GlobalKeys.Tab4):
m.activeTab = tabDriver
var cmd tea.Cmd
m.driver, cmd = m.driver.TriggerLoad()
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case "b":
// Back from race detail → calendar
case matchKey(msg, GlobalKeys.NextTab):
m.activeTab = (m.activeTab + 1) % 4
if m.activeTab == tabDriver {
var cmd tea.Cmd
m.driver, cmd = m.driver.TriggerLoad()
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
case matchKey(msg, GlobalKeys.PrevTab):
m.activeTab = (m.activeTab - 1 + 4) % 4
if m.activeTab == tabDriver {
var cmd tea.Cmd
m.driver, cmd = m.driver.TriggerLoad()
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
case matchKey(msg, GlobalKeys.Back):
// Back from race detail -> calendar
if m.activeTab == tabRaceDetail {
m.activeTab = tabCalendar
return m, nil
}
case "y":
case matchKey(msg, GlobalKeys.Year):
// Cycle years: 2025 -> 2023 -> 2024 -> 2025
if m.year == 2025 {
m.year = 2023
} else {
m.year++
}
// Update sub-models and re-trigger fetches
m.calendar.year = m.year
m.calendar.loading = true
m.standings.year = m.year
@@ -118,24 +174,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case meetingSelectedMsg:
// Switch to race detail tab and forward the message
m.activeTab = tabRaceDetail
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
// Also forward to driver model for session key tracking
m.driver, _ = m.driver.Update(msg)
return m, tea.Batch(cmds...)
case sessionsLoadedMsg:
// Forward to raceDetail and driver
var cmd1, cmd2 tea.Cmd
m.raceDetail, cmd1 = m.raceDetail.Update(msg)
m.driver, cmd2 = m.driver.Update(msg)
cmds = append(cmds, cmd1, cmd2)
return m, tea.Batch(cmds...)
// Route all loaded messages to the appropriate sub-models
case driverChampionshipLoadedMsg:
var cmd tea.Cmd
m.standings, cmd = m.standings.Update(msg)
@@ -155,6 +207,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
case meetingsLoadedMsg:
if msg.err == nil {
m.meetings = msg.meetings
}
var cmd tea.Cmd
m.calendar, cmd = m.calendar.Update(msg)
cmds = append(cmds, cmd)
@@ -184,6 +239,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case overtakesLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverListLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
@@ -208,8 +269,24 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverPositionsLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverTeamRadioLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case spinner.TickMsg:
// Forward spinner ticks to all sub-models
if m.showSplash {
var cmd tea.Cmd
m.splashSpinner, cmd = m.splashSpinner.Update(msg)
cmds = append(cmds, cmd)
}
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
m.standings, cmd1 = m.standings.Update(msg)
m.calendar, cmd2 = m.calendar.Update(msg)
@@ -243,8 +320,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m AppModel) View() string {
if m.showSplash {
return m.renderSplash()
}
w := m.width
if w < 40 {
w = 40
}
// Red accent stripe at the very top (F1 style)
stripe := styleTabStripe.Render(strings.Repeat("▔", w))
// Tab bar
tabs := renderTabBar(m.activeTab, m.width)
tabs := renderTabBar(m.activeTab, m.year, w)
// Content area
var content string
@@ -259,24 +348,178 @@ func (m AppModel) View() string {
content = m.driver.View()
}
return tabs + "\n" + content
statusBar := m.renderStatusBar(w)
// Calculate how much vertical space is available for content
usedLines := 1 + 1 + 1 + 1 // stripe + tab bar + gap + status bar
contentHeight := m.height - usedLines
if contentHeight < 5 {
contentHeight = 5
}
// Trim content to fit available height
contentLines := strings.Split(content, "\n")
if len(contentLines) > contentHeight {
contentLines = contentLines[:contentHeight]
}
content = strings.Join(contentLines, "\n")
return stripe + "\n" + tabs + "\n" + content + "\n" + statusBar
}
func renderTabBar(active tabIndex, width int) string {
func renderTabBar(active tabIndex, year int, width int) string {
// Build the logo
logo := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorF1Red)).
Background(lipgloss.Color(colorSurface0)).
Padding(0, 1).
Render("F1")
// Build tabs
var tabs []string
for i, name := range tabNames {
label := fmt.Sprintf(" %s %d %s ", tabIcons[i], i+1, name)
if tabIndex(i) == active {
tabs = append(tabs, styleActiveTab.Render(name))
tabs = append(tabs, styleActiveTab.Render(label))
} else {
tabs = append(tabs, styleInactiveTab.Render(name))
tabs = append(tabs, styleInactiveTab.Render(label))
}
}
bar := strings.Join(tabs, "")
// Pad remaining width
barWidth := lipgloss.Width(bar)
if barWidth < width {
bar += strings.Repeat(" ", width-barWidth)
// Year badge
yearBadge := styleYearBadge.Render(fmt.Sprintf(" %d ", year))
// Assemble: logo + tabs + spacer + year
left := logo + strings.Join(tabs, "")
leftWidth := lipgloss.Width(left)
yearWidth := lipgloss.Width(yearBadge)
spacerWidth := width - leftWidth - yearWidth
if spacerWidth < 0 {
spacerWidth = 0
}
return styleTabBar.Render(fmt.Sprintf("%s", bar))
spacer := lipgloss.NewStyle().
Background(lipgloss.Color(colorSurface0)).
Render(strings.Repeat(" ", spacerWidth))
bar := left + spacer + yearBadge
return styleTabBar.Render(bar)
}
func (m AppModel) renderStatusBar(width int) string {
now := time.Now()
// Left side: brand
leftParts := []string{
lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Bold(true).
Render("BOX-BOX"),
}
// Next race countdown
var nextMeeting *models.Meeting
for i := range m.meetings {
start, err := time.Parse(time.RFC3339, m.meetings[i].DateStart)
if err != nil {
start, _ = time.Parse("2006-01-02", m.meetings[i].DateStart[:min(len(m.meetings[i].DateStart), 10)])
}
if start.After(now) {
nextMeeting = &m.meetings[i]
break
}
}
if nextMeeting != nil {
start, err := time.Parse(time.RFC3339, nextMeeting.DateStart)
if err == nil {
diff := start.Sub(now)
days := int(diff.Hours() / 24)
hours := int(diff.Hours()) % 24
flag := countryFlag(nextMeeting.CountryCode)
raceName := styleStatusValue.Render(nextMeeting.MeetingName)
countdown := styleCountdown.Render(fmt.Sprintf("%dd %dh", days, hours))
leftParts = append(leftParts,
styleStatusLabel.Render("│"),
styleStatusLabel.Render("NEXT"),
flag+" "+raceName,
styleStatusLabel.Render("in"),
countdown,
)
}
}
left := strings.Join(leftParts, " ")
// Right side: cache stats + navigation hints
cacheStats := m.client.CacheStats()
cacheInfo := styleMuted.Render(fmt.Sprintf("cache %d/%d", cacheStats.Hits, cacheStats.Hits+cacheStats.Misses))
right := cacheInfo + " " + styleMuted.Render("1-4 tabs · y year · q quit")
leftW := lipgloss.Width(left)
rightW := lipgloss.Width(right)
spacerW := width - leftW - rightW - 2
if spacerW < 0 {
spacerW = 0
}
bar := " " + left + strings.Repeat(" ", spacerW) + right + " "
return styleStatusBar.Width(width).Render(bar)
}
func (m AppModel) renderSplash() string {
w := m.width
h := m.height
if w == 0 {
w = 80
}
if h == 0 {
h = 24
}
// F1-themed ASCII logo
logo := []string{
"██████╗ ██████╗ ██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗",
"██╔══██╗██╔═══██╗╚██╗██╔╝ ██╔══██╗██╔═══██╗╚██╗██╔╝",
"██████╔╝██║ ██║ ╚███╔╝█████╗██████╔╝██║ ██║ ╚███╔╝ ",
"██╔══██╗██║ ██║ ██╔██╗╚════╝██╔══██╗██║ ██║ ██╔██╗ ",
"██████╔╝╚██████╔╝██╔╝ ██╗ ██████╔╝╚██████╔╝██╔╝ ██╗",
"╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
}
redStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Bold(true)
var logoBlock strings.Builder
for _, line := range logo {
logoBlock.WriteString(redStyle.Render(line) + "\n")
}
subtitle := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Render("Formula 1 Terminal Dashboard")
loadingLine := fmt.Sprintf("%s %s",
m.splashSpinner.View(),
styleMuted.Render("Loading data..."))
hint := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorSurface2)).
Render("press any key to skip")
content := lipgloss.JoinVertical(lipgloss.Center,
logoBlock.String(),
"",
subtitle,
"",
loadingLine,
"",
hint,
)
// Center the splash on screen
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content)
}

View File

@@ -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())
}

View File

@@ -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()
}

View File

@@ -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"),
),
}

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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{}

View File

@@ -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("┃")
}