mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
UI Refresh
This commit is contained in:
2
go.mod
2
go.mod
@@ -3,6 +3,7 @@ module github.com/AmanTahiliani/box-box
|
||||
go 1.25.6
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
@@ -23,6 +24,7 @@ require (
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sahilm/fuzzy v0.1.1 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,3 +1,5 @@
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
@@ -38,6 +40,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
|
||||
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -5,12 +5,33 @@ import (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FileCache struct {
|
||||
dir string
|
||||
// CacheStats tracks cache hit/miss statistics.
|
||||
type CacheStats struct {
|
||||
Hits int64
|
||||
Misses int64
|
||||
}
|
||||
|
||||
// FileCache implements a file-backed HTTP response cache with TTL expiry.
|
||||
type FileCache struct {
|
||||
dir string
|
||||
stats CacheStats
|
||||
}
|
||||
|
||||
// Default TTL values.
|
||||
const (
|
||||
// CacheTTLShort is for current-season, frequently changing data (meetings, sessions, results).
|
||||
CacheTTLShort = 15 * time.Minute
|
||||
// CacheTTLMedium is for semi-stable data (championship standings, driver lists).
|
||||
CacheTTLMedium = 1 * time.Hour
|
||||
// CacheTTLLong is for historical data that rarely changes (past season data).
|
||||
CacheTTLLong = 24 * time.Hour
|
||||
)
|
||||
|
||||
func NewFileCache() *FileCache {
|
||||
var cacheDir string
|
||||
userCacheDir, err := os.UserCacheDir()
|
||||
@@ -35,13 +56,56 @@ func (c *FileCache) getCachePath(key string) string {
|
||||
return filepath.Join(c.dir, filename)
|
||||
}
|
||||
|
||||
// Get retrieves data from the cache. Returns nil, false if not found.
|
||||
// ttlForURL determines the appropriate TTL based on the URL pattern.
|
||||
func ttlForURL(url string) time.Duration {
|
||||
// Historical data (specific year queries for past years)
|
||||
if strings.Contains(url, "year=2023") || strings.Contains(url, "year=2024") {
|
||||
return CacheTTLLong
|
||||
}
|
||||
|
||||
// Frequently changing endpoints
|
||||
if strings.Contains(url, "/position") ||
|
||||
strings.Contains(url, "/intervals") ||
|
||||
strings.Contains(url, "/car_data") ||
|
||||
strings.Contains(url, "/location") {
|
||||
return CacheTTLShort
|
||||
}
|
||||
|
||||
// Semi-stable data
|
||||
if strings.Contains(url, "/championship") ||
|
||||
strings.Contains(url, "/drivers") {
|
||||
return CacheTTLMedium
|
||||
}
|
||||
|
||||
// Default: medium TTL for everything else
|
||||
return CacheTTLMedium
|
||||
}
|
||||
|
||||
// Get retrieves data from the cache. Returns nil, false if not found or expired.
|
||||
func (c *FileCache) Get(key string) ([]byte, bool) {
|
||||
path := c.getCachePath(key)
|
||||
data, err := os.ReadFile(path)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&c.stats.Misses, 1)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check TTL based on file modification time
|
||||
ttl := ttlForURL(key)
|
||||
if time.Since(info.ModTime()) > ttl {
|
||||
// Expired — remove the stale file
|
||||
_ = os.Remove(path)
|
||||
atomic.AddInt64(&c.stats.Misses, 1)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&c.stats.Misses, 1)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
atomic.AddInt64(&c.stats.Hits, 1)
|
||||
return data, true
|
||||
}
|
||||
|
||||
@@ -50,3 +114,45 @@ func (c *FileCache) Set(key string, data []byte) error {
|
||||
path := c.getCachePath(key)
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// Stats returns current cache hit/miss stats.
|
||||
func (c *FileCache) Stats() CacheStats {
|
||||
return CacheStats{
|
||||
Hits: atomic.LoadInt64(&c.stats.Hits),
|
||||
Misses: atomic.LoadInt64(&c.stats.Misses),
|
||||
}
|
||||
}
|
||||
|
||||
// Clear removes all cached files.
|
||||
func (c *FileCache) Clear() error {
|
||||
entries, err := os.ReadDir(c.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".json") {
|
||||
_ = os.Remove(filepath.Join(c.dir, entry.Name()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Size returns the number of cached files and total size in bytes.
|
||||
func (c *FileCache) Size() (int, int64) {
|
||||
entries, err := os.ReadDir(c.dir)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
count := 0
|
||||
var totalSize int64
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".json") {
|
||||
count++
|
||||
info, err := entry.Info()
|
||||
if err == nil {
|
||||
totalSize += info.Size()
|
||||
}
|
||||
}
|
||||
}
|
||||
return count, totalSize
|
||||
}
|
||||
|
||||
@@ -18,3 +18,13 @@ func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
|
||||
cache: NewFileCache(),
|
||||
}
|
||||
}
|
||||
|
||||
// CacheStats returns the cache hit/miss statistics.
|
||||
func (c *OpenF1Client) CacheStats() CacheStats {
|
||||
return c.cache.Stats()
|
||||
}
|
||||
|
||||
// CacheSize returns the number of cached entries and total size in bytes.
|
||||
func (c *OpenF1Client) CacheSize() (int, int64) {
|
||||
return c.cache.Size()
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/api"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -19,7 +21,11 @@ const (
|
||||
tabDriver tabIndex = 3
|
||||
)
|
||||
|
||||
var tabNames = []string{"1 Standings", "2 Calendar", "3 Race", "4 Drivers"}
|
||||
var tabNames = []string{"Standings", "Calendar", "Race", "Drivers"}
|
||||
var tabIcons = []string{"🏆", "📅", "🏁", "👤"}
|
||||
|
||||
// splashDoneMsg is sent after the splash screen duration has elapsed.
|
||||
type splashDoneMsg struct{}
|
||||
|
||||
// AppModel is the root Bubble Tea model.
|
||||
type AppModel struct {
|
||||
@@ -34,28 +40,48 @@ type AppModel struct {
|
||||
calendar CalendarModel
|
||||
raceDetail RaceDetailModel
|
||||
driver DriverModel
|
||||
|
||||
meetings []models.Meeting
|
||||
|
||||
// Splash screen state
|
||||
showSplash bool
|
||||
splashSpinner spinner.Model
|
||||
}
|
||||
|
||||
// NewAppModel creates the root model and wires sub-models.
|
||||
func NewAppModel(client *api.OpenF1Client) AppModel {
|
||||
year := 2025
|
||||
|
||||
sp := spinner.New()
|
||||
sp.Spinner = spinner.Points
|
||||
sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
|
||||
return AppModel{
|
||||
client: client,
|
||||
activeTab: tabStandings,
|
||||
year: year,
|
||||
standings: NewStandingsModel(client, year),
|
||||
calendar: NewCalendarModel(client, year),
|
||||
raceDetail: NewRaceDetailModel(client),
|
||||
driver: NewDriverModel(client),
|
||||
client: client,
|
||||
activeTab: tabStandings,
|
||||
year: year,
|
||||
standings: NewStandingsModel(client, year),
|
||||
calendar: NewCalendarModel(client, year),
|
||||
raceDetail: NewRaceDetailModel(client),
|
||||
driver: NewDriverModel(client),
|
||||
showSplash: true,
|
||||
splashSpinner: sp,
|
||||
}
|
||||
}
|
||||
|
||||
func splashTimer() tea.Cmd {
|
||||
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
||||
return splashDoneMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func (m AppModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.standings.Init(),
|
||||
m.calendar.Init(),
|
||||
m.raceDetail.Init(),
|
||||
m.driver.Init(),
|
||||
m.splashSpinner.Tick,
|
||||
splashTimer(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,46 +92,76 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
contentHeight := m.height - 3 // tab bar + help
|
||||
contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing
|
||||
m.raceDetail.SetSize(m.width-4, contentHeight)
|
||||
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
|
||||
m.standings, cmd1 = m.standings.Update(msg)
|
||||
m.calendar, cmd2 = m.calendar.Update(msg)
|
||||
m.raceDetail, cmd3 = m.raceDetail.Update(msg)
|
||||
m.driver, cmd4 = m.driver.Update(msg)
|
||||
return m, tea.Batch(cmd1, cmd2, cmd3, cmd4)
|
||||
|
||||
case splashDoneMsg:
|
||||
m.showSplash = false
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Any key press during splash dismisses it
|
||||
if m.showSplash {
|
||||
m.showSplash = false
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Global quit
|
||||
if matchKey(msg, GlobalKeys.Quit) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
// Tab switching
|
||||
switch msg.String() {
|
||||
case "1":
|
||||
switch {
|
||||
case matchKey(msg, GlobalKeys.Tab1):
|
||||
m.activeTab = tabStandings
|
||||
return m, nil
|
||||
case "2":
|
||||
case matchKey(msg, GlobalKeys.Tab2):
|
||||
m.activeTab = tabCalendar
|
||||
return m, nil
|
||||
case "3":
|
||||
case matchKey(msg, GlobalKeys.Tab3):
|
||||
m.activeTab = tabRaceDetail
|
||||
return m, nil
|
||||
case "4":
|
||||
case matchKey(msg, GlobalKeys.Tab4):
|
||||
m.activeTab = tabDriver
|
||||
var cmd tea.Cmd
|
||||
m.driver, cmd = m.driver.TriggerLoad()
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
case "b":
|
||||
// Back from race detail → calendar
|
||||
case matchKey(msg, GlobalKeys.NextTab):
|
||||
m.activeTab = (m.activeTab + 1) % 4
|
||||
if m.activeTab == tabDriver {
|
||||
var cmd tea.Cmd
|
||||
m.driver, cmd = m.driver.TriggerLoad()
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
case matchKey(msg, GlobalKeys.PrevTab):
|
||||
m.activeTab = (m.activeTab - 1 + 4) % 4
|
||||
if m.activeTab == tabDriver {
|
||||
var cmd tea.Cmd
|
||||
m.driver, cmd = m.driver.TriggerLoad()
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
case matchKey(msg, GlobalKeys.Back):
|
||||
// Back from race detail -> calendar
|
||||
if m.activeTab == tabRaceDetail {
|
||||
m.activeTab = tabCalendar
|
||||
return m, nil
|
||||
}
|
||||
case "y":
|
||||
case matchKey(msg, GlobalKeys.Year):
|
||||
// Cycle years: 2025 -> 2023 -> 2024 -> 2025
|
||||
if m.year == 2025 {
|
||||
m.year = 2023
|
||||
} else {
|
||||
m.year++
|
||||
}
|
||||
// Update sub-models and re-trigger fetches
|
||||
m.calendar.year = m.year
|
||||
m.calendar.loading = true
|
||||
m.standings.year = m.year
|
||||
@@ -118,24 +174,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case meetingSelectedMsg:
|
||||
// Switch to race detail tab and forward the message
|
||||
m.activeTab = tabRaceDetail
|
||||
var cmd tea.Cmd
|
||||
m.raceDetail, cmd = m.raceDetail.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
// Also forward to driver model for session key tracking
|
||||
m.driver, _ = m.driver.Update(msg)
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case sessionsLoadedMsg:
|
||||
// Forward to raceDetail and driver
|
||||
var cmd1, cmd2 tea.Cmd
|
||||
m.raceDetail, cmd1 = m.raceDetail.Update(msg)
|
||||
m.driver, cmd2 = m.driver.Update(msg)
|
||||
cmds = append(cmds, cmd1, cmd2)
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
// Route all loaded messages to the appropriate sub-models
|
||||
case driverChampionshipLoadedMsg:
|
||||
var cmd tea.Cmd
|
||||
m.standings, cmd = m.standings.Update(msg)
|
||||
@@ -155,6 +207,9 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case meetingsLoadedMsg:
|
||||
if msg.err == nil {
|
||||
m.meetings = msg.meetings
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.calendar, cmd = m.calendar.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
@@ -184,6 +239,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case overtakesLoadedMsg:
|
||||
var cmd tea.Cmd
|
||||
m.raceDetail, cmd = m.raceDetail.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case driverListLoadedMsg:
|
||||
var cmd tea.Cmd
|
||||
m.driver, cmd = m.driver.Update(msg)
|
||||
@@ -208,8 +269,24 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case driverPositionsLoadedMsg:
|
||||
var cmd tea.Cmd
|
||||
m.driver, cmd = m.driver.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case driverTeamRadioLoadedMsg:
|
||||
var cmd tea.Cmd
|
||||
m.driver, cmd = m.driver.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case spinner.TickMsg:
|
||||
// Forward spinner ticks to all sub-models
|
||||
if m.showSplash {
|
||||
var cmd tea.Cmd
|
||||
m.splashSpinner, cmd = m.splashSpinner.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
|
||||
m.standings, cmd1 = m.standings.Update(msg)
|
||||
m.calendar, cmd2 = m.calendar.Update(msg)
|
||||
@@ -243,8 +320,20 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m AppModel) View() string {
|
||||
if m.showSplash {
|
||||
return m.renderSplash()
|
||||
}
|
||||
|
||||
w := m.width
|
||||
if w < 40 {
|
||||
w = 40
|
||||
}
|
||||
|
||||
// Red accent stripe at the very top (F1 style)
|
||||
stripe := styleTabStripe.Render(strings.Repeat("▔", w))
|
||||
|
||||
// Tab bar
|
||||
tabs := renderTabBar(m.activeTab, m.width)
|
||||
tabs := renderTabBar(m.activeTab, m.year, w)
|
||||
|
||||
// Content area
|
||||
var content string
|
||||
@@ -259,24 +348,178 @@ func (m AppModel) View() string {
|
||||
content = m.driver.View()
|
||||
}
|
||||
|
||||
return tabs + "\n" + content
|
||||
statusBar := m.renderStatusBar(w)
|
||||
|
||||
// Calculate how much vertical space is available for content
|
||||
usedLines := 1 + 1 + 1 + 1 // stripe + tab bar + gap + status bar
|
||||
contentHeight := m.height - usedLines
|
||||
if contentHeight < 5 {
|
||||
contentHeight = 5
|
||||
}
|
||||
|
||||
// Trim content to fit available height
|
||||
contentLines := strings.Split(content, "\n")
|
||||
if len(contentLines) > contentHeight {
|
||||
contentLines = contentLines[:contentHeight]
|
||||
}
|
||||
content = strings.Join(contentLines, "\n")
|
||||
|
||||
return stripe + "\n" + tabs + "\n" + content + "\n" + statusBar
|
||||
}
|
||||
|
||||
func renderTabBar(active tabIndex, width int) string {
|
||||
func renderTabBar(active tabIndex, year int, width int) string {
|
||||
// Build the logo
|
||||
logo := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
Background(lipgloss.Color(colorSurface0)).
|
||||
Padding(0, 1).
|
||||
Render("F1")
|
||||
|
||||
// Build tabs
|
||||
var tabs []string
|
||||
for i, name := range tabNames {
|
||||
label := fmt.Sprintf(" %s %d %s ", tabIcons[i], i+1, name)
|
||||
if tabIndex(i) == active {
|
||||
tabs = append(tabs, styleActiveTab.Render(name))
|
||||
tabs = append(tabs, styleActiveTab.Render(label))
|
||||
} else {
|
||||
tabs = append(tabs, styleInactiveTab.Render(name))
|
||||
tabs = append(tabs, styleInactiveTab.Render(label))
|
||||
}
|
||||
}
|
||||
|
||||
bar := strings.Join(tabs, "")
|
||||
// Pad remaining width
|
||||
barWidth := lipgloss.Width(bar)
|
||||
if barWidth < width {
|
||||
bar += strings.Repeat(" ", width-barWidth)
|
||||
// Year badge
|
||||
yearBadge := styleYearBadge.Render(fmt.Sprintf(" %d ", year))
|
||||
|
||||
// Assemble: logo + tabs + spacer + year
|
||||
left := logo + strings.Join(tabs, "")
|
||||
leftWidth := lipgloss.Width(left)
|
||||
yearWidth := lipgloss.Width(yearBadge)
|
||||
spacerWidth := width - leftWidth - yearWidth
|
||||
if spacerWidth < 0 {
|
||||
spacerWidth = 0
|
||||
}
|
||||
return styleTabBar.Render(fmt.Sprintf("%s", bar))
|
||||
spacer := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(colorSurface0)).
|
||||
Render(strings.Repeat(" ", spacerWidth))
|
||||
|
||||
bar := left + spacer + yearBadge
|
||||
return styleTabBar.Render(bar)
|
||||
}
|
||||
|
||||
func (m AppModel) renderStatusBar(width int) string {
|
||||
now := time.Now()
|
||||
|
||||
// Left side: brand
|
||||
leftParts := []string{
|
||||
lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
Bold(true).
|
||||
Render("BOX-BOX"),
|
||||
}
|
||||
|
||||
// Next race countdown
|
||||
var nextMeeting *models.Meeting
|
||||
for i := range m.meetings {
|
||||
start, err := time.Parse(time.RFC3339, m.meetings[i].DateStart)
|
||||
if err != nil {
|
||||
start, _ = time.Parse("2006-01-02", m.meetings[i].DateStart[:min(len(m.meetings[i].DateStart), 10)])
|
||||
}
|
||||
if start.After(now) {
|
||||
nextMeeting = &m.meetings[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if nextMeeting != nil {
|
||||
start, err := time.Parse(time.RFC3339, nextMeeting.DateStart)
|
||||
if err == nil {
|
||||
diff := start.Sub(now)
|
||||
days := int(diff.Hours() / 24)
|
||||
hours := int(diff.Hours()) % 24
|
||||
|
||||
flag := countryFlag(nextMeeting.CountryCode)
|
||||
raceName := styleStatusValue.Render(nextMeeting.MeetingName)
|
||||
countdown := styleCountdown.Render(fmt.Sprintf("%dd %dh", days, hours))
|
||||
|
||||
leftParts = append(leftParts,
|
||||
styleStatusLabel.Render("│"),
|
||||
styleStatusLabel.Render("NEXT"),
|
||||
flag+" "+raceName,
|
||||
styleStatusLabel.Render("in"),
|
||||
countdown,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
left := strings.Join(leftParts, " ")
|
||||
|
||||
// Right side: cache stats + navigation hints
|
||||
cacheStats := m.client.CacheStats()
|
||||
cacheInfo := styleMuted.Render(fmt.Sprintf("cache %d/%d", cacheStats.Hits, cacheStats.Hits+cacheStats.Misses))
|
||||
right := cacheInfo + " " + styleMuted.Render("1-4 tabs · y year · q quit")
|
||||
|
||||
leftW := lipgloss.Width(left)
|
||||
rightW := lipgloss.Width(right)
|
||||
spacerW := width - leftW - rightW - 2
|
||||
if spacerW < 0 {
|
||||
spacerW = 0
|
||||
}
|
||||
|
||||
bar := " " + left + strings.Repeat(" ", spacerW) + right + " "
|
||||
return styleStatusBar.Width(width).Render(bar)
|
||||
}
|
||||
|
||||
func (m AppModel) renderSplash() string {
|
||||
w := m.width
|
||||
h := m.height
|
||||
if w == 0 {
|
||||
w = 80
|
||||
}
|
||||
if h == 0 {
|
||||
h = 24
|
||||
}
|
||||
|
||||
// F1-themed ASCII logo
|
||||
logo := []string{
|
||||
"██████╗ ██████╗ ██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗",
|
||||
"██╔══██╗██╔═══██╗╚██╗██╔╝ ██╔══██╗██╔═══██╗╚██╗██╔╝",
|
||||
"██████╔╝██║ ██║ ╚███╔╝█████╗██████╔╝██║ ██║ ╚███╔╝ ",
|
||||
"██╔══██╗██║ ██║ ██╔██╗╚════╝██╔══██╗██║ ██║ ██╔██╗ ",
|
||||
"██████╔╝╚██████╔╝██╔╝ ██╗ ██████╔╝╚██████╔╝██╔╝ ██╗",
|
||||
"╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
|
||||
}
|
||||
|
||||
redStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
Bold(true)
|
||||
|
||||
var logoBlock strings.Builder
|
||||
for _, line := range logo {
|
||||
logoBlock.WriteString(redStyle.Render(line) + "\n")
|
||||
}
|
||||
|
||||
subtitle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted)).
|
||||
Render("Formula 1 Terminal Dashboard")
|
||||
|
||||
loadingLine := fmt.Sprintf("%s %s",
|
||||
m.splashSpinner.View(),
|
||||
styleMuted.Render("Loading data..."))
|
||||
|
||||
hint := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorSurface2)).
|
||||
Render("press any key to skip")
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Center,
|
||||
logoBlock.String(),
|
||||
"",
|
||||
subtitle,
|
||||
"",
|
||||
loadingLine,
|
||||
"",
|
||||
hint,
|
||||
)
|
||||
|
||||
// Center the splash on screen
|
||||
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content)
|
||||
}
|
||||
|
||||
@@ -18,16 +18,19 @@ type CalendarModel struct {
|
||||
loading bool
|
||||
err error
|
||||
spinner spinner.Model
|
||||
cursor int
|
||||
year int
|
||||
width int
|
||||
height int
|
||||
|
||||
cursor int
|
||||
scroll int
|
||||
}
|
||||
|
||||
func NewCalendarModel(client *api.OpenF1Client, year int) CalendarModel {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.MiniDot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
|
||||
return CalendarModel{
|
||||
client: client,
|
||||
loading: true,
|
||||
@@ -51,7 +54,13 @@ func (m CalendarModel) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.loading {
|
||||
var cmd tea.Cmd
|
||||
@@ -69,26 +78,75 @@ func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) {
|
||||
m.loading = false
|
||||
// Auto-scroll to next upcoming race
|
||||
m.cursor = m.findNextRaceIndex()
|
||||
m.ensureCursorVisible()
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case matchKey(msg, GlobalKeys.Retry):
|
||||
if m.err != nil {
|
||||
m.err = nil
|
||||
m.loading = true
|
||||
return m, tea.Batch(m.spinner.Tick, fetchMeetings(m.client, m.year))
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.Up):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
m.ensureCursorVisible()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.Down):
|
||||
if m.cursor < len(m.meetings)-1 {
|
||||
m.cursor++
|
||||
m.ensureCursorVisible()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.GoTop):
|
||||
m.cursor = 0
|
||||
m.scroll = 0
|
||||
case matchKey(msg, GlobalKeys.GoBottom):
|
||||
if len(m.meetings) > 0 {
|
||||
m.cursor = len(m.meetings) - 1
|
||||
m.ensureCursorVisible()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.HalfUp):
|
||||
half := m.visibleRows() / 2
|
||||
m.cursor -= half
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
m.ensureCursorVisible()
|
||||
case matchKey(msg, GlobalKeys.HalfDown):
|
||||
half := m.visibleRows() / 2
|
||||
m.cursor += half
|
||||
if m.cursor >= len(m.meetings) {
|
||||
m.cursor = len(m.meetings) - 1
|
||||
}
|
||||
m.ensureCursorVisible()
|
||||
case matchKey(msg, GlobalKeys.Enter):
|
||||
if len(m.meetings) > 0 && m.cursor < len(m.meetings) {
|
||||
if len(m.meetings) > 0 && m.cursor >= 0 && m.cursor < len(m.meetings) {
|
||||
return m, func() tea.Msg {
|
||||
return meetingSelectedMsg{meeting: m.meetings[m.cursor]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m CalendarModel) visibleRows() int {
|
||||
rows := m.height - 10
|
||||
if rows < 5 {
|
||||
rows = 5
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (m *CalendarModel) ensureCursorVisible() {
|
||||
visible := m.visibleRows()
|
||||
if m.cursor < m.scroll {
|
||||
m.scroll = m.cursor
|
||||
}
|
||||
if m.cursor >= m.scroll+visible {
|
||||
m.scroll = m.cursor - visible + 1
|
||||
}
|
||||
}
|
||||
|
||||
func (m CalendarModel) findNextRaceIndex() int {
|
||||
@@ -110,81 +168,145 @@ func (m CalendarModel) findNextRaceIndex() int {
|
||||
|
||||
func (m CalendarModel) View() string {
|
||||
if m.loading {
|
||||
return fmt.Sprintf("\n %s Loading %d calendar…", m.spinner.View(), m.year)
|
||||
return fmt.Sprintf("\n %s Loading %d calendar...", m.spinner.View(), m.year)
|
||||
}
|
||||
if m.err != nil {
|
||||
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
|
||||
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) +
|
||||
helpBar("r retry", "q quit")
|
||||
}
|
||||
if len(m.meetings) == 0 {
|
||||
return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.", m.year))
|
||||
}
|
||||
|
||||
const (
|
||||
wRound = 3
|
||||
wName = 28
|
||||
wCircuit = 20
|
||||
wCountry = 16
|
||||
wDates = 20
|
||||
wStatus = 3
|
||||
)
|
||||
|
||||
header := styleBold.Render(
|
||||
padRight("Rd", wRound) + " " +
|
||||
padRight("Grand Prix", wName) + " " +
|
||||
padRight("Circuit", wCircuit) + " " +
|
||||
padRight("Country", wCountry) + " " +
|
||||
padRight("Dates", wDates) + " " +
|
||||
padRight("", wStatus),
|
||||
)
|
||||
|
||||
var rows []string
|
||||
rows = append(rows, header)
|
||||
|
||||
now := time.Now()
|
||||
nextIdx := m.findNextRaceIndex()
|
||||
|
||||
for i, meeting := range m.meetings {
|
||||
isNext := (i == nextIdx)
|
||||
status := meetingStatus(meeting, now, isNext)
|
||||
|
||||
dates := formatMeetingDates(meeting)
|
||||
flag := countryFlag(meeting.CountryCode)
|
||||
|
||||
country := flag + " " + truncate(meeting.CountryName, wCountry-3)
|
||||
|
||||
row := fmt.Sprintf("%s %s %s %s %s %s",
|
||||
padLeft(fmt.Sprintf("%d", i+1), wRound),
|
||||
padRight(truncate(meeting.MeetingName, wName), wName),
|
||||
padRight(truncate(meeting.CircuitShortName, wCircuit), wCircuit),
|
||||
padRight(country, wCountry),
|
||||
padRight(dates, wDates),
|
||||
status,
|
||||
)
|
||||
|
||||
if i == m.cursor {
|
||||
row = styleSelected.Render(row)
|
||||
} else if isNext {
|
||||
row = styleNext.Render(row)
|
||||
} else {
|
||||
// Mute past races
|
||||
end, err := time.Parse(time.RFC3339, meeting.DateEnd)
|
||||
if err != nil {
|
||||
end, _ = time.Parse("2006-01-02", meeting.DateEnd[:min(len(meeting.DateEnd), 10)])
|
||||
end = end.Add(24 * time.Hour)
|
||||
}
|
||||
if end.Before(now) && i != m.cursor {
|
||||
row = stylePast.Render(row)
|
||||
}
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.\n", m.year))
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n")
|
||||
sb.WriteString(strings.Join(rows, "\n"))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(helpBar("y season", "j/k navigate", "enter select race", "q quit"))
|
||||
|
||||
// Title
|
||||
title := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
Render(fmt.Sprintf(" FORMULA 1 %d RACE CALENDAR", m.year))
|
||||
sb.WriteString(title + "\n\n")
|
||||
|
||||
// Custom rendered list (no bubbles/table - gives us more control)
|
||||
now := time.Now()
|
||||
nextIdx := m.findNextRaceIndex()
|
||||
visible := m.visibleRows()
|
||||
|
||||
endIdx := m.scroll + visible
|
||||
if endIdx > len(m.meetings) {
|
||||
endIdx = len(m.meetings)
|
||||
}
|
||||
|
||||
w := m.width
|
||||
if w < 40 {
|
||||
w = 40
|
||||
}
|
||||
compact := w < 90
|
||||
wide := w >= 120
|
||||
|
||||
// Responsive column widths
|
||||
gpWidth := 30
|
||||
circuitWidth := 22
|
||||
countryWidth := 18
|
||||
if compact {
|
||||
gpWidth = min(24, w-30)
|
||||
circuitWidth = 0 // hide circuit in compact mode
|
||||
countryWidth = 14
|
||||
} else if wide {
|
||||
gpWidth = 34
|
||||
circuitWidth = 26
|
||||
}
|
||||
|
||||
// Header
|
||||
var header string
|
||||
if compact {
|
||||
header = fmt.Sprintf(" %s %s %s %s %s",
|
||||
padRight("RD", 3),
|
||||
padRight("S", 1),
|
||||
padRight("GRAND PRIX", gpWidth),
|
||||
padRight("COUNTRY", countryWidth),
|
||||
padRight("DATES", 14),
|
||||
)
|
||||
} else {
|
||||
header = fmt.Sprintf(" %s %s %s %s %s %s",
|
||||
padRight("RD", 3),
|
||||
padRight("S", 1),
|
||||
padRight("GRAND PRIX", gpWidth),
|
||||
padRight("CIRCUIT", circuitWidth),
|
||||
padRight("COUNTRY", countryWidth),
|
||||
padRight("DATES", 14),
|
||||
)
|
||||
}
|
||||
sb.WriteString(lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted)).
|
||||
Bold(true).
|
||||
Render(header) + "\n")
|
||||
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
|
||||
|
||||
for i := m.scroll; i < endIdx; i++ {
|
||||
meeting := m.meetings[i]
|
||||
isNext := (i == nextIdx)
|
||||
status := meetingStatus(meeting, now, isNext)
|
||||
dates := formatMeetingDates(meeting)
|
||||
flag := countryFlag(meeting.CountryCode)
|
||||
|
||||
// Round number with special styling
|
||||
roundNum := fmt.Sprintf("R%d", i+1)
|
||||
if i+1 < 10 {
|
||||
roundNum = fmt.Sprintf("R%d ", i+1)
|
||||
}
|
||||
|
||||
// Style round number based on status
|
||||
var roundStyle lipgloss.Style
|
||||
if isNext {
|
||||
roundStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Bold(true)
|
||||
} else {
|
||||
roundStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
|
||||
}
|
||||
|
||||
// flag is 2 regional-indicator runes but renders as 2 terminal columns (double-width).
|
||||
// Reserve 3 columns for "🇬🇧 " (2 cols for emoji + 1 for space) so country name width
|
||||
// is countryWidth-3 runes, keeping the whole field at countryWidth visible columns.
|
||||
countryField := padRight(flag+" "+truncate(meeting.CountryName, countryWidth-3), countryWidth)
|
||||
|
||||
var row string
|
||||
if compact {
|
||||
row = fmt.Sprintf(" %s %s %s %s %s",
|
||||
roundStyle.Render(padRight(roundNum, 3)),
|
||||
padRightVisible(status, 1), // status icon is 1 visible column; no extra padding needed
|
||||
padRight(truncate(meeting.MeetingName, gpWidth), gpWidth),
|
||||
countryField,
|
||||
styleMuted.Render(dates),
|
||||
)
|
||||
} else {
|
||||
row = fmt.Sprintf(" %s %s %s %s %s %s",
|
||||
roundStyle.Render(padRight(roundNum, 3)),
|
||||
padRightVisible(status, 1), // status icon is 1 visible column; no extra padding needed
|
||||
padRight(truncate(meeting.MeetingName, gpWidth), gpWidth),
|
||||
padRight(truncate(meeting.CircuitShortName, circuitWidth), circuitWidth),
|
||||
countryField,
|
||||
styleMuted.Render(dates),
|
||||
)
|
||||
}
|
||||
|
||||
if i == m.cursor {
|
||||
// Highlight the entire row
|
||||
row = styleSelected.Render(row)
|
||||
} else if isNext {
|
||||
// Subtle highlight for next race
|
||||
row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true).Render(row)
|
||||
}
|
||||
|
||||
sb.WriteString(row + "\n")
|
||||
}
|
||||
|
||||
// Scroll indicator
|
||||
if len(m.meetings) > visible {
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf("\n Showing %d-%d of %d races", m.scroll+1, endIdx, len(m.meetings))) + "\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(helpBar("y season", "j/k navigate", "g/G top/bottom", "^d/^u page", "enter select", "q quit"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -203,7 +325,7 @@ func formatMeetingDates(m models.Meeting) string {
|
||||
}
|
||||
|
||||
if start.Month() == end.Month() {
|
||||
return fmt.Sprintf("%s %d–%d", start.Format("Jan"), start.Day(), end.Day())
|
||||
return fmt.Sprintf("%s %d-%d", start.Format("Jan"), start.Day(), end.Day())
|
||||
}
|
||||
return fmt.Sprintf("%s %d – %s %d", start.Format("Jan"), start.Day(), end.Format("Jan"), end.Day())
|
||||
return fmt.Sprintf("%s %d - %s %d", start.Format("Jan"), start.Day(), end.Format("Jan"), end.Day())
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ package ui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/api"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/sahilm/fuzzy"
|
||||
)
|
||||
|
||||
type driverView int
|
||||
@@ -22,11 +26,14 @@ type DriverModel struct {
|
||||
client *api.OpenF1Client
|
||||
sessionKey int
|
||||
|
||||
drivers []models.Driver
|
||||
selectedDriver *models.Driver
|
||||
stints []models.Stint
|
||||
laps []models.Lap
|
||||
pits []models.Pit
|
||||
drivers []models.Driver
|
||||
filteredDrivers []models.Driver
|
||||
selectedDriver *models.Driver
|
||||
stints []models.Stint
|
||||
laps []models.Lap
|
||||
pits []models.Pit
|
||||
positions []models.Position
|
||||
teamRadios []models.TeamRadio
|
||||
|
||||
view driverView
|
||||
loading bool
|
||||
@@ -34,28 +41,53 @@ type DriverModel struct {
|
||||
spinner spinner.Model
|
||||
|
||||
cursor int
|
||||
scroll int
|
||||
input textinput.Model
|
||||
|
||||
// Detail view viewport
|
||||
detailView viewport.Model
|
||||
detailViewReady bool
|
||||
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
type driverSource []models.Driver
|
||||
|
||||
func (d driverSource) String(i int) string {
|
||||
return d[i].FullName + " " + d[i].NameAcronym + " " + d[i].TeamName
|
||||
}
|
||||
|
||||
func (d driverSource) Len() int {
|
||||
return len(d)
|
||||
}
|
||||
|
||||
func NewDriverModel(client *api.OpenF1Client) DriverModel {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.MiniDot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search drivers..."
|
||||
ti.Focus()
|
||||
ti.CharLimit = 50
|
||||
ti.Width = 30
|
||||
ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
ti.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
|
||||
|
||||
return DriverModel{
|
||||
client: client,
|
||||
loading: false, // lazy-loaded on first focus
|
||||
loading: false,
|
||||
spinner: s,
|
||||
input: ti,
|
||||
view: driverViewList,
|
||||
}
|
||||
}
|
||||
|
||||
func fetchDriverList(client *api.OpenF1Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Use latest session key
|
||||
drivers, err := client.GetDriversForSession(9999) // will use "latest" via a workaround
|
||||
drivers, err := client.GetDriversForSession(9999)
|
||||
if err != nil || len(drivers) == 0 {
|
||||
// Fallback: get latest championship drivers' session key
|
||||
champ, champErr := client.GetLatestDriverChampionship()
|
||||
if champErr != nil || len(champ) == 0 {
|
||||
return driverListLoadedMsg{err: err}
|
||||
@@ -99,6 +131,14 @@ func fetchDriverDetail(client *api.OpenF1Client, sessionKey, driverNumber int) t
|
||||
}
|
||||
return driverPitsLoadedMsg{pits: driverPits, err: err}
|
||||
},
|
||||
func() tea.Msg {
|
||||
positions, err := client.GetPositions(sessionKey, driverNumber)
|
||||
return driverPositionsLoadedMsg{positions: positions, err: err}
|
||||
},
|
||||
func() tea.Msg {
|
||||
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
|
||||
return driverTeamRadioLoadedMsg{radios: radios, err: err}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,7 +147,13 @@ func (m DriverModel) Init() tea.Cmd {
|
||||
}
|
||||
|
||||
func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.loading {
|
||||
var cmd tea.Cmd
|
||||
@@ -122,32 +168,44 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.drivers = msg.drivers
|
||||
m.cursor = 0
|
||||
m.filterDrivers()
|
||||
|
||||
case driverStintsLoadedMsg:
|
||||
if msg.err == nil {
|
||||
m.stints = msg.stints
|
||||
}
|
||||
m.loading = false
|
||||
m.updateDetailViewport()
|
||||
|
||||
case driverLapsLoadedMsg:
|
||||
if msg.err == nil {
|
||||
m.laps = msg.laps
|
||||
}
|
||||
m.updateDetailViewport()
|
||||
|
||||
case driverPitsLoadedMsg:
|
||||
if msg.err == nil {
|
||||
m.pits = msg.pits
|
||||
}
|
||||
m.updateDetailViewport()
|
||||
|
||||
case driverPositionsLoadedMsg:
|
||||
if msg.err == nil {
|
||||
m.positions = msg.positions
|
||||
}
|
||||
m.updateDetailViewport()
|
||||
|
||||
case driverTeamRadioLoadedMsg:
|
||||
if msg.err == nil {
|
||||
m.teamRadios = msg.radios
|
||||
}
|
||||
m.updateDetailViewport()
|
||||
|
||||
// When a meeting is selected from calendar, update session key for driver lookup
|
||||
case meetingSelectedMsg:
|
||||
// We'll pick up session key when sessions are loaded; for now reset
|
||||
m.drivers = nil
|
||||
|
||||
case sessionsLoadedMsg:
|
||||
if msg.err == nil && len(msg.sessions) > 0 {
|
||||
// Use the Race session key if available
|
||||
for _, s := range msg.sessions {
|
||||
if s.SessionName == "Race" {
|
||||
m.sessionKey = s.SessionKey
|
||||
@@ -161,32 +219,86 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
||||
switch m.view {
|
||||
case driverViewList:
|
||||
switch {
|
||||
case matchKey(msg, GlobalKeys.Retry):
|
||||
if m.err != nil {
|
||||
m.err = nil
|
||||
m.loading = true
|
||||
var cmd tea.Cmd
|
||||
m, cmd = m.TriggerLoad()
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.Up):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
m.ensureCursorVisible()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.Down):
|
||||
if m.cursor < len(m.drivers)-1 {
|
||||
if m.cursor < len(m.filteredDrivers)-1 {
|
||||
m.cursor++
|
||||
m.ensureCursorVisible()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.GoTop):
|
||||
if m.input.Value() == "" {
|
||||
m.cursor = 0
|
||||
m.scroll = 0
|
||||
} else {
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
m.filterDrivers()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.GoBottom):
|
||||
if m.input.Value() == "" {
|
||||
if len(m.filteredDrivers) > 0 {
|
||||
m.cursor = len(m.filteredDrivers) - 1
|
||||
m.ensureCursorVisible()
|
||||
}
|
||||
} else {
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
m.filterDrivers()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.HalfUp):
|
||||
half := m.visibleRows() / 2
|
||||
m.cursor -= half
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
m.ensureCursorVisible()
|
||||
case matchKey(msg, GlobalKeys.HalfDown):
|
||||
half := m.visibleRows() / 2
|
||||
m.cursor += half
|
||||
if m.cursor >= len(m.filteredDrivers) {
|
||||
m.cursor = len(m.filteredDrivers) - 1
|
||||
}
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
m.ensureCursorVisible()
|
||||
case matchKey(msg, GlobalKeys.Enter):
|
||||
if len(m.drivers) > 0 && m.cursor < len(m.drivers) {
|
||||
d := m.drivers[m.cursor]
|
||||
if len(m.filteredDrivers) > 0 && m.cursor >= 0 && m.cursor < len(m.filteredDrivers) {
|
||||
d := m.filteredDrivers[m.cursor]
|
||||
m.selectedDriver = &d
|
||||
m.stints = nil
|
||||
m.laps = nil
|
||||
m.pits = nil
|
||||
m.positions = nil
|
||||
m.teamRadios = nil
|
||||
m.view = driverViewDetail
|
||||
m.loading = true
|
||||
m.detailViewReady = false
|
||||
sessionKey := m.sessionKey
|
||||
if sessionKey == 0 && d.SessionKey != 0 {
|
||||
sessionKey = d.SessionKey
|
||||
}
|
||||
return m, tea.Batch(
|
||||
m.spinner.Tick,
|
||||
fetchDriverDetail(m.client, sessionKey, d.DriverNumber),
|
||||
)
|
||||
cmds = append(cmds, m.spinner.Tick, fetchDriverDetail(m.client, sessionKey, d.DriverNumber))
|
||||
}
|
||||
default:
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
m.filterDrivers()
|
||||
}
|
||||
case driverViewDetail:
|
||||
switch {
|
||||
@@ -196,14 +308,73 @@ func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
|
||||
m.stints = nil
|
||||
m.laps = nil
|
||||
m.pits = nil
|
||||
m.positions = nil
|
||||
m.teamRadios = nil
|
||||
m.detailViewReady = false
|
||||
case matchKey(msg, GlobalKeys.Up):
|
||||
if m.detailViewReady {
|
||||
m.detailView.LineUp(1)
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.Down):
|
||||
if m.detailViewReady {
|
||||
m.detailView.LineDown(1)
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.GoTop):
|
||||
if m.detailViewReady {
|
||||
m.detailView.GotoTop()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.GoBottom):
|
||||
if m.detailViewReady {
|
||||
m.detailView.GotoBottom()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.HalfUp):
|
||||
if m.detailViewReady {
|
||||
m.detailView.HalfViewUp()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.HalfDown):
|
||||
if m.detailViewReady {
|
||||
m.detailView.HalfViewDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m DriverModel) visibleRows() int {
|
||||
rows := m.height - 12
|
||||
if rows < 5 {
|
||||
rows = 5
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (m *DriverModel) ensureCursorVisible() {
|
||||
visible := m.visibleRows()
|
||||
if m.cursor < m.scroll {
|
||||
m.scroll = m.cursor
|
||||
}
|
||||
if m.cursor >= m.scroll+visible {
|
||||
m.scroll = m.cursor - visible + 1
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DriverModel) filterDrivers() {
|
||||
query := m.input.Value()
|
||||
if query == "" {
|
||||
m.filteredDrivers = m.drivers
|
||||
} else {
|
||||
matches := fuzzy.FindFrom(query, driverSource(m.drivers))
|
||||
m.filteredDrivers = make([]models.Driver, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
m.filteredDrivers = append(m.filteredDrivers, m.drivers[match.Index])
|
||||
}
|
||||
}
|
||||
m.cursor = 0
|
||||
m.scroll = 0
|
||||
}
|
||||
|
||||
// TriggerLoad initiates the driver list load if not already loaded.
|
||||
func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) {
|
||||
if m.drivers != nil || m.loading {
|
||||
return m, nil
|
||||
@@ -213,11 +384,12 @@ func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m DriverModel) View() string {
|
||||
if m.loading {
|
||||
return fmt.Sprintf("\n %s Loading drivers…", m.spinner.View())
|
||||
if m.loading && m.view == driverViewList {
|
||||
return fmt.Sprintf("\n %s Loading drivers...", m.spinner.View())
|
||||
}
|
||||
if m.err != nil {
|
||||
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
|
||||
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) +
|
||||
helpBar("r retry", "q quit")
|
||||
}
|
||||
|
||||
switch m.view {
|
||||
@@ -230,49 +402,133 @@ func (m DriverModel) View() string {
|
||||
}
|
||||
|
||||
func (m DriverModel) renderDriverList() string {
|
||||
if len(m.drivers) == 0 {
|
||||
return styleMuted.Render("\n No driver data. Select a race from Calendar first.\n\n" +
|
||||
helpBar("2 calendar", "q quit"))
|
||||
}
|
||||
|
||||
const (
|
||||
wNum = 3
|
||||
wAcronym = 5
|
||||
wName = 25
|
||||
wTeam = 22
|
||||
)
|
||||
|
||||
header := styleBold.Render(
|
||||
padLeft("#", wNum) + " " +
|
||||
padRight("DRV", wAcronym) + " " +
|
||||
padRight("Name", wName) + " " +
|
||||
padRight("Team", wTeam),
|
||||
)
|
||||
|
||||
var rows []string
|
||||
rows = append(rows, header)
|
||||
|
||||
for i, d := range m.drivers {
|
||||
teamStr := hexToStyle(d.TeamColour).Render(padRight(truncate(d.TeamName, wTeam), wTeam))
|
||||
row := fmt.Sprintf("%s %s %s %s",
|
||||
padLeft(fmt.Sprintf("%d", d.DriverNumber), wNum),
|
||||
padRight(d.NameAcronym, wAcronym),
|
||||
padRight(truncate(d.FullName, wName), wName),
|
||||
teamStr,
|
||||
)
|
||||
if i == m.cursor {
|
||||
row = styleSelected.Render(row)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
if len(m.drivers) == 0 && !m.loading {
|
||||
return styleMuted.Render("\n No driver data. Select a race from Calendar first.\n\n") +
|
||||
helpBar("2 calendar", "r retry", "q quit")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(strings.Join(rows, "\n"))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(helpBar("j/k navigate", "enter view driver", "q quit"))
|
||||
w := m.width
|
||||
if w < 40 {
|
||||
w = 40
|
||||
}
|
||||
compact := w < 80
|
||||
|
||||
title := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
Render(" DRIVER LOOKUP")
|
||||
sb.WriteString(title + "\n\n")
|
||||
|
||||
// Search input
|
||||
sb.WriteString(" " + m.input.View() + "\n\n")
|
||||
|
||||
// Responsive columns
|
||||
nameWidth := 24
|
||||
teamWidth := 22
|
||||
if compact {
|
||||
nameWidth = 16
|
||||
teamWidth = 0 // hide team in compact mode
|
||||
} else if w >= 120 {
|
||||
nameWidth = 28
|
||||
teamWidth = 26
|
||||
}
|
||||
|
||||
// Header
|
||||
var header string
|
||||
if compact {
|
||||
header = fmt.Sprintf(" %s %s %s %s",
|
||||
padRight("#", 3),
|
||||
padRight("", 1),
|
||||
padRight("DRV", 4),
|
||||
padRight("NAME", nameWidth),
|
||||
)
|
||||
} else {
|
||||
header = fmt.Sprintf(" %s %s %s %s %s",
|
||||
padRight("#", 3),
|
||||
padRight("", 1),
|
||||
padRight("DRV", 4),
|
||||
padRight("NAME", nameWidth),
|
||||
padRight("TEAM", teamWidth),
|
||||
)
|
||||
}
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
|
||||
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
|
||||
|
||||
visible := m.visibleRows()
|
||||
endIdx := m.scroll + visible
|
||||
if endIdx > len(m.filteredDrivers) {
|
||||
endIdx = len(m.filteredDrivers)
|
||||
}
|
||||
|
||||
for i := m.scroll; i < endIdx; i++ {
|
||||
d := m.filteredDrivers[i]
|
||||
|
||||
teamColor := colorMuted
|
||||
if d.TeamColour != "" {
|
||||
teamColor = "#" + d.TeamColour
|
||||
} else {
|
||||
teamColor = teamColorFromName(d.TeamName)
|
||||
}
|
||||
|
||||
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||
|
||||
var row string
|
||||
if compact {
|
||||
row = fmt.Sprintf(" %s %s %s %s",
|
||||
padRight(fmt.Sprintf("%d", d.DriverNumber), 3),
|
||||
colorBar,
|
||||
padRight(d.NameAcronym, 4),
|
||||
padRight(truncate(d.FullName, nameWidth), nameWidth),
|
||||
)
|
||||
} else {
|
||||
row = fmt.Sprintf(" %s %s %s %s %s",
|
||||
padRight(fmt.Sprintf("%d", d.DriverNumber), 3),
|
||||
colorBar,
|
||||
padRight(d.NameAcronym, 4),
|
||||
padRight(truncate(d.FullName, nameWidth), nameWidth),
|
||||
padRight(truncate(d.TeamName, teamWidth), teamWidth),
|
||||
)
|
||||
}
|
||||
|
||||
if i == m.cursor {
|
||||
row = styleSelected.Render(row)
|
||||
}
|
||||
sb.WriteString(row + "\n")
|
||||
}
|
||||
|
||||
if len(m.filteredDrivers) > visible {
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf("\n %d of %d drivers", len(m.filteredDrivers), len(m.drivers))) + "\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(helpBar("↑/↓ navigate", "g/G top/bottom", "^d/^u page", "enter view", "type to search", "q quit"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m *DriverModel) updateDetailViewport() {
|
||||
if m.selectedDriver == nil {
|
||||
return
|
||||
}
|
||||
content := m.renderDetailContent()
|
||||
vpWidth := m.width - 2
|
||||
if vpWidth < 40 {
|
||||
vpWidth = 40
|
||||
}
|
||||
vpHeight := m.height - 5 // room for header + help bar
|
||||
if vpHeight < 5 {
|
||||
vpHeight = 5
|
||||
}
|
||||
if !m.detailViewReady {
|
||||
m.detailView = viewport.New(vpWidth, vpHeight)
|
||||
m.detailViewReady = true
|
||||
} else {
|
||||
m.detailView.Width = vpWidth
|
||||
m.detailView.Height = vpHeight
|
||||
}
|
||||
m.detailView.SetContent(content)
|
||||
}
|
||||
|
||||
func (m DriverModel) renderDriverDetail() string {
|
||||
if m.selectedDriver == nil {
|
||||
return ""
|
||||
@@ -281,75 +537,385 @@ func (m DriverModel) renderDriverDetail() string {
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Nameplate header
|
||||
nameStyle := hexToStyle(d.TeamColour).Bold(true)
|
||||
sb.WriteString(nameStyle.Render(fmt.Sprintf(" %s %s", d.NameAcronym, d.FullName)))
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" · %s · #%d", d.TeamName, d.DriverNumber)))
|
||||
sb.WriteString("\n\n")
|
||||
teamColor := colorMuted
|
||||
if d.TeamColour != "" {
|
||||
teamColor = "#" + d.TeamColour
|
||||
} else {
|
||||
teamColor = teamColorFromName(d.TeamName)
|
||||
}
|
||||
|
||||
// Stint bar
|
||||
sb.WriteString(styleBold.Render("Stints") + "\n")
|
||||
// Driver card header (fixed, not scrollable)
|
||||
numberBg := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(teamColor)).
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Bold(true).
|
||||
Padding(0, 1).
|
||||
Render(fmt.Sprintf(" #%d ", d.DriverNumber))
|
||||
|
||||
nameStyled := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(teamColor)).
|
||||
Bold(true).
|
||||
Render(d.FullName)
|
||||
|
||||
acronymStyled := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Bold(true).
|
||||
Render(d.NameAcronym)
|
||||
|
||||
teamStyled := styleTeamName.Render(d.TeamName)
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\n %s %s %s %s\n", numberBg, nameStyled, acronymStyled, teamStyled))
|
||||
|
||||
stripeWidth := min(m.width-6, 60)
|
||||
stripe := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(teamColor)).
|
||||
Render(strings.Repeat("━", stripeWidth))
|
||||
sb.WriteString(" " + stripe + "\n")
|
||||
|
||||
if m.loading {
|
||||
sb.WriteString(fmt.Sprintf("\n %s Loading driver data...\n", m.spinner.View()))
|
||||
}
|
||||
|
||||
// Scrollable content via viewport
|
||||
if m.detailViewReady {
|
||||
sb.WriteString(m.detailView.View())
|
||||
sb.WriteString("\n")
|
||||
// Scroll indicator
|
||||
pct := m.detailView.ScrollPercent()
|
||||
scrollInfo := styleMuted.Render(fmt.Sprintf(" %.0f%%", pct*100))
|
||||
sb.WriteString(scrollInfo + "\n")
|
||||
}
|
||||
|
||||
sb.WriteString(helpBar("↑/↓ scroll", "g/G top/bottom", "^d/^u page", "b back", "q quit"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderDetailContent generates the full content for the driver detail viewport.
|
||||
func (m DriverModel) renderDetailContent() string {
|
||||
var sb strings.Builder
|
||||
|
||||
// ── POSITION HISTORY ────────────────────────────────
|
||||
sb.WriteString("\n " + styleSectionTitle.Render("POSITION HISTORY") + "\n")
|
||||
sb.WriteString(m.renderPositionChart())
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ── RACE STRATEGY ───────────────────────────────────
|
||||
sb.WriteString(" " + styleSectionTitle.Render("RACE STRATEGY") + "\n")
|
||||
sb.WriteString(m.renderStintBar())
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Lap sparkline
|
||||
sb.WriteString(styleBold.Render("Lap Times") + "\n")
|
||||
sparkWidth := min(m.width-4, 80)
|
||||
// ── LAP TIMES ───────────────────────────────────────
|
||||
sb.WriteString(" " + styleSectionTitle.Render("LAP TIMES") + "\n")
|
||||
sparkWidth := min(m.width-6, 70)
|
||||
if sparkWidth < 10 {
|
||||
sparkWidth = 40
|
||||
}
|
||||
sb.WriteString(" " + sparkline(m.laps, sparkWidth) + "\n")
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" %d laps (▁=slow, █=fast, space=pit out)\n", len(m.laps))))
|
||||
sb.WriteString("\n")
|
||||
legend := fmt.Sprintf(" %s fast %s mid %s slow %s pit",
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render("█"),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Render("█"),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render("█"),
|
||||
styleMuted.Render("·"),
|
||||
)
|
||||
sb.WriteString(legend)
|
||||
|
||||
// Pit stops
|
||||
sb.WriteString(styleBold.Render("Pit Stops") + "\n")
|
||||
if len(m.laps) > 0 {
|
||||
var bestLap, worstLap float64
|
||||
var bestLapNum, worstLapNum int
|
||||
bestLap = 999999
|
||||
for _, lap := range m.laps {
|
||||
if lap.LapDuration != nil && *lap.LapDuration > 0 {
|
||||
if *lap.LapDuration < bestLap {
|
||||
bestLap = *lap.LapDuration
|
||||
bestLapNum = lap.LapNumber
|
||||
}
|
||||
if *lap.LapDuration > worstLap && !lap.IsPitOutLap {
|
||||
worstLap = *lap.LapDuration
|
||||
worstLapNum = lap.LapNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestLap < 999999 {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s (Lap %d)",
|
||||
styleMuted.Render("Best:"),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true).Render(formatSeconds(bestLap)),
|
||||
bestLapNum,
|
||||
))
|
||||
if worstLap > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s (Lap %d)",
|
||||
styleMuted.Render("Slowest:"),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render(formatSeconds(worstLap)),
|
||||
worstLapNum,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// ── PIT STOPS ───────────────────────────────────────
|
||||
sb.WriteString(" " + styleSectionTitle.Render("PIT STOPS") + "\n")
|
||||
sb.WriteString(m.renderPitStops())
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
sb.WriteString(helpBar("b back to driver list", "q quit"))
|
||||
// ── TEAM RADIO ──────────────────────────────────────
|
||||
sb.WriteString(" " + styleSectionTitle.Render("TEAM RADIO") + "\n")
|
||||
sb.WriteString(m.renderTeamRadio())
|
||||
sb.WriteString("\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m DriverModel) renderStintBar() string {
|
||||
if len(m.stints) == 0 {
|
||||
if m.loading {
|
||||
return fmt.Sprintf(" %s", m.spinner.View())
|
||||
return fmt.Sprintf(" %s Loading...", m.spinner.View())
|
||||
}
|
||||
return styleMuted.Render(" No stint data.")
|
||||
return styleMuted.Render(" No stint data available.")
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, stint := range m.stints {
|
||||
label := fmt.Sprintf("%s %d-%d", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd)
|
||||
part := tyreStyle(stint.Compound).Render(fmt.Sprintf("[%s]", label))
|
||||
parts = append(parts, part)
|
||||
for i, stint := range m.stints {
|
||||
label := fmt.Sprintf(" %s L%d-%d ", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd)
|
||||
stintStr := tyreBgStyle(stint.Compound).Render(label)
|
||||
|
||||
parts = append(parts, stintStr)
|
||||
|
||||
// Arrow between stints
|
||||
if i < len(m.stints)-1 {
|
||||
parts = append(parts, styleMuted.Render(" > "))
|
||||
}
|
||||
}
|
||||
|
||||
return " " + strings.Join(parts, " ")
|
||||
return " " + strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func (m DriverModel) renderPitStops() string {
|
||||
if len(m.pits) == 0 {
|
||||
return styleMuted.Render(" No pit stop data.")
|
||||
return styleMuted.Render(" No pit stop data available.")
|
||||
}
|
||||
|
||||
header := styleBold.Render(
|
||||
padLeft("Lap", 4) + " " +
|
||||
padLeft("Stop", 8) + " " +
|
||||
padLeft("Lane", 8),
|
||||
var sb strings.Builder
|
||||
|
||||
// Header
|
||||
header := fmt.Sprintf(" %s %s %s %s",
|
||||
padRight("STOP", 5),
|
||||
padLeft("LAP", 4),
|
||||
padLeft("STOP TIME", 10),
|
||||
padLeft("PIT LANE", 10),
|
||||
)
|
||||
var rows []string
|
||||
rows = append(rows, header)
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
|
||||
sb.WriteString(" " + divider(35) + "\n")
|
||||
|
||||
for _, p := range m.pits {
|
||||
row := fmt.Sprintf("%s %s %s",
|
||||
for i, p := range m.pits {
|
||||
stopNum := fmt.Sprintf("#%d", i+1)
|
||||
|
||||
// Color stop duration: green = fast, red = slow
|
||||
stopDur := p.StopDuration
|
||||
var stopStyle lipgloss.Style
|
||||
switch {
|
||||
case stopDur < 2.5:
|
||||
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true)
|
||||
case stopDur < 3.5:
|
||||
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
|
||||
default:
|
||||
stopStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
}
|
||||
|
||||
row := fmt.Sprintf(" %s %s %s %s",
|
||||
padRight(stopNum, 5),
|
||||
padLeft(fmt.Sprintf("%d", p.LapNumber), 4),
|
||||
padLeft(fmt.Sprintf("%.3fs", p.StopDuration), 8),
|
||||
padLeft(fmt.Sprintf("%.3fs", p.LaneDuration), 8),
|
||||
padLeftVisible(stopStyle.Render(fmt.Sprintf("%.3fs", p.StopDuration)), 10),
|
||||
padLeft(fmt.Sprintf("%.3fs", p.LaneDuration), 10),
|
||||
)
|
||||
rows = append(rows, " "+row)
|
||||
sb.WriteString(row + "\n")
|
||||
}
|
||||
|
||||
return strings.Join(rows, "\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderPositionChart draws a text-based position history chart.
|
||||
// Shows position changes over the race using a compact inline format.
|
||||
func (m DriverModel) renderPositionChart() string {
|
||||
if len(m.positions) == 0 {
|
||||
return styleMuted.Render(" No position data available.\n")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Deduplicate: only keep position changes
|
||||
type posChange struct {
|
||||
position int
|
||||
date string
|
||||
}
|
||||
var changes []posChange
|
||||
lastPos := -1
|
||||
for _, p := range m.positions {
|
||||
if p.Position != lastPos {
|
||||
changes = append(changes, posChange{position: p.Position, date: p.Date})
|
||||
lastPos = p.Position
|
||||
}
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
return styleMuted.Render(" No position changes.\n")
|
||||
}
|
||||
|
||||
// Start and end positions
|
||||
startPos := changes[0].position
|
||||
endPos := changes[len(changes)-1].position
|
||||
bestPos := startPos
|
||||
worstPos := startPos
|
||||
for _, c := range changes {
|
||||
if c.position < bestPos {
|
||||
bestPos = c.position
|
||||
}
|
||||
if c.position > worstPos {
|
||||
worstPos = c.position
|
||||
}
|
||||
}
|
||||
|
||||
// Summary line
|
||||
startStyled := renderPosition(startPos)
|
||||
endStyled := renderPosition(endPos)
|
||||
delta := startPos - endPos // positive = gained
|
||||
var deltaStr string
|
||||
if delta > 0 {
|
||||
deltaStr = styleDeltaUp.Render(fmt.Sprintf("▲%d gained", delta))
|
||||
} else if delta < 0 {
|
||||
deltaStr = styleDeltaDown.Render(fmt.Sprintf("▼%d lost", -delta))
|
||||
} else {
|
||||
deltaStr = styleDeltaEqual.Render("─ no change")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Start: P%s Finish: P%s %s\n", startStyled, endStyled, deltaStr))
|
||||
sb.WriteString(fmt.Sprintf(" %s P%s %s P%s\n",
|
||||
styleMuted.Render("Best:"),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Bold(true).Render(fmt.Sprintf("%d", bestPos)),
|
||||
styleMuted.Render("Worst:"),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Render(fmt.Sprintf("%d", worstPos)),
|
||||
))
|
||||
|
||||
// Visual position timeline (compact sparkline-style)
|
||||
chartWidth := min(m.width-6, 60)
|
||||
if chartWidth < 20 {
|
||||
chartWidth = 20
|
||||
}
|
||||
|
||||
if len(changes) > 1 {
|
||||
posRange := worstPos - bestPos
|
||||
if posRange == 0 {
|
||||
posRange = 1
|
||||
}
|
||||
|
||||
// Sample positions at regular intervals
|
||||
samples := make([]int, chartWidth)
|
||||
for i := 0; i < chartWidth; i++ {
|
||||
idx := i * (len(changes) - 1) / (chartWidth - 1)
|
||||
if idx >= len(changes) {
|
||||
idx = len(changes) - 1
|
||||
}
|
||||
samples[i] = changes[idx].position
|
||||
}
|
||||
|
||||
// Draw the chart
|
||||
blocks := []rune("▁▂▃▄▅▆▇█")
|
||||
var chartLine strings.Builder
|
||||
for _, pos := range samples {
|
||||
// Invert: lower position (better) = taller bar
|
||||
norm := float64(pos-bestPos) / float64(posRange)
|
||||
idx := int((1.0-norm)*float64(len(blocks)-1) + 0.5)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(blocks) {
|
||||
idx = len(blocks) - 1
|
||||
}
|
||||
var blockStyle lipgloss.Style
|
||||
switch {
|
||||
case norm < 0.25:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
|
||||
case norm < 0.5:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
|
||||
case norm < 0.75:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange))
|
||||
default:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
}
|
||||
chartLine.WriteString(blockStyle.Render(string(blocks[idx])))
|
||||
}
|
||||
sb.WriteString(" " + chartLine.String() + "\n")
|
||||
sb.WriteString(fmt.Sprintf(" %s%s%s\n",
|
||||
styleMuted.Render(fmt.Sprintf("P%d", bestPos)),
|
||||
strings.Repeat(" ", max(chartWidth-8, 1)),
|
||||
styleMuted.Render(fmt.Sprintf("P%d", worstPos)),
|
||||
))
|
||||
}
|
||||
|
||||
// Position change timeline (textual)
|
||||
if len(changes) > 1 && len(changes) <= 20 {
|
||||
sb.WriteString(" ")
|
||||
for i, c := range changes {
|
||||
posStr := fmt.Sprintf("P%d", c.position)
|
||||
if c.position <= 3 {
|
||||
posStr = renderPosition(c.position)
|
||||
}
|
||||
sb.WriteString(posStr)
|
||||
if i < len(changes)-1 {
|
||||
next := changes[i+1].position
|
||||
if next < c.position {
|
||||
sb.WriteString(styleDeltaUp.Render(" > "))
|
||||
} else if next > c.position {
|
||||
sb.WriteString(styleDeltaDown.Render(" > "))
|
||||
} else {
|
||||
sb.WriteString(styleMuted.Render(" > "))
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderTeamRadio displays team radio messages with timestamps.
|
||||
func (m DriverModel) renderTeamRadio() string {
|
||||
if len(m.teamRadios) == 0 {
|
||||
return styleMuted.Render(" No team radio messages available.\n")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
countStr := styleMuted.Render(fmt.Sprintf(" %d messages during session", len(m.teamRadios)))
|
||||
sb.WriteString(countStr + "\n")
|
||||
|
||||
for i, radio := range m.teamRadios {
|
||||
// Parse timestamp
|
||||
t := "--:--:--"
|
||||
if len(radio.Date) >= 19 {
|
||||
pt, err := time.Parse(time.RFC3339, radio.Date)
|
||||
if err == nil {
|
||||
t = pt.Format("15:04:05")
|
||||
} else {
|
||||
t = radio.Date[11:19]
|
||||
}
|
||||
}
|
||||
|
||||
icon := lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render("📻")
|
||||
timeStr := styleMuted.Render(fmt.Sprintf("[%s]", t))
|
||||
|
||||
urlStr := radio.RecordingURL
|
||||
urlStyled := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorCyan)).
|
||||
Render(truncate(urlStr, min(m.width-20, 50)))
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s %s %s\n", icon, timeStr, urlStyled))
|
||||
|
||||
// Limit display to 15 entries
|
||||
if i >= 14 && i < len(m.teamRadios)-1 {
|
||||
remaining := len(m.teamRadios) - i - 1
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... and %d more messages\n", remaining)))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -4,16 +4,23 @@ import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
// GlobalKeyMap holds keybindings that work from any tab.
|
||||
type GlobalKeyMap struct {
|
||||
Tab1 key.Binding
|
||||
Tab2 key.Binding
|
||||
Tab3 key.Binding
|
||||
Tab4 key.Binding
|
||||
Quit key.Binding
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Enter key.Binding
|
||||
Back key.Binding
|
||||
Year key.Binding
|
||||
Tab1 key.Binding
|
||||
Tab2 key.Binding
|
||||
Tab3 key.Binding
|
||||
Tab4 key.Binding
|
||||
NextTab key.Binding
|
||||
PrevTab key.Binding
|
||||
Quit key.Binding
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Enter key.Binding
|
||||
Back key.Binding
|
||||
Year key.Binding
|
||||
Retry key.Binding
|
||||
GoTop key.Binding
|
||||
GoBottom key.Binding
|
||||
HalfUp key.Binding
|
||||
HalfDown key.Binding
|
||||
}
|
||||
|
||||
// GlobalKeys is the singleton global key map.
|
||||
@@ -34,30 +41,58 @@ var GlobalKeys = GlobalKeyMap{
|
||||
key.WithKeys("4"),
|
||||
key.WithHelp("4", "drivers"),
|
||||
),
|
||||
NextTab: key.NewBinding(
|
||||
key.WithKeys("tab", "right"),
|
||||
key.WithHelp("tab/->", "next tab"),
|
||||
),
|
||||
PrevTab: key.NewBinding(
|
||||
key.WithKeys("shift+tab", "left"),
|
||||
key.WithHelp("shift+tab/<-", "prev tab"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q", "quit"),
|
||||
),
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("k", "up"),
|
||||
key.WithHelp("k/↑", "up"),
|
||||
key.WithHelp("k", "up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("j", "down"),
|
||||
key.WithHelp("j/↓", "down"),
|
||||
key.WithHelp("j", "down"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "select"),
|
||||
),
|
||||
Back: key.NewBinding(
|
||||
key.WithKeys("b"),
|
||||
key.WithHelp("b", "back"),
|
||||
key.WithKeys("b", "esc"),
|
||||
key.WithHelp("b/esc", "back"),
|
||||
),
|
||||
Year: key.NewBinding(
|
||||
key.WithKeys("y"),
|
||||
key.WithHelp("y", "switch year"),
|
||||
),
|
||||
Retry: key.NewBinding(
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("r", "retry"),
|
||||
),
|
||||
GoTop: key.NewBinding(
|
||||
key.WithKeys("g", "home"),
|
||||
key.WithHelp("g", "go to top"),
|
||||
),
|
||||
GoBottom: key.NewBinding(
|
||||
key.WithKeys("G", "end"),
|
||||
key.WithHelp("G", "go to bottom"),
|
||||
),
|
||||
HalfUp: key.NewBinding(
|
||||
key.WithKeys("ctrl+u"),
|
||||
key.WithHelp("ctrl+u", "half page up"),
|
||||
),
|
||||
HalfDown: key.NewBinding(
|
||||
key.WithKeys("ctrl+d"),
|
||||
key.WithHelp("ctrl+d", "half page down"),
|
||||
),
|
||||
}
|
||||
|
||||
// StandingsKeyMap holds standing-specific keybindings.
|
||||
@@ -79,8 +114,10 @@ var StandingsKeys = StandingsKeyMap{
|
||||
|
||||
// RaceDetailKeyMap holds keybindings for the race detail tab.
|
||||
type RaceDetailKeyMap struct {
|
||||
ScrollUp key.Binding
|
||||
ScrollDown key.Binding
|
||||
ScrollUp key.Binding
|
||||
ScrollDown key.Binding
|
||||
PrevSession key.Binding
|
||||
NextSession key.Binding
|
||||
}
|
||||
|
||||
var RaceDetailKeys = RaceDetailKeyMap{
|
||||
@@ -92,4 +129,12 @@ var RaceDetailKeys = RaceDetailKeyMap{
|
||||
key.WithKeys("J"),
|
||||
key.WithHelp("J", "scroll race control down"),
|
||||
),
|
||||
PrevSession: key.NewBinding(
|
||||
key.WithKeys("["),
|
||||
key.WithHelp("[", "previous session"),
|
||||
),
|
||||
NextSession: key.NewBinding(
|
||||
key.WithKeys("]"),
|
||||
key.WithHelp("]", "next session"),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -80,6 +80,24 @@ type driverPitsLoadedMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// driverPositionsLoadedMsg carries position history for a selected driver.
|
||||
type driverPositionsLoadedMsg struct {
|
||||
positions []models.Position
|
||||
err error
|
||||
}
|
||||
|
||||
// driverTeamRadioLoadedMsg carries team radio messages for a selected driver.
|
||||
type driverTeamRadioLoadedMsg struct {
|
||||
radios []models.TeamRadio
|
||||
err error
|
||||
}
|
||||
|
||||
// overtakesLoadedMsg carries overtake data for a session.
|
||||
type overtakesLoadedMsg struct {
|
||||
overtakes []models.Overtake
|
||||
err error
|
||||
}
|
||||
|
||||
// meetingSelectedMsg is emitted when the user selects a meeting in the calendar.
|
||||
type meetingSelectedMsg struct {
|
||||
meeting models.Meeting
|
||||
|
||||
@@ -17,23 +17,26 @@ type RaceDetailModel struct {
|
||||
client *api.OpenF1Client
|
||||
meeting *models.Meeting
|
||||
|
||||
sessions []models.Session
|
||||
results []models.SessionResult
|
||||
drivers map[int]models.Driver
|
||||
rcMsgs []models.RaceControl
|
||||
weather []models.Weather
|
||||
sessions []models.Session
|
||||
results []models.SessionResult
|
||||
drivers map[int]models.Driver
|
||||
rcMsgs []models.RaceControl
|
||||
weather []models.Weather
|
||||
overtakes []models.Overtake
|
||||
|
||||
selectedSession *models.Session
|
||||
sessionCursor int
|
||||
resultsCursor int
|
||||
resultsScroll int
|
||||
|
||||
loadingSessions bool
|
||||
loadingResults bool
|
||||
errSessions error
|
||||
errResults error
|
||||
|
||||
spinner spinner.Model
|
||||
rcView viewport.Model
|
||||
rcReady bool
|
||||
spinner spinner.Model
|
||||
rcView viewport.Model
|
||||
rcReady bool
|
||||
|
||||
width int
|
||||
height int
|
||||
@@ -43,6 +46,7 @@ func NewRaceDetailModel(client *api.OpenF1Client) RaceDetailModel {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.MiniDot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
|
||||
return RaceDetailModel{
|
||||
client: client,
|
||||
spinner: s,
|
||||
@@ -79,6 +83,10 @@ func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
|
||||
weather, err := client.GetWeather(sessionKey)
|
||||
return weatherLoadedMsg{weather: weather, err: err}
|
||||
},
|
||||
func() tea.Msg {
|
||||
overtakes, err := client.GetOvertakesForSession(sessionKey)
|
||||
return overtakesLoadedMsg{overtakes: overtakes, err: err}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,6 +94,9 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
// handled by SetSize from app.go
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.loadingSessions || m.loadingResults {
|
||||
var cmd tea.Cmd
|
||||
@@ -99,8 +110,11 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
||||
m.results = nil
|
||||
m.rcMsgs = nil
|
||||
m.weather = nil
|
||||
m.overtakes = nil
|
||||
m.selectedSession = nil
|
||||
m.sessionCursor = 0
|
||||
m.resultsCursor = 0
|
||||
m.resultsScroll = 0
|
||||
m.loadingSessions = true
|
||||
m.loadingResults = false
|
||||
m.errSessions = nil
|
||||
@@ -115,13 +129,34 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.sessions = msg.sessions
|
||||
// Auto-select Race session if available
|
||||
// Auto-select the Race session and load its data
|
||||
raceIdx := -1
|
||||
for i, s := range m.sessions {
|
||||
if s.SessionName == "Race" {
|
||||
m.sessionCursor = i
|
||||
raceIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if raceIdx >= 0 {
|
||||
m.sessionCursor = raceIdx
|
||||
sess := m.sessions[raceIdx]
|
||||
m.selectedSession = &sess
|
||||
m.loadingResults = true
|
||||
m.results = nil
|
||||
m.rcMsgs = nil
|
||||
m.weather = nil
|
||||
m.drivers = make(map[int]models.Driver)
|
||||
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
|
||||
} else if len(m.sessions) > 0 {
|
||||
// Fallback: select last session
|
||||
lastIdx := len(m.sessions) - 1
|
||||
m.sessionCursor = lastIdx
|
||||
sess := m.sessions[lastIdx]
|
||||
m.selectedSession = &sess
|
||||
m.loadingResults = true
|
||||
m.drivers = make(map[int]models.Driver)
|
||||
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
|
||||
}
|
||||
|
||||
case sessionResultsLoadedMsg:
|
||||
if msg.err != nil {
|
||||
@@ -130,9 +165,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.results = msg.results
|
||||
if !m.loadingResults {
|
||||
// Both results and drivers may arrive in any order
|
||||
}
|
||||
m.resultsCursor = 0
|
||||
m.resultsScroll = 0
|
||||
m.checkResultsLoaded()
|
||||
|
||||
case sessionDriversLoadedMsg:
|
||||
@@ -154,15 +188,60 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
||||
m.weather = msg.weather
|
||||
}
|
||||
|
||||
case overtakesLoadedMsg:
|
||||
if msg.err == nil {
|
||||
m.overtakes = msg.overtakes
|
||||
}
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case matchKey(msg, GlobalKeys.Retry):
|
||||
if m.errSessions != nil && m.meeting != nil {
|
||||
m.errSessions = nil
|
||||
m.loadingSessions = true
|
||||
cmds = append(cmds, m.spinner.Tick, fetchSessions(m.client, int(m.meeting.MeetingKey)))
|
||||
} else if m.errResults != nil && m.selectedSession != nil {
|
||||
m.errResults = nil
|
||||
m.loadingResults = true
|
||||
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, m.selectedSession.SessionKey))
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.Up):
|
||||
if m.sessionCursor > 0 {
|
||||
m.sessionCursor--
|
||||
if m.results != nil && m.resultsCursor > 0 {
|
||||
m.resultsCursor--
|
||||
m.ensureResultsVisible()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.Down):
|
||||
if m.sessionCursor < len(m.sessions)-1 {
|
||||
m.sessionCursor++
|
||||
if m.results != nil && m.resultsCursor < len(m.results)-1 {
|
||||
m.resultsCursor++
|
||||
m.ensureResultsVisible()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.GoTop):
|
||||
if m.results != nil {
|
||||
m.resultsCursor = 0
|
||||
m.resultsScroll = 0
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.GoBottom):
|
||||
if m.results != nil && len(m.results) > 0 {
|
||||
m.resultsCursor = len(m.results) - 1
|
||||
m.ensureResultsVisible()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.HalfUp):
|
||||
if m.results != nil {
|
||||
half := m.resultsVisibleRows() / 2
|
||||
m.resultsCursor -= half
|
||||
if m.resultsCursor < 0 {
|
||||
m.resultsCursor = 0
|
||||
}
|
||||
m.ensureResultsVisible()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.HalfDown):
|
||||
if m.results != nil {
|
||||
half := m.resultsVisibleRows() / 2
|
||||
m.resultsCursor += half
|
||||
if m.resultsCursor >= len(m.results) {
|
||||
m.resultsCursor = len(m.results) - 1
|
||||
}
|
||||
m.ensureResultsVisible()
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.Enter):
|
||||
if len(m.sessions) > 0 && m.sessionCursor < len(m.sessions) {
|
||||
@@ -172,13 +251,24 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
||||
m.results = nil
|
||||
m.rcMsgs = nil
|
||||
m.weather = nil
|
||||
m.overtakes = nil
|
||||
m.resultsCursor = 0
|
||||
m.resultsScroll = 0
|
||||
m.drivers = make(map[int]models.Driver)
|
||||
cmds = append(cmds, fetchSessionData(m.client, sess.SessionKey))
|
||||
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
|
||||
}
|
||||
case matchKey(msg, RaceDetailKeys.ScrollUp):
|
||||
m.rcView.LineUp(3)
|
||||
case matchKey(msg, RaceDetailKeys.ScrollDown):
|
||||
m.rcView.LineDown(3)
|
||||
case matchKey(msg, RaceDetailKeys.PrevSession):
|
||||
if m.sessionCursor > 0 {
|
||||
m.sessionCursor--
|
||||
}
|
||||
case matchKey(msg, RaceDetailKeys.NextSession):
|
||||
if m.sessionCursor < len(m.sessions)-1 {
|
||||
m.sessionCursor++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,12 +282,29 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *RaceDetailModel) checkResultsLoaded() {
|
||||
// Mark done once results arrive (drivers may still be loading but we show what we have)
|
||||
if m.results != nil {
|
||||
m.loadingResults = false
|
||||
}
|
||||
}
|
||||
|
||||
func (m RaceDetailModel) resultsVisibleRows() int {
|
||||
rows := m.height - 18
|
||||
if rows < 5 {
|
||||
rows = 5
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (m *RaceDetailModel) ensureResultsVisible() {
|
||||
visible := m.resultsVisibleRows()
|
||||
if m.resultsCursor < m.resultsScroll {
|
||||
m.resultsScroll = m.resultsCursor
|
||||
}
|
||||
if m.resultsCursor >= m.resultsScroll+visible {
|
||||
m.resultsScroll = m.resultsCursor - visible + 1
|
||||
}
|
||||
}
|
||||
|
||||
func (m *RaceDetailModel) updateRCViewport() {
|
||||
content := m.renderRaceControlContent()
|
||||
if m.rcReady {
|
||||
@@ -214,177 +321,307 @@ func (m *RaceDetailModel) initViewport(w, h int) {
|
||||
|
||||
func (m RaceDetailModel) View() string {
|
||||
if m.meeting == nil {
|
||||
return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n" +
|
||||
helpBar("2 calendar", "q quit"))
|
||||
return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n") +
|
||||
helpBar("2 calendar", "q quit")
|
||||
}
|
||||
|
||||
// Title
|
||||
title := styleBold.Render(m.meeting.MeetingOfficialName)
|
||||
dates := formatMeetingDates(*m.meeting)
|
||||
subtitle := styleMuted.Render(fmt.Sprintf("%s · %s · %s", m.meeting.Location, m.meeting.CountryName, dates))
|
||||
w := m.width
|
||||
if w < 40 {
|
||||
w = 40
|
||||
}
|
||||
compact := w < 100
|
||||
|
||||
header := lipgloss.JoinVertical(lipgloss.Left, title, subtitle) + "\n\n"
|
||||
|
||||
// Two-panel layout
|
||||
leftWidth := int(float64(m.width) * 0.55)
|
||||
rightWidth := m.width - leftWidth - 4
|
||||
|
||||
left := m.renderLeft(leftWidth)
|
||||
right := m.renderRight(rightWidth)
|
||||
|
||||
panels := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
stylePanelBorder.Width(leftWidth).Render(left),
|
||||
stylePanelBorder.Width(rightWidth).Render(right),
|
||||
)
|
||||
|
||||
help := helpBar("j/k sessions", "enter load session", "K/J scroll RC", "b back to calendar", "q quit")
|
||||
return header + panels + "\n" + help
|
||||
}
|
||||
|
||||
func (m RaceDetailModel) renderLeft(width int) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Session list
|
||||
sb.WriteString(styleHeader.Render("Sessions") + "\n")
|
||||
if m.loadingSessions {
|
||||
sb.WriteString(fmt.Sprintf(" %s Loading sessions…\n", m.spinner.View()))
|
||||
} else if m.errSessions != nil {
|
||||
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errSessions)))
|
||||
// Race title header
|
||||
flag := countryFlag(m.meeting.CountryCode)
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorF1Red))
|
||||
|
||||
if compact {
|
||||
sb.WriteString(titleStyle.Render(fmt.Sprintf(" %s %s", flag, m.meeting.MeetingName)) + "\n")
|
||||
} else {
|
||||
for i, sess := range m.sessions {
|
||||
var start string
|
||||
if len(sess.DateStart) >= 10 {
|
||||
t, err := time.Parse(time.RFC3339, sess.DateStart)
|
||||
if err == nil {
|
||||
start = t.Format("Mon Jan 2")
|
||||
} else {
|
||||
start = sess.DateStart[:10]
|
||||
}
|
||||
sb.WriteString(titleStyle.Render(fmt.Sprintf(" %s %s", flag, m.meeting.MeetingOfficialName)) + "\n")
|
||||
}
|
||||
|
||||
dates := formatMeetingDates(*m.meeting)
|
||||
subtitle := fmt.Sprintf(" %s %s %s",
|
||||
styleMuted.Render(m.meeting.Location),
|
||||
styleMuted.Render("·"),
|
||||
styleMuted.Render(dates))
|
||||
sb.WriteString(subtitle + "\n\n")
|
||||
|
||||
// Session selector pills
|
||||
sb.WriteString(m.renderSessionPills())
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
if compact {
|
||||
// Single column layout for narrow terminals
|
||||
sb.WriteString(m.renderResults(w - 4))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(m.renderWeatherCard(w - 4))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(m.renderOvertakes(w - 4))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
|
||||
if !m.rcReady || m.selectedSession == nil {
|
||||
sb.WriteString(styleMuted.Render(" No session selected.\n"))
|
||||
} else {
|
||||
// Show limited RC messages inline
|
||||
lines := strings.Split(m.renderRaceControlContent(), "\n")
|
||||
maxRC := 8
|
||||
if len(lines) > maxRC {
|
||||
lines = lines[len(lines)-maxRC:]
|
||||
}
|
||||
row := fmt.Sprintf(" %-12s %s", sess.SessionName, start)
|
||||
if i == m.sessionCursor {
|
||||
row = styleSelected.Render(row)
|
||||
} else if m.selectedSession != nil && m.selectedSession.SessionKey == sess.SessionKey {
|
||||
row = styleDeltaUp.Render(row)
|
||||
sb.WriteString(strings.Join(lines, "\n") + "\n")
|
||||
if len(m.rcMsgs) > maxRC {
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... %d more messages (K/J scroll)", len(m.rcMsgs)-maxRC)) + "\n")
|
||||
}
|
||||
sb.WriteString(row + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + styleHeader.Render("Results") + "\n")
|
||||
|
||||
if m.loadingResults {
|
||||
sb.WriteString(fmt.Sprintf(" %s Loading results…\n", m.spinner.View()))
|
||||
} else if m.errResults != nil {
|
||||
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults)))
|
||||
} else if m.selectedSession == nil {
|
||||
sb.WriteString(styleMuted.Render(" Press Enter to load session results.\n"))
|
||||
} else if len(m.results) == 0 {
|
||||
sb.WriteString(styleMuted.Render(" No results available.\n"))
|
||||
} else {
|
||||
sb.WriteString(m.renderResults(width - 4))
|
||||
// Two-panel layout
|
||||
leftWidth := int(float64(w) * 0.55)
|
||||
rightWidth := w - leftWidth - 6
|
||||
|
||||
left := m.renderResults(leftWidth)
|
||||
right := m.renderRightPanel(rightWidth)
|
||||
|
||||
panels := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
stylePanelBorder.Width(leftWidth).Render(left),
|
||||
stylePanelBorder.Width(rightWidth).Render(right),
|
||||
)
|
||||
|
||||
sb.WriteString(panels + "\n")
|
||||
}
|
||||
|
||||
sb.WriteString(helpBar("[/] sessions", "enter load", "j/k results", "g/G top/bottom", "K/J scroll RC", "b back", "q quit"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m RaceDetailModel) renderResults(width int) string {
|
||||
isRace := m.selectedSession != nil && m.selectedSession.SessionType == "Race"
|
||||
|
||||
const (
|
||||
wPos = 3
|
||||
wDRV = 4
|
||||
wTeam = 16
|
||||
wLaps = 4
|
||||
wGap = 12
|
||||
wPts = 4
|
||||
)
|
||||
|
||||
var header string
|
||||
if isRace {
|
||||
header = styleBold.Render(
|
||||
padLeft("Pos", wPos) + " " +
|
||||
padRight("DRV", wDRV) + " " +
|
||||
padRight("Team", wTeam) + " " +
|
||||
padLeft("Laps", wLaps) + " " +
|
||||
padLeft("Gap", wGap) + " " +
|
||||
padLeft("Pts", wPts),
|
||||
)
|
||||
} else {
|
||||
header = styleBold.Render(
|
||||
padLeft("Pos", wPos) + " " +
|
||||
padRight("DRV", wDRV) + " " +
|
||||
padRight("Team", wTeam) + " " +
|
||||
padLeft("Time", wGap),
|
||||
)
|
||||
func (m RaceDetailModel) renderSessionPills() string {
|
||||
if m.loadingSessions {
|
||||
return fmt.Sprintf(" %s Loading sessions...", m.spinner.View())
|
||||
}
|
||||
if m.errSessions != nil {
|
||||
return styleError.Render(fmt.Sprintf(" Error: %v", m.errSessions))
|
||||
}
|
||||
|
||||
var rows []string
|
||||
rows = append(rows, header)
|
||||
var pills []string
|
||||
for i, sess := range m.sessions {
|
||||
// Format date
|
||||
var dateStr string
|
||||
if len(sess.DateStart) >= 10 {
|
||||
t, err := time.Parse(time.RFC3339, sess.DateStart)
|
||||
if err == nil {
|
||||
dateStr = t.Format("Mon 2")
|
||||
} else {
|
||||
dateStr = sess.DateStart[:10]
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range m.results {
|
||||
label := fmt.Sprintf("%s %s", sess.SessionName, dateStr)
|
||||
|
||||
if m.selectedSession != nil && m.selectedSession.SessionKey == sess.SessionKey {
|
||||
pills = append(pills, styleSessionActive.Render(label))
|
||||
} else if i == m.sessionCursor {
|
||||
pills = append(pills, styleSessionCursor.Render(label))
|
||||
} else {
|
||||
pills = append(pills, styleSessionInactive.Render(label))
|
||||
}
|
||||
}
|
||||
|
||||
return " " + strings.Join(pills, " ")
|
||||
}
|
||||
|
||||
func (m RaceDetailModel) renderResults(width int) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(styleSectionTitle.Render("RESULTS") + "\n")
|
||||
|
||||
if m.loadingResults {
|
||||
sb.WriteString(fmt.Sprintf(" %s Loading results...\n", m.spinner.View()))
|
||||
return sb.String()
|
||||
}
|
||||
if m.errResults != nil {
|
||||
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults)))
|
||||
return sb.String()
|
||||
}
|
||||
if m.selectedSession == nil {
|
||||
sb.WriteString(styleMuted.Render(" Press Enter to load session results.\n"))
|
||||
return sb.String()
|
||||
}
|
||||
if len(m.results) == 0 {
|
||||
sb.WriteString(styleMuted.Render(" No results available.\n"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
isRace := m.selectedSession.SessionType == "Race"
|
||||
compact := width < 55
|
||||
|
||||
// Responsive team name width
|
||||
teamWidth := 16
|
||||
if width >= 70 {
|
||||
teamWidth = 20
|
||||
} else if compact {
|
||||
teamWidth = 10
|
||||
}
|
||||
|
||||
// Column header
|
||||
var header string
|
||||
if isRace {
|
||||
if compact {
|
||||
header = fmt.Sprintf(" %s %s %s %s %s",
|
||||
padRight("P", 3),
|
||||
padRight("", 1),
|
||||
padRight("DRV", 4),
|
||||
padLeft("GAP", 10),
|
||||
padLeft("PTS", 4),
|
||||
)
|
||||
} else {
|
||||
header = fmt.Sprintf(" %s %s %s %s %s %s %s",
|
||||
padRight("P", 3),
|
||||
padRight("", 1),
|
||||
padRight("DRV", 4),
|
||||
padRight("TEAM", teamWidth),
|
||||
padLeft("LAPS", 4),
|
||||
padLeft("GAP", 12),
|
||||
padLeft("PTS", 4),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if compact {
|
||||
header = fmt.Sprintf(" %s %s %s %s",
|
||||
padRight("P", 3),
|
||||
padRight("", 1),
|
||||
padRight("DRV", 4),
|
||||
padLeft("TIME", 12),
|
||||
)
|
||||
} else {
|
||||
header = fmt.Sprintf(" %s %s %s %s %s",
|
||||
padRight("P", 3),
|
||||
padRight("", 1),
|
||||
padRight("DRV", 4),
|
||||
padRight("TEAM", teamWidth),
|
||||
padLeft("TIME", 12),
|
||||
)
|
||||
}
|
||||
}
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n")
|
||||
sb.WriteString(" " + divider(min(width-4, lipgloss.Width(header))) + "\n")
|
||||
|
||||
visible := m.resultsVisibleRows()
|
||||
endIdx := m.resultsScroll + visible
|
||||
if endIdx > len(m.results) {
|
||||
endIdx = len(m.results)
|
||||
}
|
||||
|
||||
for i := m.resultsScroll; i < endIdx; i++ {
|
||||
r := m.results[i]
|
||||
d := m.drivers[r.DriverNumber]
|
||||
acronym := d.NameAcronym
|
||||
if acronym == "" {
|
||||
acronym = fmt.Sprintf("#%d", r.DriverNumber)
|
||||
}
|
||||
teamName := d.TeamName
|
||||
teamColor := d.TeamColour
|
||||
|
||||
teamStr := hexToStyle(teamColor).Render(padRight(truncate(teamName, wTeam), wTeam))
|
||||
teamColor := colorMuted
|
||||
if d.TeamColour != "" {
|
||||
teamColor = "#" + d.TeamColour
|
||||
} else if d.TeamName != "" {
|
||||
teamColor = teamColorFromName(d.TeamName)
|
||||
}
|
||||
|
||||
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||
|
||||
// Position with status
|
||||
var pos string
|
||||
if r.DNF {
|
||||
pos = styleDNF.Render("DNF")
|
||||
} else if r.DNS {
|
||||
pos = styleDNF.Render("DNS")
|
||||
} else if r.DSQ {
|
||||
pos = styleDNF.Render("DSQ")
|
||||
} else {
|
||||
pos = renderPosition(r.Position)
|
||||
}
|
||||
|
||||
var row string
|
||||
pos := fmt.Sprintf("%d", r.Position)
|
||||
if r.DNF {
|
||||
pos = "DNF"
|
||||
} else if r.DNS {
|
||||
pos = "DNS"
|
||||
} else if r.DSQ {
|
||||
pos = "DSQ"
|
||||
}
|
||||
|
||||
if isRace {
|
||||
row = fmt.Sprintf("%s %s %s %s %s %s",
|
||||
padLeft(pos, wPos),
|
||||
padRight(acronym, wDRV),
|
||||
teamStr,
|
||||
padLeft(fmt.Sprintf("%d", r.NumberOfLaps), wLaps),
|
||||
padLeft(formatGap(r.GapToLeader), wGap),
|
||||
padLeft(fmt.Sprintf("%.0f", r.Points), wPts),
|
||||
)
|
||||
gap := formatGap(r.GapToLeader)
|
||||
var gapStyled string
|
||||
if gap == "LEADER" {
|
||||
gapStyled = styleLeader.Render("LEADER")
|
||||
} else {
|
||||
gapStyled = styleGap.Render(gap)
|
||||
}
|
||||
|
||||
if compact {
|
||||
row = fmt.Sprintf(" %s %s %s %s %s",
|
||||
padRightVisible(pos, 3),
|
||||
colorBar,
|
||||
padRight(acronym, 4),
|
||||
padLeftVisible(gapStyled, 10),
|
||||
padLeft(fmt.Sprintf("%.0f", r.Points), 4),
|
||||
)
|
||||
} else {
|
||||
row = fmt.Sprintf(" %s %s %s %s %s %s %s",
|
||||
padRightVisible(pos, 3),
|
||||
colorBar,
|
||||
padRight(acronym, 4),
|
||||
padRight(truncate(d.TeamName, teamWidth), teamWidth),
|
||||
padLeft(fmt.Sprintf("%d", r.NumberOfLaps), 4),
|
||||
padLeftVisible(gapStyled, 12),
|
||||
padLeft(fmt.Sprintf("%.0f", r.Points), 4),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
row = fmt.Sprintf("%s %s %s %s",
|
||||
padLeft(pos, wPos),
|
||||
padRight(acronym, wDRV),
|
||||
teamStr,
|
||||
padLeft(formatDuration(r.Duration), wGap),
|
||||
)
|
||||
dur := formatDuration(r.Duration)
|
||||
if compact {
|
||||
row = fmt.Sprintf(" %s %s %s %s",
|
||||
padRightVisible(pos, 3),
|
||||
colorBar,
|
||||
padRight(acronym, 4),
|
||||
padLeft(dur, 12),
|
||||
)
|
||||
} else {
|
||||
row = fmt.Sprintf(" %s %s %s %s %s",
|
||||
padRightVisible(pos, 3),
|
||||
colorBar,
|
||||
padRight(acronym, 4),
|
||||
padRight(truncate(d.TeamName, teamWidth), teamWidth),
|
||||
padLeft(dur, 12),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if r.DNF || r.DNS || r.DSQ {
|
||||
row = styleMuted.Render(row)
|
||||
if i == m.resultsCursor {
|
||||
row = styleSelected.Render(row)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
sb.WriteString(row + "\n")
|
||||
}
|
||||
|
||||
return strings.Join(rows, "\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m RaceDetailModel) renderRight(width int) string {
|
||||
func (m RaceDetailModel) renderRightPanel(width int) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Weather card at the top
|
||||
sb.WriteString(m.renderWeatherCard(width))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Overtakes summary
|
||||
sb.WriteString(m.renderOvertakes(width))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Race control
|
||||
sb.WriteString(styleHeader.Render("Race Control") + "\n")
|
||||
sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n")
|
||||
if !m.rcReady || m.selectedSession == nil {
|
||||
sb.WriteString(styleMuted.Render(" No session selected.\n"))
|
||||
} else {
|
||||
sb.WriteString(m.rcView.View() + "\n")
|
||||
}
|
||||
|
||||
// Weather strip
|
||||
sb.WriteString("\n" + styleHeader.Render("Weather") + "\n")
|
||||
sb.WriteString(m.renderWeather(width))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -405,52 +642,107 @@ func (m RaceDetailModel) renderRaceControlContent() string {
|
||||
}
|
||||
}
|
||||
|
||||
var flagStyle lipgloss.Style
|
||||
switch rc.Flag {
|
||||
case models.FlagGreen:
|
||||
flagStyle = styleFlagGreen
|
||||
case models.FlagYellow, models.FlagDoubleYellow:
|
||||
flagStyle = styleFlagYellow
|
||||
case models.FlagRed:
|
||||
flagStyle = styleFlagRed
|
||||
case models.FlagBlue:
|
||||
flagStyle = styleFlagBlue
|
||||
// Category-specific icon and styling
|
||||
var prefix string
|
||||
switch rc.Category {
|
||||
case models.CategorySafetyCar:
|
||||
prefix = styleSafetyCar.Render(fmt.Sprintf(" ⚠ [%s]", t))
|
||||
case models.CategoryDRS:
|
||||
prefix = styleDRS.Render(fmt.Sprintf(" ▸ [%s]", t))
|
||||
default:
|
||||
flagStyle = styleMuted
|
||||
// Flag-based coloring
|
||||
var flagStyle lipgloss.Style
|
||||
switch rc.Flag {
|
||||
case models.FlagGreen:
|
||||
flagStyle = styleFlagGreen
|
||||
case models.FlagYellow, models.FlagDoubleYellow:
|
||||
flagStyle = styleFlagYellow
|
||||
case models.FlagRed:
|
||||
flagStyle = styleFlagRed
|
||||
case models.FlagBlue:
|
||||
flagStyle = styleFlagBlue
|
||||
case models.FlagChequered:
|
||||
flagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true)
|
||||
default:
|
||||
flagStyle = styleMuted
|
||||
}
|
||||
|
||||
icon := " "
|
||||
switch rc.Flag {
|
||||
case models.FlagGreen:
|
||||
icon = "🟢"
|
||||
case models.FlagYellow:
|
||||
icon = "🟡"
|
||||
case models.FlagDoubleYellow:
|
||||
icon = "🟡"
|
||||
case models.FlagRed:
|
||||
icon = "🔴"
|
||||
case models.FlagBlue:
|
||||
icon = "🔵"
|
||||
case models.FlagChequered:
|
||||
icon = "🏁"
|
||||
default:
|
||||
icon = " "
|
||||
}
|
||||
prefix = flagStyle.Render(fmt.Sprintf(" %s [%s]", icon, t))
|
||||
}
|
||||
|
||||
prefix := flagStyle.Render(fmt.Sprintf("[%s]", t))
|
||||
lines = append(lines, fmt.Sprintf("%s %s", prefix, rc.Message))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m RaceDetailModel) renderWeather(width int) string {
|
||||
func (m RaceDetailModel) renderWeatherCard(width int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(styleSectionTitle.Render("WEATHER") + "\n")
|
||||
|
||||
if len(m.weather) == 0 {
|
||||
return styleMuted.Render(" No weather data.")
|
||||
sb.WriteString(styleMuted.Render(" No weather data.\n"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Use the latest weather snapshot
|
||||
w := m.weather[len(m.weather)-1]
|
||||
|
||||
rain := "Dry"
|
||||
// Weather conditions
|
||||
var condStr string
|
||||
if w.Rainfall > 0 {
|
||||
rain = styleFlagBlue.Render("Rain")
|
||||
condStr = styleRain.Render("🌧 Rain")
|
||||
} else {
|
||||
condStr = styleDry.Render("☀ Dry")
|
||||
}
|
||||
|
||||
return fmt.Sprintf(" Air: %.1f°C Track: %.1f°C %s Humidity: %.0f%% Wind: %s %.1fm/s",
|
||||
w.AirTemperature, w.TrackTemperature, rain,
|
||||
w.Humidity, windArrow(w.WindDirection), w.WindSpeed)
|
||||
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s %s %s %s%.1fm/s\n",
|
||||
condStr,
|
||||
styleWeatherLabel.Render("Air:"),
|
||||
styleWeatherValue.Render(fmt.Sprintf("%.1f°C", w.AirTemperature)),
|
||||
styleWeatherLabel.Render("Track:"),
|
||||
styleWeatherValue.Render(fmt.Sprintf("%.1f°C", w.TrackTemperature)),
|
||||
styleWeatherLabel.Render("Humidity:"),
|
||||
styleWeatherValue.Render(fmt.Sprintf("%.0f%%", w.Humidity)),
|
||||
styleWeatherLabel.Render("Wind:"),
|
||||
styleWeatherValue.Render(windArrow(w.WindDirection)+" "),
|
||||
w.WindSpeed,
|
||||
))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// SetSize updates the model's dimensions and initialises the race control viewport.
|
||||
func (m *RaceDetailModel) SetSize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
// Right panel, minus header/weather/borders
|
||||
rightWidth := int(float64(w)*0.45) - 6
|
||||
rcHeight := h - 12 // approximate: title + sessions + header + weather + help
|
||||
if w < 40 {
|
||||
w = 40
|
||||
}
|
||||
compact := w < 100
|
||||
var rightWidth, rcHeight int
|
||||
if compact {
|
||||
rightWidth = w - 6
|
||||
rcHeight = 8
|
||||
} else {
|
||||
rightWidth = int(float64(w)*0.45) - 8
|
||||
rcHeight = h - 14
|
||||
}
|
||||
if rcHeight < 3 {
|
||||
rcHeight = 3
|
||||
}
|
||||
@@ -461,3 +753,98 @@ func (m *RaceDetailModel) SetSize(w, h int) {
|
||||
m.rcView.Height = rcHeight
|
||||
}
|
||||
}
|
||||
|
||||
// renderOvertakes renders the overtakes summary section.
|
||||
func (m RaceDetailModel) renderOvertakes(width int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(styleSectionTitle.Render("OVERTAKES") + "\n")
|
||||
|
||||
if len(m.overtakes) == 0 {
|
||||
sb.WriteString(styleMuted.Render(" No overtake data.\n"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Total count
|
||||
sb.WriteString(fmt.Sprintf(" %s %s\n",
|
||||
styleWeatherLabel.Render("Total:"),
|
||||
lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Bold(true).Render(fmt.Sprintf("%d", len(m.overtakes))),
|
||||
))
|
||||
|
||||
// Tally: which drivers overtook the most?
|
||||
type driverOvertakes struct {
|
||||
driverNum int
|
||||
overtaking int // times this driver overtook someone
|
||||
overtaken int // times this driver was overtaken
|
||||
}
|
||||
stats := make(map[int]*driverOvertakes)
|
||||
for _, o := range m.overtakes {
|
||||
if _, ok := stats[o.OvertakingDriverNumber]; !ok {
|
||||
stats[o.OvertakingDriverNumber] = &driverOvertakes{driverNum: o.OvertakingDriverNumber}
|
||||
}
|
||||
stats[o.OvertakingDriverNumber].overtaking++
|
||||
|
||||
if _, ok := stats[o.OvertakenDriverNumber]; !ok {
|
||||
stats[o.OvertakenDriverNumber] = &driverOvertakes{driverNum: o.OvertakenDriverNumber}
|
||||
}
|
||||
stats[o.OvertakenDriverNumber].overtaken++
|
||||
}
|
||||
|
||||
// Find top overtakers (sorted by most overtakes made)
|
||||
type rankedDriver struct {
|
||||
driverNum int
|
||||
overtaking int
|
||||
overtaken int
|
||||
}
|
||||
var ranked []rankedDriver
|
||||
for _, s := range stats {
|
||||
ranked = append(ranked, rankedDriver{driverNum: s.driverNum, overtaking: s.overtaking, overtaken: s.overtaken})
|
||||
}
|
||||
|
||||
// Sort by overtaking count descending
|
||||
for i := 0; i < len(ranked); i++ {
|
||||
for j := i + 1; j < len(ranked); j++ {
|
||||
if ranked[j].overtaking > ranked[i].overtaking {
|
||||
ranked[i], ranked[j] = ranked[j], ranked[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show top 5 overtakers
|
||||
maxShow := 5
|
||||
if len(ranked) < maxShow {
|
||||
maxShow = len(ranked)
|
||||
}
|
||||
for i := 0; i < maxShow; i++ {
|
||||
r := ranked[i]
|
||||
d := m.drivers[r.driverNum]
|
||||
acronym := d.NameAcronym
|
||||
if acronym == "" {
|
||||
acronym = fmt.Sprintf("#%d", r.driverNum)
|
||||
}
|
||||
|
||||
teamColor := colorMuted
|
||||
if d.TeamColour != "" {
|
||||
teamColor = "#" + d.TeamColour
|
||||
} else if d.TeamName != "" {
|
||||
teamColor = teamColorFromName(d.TeamName)
|
||||
}
|
||||
|
||||
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||
|
||||
overtakingStr := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorGreen)).Bold(true).
|
||||
Render(fmt.Sprintf("+%d", r.overtaking))
|
||||
overtakenStr := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
Render(fmt.Sprintf("-%d", r.overtaken))
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s %s %s %s\n",
|
||||
colorBar,
|
||||
padRight(acronym, 4),
|
||||
overtakingStr,
|
||||
overtakenStr,
|
||||
))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -23,15 +23,17 @@ type StandingsModel struct {
|
||||
|
||||
driverStandings []models.ChampionshipDriver
|
||||
teamStandings []models.ChampionshipTeam
|
||||
drivers map[int]models.Driver // driver_number → Driver
|
||||
drivers map[int]models.Driver // driver_number -> Driver
|
||||
|
||||
view standingsView
|
||||
loading bool
|
||||
err error
|
||||
spinner spinner.Model
|
||||
|
||||
year int
|
||||
cursor int
|
||||
scroll int
|
||||
|
||||
year int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
@@ -40,6 +42,7 @@ func NewStandingsModel(client *api.OpenF1Client, year int) StandingsModel {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.MiniDot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
|
||||
return StandingsModel{
|
||||
client: client,
|
||||
view: standingsViewDriver,
|
||||
@@ -80,7 +83,13 @@ func fetchStandingsDrivers(client *api.OpenF1Client, sessionKey int) tea.Cmd {
|
||||
}
|
||||
|
||||
func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
|
||||
case spinner.TickMsg:
|
||||
if m.loading {
|
||||
var cmd tea.Cmd
|
||||
@@ -95,7 +104,6 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.driverStandings = msg.standings
|
||||
// Phase 2: fetch drivers to join names
|
||||
if len(msg.standings) > 0 {
|
||||
return m, fetchStandingsDrivers(m.client, msg.standings[0].SessionKey)
|
||||
}
|
||||
@@ -121,47 +129,116 @@ func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case matchKey(msg, GlobalKeys.Retry):
|
||||
if m.err != nil {
|
||||
m.err = nil
|
||||
m.loading = true
|
||||
return m, m.Init()
|
||||
}
|
||||
case matchKey(msg, StandingsKeys.DriverView):
|
||||
m.view = standingsViewDriver
|
||||
m.cursor = 0
|
||||
m.scroll = 0
|
||||
case matchKey(msg, StandingsKeys.ConstructorView):
|
||||
m.view = standingsViewConstructor
|
||||
m.cursor = 0
|
||||
m.scroll = 0
|
||||
case matchKey(msg, GlobalKeys.Up):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
if m.cursor < m.scroll {
|
||||
m.scroll = m.cursor
|
||||
}
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.Down):
|
||||
m.cursor++
|
||||
maxIdx := m.itemCount() - 1
|
||||
if m.cursor < maxIdx {
|
||||
m.cursor++
|
||||
visibleRows := m.visibleRows()
|
||||
if m.cursor >= m.scroll+visibleRows {
|
||||
m.scroll = m.cursor - visibleRows + 1
|
||||
}
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.GoTop):
|
||||
m.cursor = 0
|
||||
m.scroll = 0
|
||||
case matchKey(msg, GlobalKeys.GoBottom):
|
||||
maxIdx := m.itemCount() - 1
|
||||
if maxIdx >= 0 {
|
||||
m.cursor = maxIdx
|
||||
visibleRows := m.visibleRows()
|
||||
if m.cursor >= visibleRows {
|
||||
m.scroll = m.cursor - visibleRows + 1
|
||||
}
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.HalfUp):
|
||||
half := m.visibleRows() / 2
|
||||
m.cursor -= half
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
if m.cursor < m.scroll {
|
||||
m.scroll = m.cursor
|
||||
}
|
||||
case matchKey(msg, GlobalKeys.HalfDown):
|
||||
half := m.visibleRows() / 2
|
||||
maxIdx := m.itemCount() - 1
|
||||
m.cursor += half
|
||||
if m.cursor > maxIdx {
|
||||
m.cursor = maxIdx
|
||||
}
|
||||
visibleRows := m.visibleRows()
|
||||
if m.cursor >= m.scroll+visibleRows {
|
||||
m.scroll = m.cursor - visibleRows + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m StandingsModel) itemCount() int {
|
||||
if m.view == standingsViewDriver {
|
||||
return len(m.driverStandings)
|
||||
}
|
||||
return len(m.teamStandings)
|
||||
}
|
||||
|
||||
func (m StandingsModel) visibleRows() int {
|
||||
rows := m.height - 12 // header + toggle + help + padding
|
||||
if rows < 5 {
|
||||
rows = 5
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (m StandingsModel) View() string {
|
||||
if m.loading {
|
||||
return fmt.Sprintf("\n %s Loading %d championship standings…", m.spinner.View(), m.year)
|
||||
return fmt.Sprintf("\n %s Loading %d championship standings...", m.spinner.View(), m.year)
|
||||
}
|
||||
if m.err != nil {
|
||||
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
|
||||
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) +
|
||||
helpBar("r retry", "q quit")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Year indicator
|
||||
sb.WriteString(styleBold.Render(fmt.Sprintf(" Season: %d", m.year)) + "\n\n")
|
||||
// Title row with year and toggle
|
||||
title := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
Render(fmt.Sprintf(" FORMULA 1 %d CHAMPIONSHIP", m.year))
|
||||
|
||||
sb.WriteString(title + "\n\n")
|
||||
|
||||
// Toggle bar
|
||||
dStyle, cStyle := styleInactiveTab, styleInactiveTab
|
||||
dLabel, cLabel := " d Drivers ", " c Constructors "
|
||||
if m.view == standingsViewDriver {
|
||||
dStyle = styleActiveTab
|
||||
sb.WriteString(styleActiveTab.Render(dLabel))
|
||||
sb.WriteString(styleInactiveTab.Render(cLabel))
|
||||
} else {
|
||||
cStyle = styleActiveTab
|
||||
sb.WriteString(styleInactiveTab.Render(dLabel))
|
||||
sb.WriteString(styleActiveTab.Render(cLabel))
|
||||
}
|
||||
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
dStyle.Render("d Drivers"),
|
||||
cStyle.Render("c Constructors"),
|
||||
))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
if m.view == standingsViewDriver {
|
||||
@@ -171,122 +248,206 @@ func (m StandingsModel) View() string {
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(helpBar("y season", "d drivers", "c constructors", "j/k navigate", "q quit"))
|
||||
sb.WriteString(helpBar("y season", "d drivers", "c constructors", "j/k navigate", "g/G top/bottom", "^d/^u page", "q quit"))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m StandingsModel) renderDriverStandings() string {
|
||||
if len(m.driverStandings) == 0 {
|
||||
return styleMuted.Render(" No standings data available.")
|
||||
return styleMuted.Render(" No standings data available.\n")
|
||||
}
|
||||
|
||||
// Column widths
|
||||
const (
|
||||
wPos = 4
|
||||
wAcronym = 5
|
||||
wName = 22
|
||||
wTeam = 22
|
||||
wPoints = 8
|
||||
wDelta = 5
|
||||
)
|
||||
|
||||
header := styleBold.Render(
|
||||
padRight("Pos", wPos) + " " +
|
||||
padRight("DRV", wAcronym) + " " +
|
||||
padRight("Name", wName) + " " +
|
||||
padRight("Team", wTeam) + " " +
|
||||
padLeft("Pts", wPoints) + " " +
|
||||
padLeft("Δ", wDelta),
|
||||
)
|
||||
|
||||
var rows []string
|
||||
rows = append(rows, header)
|
||||
|
||||
maxCursor := len(m.driverStandings) - 1
|
||||
if m.cursor > maxCursor {
|
||||
_ = maxCursor // cursor clamping happens in Update
|
||||
var sb strings.Builder
|
||||
maxPoints := m.driverStandings[0].PointsCurrent
|
||||
w := m.width
|
||||
if w < 40 {
|
||||
w = 40
|
||||
}
|
||||
|
||||
for i, s := range m.driverStandings {
|
||||
// Responsive column widths
|
||||
compact := w < 80
|
||||
nameWidth := 20
|
||||
teamWidth := 20
|
||||
barWidth := 20
|
||||
if w >= 130 {
|
||||
nameWidth = 24
|
||||
teamWidth = 24
|
||||
barWidth = 35
|
||||
} else if w >= 100 {
|
||||
barWidth = 25
|
||||
} else if compact {
|
||||
nameWidth = 0 // hide full name in compact mode
|
||||
teamWidth = 14
|
||||
barWidth = 12
|
||||
}
|
||||
|
||||
visible := m.visibleRows()
|
||||
endIdx := m.scroll + visible
|
||||
if endIdx > len(m.driverStandings) {
|
||||
endIdx = len(m.driverStandings)
|
||||
}
|
||||
|
||||
// Header
|
||||
var header string
|
||||
if compact {
|
||||
header = fmt.Sprintf(" %s %s %s %s %s %s",
|
||||
padRight("POS", 3),
|
||||
padRight("", 2),
|
||||
padRight("", 1),
|
||||
padRight("DRV", 4),
|
||||
padRight("TEAM", teamWidth),
|
||||
padLeft("PTS", 5),
|
||||
)
|
||||
} else {
|
||||
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s",
|
||||
padRight("POS", 4),
|
||||
padRight("", 2),
|
||||
padRight("", 1),
|
||||
padRight("DRV", 4),
|
||||
padRight("DRIVER", nameWidth),
|
||||
padRight("TEAM", teamWidth),
|
||||
padLeft("PTS", 6),
|
||||
padRight("", barWidth),
|
||||
)
|
||||
}
|
||||
sb.WriteString(lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted)).
|
||||
Bold(true).
|
||||
Render(header) + "\n")
|
||||
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
|
||||
|
||||
for i := m.scroll; i < endIdx; i++ {
|
||||
s := m.driverStandings[i]
|
||||
d, ok := m.drivers[s.DriverNumber]
|
||||
acronym, name, team, teamColor := "---", "Unknown Driver", "Unknown Team", ""
|
||||
acronym, name, team, teamColor := "---", "Unknown", "Unknown", colorMuted
|
||||
if ok {
|
||||
acronym = d.NameAcronym
|
||||
name = d.FullName
|
||||
team = d.TeamName
|
||||
teamColor = d.TeamColour
|
||||
if d.TeamColour != "" {
|
||||
teamColor = "#" + d.TeamColour
|
||||
} else {
|
||||
teamColor = teamColorFromName(d.TeamName)
|
||||
}
|
||||
}
|
||||
|
||||
delta := renderDelta(s.PositionCurrent, s.PositionStart)
|
||||
pos := renderPosition(s.PositionCurrent)
|
||||
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||
pointsBar := renderPointsBar(s.PointsCurrent, maxPoints, barWidth, teamColor)
|
||||
|
||||
teamStr := hexToStyle(teamColor).Render(truncate(team, wTeam))
|
||||
// Pad team to width (lipgloss rendering may shift widths, so use padRight on plain string then style)
|
||||
teamPlain := padRight(truncate(team, wTeam), wTeam)
|
||||
teamStr = hexToStyle(teamColor).Render(teamPlain)
|
||||
|
||||
row := fmt.Sprintf("%s %s %s %s %s %s",
|
||||
padLeft(fmt.Sprintf("%d", s.PositionCurrent), wPos),
|
||||
padRight(acronym, wAcronym),
|
||||
padRight(truncate(name, wName), wName),
|
||||
teamStr,
|
||||
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), wPoints),
|
||||
delta,
|
||||
)
|
||||
var row string
|
||||
if compact {
|
||||
row = fmt.Sprintf(" %s %s %s %s %s %s",
|
||||
padRightVisible(pos, 3),
|
||||
delta,
|
||||
colorBar,
|
||||
padRight(acronym, 4),
|
||||
padRight(truncate(team, teamWidth), teamWidth),
|
||||
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 5),
|
||||
)
|
||||
} else {
|
||||
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s",
|
||||
padRightVisible(pos, 4),
|
||||
delta,
|
||||
colorBar,
|
||||
padRight(acronym, 4),
|
||||
padRight(truncate(name, nameWidth), nameWidth),
|
||||
padRight(truncate(team, teamWidth), teamWidth),
|
||||
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6),
|
||||
pointsBar,
|
||||
)
|
||||
}
|
||||
|
||||
if i == m.cursor {
|
||||
row = styleSelected.Render(row)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
|
||||
sb.WriteString(row + "\n")
|
||||
}
|
||||
|
||||
return strings.Join(rows, "\n")
|
||||
// Scroll indicator
|
||||
if len(m.driverStandings) > visible {
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" Showing %d-%d of %d", m.scroll+1, endIdx, len(m.driverStandings))) + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m StandingsModel) renderTeamStandings() string {
|
||||
if len(m.teamStandings) == 0 {
|
||||
return styleMuted.Render(" No constructor standings available.")
|
||||
return styleMuted.Render(" No standings data available.\n")
|
||||
}
|
||||
|
||||
const (
|
||||
wPos = 4
|
||||
wTeam = 30
|
||||
wPoints = 8
|
||||
wDelta = 5
|
||||
var sb strings.Builder
|
||||
maxPoints := m.teamStandings[0].PointsCurrent
|
||||
w := m.width
|
||||
if w < 40 {
|
||||
w = 40
|
||||
}
|
||||
|
||||
// Responsive
|
||||
compact := w < 80
|
||||
teamWidth := 28
|
||||
barWidth := 30
|
||||
if w >= 130 {
|
||||
barWidth = 50
|
||||
} else if w >= 100 {
|
||||
barWidth = 40
|
||||
} else if compact {
|
||||
teamWidth = 18
|
||||
barWidth = 15
|
||||
}
|
||||
|
||||
visible := m.visibleRows()
|
||||
endIdx := m.scroll + visible
|
||||
if endIdx > len(m.teamStandings) {
|
||||
endIdx = len(m.teamStandings)
|
||||
}
|
||||
|
||||
// Header
|
||||
header := fmt.Sprintf(" %s %s %s %s %s %s",
|
||||
padRight("POS", 4),
|
||||
padRight("", 2),
|
||||
padRight("", 1),
|
||||
padRight("CONSTRUCTOR", teamWidth),
|
||||
padLeft("PTS", 6),
|
||||
padRight("", barWidth),
|
||||
)
|
||||
sb.WriteString(lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted)).
|
||||
Bold(true).
|
||||
Render(header) + "\n")
|
||||
sb.WriteString(" " + divider(min(w-6, lipgloss.Width(header))) + "\n")
|
||||
|
||||
header := styleBold.Render(
|
||||
padRight("Pos", wPos) + " " +
|
||||
padRight("Constructor", wTeam) + " " +
|
||||
padLeft("Pts", wPoints) + " " +
|
||||
padLeft("Δ", wDelta),
|
||||
)
|
||||
for i := m.scroll; i < endIdx; i++ {
|
||||
s := m.teamStandings[i]
|
||||
teamColor := teamColorFromName(s.TeamName)
|
||||
|
||||
var rows []string
|
||||
rows = append(rows, header)
|
||||
|
||||
for i, s := range m.teamStandings {
|
||||
delta := renderDelta(s.PositionCurrent, s.PositionStart)
|
||||
row := fmt.Sprintf("%s %s %s %s",
|
||||
padLeft(fmt.Sprintf("%d", s.PositionCurrent), wPos),
|
||||
padRight(truncate(s.TeamName, wTeam), wTeam),
|
||||
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), wPoints),
|
||||
pos := renderPosition(s.PositionCurrent)
|
||||
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||
pointsBar := renderPointsBar(s.PointsCurrent, maxPoints, barWidth, teamColor)
|
||||
|
||||
row := fmt.Sprintf(" %s %s %s %s %s %s",
|
||||
padRightVisible(pos, 4),
|
||||
delta,
|
||||
colorBar,
|
||||
padRight(truncate(s.TeamName, teamWidth), teamWidth),
|
||||
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), 6),
|
||||
pointsBar,
|
||||
)
|
||||
|
||||
if i == m.cursor {
|
||||
row = styleSelected.Render(row)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
|
||||
sb.WriteString(row + "\n")
|
||||
}
|
||||
|
||||
return strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
// matchKey checks if a KeyMsg matches a binding.
|
||||
func matchKey(msg tea.KeyMsg, binding interface{ Keys() []string }) bool {
|
||||
for _, k := range binding.Keys() {
|
||||
if msg.String() == k {
|
||||
return true
|
||||
}
|
||||
if len(m.teamStandings) > visible {
|
||||
sb.WriteString(styleMuted.Render(fmt.Sprintf(" Showing %d-%d of %d", m.scroll+1, endIdx, len(m.teamStandings))) + "\n")
|
||||
}
|
||||
return false
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -4,23 +4,44 @@ import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// F1 brand colors
|
||||
const (
|
||||
colorF1Red = "#E8002D"
|
||||
colorF1Red = "#E10600"
|
||||
colorF1Black = "#15151E"
|
||||
colorSubtle = "#3C3C4A"
|
||||
colorSubtle = "#2A2A3C"
|
||||
colorMuted = "#6B6B7A"
|
||||
colorWhite = "#FFFFFF"
|
||||
colorGreen = "#39B54A"
|
||||
colorGreen = "#00D26A"
|
||||
colorYellow = "#FFD700"
|
||||
colorOrange = "#FF8700"
|
||||
colorCyan = "#00BFFF"
|
||||
|
||||
// Surfaces
|
||||
colorSurface0 = "#1B1B2F"
|
||||
colorSurface1 = "#222236"
|
||||
colorSurface2 = "#2D2D44"
|
||||
colorBorder = "#3C3C54"
|
||||
|
||||
// Tyre compounds
|
||||
colorSoft = "#FF1E1E"
|
||||
colorSoft = "#FF3333"
|
||||
colorMedium = "#FFD700"
|
||||
colorHard = "#EEEEEE"
|
||||
colorHard = "#CCCCCC"
|
||||
colorInter = "#39B54A"
|
||||
colorWet = "#0057FF"
|
||||
colorWet = "#0080FF"
|
||||
|
||||
// F1 team colors (2024/2025 season)
|
||||
colorRedbull = "#3671C6"
|
||||
colorMercedes = "#27F4D2"
|
||||
colorFerrari = "#E8002D"
|
||||
colorMclaren = "#FF8000"
|
||||
colorAstonMartin = "#229971"
|
||||
colorAlpine = "#FF87BC"
|
||||
colorWilliams = "#64C4FF"
|
||||
colorHaas = "#B6BABD"
|
||||
colorRB = "#6692FF"
|
||||
colorSauber = "#52E252"
|
||||
)
|
||||
|
||||
var (
|
||||
// Tab bar styles
|
||||
// ── Tab bar ──────────────────────────────────────────
|
||||
styleActiveTab = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
@@ -29,18 +50,28 @@ var (
|
||||
|
||||
styleInactiveTab = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted)).
|
||||
Background(lipgloss.Color(colorSurface0)).
|
||||
Padding(0, 2)
|
||||
|
||||
styleTabBar = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(colorF1Black)).
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderBottom(true).
|
||||
BorderForeground(lipgloss.Color(colorSubtle))
|
||||
Background(lipgloss.Color(colorSurface0))
|
||||
|
||||
// Panel / border styles
|
||||
// Tab bar accent stripe
|
||||
styleTabStripe = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(colorF1Red)).
|
||||
Foreground(lipgloss.Color(colorF1Red))
|
||||
|
||||
// Year badge in tab bar
|
||||
styleYearBadge = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorYellow)).
|
||||
Background(lipgloss.Color(colorSurface0)).
|
||||
Padding(0, 1)
|
||||
|
||||
// ── Panel / border styles ────────────────────────────
|
||||
stylePanelBorder = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color(colorSubtle)).
|
||||
BorderForeground(lipgloss.Color(colorBorder)).
|
||||
Padding(0, 1)
|
||||
|
||||
styleActivePanelBorder = lipgloss.NewStyle().
|
||||
@@ -48,8 +79,8 @@ var (
|
||||
BorderForeground(lipgloss.Color(colorF1Red)).
|
||||
Padding(0, 1)
|
||||
|
||||
// Text styles
|
||||
styleBold = lipgloss.NewStyle().Bold(true)
|
||||
// ── Text styles ──────────────────────────────────────
|
||||
styleBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorWhite))
|
||||
|
||||
styleMuted = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted))
|
||||
@@ -61,10 +92,16 @@ var (
|
||||
styleHeader = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Background(lipgloss.Color(colorSubtle)).
|
||||
Padding(0, 1)
|
||||
Background(lipgloss.Color(colorSurface2)).
|
||||
Padding(0, 1).
|
||||
MarginBottom(0)
|
||||
|
||||
// Delta styles
|
||||
styleSectionTitle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
PaddingLeft(1)
|
||||
|
||||
// ── Delta styles ─────────────────────────────────────
|
||||
styleDeltaUp = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorGreen)).
|
||||
Bold(true)
|
||||
@@ -76,28 +113,154 @@ var (
|
||||
styleDeltaEqual = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted))
|
||||
|
||||
// Selected row style
|
||||
// ── Selected row style ───────────────────────────────
|
||||
styleSelected = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(colorSubtle)).
|
||||
Background(lipgloss.Color(colorSurface2)).
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Bold(true)
|
||||
|
||||
// Status indicators
|
||||
// ── Status indicators ────────────────────────────────
|
||||
stylePast = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted))
|
||||
Foreground(lipgloss.Color(colorGreen))
|
||||
|
||||
styleNext = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
Bold(true)
|
||||
|
||||
// Flag colors for race control
|
||||
styleFuture = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted))
|
||||
|
||||
// ── Flag colors for race control ─────────────────────
|
||||
styleFlagGreen = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
|
||||
styleFlagYellow = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium))
|
||||
styleFlagYellow = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
|
||||
styleFlagRed = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
styleFlagBlue = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet))
|
||||
styleFlagBlue = lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan))
|
||||
styleFlagWhite = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
|
||||
|
||||
// Help bar
|
||||
// Race control category styles
|
||||
styleSafetyCar = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorOrange)).
|
||||
Bold(true)
|
||||
|
||||
styleDRS = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorCyan))
|
||||
|
||||
// ── Help bar ─────────────────────────────────────────
|
||||
styleHelp = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted)).
|
||||
Background(lipgloss.Color(colorSurface0)).
|
||||
Padding(0, 1)
|
||||
|
||||
styleHelpKey = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Bold(true)
|
||||
|
||||
styleHelpDesc = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted))
|
||||
|
||||
// ── Status bar ───────────────────────────────────────
|
||||
styleStatusBar = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(colorSurface0)).
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Padding(0, 1)
|
||||
|
||||
styleStatusLabel = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted))
|
||||
|
||||
styleStatusValue = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Bold(true)
|
||||
|
||||
styleCountdown = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorYellow)).
|
||||
Bold(true)
|
||||
|
||||
// ── Points bar ───────────────────────────────────────
|
||||
stylePointsBarFilled = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(colorF1Red)).
|
||||
Foreground(lipgloss.Color(colorF1Red))
|
||||
|
||||
stylePointsBarEmpty = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(colorSurface2)).
|
||||
Foreground(lipgloss.Color(colorSurface2))
|
||||
|
||||
// ── Session pill ─────────────────────────────────────
|
||||
styleSessionActive = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Background(lipgloss.Color(colorF1Red)).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
styleSessionInactive = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted)).
|
||||
Background(lipgloss.Color(colorSurface1)).
|
||||
Padding(0, 1)
|
||||
|
||||
styleSessionCursor = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Background(lipgloss.Color(colorSurface2)).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
// ── Driver card ──────────────────────────────────────
|
||||
styleDriverNumber = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Padding(0, 1)
|
||||
|
||||
styleDriverName = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color(colorWhite))
|
||||
|
||||
styleTeamName = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted))
|
||||
|
||||
// ── Misc ─────────────────────────────────────────────
|
||||
stylePositionFirst = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorYellow)).
|
||||
Bold(true)
|
||||
|
||||
stylePositionSecond = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorHard))
|
||||
|
||||
stylePositionThird = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorOrange))
|
||||
|
||||
styleDNF = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorF1Red)).
|
||||
Bold(true)
|
||||
|
||||
styleLeader = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorYellow)).
|
||||
Bold(true)
|
||||
|
||||
styleGap = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted))
|
||||
|
||||
// Base Table styles
|
||||
styleTableBase = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color(colorBorder))
|
||||
|
||||
// Weather card
|
||||
styleWeatherLabel = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorMuted))
|
||||
|
||||
styleWeatherValue = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorWhite)).
|
||||
Bold(true)
|
||||
|
||||
styleRain = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorCyan)).
|
||||
Bold(true)
|
||||
|
||||
styleDry = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorGreen))
|
||||
)
|
||||
|
||||
// DefaultTableStyles returns a base style map for bubbles/table.
|
||||
func DefaultTableStyles() tableStyles {
|
||||
return tableStyles{}
|
||||
}
|
||||
|
||||
type tableStyles struct{}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
@@ -35,7 +36,6 @@ func formatGap(v interface{}) string {
|
||||
case string:
|
||||
return val
|
||||
case []interface{}:
|
||||
// Qualifying: return best Q time
|
||||
if len(val) == 0 {
|
||||
return "--"
|
||||
}
|
||||
@@ -56,7 +56,6 @@ func formatDuration(v interface{}) string {
|
||||
case float64:
|
||||
return formatSeconds(val)
|
||||
case []interface{}:
|
||||
// Qualifying: return best Q time
|
||||
if len(val) == 0 {
|
||||
return "--"
|
||||
}
|
||||
@@ -69,7 +68,6 @@ func formatDuration(v interface{}) string {
|
||||
}
|
||||
|
||||
// hexToStyle creates a lipgloss.Style with the given hex color as the foreground.
|
||||
// The hex string may or may not have a leading '#'.
|
||||
func hexToStyle(hex string) lipgloss.Style {
|
||||
if hex == "" {
|
||||
return lipgloss.NewStyle()
|
||||
@@ -80,6 +78,17 @@ func hexToStyle(hex string) lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
|
||||
}
|
||||
|
||||
// hexToBgStyle creates a lipgloss.Style with the given hex color as the background.
|
||||
func hexToBgStyle(hex string) lipgloss.Style {
|
||||
if hex == "" {
|
||||
return lipgloss.NewStyle()
|
||||
}
|
||||
if !strings.HasPrefix(hex, "#") {
|
||||
hex = "#" + hex
|
||||
}
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(hex)).Foreground(lipgloss.Color(colorWhite))
|
||||
}
|
||||
|
||||
// sparkline generates a unicode block chart for lap times.
|
||||
// Pit laps (nil duration) are rendered as spaces.
|
||||
func sparkline(laps []models.Lap, width int) string {
|
||||
@@ -87,7 +96,7 @@ func sparkline(laps []models.Lap, width int) string {
|
||||
blockRunes := []rune(blocks)
|
||||
|
||||
if len(laps) == 0 {
|
||||
return strings.Repeat(" ", width)
|
||||
return styleMuted.Render(strings.Repeat("·", width))
|
||||
}
|
||||
|
||||
// Collect valid lap durations
|
||||
@@ -120,7 +129,7 @@ func sparkline(laps []models.Lap, width int) string {
|
||||
break
|
||||
}
|
||||
if d < 0 {
|
||||
sb.WriteRune(' ')
|
||||
sb.WriteString(styleMuted.Render("·"))
|
||||
} else {
|
||||
norm := (d - minDur) / rng
|
||||
// Invert: fast laps = tall bar (higher index)
|
||||
@@ -131,16 +140,26 @@ func sparkline(laps []models.Lap, width int) string {
|
||||
if idx >= len(blockRunes) {
|
||||
idx = len(blockRunes) - 1
|
||||
}
|
||||
sb.WriteRune(blockRunes[idx])
|
||||
// Color based on performance: fast=green, mid=yellow, slow=red
|
||||
var blockStyle lipgloss.Style
|
||||
switch {
|
||||
case norm < 0.25:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
|
||||
case norm < 0.5:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow))
|
||||
case norm < 0.75:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange))
|
||||
default:
|
||||
blockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
|
||||
}
|
||||
sb.WriteString(blockStyle.Render(string(blockRunes[idx])))
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
result := sb.String()
|
||||
resultLen := utf8.RuneCountInString(result)
|
||||
if resultLen < width {
|
||||
result += strings.Repeat(" ", width-resultLen)
|
||||
}
|
||||
resultLen := utf8.RuneCountInString(lipgloss.NewStyle().Render(result))
|
||||
_ = resultLen
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -169,6 +188,24 @@ func tyreStyle(c models.TyreCompound) lipgloss.Style {
|
||||
}
|
||||
}
|
||||
|
||||
// tyreBgStyle returns a lipgloss.Style with background for tyre compound.
|
||||
func tyreBgStyle(c models.TyreCompound) lipgloss.Style {
|
||||
switch c {
|
||||
case models.CompoundSoft:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorSoft)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
||||
case models.CompoundMedium:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorMedium)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
||||
case models.CompoundHard:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorHard)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
||||
case models.CompoundIntermediate:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorInter)).Foreground(lipgloss.Color("#000000")).Bold(true)
|
||||
case models.CompoundWet:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorWet)).Foreground(lipgloss.Color(colorWhite)).Bold(true)
|
||||
default:
|
||||
return lipgloss.NewStyle().Background(lipgloss.Color(colorSurface2)).Foreground(lipgloss.Color(colorMuted))
|
||||
}
|
||||
}
|
||||
|
||||
// tyreAbbrev returns a single-letter abbreviation for a tyre compound.
|
||||
func tyreAbbrev(c models.TyreCompound) string {
|
||||
switch c {
|
||||
@@ -187,25 +224,72 @@ func tyreAbbrev(c models.TyreCompound) string {
|
||||
}
|
||||
}
|
||||
|
||||
// renderDelta returns a colored ▲N/▼N/= string for position change.
|
||||
// renderDelta returns a colored position change indicator, always 2 visible columns wide.
|
||||
func renderDelta(current, start int) string {
|
||||
diff := start - current // positive = gained positions
|
||||
switch {
|
||||
case diff > 0:
|
||||
return styleDeltaUp.Render(fmt.Sprintf("▲%d", diff))
|
||||
s := fmt.Sprintf("▲%d", diff)
|
||||
return padRightVisible(styleDeltaUp.Render(s), 2)
|
||||
case diff < 0:
|
||||
return styleDeltaDown.Render(fmt.Sprintf("▼%d", -diff))
|
||||
s := fmt.Sprintf("▼%d", -diff)
|
||||
return padRightVisible(styleDeltaDown.Render(s), 2)
|
||||
default:
|
||||
return styleDeltaEqual.Render("=")
|
||||
return padRightVisible(styleDeltaEqual.Render("─"), 2)
|
||||
}
|
||||
}
|
||||
|
||||
// renderPosition formats a position with podium coloring.
|
||||
func renderPosition(pos int) string {
|
||||
s := fmt.Sprintf("%d", pos)
|
||||
switch pos {
|
||||
case 1:
|
||||
return stylePositionFirst.Render(s)
|
||||
case 2:
|
||||
return stylePositionSecond.Render(s)
|
||||
case 3:
|
||||
return stylePositionThird.Render(s)
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// renderPointsBar draws a horizontal progress bar for points.
|
||||
func renderPointsBar(points, maxPoints float64, width int, color string) string {
|
||||
if maxPoints == 0 || width <= 0 {
|
||||
return ""
|
||||
}
|
||||
ratio := points / maxPoints
|
||||
filled := int(ratio * float64(width))
|
||||
if filled > width {
|
||||
filled = width
|
||||
}
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
|
||||
barStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(color)).
|
||||
Foreground(lipgloss.Color(color))
|
||||
emptyStyle := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color(colorSurface2)).
|
||||
Foreground(lipgloss.Color(colorSurface2))
|
||||
|
||||
bar := ""
|
||||
if filled > 0 {
|
||||
bar += barStyle.Render(strings.Repeat("━", filled))
|
||||
}
|
||||
remaining := width - filled
|
||||
if remaining > 0 {
|
||||
bar += emptyStyle.Render(strings.Repeat("━", remaining))
|
||||
}
|
||||
return bar
|
||||
}
|
||||
|
||||
// meetingStatus returns a status indicator for a meeting.
|
||||
// isNext should be true only for the first upcoming meeting.
|
||||
func meetingStatus(m models.Meeting, now time.Time, isNext bool) string {
|
||||
end, err := time.Parse(time.RFC3339, m.DateEnd)
|
||||
if err != nil {
|
||||
// Fallback: try date-only parsing
|
||||
end, err = time.Parse("2006-01-02", m.DateEnd[:min(len(m.DateEnd), 10)])
|
||||
if err != nil {
|
||||
return " "
|
||||
@@ -217,9 +301,9 @@ func meetingStatus(m models.Meeting, now time.Time, isNext bool) string {
|
||||
return stylePast.Render("✓")
|
||||
}
|
||||
if isNext {
|
||||
return styleNext.Render("◎")
|
||||
return styleNext.Render("●")
|
||||
}
|
||||
return " "
|
||||
return styleFuture.Render("○")
|
||||
}
|
||||
|
||||
// truncate shortens a string to max runes, appending "…" if truncated.
|
||||
@@ -234,7 +318,8 @@ func truncate(s string, max int) string {
|
||||
return string(runes[:max-1]) + "…"
|
||||
}
|
||||
|
||||
// padRight pads or truncates a string to exactly width runes.
|
||||
// padRight pads or truncates a plain string to exactly width runes.
|
||||
// Only use this for strings that contain no ANSI escape codes.
|
||||
func padRight(s string, width int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) >= width {
|
||||
@@ -243,7 +328,8 @@ func padRight(s string, width int) string {
|
||||
return s + strings.Repeat(" ", width-len(runes))
|
||||
}
|
||||
|
||||
// padLeft left-pads a string to exactly width runes.
|
||||
// padLeft left-pads a plain string to exactly width runes.
|
||||
// Only use this for strings that contain no ANSI escape codes.
|
||||
func padLeft(s string, width int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) >= width {
|
||||
@@ -252,6 +338,26 @@ func padLeft(s string, width int) string {
|
||||
return strings.Repeat(" ", width-len(runes)) + s
|
||||
}
|
||||
|
||||
// padRightVisible pads an ANSI-styled string to exactly width visible columns.
|
||||
// Uses lipgloss.Width to measure the visible display width, ignoring escape codes.
|
||||
func padRightVisible(s string, width int) string {
|
||||
vis := lipgloss.Width(s)
|
||||
if vis >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-vis)
|
||||
}
|
||||
|
||||
// padLeftVisible left-pads an ANSI-styled string to exactly width visible columns.
|
||||
// Uses lipgloss.Width to measure the visible display width, ignoring escape codes.
|
||||
func padLeftVisible(s string, width int) string {
|
||||
vis := lipgloss.Width(s)
|
||||
if vis >= width {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat(" ", width-vis) + s
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
@@ -266,21 +372,90 @@ func max(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// countryFlag converts a country flag URL or code to an emoji flag.
|
||||
// OpenF1 provides flag URLs; we do a best-effort mapping from country_code.
|
||||
// countryFlag converts a country code to an emoji flag.
|
||||
func countryFlag(countryCode string) string {
|
||||
// Convert ISO 3166-1 alpha-2 to emoji regional indicators
|
||||
code := strings.ToUpper(countryCode)
|
||||
if len(code) != 2 {
|
||||
return " "
|
||||
}
|
||||
// Each letter maps to a regional indicator symbol (U+1F1E6 = 'A')
|
||||
r1 := rune(0x1F1E6 + int(code[0]-'A'))
|
||||
r2 := rune(0x1F1E6 + int(code[1]-'A'))
|
||||
return string(r1) + string(r2)
|
||||
}
|
||||
|
||||
// helpBar renders a horizontal help bar for a set of key hints.
|
||||
// helpBar renders a styled help bar with key/description pairs.
|
||||
// Format: "key:description" pairs separated by spaces in the output.
|
||||
func helpBar(hints ...string) string {
|
||||
return styleHelp.Render(strings.Join(hints, " "))
|
||||
var parts []string
|
||||
for _, h := range hints {
|
||||
// Split on first space: "key description"
|
||||
idx := strings.Index(h, " ")
|
||||
if idx > 0 {
|
||||
key := h[:idx]
|
||||
desc := h[idx+1:]
|
||||
parts = append(parts, styleHelpKey.Render(key)+" "+styleHelpDesc.Render(desc))
|
||||
} else {
|
||||
parts = append(parts, styleHelpDesc.Render(h))
|
||||
}
|
||||
}
|
||||
bar := strings.Join(parts, styleMuted.Render(" │ "))
|
||||
return styleHelp.Render(bar)
|
||||
}
|
||||
|
||||
// divider renders a subtle horizontal divider line.
|
||||
func divider(width int) string {
|
||||
if width <= 0 {
|
||||
width = 40
|
||||
}
|
||||
return styleMuted.Render(strings.Repeat("─", width))
|
||||
}
|
||||
|
||||
// matchKey checks if a KeyMsg matches a binding.
|
||||
func matchKey(msg tea.KeyMsg, binding interface{ Keys() []string }) bool {
|
||||
for _, k := range binding.Keys() {
|
||||
if msg.String() == k {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// teamColorFromName returns the known team color hex for a team name, or a fallback.
|
||||
func teamColorFromName(teamName string) string {
|
||||
name := strings.ToLower(teamName)
|
||||
switch {
|
||||
case strings.Contains(name, "red bull") && !strings.Contains(name, "racing bulls"):
|
||||
return colorRedbull
|
||||
case strings.Contains(name, "mercedes"):
|
||||
return colorMercedes
|
||||
case strings.Contains(name, "ferrari"):
|
||||
return colorFerrari
|
||||
case strings.Contains(name, "mclaren"):
|
||||
return colorMclaren
|
||||
case strings.Contains(name, "aston martin"):
|
||||
return colorAstonMartin
|
||||
case strings.Contains(name, "alpine"):
|
||||
return colorAlpine
|
||||
case strings.Contains(name, "williams"):
|
||||
return colorWilliams
|
||||
case strings.Contains(name, "haas"):
|
||||
return colorHaas
|
||||
case strings.Contains(name, "racing bulls"), strings.Contains(name, "alphatauri"), strings.Contains(name, "rb "):
|
||||
return colorRB
|
||||
case strings.Contains(name, "sauber"), strings.Contains(name, "alfa romeo"), strings.Contains(name, "stake"):
|
||||
return colorSauber
|
||||
default:
|
||||
return colorMuted
|
||||
}
|
||||
}
|
||||
|
||||
// teamColorBar renders a thin colored bar (e.g., "█") for team identity.
|
||||
func teamColorBar(teamColor string) string {
|
||||
if teamColor == "" {
|
||||
teamColor = colorMuted
|
||||
}
|
||||
if !strings.HasPrefix(teamColor, "#") {
|
||||
teamColor = "#" + teamColor
|
||||
}
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user