TUI Polish

This commit is contained in:
2026-03-27 19:27:38 -04:00
parent 16ea2e8683
commit c4e6c634d1
13 changed files with 462 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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