mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
TUI Polish
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user