diff --git a/README.md b/README.md index 6d884f4..c914914 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ - ๐Ÿ“… **Race Calendar**: The full 2025 schedule at your fingertips. - ๐ŸŽ๏ธ **Race Details**: Deep dive into session results, starting grids, and lap data. - ๐Ÿ‘ค **Driver Profiles**: Detailed stats for every driver on the grid. +- ๐Ÿ”ด **Official Live Timing**: Real-time F1 timing tower via the official SignalR feed โ€” gaps, intervals, tyre age, sector times, DRS, and track status. +- โš”๏ธ **Battle Tracker**: Auto-detects on-track duels within DRS range with gap sparklines and tyre strategy comparison. +- ๐Ÿ”ง **Pit Window Calculator**: Predicts rejoin position if a driver pits now, using per-circuit pit loss times. +- โช **Race Replay**: Lap-by-lap scrubber for completed races โ€” relive the whole field's evolution with pit annotations and race control messages. +- ๐Ÿ—บ๏ธ **ASCII Track Map**: Live car positions on a terminal-rendered track outline, team-coloured. - ๐Ÿ”Œ **Offline-ish**: Fast, lightweight, and powered by the wonderful [OpenF1 API](https://openf1.org). ## ๐Ÿš€ Quick Start @@ -40,14 +45,24 @@ go run cmd/main.go | Key | Action | | --- | --- | -| `1` | Switch to **Standings** | -| `2` | Switch to **Calendar** | -| `3` | Switch to **Race Details** | -| `4` | Switch to **Drivers** | +| `1` | Switch to **Home** | +| `2` | Switch to **Standings** | +| `3` | Switch to **Calendar** | +| `4` | Switch to **Race Details** | +| `5` | Switch to **Drivers** | +| `6` | Switch to **Live Timing** | +| `7` | Switch to **Track Map** | +| `tab` / `shift+tab` | Next / Previous tab | | `j`/`โ†“` | Navigate down | | `k`/`โ†‘` | Navigate up | | `enter` | Select/Inspect item | -| `b` | Go back | +| `b` / `esc` | Go back / collapse | +| `s` | Toggle sector times (Live tab) | +| `b` | Toggle Battle Tracker (Live tab) | +| `p` | Toggle Pit Window Calculator (Live tab) | +| `r` | Enter Race Replay (Race Detail tab, Race sessions) | +| `โ†`/`h` ยท `โ†’`/`l` | Scrub laps in Replay | +| `y` | Cycle season year | | `q` / `ctrl+c` | Exit | ## ๐Ÿ› ๏ธ Tech Stack diff --git a/box-box b/box-box index 9a8f694..01696ce 100755 Binary files a/box-box and b/box-box differ diff --git a/box-box-stable b/box-box-stable new file mode 100755 index 0000000..9651426 Binary files /dev/null and b/box-box-stable differ diff --git a/box-box.log b/box-box.log new file mode 100644 index 0000000..e69de29 diff --git a/cmd/main.go b/cmd/main.go index 71616ff..71d5bf6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "log" "os" "time" @@ -11,6 +12,12 @@ import ( ) func main() { + // Redirect all log output to a file so it never pollutes the TUI. + if f, err := os.OpenFile("box-box.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err == nil { + log.SetOutput(f) + defer f.Close() + } + var client *api.OpenF1Client if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" { client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey) diff --git a/internal/ui/app.go b/internal/ui/app.go index 7949948..bbb2288 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -21,10 +21,13 @@ const ( tabRaceDetail tabIndex = 3 tabDriver tabIndex = 4 tabLive tabIndex = 5 + tabTrackMap tabIndex = 6 ) -var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live"} -var tabIcons = []string{"๐Ÿ ", "๐Ÿ†", "๐Ÿ“…", "๐Ÿ", "๐Ÿ‘ค", "๐Ÿ”ด"} +const numTabs = 7 + +var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live", "Map"} +var tabIcons = []string{"๐Ÿ ", "๐Ÿ†", "๐Ÿ“…", "๐Ÿ", "๐Ÿ‘ค", "๐Ÿ”ด", "๐Ÿ—บ"} // splashDoneMsg is sent after the splash screen duration has elapsed. type splashDoneMsg struct{} @@ -44,6 +47,7 @@ type AppModel struct { driver DriverModel dashboard DashboardModel live OfficialLiveModel + trackMap TrackMapModel meetings []models.Meeting @@ -69,6 +73,7 @@ func NewAppModel(client *api.OpenF1Client) AppModel { driver: NewDriverModel(client), dashboard: NewDashboardModel(client, year), live: NewOfficialLiveModel(), + trackMap: NewTrackMapModel(client), showSplash: true, splashSpinner: sp, } @@ -88,6 +93,7 @@ func (m AppModel) Init() tea.Cmd { m.calendar.Init(), m.raceDetail.Init(), m.driver.Init(), + m.trackMap.Init(), m.splashSpinner.Tick, splashTimer(), ) @@ -103,14 +109,15 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing m.raceDetail.SetSize(m.width-4, contentHeight) m.live.SetSize(m.width, contentHeight) - var cmd1, cmd2, cmd3, cmd4, cmd5, cmd6 tea.Cmd + var cmd1, cmd2, cmd3, cmd4, cmd5, cmd6, cmd7 tea.Cmd m.dashboard, cmd1 = m.dashboard.Update(msg) m.live, cmd2 = m.live.Update(msg) m.standings, cmd3 = m.standings.Update(msg) m.calendar, cmd4 = m.calendar.Update(msg) m.raceDetail, cmd5 = m.raceDetail.Update(msg) m.driver, cmd6 = m.driver.Update(msg) - return m, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6) + m.trackMap, cmd7 = m.trackMap.Update(msg) + return m, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6, cmd7) case splashDoneMsg: m.showSplash = false @@ -150,8 +157,16 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case matchKey(msg, GlobalKeys.Tab6): m.activeTab = tabLive return m, nil + case matchKey(msg, GlobalKeys.Tab7): + m.activeTab = tabTrackMap + if !m.trackMap.HasSession() { + var cmd tea.Cmd + m.trackMap, cmd = m.trackMap.FetchActiveSession(m.client) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) case matchKey(msg, GlobalKeys.NextTab): - m.activeTab = (m.activeTab + 1) % 6 + m.activeTab = (m.activeTab + 1) % numTabs if m.activeTab == tabDriver { var cmd tea.Cmd m.driver, cmd = m.driver.TriggerLoad() @@ -159,7 +174,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) case matchKey(msg, GlobalKeys.PrevTab): - m.activeTab = (m.activeTab - 1 + 6) % 6 + m.activeTab = (m.activeTab - 1 + numTabs) % numTabs if m.activeTab == tabDriver { var cmd tea.Cmd m.driver, cmd = m.driver.TriggerLoad() @@ -262,6 +277,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) return m, tea.Batch(cmds...) + case startingGridLoadedMsg: + var cmd tea.Cmd + m.raceDetail, cmd = m.raceDetail.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + case loadSecondaryDataMsg: var cmd tea.Cmd m.raceDetail, cmd = m.raceDetail.Update(msg) @@ -308,6 +329,14 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.live, cmd = m.live.Update(msg) cmds = append(cmds, cmd) + // Forward driver info to track map for car-marker colouring + m.trackMap.InjectDriverInfo(msg.DriverInfo) + return m, tea.Batch(cmds...) + + case trackOutlineLoadedMsg, trackCarsLoadedMsg, trackMapTickMsg: + var cmd tea.Cmd + m.trackMap, cmd = m.trackMap.Update(msg) + cmds = append(cmds, cmd) return m, tea.Batch(cmds...) case spinner.TickMsg: @@ -316,12 +345,13 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.splashSpinner, cmd = m.splashSpinner.Update(msg) cmds = append(cmds, cmd) } - var cmd1, cmd2, cmd3, cmd4 tea.Cmd + var cmd1, cmd2, cmd3, cmd4, cmd5 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) - cmds = append(cmds, cmd1, cmd2, cmd3, cmd4) + m.trackMap, cmd5 = m.trackMap.Update(msg) + cmds = append(cmds, cmd1, cmd2, cmd3, cmd4, cmd5) return m, tea.Batch(cmds...) } @@ -351,6 +381,10 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.driver, cmd = m.driver.Update(msg) cmds = append(cmds, cmd) + case tabTrackMap: + var cmd tea.Cmd + m.trackMap, cmd = m.trackMap.Update(msg) + cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) @@ -387,6 +421,8 @@ func (m AppModel) View() string { content = m.raceDetail.View() case tabDriver: content = m.driver.View() + case tabTrackMap: + content = m.trackMap.View() } statusBar := m.renderStatusBar(w) @@ -497,7 +533,7 @@ func (m AppModel) renderStatusBar(width int) string { // 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-6 tabs ยท y year ยท q quit") + right := cacheInfo + " " + styleMuted.Render("1-7 tabs ยท y year ยท q quit") leftW := lipgloss.Width(left) rightW := lipgloss.Width(right) diff --git a/internal/ui/battles.go b/internal/ui/battles.go new file mode 100644 index 0000000..447c929 --- /dev/null +++ b/internal/ui/battles.go @@ -0,0 +1,230 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// drsRange is the gap threshold (seconds) within which two drivers are +// considered to be in a battle. 1.0s is the DRS activation gap in F1. +const drsRange = 1.0 + +// Battle represents an active on-track duel between two drivers. +type Battle struct { + LeaderNum string // racing number of the driver ahead + ChaserNum string // racing number of the driver behind + GapSeconds float64 + LeaderData LiveDriverData + ChaserData LiveDriverData + LeaderInfo F1DriverListEntry + ChaserInfo F1DriverListEntry + LeaderTyre LiveTyreData + ChaserTyre LiveTyreData + LeaderStints []LiveStintData + ChaserStints []LiveStintData + // GapHistory for the chaser (interval to car ahead) + GapHistory []float64 +} + +// detectBattles scans the sorted driver list and returns all pairs whose +// interval to the car directly ahead is within drsRange seconds. +func detectBattles( + drivers []LiveDriverData, + driverInfo map[string]F1DriverListEntry, + tyres map[string]LiveTyreData, + stints map[string][]LiveStintData, + gapHistory map[string][]float64, +) []Battle { + var battles []Battle + + // Build position-sorted list (positioned drivers only) + var positioned []LiveDriverData + for _, d := range drivers { + if d.Position > 0 && !d.Retired && !d.InPit { + positioned = append(positioned, d) + } + } + sort.Slice(positioned, func(i, j int) bool { + return positioned[i].Position < positioned[j].Position + }) + + for i := 1; i < len(positioned); i++ { + chaser := positioned[i] + leader := positioned[i-1] + + gap := parseGapToFloat(chaser.Interval) + if gap < 0 || gap > drsRange { + continue + } + + b := Battle{ + LeaderNum: leader.RacingNumber, + ChaserNum: chaser.RacingNumber, + GapSeconds: gap, + LeaderData: leader, + ChaserData: chaser, + LeaderInfo: driverInfo[leader.RacingNumber], + ChaserInfo: driverInfo[chaser.RacingNumber], + LeaderTyre: tyres[leader.RacingNumber], + ChaserTyre: tyres[chaser.RacingNumber], + LeaderStints: stints[leader.RacingNumber], + ChaserStints: stints[chaser.RacingNumber], + GapHistory: gapHistory[chaser.RacingNumber], + } + battles = append(battles, b) + } + return battles +} + +// renderBattlePanel renders the full battle tracker panel. +// width is the available character width. +func renderBattlePanel( + battles []Battle, + width int, + isRace bool, +) string { + var sb strings.Builder + + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color(colorF1Red)). + Render("โš” BATTLE TRACKER") + + subtitle := styleMuted.Render(fmt.Sprintf(" drivers within %.1fs (DRS range)", drsRange)) + sb.WriteString(" " + title + " " + subtitle + "\n") + sb.WriteString(" " + divider(min(width-4, 70)) + "\n") + + if !isRace { + sb.WriteString("\n" + styleMuted.Render(" Battle tracker is only available during Race sessions.\n")) + return sb.String() + } + + if len(battles) == 0 { + sb.WriteString("\n" + styleMuted.Render(" No active battles detected โ€” all gaps > 1.0s.\n")) + return sb.String() + } + + for i, b := range battles { + if i > 0 { + sb.WriteString(" " + styleMuted.Render(strings.Repeat("ยท", min(width-4, 60))) + "\n") + } + sb.WriteString(renderBattleCard(b, width)) + } + + return sb.String() +} + +// renderBattleCard renders a single battle card showing both drivers side by side. +func renderBattleCard(b Battle, width int) string { + var sb strings.Builder + + // โ”€โ”€ Gap headline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + gapStr := fmt.Sprintf("%.3fs", b.GapSeconds) + var gapColor string + switch { + case b.GapSeconds < 0.3: + gapColor = colorF1Red // very close, danger + case b.GapSeconds < 0.6: + gapColor = colorOrange + default: + gapColor = colorYellow + } + gapStyled := lipgloss.NewStyle().Foreground(lipgloss.Color(gapColor)).Bold(true).Render(gapStr) + + trend := "" + if len(b.GapHistory) >= 2 { + trend = " " + gapTrendIndicator(b.GapHistory) + } + + sb.WriteString(fmt.Sprintf(" P%d vs P%d gap: %s%s\n", + b.LeaderData.Position, b.ChaserData.Position, + gapStyled, trend)) + + // โ”€โ”€ Driver rows โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Each row: [TEAM-COLOR-BAR] TLA tyre(age) lastlap best stints + sb.WriteString(renderBattleDriverRow("AHEAD", b.LeaderData, b.LeaderInfo, b.LeaderTyre, b.LeaderStints)) + sb.WriteString(renderBattleDriverRow("CHASE", b.ChaserData, b.ChaserInfo, b.ChaserTyre, b.ChaserStints)) + + return sb.String() +} + +func renderBattleDriverRow( + role string, + d LiveDriverData, + info F1DriverListEntry, + tyre LiveTyreData, + stints []LiveStintData, +) string { + tla := d.RacingNumber + teamColor := colorMuted + if info.Tla != "" { + tla = info.Tla + } + if info.TeamColour != "" { + teamColor = "#" + info.TeamColour + } else if info.TeamName != "" { + teamColor = teamColorFromName(info.TeamName) + } + + colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("โ”ƒ") + tlaStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(teamColor)) + + roleStyle := styleMuted + tlaRendered := tlaStyle.Render(padRight(tla, 4)) + + // Tyre indicator + tyreStr := " ?" + if tyre.Compound != "" { + abbrev, ts := compoundAbbrevStyle(strings.ToUpper(tyre.Compound)) + newMark := " " + if tyre.New { + newMark = ts.Render("*") + } + tyreStr = ts.Render("โ—") + " " + abbrev + newMark + if tyre.Age > 0 { + tyreStr += styleMuted.Render(fmt.Sprintf("/%d", tyre.Age)) + } + } + + // Last lap time + lastLap := styleMuted.Render(" --:--.---") + if d.LastLapTime != "" { + ll := d.LastLapTime + if d.LastLapOB { + lastLap = " " + stylePurple.Render(ll) + } else if d.LastLapPB { + lastLap = " " + lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(ll) + } else { + lastLap = " " + ll + } + } + + // Stint history (compact: S(12)โ†’M(5)) + stintStr := "" + if len(stints) > 0 { + var parts []string + for _, st := range stints { + abbrev, ts := compoundAbbrevStyle(strings.ToUpper(st.Compound)) + newMark := "" + if st.New { + newMark = "*" + } + parts = append(parts, ts.Render(fmt.Sprintf("%s(%d%s)", abbrev, st.Laps, newMark))) + } + stintStr = " " + strings.Join(parts, styleMuted.Render("โ†’")) + } + + row := fmt.Sprintf(" %s %s %s %s%s%s", + colorBar, + roleStyle.Render(padRight(role, 5)), + tlaRendered, + tyreStr, + lastLap, + stintStr, + ) + + return row + "\n" +} diff --git a/internal/ui/driver.go b/internal/ui/driver.go index 11bf61b..eab016f 100644 --- a/internal/ui/driver.go +++ b/internal/ui/driver.go @@ -603,6 +603,12 @@ func (m DriverModel) renderDetailContent() string { sb.WriteString(m.renderStintBar()) sb.WriteString("\n\n") + // โ”€โ”€ TYRE DEGRADATION โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if degContent := m.renderTyreDegradation(); degContent != "" { + sb.WriteString(degContent) + sb.WriteString("\n") + } + // โ”€โ”€ LAP TIMES โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ sb.WriteString(" " + styleSectionTitle.Render("LAP TIMES") + "\n") sparkWidth := min(m.width-6, 70) @@ -651,6 +657,12 @@ func (m DriverModel) renderDetailContent() string { } sb.WriteString("\n\n") + // โ”€โ”€ SECTOR ANALYSIS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if sectorContent := m.renderSectorAnalysis(); sectorContent != "" { + sb.WriteString(sectorContent) + sb.WriteString("\n") + } + // โ”€โ”€ PIT STOPS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ sb.WriteString(" " + styleSectionTitle.Render("PIT STOPS") + "\n") sb.WriteString(m.renderPitStops()) @@ -875,6 +887,288 @@ func (m DriverModel) renderPositionChart() string { return sb.String() } +// renderSectorAnalysis renders sector time and speed trap analysis from existing lap data. +func (m DriverModel) renderSectorAnalysis() string { + if len(m.laps) < 2 { + return "" + } + + // Collect valid sector times and speeds + type sectorStats struct { + best float64 + total float64 + count int + bestLap int + } + var s1, s2, s3 sectorStats + s1.best, s2.best, s3.best = 999, 999, 999 + + var speeds []int // speed trap values + var bestSpeed, bestSpeedLap int + + for _, lap := range m.laps { + if lap.IsPitOutLap { + continue + } + if lap.DurationSector1 != nil && *lap.DurationSector1 > 0 { + v := *lap.DurationSector1 + s1.total += v + s1.count++ + if v < s1.best { + s1.best = v + s1.bestLap = lap.LapNumber + } + } + if lap.DurationSector2 != nil && *lap.DurationSector2 > 0 { + v := *lap.DurationSector2 + s2.total += v + s2.count++ + if v < s2.best { + s2.best = v + s2.bestLap = lap.LapNumber + } + } + if lap.DurationSector3 != nil && *lap.DurationSector3 > 0 { + v := *lap.DurationSector3 + s3.total += v + s3.count++ + if v < s3.best { + s3.best = v + s3.bestLap = lap.LapNumber + } + } + if lap.StSpeed > 0 { + speeds = append(speeds, lap.StSpeed) + if lap.StSpeed > bestSpeed { + bestSpeed = lap.StSpeed + bestSpeedLap = lap.LapNumber + } + } + } + + // Need at least some sector data + if s1.count == 0 && s2.count == 0 && s3.count == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString(" " + styleSectionTitle.Render("SECTOR ANALYSIS") + "\n") + + // Sector best/avg table + header := fmt.Sprintf(" %s %s %s", + padRight("", 3), + padLeft("BEST", 10), + padLeft("AVG", 10), + ) + sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n") + + sectors := []struct { + name string + stats sectorStats + }{ + {"S1", s1}, + {"S2", s2}, + {"S3", s3}, + } + + // Theoretical best lap + var theoreticalBest float64 + + for _, sec := range sectors { + if sec.stats.count == 0 { + sb.WriteString(fmt.Sprintf(" %s %s %s\n", + styleBold.Render(padRight(sec.name, 3)), + padLeft("--", 10), + padLeft("--", 10), + )) + continue + } + + theoreticalBest += sec.stats.best + avg := sec.stats.total / float64(sec.stats.count) + + bestStr := lipgloss.NewStyle().Foreground(lipgloss.Color(colorPurple)).Bold(true). + Render(fmt.Sprintf("%.3f", sec.stats.best)) + avgStr := styleBold.Render(fmt.Sprintf("%.3f", avg)) + + sb.WriteString(fmt.Sprintf(" %s %s %s %s\n", + styleBold.Render(padRight(sec.name, 3)), + padLeftVisible(bestStr, 10), + padLeftVisible(avgStr, 10), + styleMuted.Render(fmt.Sprintf("L%d", sec.stats.bestLap)), + )) + } + + // Theoretical best + if theoreticalBest > 0 { + theoryStr := lipgloss.NewStyle().Foreground(lipgloss.Color(colorPurple)).Bold(true). + Render(formatSeconds(theoreticalBest)) + sb.WriteString(fmt.Sprintf(" %s %s\n", + styleMuted.Render("Theoretical best:"), + theoryStr, + )) + } + + // Speed trap summary + if len(speeds) > 0 && bestSpeed > 0 { + var totalSpeed int + for _, s := range speeds { + totalSpeed += s + } + avgSpeed := totalSpeed / len(speeds) + + sb.WriteString(fmt.Sprintf(" %s %s %s %s %s\n", + styleMuted.Render("Top speed:"), + lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)).Bold(true). + Render(fmt.Sprintf("%dkm/h", bestSpeed)), + styleMuted.Render("Avg:"), + styleBold.Render(fmt.Sprintf("%dkm/h", avgSpeed)), + styleMuted.Render(fmt.Sprintf("(L%d)", bestSpeedLap)), + )) + + // Speed sparkline + sparkWidth := min(len(speeds), min(m.width-8, 50)) + if sparkWidth >= 3 { + sb.WriteString(" " + speedSparkline(speeds, sparkWidth) + "\n") + } + } + + return sb.String() +} + +// stintLapTimes returns the valid (non-pit, non-nil) lap durations for a stint, +// along with the lap numbers. Used for degradation computation. +func (m DriverModel) stintLapTimes(stint models.Stint) ([]float64, []int) { + var durations []float64 + var lapNums []int + for _, lap := range m.laps { + if lap.LapNumber < stint.LapStart || lap.LapNumber > stint.LapEnd { + continue + } + if lap.IsPitOutLap || lap.LapDuration == nil || *lap.LapDuration <= 0 { + continue + } + durations = append(durations, *lap.LapDuration) + lapNums = append(lapNums, lap.LapNumber) + } + return durations, lapNums +} + +// renderTyreDegradation computes and renders degradation analysis per stint. +// Returns empty string if insufficient data. +func (m DriverModel) renderTyreDegradation() string { + if len(m.stints) == 0 || len(m.laps) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString(" " + styleSectionTitle.Render("TYRE DEGRADATION") + "\n") + + hasData := false + for _, stint := range m.stints { + durations, lapNums := m.stintLapTimes(stint) + if len(durations) < 3 { + continue + } + hasData = true + + compoundStyle := tyreStyle(stint.Compound) + label := compoundStyle.Render(fmt.Sprintf("%s L%d-%d", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd)) + lapsOnTyre := stint.LapEnd - stint.LapStart + 1 + if stint.TyreAgeAtStart > 0 { + label += styleMuted.Render(fmt.Sprintf(" (used +%d)", stint.TyreAgeAtStart)) + } + sb.WriteString(fmt.Sprintf(" %s %s laps\n", label, styleMuted.Render(fmt.Sprintf("%d", lapsOnTyre)))) + + // Find best and average lap time in stint + bestTime := durations[0] + var total float64 + for _, d := range durations { + total += d + if d < bestTime { + bestTime = d + } + } + avgTime := total / float64(len(durations)) + + // Degradation rate: compare first 3 laps avg vs last 3 laps avg + firstN := 3 + lastN := 3 + if len(durations) < 6 { + firstN = len(durations) / 2 + lastN = len(durations) / 2 + } + if firstN < 1 { + firstN = 1 + } + if lastN < 1 { + lastN = 1 + } + + var earlySum, lateSum float64 + for i := 0; i < firstN; i++ { + earlySum += durations[i] + } + for i := len(durations) - lastN; i < len(durations); i++ { + lateSum += durations[i] + } + earlyAvg := earlySum / float64(firstN) + lateAvg := lateSum / float64(lastN) + degTotal := lateAvg - earlyAvg + degPerLap := degTotal / float64(len(durations)-1) + + // Stats line + bestStr := lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(formatSeconds(bestTime)) + avgStr := styleBold.Render(formatSeconds(avgTime)) + sb.WriteString(fmt.Sprintf(" Best %s Avg %s", bestStr, avgStr)) + + // Deg rate with color coding + if degPerLap > 0 { + var degStyle lipgloss.Style + switch { + case degPerLap < 0.05: + degStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)) + case degPerLap < 0.15: + degStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)) + default: + degStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) + } + sb.WriteString(fmt.Sprintf(" Deg %s", + degStyle.Render(fmt.Sprintf("+%.3fs/lap", degPerLap)))) + } else { + sb.WriteString(fmt.Sprintf(" Deg %s", + lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render("improving"))) + } + sb.WriteString("\n") + + // Mini sparkline for this stint's lap times + sparkWidth := min(len(durations), min(m.width-8, 50)) + if sparkWidth >= 3 { + // Build mini laps slice for sparkline + stintLaps := make([]models.Lap, len(durations)) + for i, d := range durations { + dur := d + stintLaps[i] = models.Lap{ + LapDuration: &dur, + LapNumber: lapNums[i], + } + } + sb.WriteString(" " + sparkline(stintLaps, sparkWidth) + "\n") + sb.WriteString(fmt.Sprintf(" %s%s%s\n", + styleMuted.Render(fmt.Sprintf("L%d", stint.LapStart)), + strings.Repeat(" ", max(sparkWidth-8, 1)), + styleMuted.Render(fmt.Sprintf("L%d", stint.LapEnd)), + )) + } + } + + if !hasData { + return "" + } + + return sb.String() +} + // renderTeamRadio displays team radio messages with timestamps. func (m DriverModel) renderTeamRadio() string { if len(m.teamRadios) == 0 { diff --git a/internal/ui/keys.go b/internal/ui/keys.go index eaea442..94f6de9 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -10,6 +10,7 @@ type GlobalKeyMap struct { Tab4 key.Binding Tab5 key.Binding Tab6 key.Binding + Tab7 key.Binding NextTab key.Binding PrevTab key.Binding Quit key.Binding @@ -51,6 +52,10 @@ var GlobalKeys = GlobalKeyMap{ key.WithKeys("6"), key.WithHelp("6", "live"), ), + Tab7: key.NewBinding( + key.WithKeys("7"), + key.WithHelp("7", "track map"), + ), NextTab: key.NewBinding( key.WithKeys("tab", "right"), key.WithHelp("tab/->", "next tab"), @@ -124,12 +129,14 @@ var StandingsKeys = StandingsKeyMap{ // LiveKeyMap holds keybindings specific to the live timing tab. type LiveKeyMap struct { - ToggleSectors key.Binding - ToggleRC key.Binding - ScrollRCUp key.Binding - ScrollRCDown key.Binding - ExpandDriver key.Binding - Collapse key.Binding + ToggleSectors key.Binding + ToggleRC key.Binding + ToggleBattles key.Binding + TogglePitWindow key.Binding + ScrollRCUp key.Binding + ScrollRCDown key.Binding + ExpandDriver key.Binding + Collapse key.Binding } var LiveKeys = LiveKeyMap{ @@ -141,6 +148,14 @@ var LiveKeys = LiveKeyMap{ key.WithKeys("r"), key.WithHelp("r", "toggle RC panel"), ), + ToggleBattles: key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "toggle battles"), + ), + TogglePitWindow: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "pit window"), + ), ScrollRCUp: key.NewBinding( key.WithKeys("K"), key.WithHelp("K", "scroll RC up"), @@ -154,8 +169,8 @@ var LiveKeys = LiveKeyMap{ key.WithHelp("enter", "driver detail"), ), Collapse: key.NewBinding( - key.WithKeys("esc", "b"), - key.WithHelp("esc/b", "collapse"), + key.WithKeys("esc"), + key.WithHelp("esc", "collapse"), ), } @@ -165,6 +180,7 @@ type RaceDetailKeyMap struct { ScrollDown key.Binding PrevSession key.Binding NextSession key.Binding + Replay key.Binding } var RaceDetailKeys = RaceDetailKeyMap{ @@ -184,4 +200,8 @@ var RaceDetailKeys = RaceDetailKeyMap{ key.WithKeys("]"), key.WithHelp("]", "next session"), ), + Replay: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "replay lap scrubber"), + ), } diff --git a/internal/ui/messages.go b/internal/ui/messages.go index 2ea196a..531350b 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -115,6 +115,12 @@ type driverSelectedMsg struct { sessionKey int } +// startingGridLoadedMsg carries starting grid data for a session. +type startingGridLoadedMsg struct { + grid []models.StartingGrid + err error +} + // loadSecondaryDataMsg triggers loading of secondary session data (race control, weather, overtakes) // after the primary data (results, drivers) has arrived. type loadSecondaryDataMsg struct { diff --git a/internal/ui/official_live.go b/internal/ui/official_live.go index ea9ed94..b1511fe 100644 --- a/internal/ui/official_live.go +++ b/internal/ui/official_live.go @@ -37,7 +37,7 @@ type F1TimingLine struct { Value interface{} `json:"Value"` } `json:"IntervalToPositionAhead"` Position interface{} `json:"Position"` - RacingNumber string `json:"RacingNumber"` + RacingNumber string `json:"RacingNumber"` LastLapTime struct { Value string `json:"Value"` PersonalFastest bool `json:"PersonalFastest"` @@ -268,10 +268,6 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { } if json.Unmarshal(data, &td) == nil { for num, lineRaw := range td.Lines { - // Debug: dump first driver's raw JSON to see field types - if num == "1" || num == "81" || num == "44" { - log.Printf("[DEBUG TimingData] driver=%s raw=%s", num, string(lineRaw)) - } var line F1TimingLine if json.Unmarshal(lineRaw, &line) == nil { updateDriver(drivers, num, line) @@ -306,8 +302,8 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { } case "ExtrapolatedClock": var ec struct { - Remaining string `json:"Remaining"` - Utc string `json:"Utc"` + Remaining string `json:"Remaining"` + Utc string `json:"Utc"` Extrapolating bool `json:"Extrapolating"` } if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" { @@ -427,10 +423,12 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { New string `json:"New"` } if json.Unmarshal(raw, &td) == nil && td.Compound != "" { - tyres[num] = LiveTyreData{ - Compound: td.Compound, - New: td.New == "true" || td.New == "True", - } + // Preserve the existing Age โ€” CurrentTyres only carries + // compound and newness, not lap count. + t := tyres[num] + t.Compound = td.Compound + t.New = td.New == "true" || td.New == "True" + tyres[num] = t updated = true } } @@ -462,11 +460,13 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error { } if len(driverStints) > 0 { stints[num] = driverStints - // Always sync tyre from latest stint + // Sync compound and age from the latest stint. + // Stints are authoritative: they include historical data and + // carry both compound and laps on the current set. lastStint := driverStints[len(driverStints)-1] t := tyres[num] t.Age = lastStint.Laps - if t.Compound == "" && lastStint.Compound != "" { + if lastStint.Compound != "" { t.Compound = lastStint.Compound t.New = lastStint.New } @@ -727,6 +727,129 @@ func parseGap(val string) string { return val } +// parseLapTimeMs parses a lap time string like "1:33.596" or "1:33" into +// milliseconds. Returns -1 if the string cannot be parsed. +func parseLapTimeMs(s string) int64 { + if s == "" { + return -1 + } + // Handle M:SS.mmm or M:SS + parts := strings.SplitN(s, ":", 2) + if len(parts) == 2 { + var mins int + if _, err := fmt.Sscanf(parts[0], "%d", &mins); err != nil { + return -1 + } + var secs float64 + if _, err := fmt.Sscanf(parts[1], "%f", &secs); err != nil { + return -1 + } + return int64(float64(mins)*60000 + secs*1000) + } + // Bare seconds: SS.mmm + var secs float64 + if _, err := fmt.Sscanf(s, "%f", &secs); err != nil { + return -1 + } + return int64(secs * 1000) +} + +// computeGapFromBestLap returns a gap string like "+1.234" computed from the +// difference between the driver's best lap time and the leader's best lap time. +// Returns "" if either time is missing. +func computeGapFromBestLap(driverBest, leaderBest string) string { + dMs := parseLapTimeMs(driverBest) + lMs := parseLapTimeMs(leaderBest) + if dMs < 0 || lMs < 0 { + return "" + } + diff := dMs - lMs + if diff <= 0 { + return "" + } + return fmt.Sprintf("+%.3f", float64(diff)/1000.0) +} + +// parseGapToFloat extracts a numeric gap value from a gap string like "+1.234" or "1.234". +// Returns -1 if the string cannot be parsed (e.g. "1 LAP", empty, "LAP"). +func parseGapToFloat(val string) float64 { + if val == "" { + return -1 + } + s := strings.TrimPrefix(val, "+") + var f float64 + if _, err := fmt.Sscanf(s, "%f", &f); err == nil && f >= 0 { + return f + } + return -1 +} + +// gapTrendIndicator renders a compact 8-character sparkline showing gap trend. +// Rising = gap increasing (bad, red), falling = gap decreasing (good, green). +func gapTrendIndicator(history []float64) string { + if len(history) < 2 { + return "" + } + + const trendWidth = 8 + blocks := []rune("โ–โ–‚โ–ƒโ–„โ–…โ–†โ–‡โ–ˆ") + + // Use the most recent samples that fit + data := history + if len(data) > trendWidth { + data = data[len(data)-trendWidth:] + } + + minVal, maxVal := data[0], data[0] + for _, v := range data { + if v < minVal { + minVal = v + } + if v > maxVal { + maxVal = v + } + } + + rng := maxVal - minVal + if rng < 0.01 { + // Gap is stable โ€” show flat indicator + return styleMuted.Render(strings.Repeat("โ”€", len(data))) + } + + // Determine trend direction: compare first half avg vs second half avg + mid := len(data) / 2 + var firstHalf, secondHalf float64 + for i := 0; i < mid; i++ { + firstHalf += data[i] + } + for i := mid; i < len(data); i++ { + secondHalf += data[i] + } + firstHalf /= float64(mid) + secondHalf /= float64(len(data) - mid) + closing := secondHalf < firstHalf // gap shrinking = good + + var sb strings.Builder + for _, v := range data { + norm := (v - minVal) / rng + idx := int(norm*float64(len(blocks)-1) + 0.5) + if idx < 0 { + idx = 0 + } + if idx >= len(blocks) { + idx = len(blocks) - 1 + } + var style lipgloss.Style + if closing { + style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)) + } else { + style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) + } + sb.WriteString(style.Render(string(blocks[idx]))) + } + return sb.String() +} + // compoundAbbrevStyle returns the single-letter abbreviation and lipgloss style for a tyre compound string. func compoundAbbrevStyle(compound string) (string, lipgloss.Style) { switch { @@ -759,17 +882,20 @@ func parseHHMMSS(s string) (time.Duration, error) { return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec)*time.Second, nil } +// maxGapHistory is the number of gap samples to keep per driver for trend sparklines. +const maxGapHistory = 20 + type OfficialLiveModel struct { width int height int - dataChan chan LiveStreamData - drivers map[string]LiveDriverData - driverInfo map[string]F1DriverListEntry - tyres map[string]LiveTyreData - rcMessages []LiveRCMessage - weather LiveWeatherData - session LiveSessionMeta + dataChan chan LiveStreamData + drivers map[string]LiveDriverData + driverInfo map[string]F1DriverListEntry + tyres map[string]LiveTyreData + rcMessages []LiveRCMessage + weather LiveWeatherData + session LiveSessionMeta trackStatus string currentLap int totalLaps int @@ -777,6 +903,7 @@ type OfficialLiveModel struct { clockRefTime time.Time clockExtrapolating bool stints map[string][]LiveStintData + gapHistory map[string][]float64 // racing number -> recent gap-to-leader values err error // UI state @@ -785,6 +912,8 @@ type OfficialLiveModel struct { showSectors bool expandedDriver string // racing number, "" if none showRC bool // compact mode RC overlay toggle + showBattles bool // battle tracker overlay + showPitWindow bool // pit window calculator overlay rcView viewport.Model rcReady bool @@ -797,6 +926,7 @@ func NewOfficialLiveModel() OfficialLiveModel { driverInfo: make(map[string]F1DriverListEntry), tyres: make(map[string]LiveTyreData), stints: make(map[string][]LiveStintData), + gapHistory: make(map[string][]float64), } } @@ -831,9 +961,126 @@ func (m OfficialLiveModel) displayClock() string { return fmt.Sprintf("%02d:%02d:%02d", h, mnt, sec) } +// recomputeImpliedPositions assigns positions to drivers based on session type. +// +// In practice and qualifying sessions the feed's Position field is unreliable โ€” +// many drivers never receive an explicit position. Instead, we derive positions +// from best lap times (fastest = P1), which matches the official F1 timing +// screen behaviour. Drivers without a time are placed at the bottom. +// +// In race sessions the feed's Position field is authoritative, so we only +// back-fill drivers that still have Position == 0. +func (m *OfficialLiveModel) recomputeImpliedPositions() { + type entry struct { + num string + bestLapTime string + racingNum int + } + + if m.isPracticeOrQuali() { + // -- Practice / Qualifying: rank everyone by best lap time ---------- + var all []entry + for num, d := range m.drivers { + var n int + fmt.Sscanf(num, "%d", &n) + all = append(all, entry{num, d.BestLapTime, n}) + } + // Also include drivers from driverInfo that have no timing data yet. + for num := range m.driverInfo { + if _, exists := m.drivers[num]; !exists { + var n int + fmt.Sscanf(num, "%d", &n) + all = append(all, entry{num, "", n}) + } + } + + // Drivers with a time sort first (ascending), then drivers without a + // time are ordered by racing number. + sort.Slice(all, func(i, j int) bool { + ti, tj := all[i].bestLapTime, all[j].bestLapTime + switch { + case ti != "" && tj != "": + return ti < tj + case ti != "": + return true + case tj != "": + return false + default: + return all[i].racingNum < all[j].racingNum + } + }) + + for i, e := range all { + newPos := i + 1 + d, exists := m.drivers[e.num] + if !exists { + d = LiveDriverData{RacingNumber: e.num} + } + if d.Position != newPos { + d.PrevPosition = d.Position + if d.PrevPosition == 0 { + d.PrevPosition = newPos // suppress spurious delta arrow on first assignment + } + d.Position = newPos + } + m.drivers[e.num] = d + } + return + } + + // -- Race: only back-fill drivers whose Position is still 0 --------------- + var unpos []entry + for num, d := range m.drivers { + if d.Position == 0 { + var n int + fmt.Sscanf(num, "%d", &n) + unpos = append(unpos, entry{num, d.BestLapTime, n}) + } + } + if len(unpos) == 0 { + return + } + + highestExplicit := 0 + for _, d := range m.drivers { + if d.Position > highestExplicit { + highestExplicit = d.Position + } + } + + sort.Slice(unpos, func(i, j int) bool { + ti, tj := unpos[i].bestLapTime, unpos[j].bestLapTime + switch { + case ti != "" && tj != "": + return ti < tj + case ti != "": + return true + case tj != "": + return false + default: + return unpos[i].racingNum < unpos[j].racingNum + } + }) + + for i, e := range unpos { + impliedPos := highestExplicit + i + 1 + d := m.drivers[e.num] + if d.Position != impliedPos { + d.PrevPosition = d.Position + if d.PrevPosition == 0 { + d.PrevPosition = impliedPos + } + d.Position = impliedPos + m.drivers[e.num] = d + } + } +} + func (m OfficialLiveModel) sortedDrivers() []LiveDriverData { - // Merge timing data with driver list so all known drivers appear, - // even those who have not set a lap time yet (Position == 0). + // In practice/qualifying, recomputeImpliedPositions already placed every + // known driver (including driverInfo-only entries) into m.drivers with a + // correct Position. For races we still merge driverInfo to show drivers + // that haven't received any timing data yet. merged := make(map[string]LiveDriverData, len(m.drivers)+len(m.driverInfo)) for num, d := range m.drivers { merged[num] = d @@ -844,35 +1091,68 @@ func (m OfficialLiveModel) sortedDrivers() []LiveDriverData { } } - var positioned, unpositioned []LiveDriverData + var all []LiveDriverData for _, d := range merged { - if d.Position > 0 { - positioned = append(positioned, d) - } else { - unpositioned = append(unpositioned, d) - } + all = append(all, d) } - - sort.Slice(positioned, func(i, j int) bool { - return positioned[i].Position < positioned[j].Position - }) - sort.Slice(unpositioned, func(i, j int) bool { + sort.Slice(all, func(i, j int) bool { + pi, pj := all[i].Position, all[j].Position + if pi > 0 && pj > 0 { + return pi < pj + } + if pi > 0 { + return true + } + if pj > 0 { + return false + } + // Both unpositioned: sort by best lap time, then racing number + ti, tj := all[i].BestLapTime, all[j].BestLapTime + if ti != "" && tj != "" { + return ti < tj + } + if ti != "" { + return true + } + if tj != "" { + return false + } var ni, nj int - fmt.Sscanf(unpositioned[i].RacingNumber, "%d", &ni) - fmt.Sscanf(unpositioned[j].RacingNumber, "%d", &nj) + fmt.Sscanf(all[i].RacingNumber, "%d", &ni) + fmt.Sscanf(all[j].RacingNumber, "%d", &nj) return ni < nj }) - return append(positioned, unpositioned...) + // Back-fill any remaining Position==0 entries so the P column is never blank. + for i := range all { + if all[i].Position == 0 { + all[i].Position = i + 1 + } + } + + return all } -// isPracticeOrQuali returns true for Free Practice, Qualifying, and Sprint Qualifying. -// In these sessions the timing tower shows BEST lap time as the primary column. +// isPracticeOrQuali returns true for Free Practice, Qualifying, Sprint Qualifying, +// and Sprint Shootout. In these sessions the timing tower shows BEST lap time +// as the primary column. func (m OfficialLiveModel) isPracticeOrQuali() bool { t := strings.ToLower(m.session.SessionType) + return m.isTimeBasedSession(t) +} + +// isTimeBasedSession returns true for any session type where positions are +// determined by best lap time rather than by on-track race order. This covers +// Free Practice, Qualifying, Sprint Qualifying, and Sprint Shootout. +// "Sprint" on its own is a race and is NOT included. +func (m OfficialLiveModel) isTimeBasedSession(sessionType string) bool { + t := sessionType + if t == "" { + t = strings.ToLower(m.session.SessionType) + } return strings.Contains(t, "practice") || strings.Contains(t, "qualifying") || - strings.Contains(t, "sprint") || + strings.Contains(t, "shootout") || t == "fp1" || t == "fp2" || t == "fp3" || t == "q" || t == "sq" } @@ -969,6 +1249,19 @@ func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) { m.clockRefTime = msg.ClockRefTime m.clockExtrapolating = msg.ClockExtrapolating m.stints = msg.Stints + // Record gap history for trend sparklines + for num, d := range msg.Drivers { + if gap := parseGapToFloat(d.GapToLeader); gap >= 0 { + hist := m.gapHistory[num] + hist = append(hist, gap) + if len(hist) > maxGapHistory { + hist = hist[len(hist)-maxGapHistory:] + } + m.gapHistory[num] = hist + } + } + // Back-fill implied positions for drivers the feed hasn't positioned yet. + m.recomputeImpliedPositions() m.updateRCViewport() return m, listenForWSData(m.dataChan) case tea.KeyMsg: @@ -1011,13 +1304,35 @@ func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) { case matchKey(msg, LiveKeys.ToggleRC): if m.width < 100 { m.showRC = !m.showRC + if m.showRC { + m.showBattles = false + m.showPitWindow = false + } + } + case matchKey(msg, LiveKeys.ToggleBattles): + m.showBattles = !m.showBattles + if m.showBattles { + m.showRC = false + m.showPitWindow = false + m.expandedDriver = "" + } + case matchKey(msg, LiveKeys.TogglePitWindow): + m.showPitWindow = !m.showPitWindow + if m.showPitWindow { + m.showRC = false + m.showBattles = false + m.expandedDriver = "" } case matchKey(msg, LiveKeys.ExpandDriver): if len(drivers) > 0 && m.cursor < len(drivers) { m.expandedDriver = drivers[m.cursor].RacingNumber + m.showBattles = false + m.showPitWindow = false } case matchKey(msg, LiveKeys.Collapse): m.expandedDriver = "" + m.showBattles = false + m.showPitWindow = false case matchKey(msg, LiveKeys.ScrollRCUp): if m.rcReady { m.rcView.LineUp(3) @@ -1067,14 +1382,27 @@ func (m OfficialLiveModel) View() string { leftWidth := int(float64(w) * 0.6) rightWidth := w - leftWidth - 4 + var rightPanel string + if m.showBattles { + rightPanel = m.renderBattlesPanel(rightWidth) + } else if m.showPitWindow { + rightPanel = m.renderPitWindowPanel(rightWidth) + } else { + rightPanel = m.renderRightPanel(rightWidth) + } + panels := lipgloss.JoinHorizontal(lipgloss.Top, m.renderTimingTower(leftWidth), " ", - m.renderRightPanel(rightWidth), + rightPanel, ) sb.WriteString(panels) } else { - if m.showRC { + if m.showBattles { + sb.WriteString(m.renderBattlesPanel(w - 2)) + } else if m.showPitWindow { + sb.WriteString(m.renderPitWindowPanel(w - 2)) + } else if m.showRC { sb.WriteString(m.renderRCContent()) } else { sb.WriteString(m.renderTimingTower(w - 2)) @@ -1083,9 +1411,9 @@ func (m OfficialLiveModel) View() string { sb.WriteString("\n") if wide { - sb.WriteString(helpBar("j/k scroll", "enter detail", "s sectors", "K/J race ctrl", "1-6 tabs", "q quit")) + sb.WriteString(helpBar("j/k scroll", "enter detail", "s sectors", "b battles", "p pit win", "K/J race ctrl", "1-7 tabs", "q quit")) } else { - sb.WriteString(helpBar("j/k scroll", "enter detail", "s sectors", "r toggle RC", "1-6 tabs", "q quit")) + sb.WriteString(helpBar("j/k scroll", "enter detail", "s sectors", "b battles", "p pit win", "r RC", "1-7 tabs", "q quit")) } return sb.String() @@ -1232,10 +1560,10 @@ func (m OfficialLiveModel) renderTimingTower(w int) string { padRight("TYRE", 5), padRight("AGE", 3), padRight("", 1), padRight("BEST", 10), padRight("LAST", 10), padRight("GAP", 10)) } else { - header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s", + header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s", padRight("P", 3), padRight("ฮ”", 2), padRight("", 1), padRight("TLA", 4), padRight("TYRE", 5), padRight("AGE", 3), padRight("LAST", 10), - padRight("GAP", 10), padRight("INT", 10)) + padRight("GAP", 10), padRight("INT", 10), padRight("TREND", 8)) } sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true).Render(header) + "\n") sb.WriteString(" " + divider(min(w-4, lipgloss.Width(header))) + "\n") @@ -1355,13 +1683,18 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool, row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render(row) } } else { - // Race mode: LAST + GAP + INT - row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s", + // Race mode: LAST + GAP + INT + TREND + trend := "" + if hist, ok := m.gapHistory[d.RacingNumber]; ok && d.Position > 1 { + trend = gapTrendIndicator(hist) + } + row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s", posStr, deltaStr, colorBar, tlaStr, tyreStr, m.renderTyreAge(d.RacingNumber), lastLap, m.renderGapStr(d, fpq), - m.renderIntvStr(d)) + m.renderIntvStr(d), + padRightVisible(trend, 8)) } if idx == m.cursor { @@ -1395,9 +1728,20 @@ func (m OfficialLiveModel) renderGapStr(d LiveDriverData, fpq bool) string { } return padRightVisible(styleLeader.Render("LEADER"), 10) } + // Try the feed-supplied gap first. if g := parseGap(d.GapToLeader); g != "" { return padRightVisible(styleGap.Render(g), 10) } + // In practice/qualifying, compute gap from best lap times when the feed + // doesn't supply one. + if fpq { + if g := computeGapFromBestLap(d.BestLapTime, m.overallBestLapTime()); g != "" { + return padRightVisible(styleGap.Render(g), 10) + } + if d.BestLapTime == "" { + return padRight(styleMuted.Render("no time"), 10) + } + } return padRight("", 10) } @@ -1429,7 +1773,7 @@ func (m OfficialLiveModel) renderTyreIndicator(num string) string { func (m OfficialLiveModel) renderTyreAge(num string) string { tyre, ok := m.tyres[num] - if !ok { + if !ok || tyre.Age == 0 { return padRight("", 3) } @@ -1525,6 +1869,20 @@ func (m OfficialLiveModel) renderRightPanel(w int) string { return sb.String() } +// renderBattlesPanel is a thin receiver wrapper around the free renderBattlePanel function. +func (m OfficialLiveModel) renderBattlesPanel(width int) string { + drivers := m.sortedDrivers() + battles := detectBattles(drivers, m.driverInfo, m.tyres, m.stints, m.gapHistory) + isRace := !m.isPracticeOrQuali() + return renderBattlePanel(battles, width, isRace) +} + +// renderPitWindowPanel is a thin receiver wrapper around the free renderPitWindowPanel function. +func (m OfficialLiveModel) renderPitWindowPanel(width int) string { + isRace := !m.isPracticeOrQuali() + return renderPitWindowPanel(m.drivers, m.driverInfo, m.session.CircuitName, isRace, width) +} + func (m OfficialLiveModel) renderDriverDetail(w int) string { num := m.expandedDriver d, ok := m.drivers[num] diff --git a/internal/ui/pitwindow.go b/internal/ui/pitwindow.go new file mode 100644 index 0000000..17ce857 --- /dev/null +++ b/internal/ui/pitwindow.go @@ -0,0 +1,307 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Pit loss table โ€” average pit lane + stationary time per circuit. +// These are sensible defaults derived from historical F1 data. +// All times are in seconds. +// --------------------------------------------------------------------------- + +// pitLossTable maps circuit short names (lowercased) to average pit loss time. +// Pit loss = pit lane traversal time delta vs staying on track at race pace. +var pitLossTable = map[string]float64{ + "monza": 17.0, + "spa": 21.0, + "silverstone": 22.0, + "monaco": 25.0, + "singapore": 30.0, + "baku": 24.0, + "montreal": 23.0, + "austin": 23.5, + "mexico": 21.0, + "interlagos": 24.0, + "suzuka": 22.5, + "bahrain": 22.0, + "jeddah": 21.5, + "melbourne": 25.0, + "shanghai": 25.0, + "miami": 24.5, + "barcelona": 22.0, + "budapest": 25.5, + "zandvoort": 24.5, + "abu dhabi": 23.0, + "las vegas": 26.0, + "imola": 25.0, + "lusail": 22.0, +} + +const defaultPitLoss = 23.0 // fallback when circuit not in table + +// pitWindowLookupLoss returns the pit loss for the current circuit name. +// Falls back to defaultPitLoss. +func pitWindowLookupLoss(circuitName string) float64 { + lower := strings.ToLower(circuitName) + for k, v := range pitLossTable { + if strings.Contains(lower, k) { + return v + } + } + return defaultPitLoss +} + +// --------------------------------------------------------------------------- +// Pit window prediction logic +// --------------------------------------------------------------------------- + +// PitPrediction describes the predicted outcome if a given driver pits now. +type PitPrediction struct { + DriverNum string + DriverTLA string + TeamColor string + PredictedP int // predicted position after rejoin + RejoinGap float64 // gap to the car they'd rejoin behind (seconds, +ve = behind) + TightestCar string // TLA of the nearest rival after rejoin + PitLoss float64 // pit stop time cost used in this calculation +} + +// computePitWindow calculates the predicted pit window for every positioned +// driver that is currently on track (not in pit, not retired). +// +// Algorithm: +// 1. For each driver D, simulate their position after a pit stop of pitLoss seconds. +// 2. For each rival R ahead of D: if gap(Dโ†’R) + pitLoss > 0, D rejoins behind R. +// 3. For each rival R behind D: if gap(Dโ†’R) - pitLoss < threshold, R may undercut D. +// 4. Return the predicted finishing position after the pit. +func computePitWindow( + drivers map[string]LiveDriverData, + driverInfo map[string]F1DriverListEntry, + pitLoss float64, +) []PitPrediction { + if len(drivers) == 0 { + return nil + } + + // Sort all on-track drivers by position + var sorted []LiveDriverData + for _, d := range drivers { + if d.Position > 0 && !d.Retired && !d.InPit { + sorted = append(sorted, d) + } + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Position < sorted[j].Position + }) + + // Build gap-to-leader map (seconds, -1 = leader) + gapToLeader := make(map[string]float64, len(sorted)) + for _, d := range sorted { + if d.Position == 1 { + gapToLeader[d.RacingNumber] = 0 + } else { + g := parseGapToFloat(d.GapToLeader) + if g < 0 { + g = 9999 // lapped car + } + gapToLeader[d.RacingNumber] = g + } + } + + var predictions []PitPrediction + + for _, d := range sorted { + dGap := gapToLeader[d.RacingNumber] + if dGap == 9999 { + continue // don't predict for lapped cars + } + + // Gap after pit stop = original gap + pitLoss (D is now pitLoss seconds further back) + gapAfterPit := dGap + pitLoss + + // Count rivals ahead that D will now be behind + predictedPos := 1 + var rejoinBehind LiveDriverData + tightestGap := 9999.0 + + for _, rival := range sorted { + if rival.RacingNumber == d.RacingNumber { + continue + } + rGap := gapToLeader[rival.RacingNumber] + if rGap == 9999 { + continue + } + // Rival is ahead if their gap < gapAfterPit + if rGap < gapAfterPit { + predictedPos++ + // Track the rival we'd rejoin closest behind + behind := gapAfterPit - rGap + if behind < tightestGap { + tightestGap = behind + rejoinBehind = rival + } + } + } + + // Team color + teamColor := colorMuted + tla := d.RacingNumber + if info, ok := driverInfo[d.RacingNumber]; ok { + if info.Tla != "" { + tla = info.Tla + } + if info.TeamColour != "" { + teamColor = "#" + info.TeamColour + } else if info.TeamName != "" { + teamColor = teamColorFromName(info.TeamName) + } + } + + tightestTLA := "" + if rejoinBehind.RacingNumber != "" { + if info, ok := driverInfo[rejoinBehind.RacingNumber]; ok && info.Tla != "" { + tightestTLA = info.Tla + } else { + tightestTLA = rejoinBehind.RacingNumber + } + } + + rejoinGap := 0.0 + if tightestGap < 9999 { + rejoinGap = tightestGap + } + + predictions = append(predictions, PitPrediction{ + DriverNum: d.RacingNumber, + DriverTLA: tla, + TeamColor: teamColor, + PredictedP: predictedPos, + RejoinGap: rejoinGap, + TightestCar: tightestTLA, + PitLoss: pitLoss, + }) + } + + // Sort predictions by current position (same as sorted) + sort.Slice(predictions, func(i, j int) bool { + di, _ := drivers[predictions[i].DriverNum] + dj, _ := drivers[predictions[j].DriverNum] + return di.Position < dj.Position + }) + + return predictions +} + +// --------------------------------------------------------------------------- +// Pit window view renderer +// --------------------------------------------------------------------------- + +// renderPitWindowPanel renders the full pit window calculator panel. +func renderPitWindowPanel( + drivers map[string]LiveDriverData, + driverInfo map[string]F1DriverListEntry, + circuitName string, + isRace bool, + width int, +) string { + var sb strings.Builder + + pitLoss := pitWindowLookupLoss(circuitName) + + title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorF1Red)). + Render("๐Ÿ”ง PIT WINDOW CALCULATOR") + meta := styleMuted.Render(fmt.Sprintf("circuit: %s pit loss: %.1fs", + circuitNameShort(circuitName), pitLoss)) + + sb.WriteString(" " + title + "\n") + sb.WriteString(" " + meta + "\n") + sb.WriteString(" " + divider(min(width-4, 70)) + "\n") + + if !isRace { + sb.WriteString("\n" + styleMuted.Render(" Pit window calculator is only available during Race sessions.\n")) + return sb.String() + } + + if len(drivers) == 0 { + sb.WriteString("\n" + styleMuted.Render(" Waiting for timing data...\n")) + return sb.String() + } + + predictions := computePitWindow(drivers, driverInfo, pitLoss) + if len(predictions) == 0 { + sb.WriteString("\n" + styleMuted.Render(" Not enough timing data to compute pit window.\n")) + return sb.String() + } + + // Table header + sb.WriteString(styleMuted.Render(fmt.Sprintf(" %-4s %-4s %-6s %-6s %s\n", + "NOW", "TLA", "โ†’ P", "GAP", "REJOINS BEHIND"))) + sb.WriteString(" " + divider(min(width-4, 55)) + "\n") + + for _, p := range predictions { + d := drivers[p.DriverNum] + curPosStr := renderPosition(d.Position) + predPosStr := renderPosition(p.PredictedP) + + tlaStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(p.TeamColor)) + tlaRendered := tlaStyle.Render(padRight(p.DriverTLA, 4)) + + // Position change indicator + diff := d.Position - p.PredictedP // negative = losing positions + var posChange string + switch { + case diff > 0: + posChange = styleDeltaUp.Render(fmt.Sprintf("โ–ฒ%d", diff)) + case diff < 0: + posChange = styleDeltaDown.Render(fmt.Sprintf("โ–ผ%d", -diff)) + default: + posChange = styleDeltaEqual.Render("โ”€") + } + + // Rejoin gap + gapStr := styleMuted.Render(" -") + if p.RejoinGap > 0 { + gapStr = styleGap.Render(fmt.Sprintf("+%.1fs", p.RejoinGap)) + } + + // Rejoin target + rejoinStr := "" + if p.TightestCar != "" { + rejoinStr = styleMuted.Render("behind ") + styleBold.Render(p.TightestCar) + } else if p.PredictedP == 1 { + rejoinStr = styleLeader.Render("LEADS") + } + + sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s\n", + padRightVisible(curPosStr, 4), + tlaRendered, + padRightVisible(predPosStr, 2), + padRightVisible(posChange, 3), + padRightVisible(gapStr, 7), + rejoinStr, + )) + } + + sb.WriteString("\n") + sb.WriteString(styleMuted.Render(fmt.Sprintf(" Assumes %.1fs pit loss. Gaps are approximate.", pitLoss))) + sb.WriteString("\n") + + return sb.String() +} + +// circuitNameShort returns a display-friendly short name for a circuit. +func circuitNameShort(name string) string { + if name == "" { + return "Unknown" + } + if len(name) > 20 { + return name[:20] + "โ€ฆ" + } + return name +} diff --git a/internal/ui/racedetail.go b/internal/ui/racedetail.go index a69e637..0af8315 100644 --- a/internal/ui/racedetail.go +++ b/internal/ui/racedetail.go @@ -17,12 +17,13 @@ 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 - overtakes []models.Overtake + sessions []models.Session + results []models.SessionResult + drivers map[int]models.Driver + startingGrid []models.StartingGrid + rcMsgs []models.RaceControl + weather []models.Weather + overtakes []models.Overtake selectedSession *models.Session sessionCursor int @@ -40,6 +41,8 @@ type RaceDetailModel struct { rcView viewport.Model rcReady bool + replay ReplayModel + width int height int } @@ -53,6 +56,7 @@ func NewRaceDetailModel(client *api.OpenF1Client) RaceDetailModel { client: client, spinner: s, drivers: make(map[int]models.Driver), + replay: NewReplayModel(client), } } @@ -82,9 +86,9 @@ func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd { ) } -// fetchSecondaryData fetches lower-priority data (race control, weather, overtakes). -func fetchSecondaryData(client *api.OpenF1Client, sessionKey int) tea.Cmd { - return tea.Batch( +// fetchSecondaryData fetches lower-priority data (race control, weather, overtakes, starting grid). +func fetchSecondaryData(client *api.OpenF1Client, sessionKey int, isRace bool) tea.Cmd { + cmds := []tea.Cmd{ func() tea.Msg { msgs, err := client.GetRaceControl(sessionKey) return raceControlLoadedMsg{messages: msgs, err: err} @@ -97,7 +101,15 @@ func fetchSecondaryData(client *api.OpenF1Client, sessionKey int) tea.Cmd { overtakes, err := client.GetOvertakesForSession(sessionKey) return overtakesLoadedMsg{overtakes: overtakes, err: err} }, - ) + } + // Starting grid is only relevant for Race sessions โ€” shows qualifying order + if isRace { + cmds = append(cmds, func() tea.Msg { + grid, err := client.GetStartingGrid(sessionKey) + return startingGridLoadedMsg{grid: grid, err: err} + }) + } + return tea.Batch(cmds...) } func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { @@ -113,11 +125,21 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) } + // Also forward to replay spinner + var cmd tea.Cmd + m.replay, cmd = m.replay.Update(msg) + cmds = append(cmds, cmd) + + case replayDataLoadedMsg: + var cmd tea.Cmd + m.replay, cmd = m.replay.Update(msg) + cmds = append(cmds, cmd) case meetingSelectedMsg: m.meeting = &msg.meeting m.sessions = nil m.results = nil + m.startingGrid = nil m.rcMsgs = nil m.weather = nil m.overtakes = nil @@ -168,6 +190,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.driversLoaded = false m.secondaryLoading = false m.results = nil + m.startingGrid = nil m.rcMsgs = nil m.weather = nil m.overtakes = nil @@ -202,7 +225,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { case loadSecondaryDataMsg: // Only load secondary data if it's still for the currently selected session if m.selectedSession != nil && m.selectedSession.SessionKey == msg.sessionKey { - cmds = append(cmds, fetchSecondaryData(m.client, msg.sessionKey)) + isRace := m.selectedSession.SessionType == "Race" + cmds = append(cmds, fetchSecondaryData(m.client, msg.sessionKey, isRace)) } case raceControlLoadedMsg: @@ -221,7 +245,24 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.overtakes = msg.overtakes } + case startingGridLoadedMsg: + if msg.err == nil { + m.startingGrid = msg.grid + } + case tea.KeyMsg: + // When replay is active, route keys to replay model; only 'b'/'esc' exits. + if m.replay.IsActive() { + if matchKey(msg, GlobalKeys.Back) { + m.replay = m.replay.Exit() + return m, nil + } + var cmd tea.Cmd + m.replay, cmd = m.replay.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + switch { case matchKey(msg, GlobalKeys.Retry): if m.errSessions != nil && m.meeting != nil { @@ -282,6 +323,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.secondaryLoading = false m.errResults = nil m.results = nil + m.startingGrid = nil m.rcMsgs = nil m.weather = nil m.overtakes = nil @@ -305,6 +347,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.secondaryLoading = false m.errResults = nil m.results = nil + m.startingGrid = nil m.rcMsgs = nil m.weather = nil m.overtakes = nil @@ -325,6 +368,7 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { m.secondaryLoading = false m.errResults = nil m.results = nil + m.startingGrid = nil m.rcMsgs = nil m.weather = nil m.overtakes = nil @@ -334,6 +378,15 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) { cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey)) } } + case matchKey(msg, RaceDetailKeys.Replay): + // Only enter replay for Race sessions with data loaded + if m.selectedSession != nil && + m.selectedSession.SessionType == "Race" && + !m.loadingResults { + var cmd tea.Cmd + m.replay, cmd = m.replay.Enter(m.selectedSession.SessionKey, m.selectedSession.SessionName) + cmds = append(cmds, cmd) + } } } @@ -399,6 +452,14 @@ func (m RaceDetailModel) View() string { helpBar("2 calendar", "q quit") } + // When replay is active, show the replay pane instead of normal race detail. + if m.replay.IsActive() { + m2 := m.replay + m2.width = m.width + m2.height = m.height + return m2.View() + } + w := m.width if w < 40 { w = 40 @@ -436,6 +497,10 @@ func (m RaceDetailModel) View() string { sb.WriteString("\n") sb.WriteString(m.renderWeatherCard(w - 4)) sb.WriteString("\n") + if len(m.startingGrid) > 0 { + sb.WriteString(m.renderStartingGrid(w - 4)) + sb.WriteString("\n") + } sb.WriteString(m.renderOvertakes(w - 4)) sb.WriteString("\n") sb.WriteString(styleSectionTitle.Render("RACE CONTROL") + "\n") @@ -469,7 +534,7 @@ func (m RaceDetailModel) View() string { sb.WriteString(panels + "\n") } - sb.WriteString(helpBar("[/] sessions", "j/k results", "g/G top/bottom", "K/J scroll RC", "b back", "q quit")) + sb.WriteString(helpBar("[/] sessions", "j/k results", "g/G top/bottom", "K/J scroll RC", "r replay", "b back", "q quit")) return sb.String() } @@ -684,6 +749,12 @@ func (m RaceDetailModel) renderRightPanel(width int) string { sb.WriteString(m.renderWeatherCard(width)) sb.WriteString("\n") + // Starting grid (Race sessions only) + if len(m.startingGrid) > 0 { + sb.WriteString(m.renderStartingGrid(width)) + sb.WriteString("\n") + } + // Overtakes summary sb.WriteString(m.renderOvertakes(width)) sb.WriteString("\n") @@ -922,3 +993,74 @@ func (m RaceDetailModel) renderOvertakes(width int) string { return sb.String() } + +// renderStartingGrid renders the qualifying-based starting grid for a Race session. +func (m RaceDetailModel) renderStartingGrid(width int) string { + var sb strings.Builder + sb.WriteString(styleSectionTitle.Render("STARTING GRID") + "\n") + + if len(m.startingGrid) == 0 { + sb.WriteString(styleMuted.Render(" No grid data.\n")) + return sb.String() + } + + // Find the pole time to compute deltas + var poleTime float64 + for _, g := range m.startingGrid { + if g.Position == 1 { + poleTime = g.LapDuration + break + } + } + + // Show top entries (limit to keep the panel compact) + maxShow := 10 + if len(m.startingGrid) < maxShow { + maxShow = len(m.startingGrid) + } + + for i := 0; i < maxShow; i++ { + g := m.startingGrid[i] + d := m.drivers[g.DriverNumber] + acronym := d.NameAcronym + if acronym == "" { + acronym = fmt.Sprintf("#%d", g.DriverNumber) + } + + teamColor := colorMuted + if d.TeamColour != "" { + teamColor = "#" + d.TeamColour + } else if d.TeamName != "" { + teamColor = teamColorFromName(d.TeamName) + } + + colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("โ”ƒ") + pos := renderPosition(g.Position) + + // Qualifying time or delta to pole + var timeStr string + if g.LapDuration <= 0 { + timeStr = styleMuted.Render("no time") + } else if g.Position == 1 { + timeStr = lipgloss.NewStyle().Foreground(lipgloss.Color(colorPurple)).Bold(true).Render(formatSeconds(g.LapDuration)) + } else if poleTime > 0 { + delta := g.LapDuration - poleTime + timeStr = styleGap.Render(fmt.Sprintf("+%.3fs", delta)) + } else { + timeStr = formatSeconds(g.LapDuration) + } + + sb.WriteString(fmt.Sprintf(" %s %s %s %s\n", + padRightVisible(pos, 3), + colorBar, + padRight(acronym, 4), + timeStr, + )) + } + + if len(m.startingGrid) > maxShow { + sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... %d more\n", len(m.startingGrid)-maxShow))) + } + + return sb.String() +} diff --git a/internal/ui/replay.go b/internal/ui/replay.go new file mode 100644 index 0000000..9a14b6f --- /dev/null +++ b/internal/ui/replay.go @@ -0,0 +1,568 @@ +package ui + +import ( + "fmt" + "sort" + "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" +) + +// --------------------------------------------------------------------------- +// Replay data structures +// --------------------------------------------------------------------------- + +// ReplayLapSnapshot holds a full field snapshot at the end of a given lap. +type ReplayLapSnapshot struct { + LapNumber int + // Positions: driver number โ†’ position at end of this lap + Positions map[int]int + // GapsToLeader: driver number โ†’ gap to leader in seconds (-1 = leader/lap down) + GapsToLeader map[int]float64 + // PitsThisLap: driver numbers who pitted on this lap + PitsThisLap []int + // PitDurations: driver number โ†’ stop duration for this lap (0 if no pit) + PitDurations map[int]float64 + // RCMessages this lap + RCMessages []models.RaceControl + // Weather snapshot (last reading before/at this lap) + Weather *models.Weather + // LapTimes: driver number โ†’ lap duration this lap (0 if unknown) + LapTimes map[int]float64 +} + +// ReplayData is the full pre-processed replay dataset for a race session. +type ReplayData struct { + SessionKey int + SessionName string + TotalLaps int + Drivers map[int]models.Driver // driver number โ†’ Driver + Snapshots []ReplayLapSnapshot // index 0 = lap 1 +} + +// --------------------------------------------------------------------------- +// Message types +// --------------------------------------------------------------------------- + +// replayLoadMsg is sent when the user presses `r` on a race session to trigger +// a lazy data load. +type replayLoadMsg struct { + sessionKey int + sessionName string +} + +// replayDataLoadedMsg is the async response with the fully processed ReplayData. +type replayDataLoadedMsg struct { + data *ReplayData + err error +} + +// --------------------------------------------------------------------------- +// Async fetch command +// --------------------------------------------------------------------------- + +func fetchReplayData(client *api.OpenF1Client, sessionKey int, sessionName string) tea.Cmd { + return func() tea.Msg { + data, err := buildReplayData(client, sessionKey, sessionName) + return replayDataLoadedMsg{data: data, err: err} + } +} + +func buildReplayData(client *api.OpenF1Client, sessionKey int, sessionName string) (*ReplayData, error) { + // Fetch all required data concurrently via goroutines with a simple fan-in. + type result struct { + tag string + val interface{} + err error + } + ch := make(chan result, 5) + + go func() { + v, err := client.GetDriversForSession(sessionKey) + ch <- result{"drivers", v, err} + }() + go func() { + v, err := client.GetPositions(sessionKey, 0) + ch <- result{"positions", v, err} + }() + go func() { + v, err := client.GetLapsForSession(sessionKey) + ch <- result{"laps", v, err} + }() + go func() { + v, err := client.GetPitStopsForSession(sessionKey) + ch <- result{"pits", v, err} + }() + go func() { + v, err := client.GetRaceControl(sessionKey) + ch <- result{"rc", v, err} + }() + + var ( + driverList []models.Driver + positions []models.Position + laps []models.Lap + pits []models.Pit + rcMsgs []models.RaceControl + ) + + for i := 0; i < 5; i++ { + r := <-ch + if r.err != nil { + return nil, fmt.Errorf("replay fetch %s: %w", r.tag, r.err) + } + switch r.tag { + case "drivers": + driverList = r.val.([]models.Driver) + case "positions": + positions = r.val.([]models.Position) + case "laps": + laps = r.val.([]models.Lap) + case "pits": + pits = r.val.([]models.Pit) + case "rc": + rcMsgs = r.val.([]models.RaceControl) + } + } + + // Build driver map + drivers := make(map[int]models.Driver, len(driverList)) + for _, d := range driverList { + drivers[d.DriverNumber] = d + } + + // Determine total laps from lap data + totalLaps := 0 + for _, l := range laps { + if l.LapNumber > totalLaps { + totalLaps = l.LapNumber + } + } + if totalLaps == 0 { + totalLaps = 1 + } + + // Build per-lap position snapshots from the position stream. + // The position stream provides the position of each driver at timestamps. + // We bucket positions by lap number: for each driver, their position at + // the end of each lap is the last recorded position entry whose timestamp + // falls before or at the next lap's start timestamp. + // + // Strategy: parse lap start times per driver, then for each lap find the + // latest position reading before that driver's next lap start. + + // lap start times: driverNum โ†’ lapNum โ†’ DateStart + lapStartByDriver := make(map[int]map[int]time.Time) + for _, l := range laps { + if _, ok := lapStartByDriver[l.DriverNumber]; !ok { + lapStartByDriver[l.DriverNumber] = make(map[int]time.Time) + } + t, err := time.Parse(time.RFC3339, l.DateStart) + if err == nil { + lapStartByDriver[l.DriverNumber][l.LapNumber] = t.UTC() + } + } + + // Sort position stream per driver by time + type posEntry struct { + t time.Time + pos int + } + posByDriver := make(map[int][]posEntry) + for _, p := range positions { + t, err := time.Parse(time.RFC3339, p.Date) + if err != nil { + continue + } + posByDriver[p.DriverNumber] = append(posByDriver[p.DriverNumber], posEntry{t.UTC(), p.Position}) + } + for dn := range posByDriver { + sort.Slice(posByDriver[dn], func(i, j int) bool { + return posByDriver[dn][i].t.Before(posByDriver[dn][j].t) + }) + } + + // Lap time per driver per lap + lapTimeMap := make(map[int]map[int]float64) // driverNum โ†’ lapNum โ†’ seconds + for _, l := range laps { + if l.LapDuration == nil || *l.LapDuration <= 0 || l.IsPitOutLap { + continue + } + if _, ok := lapTimeMap[l.DriverNumber]; !ok { + lapTimeMap[l.DriverNumber] = make(map[int]float64) + } + lapTimeMap[l.DriverNumber][l.LapNumber] = *l.LapDuration + } + + // Pit stop map: driverNum โ†’ lapNum โ†’ stop duration + pitMap := make(map[int]map[int]float64) + for _, p := range pits { + if _, ok := pitMap[p.DriverNumber]; !ok { + pitMap[p.DriverNumber] = make(map[int]float64) + } + dur := p.StopDuration + if dur == 0 { + dur = p.PitDuration + } + pitMap[p.DriverNumber][p.LapNumber] = dur + } + + // RC messages per lap + rcByLap := make(map[int][]models.RaceControl) + for _, rc := range rcMsgs { + lap := 0 + if rc.LapNumber != nil { + lap = *rc.LapNumber + } + rcByLap[lap] = append(rcByLap[lap], rc) + } + + // Build snapshots for each lap + snapshots := make([]ReplayLapSnapshot, totalLaps) + for lapIdx := 0; lapIdx < totalLaps; lapIdx++ { + lapNum := lapIdx + 1 + snap := ReplayLapSnapshot{ + LapNumber: lapNum, + Positions: make(map[int]int), + GapsToLeader: make(map[int]float64), + PitDurations: make(map[int]float64), + LapTimes: make(map[int]float64), + } + + // Get position of each driver at end of this lap. + // Use the last position reading before (lapNum+1)'s start time for each driver. + for dn, entries := range posByDriver { + // Find the upper bound time: start of next lap for this driver + var upperBound time.Time + if nextStart, ok := lapStartByDriver[dn][lapNum+1]; ok { + upperBound = nextStart + } else if lapStart, ok := lapStartByDriver[dn][lapNum]; ok { + // No next lap start โ€” use current lap start + 2 min as safety bound + upperBound = lapStart.Add(2 * time.Minute) + } else { + // No timing at all, use last entry + upperBound = time.Now() + } + + // Binary search for last entry before upperBound + lastPos := 0 + for _, e := range entries { + if e.t.Before(upperBound) { + lastPos = e.pos + } + } + if lastPos > 0 { + snap.Positions[dn] = lastPos + } + } + + // Compute approximate gaps to leader from positions. + // We derive gap from the accumulated lap-time differences โ€” a rough but + // useful reconstruction. Set leader gap = 0, others accumulate based on + // position ordering relative to leader average lap pace. + // For simplicity we store 0 = leader. + for dn := range snap.Positions { + snap.GapsToLeader[dn] = -1 // will be filled per position order + } + + // Pits this lap + for dn, lapPits := range pitMap { + if dur, ok := lapPits[lapNum]; ok { + snap.PitsThisLap = append(snap.PitsThisLap, dn) + snap.PitDurations[dn] = dur + } + } + sort.Ints(snap.PitsThisLap) + + // Lap times this lap + for dn, lapTimes := range lapTimeMap { + if lt, ok := lapTimes[lapNum]; ok { + snap.LapTimes[dn] = lt + } + } + + // RC messages this lap + snap.RCMessages = rcByLap[lapNum] + + snapshots[lapIdx] = snap + } + + return &ReplayData{ + SessionKey: sessionKey, + SessionName: sessionName, + TotalLaps: totalLaps, + Drivers: drivers, + Snapshots: snapshots, + }, nil +} + +// --------------------------------------------------------------------------- +// ReplayModel โ€” Bubble Tea model for the replay UI +// --------------------------------------------------------------------------- + +// ReplayState tracks what mode the race-detail tab's replay sub-component is in. +type ReplayState int + +const ( + ReplayStateInactive ReplayState = iota // not in replay mode + ReplayStateLoading // data fetch in flight + ReplayStateActive // showing replay +) + +type ReplayModel struct { + client *api.OpenF1Client + state ReplayState + data *ReplayData + err error + spinner spinner.Model + + // Current scrub position (0-indexed into data.Snapshots) + cursor int + // Number of visible rows in content area + height int + width int +} + +func NewReplayModel(client *api.OpenF1Client) ReplayModel { + sp := spinner.New() + sp.Spinner = spinner.Points + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) + return ReplayModel{ + client: client, + state: ReplayStateInactive, + spinner: sp, + } +} + +// IsActive returns true when the replay pane is active (loading or showing). +func (m ReplayModel) IsActive() bool { + return m.state != ReplayStateInactive +} + +// Enter triggers a data load for the given race session. +func (m ReplayModel) Enter(sessionKey int, sessionName string) (ReplayModel, tea.Cmd) { + m.state = ReplayStateLoading + m.data = nil + m.err = nil + m.cursor = 0 + return m, tea.Batch( + fetchReplayData(m.client, sessionKey, sessionName), + m.spinner.Tick, + ) +} + +// Exit resets replay to inactive. +func (m ReplayModel) Exit() ReplayModel { + m.state = ReplayStateInactive + m.data = nil + m.err = nil + return m +} + +func (m ReplayModel) Update(msg tea.Msg) (ReplayModel, tea.Cmd) { + switch msg := msg.(type) { + case spinner.TickMsg: + if m.state == ReplayStateLoading { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + case replayDataLoadedMsg: + if msg.err != nil { + m.err = msg.err + m.state = ReplayStateActive // show error in active state + return m, nil + } + m.data = msg.data + m.state = ReplayStateActive + // Start at lap 1 + m.cursor = 0 + return m, nil + + case tea.KeyMsg: + if m.state != ReplayStateActive || m.data == nil { + return m, nil + } + switch { + case matchKey(msg, replayKeyLeft): + if m.cursor > 0 { + m.cursor-- + } + case matchKey(msg, replayKeyRight): + if m.cursor < m.data.TotalLaps-1 { + m.cursor++ + } + case matchKey(msg, replayKeyStart): + m.cursor = 0 + case matchKey(msg, replayKeyEnd): + if m.data.TotalLaps > 0 { + m.cursor = m.data.TotalLaps - 1 + } + } + } + return m, nil +} + +// replayKey helpers โ€” local bindings for the replay scrubber. +var ( + replayKeyLeft = mustNewBinding("left", "h") + replayKeyRight = mustNewBinding("right", "l") + replayKeyStart = mustNewBinding("g", "home") + replayKeyEnd = mustNewBinding("G", "end") +) + +// simpleBinding is a minimal implementation of the Keys() interface. +type simpleBinding struct{ keys []string } + +func (s simpleBinding) Keys() []string { return s.keys } + +func mustNewBinding(keys ...string) simpleBinding { + return simpleBinding{keys: keys} +} + +// View renders the replay pane for embedding into the RaceDetail view. +func (m ReplayModel) View() string { + switch m.state { + case ReplayStateInactive: + return "" + case ReplayStateLoading: + return fmt.Sprintf("\n %s Loading replay data...\n", m.spinner.View()) + case ReplayStateActive: + if m.err != nil { + return renderErrorView(m.err) + } + if m.data == nil { + return styleMuted.Render("\n No replay data.\n") + } + return m.renderReplay() + } + return "" +} + +func (m ReplayModel) renderReplay() string { + var sb strings.Builder + data := m.data + + if m.cursor >= len(data.Snapshots) { + return styleMuted.Render("\n No snapshot data for this lap.\n") + } + snap := data.Snapshots[m.cursor] + + // โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorF1Red)). + Render("โช RACE REPLAY") + lapBadge := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorYellow)). + Render(fmt.Sprintf("LAP %d / %d", snap.LapNumber, data.TotalLaps)) + + // Progress bar + barWidth := 24 + filled := 0 + if data.TotalLaps > 0 { + filled = int(float64(snap.LapNumber) / float64(data.TotalLaps) * float64(barWidth)) + } + if filled > barWidth { + filled = barWidth + } + bar := stylePointsBarFilled.Render(strings.Repeat("โ–ˆ", filled)) + + stylePointsBarEmpty.Render(strings.Repeat("โ–‘", barWidth-filled)) + + sb.WriteString(fmt.Sprintf("\n %s %s %s\n", title, lapBadge, bar)) + sb.WriteString(" " + divider(min(m.width-4, 72)) + "\n") + + // โ”€โ”€ Field snapshot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Sort drivers by position at this lap + type driverPos struct { + driverNum int + pos int + } + var sorted []driverPos + for dn, pos := range snap.Positions { + sorted = append(sorted, driverPos{dn, pos}) + } + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].pos < sorted[j].pos + }) + + // Table header + sb.WriteString(styleMuted.Render(fmt.Sprintf(" %-4s %-4s %-18s %-12s %s\n", + "POS", "NO", "DRIVER", "TIME", "PIT"))) + sb.WriteString(" " + divider(min(m.width-4, 60)) + "\n") + + for _, dp := range sorted { + d, ok := data.Drivers[dp.driverNum] + name := fmt.Sprintf("#%d", dp.driverNum) + teamColor := colorMuted + if ok { + name = d.NameAcronym + if d.TeamColour != "" { + teamColor = "#" + d.TeamColour + } else { + teamColor = teamColorFromName(d.TeamName) + } + } + + colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("โ”ƒ") + nameStyled := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Bold(true).Render(padRight(name, 4)) + posStyled := renderPosition(dp.pos) + + // Lap time + ltStr := styleMuted.Render(" --") + if lt, ok := snap.LapTimes[dp.driverNum]; ok && lt > 0 { + ltStr = padRight(formatSeconds(lt), 12) + } + + // Pit this lap + pitStr := "" + if dur, ok := snap.PitDurations[dp.driverNum]; ok && dur > 0 { + pitStr = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Bold(true). + Render(fmt.Sprintf("PIT %.1fs", dur)) + } + + sb.WriteString(fmt.Sprintf(" %s %s %s %s %s\n", + padRightVisible(posStyled, 4), + colorBar, + nameStyled, + ltStr, + pitStr, + )) + } + + // โ”€โ”€ RC messages this lap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if len(snap.RCMessages) > 0 { + sb.WriteString("\n " + styleSectionTitle.Render("RACE CONTROL") + "\n") + for _, rc := range snap.RCMessages { + icon := " " + var flagStyle lipgloss.Style + switch rc.Flag { + case "GREEN": + icon, flagStyle = "๐ŸŸข", styleFlagGreen + case "YELLOW", "DOUBLE YELLOW": + icon, flagStyle = "๐ŸŸก", styleFlagYellow + case "RED": + icon, flagStyle = "๐Ÿ”ด", styleFlagRed + case "BLUE": + icon, flagStyle = "๐Ÿ”ต", styleFlagBlue + case "CHEQUERED": + icon = "๐Ÿ" + flagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true) + default: + flagStyle = styleMuted + } + sb.WriteString(fmt.Sprintf(" %s%s\n", + flagStyle.Render(icon+" "), + rc.Message)) + } + } + + // โ”€โ”€ Help bar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + sb.WriteString("\n") + sb.WriteString(helpBar("โ†/h prev lap", "โ†’/l next lap", "g first", "G last", "b back to race")) + + return sb.String() +} diff --git a/internal/ui/trackmap.go b/internal/ui/trackmap.go new file mode 100644 index 0000000..4cf5713 --- /dev/null +++ b/internal/ui/trackmap.go @@ -0,0 +1,599 @@ +package ui + +import ( + "fmt" + "math" + "sort" + "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" +) + +// --------------------------------------------------------------------------- +// Track Map model +// --------------------------------------------------------------------------- + +// trackPoint is a normalized (col, row) point in the terminal canvas. +type trackPoint struct { + col, row int +} + +// TrackMapModel renders an ASCII track outline with live car positions. +type TrackMapModel struct { + client *api.OpenF1Client + + width int + height int + + // Resolved session key used to fetch location data + sessionKey int + + // Track outline (normalized points from driver 1's path) + outline []trackPoint + // Bounds of the raw coordinate space (filled during normalization) + rawMinX, rawMaxX float64 + rawMinY, rawMaxY float64 + + // Car positions: driver number โ†’ latest location + carPositions map[int]models.Location + // Driver info from OfficialLiveModel (injected on each render) + driverInfo map[string]F1DriverListEntry + + // State machine + loadingSession bool // true while resolving the active session key + loadingOutline bool + loadingCars bool + outlineReady bool + err error + + spinner spinner.Model +} + +func NewTrackMapModel(client *api.OpenF1Client) TrackMapModel { + sp := spinner.New() + sp.Spinner = spinner.Points + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) + return TrackMapModel{ + client: client, + spinner: sp, + carPositions: make(map[int]models.Location), + driverInfo: make(map[string]F1DriverListEntry), + } +} + +// HasSession returns true if a session key is already set. +func (m TrackMapModel) HasSession() bool { + return m.sessionKey != 0 +} + +// FetchActiveSession fetches the currently active session from OpenF1 and sets it. +func (m *TrackMapModel) FetchActiveSession(client *api.OpenF1Client) (TrackMapModel, tea.Cmd) { + year := time.Now().Year() + m.loadingSession = true + m.err = nil + cmd := tea.Batch(m.fetchActiveSession(client, year), m.spinner.Tick) + return *m, cmd +} + +func (m *TrackMapModel) fetchActiveSession(client *api.OpenF1Client, year int) tea.Cmd { + return func() tea.Msg { + meetings, err := client.GetMeetingsForYear(year) + if err != nil { + return trackOutlineLoadedMsg{err: err} + } + + now := time.Now() + var currentMtg *models.Meeting + for i := range meetings { + end, _ := time.Parse(time.RFC3339, meetings[i].DateEnd) + if now.Before(end.Local()) || now.Sub(end.Local()) < 24*time.Hour { + currentMtg = &meetings[i] + break + } + } + + if currentMtg == nil { + return trackOutlineLoadedMsg{err: fmt.Errorf("no active weekend found")} + } + + sessions, err := client.GetSessionsForMeeting(int(currentMtg.MeetingKey)) + if err != nil { + return trackOutlineLoadedMsg{err: err} + } + + var activeSess *models.Session + for i := range sessions { + st, _ := time.Parse(time.RFC3339, sessions[i].DateStart) + en, _ := time.Parse(time.RFC3339, sessions[i].DateEnd) + if now.After(st.Local()) && now.Before(en.Local().Add(2*time.Hour)) { + activeSess = &sessions[i] + } + } + + if activeSess == nil && len(sessions) > 0 { + activeSess = &sessions[len(sessions)-1] + } + + if activeSess == nil { + return trackOutlineLoadedMsg{err: fmt.Errorf("no active session found")} + } + + return sessionKeyMsg{sessionKey: activeSess.SessionKey} + } +} + +type sessionKeyMsg struct { + sessionKey int +} + +// --------------------------------------------------------------------------- +// Message types +// --------------------------------------------------------------------------- + +type trackOutlineLoadedMsg struct { + locations []models.Location + err error +} + +type trackCarsLoadedMsg struct { + locations []models.Location + err error +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +// fetchTrackOutline downloads location data for a single reference driver +// (driver 1 by convention, then any driver if 1 is absent) to build the +// track outline for the given session. +func fetchTrackOutline(client *api.OpenF1Client, sessionKey int) tea.Cmd { + return func() tea.Msg { + // Try a set of likely driver numbers to find one with location data. + candidates := []int{1, 11, 44, 16, 55, 4, 14, 63, 81, 24} + for _, dn := range candidates { + locs, err := client.GetLocation(sessionKey, dn) + if err == nil && len(locs) > 50 { + return trackOutlineLoadedMsg{locations: locs} + } + } + return trackOutlineLoadedMsg{err: fmt.Errorf("no location data available for session %d", sessionKey)} + } +} + +// fetchAllCarPositions downloads the most recent location for every driver +// (using the live session key stored in the model). We fetch all 20 drivers +// concurrently and keep only the last location per driver. +func fetchAllCarPositions(client *api.OpenF1Client, sessionKey int) tea.Cmd { + return func() tea.Msg { + locs, err := client.GetLocation(sessionKey, 0) + if err != nil { + return trackCarsLoadedMsg{err: err} + } + return trackCarsLoadedMsg{locations: locs} + } +} + +// tickTrackMap schedules a periodic car-position refresh. +func tickTrackMap() tea.Cmd { + return tea.Tick(5*time.Second, func(_ time.Time) tea.Msg { + return trackMapTickMsg{} + }) +} + +type trackMapTickMsg struct{} + +// --------------------------------------------------------------------------- +// Init / Update / View +// --------------------------------------------------------------------------- + +func (m TrackMapModel) Init() tea.Cmd { + return m.spinner.Tick +} + +// SetSessionKey wires the track map to a specific session. If the session +// differs from the one already loaded, it triggers a fresh outline fetch. +func (m TrackMapModel) SetSessionKey(sessionKey int) (TrackMapModel, tea.Cmd) { + if sessionKey == m.sessionKey && m.outlineReady { + return m, nil + } + m.sessionKey = sessionKey + m.loadingOutline = true + m.outlineReady = false + m.outline = nil + m.carPositions = make(map[int]models.Location) + m.err = nil + return m, tea.Batch(fetchTrackOutline(m.client, sessionKey), m.spinner.Tick) +} + +// InjectDriverInfo forwards the latest DriverInfo map from OfficialLiveModel +// so car markers can be team-coloured. +func (m *TrackMapModel) InjectDriverInfo(info map[string]F1DriverListEntry) { + m.driverInfo = info +} + +func (m TrackMapModel) Update(msg tea.Msg) (TrackMapModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case spinner.TickMsg: + if m.loadingSession || m.loadingOutline || m.loadingCars { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + case trackOutlineLoadedMsg: + m.loadingSession = false + m.loadingOutline = false + if msg.err != nil { + m.err = msg.err + return m, nil + } + m.buildOutline(msg.locations) + m.outlineReady = true + // Start fetching car positions + m.loadingCars = true + return m, tea.Batch(fetchAllCarPositions(m.client, m.sessionKey), tickTrackMap()) + + case trackCarsLoadedMsg: + m.loadingCars = false + if msg.err != nil { + // Soft error โ€” keep the outline, show empty cars + return m, nil + } + // Keep only the latest location per driver + latest := make(map[int]models.Location) + for _, loc := range msg.locations { + existing, ok := latest[loc.DriverNumber] + if !ok || loc.Date > existing.Date { + latest[loc.DriverNumber] = loc + } + } + m.carPositions = latest + return m, nil + + case sessionKeyMsg: + m.loadingSession = false + if msg.sessionKey != m.sessionKey { + m.sessionKey = msg.sessionKey + m.loadingOutline = true + m.outlineReady = false + m.outline = nil + m.carPositions = make(map[int]models.Location) + m.err = nil + return m, tea.Batch(fetchTrackOutline(m.client, msg.sessionKey), m.spinner.Tick) + } + return m, nil + + case trackMapTickMsg: + if m.outlineReady && m.sessionKey != 0 { + m.loadingCars = true + return m, fetchAllCarPositions(m.client, m.sessionKey) + } + return m, tickTrackMap() + + case wsDataMsg: + // When live WS data arrives, update driver info if we can. + // (TrackMapModel.InjectDriverInfo is called from app.go on each wsDataMsg) + return m, nil + } + return m, nil +} + +func (m TrackMapModel) View() string { + if m.loadingSession { + return fmt.Sprintf("\n %s Resolving active session...\n", m.spinner.View()) + } + if m.loadingOutline { + return fmt.Sprintf("\n %s Building track outline...\n", m.spinner.View()) + } + if m.err != nil { + return renderErrorView(m.err) + } + if !m.outlineReady { + return "\n " + styleMuted.Render("No active session found. Press 7 again once a session is underway.") + "\n" + } + return m.renderMap() +} + +// --------------------------------------------------------------------------- +// Track outline builder +// --------------------------------------------------------------------------- + +// buildOutline computes a set of normalized terminal-space (col, row) points +// from raw X/Y location data. +func (m *TrackMapModel) buildOutline(locs []models.Location) { + if len(locs) == 0 { + return + } + + // Find bounding box of raw coordinates + m.rawMinX, m.rawMaxX = locs[0].X, locs[0].X + m.rawMinY, m.rawMaxY = locs[0].Y, locs[0].Y + for _, l := range locs { + if l.X < m.rawMinX { + m.rawMinX = l.X + } + if l.X > m.rawMaxX { + m.rawMaxX = l.X + } + if l.Y < m.rawMinY { + m.rawMinY = l.Y + } + if l.Y > m.rawMaxY { + m.rawMaxY = l.Y + } + } + + // Use a step-down approach to avoid over-sampling: only add a new point + // if it's sufficiently different from the previous one (in normalised space). + // We normalize to a 60ร—24 grid first, then de-duplicate. + const gridW, gridH = 60, 22 + seen := make(map[trackPoint]struct{}) + var pts []trackPoint + + for _, l := range locs { + tp := m.rawToGrid(l.X, l.Y, gridW, gridH) + if _, dup := seen[tp]; dup { + continue + } + seen[tp] = struct{}{} + pts = append(pts, tp) + } + + m.outline = pts +} + +// rawToGrid converts raw X/Y to (col, row) in a canvas of gridWร—gridH. +// Terminal characters are roughly 2ร— taller than wide, so we compress the +// X dimension by a factor of 0.5 to preserve the circuit's visual aspect ratio. +func (m *TrackMapModel) rawToGrid(x, y float64, gridW, gridH int) trackPoint { + rangeX := m.rawMaxX - m.rawMinX + rangeY := m.rawMaxY - m.rawMinY + if rangeX == 0 { + rangeX = 1 + } + if rangeY == 0 { + rangeY = 1 + } + + // Apply 0.5ร— X compression for terminal aspect ratio + normX := (x - m.rawMinX) / rangeX + normY := (y - m.rawMinY) / rangeY + + col := int(normX * float64(gridW-1) * 0.5) // compress horizontal + row := gridH - 1 - int(normY*float64(gridH-1)) // flip Y (screen rows go down) + return trackPoint{col: clampInt(col, 0, gridW-1), row: clampInt(row, 0, gridH-1)} +} + +// rawToCanvas converts raw X/Y to (col, row) for the actual render canvas size. +func (m *TrackMapModel) rawToCanvas(x, y float64, canvasW, canvasH int) trackPoint { + rangeX := m.rawMaxX - m.rawMinX + rangeY := m.rawMaxY - m.rawMinY + if rangeX == 0 { + rangeX = 1 + } + if rangeY == 0 { + rangeY = 1 + } + + normX := (x - m.rawMinX) / rangeX + normY := (y - m.rawMinY) / rangeY + + // Margins + marginH := 2 + marginV := 1 + drawW := canvasW - marginH*2 + drawH := canvasH - marginV*2 + + col := marginH + int(normX*float64(drawW-1)*0.5) + row := marginV + (drawH - 1 - int(normY*float64(drawH-1))) + return trackPoint{ + col: clampInt(col, marginH, marginH+drawW-1), + row: clampInt(row, marginV, marginV+drawH-1), + } +} + +// --------------------------------------------------------------------------- +// Map renderer +// --------------------------------------------------------------------------- + +func (m TrackMapModel) renderMap() string { + w := m.width + if w < 40 { + w = 40 + } + h := m.height + if h < 20 { + h = 20 + } + + // Reserve space for header (3 lines) + help bar (1 line) + const headerLines = 4 + canvasW := min(w-2, 80) // cap width for readability + canvasH := h - headerLines - 2 + if canvasH < 10 { + canvasH = 10 + } + + // Allocate canvas grid + grid := make([][]rune, canvasH) + colorGrid := make([][]string, canvasH) + for r := range grid { + grid[r] = make([]rune, canvasW) + colorGrid[r] = make([]string, canvasW) + for c := range grid[r] { + grid[r][c] = ' ' + } + } + + // Draw track outline using 'ยท' dots + for _, loc := range m.outline { + // Re-normalize outline points from the 60ร—22 grid to the canvas + // by converting back through raw fraction space. + normCol := float64(loc.col) / (30.0) // gridW/2 for the 0.5 compression + normRow := float64(22-1-loc.row) / float64(22-1) + + rawX := m.rawMinX + normCol*(m.rawMaxX-m.rawMinX) + rawY := m.rawMinY + normRow*(m.rawMaxY-m.rawMinY) + + tp := m.rawToCanvas(rawX, rawY, canvasW, canvasH) + if tp.row >= 0 && tp.row < canvasH && tp.col >= 0 && tp.col < canvasW { + if grid[tp.row][tp.col] == ' ' { + grid[tp.row][tp.col] = 'ยท' + colorGrid[tp.row][tp.col] = colorSurface2 + } + } + } + + // Place car markers + type carPlacement struct { + tp trackPoint + dn int + color string + tla string + } + var placements []carPlacement + + // Sort driver numbers for deterministic overdraw + var dnums []int + for dn := range m.carPositions { + dnums = append(dnums, dn) + } + sort.Ints(dnums) + + for _, dn := range dnums { + loc := m.carPositions[dn] + tp := m.rawToCanvas(loc.X, loc.Y, canvasW, canvasH) + + numStr := fmt.Sprintf("%d", dn) + teamColor := colorMuted + tla := numStr + + if info, ok := m.driverInfo[numStr]; ok { + if info.Tla != "" { + tla = info.Tla + } + if info.TeamColour != "" { + teamColor = "#" + info.TeamColour + } else if info.TeamName != "" { + teamColor = teamColorFromName(info.TeamName) + } + } + + placements = append(placements, carPlacement{tp, dn, teamColor, tla}) + } + + // Draw car glyphs โ€” use 'โ—' at the car's point, then try to fit TLA inline + for _, cp := range placements { + r, c := cp.tp.row, cp.tp.col + if r < 0 || r >= canvasH || c < 0 || c >= canvasW { + continue + } + grid[r][c] = 'โ—' + colorGrid[r][c] = cp.color + // Write TLA to the right of the marker if space allows + for i, ch := range cp.tla { + nc := c + 1 + i + if nc >= canvasW { + break + } + grid[r][nc] = ch + colorGrid[r][nc] = cp.color + } + } + + // Render grid to string + var sb strings.Builder + + // Header + title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(colorF1Red)).Render("๐Ÿ—บ TRACK MAP") + driverCount := fmt.Sprintf("%d cars tracked", len(m.carPositions)) + if len(m.carPositions) == 0 { + driverCount = "waiting for car positions..." + } + sb.WriteString("\n " + title + " " + styleMuted.Render(driverCount) + "\n") + sb.WriteString(" " + divider(min(w-4, canvasW)) + "\n") + + // Canvas border top + borderStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorBorder)) + sb.WriteString(" " + borderStyle.Render("โ•ญ"+strings.Repeat("โ”€", canvasW)+"โ•ฎ") + "\n") + + for r := 0; r < canvasH; r++ { + sb.WriteString(" " + borderStyle.Render("โ”‚")) + for c := 0; c < canvasW; c++ { + ch := grid[r][c] + color := colorGrid[r][c] + if color != "" { + sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(string(ch))) + } else { + sb.WriteRune(ch) + } + } + sb.WriteString(borderStyle.Render("โ”‚") + "\n") + } + + sb.WriteString(" " + borderStyle.Render("โ•ฐ"+strings.Repeat("โ”€", canvasW)+"โ•ฏ") + "\n") + sb.WriteString("\n") + sb.WriteString(helpBar("1-7 tabs", "q quit")) + + return sb.String() +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// distanceSq returns the squared Euclidean distance between two points. +func distanceSq(x1, y1, x2, y2 float64) float64 { + dx, dy := x2-x1, y2-y1 + return dx*dx + dy*dy +} + +// nearestOutlinePoint finds the outline point closest to (x, y) in raw space. +// Returns math.MaxFloat64 if outline is empty. +func (m *TrackMapModel) nearestOutlineDistance(x, y float64) float64 { + if len(m.outline) == 0 { + return math.MaxFloat64 + } + minDist := math.MaxFloat64 + rangeX := m.rawMaxX - m.rawMinX + rangeY := m.rawMaxY - m.rawMinY + if rangeX == 0 { + rangeX = 1 + } + if rangeY == 0 { + rangeY = 1 + } + // Convert outline back to raw for distance calculation + for _, tp := range m.outline { + nx := float64(tp.col) / 30.0 + ny := float64(22-1-tp.row) / float64(22-1) + rx := m.rawMinX + nx*rangeX + ry := m.rawMinY + ny*rangeY + d := distanceSq(x, y, rx, ry) + if d < minDist { + minDist = d + } + } + return math.Sqrt(minDist) +} diff --git a/internal/ui/util.go b/internal/ui/util.go index 87db1d0..b4cd31e 100644 --- a/internal/ui/util.go +++ b/internal/ui/util.go @@ -164,6 +164,62 @@ func sparkline(laps []models.Lap, width int) string { return result } +// speedSparkline generates a unicode block chart for speed trap values. +// Higher speed = taller bar (green). Lower speed = shorter bar (red). +func speedSparkline(speeds []int, width int) string { + const blocks = "โ–โ–‚โ–ƒโ–„โ–…โ–†โ–‡โ–ˆ" + blockRunes := []rune(blocks) + + if len(speeds) == 0 { + return styleMuted.Render(strings.Repeat("ยท", width)) + } + + minSpd, maxSpd := speeds[0], speeds[0] + for _, s := range speeds { + if s < minSpd { + minSpd = s + } + if s > maxSpd { + maxSpd = s + } + } + rng := maxSpd - minSpd + if rng == 0 { + rng = 1 + } + + var sb strings.Builder + count := 0 + for _, s := range speeds { + if count >= width { + break + } + norm := float64(s-minSpd) / float64(rng) + idx := int(norm*float64(len(blockRunes)-1) + 0.5) + if idx < 0 { + idx = 0 + } + if idx >= len(blockRunes) { + idx = len(blockRunes) - 1 + } + var style lipgloss.Style + switch { + case norm > 0.75: + style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)) + case norm > 0.5: + style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)) + case norm > 0.25: + style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)) + default: + style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)) + } + sb.WriteString(style.Render(string(blockRunes[idx]))) + count++ + } + + return sb.String() +} + // windArrow maps a wind direction in degrees to a unicode arrow. func windArrow(degrees int) string { arrows := []string{"โ†‘", "โ†—", "โ†’", "โ†˜", "โ†“", "โ†™", "โ†", "โ†–"} diff --git a/main b/main index 89217a6..0c9dfd2 100755 Binary files a/main and b/main differ diff --git a/plan.md b/plan.md index a60ce82..3d9bd97 100644 --- a/plan.md +++ b/plan.md @@ -1,106 +1,47 @@ -Great name! **box-box** it is ๐ŸŽ๏ธ +# box-box Roadmap -Here's your getting started guide: +A living document tracking the evolution of box-box from a solid F1 terminal dashboard into the ultimate pit wall companion. --- -## The Stack +## Phase 1 โ€” "Pit Wall Mode" (Enhance What Exists) -| Tool | Purpose | -|---|---| -| **Bubble Tea** | TUI framework โ€” the "engine" (like React for terminals) | -| **Lipgloss** | Styling โ€” colors, borders, padding | -| **Bubbles** | Pre-built components โ€” tables, spinners, viewports | -| **OpenF1 API** | Data source โ€” free, no key needed | +Enrich existing tabs with data that's already available from the API but not yet rendered. Zero or minimal new API calls โ€” prioritizes computed insights from data we already fetch. + +- [x] **Starting Grid View** โ€” Show qualifying lap times and grid order in Race Detail tab (secondary data tier, Race sessions only) +- [x] **Tyre Degradation Analysis** โ€” Compute deg rate per stint from existing laps + stints data in Driver Detail. Show pace drop-off, stint consistency, and degradation sparklines +- [x] **Sector & Speed Analysis** โ€” Sector time sparklines (S1/S2/S3) and speed trap trends from existing lap data in Driver Detail +- [x] **Gap Trend Sparklines in Live Feed** โ€” Track gap-to-leader history per driver over WebSocket updates. Show mini trend indicator in the timing tower during races --- -## Core Bubble Tea Concepts to Know +## Phase 2 โ€” "Race Director" (Killer Features) -Bubble Tea follows the **Elm architecture** โ€” just 3 things: +New views that reconstruct the race narrative and make box-box indispensable during a live session. -1. **Model** โ€” your app's state (what data you're holding, which tab is active, etc.) -2. **Update** โ€” handles events (keypresses, API responses) and returns a new model -3. **View** โ€” renders the model to a string that gets printed to the terminal - -Everything flows in one direction: `event โ†’ update โ†’ view`. That's it. - -``` -type model struct { - activeTab int - standings []Driver - loading bool -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ... } -func (m model) View() string { ... } -``` +- [x] **ASCII Track Map** โ€” Render track outline from Location data with live car positions colored by team +- [x] **Race Replay / Lap Scrubber** โ€” Step through a completed race lap-by-lap with full context (positions, pits, RC messages, weather). Arrow keys scrub, showing the field evolve over time +- [x] **Battle Tracker** โ€” Auto-detect duels (drivers within DRS range) and show head-to-head gap analysis, pace comparison, and pit strategy divergence +- [x] **Pit Window Calculator** โ€” Predict rejoin position if a driver pits now, based on current gaps + avg pit loss + estimated tyre deg rate --- -## Project Structure +## Phase 3 โ€” "Engineering Room" (Companion Web View) -``` -box-box/ -โ”œโ”€โ”€ cmd/ -โ”‚ โ””โ”€โ”€ main.go # Entry point -โ”œโ”€โ”€ internal/ -โ”‚ โ”œโ”€โ”€ api/ -โ”‚ โ”‚ โ””โ”€โ”€ openf1.go # All API calls -โ”‚ โ”œโ”€โ”€ ui/ -โ”‚ โ”‚ โ”œโ”€โ”€ app.go # Root model, tab switching -โ”‚ โ”‚ โ”œโ”€โ”€ standings.go # Standings tab -โ”‚ โ”‚ โ”œโ”€โ”€ calendar.go # Calendar tab -โ”‚ โ”‚ โ”œโ”€โ”€ results.go # Results tab -โ”‚ โ”‚ โ””โ”€โ”€ driver.go # Driver lookup tab -โ”‚ โ””โ”€โ”€ models/ -โ”‚ โ””โ”€โ”€ types.go # Structs (Driver, Race, Result, etc.) -โ”œโ”€โ”€ go.mod -โ””โ”€โ”€ README.md -``` +A lightweight local web UI for visualizations that need a proper canvas. + +- [ ] **`box-box --web` server** โ€” Spawn a localhost SPA from Go embedded assets, sharing the same SQLite cache +- [ ] **SVG Track Map** โ€” Animated car positions on a real circuit layout with team colors +- [ ] **Telemetry Overlay** โ€” Interactive throttle/brake/speed graph through a lap (D3.js or Canvas) +- [ ] **Strategy Timeline** โ€” Visual pit stop and stint timeline for the full field --- -## How to Bootstrap It +## Phase 4 โ€” "Always On" (Background Intelligence) -```bash -mkdir box-box && cd box-box -go mod init github.com/yourusername/box-box +Passive monitoring and historical analysis features. -# Install dependencies -go get github.com/charmbracelet/bubbletea -go get github.com/charmbracelet/lipgloss -go get github.com/charmbracelet/bubbles -``` - ---- - -## Key Concepts for a Beginner - -**1. Commands (Cmd) are how you do async work** -API calls happen outside the Update loop โ€” you return a `tea.Cmd` which runs in the background and sends a message back when done. This keeps the UI non-blocking. - -**2. Messages (Msg) are how things communicate** -When your API call finishes, it sends a message like `standingsFetchedMsg` back into Update. You pattern match on it and update your model. - -**3. Tabs = multiple models composed together** -Each tab (standings, calendar, etc.) can be its own mini Bubble Tea model. The root `app.go` model holds them all and delegates keypresses to whichever tab is active. - -**4. Lipgloss is just styling strings** -Since everything in Bubble Tea is strings, Lipgloss lets you wrap them with colors, borders, and layout โ€” think of it like CSS for your terminal output. - ---- - -## Suggested Learning Order - -1. Follow the [Bubble Tea tutorial](https://github.com/charmbracelet/bubbletea/tree/master/tutorials) โ€” takes ~30 mins -2. Build a single tab first (just standings) โ€” get data showing in a table -3. Add tab navigation -4. Add the remaining views one by one -5. Polish with Lipgloss last - ---- - -The OpenF1 API is straightforward REST โ€” for example `https://api.openf1.org/v1/drivers?session_key=latest` gives you current session drivers. No auth, no rate limits to worry about for personal use. - -Want me to write out the skeleton code to get you started โ€” just the structure with empty stubs and the Bubble Tea boilerplate wired up? +- [ ] **Daemon / Notification Mode** โ€” `box-box --watch` sends OS notifications for key race events (flags, overtakes, pit stops) +- [ ] **Championship Simulator** โ€” "What if" scenarios: set finishing positions per driver and project championship standings forward +- [ ] **Multi-Year Driver Comparison** โ€” Career arc and season-over-season stats with win/pole rates +- [ ] **tmux / Status Bar Integration** โ€” Compact race status output for shell prompts and status bars