Upgrades to Live and API Caching

This commit is contained in:
2026-03-27 00:30:53 -04:00
parent 7c2fcfef8d
commit 360bde4602
19 changed files with 838 additions and 225 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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