mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
TUI Polish
This commit is contained in:
161
CLAUDE.md
161
CLAUDE.md
@@ -1,89 +1,116 @@
|
|||||||
# CLAUDE.md
|
# 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
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build
|
go build -o box-box ./cmd/main.go # Build binary
|
||||||
go build ./cmd
|
go run cmd/main.go # Run directly
|
||||||
|
go test ./... # All tests
|
||||||
# Run
|
go test -v ./internal/api # API integration tests (requires internet, rate-limit aware)
|
||||||
go run ./cmd/main.go
|
OPENF1_API_KEY=key go run cmd/main.go # Run with paid tier (enables live session access)
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Architecture
|
||||||
|
|
||||||
### Tech Stack
|
### Bubble Tea Pattern
|
||||||
|
|
||||||
| Tool | Purpose |
|
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.
|
||||||
|---|---|
|
|
||||||
| **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/` |
|
|
||||||
|
|
||||||
### 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)
|
- **Two-phase standings load**: `GetLatestDriverChampionship()` -> extract SessionKey -> `GetDriversForSession(sessionKey)` -> join by DriverNumber for names/colors
|
||||||
- **Update(msg)** — handles keypresses and API response messages, returns new model + optional `tea.Cmd`
|
- **Driver tab lazy load**: Drivers loaded on first Tab 4 focus via `TriggerLoad()`
|
||||||
- **View()** — renders model to a string printed to terminal
|
- **Stale data fallback**: When API errors, client returns expired cache data + sets atomic flag for UI disclaimer banner
|
||||||
- **Cmd** — async work (API calls) that runs outside the Update loop and sends a `Msg` back when done
|
- **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
|
||||||
|
|
||||||
```
|
Standings: `d` driver view, `c` constructor view
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Current Status
|
Race Detail: `[`/`]` prev/next session, `r` replay mode, `K`/`J` scroll RC
|
||||||
|
|
||||||
- **API layer**: Complete — all 23 OpenF1 endpoints implemented
|
Live: `s` sectors, `r` race control, `b` battles, `p` pit window, `K`/`J` scroll RC
|
||||||
- **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
|
|
||||||
|
|
||||||
### 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:
|
- **Season**: `GetMeetingsForYear`, `GetSessionsForMeeting`
|
||||||
- **Session context**: `GetMeetings`, `GetSessions`
|
- **Championship**: `GetDriverChampionshipForYear`, `GetTeamChampionshipForYear`, `GetLatest*`
|
||||||
- **Standings**: `GetDriverChampionship`, `GetTeamChampionship`
|
- **Results**: `GetSessionResult`, `GetStartingGrid`, `GetStintsForSession`
|
||||||
- **Race data**: `GetSessionResults`, `GetStartingGrid`, `GetLaps`, `GetStints`, `GetPits`
|
- **Telemetry**: `GetLapsForDriver`, `GetPitStopsForSession`, `GetPositions`, `GetIntervals`
|
||||||
- **Live telemetry**: `GetPositions`, `GetIntervals`, `GetCarData`, `GetLocations`
|
- **Live**: `GetCarData`, `GetLocation` (GPS), `GetTeamRadio`
|
||||||
- **Race events**: `GetRaceControl`, `GetOvertakes`, `GetWeather`, `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)
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ func NewCache() *Cache {
|
|||||||
// Limit connections — SQLite is single-writer.
|
// Limit connections — SQLite is single-writer.
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
|
|
||||||
// Create the table if it doesn't exist.
|
// Create the HTTP response cache table if it doesn't exist.
|
||||||
_, _ = db.Exec(`
|
_, _ = db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS cache (
|
CREATE TABLE IF NOT EXISTS cache (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -66,6 +68,19 @@ func NewCache() *Cache {
|
|||||||
// Create an index on created_at for efficient expiry cleanup.
|
// 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)`)
|
_, _ = 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}
|
return &Cache{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +149,24 @@ func (c *Cache) Get(key string) ([]byte, bool) {
|
|||||||
return data, true
|
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.
|
// Set stores data in the cache, replacing any existing entry for the same key.
|
||||||
func (c *Cache) Set(key string, data []byte) error {
|
func (c *Cache) Set(key string, data []byte) error {
|
||||||
_, err := c.db.Exec(
|
_, err := c.db.Exec(
|
||||||
@@ -188,6 +221,45 @@ func (c *Cache) Close() error {
|
|||||||
return nil
|
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
|
// CleanupOldFileCache removes the old file-based cache directory. Since file
|
||||||
// cache entries used SHA-256 hashed filenames (not reversible), we can't
|
// cache entries used SHA-256 hashed filenames (not reversible), we can't
|
||||||
// migrate them — just clean up. New fetches will repopulate the SQLite cache.
|
// migrate them — just clean up. New fetches will repopulate the SQLite cache.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,6 +11,12 @@ type OpenF1Client struct {
|
|||||||
apiKey string
|
apiKey string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
cache *Cache
|
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 {
|
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.
|
// CacheStats returns the cache hit/miss statistics.
|
||||||
func (c *OpenF1Client) CacheStats() CacheStats {
|
func (c *OpenF1Client) CacheStats() CacheStats {
|
||||||
return c.cache.Stats()
|
return c.cache.Stats()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"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
|
// 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) {
|
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 {
|
if cachedData, ok := c.cache.Get(url); ok {
|
||||||
return io.NopCloser(bytes.NewReader(cachedData)), nil
|
return io.NopCloser(bytes.NewReader(cachedData)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Attempt a live network request.
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
// Even a request-construction failure warrants a stale fallback.
|
||||||
|
return c.tryStale(url, err)
|
||||||
}
|
}
|
||||||
if c.apiKey != "" {
|
if c.apiKey != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+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)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return c.tryStale(url, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 != "" {
|
if json.Unmarshal(body, &apiErr) == nil && apiErr.Detail != "" {
|
||||||
detail := strings.ToLower(apiErr.Detail)
|
detail := strings.ToLower(apiErr.Detail)
|
||||||
if strings.Contains(detail, "live") && strings.Contains(detail, "session") {
|
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)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
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)
|
_ = c.cache.Set(url, data)
|
||||||
|
|
||||||
return io.NopCloser(bytes.NewReader(data)), nil
|
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) {
|
func (c *OpenF1Client) GetMeetingsForYear(year int) ([]models.Meeting, error) {
|
||||||
if year < 2023 {
|
if year < 2023 {
|
||||||
return nil, errors.New("Invalid year: " + strconv.Itoa(year) + ". Year must be 2023 or later.")
|
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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴", "🗺"}
|
|||||||
// splashDoneMsg is sent after the splash screen duration has elapsed.
|
// splashDoneMsg is sent after the splash screen duration has elapsed.
|
||||||
type splashDoneMsg struct{}
|
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.
|
// AppModel is the root Bubble Tea model.
|
||||||
type AppModel struct {
|
type AppModel struct {
|
||||||
client *api.OpenF1Client
|
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 {
|
func (m AppModel) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
m.dashboard.Init(),
|
m.dashboard.Init(),
|
||||||
@@ -96,6 +116,9 @@ func (m AppModel) Init() tea.Cmd {
|
|||||||
m.trackMap.Init(),
|
m.trackMap.Init(),
|
||||||
m.splashSpinner.Tick,
|
m.splashSpinner.Tick,
|
||||||
splashTimer(),
|
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
|
m.showSplash = false
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case trackPrefetchDoneMsg:
|
||||||
|
// Background track pre-fetch finished — nothing to display.
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
// Any key press during splash dismisses it
|
// Any key press during splash dismisses it
|
||||||
if m.showSplash {
|
if m.showSplash {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type CalendarModel struct {
|
|||||||
client *api.OpenF1Client
|
client *api.OpenF1Client
|
||||||
meetings []models.Meeting
|
meetings []models.Meeting
|
||||||
loading bool
|
loading bool
|
||||||
|
stale bool
|
||||||
err error
|
err error
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
year int
|
year int
|
||||||
@@ -76,6 +77,9 @@ func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.meetings = msg.meetings
|
m.meetings = msg.meetings
|
||||||
m.loading = false
|
m.loading = false
|
||||||
|
if m.client.LastResponseWasStale() {
|
||||||
|
m.stale = true
|
||||||
|
}
|
||||||
// Auto-scroll to next upcoming race
|
// Auto-scroll to next upcoming race
|
||||||
m.cursor = m.findNextRaceIndex()
|
m.cursor = m.findNextRaceIndex()
|
||||||
m.ensureCursorVisible()
|
m.ensureCursorVisible()
|
||||||
@@ -85,6 +89,7 @@ func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) {
|
|||||||
case matchKey(msg, GlobalKeys.Retry):
|
case matchKey(msg, GlobalKeys.Retry):
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
m.err = nil
|
m.err = nil
|
||||||
|
m.stale = false
|
||||||
m.loading = true
|
m.loading = true
|
||||||
return m, tea.Batch(m.spinner.Tick, fetchMeetings(m.client, m.year))
|
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
|
var sb strings.Builder
|
||||||
|
|
||||||
|
if m.stale {
|
||||||
|
sb.WriteString(renderStaleBanner())
|
||||||
|
}
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
title := lipgloss.NewStyle().
|
title := lipgloss.NewStyle().
|
||||||
Bold(true).
|
Bold(true).
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type DashboardModel struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
loading bool
|
loading bool
|
||||||
|
stale bool
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
meetings []models.Meeting
|
meetings []models.Meeting
|
||||||
next *models.Meeting
|
next *models.Meeting
|
||||||
@@ -108,6 +109,9 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.meetings = msg.meetings
|
m.meetings = msg.meetings
|
||||||
|
if m.client.LastResponseWasStale() {
|
||||||
|
m.stale = true
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// First priority: find a meeting that has already started but not yet ended
|
// 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
|
m.loading = false
|
||||||
if msg.err == nil {
|
if msg.err == nil {
|
||||||
m.sessions = msg.sessions
|
m.sessions = msg.sessions
|
||||||
|
if m.client.LastResponseWasStale() {
|
||||||
|
m.stale = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -152,6 +159,7 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) {
|
|||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if matchKey(msg, GlobalKeys.Retry) && m.err != nil {
|
if matchKey(msg, GlobalKeys.Retry) && m.err != nil {
|
||||||
m.err = nil
|
m.err = nil
|
||||||
|
m.stale = false
|
||||||
m.loading = true
|
m.loading = true
|
||||||
return m, m.Init()
|
return m, m.Init()
|
||||||
}
|
}
|
||||||
@@ -177,6 +185,10 @@ func (m DashboardModel) View() string {
|
|||||||
w = 40
|
w = 40
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.stale {
|
||||||
|
sb.WriteString(renderStaleBanner())
|
||||||
|
}
|
||||||
|
|
||||||
titleStyle := lipgloss.NewStyle().
|
titleStyle := lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color(colorF1Red)).
|
Foreground(lipgloss.Color(colorF1Red)).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type DriverModel struct {
|
|||||||
|
|
||||||
view driverView
|
view driverView
|
||||||
loading bool
|
loading bool
|
||||||
|
stale bool
|
||||||
err error
|
err error
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
|
|
||||||
@@ -168,6 +169,9 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.drivers = msg.drivers
|
m.drivers = msg.drivers
|
||||||
|
if m.client.LastResponseWasStale() {
|
||||||
|
m.stale = true
|
||||||
|
}
|
||||||
m.filterDrivers()
|
m.filterDrivers()
|
||||||
|
|
||||||
case driverStintsLoadedMsg:
|
case driverStintsLoadedMsg:
|
||||||
@@ -222,6 +226,7 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
|||||||
case matchKey(msg, GlobalKeys.Retry):
|
case matchKey(msg, GlobalKeys.Retry):
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
m.err = nil
|
m.err = nil
|
||||||
|
m.stale = false
|
||||||
m.loading = true
|
m.loading = true
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m, cmd = m.TriggerLoad()
|
m, cmd = m.TriggerLoad()
|
||||||
@@ -391,11 +396,17 @@ func (m DriverModel) View() string {
|
|||||||
return renderErrorView(m.err)
|
return renderErrorView(m.err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepend stale banner when showing the list or detail view
|
||||||
|
prefix := ""
|
||||||
|
if m.stale {
|
||||||
|
prefix = renderStaleBanner()
|
||||||
|
}
|
||||||
|
|
||||||
switch m.view {
|
switch m.view {
|
||||||
case driverViewList:
|
case driverViewList:
|
||||||
return m.renderDriverList()
|
return prefix + m.renderDriverList()
|
||||||
case driverViewDetail:
|
case driverViewDetail:
|
||||||
return m.renderDriverDetail()
|
return prefix + m.renderDriverDetail()
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type RaceDetailModel struct {
|
|||||||
|
|
||||||
loadingSessions bool
|
loadingSessions bool
|
||||||
loadingResults bool
|
loadingResults bool
|
||||||
|
stale bool
|
||||||
driversLoaded bool
|
driversLoaded bool
|
||||||
secondaryLoading bool
|
secondaryLoading bool
|
||||||
errSessions error
|
errSessions error
|
||||||
@@ -149,6 +150,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
m.resultsScroll = 0
|
m.resultsScroll = 0
|
||||||
m.loadingSessions = true
|
m.loadingSessions = true
|
||||||
m.loadingResults = false
|
m.loadingResults = false
|
||||||
|
m.stale = false
|
||||||
m.driversLoaded = false
|
m.driversLoaded = false
|
||||||
m.secondaryLoading = false
|
m.secondaryLoading = false
|
||||||
m.errSessions = nil
|
m.errSessions = nil
|
||||||
@@ -163,6 +165,9 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.sessions = msg.sessions
|
m.sessions = msg.sessions
|
||||||
|
if m.client.LastResponseWasStale() {
|
||||||
|
m.stale = true
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-select the best session to show:
|
// Auto-select the best session to show:
|
||||||
// 1. The last session that has already started (ongoing or completed).
|
// 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.results = msg.results
|
||||||
m.resultsCursor = 0
|
m.resultsCursor = 0
|
||||||
m.resultsScroll = 0
|
m.resultsScroll = 0
|
||||||
|
if m.client.LastResponseWasStale() {
|
||||||
|
m.stale = true
|
||||||
|
}
|
||||||
if cmd := m.checkPrimaryLoaded(); cmd != nil {
|
if cmd := m.checkPrimaryLoaded(); cmd != nil {
|
||||||
cmds = append(cmds, cmd)
|
cmds = append(cmds, cmd)
|
||||||
}
|
}
|
||||||
@@ -468,6 +476,10 @@ func (m RaceDetailModel) View() string {
|
|||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
|
if m.stale {
|
||||||
|
sb.WriteString(renderStaleBanner())
|
||||||
|
}
|
||||||
|
|
||||||
// Race title header
|
// Race title header
|
||||||
flag := countryFlag(m.meeting.CountryCode)
|
flag := countryFlag(m.meeting.CountryCode)
|
||||||
titleStyle := lipgloss.NewStyle().
|
titleStyle := lipgloss.NewStyle().
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type StandingsModel struct {
|
|||||||
|
|
||||||
view standingsView
|
view standingsView
|
||||||
loading bool
|
loading bool
|
||||||
|
stale bool
|
||||||
err error
|
err error
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
|
|
||||||
@@ -104,6 +105,9 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.driverStandings = msg.standings
|
m.driverStandings = msg.standings
|
||||||
|
if m.client.LastResponseWasStale() {
|
||||||
|
m.stale = true
|
||||||
|
}
|
||||||
if len(msg.standings) > 0 {
|
if len(msg.standings) > 0 {
|
||||||
return m, fetchStandingsDrivers(m.client, msg.standings[0].SessionKey)
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
m.teamStandings = msg.standings
|
m.teamStandings = msg.standings
|
||||||
|
if m.client.LastResponseWasStale() {
|
||||||
|
m.stale = true
|
||||||
|
}
|
||||||
|
|
||||||
case standingsDriversLoadedMsg:
|
case standingsDriversLoadedMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
@@ -132,6 +139,7 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
|
|||||||
case matchKey(msg, GlobalKeys.Retry):
|
case matchKey(msg, GlobalKeys.Retry):
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
m.err = nil
|
m.err = nil
|
||||||
|
m.stale = false
|
||||||
m.loading = true
|
m.loading = true
|
||||||
return m, m.Init()
|
return m, m.Init()
|
||||||
}
|
}
|
||||||
@@ -221,6 +229,10 @@ func (m StandingsModel) View() string {
|
|||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
|
if m.stale {
|
||||||
|
sb.WriteString(renderStaleBanner())
|
||||||
|
}
|
||||||
|
|
||||||
// Title row with year and toggle
|
// Title row with year and toggle
|
||||||
title := lipgloss.NewStyle().
|
title := lipgloss.NewStyle().
|
||||||
Bold(true).
|
Bold(true).
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ type TrackMapModel struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
|
|
||||||
// Resolved session key used to fetch location data
|
// Resolved session key used to fetch live location data.
|
||||||
sessionKey int
|
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)
|
// Track outline (normalized points from driver 1's path)
|
||||||
outline []trackPoint
|
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 trackOutlineLoadedMsg{err: fmt.Errorf("no active session found")}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sessionKeyMsg{sessionKey: activeSess.SessionKey}
|
return sessionKeyMsg{
|
||||||
|
sessionKey: activeSess.SessionKey,
|
||||||
|
circuitKey: currentMtg.CircuitKey,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type sessionKeyMsg struct {
|
type sessionKeyMsg struct {
|
||||||
sessionKey int
|
sessionKey int
|
||||||
|
circuitKey int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -152,13 +159,30 @@ type trackCarsLoadedMsg struct {
|
|||||||
// fetchTrackOutline downloads location data for a single reference driver
|
// fetchTrackOutline downloads location data for a single reference driver
|
||||||
// (driver 1 by convention, then any driver if 1 is absent) to build the
|
// (driver 1 by convention, then any driver if 1 is absent) to build the
|
||||||
// track outline for the given session.
|
// 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 {
|
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}
|
candidates := []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24}
|
||||||
for _, dn := range candidates {
|
for _, dn := range candidates {
|
||||||
locs, err := client.GetLocation(sessionKey, dn)
|
locs, err := client.GetLocation(sessionKey, dn)
|
||||||
if err == nil && len(locs) > 50 {
|
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}
|
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
|
// SetSessionKey wires the track map to a specific session. If the session
|
||||||
// differs from the one already loaded, it triggers a fresh outline fetch.
|
// 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 {
|
if sessionKey == m.sessionKey && m.outlineReady {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
m.sessionKey = sessionKey
|
m.sessionKey = sessionKey
|
||||||
|
m.circuitKey = circuitKey
|
||||||
m.loadingOutline = true
|
m.loadingOutline = true
|
||||||
m.outlineReady = false
|
m.outlineReady = false
|
||||||
m.outline = nil
|
m.outline = nil
|
||||||
m.carPositions = make(map[int]models.Location)
|
m.carPositions = make(map[int]models.Location)
|
||||||
m.err = nil
|
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
|
// InjectDriverInfo forwards the latest DriverInfo map from OfficialLiveModel
|
||||||
@@ -263,14 +289,15 @@ func (m TrackMapModel) Update(msg tea.Msg) (TrackMapModel, tea.Cmd) {
|
|||||||
|
|
||||||
case sessionKeyMsg:
|
case sessionKeyMsg:
|
||||||
m.loadingSession = false
|
m.loadingSession = false
|
||||||
if msg.sessionKey != m.sessionKey {
|
if msg.sessionKey != m.sessionKey || msg.circuitKey != m.circuitKey {
|
||||||
m.sessionKey = msg.sessionKey
|
m.sessionKey = msg.sessionKey
|
||||||
|
m.circuitKey = msg.circuitKey
|
||||||
m.loadingOutline = true
|
m.loadingOutline = true
|
||||||
m.outlineReady = false
|
m.outlineReady = false
|
||||||
m.outline = nil
|
m.outline = nil
|
||||||
m.carPositions = make(map[int]models.Location)
|
m.carPositions = make(map[int]models.Location)
|
||||||
m.err = nil
|
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
|
return m, nil
|
||||||
|
|
||||||
|
|||||||
@@ -531,6 +531,19 @@ func teamColorBar(teamColor string) string {
|
|||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
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
|
// renderErrorView returns a formatted error view. If the error is the OpenF1
|
||||||
// live-session lockout, it shows a special informational banner instead of a
|
// live-session lockout, it shows a special informational banner instead of a
|
||||||
// raw error string.
|
// raw error string.
|
||||||
|
|||||||
Reference in New Issue
Block a user