diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56677d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Binaries +cmd/cmd +box-box + +# SQLite database files +*.db +*.db-wal +*.db-shm + +# Old file cache +.cache/ diff --git a/box-box b/box-box index 421d7c2..9a8f694 100755 Binary files a/box-box and b/box-box differ diff --git a/cmd/main.go b/cmd/main.go index c3bf27a..71616ff 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -11,7 +11,17 @@ import ( ) func main() { - client := api.NewOpenF1Client("https://api.openf1.org", 15*time.Second) + var client *api.OpenF1Client + if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" { + client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey) + } else { + client = api.NewOpenF1Client("https://api.openf1.org", 15*time.Second) + } + defer client.Close() + + // Clean up old file-based cache (one-time migration). + go api.CleanupOldFileCache() + model := ui.NewAppModel(client) p := tea.NewProgram( diff --git a/debug.log b/debug.log new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod index 9204e7f..92f0409 100644 --- a/go.mod +++ b/go.mod @@ -2,21 +2,28 @@ module github.com/AmanTahiliani/box-box go 1.25.6 +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/gorilla/websocket v1.5.3 + github.com/sahilm/fuzzy v0.1.1 + modernc.org/sqlite v1.47.0 +) + 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 github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/gorilla/websocket v1.5.3 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -24,9 +31,13 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/sys v0.42.0 // indirect golang.org/x/text v0.3.8 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 9e7500c..c15416a 100644 --- a/go.sum +++ b/go.sum @@ -22,10 +22,20 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -40,15 +50,55 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/cache.go b/internal/api/cache.go index 76add63..e6d5897 100644 --- a/internal/api/cache.go +++ b/internal/api/cache.go @@ -1,13 +1,14 @@ package api import ( - "crypto/sha256" - "encoding/hex" + "database/sql" "os" "path/filepath" "strings" "sync/atomic" "time" + + _ "modernc.org/sqlite" ) // CacheStats tracks cache hit/miss statistics. @@ -16,54 +17,76 @@ type CacheStats struct { Misses int64 } -// FileCache implements a file-backed HTTP response cache with TTL expiry. -type FileCache struct { - dir string +// Cache implements a SQLite-backed HTTP response cache with TTL expiry. +// The database is a single file stored in the user's cache directory. +type Cache struct { + db *sql.DB stats CacheStats } // Default TTL values. const ( - // CacheTTLShort is for current-season, frequently changing data (meetings, sessions, results). + // CacheTTLShort is for live/telemetry data that changes every few seconds. 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 + // CacheTTLForever is for data that will never change (completed past-season results). + CacheTTLForever = 0 ) -func NewFileCache() *FileCache { - var cacheDir string - userCacheDir, err := os.UserCacheDir() - if err == nil { - cacheDir = filepath.Join(userCacheDir, "box-box", "openf1") - } else { - // Fallback to a local .cache directory - cacheDir = ".cache/box-box/openf1" +// NewCache creates a SQLite-backed cache. The database file is placed in the +// user's OS cache directory under box-box/cache.db. No setup is required — the +// schema is created automatically on first run. +func NewCache() *Cache { + dbPath := cacheDBPath() + + // Ensure the parent directory exists. + _ = os.MkdirAll(filepath.Dir(dbPath), 0755) + + db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + // Fall back to in-memory if the file can't be opened. + db, _ = sql.Open("sqlite", ":memory:") } - // Ensure the cache directory exists - _ = os.MkdirAll(cacheDir, 0755) + // Limit connections — SQLite is single-writer. + db.SetMaxOpenConns(1) - return &FileCache{ - dir: cacheDir, - } + // Create the table if it doesn't exist. + _, _ = db.Exec(` + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + data BLOB NOT NULL, + created_at INTEGER NOT NULL + ) + `) + + // Create an index on created_at for efficient expiry cleanup. + _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_cache_created_at ON cache(created_at)`) + + return &Cache{db: db} } -func (c *FileCache) getCachePath(key string) string { - hash := sha256.Sum256([]byte(key)) - filename := hex.EncodeToString(hash[:]) + ".json" - return filepath.Join(c.dir, filename) +// cacheDBPath returns the path to the cache database file. +func cacheDBPath() string { + userCacheDir, err := os.UserCacheDir() + if err == nil { + return filepath.Join(userCacheDir, "box-box", "cache.db") + } + return filepath.Join(".cache", "box-box", "cache.db") } // ttlForURL determines the appropriate TTL based on the URL pattern. +// Returns 0 (CacheTTLForever) for historical data that will never change. func ttlForURL(url string) time.Duration { - // Historical data (specific year queries for past years) + // Historical data — completed past seasons never change. if strings.Contains(url, "year=2023") || strings.Contains(url, "year=2024") { - return CacheTTLLong + return CacheTTLForever } - // Frequently changing endpoints + // Live telemetry endpoints — change every few seconds during a session. if strings.Contains(url, "/position") || strings.Contains(url, "/intervals") || strings.Contains(url, "/car_data") || @@ -71,88 +94,127 @@ func ttlForURL(url string) time.Duration { return CacheTTLShort } - // Semi-stable data + // Semi-stable data — standings and driver info. if strings.Contains(url, "/championship") || strings.Contains(url, "/drivers") { return CacheTTLMedium } - // Default: medium TTL for everything else + // 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) - info, err := os.Stat(path) +func (c *Cache) Get(key string) ([]byte, bool) { + var data []byte + var createdAt int64 + + err := c.db.QueryRow( + `SELECT data, created_at FROM cache WHERE key = ?`, key, + ).Scan(&data, &createdAt) + if err != nil { atomic.AddInt64(&c.stats.Misses, 1) return nil, false } - // Check TTL based on file modification time + // Check TTL (0 = never expires). 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 + if ttl > 0 { + age := time.Since(time.Unix(createdAt, 0)) + if age > ttl { + // Expired — delete and return miss. + _, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key) + atomic.AddInt64(&c.stats.Misses, 1) + return nil, false + } } atomic.AddInt64(&c.stats.Hits, 1) return data, true } -// Set saves data to the cache. -func (c *FileCache) Set(key string, data []byte) error { - path := c.getCachePath(key) - return os.WriteFile(path, data, 0644) +// Set stores data in the cache, replacing any existing entry for the same key. +func (c *Cache) Set(key string, data []byte) error { + _, err := c.db.Exec( + `INSERT OR REPLACE INTO cache (key, data, created_at) VALUES (?, ?, ?)`, + key, data, time.Now().Unix(), + ) + return err } // Stats returns current cache hit/miss stats. -func (c *FileCache) Stats() CacheStats { +func (c *Cache) 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())) - } +// Clear removes all cached entries. +func (c *Cache) Clear() error { + _, err := c.db.Exec(`DELETE FROM cache`) + return err +} + +// Size returns the number of cached entries and total data size in bytes. +func (c *Cache) Size() (int, int64) { + var count int + var totalSize int64 + + _ = c.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(LENGTH(data)), 0) FROM cache`).Scan(&count, &totalSize) + return count, totalSize +} + +// Prune removes expired entries from the cache. This can be called periodically +// to keep the database lean. It does not touch entries with CacheTTLForever. +func (c *Cache) Prune() error { + // Remove anything older than CacheTTLLong that isn't permanent. + // We can't perfectly distinguish by URL here, so we prune entries older + // than the longest non-permanent TTL. Permanent entries are re-set on each + // access, so their created_at stays fresh. As a safe cutoff, prune anything + // older than 7 days that hasn't been refreshed — this catches stale entries + // while keeping truly permanent historical data (which gets re-stored on use). + cutoff := time.Now().Add(-7 * 24 * time.Hour).Unix() + _, err := c.db.Exec(`DELETE FROM cache WHERE created_at < ?`, cutoff) + return err +} + +// Close closes the database connection. +func (c *Cache) Close() error { + if c.db != nil { + return c.db.Close() } 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) +// CleanupOldFileCache removes the old file-based cache directory. Since file +// cache entries used SHA-256 hashed filenames (not reversible), we can't +// migrate them — just clean up. New fetches will repopulate the SQLite cache. +func CleanupOldFileCache() { + oldDir := oldFileCacheDir() + entries, err := os.ReadDir(oldDir) if err != nil { - return 0, 0 + return } - 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() - } + _ = os.Remove(filepath.Join(oldDir, entry.Name())) } } - return count, totalSize + + // Remove the old directory if empty. + remaining, _ := os.ReadDir(oldDir) + if len(remaining) == 0 { + _ = os.Remove(oldDir) + } +} + +func oldFileCacheDir() string { + userCacheDir, err := os.UserCacheDir() + if err == nil { + return filepath.Join(userCacheDir, "box-box", "openf1") + } + return filepath.Join(".cache", "box-box", "openf1") } diff --git a/internal/api/client.go b/internal/api/client.go index 85d897a..f84b9dd 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -7,15 +7,27 @@ import ( type OpenF1Client struct { url string + apiKey string httpClient *http.Client - cache *FileCache + cache *Cache } func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client { return &OpenF1Client{ url: url, httpClient: &http.Client{Timeout: timeout}, - cache: NewFileCache(), + cache: NewCache(), + } +} + +// NewOpenF1ClientWithKey creates a client that authenticates with a Bearer token. +// This allows access during live sessions (paid tier). +func NewOpenF1ClientWithKey(url string, timeout time.Duration, apiKey string) *OpenF1Client { + return &OpenF1Client{ + url: url, + apiKey: apiKey, + httpClient: &http.Client{Timeout: timeout}, + cache: NewCache(), } } @@ -28,3 +40,8 @@ func (c *OpenF1Client) CacheStats() CacheStats { func (c *OpenF1Client) CacheSize() (int, int64) { return c.cache.Size() } + +// Close releases resources held by the client (closes the cache database). +func (c *OpenF1Client) Close() error { + return c.cache.Close() +} diff --git a/internal/api/openf1.go b/internal/api/openf1.go index 4a4f09f..f779244 100644 --- a/internal/api/openf1.go +++ b/internal/api/openf1.go @@ -8,10 +8,23 @@ import ( "io" "net/http" "strconv" + "strings" + "time" "github.com/AmanTahiliani/box-box/internal/models" ) +// ErrLiveSessionLocked is returned when the OpenF1 API blocks free-tier access +// during a live F1 session. All endpoints (including historical data) return 401 +// from ~30 min before a session starts until ~30 min after it ends. +var ErrLiveSessionLocked = errors.New("live F1 session in progress — API access is restricted to authenticated users until the session ends") + +// IsLiveSessionError reports whether err (or any error in its chain) is the +// live-session lockout error from the OpenF1 API. +func IsLiveSessionError(err error) bool { + return errors.Is(err, ErrLiveSessionLocked) +} + // get performs a GET request and returns the response body, or an error if the // status code is not 200 OK. It checks a local file cache before making the request. func (c *OpenF1Client) get(url string) (io.ReadCloser, error) { @@ -19,13 +32,35 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(cachedData)), nil } - resp, err := c.httpClient.Get(url) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + // Try to parse the JSON error body for a better message. + if resp.StatusCode == http.StatusUnauthorized { + body, _ := io.ReadAll(resp.Body) + var apiErr struct { + Detail string `json:"detail"` + } + if json.Unmarshal(body, &apiErr) == nil && apiErr.Detail != "" { + detail := strings.ToLower(apiErr.Detail) + if strings.Contains(detail, "live") && strings.Contains(detail, "session") { + return nil, fmt.Errorf("%w", ErrLiveSessionLocked) + } + return nil, fmt.Errorf("openf1 API: %s", apiErr.Detail) + } + } return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url) } @@ -130,8 +165,8 @@ func (c *OpenF1Client) GetTeamChampionship(sessionKey int) ([]models.Championshi return result, nil } -// getLatestRaceSessionKey returns the session_key of the most recent Race session -// by fetching sessions filtered to session_name=Race and returning the last one. +// getLatestRaceSessionKey returns the session_key of the most recent completed +// Race session across all years. func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) { body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race", c.url)) if err != nil { @@ -146,16 +181,31 @@ func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) { if len(sessions) == 0 { return 0, errors.New("no Race sessions found") } - return sessions[len(sessions)-1].SessionKey, nil + + // Walk backwards to find the most recent completed race. + now := time.Now() + for i := len(sessions) - 1; i >= 0; i-- { + s := sessions[i] + if s.DateEnd != "" { + endTime, err := time.Parse(time.RFC3339, s.DateEnd) + if err == nil && endTime.Before(now) { + return s.SessionKey, nil + } + } else if s.DateStart != "" { + startTime, err := time.Parse(time.RFC3339, s.DateStart) + if err == nil && startTime.Add(3*time.Hour).Before(now) { + return s.SessionKey, nil + } + } + } + + return 0, errors.New("no completed Race sessions found") } -// getLatestRaceSessionKeyForYear returns the session_key of the most recent Race session -// for a specific year. +// getLatestRaceSessionKeyForYear returns the session_key of the most recent +// completed Race session for a specific year. It walks backwards through the +// year's races to find one whose date_end is in the past (i.e. has results). func (c *OpenF1Client) getLatestRaceSessionKeyForYear(year int) (int, error) { - // The OpenF1 API doesn't support a direct year filter on sessions yet (verified by docs/common patterns) - // so we'll fetch meetings for that year first, then find the latest session. - // Actually, session endpoint does support year filter according to some versions of docs. - // Let's try year filter first as it's more efficient. body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race&year=%d", c.url, year)) if err != nil { return 0, err @@ -169,7 +219,26 @@ func (c *OpenF1Client) getLatestRaceSessionKeyForYear(year int) (int, error) { if len(sessions) == 0 { return 0, fmt.Errorf("no Race sessions found for year %d", year) } - return sessions[len(sessions)-1].SessionKey, nil + + // Walk backwards to find the most recent completed race. + now := time.Now() + for i := len(sessions) - 1; i >= 0; i-- { + s := sessions[i] + if s.DateEnd != "" { + endTime, err := time.Parse(time.RFC3339, s.DateEnd) + if err == nil && endTime.Before(now) { + return s.SessionKey, nil + } + } else if s.DateStart != "" { + // Fallback: if no DateEnd, check DateStart + 3 hours as a rough estimate. + startTime, err := time.Parse(time.RFC3339, s.DateStart) + if err == nil && startTime.Add(3*time.Hour).Before(now) { + return s.SessionKey, nil + } + } + } + + return 0, fmt.Errorf("no completed Race sessions found for year %d", year) } // GetLatestDriverChampionship returns championship standings for the most recent diff --git a/internal/ui/app.go b/internal/ui/app.go index c8054ed..7949948 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -262,6 +262,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) return m, tea.Batch(cmds...) + case loadSecondaryDataMsg: + 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) diff --git a/internal/ui/calendar.go b/internal/ui/calendar.go index 4ff7b23..e86f61b 100644 --- a/internal/ui/calendar.go +++ b/internal/ui/calendar.go @@ -171,8 +171,7 @@ func (m CalendarModel) View() string { 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\n\n", m.err)) + - helpBar("r retry", "q quit") + return renderErrorView(m.err) } if len(m.meetings) == 0 { return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.\n", m.year)) diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index f540942..ce5fc7b 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -109,7 +109,7 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { } m.meetings = msg.meetings now := time.Now() - + for i := range m.meetings { mtg := m.meetings[i] end, _ := time.Parse(time.RFC3339, mtg.DateEnd) @@ -124,7 +124,7 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { } m.loading = false return m, nil - + case dashboardSessionsLoadedMsg: m.loading = false if msg.err == nil { @@ -134,6 +134,12 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { case tickCountdownMsg: return m, tickCountdown() + case tea.KeyMsg: + if matchKey(msg, GlobalKeys.Retry) && m.err != nil { + m.err = nil + m.loading = true + return m, m.Init() + } } return m, nil } @@ -143,7 +149,7 @@ func (m DashboardModel) View() string { return fmt.Sprintf("\n %s Loading dashboard...", m.spinner.View()) } if m.err != nil { - return styleError.Render(fmt.Sprintf("\n Error: %v\n", m.err)) + return renderErrorView(m.err) } if m.next == nil { @@ -163,7 +169,7 @@ func (m DashboardModel) View() string { sb.WriteString("\n") sb.WriteString(titleStyle.Render(fmt.Sprintf(" NEXT RACE: %s", m.next.MeetingOfficialName)) + "\n") sb.WriteString(fmt.Sprintf(" %s • %s\n", countryFlag(m.next.CountryCode), m.next.Location)) - + now := time.Now() end, _ := time.Parse(time.RFC3339, m.next.DateEnd) endLocal := end.Local() @@ -179,7 +185,7 @@ func (m DashboardModel) View() string { break } } - + if !startFound { nextStart, _ = time.Parse(time.RFC3339, m.next.DateStart) nextStart = nextStart.Local() @@ -201,7 +207,7 @@ func (m DashboardModel) View() string { } sb.WriteString("\n") - + // Weekend Schedule if len(m.sessions) > 0 { sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true).Render(" WEEKEND SCHEDULE (Local Time)") + "\n") @@ -210,12 +216,12 @@ func (m DashboardModel) View() string { stLocal := st.Local() day := stLocal.Format("Mon 02 Jan") tStr := stLocal.Format("15:04") - + rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)) if now.After(stLocal) { rowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)) } - + sb.WriteString(rowStyle.Render(fmt.Sprintf(" %-15s %-12s %s", s.SessionName, day, tStr)) + "\n") } } diff --git a/internal/ui/driver.go b/internal/ui/driver.go index 797de9e..11bf61b 100644 --- a/internal/ui/driver.go +++ b/internal/ui/driver.go @@ -388,8 +388,7 @@ func (m DriverModel) View() string { return fmt.Sprintf("\n %s Loading drivers...", m.spinner.View()) } if m.err != nil { - return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) + - helpBar("r retry", "q quit") + return renderErrorView(m.err) } switch m.view { diff --git a/internal/ui/live.go b/internal/ui/live.go index ebf3315..3548e0d 100644 --- a/internal/ui/live.go +++ b/internal/ui/live.go @@ -161,7 +161,7 @@ func (m LiveModel) Update(msg tea.Msg) (LiveModel, tea.Cmd) { return m, nil } m.err = nil - + // Get latest positions and intervals per driver for _, p := range msg.positions { current, exists := m.positions[p.DriverNumber] @@ -185,7 +185,7 @@ func (m LiveModel) View() string { return fmt.Sprintf("\n %s Loading live telemetry...", m.spinner.View()) } if m.err != nil && len(m.positions) == 0 { - return styleError.Render(fmt.Sprintf("\n Error: %v\n", m.err)) + return renderErrorView(m.err) } if m.session == nil { return styleMuted.Render("\n No active session found.\n") @@ -193,7 +193,7 @@ func (m LiveModel) View() string { var sb strings.Builder titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Bold(true) - + sb.WriteString("\n " + titleStyle.Render(fmt.Sprintf("LIVE: %s", m.session.SessionName)) + "\n\n") if len(m.positions) == 0 { @@ -217,7 +217,7 @@ func (m LiveModel) View() string { for _, d := range drivers { pos := m.positions[d].Position interval := m.intervals[d] - + gapToLeader := "LAP" if interval.GapToLeader != nil { gapToLeader = fmt.Sprintf("+%.3fs", *interval.GapToLeader) diff --git a/internal/ui/messages.go b/internal/ui/messages.go index 427d35c..2ea196a 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -114,3 +114,9 @@ type driverSelectedMsg struct { driver models.Driver sessionKey int } + +// loadSecondaryDataMsg triggers loading of secondary session data (race control, weather, overtakes) +// after the primary data (results, drivers) has arrived. +type loadSecondaryDataMsg struct { + sessionKey int +} diff --git a/internal/ui/official_live.go b/internal/ui/official_live.go index 1d28bdb..ea9ed94 100644 --- a/internal/ui/official_live.go +++ b/internal/ui/official_live.go @@ -8,6 +8,7 @@ import ( "net/url" "sort" "strings" + "time" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" @@ -35,21 +36,27 @@ type F1TimingLine struct { IntervalToPositionAhead struct { Value interface{} `json:"Value"` } `json:"IntervalToPositionAhead"` - Position string `json:"Position"` - RacingNumber string `json:"RacingNumber"` + Position interface{} `json:"Position"` + RacingNumber string `json:"RacingNumber"` LastLapTime struct { Value string `json:"Value"` PersonalFastest bool `json:"PersonalFastest"` OverallFastest bool `json:"OverallFastest"` } `json:"LastLapTime"` BestLapTime struct { - Value string `json:"Value"` + Value string `json:"Value"` + PersonalFastest bool `json:"PersonalFastest"` + OverallFastest bool `json:"OverallFastest"` + Lap int `json:"Lap"` } `json:"BestLapTime"` InPit interface{} `json:"InPit"` PitOut interface{} `json:"PitOut"` Retired interface{} `json:"Retired"` + KnockedOut interface{} `json:"KnockedOut"` + Cutoff interface{} `json:"Cutoff"` NumberOfLaps interface{} `json:"NumberOfLaps"` Sectors map[string]json.RawMessage `json:"Sectors"` + Speeds map[string]json.RawMessage `json:"Speeds"` } type F1DriverListEntry struct { @@ -108,10 +115,17 @@ type LiveDriverData struct { LastLapPB bool // personal best LastLapOB bool // overall best BestLapTime string + BestLapPB bool // just set a new personal best + BestLapOB bool // overall fastest in session + BestLapNum int // lap number when best was set InPit bool PitOut bool Retired bool + KnockedOut bool // eliminated in qualifying + Cutoff bool // currently in elimination zone (danger zone) + OnFlyingLap bool // currently running a timed lap (derived from sector state) NumberOfLaps int + SpeedTrap string // fastest recorded speed at speed trap Sectors [3]LiveSectorData } @@ -122,17 +136,19 @@ type LiveStintData struct { } type LiveStreamData struct { - Drivers map[string]LiveDriverData - DriverInfo map[string]F1DriverListEntry - Tyres map[string]LiveTyreData - RCMessages []LiveRCMessage - Weather LiveWeatherData - Session LiveSessionMeta - TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC - CurrentLap int - TotalLaps int - Clock string // "HH:MM:SS" remaining - Stints map[string][]LiveStintData + Drivers map[string]LiveDriverData + DriverInfo map[string]F1DriverListEntry + Tyres map[string]LiveTyreData + RCMessages []LiveRCMessage + Weather LiveWeatherData + Session LiveSessionMeta + TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC + CurrentLap int + TotalLaps int + Clock string // "HH:MM:SS" remaining at ClockRefTime + ClockRefTime time.Time // UTC when Clock was accurate + ClockExtrapolating bool // true = actively counting down + Stints map[string][]LiveStintData } // --------------------------------------------------------------------------- @@ -197,6 +213,8 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { var trackStatus string var currentLap, totalLaps int var clock string + var clockRefTime time.Time + var clockExtrapolating bool sendUpdate := func() { cpyDrivers := make(map[string]LiveDriverData) @@ -222,17 +240,19 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { select { case dataChan <- LiveStreamData{ - Drivers: cpyDrivers, - DriverInfo: cpyInfo, - Tyres: cpyTyres, - RCMessages: cpyRC, - Weather: weather, - Session: session, - TrackStatus: trackStatus, - CurrentLap: currentLap, - TotalLaps: totalLaps, - Clock: clock, - Stints: cpyStints, + Drivers: cpyDrivers, + DriverInfo: cpyInfo, + Tyres: cpyTyres, + RCMessages: cpyRC, + Weather: weather, + Session: session, + TrackStatus: trackStatus, + CurrentLap: currentLap, + TotalLaps: totalLaps, + Clock: clock, + ClockRefTime: clockRefTime, + ClockExtrapolating: clockExtrapolating, + Stints: cpyStints, }: default: } @@ -248,6 +268,10 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { } if json.Unmarshal(data, &td) == nil { for num, lineRaw := range td.Lines { + // Debug: dump first driver's raw JSON to see field types + if num == "1" || num == "81" || num == "44" { + log.Printf("[DEBUG TimingData] driver=%s raw=%s", num, string(lineRaw)) + } var line F1TimingLine if json.Unmarshal(lineRaw, &line) == nil { updateDriver(drivers, num, line) @@ -282,10 +306,25 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { } case "ExtrapolatedClock": var ec struct { - Remaining string `json:"Remaining"` + Remaining string `json:"Remaining"` + Utc string `json:"Utc"` + Extrapolating bool `json:"Extrapolating"` } if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" { clock = ec.Remaining + clockExtrapolating = ec.Extrapolating + if ec.Utc != "" { + // Try RFC3339 first, then with milliseconds + if t, err := time.Parse(time.RFC3339, ec.Utc); err == nil { + clockRefTime = t + } else if t, err := time.Parse("2006-01-02T15:04:05.999Z", ec.Utc); err == nil { + clockRefTime = t + } else { + clockRefTime = time.Now() + } + } else { + clockRefTime = time.Now() + } updated = true } case "TrackStatus": @@ -423,11 +462,15 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { } if len(driverStints) > 0 { stints[num] = driverStints - // Update tyre age from latest stint - if t, ok := tyres[num]; ok { - t.Age = driverStints[len(driverStints)-1].Laps - tyres[num] = t + // Always sync tyre from latest stint + lastStint := driverStints[len(driverStints)-1] + t := tyres[num] + t.Age = lastStint.Laps + if t.Compound == "" && lastStint.Compound != "" { + t.Compound = lastStint.Compound + t.New = lastStint.New } + tyres[num] = t updated = true } } @@ -512,19 +555,28 @@ func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLi } } - if line.Position != "" { + if line.Position != nil { var newPos int - fmt.Sscanf(line.Position, "%d", &newPos) + switch v := line.Position.(type) { + case string: + fmt.Sscanf(v, "%d", &newPos) + case float64: + newPos = int(v) + } if newPos > 0 && newPos != d.Position { d.PrevPosition = d.Position d.Position = newPos } } if line.GapToLeader != nil { - d.GapToLeader = fmt.Sprintf("%v", line.GapToLeader) + if s := extractStringVal(line.GapToLeader); s != "" { + d.GapToLeader = s + } } if line.IntervalToPositionAhead.Value != nil { - d.Interval = fmt.Sprintf("%v", line.IntervalToPositionAhead.Value) + if s := extractStringVal(line.IntervalToPositionAhead.Value); s != "" { + d.Interval = s + } } if line.LastLapTime.Value != "" { d.LastLapTime = line.LastLapTime.Value @@ -533,6 +585,11 @@ func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLi } if line.BestLapTime.Value != "" { d.BestLapTime = line.BestLapTime.Value + d.BestLapPB = line.BestLapTime.PersonalFastest + d.BestLapOB = line.BestLapTime.OverallFastest + if line.BestLapTime.Lap > 0 { + d.BestLapNum = line.BestLapTime.Lap + } } if line.InPit != nil { d.InPit = toBool(line.InPit) @@ -543,13 +600,29 @@ func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLi if line.Retired != nil { d.Retired = toBool(line.Retired) } + if line.KnockedOut != nil { + d.KnockedOut = toBool(line.KnockedOut) + } + if line.Cutoff != nil { + d.Cutoff = toBool(line.Cutoff) + } if line.NumberOfLaps != nil { if v, ok := toInt(line.NumberOfLaps); ok { d.NumberOfLaps = v } } - // Parse sector times + // Parse speed trap (ST = highest speed on track) + if st, ok := line.Speeds["ST"]; ok { + var sp struct { + Value string `json:"Value"` + } + if json.Unmarshal(st, &sp) == nil && sp.Value != "" { + d.SpeedTrap = sp.Value + } + } + + // Parse sector times — handle empty Value as a sector clear (new lap starting) for idx, sRaw := range line.Sectors { i := 0 fmt.Sscanf(idx, "%d", &i) @@ -559,19 +632,50 @@ func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLi PersonalFastest bool `json:"PersonalFastest"` OverallFastest bool `json:"OverallFastest"` } - if json.Unmarshal(sRaw, &sec) == nil && sec.Value != "" { - d.Sectors[i] = LiveSectorData{ - Value: sec.Value, - PersonalFastest: sec.PersonalFastest, - OverallFastest: sec.OverallFastest, + if json.Unmarshal(sRaw, &sec) == nil { + if sec.Value == "" { + d.Sectors[i] = LiveSectorData{} // clear = new lap starting + } else { + d.Sectors[i] = LiveSectorData{ + Value: sec.Value, + PersonalFastest: sec.PersonalFastest, + OverallFastest: sec.OverallFastest, + } } } } } + // Derive: driver is on a flying lap if S1 or S2 populated but S3 not yet + d.OnFlyingLap = !d.InPit && !d.Retired && + (d.Sectors[0].Value != "" || d.Sectors[1].Value != "") && + d.Sectors[2].Value == "" + drivers[num] = d } +// extractStringVal extracts a string from a timing value that may arrive as a +// plain string, a float64, or a {"Value": "..."} object from the SignalR feed. +func extractStringVal(v interface{}) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case float64: + if val == 0 { + return "" + } + return fmt.Sprintf("+%.3f", val) + case map[string]interface{}: + if s, ok := val["Value"].(string); ok { + return s + } + } + return "" +} + func toBool(v interface{}) bool { switch val := v.(type) { case bool: @@ -611,10 +715,50 @@ func listenForWSData(sub chan LiveStreamData) tea.Cmd { } } +type clockTickMsg time.Time + +func clockTick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return clockTickMsg(t) + }) +} + func parseGap(val string) string { return val } +// compoundAbbrevStyle returns the single-letter abbreviation and lipgloss style for a tyre compound string. +func compoundAbbrevStyle(compound string) (string, lipgloss.Style) { + switch { + case strings.Contains(compound, "SOFT") || compound == "C4" || compound == "C5": + return "S", lipgloss.NewStyle().Foreground(lipgloss.Color(colorSoft)).Bold(true) + case strings.Contains(compound, "MEDIUM") || compound == "C3": + return "M", lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)).Bold(true) + case strings.Contains(compound, "HARD") || compound == "C1" || compound == "C2": + return "H", lipgloss.NewStyle().Foreground(lipgloss.Color(colorHard)).Bold(true) + case strings.Contains(compound, "INTER"): + return "I", lipgloss.NewStyle().Foreground(lipgloss.Color(colorInter)).Bold(true) + case strings.Contains(compound, "WET"): + return "W", lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet)).Bold(true) + default: + abbrev := "?" + if compound != "" { + abbrev = string([]rune(compound)[0]) + } + return abbrev, styleMuted + } +} + +// parseHHMMSS parses "H:MM:SS" or "HH:MM:SS" into a time.Duration. +func parseHHMMSS(s string) (time.Duration, error) { + var h, m, sec int + _, err := fmt.Sscanf(s, "%d:%d:%d", &h, &m, &sec) + if err != nil { + return 0, err + } + return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec)*time.Second, nil +} + type OfficialLiveModel struct { width int height int @@ -626,12 +770,14 @@ type OfficialLiveModel struct { rcMessages []LiveRCMessage weather LiveWeatherData session LiveSessionMeta - trackStatus string - currentLap int - totalLaps int - clock string - stints map[string][]LiveStintData - err error + trackStatus string + currentLap int + totalLaps int + clock string + clockRefTime time.Time + clockExtrapolating bool + stints map[string][]LiveStintData + err error // UI state cursor int @@ -659,20 +805,91 @@ func (m OfficialLiveModel) Init() tea.Cmd { if err != nil { return func() tea.Msg { return err } } - return listenForWSData(m.dataChan) + return tea.Batch(listenForWSData(m.dataChan), clockTick()) +} + +// displayClock returns the session clock, counting down locally between feed updates. +func (m OfficialLiveModel) displayClock() string { + if m.clock == "" { + return "" + } + if !m.clockExtrapolating || m.clockRefTime.IsZero() { + return m.clock + } + remaining, err := parseHHMMSS(m.clock) + if err != nil { + return m.clock + } + elapsed := time.Since(m.clockRefTime) + actual := remaining - elapsed + if actual < 0 { + actual = 0 + } + h := int(actual.Hours()) + mnt := int(actual.Minutes()) % 60 + sec := int(actual.Seconds()) % 60 + return fmt.Sprintf("%02d:%02d:%02d", h, mnt, sec) } func (m OfficialLiveModel) sortedDrivers() []LiveDriverData { - var drivers []LiveDriverData - for _, d := range m.drivers { - if d.Position > 0 { - drivers = append(drivers, d) + // Merge timing data with driver list so all known drivers appear, + // even those who have not set a lap time yet (Position == 0). + merged := make(map[string]LiveDriverData, len(m.drivers)+len(m.driverInfo)) + for num, d := range m.drivers { + merged[num] = d + } + for num := range m.driverInfo { + if _, exists := merged[num]; !exists { + merged[num] = LiveDriverData{RacingNumber: num} } } - sort.Slice(drivers, func(i, j int) bool { - return drivers[i].Position < drivers[j].Position + + var positioned, unpositioned []LiveDriverData + for _, d := range merged { + if d.Position > 0 { + positioned = append(positioned, d) + } else { + unpositioned = append(unpositioned, d) + } + } + + sort.Slice(positioned, func(i, j int) bool { + return positioned[i].Position < positioned[j].Position }) - return drivers + sort.Slice(unpositioned, func(i, j int) bool { + var ni, nj int + fmt.Sscanf(unpositioned[i].RacingNumber, "%d", &ni) + fmt.Sscanf(unpositioned[j].RacingNumber, "%d", &nj) + return ni < nj + }) + + return append(positioned, unpositioned...) +} + +// isPracticeOrQuali returns true for Free Practice, Qualifying, and Sprint Qualifying. +// In these sessions the timing tower shows BEST lap time as the primary column. +func (m OfficialLiveModel) isPracticeOrQuali() bool { + t := strings.ToLower(m.session.SessionType) + return strings.Contains(t, "practice") || + strings.Contains(t, "qualifying") || + strings.Contains(t, "sprint") || + t == "fp1" || t == "fp2" || t == "fp3" || + t == "q" || t == "sq" +} + +// overallBestLapTime returns the string of the overall fastest BestLapTime across all drivers. +// Uses lexicographic comparison which is valid for M:SS.mmm formatted times. +func (m OfficialLiveModel) overallBestLapTime() string { + best := "" + for _, d := range m.drivers { + if d.BestLapTime == "" { + continue + } + if best == "" || d.BestLapTime < best { + best = d.BestLapTime + } + } + return best } func (m OfficialLiveModel) visibleRows() int { @@ -735,6 +952,9 @@ func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) { case error: m.err = msg return m, nil + case clockTickMsg: + // Re-render every second so the local countdown stays smooth + return m, clockTick() case wsDataMsg: m.drivers = msg.Drivers m.driverInfo = msg.DriverInfo @@ -746,6 +966,8 @@ func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) { m.currentLap = msg.CurrentLap m.totalLaps = msg.TotalLaps m.clock = msg.Clock + m.clockRefTime = msg.ClockRefTime + m.clockExtrapolating = msg.ClockExtrapolating m.stints = msg.Stints m.updateRCViewport() return m, listenForWSData(m.dataChan) @@ -872,7 +1094,11 @@ func (m OfficialLiveModel) View() string { func (m OfficialLiveModel) renderLiveHeader(w int) string { var sb strings.Builder - sessionType := m.session.SessionType + // Prefer specific session name (e.g. "FP1", "Q3") over generic type + sessionType := m.session.SessionName + if sessionType == "" { + sessionType = m.session.SessionType + } if sessionType == "" { sessionType = "LIVE" } @@ -912,8 +1138,8 @@ func (m OfficialLiveModel) renderLiveHeader(w int) string { parts = append(parts, label) } - if m.clock != "" { - parts = append(parts, styleCountdown.Render(m.clock)) + if clk := m.displayClock(); clk != "" { + parts = append(parts, styleCountdown.Render(clk)) } sb.WriteString("\n " + strings.Join(parts, " ") + "\n") @@ -988,13 +1214,23 @@ func (m OfficialLiveModel) renderTimingTower(w int) string { var sb strings.Builder drivers := m.sortedDrivers() + fpq := m.isPracticeOrQuali() var header string if m.showSectors { + timeLabel := "LAST" + if fpq { + timeLabel = "BEST" + } header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s", padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4), - padRight("TYRE", 5), padRight("LAST", 10), + padRight("TYRE", 5), padRight(timeLabel, 10), padRight("S1", 8), padRight("S2", 8), padRight("S3", 8), padRight("GAP", 10)) + } else if fpq { + header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s", + padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4), + padRight("TYRE", 5), padRight("AGE", 3), padRight("", 1), + padRight("BEST", 10), padRight("LAST", 10), padRight("GAP", 10)) } else { header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s", padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4), @@ -1010,8 +1246,9 @@ func (m OfficialLiveModel) renderTimingTower(w int) string { endIdx = len(drivers) } + overallBest := m.overallBestLapTime() for i := m.scroll; i < endIdx; i++ { - sb.WriteString(m.renderDriverRow(drivers[i], i) + "\n") + sb.WriteString(m.renderDriverRow(drivers[i], i, fpq, overallBest) + "\n") } if len(drivers) > visible { @@ -1021,7 +1258,7 @@ func (m OfficialLiveModel) renderTimingTower(w int) string { return sb.String() } -func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int) string { +func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool, overallBest string) string { info, hasInfo := m.driverInfo[d.RacingNumber] tla := d.RacingNumber teamColor := colorMuted @@ -1042,7 +1279,13 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int) string { } colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃") - tlaStr := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(teamColor)).Render(padRight(tla, 4)) + + // In qualifying, dim knocked-out drivers + tlaStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(teamColor)) + if d.KnockedOut { + tlaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)) + } + tlaStr := tlaStyle.Render(padRight(tla, 4)) tyreStr := m.renderTyreIndicator(d.RacingNumber) if d.Retired { @@ -1052,6 +1295,9 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int) string { if idx == m.cursor { return styleSelected.Render(row) } + if d.KnockedOut { + return styleMuted.Render(row) + } return row } @@ -1059,7 +1305,7 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int) string { row := fmt.Sprintf(" %s %s %s %s %s %s %s", posStr, deltaStr, colorBar, tlaStr, tyreStr, styleSafetyCar.Render(padRight("PIT", 10)), - m.renderGapStr(d)) + m.renderGapStr(d, fpq)) if idx == m.cursor { return styleSelected.Render(row) } @@ -1067,41 +1313,86 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int) string { } // Last lap time coloring - lastLap := padRight(d.LastLapTime, 10) - if d.LastLapTime != "" { + lastLapRaw := d.LastLapTime + lastLap := padRight(lastLapRaw, 10) + if lastLapRaw != "" { if d.LastLapOB { - lastLap = stylePurple.Render(padRight(d.LastLapTime, 10)) + lastLap = padRightVisible(stylePurple.Render(lastLapRaw), 10) } else if d.LastLapPB { - lastLap = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(padRight(d.LastLapTime, 10)) + lastLap = padRightVisible(lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(lastLapRaw), 10) } } var row string if m.showSectors { + timeCol := lastLap + if fpq { + timeCol = m.renderBestLapTime(d, overallBest) + } row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s", posStr, deltaStr, colorBar, tlaStr, tyreStr, - lastLap, + timeCol, m.renderSector(d.Sectors[0]), m.renderSector(d.Sectors[1]), m.renderSector(d.Sectors[2]), - m.renderGapStr(d)) + m.renderGapStr(d, fpq)) + } else if fpq { + // FP / Qualifying: show BEST lap as primary, LAST as secondary + bestLap := m.renderBestLapTime(d, overallBest) + flyingIndicator := " " + if d.OnFlyingLap { + flyingIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Render("◎") + } + row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s", + posStr, deltaStr, colorBar, tlaStr, tyreStr, + m.renderTyreAge(d.RacingNumber), + flyingIndicator, + bestLap, + lastLap, + m.renderGapStr(d, fpq)) + // Highlight danger zone (cutoff) in qualifying + if d.Cutoff && idx != m.cursor { + row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render(row) + } } else { + // Race mode: LAST + GAP + INT row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s", posStr, deltaStr, colorBar, tlaStr, tyreStr, m.renderTyreAge(d.RacingNumber), lastLap, - m.renderGapStr(d), + m.renderGapStr(d, fpq), m.renderIntvStr(d)) } if idx == m.cursor { return styleSelected.Render(row) } + if d.KnockedOut { + return styleMuted.Render(row) + } return row } -func (m OfficialLiveModel) renderGapStr(d LiveDriverData) string { +// renderBestLapTime renders a driver's session best lap time with appropriate coloring. +func (m OfficialLiveModel) renderBestLapTime(d LiveDriverData, overallBest string) string { + if d.BestLapTime == "" { + return padRightVisible(styleMuted.Render("no time"), 10) + } + isOverallBest := overallBest != "" && d.BestLapTime == overallBest + if isOverallBest || d.BestLapOB { + return padRightVisible(stylePurple.Render(d.BestLapTime), 10) + } + if d.BestLapPB { + return padRightVisible(lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(d.BestLapTime), 10) + } + return padRightVisible(styleBold.Render(d.BestLapTime), 10) +} + +func (m OfficialLiveModel) renderGapStr(d LiveDriverData, fpq bool) string { if d.Position == 1 { + if fpq { + return padRightVisible(styleLeader.Render("P1"), 10) + } return padRightVisible(styleLeader.Render("LEADER"), 10) } if g := parseGap(d.GapToLeader); g != "" { @@ -1127,22 +1418,7 @@ func (m OfficialLiveModel) renderTyreIndicator(num string) string { } compound := strings.ToUpper(tyre.Compound) - abbrev := "?" - var style lipgloss.Style - switch { - case strings.Contains(compound, "SOFT"): - abbrev, style = "S", lipgloss.NewStyle().Foreground(lipgloss.Color(colorSoft)).Bold(true) - case strings.Contains(compound, "MEDIUM"): - abbrev, style = "M", lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)).Bold(true) - case strings.Contains(compound, "HARD"): - abbrev, style = "H", lipgloss.NewStyle().Foreground(lipgloss.Color(colorHard)).Bold(true) - case strings.Contains(compound, "INTER"): - abbrev, style = "I", lipgloss.NewStyle().Foreground(lipgloss.Color(colorInter)).Bold(true) - case strings.Contains(compound, "WET"): - abbrev, style = "W", lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet)).Bold(true) - default: - style = styleMuted - } + abbrev, style := compoundAbbrevStyle(compound) newMark := " " if tyre.New { @@ -1284,22 +1560,7 @@ func (m OfficialLiveModel) renderDriverDetail(w int) string { sb.WriteString(" ") for i, st := range driverStints { compound := strings.ToUpper(st.Compound) - abbrev := "?" - var style lipgloss.Style - switch { - case strings.Contains(compound, "SOFT"): - abbrev, style = "S", lipgloss.NewStyle().Foreground(lipgloss.Color(colorSoft)).Bold(true) - case strings.Contains(compound, "MEDIUM"): - abbrev, style = "M", lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)).Bold(true) - case strings.Contains(compound, "HARD"): - abbrev, style = "H", lipgloss.NewStyle().Foreground(lipgloss.Color(colorHard)).Bold(true) - case strings.Contains(compound, "INTER"): - abbrev, style = "I", lipgloss.NewStyle().Foreground(lipgloss.Color(colorInter)).Bold(true) - case strings.Contains(compound, "WET"): - abbrev, style = "W", lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet)).Bold(true) - default: - style = styleMuted - } + abbrev, style := compoundAbbrevStyle(compound) newMark := "" if st.New { newMark = "*" @@ -1314,12 +1575,19 @@ func (m OfficialLiveModel) renderDriverDetail(w int) string { if d.BestLapTime != "" { sb.WriteString(styleMuted.Render(" Best: ") + styleBold.Render(d.BestLapTime)) - if d.LastLapOB { + if d.BestLapOB || d.LastLapOB { sb.WriteString(" " + stylePurple.Render("FL")) } + if d.BestLapNum > 0 { + sb.WriteString(styleMuted.Render(fmt.Sprintf(" (L%d)", d.BestLapNum))) + } sb.WriteString("\n") } + if d.SpeedTrap != "" { + sb.WriteString(styleMuted.Render(" Speed: ") + styleWeatherValue.Render(d.SpeedTrap+"km/h") + "\n") + } + sb.WriteString(fmt.Sprintf(" %s P%d %s %d laps\n", styleMuted.Render("Pos:"), d.Position, styleMuted.Render("Laps:"), d.NumberOfLaps)) diff --git a/internal/ui/racedetail.go b/internal/ui/racedetail.go index 4eb7a82..cccdc4f 100644 --- a/internal/ui/racedetail.go +++ b/internal/ui/racedetail.go @@ -29,10 +29,12 @@ type RaceDetailModel struct { resultsCursor int resultsScroll int - loadingSessions bool - loadingResults bool - errSessions error - errResults error + loadingSessions bool + loadingResults bool + driversLoaded bool + secondaryLoading bool + errSessions error + errResults error spinner spinner.Model rcView viewport.Model @@ -65,6 +67,8 @@ func fetchSessions(client *api.OpenF1Client, meetingKey int) tea.Cmd { } } +// fetchSessionData fetches primary data (results + drivers) for a session. +// Secondary data (race control, weather, overtakes) is loaded after primary data arrives. func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd { return tea.Batch( func() tea.Msg { @@ -75,6 +79,12 @@ func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd { drivers, err := client.GetDriversForSession(sessionKey) return sessionDriversLoadedMsg{drivers: drivers, err: err} }, + ) +} + +// fetchSecondaryData fetches lower-priority data (race control, weather, overtakes). +func fetchSecondaryData(client *api.OpenF1Client, sessionKey int) tea.Cmd { + return tea.Batch( func() tea.Msg { msgs, err := client.GetRaceControl(sessionKey) return raceControlLoadedMsg{messages: msgs, err: err} @@ -117,6 +127,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.resultsScroll = 0 m.loadingSessions = true m.loadingResults = false + m.driversLoaded = false + m.secondaryLoading = false m.errSessions = nil m.errResults = nil m.rcReady = false @@ -142,6 +154,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { sess := m.sessions[raceIdx] m.selectedSession = &sess m.loadingResults = true + m.driversLoaded = false + m.secondaryLoading = false m.results = nil m.rcMsgs = nil m.weather = nil @@ -154,6 +168,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { sess := m.sessions[lastIdx] m.selectedSession = &sess m.loadingResults = true + m.driversLoaded = false + m.secondaryLoading = false m.drivers = make(map[int]models.Driver) cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey)) } @@ -167,7 +183,9 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.results = msg.results m.resultsCursor = 0 m.resultsScroll = 0 - m.checkResultsLoaded() + if cmd := m.checkPrimaryLoaded(); cmd != nil { + cmds = append(cmds, cmd) + } case sessionDriversLoadedMsg: if msg.err == nil { @@ -175,7 +193,16 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.drivers[d.DriverNumber] = d } } - m.checkResultsLoaded() + m.driversLoaded = true + if cmd := m.checkPrimaryLoaded(); cmd != nil { + cmds = append(cmds, cmd) + } + + case loadSecondaryDataMsg: + // Only load secondary data if it's still for the currently selected session + if m.selectedSession != nil && m.selectedSession.SessionKey == msg.sessionKey { + cmds = append(cmds, fetchSecondaryData(m.client, msg.sessionKey)) + } case raceControlLoadedMsg: if msg.err == nil { @@ -203,6 +230,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { } else if m.errResults != nil && m.selectedSession != nil { m.errResults = nil m.loadingResults = true + m.driversLoaded = false + m.secondaryLoading = false cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, m.selectedSession.SessionKey)) } case matchKey(msg, GlobalKeys.Up): @@ -248,6 +277,9 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { sess := m.sessions[m.sessionCursor] m.selectedSession = &sess m.loadingResults = true + m.driversLoaded = false + m.secondaryLoading = false + m.errResults = nil m.results = nil m.rcMsgs = nil m.weather = nil @@ -264,10 +296,42 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { case matchKey(msg, RaceDetailKeys.PrevSession): if m.sessionCursor > 0 { m.sessionCursor-- + sess := m.sessions[m.sessionCursor] + if m.selectedSession == nil || m.selectedSession.SessionKey != sess.SessionKey { + m.selectedSession = &sess + m.loadingResults = true + m.driversLoaded = false + m.secondaryLoading = false + m.errResults = nil + 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, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey)) + } } case matchKey(msg, RaceDetailKeys.NextSession): if m.sessionCursor < len(m.sessions)-1 { m.sessionCursor++ + sess := m.sessions[m.sessionCursor] + if m.selectedSession == nil || m.selectedSession.SessionKey != sess.SessionKey { + m.selectedSession = &sess + m.loadingResults = true + m.driversLoaded = false + m.secondaryLoading = false + m.errResults = nil + 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, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey)) + } } } } @@ -281,10 +345,19 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *RaceDetailModel) checkResultsLoaded() { - if m.results != nil { +// checkPrimaryLoaded checks if both results and drivers have arrived. +// If so, marks loading complete and returns a command to trigger secondary data loading. +func (m *RaceDetailModel) checkPrimaryLoaded() tea.Cmd { + if m.results != nil && m.driversLoaded { m.loadingResults = false + if m.selectedSession != nil && !m.secondaryLoading { + m.secondaryLoading = true + return func() tea.Msg { + return loadSecondaryDataMsg{sessionKey: m.selectedSession.SessionKey} + } + } } + return nil } func (m RaceDetailModel) resultsVisibleRows() int { @@ -395,7 +468,7 @@ func (m RaceDetailModel) View() string { 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")) + sb.WriteString(helpBar("[/] sessions", "j/k results", "g/G top/bottom", "K/J scroll RC", "b back", "q quit")) return sb.String() } @@ -404,7 +477,7 @@ func (m RaceDetailModel) renderSessionPills() string { return fmt.Sprintf(" %s Loading sessions...", m.spinner.View()) } if m.errSessions != nil { - return styleError.Render(fmt.Sprintf(" Error: %v", m.errSessions)) + return renderErrorView(m.errSessions) } var pills []string @@ -444,11 +517,11 @@ func (m RaceDetailModel) renderResults(width int) string { return sb.String() } if m.errResults != nil { - sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults))) + sb.WriteString(renderErrorView(m.errResults)) return sb.String() } if m.selectedSession == nil { - sb.WriteString(styleMuted.Render(" Press Enter to load session results.\n")) + sb.WriteString(styleMuted.Render(" Use [ ] to select a session.\n")) return sb.String() } if len(m.results) == 0 { diff --git a/internal/ui/standings.go b/internal/ui/standings.go index cb5a50e..a8aaf34 100644 --- a/internal/ui/standings.go +++ b/internal/ui/standings.go @@ -216,8 +216,7 @@ func (m StandingsModel) View() string { 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\n\n", m.err)) + - helpBar("r retry", "q quit") + return renderErrorView(m.err) } var sb strings.Builder diff --git a/internal/ui/util.go b/internal/ui/util.go index 9367e53..adedbdc 100644 --- a/internal/ui/util.go +++ b/internal/ui/util.go @@ -7,6 +7,7 @@ import ( "time" "unicode/utf8" + "github.com/AmanTahiliani/box-box/internal/api" "github.com/AmanTahiliani/box-box/internal/models" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -459,3 +460,29 @@ func teamColorBar(teamColor string) string { } return lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃") } + +// renderErrorView returns a formatted error view. If the error is the OpenF1 +// live-session lockout, it shows a special informational banner instead of a +// raw error string. +func renderErrorView(err error) string { + if api.IsLiveSessionError(err) { + title := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorF1Red)). + Bold(true). + Render(" LIVE SESSION IN PROGRESS") + + body := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorWhite)). + Render(" The OpenF1 API restricts all access (including historical data)\n during live F1 sessions. This applies to the free tier.") + + hint := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorMuted)). + Render(" Access will be restored ~30 minutes after the session ends.\n Set OPENF1_API_KEY to bypass this restriction (paid tier).") + + return fmt.Sprintf("\n%s\n\n%s\n\n%s\n\n", title, body, hint) + + helpBar("r retry", "q quit") + } + + return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", err)) + + helpBar("r retry", "q quit") +}