diff --git a/CLAUDE.md b/CLAUDE.md index 3d10113..2cbc460 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,89 +1,116 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -**box-box** is a Formula 1 Terminal User Interface (TUI) application written in Go. It displays F1 standings, race calendar, results, and driver data sourced from the [OpenF1 API](https://openf1.org) (free, no auth required). - ## Commands ```bash -# Build -go build ./cmd - -# Run -go run ./cmd/main.go - -# Run all tests -go test ./... - -# Run tests with output -go test -v ./internal/api - -# Install dependencies (not yet done) -go get github.com/charmbracelet/bubbletea -go get github.com/charmbracelet/lipgloss -go get github.com/charmbracelet/bubbles +go build -o box-box ./cmd/main.go # Build binary +go run cmd/main.go # Run directly +go test ./... # All tests +go test -v ./internal/api # API integration tests (requires internet, rate-limit aware) +OPENF1_API_KEY=key go run cmd/main.go # Run with paid tier (enables live session access) ``` -Tests in `internal/api/openf1_test.go` are integration tests hitting the real OpenF1 API — they include rate-limit-aware skipping logic. +## Project Overview + +**box-box** is an F1 Terminal UI (TUI) dashboard built in Go with Bubble Tea. It shows live timing, standings, race calendar, driver telemetry, track maps, and race replay — all sourced from the OpenF1 API. + +**Status**: Pre-beta, actively developed. All layers (API, models, UI) are fully implemented. + +## Tech Stack + +- **Bubble Tea** — TUI framework (Elm architecture: Model -> Update -> View) +- **Lipgloss** — Terminal styling +- **Bubbles** — TUI components (spinner, viewport, table) +- **OpenF1 API** — F1 data at `https://api.openf1.org` +- **gorilla/websocket** — Official F1 SignalR live feed +- **modernc.org/sqlite** — HTTP response caching with TTL + +## File Map + +``` +cmd/main.go Entry point (package main). Inits client, launches TUI. + +internal/api/ + client.go OpenF1Client: HTTP client, 15s timeout, optional Bearer auth + cache.go SQLite cache (~/.cache/box-box/cache.db), TTL-based, stale fallback + openf1.go 37 API endpoint methods + openf1_test.go Integration tests (real API, rate-limit skip) + +internal/models/ + types.go All data structs (Meeting, Session, Driver, Lap, Stint, etc.) + +internal/ui/ + app.go Root AppModel. 7 tabs, message routing, splash screen + messages.go All tea.Msg types for async data loading + styles.go Lipgloss styles, F1 color palette, team colors + keys.go key.Binding definitions for all keybindings + util.go Helpers: formatSeconds, sparkline, matchKey, country flags + dashboard.go Tab 0: Next race countdown + session schedule + standings.go Tab 1: Driver/constructor championship tables + calendar.go Tab 2: Season meeting list, select -> race detail + racedetail.go Tab 3: Session results, grid, sectors, RC, weather, overtakes + driver.go Tab 4: Driver list + per-driver telemetry (stints, laps, pits) + official_live.go Tab 5: Real-time timing via F1 SignalR WebSocket + live.go Legacy/alternate live timing implementation + trackmap.go Tab 6: ASCII track outline with live car positions + battles.go Sub-view: Auto-detected on-track battles with gap sparkline + pitwindow.go Sub-view: Pit stop rejoin position calculator + replay.go Sub-view: Lap-by-lap race replay scrubber +``` ## Architecture -### Tech Stack +### Bubble Tea Pattern -| Tool | Purpose | -|---|---| -| **Bubble Tea** | TUI framework using Elm architecture (Model → Update → View) | -| **Lipgloss** | Terminal styling — colors, borders, layout | -| **Bubbles** | Pre-built TUI components (tables, spinners, viewports) | -| **OpenF1 API** | F1 data source at `https://api.openf1.org/v1/` | +Each tab is a sub-model with `Init()`, `Update(msg)`, `View()`. The root `AppModel` in `app.go` holds all sub-models and routes messages by type. All state changes are message-driven — no direct mutation. -### Elm Architecture (Bubble Tea) +Async work (API calls, WebSocket) returns `tea.Cmd` that emits typed messages back to Update. Use `tea.Batch()` for parallel fetches. -All UI follows the unidirectional flow: `event → Update → View` +### Key Patterns -- **Model** — app state (active tab, loaded data, loading flags) -- **Update(msg)** — handles keypresses and API response messages, returns new model + optional `tea.Cmd` -- **View()** — renders model to a string printed to terminal -- **Cmd** — async work (API calls) that runs outside the Update loop and sends a `Msg` back when done +- **Two-phase standings load**: `GetLatestDriverChampionship()` -> extract SessionKey -> `GetDriversForSession(sessionKey)` -> join by DriverNumber for names/colors +- **Driver tab lazy load**: Drivers loaded on first Tab 4 focus via `TriggerLoad()` +- **Stale data fallback**: When API errors, client returns expired cache data + sets atomic flag for UI disclaimer banner +- **Cache TTL tiers**: 15min (live telemetry), 1hr (standings), 24hr (recent), forever (historical 2023/2024) +- **Track outline pre-fetch**: Background fetch of circuit GPS data during app init +- **`matchKey` helper**: Renamed from `key` to avoid collision with `bubbles/key` package import -Each tab (standings, calendar, results, driver) is its own Bubble Tea sub-model. The root `app.go` holds all tabs and delegates input to the active one. +### Keybindings -### Package Structure +Global: `1-7` tabs, `tab`/`shift+tab` cycle, `j/k` navigate, `enter` select, `b`/`esc` back, `y` cycle year, `g`/`G` top/bottom, `ctrl+u`/`ctrl+d` half-page, `q` quit -``` -cmd/main.go # Entry point — wire up and launch the TUI -internal/ - api/ - client.go # OpenF1Client: HTTP wrapper with 10s timeout - openf1.go # 23 endpoint methods (meetings, drivers, results, telemetry, etc.) - openf1_test.go # Integration tests for API layer - models/ - types.go # 18 data structs: Circuit, Meeting, Session, Driver, Lap, Stint, etc. - ui/ - app.go # (planned) Root model, tab switching - standings.go # (planned) Championship standings tab - calendar.go # (planned) Race calendar tab - results.go # (planned) Race results tab - driver.go # (planned) Driver lookup tab -``` +Standings: `d` driver view, `c` constructor view -### Current Status +Race Detail: `[`/`]` prev/next session, `r` replay mode, `K`/`J` scroll RC -- **API layer**: Complete — all 23 OpenF1 endpoints implemented -- **Data models**: Complete — 18 structs covering all F1 entities -- **UI layer**: Not yet implemented — `internal/ui/` is empty, `cmd/main.go` is a stub -- **Dependencies**: Not yet installed — `go.mod` has no direct deps yet +Live: `s` sectors, `r` race control, `b` battles, `p` pit window, `K`/`J` scroll RC -### API Layer +Replay: `h`/`l` or arrows scrub laps -`OpenF1Client` in `internal/api/client.go` wraps a standard `http.Client`. All methods in `openf1.go` follow the pattern: build query params → GET from `https://api.openf1.org/v1/{endpoint}` → decode JSON into model types. +### API Endpoint Groups -Key endpoint groups: -- **Session context**: `GetMeetings`, `GetSessions` -- **Standings**: `GetDriverChampionship`, `GetTeamChampionship` -- **Race data**: `GetSessionResults`, `GetStartingGrid`, `GetLaps`, `GetStints`, `GetPits` -- **Live telemetry**: `GetPositions`, `GetIntervals`, `GetCarData`, `GetLocations` -- **Race events**: `GetRaceControl`, `GetOvertakes`, `GetWeather`, `GetTeamRadio` +- **Season**: `GetMeetingsForYear`, `GetSessionsForMeeting` +- **Championship**: `GetDriverChampionshipForYear`, `GetTeamChampionshipForYear`, `GetLatest*` +- **Results**: `GetSessionResult`, `GetStartingGrid`, `GetStintsForSession` +- **Telemetry**: `GetLapsForDriver`, `GetPitStopsForSession`, `GetPositions`, `GetIntervals` +- **Live**: `GetCarData`, `GetLocation` (GPS), `GetTeamRadio` +- **Events**: `GetRaceControl`, `GetOvertakes`, `GetWeather` +- **Track**: `PrefetchTrackOutlines` + +## How To Extend + +- **New tab**: Create model in `internal/ui/`, add to `AppModel` struct in `app.go`, add tab constant, implement `Init/Update/View`, handle message routing in `app.go Update()` +- **New API endpoint**: Add method to `openf1.go`, add response struct to `types.go`, set cache TTL in the method +- **New message type**: Define in `messages.go`, handle in relevant model's `Update()` +- **New keybinding**: Define in `keys.go`, handle in relevant model's `Update()` +- **New styles**: Add to `styles.go`, reference F1 palette constants + +## Testing + +Tests in `openf1_test.go` hit the real OpenF1 API. They use `skipOnRateLimit(t, err)` to gracefully skip on HTTP 429. Require internet. + +## Environment + +- `OPENF1_API_KEY` — Optional Bearer token for paid tier (live session WebSocket access) +- Logs go to `box-box.log` in project root (prevents TUI pollution) +- Cache at `~/.cache/box-box/cache.db` (SQLite WAL mode, auto-created) diff --git a/box-box b/box-box index 01696ce..c5d3cc5 100755 Binary files a/box-box and b/box-box differ diff --git a/internal/api/cache.go b/internal/api/cache.go index e6d5897..3b2826a 100644 --- a/internal/api/cache.go +++ b/internal/api/cache.go @@ -2,12 +2,14 @@ package api import ( "database/sql" + "encoding/json" "os" "path/filepath" "strings" "sync/atomic" "time" + "github.com/AmanTahiliani/box-box/internal/models" _ "modernc.org/sqlite" ) @@ -54,7 +56,7 @@ func NewCache() *Cache { // Limit connections — SQLite is single-writer. db.SetMaxOpenConns(1) - // Create the table if it doesn't exist. + // Create the HTTP response cache table if it doesn't exist. _, _ = db.Exec(` CREATE TABLE IF NOT EXISTS cache ( key TEXT PRIMARY KEY, @@ -66,6 +68,19 @@ func NewCache() *Cache { // 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)`) + // Create the track outlines table — stores pre-fetched GPS location data + // keyed by (circuit_key, year) so the track map works during live sessions + // when the free-tier API is locked. + _, _ = db.Exec(` + CREATE TABLE IF NOT EXISTS track_outlines ( + circuit_key INTEGER NOT NULL, + year INTEGER NOT NULL, + data BLOB NOT NULL, + fetched_at INTEGER NOT NULL, + PRIMARY KEY (circuit_key, year) + ) + `) + return &Cache{db: db} } @@ -134,6 +149,24 @@ func (c *Cache) Get(key string) ([]byte, bool) { return data, true } +// GetStale retrieves data from the cache regardless of TTL expiry. This is +// used as a last-resort fallback when the API is unreachable (e.g. during a +// live session lockout on the free tier). The entry is NOT deleted even if it +// has expired — it remains available for future stale reads. +// Returns nil, false only when the key is not in the cache at all. +func (c *Cache) GetStale(key string) ([]byte, bool) { + var data []byte + + err := c.db.QueryRow( + `SELECT data FROM cache WHERE key = ?`, key, + ).Scan(&data) + + if err != nil { + return nil, false + } + return data, true +} + // 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( @@ -188,6 +221,45 @@ func (c *Cache) Close() error { return nil } +// --------------------------------------------------------------------------- +// Track outline persistence +// --------------------------------------------------------------------------- + +// GetTrackOutline retrieves pre-fetched GPS location data for a circuit in a +// given season year. Returns the locations and true if a record exists for +// that (circuit_key, year) pair, otherwise nil and false. +func (c *Cache) GetTrackOutline(circuitKey, year int) ([]models.Location, bool) { + var raw []byte + err := c.db.QueryRow( + `SELECT data FROM track_outlines WHERE circuit_key = ? AND year = ?`, + circuitKey, year, + ).Scan(&raw) + if err != nil { + return nil, false + } + + var locs []models.Location + if err := json.Unmarshal(raw, &locs); err != nil { + return nil, false + } + return locs, true +} + +// SetTrackOutline persists GPS location data for a circuit in a given season +// year. The data is stored as a JSON blob and keyed by (circuit_key, year). +// Calling this again for the same key overwrites the existing record. +func (c *Cache) SetTrackOutline(circuitKey, year int, locs []models.Location) error { + raw, err := json.Marshal(locs) + if err != nil { + return err + } + _, err = c.db.Exec( + `INSERT OR REPLACE INTO track_outlines (circuit_key, year, data, fetched_at) VALUES (?, ?, ?, ?)`, + circuitKey, year, raw, time.Now().Unix(), + ) + return err +} + // 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. diff --git a/internal/api/client.go b/internal/api/client.go index f84b9dd..976e139 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "sync/atomic" "time" ) @@ -10,6 +11,12 @@ type OpenF1Client struct { apiKey string httpClient *http.Client cache *Cache + + // staleFlag is set to 1 atomically whenever a request falls back to stale + // cached data (e.g. because the API is locked during a live session). + // The UI reads this via LastResponseWasStale() to decide whether to show + // a disclaimer banner. The flag is sticky until ClearStaleFlag() is called. + staleFlag int32 } func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client { @@ -31,6 +38,31 @@ func NewOpenF1ClientWithKey(url string, timeout time.Duration, apiKey string) *O } } +// Cache returns the underlying Cache so callers can access track outline +// storage and other persistent data directly. +func (c *OpenF1Client) Cache() *Cache { + return c.cache +} + +// LastResponseWasStale reports whether the most recent API request (or any +// request since the last ClearStaleFlag call) fell back to expired cached +// data because the API was unavailable. The UI uses this to show a +// disclaimer banner informing the user that data may be stale. +func (c *OpenF1Client) LastResponseWasStale() bool { + return atomic.LoadInt32(&c.staleFlag) == 1 +} + +// ClearStaleFlag resets the stale indicator. Call this when navigating away +// from a tab or after the disclaimer has been acknowledged. +func (c *OpenF1Client) ClearStaleFlag() { + atomic.StoreInt32(&c.staleFlag, 0) +} + +// setStale marks the client as having served stale data. +func (c *OpenF1Client) setStale() { + atomic.StoreInt32(&c.staleFlag, 1) +} + // CacheStats returns the cache hit/miss statistics. func (c *OpenF1Client) CacheStats() CacheStats { return c.cache.Stats() diff --git a/internal/api/openf1.go b/internal/api/openf1.go index f779244..6f4c85f 100644 --- a/internal/api/openf1.go +++ b/internal/api/openf1.go @@ -9,6 +9,7 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "github.com/AmanTahiliani/box-box/internal/models" @@ -26,15 +27,24 @@ func IsLiveSessionError(err error) bool { } // 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. +// status code is not 200 OK. It checks the SQLite cache before making a +// network request. +// +// Stale fallback: if the live request fails for any reason (network error, +// 401 lockout during a live session, etc.) and the cache contains an expired +// entry for this URL, that stale entry is returned instead of propagating the +// error. The client's staleFlag is set so the UI can show a disclaimer. func (c *OpenF1Client) get(url string) (io.ReadCloser, error) { + // 1. Check the cache for a fresh (non-expired) entry. if cachedData, ok := c.cache.Get(url); ok { return io.NopCloser(bytes.NewReader(cachedData)), nil } + // 2. Attempt a live network request. req, err := http.NewRequest("GET", url, nil) if err != nil { - return nil, err + // Even a request-construction failure warrants a stale fallback. + return c.tryStale(url, err) } if c.apiKey != "" { req.Header.Set("Authorization", "Bearer "+c.apiKey) @@ -42,7 +52,7 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) { resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return c.tryStale(url, err) } defer resp.Body.Close() @@ -56,25 +66,40 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) { 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) + liveErr := fmt.Errorf("%w", ErrLiveSessionLocked) + return c.tryStale(url, liveErr) } - return nil, fmt.Errorf("openf1 API: %s", apiErr.Detail) + apiErrFmt := fmt.Errorf("openf1 API: %s", apiErr.Detail) + return c.tryStale(url, apiErrFmt) } } - return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url) + statusErr := fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url) + return c.tryStale(url, statusErr) } + // 3. Success — read the body, store in cache, return. data, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return c.tryStale(url, err) } - // Save to cache (ignoring errors as cache is not critical) + // Store fresh data in cache (non-critical; ignore errors). _ = c.cache.Set(url, data) return io.NopCloser(bytes.NewReader(data)), nil } +// tryStale attempts to return stale cached data when a live request has failed. +// If stale data exists it sets the client's stale flag and returns the data. +// Otherwise it returns the original error unchanged so callers can handle it. +func (c *OpenF1Client) tryStale(url string, originalErr error) (io.ReadCloser, error) { + if staleData, ok := c.cache.GetStale(url); ok { + c.setStale() + return io.NopCloser(bytes.NewReader(staleData)), nil + } + return nil, originalErr +} + func (c *OpenF1Client) GetMeetingsForYear(year int) ([]models.Meeting, error) { if year < 2023 { return nil, errors.New("Invalid year: " + strconv.Itoa(year) + ". Year must be 2023 or later.") @@ -478,3 +503,100 @@ func (c *OpenF1Client) GetTeamRadio(sessionKey, driverNumber int) ([]models.Team } return result, nil } + +// --------------------------------------------------------------------------- +// Track outline pre-fetch +// --------------------------------------------------------------------------- + +// candidateDrivers is the ordered list of driver numbers we try when looking +// for location data to build a track outline. We try well-known numbers first +// to maximise the chance of finding data quickly. +var candidateDrivers = []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24} + +// PrefetchTrackOutlines fetches GPS location data for every circuit in the +// provided meeting list and stores it in the cache so the track map tab can +// render during live sessions when the free-tier API is locked. +// +// Each meeting is processed concurrently (up to maxWorkers goroutines). +// Circuits that already have a stored outline for this year are skipped. +// Errors per-circuit are silently ignored — this is a best-effort operation +// and must never block or crash the main UI. +func (c *OpenF1Client) PrefetchTrackOutlines(meetings []models.Meeting) { + const maxWorkers = 3 + + year := time.Now().Year() + + // Filter to meetings that need fetching. + var pending []models.Meeting + for _, m := range meetings { + if m.CircuitKey == 0 { + continue + } + if _, ok := c.cache.GetTrackOutline(m.CircuitKey, year); ok { + continue // already cached for this season + } + pending = append(pending, m) + } + + if len(pending) == 0 { + return + } + + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + + for _, mtg := range pending { + mtg := mtg // capture + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + c.prefetchCircuit(mtg, year) + }() + } + + wg.Wait() +} + +// prefetchCircuit fetches the track outline for a single meeting and stores it. +// It prefers completed sessions (past date_end) so the data is full and stable. +func (c *OpenF1Client) prefetchCircuit(mtg models.Meeting, year int) { + sessions, err := c.GetSessionsForMeeting(int(mtg.MeetingKey)) + if err != nil || len(sessions) == 0 { + return + } + + // Pick the best session: prefer a completed race, then any session with + // a past end time, then fall back to the most recent session. + now := time.Now() + var bestSession *models.Session + for i := range sessions { + s := &sessions[i] + if s.DateEnd == "" { + continue + } + endTime, err := time.Parse(time.RFC3339, s.DateEnd) + if err != nil || endTime.After(now) { + continue + } + // Prefer Race sessions; otherwise take any completed session. + if bestSession == nil || s.SessionName == "Race" { + bestSession = s + } + } + if bestSession == nil { + return + } + + // Try candidate drivers in order until we find one with enough points. + for _, dn := range candidateDrivers { + locs, err := c.GetLocation(bestSession.SessionKey, dn) + if err != nil || len(locs) < 50 { + continue + } + // Store under the circuit key for this year and stop. + _ = c.cache.SetTrackOutline(mtg.CircuitKey, year, locs) + return + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index bbb2288..4910d49 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -32,6 +32,11 @@ var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴", "🗺"} // splashDoneMsg is sent after the splash screen duration has elapsed. type splashDoneMsg struct{} +// trackPrefetchDoneMsg is sent (and silently ignored) when the background +// track-outline pre-fetch finishes. It carries no data — its only purpose is +// to satisfy the tea.Cmd contract. +type trackPrefetchDoneMsg struct{} + // AppModel is the root Bubble Tea model. type AppModel struct { client *api.OpenF1Client @@ -85,6 +90,21 @@ func splashTimer() tea.Cmd { }) } +// prefetchTrackOutlines fetches the season calendar and then pre-populates +// the track outline cache for every circuit. This runs as a background command +// so the UI is never blocked. Errors are silently discarded — this is a +// best-effort operation. +func prefetchTrackOutlines(client *api.OpenF1Client, year int) tea.Cmd { + return func() tea.Msg { + meetings, err := client.GetMeetingsForYear(year) + if err != nil || len(meetings) == 0 { + return trackPrefetchDoneMsg{} + } + client.PrefetchTrackOutlines(meetings) + return trackPrefetchDoneMsg{} + } +} + func (m AppModel) Init() tea.Cmd { return tea.Batch( m.dashboard.Init(), @@ -96,6 +116,9 @@ func (m AppModel) Init() tea.Cmd { m.trackMap.Init(), m.splashSpinner.Tick, splashTimer(), + // Background: pre-fetch track outlines for all circuits this season + // so the track map works during live sessions when the API is locked. + prefetchTrackOutlines(m.client, m.year), ) } @@ -123,6 +146,10 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.showSplash = false return m, nil + case trackPrefetchDoneMsg: + // Background track pre-fetch finished — nothing to display. + return m, nil + case tea.KeyMsg: // Any key press during splash dismisses it if m.showSplash { diff --git a/internal/ui/calendar.go b/internal/ui/calendar.go index befd4b9..84ef15b 100644 --- a/internal/ui/calendar.go +++ b/internal/ui/calendar.go @@ -16,6 +16,7 @@ type CalendarModel struct { client *api.OpenF1Client meetings []models.Meeting loading bool + stale bool err error spinner spinner.Model year int @@ -76,6 +77,9 @@ func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) { } m.meetings = msg.meetings m.loading = false + if m.client.LastResponseWasStale() { + m.stale = true + } // Auto-scroll to next upcoming race m.cursor = m.findNextRaceIndex() m.ensureCursorVisible() @@ -85,6 +89,7 @@ func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) { case matchKey(msg, GlobalKeys.Retry): if m.err != nil { m.err = nil + m.stale = false m.loading = true return m, tea.Batch(m.spinner.Tick, fetchMeetings(m.client, m.year)) } @@ -197,6 +202,10 @@ func (m CalendarModel) View() string { var sb strings.Builder + if m.stale { + sb.WriteString(renderStaleBanner()) + } + // Title title := lipgloss.NewStyle(). Bold(true). diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go index 476c9e1..df28579 100644 --- a/internal/ui/dashboard.go +++ b/internal/ui/dashboard.go @@ -19,6 +19,7 @@ type DashboardModel struct { width int height int loading bool + stale bool spinner spinner.Model meetings []models.Meeting next *models.Meeting @@ -108,6 +109,9 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { return m, nil } m.meetings = msg.meetings + if m.client.LastResponseWasStale() { + m.stale = true + } now := time.Now() // First priority: find a meeting that has already started but not yet ended @@ -144,6 +148,9 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { m.loading = false if msg.err == nil { m.sessions = msg.sessions + if m.client.LastResponseWasStale() { + m.stale = true + } } return m, nil @@ -152,6 +159,7 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { case tea.KeyMsg: if matchKey(msg, GlobalKeys.Retry) && m.err != nil { m.err = nil + m.stale = false m.loading = true return m, m.Init() } @@ -177,6 +185,10 @@ func (m DashboardModel) View() string { w = 40 } + if m.stale { + sb.WriteString(renderStaleBanner()) + } + titleStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color(colorF1Red)). Bold(true) diff --git a/internal/ui/driver.go b/internal/ui/driver.go index eab016f..6480c46 100644 --- a/internal/ui/driver.go +++ b/internal/ui/driver.go @@ -38,6 +38,7 @@ type DriverModel struct { view driverView loading bool + stale bool err error spinner spinner.Model @@ -168,6 +169,9 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) { return m, nil } m.drivers = msg.drivers + if m.client.LastResponseWasStale() { + m.stale = true + } m.filterDrivers() case driverStintsLoadedMsg: @@ -222,6 +226,7 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) { case matchKey(msg, GlobalKeys.Retry): if m.err != nil { m.err = nil + m.stale = false m.loading = true var cmd tea.Cmd m, cmd = m.TriggerLoad() @@ -391,11 +396,17 @@ func (m DriverModel) View() string { return renderErrorView(m.err) } + // Prepend stale banner when showing the list or detail view + prefix := "" + if m.stale { + prefix = renderStaleBanner() + } + switch m.view { case driverViewList: - return m.renderDriverList() + return prefix + m.renderDriverList() case driverViewDetail: - return m.renderDriverDetail() + return prefix + m.renderDriverDetail() } return "" } diff --git a/internal/ui/racedetail.go b/internal/ui/racedetail.go index 0af8315..e5a86f4 100644 --- a/internal/ui/racedetail.go +++ b/internal/ui/racedetail.go @@ -32,6 +32,7 @@ type RaceDetailModel struct { loadingSessions bool loadingResults bool + stale bool driversLoaded bool secondaryLoading bool errSessions error @@ -149,6 +150,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.resultsScroll = 0 m.loadingSessions = true m.loadingResults = false + m.stale = false m.driversLoaded = false m.secondaryLoading = false m.errSessions = nil @@ -163,6 +165,9 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { return m, nil } m.sessions = msg.sessions + if m.client.LastResponseWasStale() { + m.stale = true + } // Auto-select the best session to show: // 1. The last session that has already started (ongoing or completed). @@ -207,6 +212,9 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.results = msg.results m.resultsCursor = 0 m.resultsScroll = 0 + if m.client.LastResponseWasStale() { + m.stale = true + } if cmd := m.checkPrimaryLoaded(); cmd != nil { cmds = append(cmds, cmd) } @@ -468,6 +476,10 @@ func (m RaceDetailModel) View() string { var sb strings.Builder + if m.stale { + sb.WriteString(renderStaleBanner()) + } + // Race title header flag := countryFlag(m.meeting.CountryCode) titleStyle := lipgloss.NewStyle(). diff --git a/internal/ui/standings.go b/internal/ui/standings.go index a8aaf34..80ebfc0 100644 --- a/internal/ui/standings.go +++ b/internal/ui/standings.go @@ -27,6 +27,7 @@ type StandingsModel struct { view standingsView loading bool + stale bool err error spinner spinner.Model @@ -104,6 +105,9 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) { return m, nil } m.driverStandings = msg.standings + if m.client.LastResponseWasStale() { + m.stale = true + } if len(msg.standings) > 0 { return m, fetchStandingsDrivers(m.client, msg.standings[0].SessionKey) } @@ -115,6 +119,9 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) { return m, nil } m.teamStandings = msg.standings + if m.client.LastResponseWasStale() { + m.stale = true + } case standingsDriversLoadedMsg: if msg.err != nil { @@ -132,6 +139,7 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) { case matchKey(msg, GlobalKeys.Retry): if m.err != nil { m.err = nil + m.stale = false m.loading = true return m, m.Init() } @@ -221,6 +229,10 @@ func (m StandingsModel) View() string { var sb strings.Builder + if m.stale { + sb.WriteString(renderStaleBanner()) + } + // Title row with year and toggle title := lipgloss.NewStyle(). Bold(true). diff --git a/internal/ui/trackmap.go b/internal/ui/trackmap.go index 4cf5713..75929ec 100644 --- a/internal/ui/trackmap.go +++ b/internal/ui/trackmap.go @@ -30,8 +30,11 @@ type TrackMapModel struct { width int height int - // Resolved session key used to fetch location data + // Resolved session key used to fetch live location data. sessionKey int + // circuitKey identifies the physical circuit for cached track outline lookups. + // It is stable across sessions and years for the same track. + circuitKey int // Track outline (normalized points from driver 1's path) outline []trackPoint @@ -123,12 +126,16 @@ func (m *TrackMapModel) fetchActiveSession(client *api.OpenF1Client, year int) t return trackOutlineLoadedMsg{err: fmt.Errorf("no active session found")} } - return sessionKeyMsg{sessionKey: activeSess.SessionKey} + return sessionKeyMsg{ + sessionKey: activeSess.SessionKey, + circuitKey: currentMtg.CircuitKey, + } } } type sessionKeyMsg struct { sessionKey int + circuitKey int } // --------------------------------------------------------------------------- @@ -152,13 +159,30 @@ type trackCarsLoadedMsg struct { // fetchTrackOutline downloads location data for a single reference driver // (driver 1 by convention, then any driver if 1 is absent) to build the // track outline for the given session. -func fetchTrackOutline(client *api.OpenF1Client, sessionKey int) tea.Cmd { +// +// It first checks the persistent track outline cache keyed by circuitKey. +// If a stored outline exists for this season it is used directly, which means +// the track map works even during a live-session API lockout on the free tier. +func fetchTrackOutline(client *api.OpenF1Client, sessionKey, circuitKey int) tea.Cmd { return func() tea.Msg { - // Try a set of likely driver numbers to find one with location data. + year := time.Now().Year() + + // Check the pre-fetched outline cache before hitting the API. + if circuitKey != 0 { + if locs, ok := client.Cache().GetTrackOutline(circuitKey, year); ok && len(locs) >= 50 { + return trackOutlineLoadedMsg{locations: locs} + } + } + + // Fall back to a live API fetch. candidates := []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24} for _, dn := range candidates { locs, err := client.GetLocation(sessionKey, dn) if err == nil && len(locs) > 50 { + // Opportunistically save to the track outline cache for next time. + if circuitKey != 0 { + _ = client.Cache().SetTrackOutline(circuitKey, year, locs) + } return trackOutlineLoadedMsg{locations: locs} } } @@ -198,17 +222,19 @@ func (m TrackMapModel) Init() tea.Cmd { // SetSessionKey wires the track map to a specific session. If the session // differs from the one already loaded, it triggers a fresh outline fetch. -func (m TrackMapModel) SetSessionKey(sessionKey int) (TrackMapModel, tea.Cmd) { +// circuitKey is used to look up the pre-cached track outline for this circuit. +func (m TrackMapModel) SetSessionKey(sessionKey, circuitKey int) (TrackMapModel, tea.Cmd) { if sessionKey == m.sessionKey && m.outlineReady { return m, nil } m.sessionKey = sessionKey + m.circuitKey = circuitKey m.loadingOutline = true m.outlineReady = false m.outline = nil m.carPositions = make(map[int]models.Location) m.err = nil - return m, tea.Batch(fetchTrackOutline(m.client, sessionKey), m.spinner.Tick) + return m, tea.Batch(fetchTrackOutline(m.client, sessionKey, circuitKey), m.spinner.Tick) } // InjectDriverInfo forwards the latest DriverInfo map from OfficialLiveModel @@ -263,14 +289,15 @@ func (m TrackMapModel) Update(msg tea.Msg) (TrackMapModel, tea.Cmd) { case sessionKeyMsg: m.loadingSession = false - if msg.sessionKey != m.sessionKey { + if msg.sessionKey != m.sessionKey || msg.circuitKey != m.circuitKey { m.sessionKey = msg.sessionKey + m.circuitKey = msg.circuitKey m.loadingOutline = true m.outlineReady = false m.outline = nil m.carPositions = make(map[int]models.Location) m.err = nil - return m, tea.Batch(fetchTrackOutline(m.client, msg.sessionKey), m.spinner.Tick) + return m, tea.Batch(fetchTrackOutline(m.client, msg.sessionKey, msg.circuitKey), m.spinner.Tick) } return m, nil diff --git a/internal/ui/util.go b/internal/ui/util.go index b4cd31e..2ee50e9 100644 --- a/internal/ui/util.go +++ b/internal/ui/util.go @@ -531,6 +531,19 @@ func teamColorBar(teamColor string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃") } +// renderStaleBanner returns a yellow warning banner indicating that the +// displayed data was served from an expired cache (stale fallback). +// Shown at the top of any tab whose data came from stale cache during a live +// session lockout. +func renderStaleBanner() string { + return lipgloss.NewStyle(). + Background(lipgloss.Color(colorYellow)). + Foreground(lipgloss.Color(colorF1Black)). + Bold(true). + Padding(0, 1). + Render(" ⚠ STALE DATA — served from cache (live session in progress) ") + "\n\n" +} + // 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.