TUI Build

This commit is contained in:
2026-03-03 00:48:05 -05:00
parent cc44f96127
commit 2599283c17
15 changed files with 2465 additions and 20 deletions

89
CLAUDE.md Normal file
View File

@@ -0,0 +1,89 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**box-box** is a Formula 1 Terminal User Interface (TUI) application written in Go. It displays F1 standings, race calendar, results, and driver data sourced from the [OpenF1 API](https://openf1.org) (free, no auth required).
## Commands
```bash
# Build
go build ./cmd
# Run
go run ./cmd/main.go
# Run all tests
go test ./...
# Run tests with output
go test -v ./internal/api
# Install dependencies (not yet done)
go get github.com/charmbracelet/bubbletea
go get github.com/charmbracelet/lipgloss
go get github.com/charmbracelet/bubbles
```
Tests in `internal/api/openf1_test.go` are integration tests hitting the real OpenF1 API — they include rate-limit-aware skipping logic.
## Architecture
### Tech Stack
| Tool | Purpose |
|---|---|
| **Bubble Tea** | TUI framework using Elm architecture (Model → Update → View) |
| **Lipgloss** | Terminal styling — colors, borders, layout |
| **Bubbles** | Pre-built TUI components (tables, spinners, viewports) |
| **OpenF1 API** | F1 data source at `https://api.openf1.org/v1/` |
### Elm Architecture (Bubble Tea)
All UI follows the unidirectional flow: `event → Update → View`
- **Model** — app state (active tab, loaded data, loading flags)
- **Update(msg)** — handles keypresses and API response messages, returns new model + optional `tea.Cmd`
- **View()** — renders model to a string printed to terminal
- **Cmd** — async work (API calls) that runs outside the Update loop and sends a `Msg` back when done
Each tab (standings, calendar, results, driver) is its own Bubble Tea sub-model. The root `app.go` holds all tabs and delegates input to the active one.
### Package Structure
```
cmd/main.go # Entry point — wire up and launch the TUI
internal/
api/
client.go # OpenF1Client: HTTP wrapper with 10s timeout
openf1.go # 23 endpoint methods (meetings, drivers, results, telemetry, etc.)
openf1_test.go # Integration tests for API layer
models/
types.go # 18 data structs: Circuit, Meeting, Session, Driver, Lap, Stint, etc.
ui/
app.go # (planned) Root model, tab switching
standings.go # (planned) Championship standings tab
calendar.go # (planned) Race calendar tab
results.go # (planned) Race results tab
driver.go # (planned) Driver lookup tab
```
### Current Status
- **API layer**: Complete — all 23 OpenF1 endpoints implemented
- **Data models**: Complete — 18 structs covering all F1 entities
- **UI layer**: Not yet implemented — `internal/ui/` is empty, `cmd/main.go` is a stub
- **Dependencies**: Not yet installed — `go.mod` has no direct deps yet
### API Layer
`OpenF1Client` in `internal/api/client.go` wraps a standard `http.Client`. All methods in `openf1.go` follow the pattern: build query params → GET from `https://api.openf1.org/v1/{endpoint}` → decode JSON into model types.
Key endpoint groups:
- **Session context**: `GetMeetings`, `GetSessions`
- **Standings**: `GetDriverChampionship`, `GetTeamChampionship`
- **Race data**: `GetSessionResults`, `GetStartingGrid`, `GetLaps`, `GetStints`, `GetPits`
- **Live telemetry**: `GetPositions`, `GetIntervals`, `GetCarData`, `GetLocations`
- **Race events**: `GetRaceControl`, `GetOvertakes`, `GetWeather`, `GetTeamRadio`

View File

@@ -1 +1,27 @@
package cmd package main
import (
"fmt"
"os"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/ui"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
client := api.NewOpenF1Client("https://api.openf1.org", 15*time.Second)
model := ui.NewAppModel(client)
p := tea.NewProgram(
model,
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "box-box error: %v\n", err)
os.Exit(1)
}
}

26
go.mod
View File

@@ -1,3 +1,29 @@
module github.com/AmanTahiliani/box-box module github.com/AmanTahiliani/box-box
go 1.25.6 go 1.25.6
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

48
go.sum
View File

@@ -0,0 +1,48 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

View File

@@ -115,35 +115,41 @@ func (c *OpenF1Client) GetTeamChampionship(sessionKey int) ([]models.Championshi
return result, nil return result, nil
} }
// GetLatestDriverChampionship returns championship standings for the most recent // getLatestRaceSessionKey returns the session_key of the most recent Race session
// race session. Note: requires the caller to supply a recent race session_key; // by fetching sessions filtered to session_name=Race and returning the last one.
// the OpenF1 API does not support a "latest" shortcut for this endpoint. func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) {
func (c *OpenF1Client) GetLatestDriverChampionship() ([]models.ChampionshipDriver, error) { body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race", c.url))
body, err := c.get(fmt.Sprintf("%s/v1/championship_drivers?session_key=latest", c.url))
if err != nil { if err != nil {
return nil, err return 0, err
} }
defer body.Close() defer body.Close()
var result []models.ChampionshipDriver var sessions []models.Session
if err := json.NewDecoder(body).Decode(&result); err != nil { if err := json.NewDecoder(body).Decode(&sessions); err != nil {
return nil, err return 0, err
} }
return result, nil if len(sessions) == 0 {
return 0, errors.New("no Race sessions found")
}
return sessions[len(sessions)-1].SessionKey, nil
}
// GetLatestDriverChampionship returns championship standings for the most recent
// Race session. It resolves the latest session key automatically.
func (c *OpenF1Client) GetLatestDriverChampionship() ([]models.ChampionshipDriver, error) {
sessionKey, err := c.getLatestRaceSessionKey()
if err != nil {
return nil, fmt.Errorf("could not resolve latest race session: %w", err)
}
return c.GetDriverChampionship(sessionKey)
} }
func (c *OpenF1Client) GetLatestTeamChampionship() ([]models.ChampionshipTeam, error) { func (c *OpenF1Client) GetLatestTeamChampionship() ([]models.ChampionshipTeam, error) {
body, err := c.get(fmt.Sprintf("%s/v1/championship_teams?session_key=latest", c.url)) sessionKey, err := c.getLatestRaceSessionKey()
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("could not resolve latest race session: %w", err)
} }
defer body.Close() return c.GetTeamChampionship(sessionKey)
var result []models.ChampionshipTeam
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
} }
func (c *OpenF1Client) GetSessionResult(sessionKey int) ([]models.SessionResult, error) { func (c *OpenF1Client) GetSessionResult(sessionKey int) ([]models.SessionResult, error) {

262
internal/ui/app.go Normal file
View File

@@ -0,0 +1,262 @@
package ui
import (
"fmt"
"strings"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type tabIndex int
const (
tabStandings tabIndex = 0
tabCalendar tabIndex = 1
tabRaceDetail tabIndex = 2
tabDriver tabIndex = 3
)
var tabNames = []string{"1 Standings", "2 Calendar", "3 Race", "4 Drivers"}
// AppModel is the root Bubble Tea model.
type AppModel struct {
client *api.OpenF1Client
activeTab tabIndex
width int
height int
standings StandingsModel
calendar CalendarModel
raceDetail RaceDetailModel
driver DriverModel
}
// NewAppModel creates the root model and wires sub-models.
func NewAppModel(client *api.OpenF1Client) AppModel {
return AppModel{
client: client,
activeTab: tabStandings,
standings: NewStandingsModel(client),
calendar: NewCalendarModel(client),
raceDetail: NewRaceDetailModel(client),
driver: NewDriverModel(client),
}
}
func (m AppModel) Init() tea.Cmd {
return tea.Batch(
m.standings.Init(),
m.calendar.Init(),
m.raceDetail.Init(),
m.driver.Init(),
)
}
func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
contentHeight := m.height - 3 // tab bar + help
m.raceDetail.SetSize(m.width-4, contentHeight)
return m, nil
case tea.KeyMsg:
// Global quit
if matchKey(msg, GlobalKeys.Quit) {
return m, tea.Quit
}
// Tab switching
switch msg.String() {
case "1":
m.activeTab = tabStandings
return m, nil
case "2":
m.activeTab = tabCalendar
return m, nil
case "3":
m.activeTab = tabRaceDetail
return m, nil
case "4":
m.activeTab = tabDriver
var cmd tea.Cmd
m.driver, cmd = m.driver.TriggerLoad()
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case "b":
// Back from race detail → calendar
if m.activeTab == tabRaceDetail {
m.activeTab = tabCalendar
return m, nil
}
}
case meetingSelectedMsg:
// Switch to race detail tab and forward the message
m.activeTab = tabRaceDetail
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
// Also forward to driver model for session key tracking
m.driver, _ = m.driver.Update(msg)
return m, tea.Batch(cmds...)
case sessionsLoadedMsg:
// Forward to raceDetail and driver
var cmd1, cmd2 tea.Cmd
m.raceDetail, cmd1 = m.raceDetail.Update(msg)
m.driver, cmd2 = m.driver.Update(msg)
cmds = append(cmds, cmd1, cmd2)
return m, tea.Batch(cmds...)
// Route all loaded messages to the appropriate sub-models
case driverChampionshipLoadedMsg:
var cmd tea.Cmd
m.standings, cmd = m.standings.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case teamChampionshipLoadedMsg:
var cmd tea.Cmd
m.standings, cmd = m.standings.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case standingsDriversLoadedMsg:
var cmd tea.Cmd
m.standings, cmd = m.standings.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case meetingsLoadedMsg:
var cmd tea.Cmd
m.calendar, cmd = m.calendar.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case sessionResultsLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case sessionDriversLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case raceControlLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case weatherLoadedMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverListLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverStintsLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverLapsLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverPitsLoadedMsg:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case spinner.TickMsg:
// Forward spinner ticks to all sub-models
var cmd1, cmd2, cmd3, cmd4 tea.Cmd
m.standings, cmd1 = m.standings.Update(msg)
m.calendar, cmd2 = m.calendar.Update(msg)
m.raceDetail, cmd3 = m.raceDetail.Update(msg)
m.driver, cmd4 = m.driver.Update(msg)
cmds = append(cmds, cmd1, cmd2, cmd3, cmd4)
return m, tea.Batch(cmds...)
}
// Route keyboard input to active tab
switch m.activeTab {
case tabStandings:
var cmd tea.Cmd
m.standings, cmd = m.standings.Update(msg)
cmds = append(cmds, cmd)
case tabCalendar:
var cmd tea.Cmd
m.calendar, cmd = m.calendar.Update(msg)
cmds = append(cmds, cmd)
case tabRaceDetail:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
case tabDriver:
var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m AppModel) View() string {
// Tab bar
tabs := renderTabBar(m.activeTab, m.width)
// Content area
var content string
switch m.activeTab {
case tabStandings:
content = m.standings.View()
case tabCalendar:
content = m.calendar.View()
case tabRaceDetail:
content = m.raceDetail.View()
case tabDriver:
content = m.driver.View()
}
return tabs + "\n" + content
}
func renderTabBar(active tabIndex, width int) string {
var tabs []string
for i, name := range tabNames {
if tabIndex(i) == active {
tabs = append(tabs, styleActiveTab.Render(name))
} else {
tabs = append(tabs, styleInactiveTab.Render(name))
}
}
bar := strings.Join(tabs, "")
// Pad remaining width
barWidth := lipgloss.Width(bar)
if barWidth < width {
bar += strings.Repeat(" ", width-barWidth)
}
return styleTabBar.Render(fmt.Sprintf("%s", bar))
}

206
internal/ui/calendar.go Normal file
View File

@@ -0,0 +1,206 @@
package ui
import (
"fmt"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type CalendarModel struct {
client *api.OpenF1Client
meetings []models.Meeting
loading bool
err error
spinner spinner.Model
cursor int
width int
height int
}
func NewCalendarModel(client *api.OpenF1Client) CalendarModel {
s := spinner.New()
s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return CalendarModel{
client: client,
loading: true,
spinner: s,
}
}
func fetchMeetings(client *api.OpenF1Client, year int) tea.Cmd {
return func() tea.Msg {
meetings, err := client.GetMeetingsForYear(year)
return meetingsLoadedMsg{meetings: meetings, err: err}
}
}
func (m CalendarModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
fetchMeetings(m.client, 2025),
)
}
func (m CalendarModel) Update(msg tea.Msg) (CalendarModel, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
if m.loading {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
case meetingsLoadedMsg:
if msg.err != nil {
m.err = msg.err
m.loading = false
return m, nil
}
m.meetings = msg.meetings
m.loading = false
// Auto-scroll to next upcoming race
m.cursor = m.findNextRaceIndex()
case tea.KeyMsg:
switch {
case matchKey(msg, GlobalKeys.Up):
if m.cursor > 0 {
m.cursor--
}
case matchKey(msg, GlobalKeys.Down):
if m.cursor < len(m.meetings)-1 {
m.cursor++
}
case matchKey(msg, GlobalKeys.Enter):
if len(m.meetings) > 0 && m.cursor < len(m.meetings) {
return m, func() tea.Msg {
return meetingSelectedMsg{meeting: m.meetings[m.cursor]}
}
}
}
}
return m, nil
}
func (m CalendarModel) findNextRaceIndex() int {
now := time.Now()
for i, meeting := range m.meetings {
start, err := time.Parse(time.RFC3339, meeting.DateStart)
if err != nil {
start, err = time.Parse("2006-01-02", meeting.DateStart[:min(len(meeting.DateStart), 10)])
if err != nil {
continue
}
}
if start.After(now) {
return i
}
}
return max(0, len(m.meetings)-1)
}
func (m CalendarModel) View() string {
if m.loading {
return fmt.Sprintf("\n %s Loading 2025 calendar…", m.spinner.View())
}
if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
}
if len(m.meetings) == 0 {
return styleMuted.Render("\n No meetings found for 2025.")
}
const (
wRound = 3
wName = 28
wCircuit = 20
wCountry = 16
wDates = 20
wStatus = 3
)
header := styleBold.Render(
padRight("Rd", wRound) + " " +
padRight("Grand Prix", wName) + " " +
padRight("Circuit", wCircuit) + " " +
padRight("Country", wCountry) + " " +
padRight("Dates", wDates) + " " +
padRight("", wStatus),
)
var rows []string
rows = append(rows, header)
now := time.Now()
nextIdx := m.findNextRaceIndex()
for i, meeting := range m.meetings {
isNext := (i == nextIdx)
status := meetingStatus(meeting, now, isNext)
dates := formatMeetingDates(meeting)
flag := countryFlag(meeting.CountryCode)
country := flag + " " + truncate(meeting.CountryName, wCountry-3)
row := fmt.Sprintf("%s %s %s %s %s %s",
padLeft(fmt.Sprintf("%d", i+1), wRound),
padRight(truncate(meeting.MeetingName, wName), wName),
padRight(truncate(meeting.CircuitShortName, wCircuit), wCircuit),
padRight(country, wCountry),
padRight(dates, wDates),
status,
)
if i == m.cursor {
row = styleSelected.Render(row)
} else if isNext {
row = styleNext.Render(row)
} else {
// Mute past races
end, err := time.Parse(time.RFC3339, meeting.DateEnd)
if err != nil {
end, _ = time.Parse("2006-01-02", meeting.DateEnd[:min(len(meeting.DateEnd), 10)])
end = end.Add(24 * time.Hour)
}
if end.Before(now) && i != m.cursor {
row = stylePast.Render(row)
}
}
rows = append(rows, row)
}
var sb strings.Builder
sb.WriteString(strings.Join(rows, "\n"))
sb.WriteString("\n\n")
sb.WriteString(helpBar("j/k navigate", "enter select race", "q quit"))
return sb.String()
}
func formatMeetingDates(m models.Meeting) string {
start, err1 := time.Parse(time.RFC3339, m.DateStart)
end, err2 := time.Parse(time.RFC3339, m.DateEnd)
if err1 != nil {
if len(m.DateStart) >= 10 {
start, _ = time.Parse("2006-01-02", m.DateStart[:10])
}
}
if err2 != nil {
if len(m.DateEnd) >= 10 {
end, _ = time.Parse("2006-01-02", m.DateEnd[:10])
}
}
if start.Month() == end.Month() {
return fmt.Sprintf("%s %d%d", start.Format("Jan"), start.Day(), end.Day())
}
return fmt.Sprintf("%s %d %s %d", start.Format("Jan"), start.Day(), end.Format("Jan"), end.Day())
}

355
internal/ui/driver.go Normal file
View File

@@ -0,0 +1,355 @@
package ui
import (
"fmt"
"strings"
"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"
)
type driverView int
const (
driverViewList driverView = iota
driverViewDetail
)
type DriverModel struct {
client *api.OpenF1Client
sessionKey int
drivers []models.Driver
selectedDriver *models.Driver
stints []models.Stint
laps []models.Lap
pits []models.Pit
view driverView
loading bool
err error
spinner spinner.Model
cursor int
width int
height int
}
func NewDriverModel(client *api.OpenF1Client) DriverModel {
s := spinner.New()
s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return DriverModel{
client: client,
loading: false, // lazy-loaded on first focus
spinner: s,
view: driverViewList,
}
}
func fetchDriverList(client *api.OpenF1Client) tea.Cmd {
return func() tea.Msg {
// Use latest session key
drivers, err := client.GetDriversForSession(9999) // will use "latest" via a workaround
if err != nil || len(drivers) == 0 {
// Fallback: get latest championship drivers' session key
champ, champErr := client.GetLatestDriverChampionship()
if champErr != nil || len(champ) == 0 {
return driverListLoadedMsg{err: err}
}
drivers, err = client.GetDriversForSession(champ[0].SessionKey)
}
return driverListLoadedMsg{drivers: drivers, err: err}
}
}
func fetchDriverListForSession(client *api.OpenF1Client, sessionKey int) tea.Cmd {
return func() tea.Msg {
drivers, err := client.GetDriversForSession(sessionKey)
return driverListLoadedMsg{drivers: drivers, err: err}
}
}
func fetchDriverDetail(client *api.OpenF1Client, sessionKey, driverNumber int) tea.Cmd {
return tea.Batch(
func() tea.Msg {
stints, err := client.GetStintsForSession(sessionKey)
var driverStints []models.Stint
for _, s := range stints {
if s.DriverNumber == driverNumber {
driverStints = append(driverStints, s)
}
}
return driverStintsLoadedMsg{stints: driverStints, err: err}
},
func() tea.Msg {
laps, err := client.GetLapsForDriver(sessionKey, driverNumber)
return driverLapsLoadedMsg{laps: laps, err: err}
},
func() tea.Msg {
pits, err := client.GetPitStopsForSession(sessionKey)
var driverPits []models.Pit
for _, p := range pits {
if p.DriverNumber == driverNumber {
driverPits = append(driverPits, p)
}
}
return driverPitsLoadedMsg{pits: driverPits, err: err}
},
)
}
func (m DriverModel) Init() tea.Cmd {
return m.spinner.Tick
}
func (m DriverModel) Update(msg tea.Msg) (DriverModel, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
if m.loading {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
case driverListLoadedMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.drivers = msg.drivers
m.cursor = 0
case driverStintsLoadedMsg:
if msg.err == nil {
m.stints = msg.stints
}
m.loading = false
case driverLapsLoadedMsg:
if msg.err == nil {
m.laps = msg.laps
}
case driverPitsLoadedMsg:
if msg.err == nil {
m.pits = msg.pits
}
// When a meeting is selected from calendar, update session key for driver lookup
case meetingSelectedMsg:
// We'll pick up session key when sessions are loaded; for now reset
m.drivers = nil
case sessionsLoadedMsg:
if msg.err == nil && len(msg.sessions) > 0 {
// Use the Race session key if available
for _, s := range msg.sessions {
if s.SessionName == "Race" {
m.sessionKey = s.SessionKey
return m, nil
}
}
m.sessionKey = msg.sessions[len(msg.sessions)-1].SessionKey
}
case tea.KeyMsg:
switch m.view {
case driverViewList:
switch {
case matchKey(msg, GlobalKeys.Up):
if m.cursor > 0 {
m.cursor--
}
case matchKey(msg, GlobalKeys.Down):
if m.cursor < len(m.drivers)-1 {
m.cursor++
}
case matchKey(msg, GlobalKeys.Enter):
if len(m.drivers) > 0 && m.cursor < len(m.drivers) {
d := m.drivers[m.cursor]
m.selectedDriver = &d
m.stints = nil
m.laps = nil
m.pits = nil
m.view = driverViewDetail
m.loading = true
sessionKey := m.sessionKey
if sessionKey == 0 && d.SessionKey != 0 {
sessionKey = d.SessionKey
}
return m, tea.Batch(
m.spinner.Tick,
fetchDriverDetail(m.client, sessionKey, d.DriverNumber),
)
}
}
case driverViewDetail:
switch {
case matchKey(msg, GlobalKeys.Back):
m.view = driverViewList
m.selectedDriver = nil
m.stints = nil
m.laps = nil
m.pits = nil
}
}
}
return m, nil
}
// TriggerLoad initiates the driver list load if not already loaded.
func (m DriverModel) TriggerLoad() (DriverModel, tea.Cmd) {
if m.drivers != nil || m.loading {
return m, nil
}
m.loading = true
return m, tea.Batch(m.spinner.Tick, fetchDriverList(m.client))
}
func (m DriverModel) View() string {
if m.loading {
return fmt.Sprintf("\n %s Loading drivers…", m.spinner.View())
}
if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
}
switch m.view {
case driverViewList:
return m.renderDriverList()
case driverViewDetail:
return m.renderDriverDetail()
}
return ""
}
func (m DriverModel) renderDriverList() string {
if len(m.drivers) == 0 {
return styleMuted.Render("\n No driver data. Select a race from Calendar first.\n\n" +
helpBar("2 calendar", "q quit"))
}
const (
wNum = 3
wAcronym = 5
wName = 25
wTeam = 22
)
header := styleBold.Render(
padLeft("#", wNum) + " " +
padRight("DRV", wAcronym) + " " +
padRight("Name", wName) + " " +
padRight("Team", wTeam),
)
var rows []string
rows = append(rows, header)
for i, d := range m.drivers {
teamStr := hexToStyle(d.TeamColour).Render(padRight(truncate(d.TeamName, wTeam), wTeam))
row := fmt.Sprintf("%s %s %s %s",
padLeft(fmt.Sprintf("%d", d.DriverNumber), wNum),
padRight(d.NameAcronym, wAcronym),
padRight(truncate(d.FullName, wName), wName),
teamStr,
)
if i == m.cursor {
row = styleSelected.Render(row)
}
rows = append(rows, row)
}
var sb strings.Builder
sb.WriteString(strings.Join(rows, "\n"))
sb.WriteString("\n\n")
sb.WriteString(helpBar("j/k navigate", "enter view driver", "q quit"))
return sb.String()
}
func (m DriverModel) renderDriverDetail() string {
if m.selectedDriver == nil {
return ""
}
d := m.selectedDriver
var sb strings.Builder
// Nameplate header
nameStyle := hexToStyle(d.TeamColour).Bold(true)
sb.WriteString(nameStyle.Render(fmt.Sprintf(" %s %s", d.NameAcronym, d.FullName)))
sb.WriteString(styleMuted.Render(fmt.Sprintf(" · %s · #%d", d.TeamName, d.DriverNumber)))
sb.WriteString("\n\n")
// Stint bar
sb.WriteString(styleBold.Render("Stints") + "\n")
sb.WriteString(m.renderStintBar())
sb.WriteString("\n\n")
// Lap sparkline
sb.WriteString(styleBold.Render("Lap Times") + "\n")
sparkWidth := min(m.width-4, 80)
if sparkWidth < 10 {
sparkWidth = 40
}
sb.WriteString(" " + sparkline(m.laps, sparkWidth) + "\n")
sb.WriteString(styleMuted.Render(fmt.Sprintf(" %d laps (▁=slow, █=fast, space=pit out)\n", len(m.laps))))
sb.WriteString("\n")
// Pit stops
sb.WriteString(styleBold.Render("Pit Stops") + "\n")
sb.WriteString(m.renderPitStops())
sb.WriteString("\n\n")
sb.WriteString(helpBar("b back to driver list", "q quit"))
return sb.String()
}
func (m DriverModel) renderStintBar() string {
if len(m.stints) == 0 {
if m.loading {
return fmt.Sprintf(" %s", m.spinner.View())
}
return styleMuted.Render(" No stint data.")
}
var parts []string
for _, stint := range m.stints {
label := fmt.Sprintf("%s %d-%d", tyreAbbrev(stint.Compound), stint.LapStart, stint.LapEnd)
part := tyreStyle(stint.Compound).Render(fmt.Sprintf("[%s]", label))
parts = append(parts, part)
}
return " " + strings.Join(parts, " ")
}
func (m DriverModel) renderPitStops() string {
if len(m.pits) == 0 {
return styleMuted.Render(" No pit stop data.")
}
header := styleBold.Render(
padLeft("Lap", 4) + " " +
padLeft("Stop", 8) + " " +
padLeft("Lane", 8),
)
var rows []string
rows = append(rows, header)
for _, p := range m.pits {
row := fmt.Sprintf("%s %s %s",
padLeft(fmt.Sprintf("%d", p.LapNumber), 4),
padLeft(fmt.Sprintf("%.3fs", p.StopDuration), 8),
padLeft(fmt.Sprintf("%.3fs", p.LaneDuration), 8),
)
rows = append(rows, " "+row)
}
return strings.Join(rows, "\n")
}

90
internal/ui/keys.go Normal file
View File

@@ -0,0 +1,90 @@
package ui
import "github.com/charmbracelet/bubbles/key"
// GlobalKeyMap holds keybindings that work from any tab.
type GlobalKeyMap struct {
Tab1 key.Binding
Tab2 key.Binding
Tab3 key.Binding
Tab4 key.Binding
Quit key.Binding
Up key.Binding
Down key.Binding
Enter key.Binding
Back key.Binding
}
// GlobalKeys is the singleton global key map.
var GlobalKeys = GlobalKeyMap{
Tab1: key.NewBinding(
key.WithKeys("1"),
key.WithHelp("1", "standings"),
),
Tab2: key.NewBinding(
key.WithKeys("2"),
key.WithHelp("2", "calendar"),
),
Tab3: key.NewBinding(
key.WithKeys("3"),
key.WithHelp("3", "race detail"),
),
Tab4: key.NewBinding(
key.WithKeys("4"),
key.WithHelp("4", "drivers"),
),
Quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
Up: key.NewBinding(
key.WithKeys("k", "up"),
key.WithHelp("k/↑", "up"),
),
Down: key.NewBinding(
key.WithKeys("j", "down"),
key.WithHelp("j/↓", "down"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select"),
),
Back: key.NewBinding(
key.WithKeys("b"),
key.WithHelp("b", "back"),
),
}
// StandingsKeyMap holds standing-specific keybindings.
type StandingsKeyMap struct {
DriverView key.Binding
ConstructorView key.Binding
}
var StandingsKeys = StandingsKeyMap{
DriverView: key.NewBinding(
key.WithKeys("d"),
key.WithHelp("d", "drivers"),
),
ConstructorView: key.NewBinding(
key.WithKeys("c"),
key.WithHelp("c", "constructors"),
),
}
// RaceDetailKeyMap holds keybindings for the race detail tab.
type RaceDetailKeyMap struct {
ScrollUp key.Binding
ScrollDown key.Binding
}
var RaceDetailKeys = RaceDetailKeyMap{
ScrollUp: key.NewBinding(
key.WithKeys("K"),
key.WithHelp("K", "scroll race control up"),
),
ScrollDown: key.NewBinding(
key.WithKeys("J"),
key.WithHelp("J", "scroll race control down"),
),
}

92
internal/ui/messages.go Normal file
View File

@@ -0,0 +1,92 @@
package ui
import "github.com/AmanTahiliani/box-box/internal/models"
// driverChampionshipLoadedMsg carries the loaded driver championship data.
type driverChampionshipLoadedMsg struct {
standings []models.ChampionshipDriver
err error
}
// teamChampionshipLoadedMsg carries the loaded team championship data.
type teamChampionshipLoadedMsg struct {
standings []models.ChampionshipTeam
err error
}
// standingsDriversLoadedMsg carries drivers for the standings join.
type standingsDriversLoadedMsg struct {
drivers []models.Driver
err error
}
// meetingsLoadedMsg carries the full meeting list for the calendar.
type meetingsLoadedMsg struct {
meetings []models.Meeting
err error
}
// sessionsLoadedMsg carries sessions for a selected meeting.
type sessionsLoadedMsg struct {
sessions []models.Session
err error
}
// sessionResultsLoadedMsg carries results for a selected session.
type sessionResultsLoadedMsg struct {
results []models.SessionResult
err error
}
// sessionDriversLoadedMsg carries drivers for a selected session.
type sessionDriversLoadedMsg struct {
drivers []models.Driver
err error
}
// raceControlLoadedMsg carries race control messages for a session.
type raceControlLoadedMsg struct {
messages []models.RaceControl
err error
}
// weatherLoadedMsg carries weather data for a session.
type weatherLoadedMsg struct {
weather []models.Weather
err error
}
// driverListLoadedMsg carries all drivers for the driver tab.
type driverListLoadedMsg struct {
drivers []models.Driver
err error
}
// driverStintsLoadedMsg carries stints for a selected driver.
type driverStintsLoadedMsg struct {
stints []models.Stint
err error
}
// driverLapsLoadedMsg carries laps for a selected driver.
type driverLapsLoadedMsg struct {
laps []models.Lap
err error
}
// driverPitsLoadedMsg carries pit stops for a selected driver.
type driverPitsLoadedMsg struct {
pits []models.Pit
err error
}
// meetingSelectedMsg is emitted when the user selects a meeting in the calendar.
type meetingSelectedMsg struct {
meeting models.Meeting
}
// driverSelectedMsg is emitted when the user selects a driver in the driver tab.
type driverSelectedMsg struct {
driver models.Driver
sessionKey int
}

463
internal/ui/racedetail.go Normal file
View File

@@ -0,0 +1,463 @@
package ui
import (
"fmt"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
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
selectedSession *models.Session
sessionCursor int
loadingSessions bool
loadingResults bool
errSessions error
errResults error
spinner spinner.Model
rcView viewport.Model
rcReady bool
width int
height int
}
func NewRaceDetailModel(client *api.OpenF1Client) RaceDetailModel {
s := spinner.New()
s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return RaceDetailModel{
client: client,
spinner: s,
drivers: make(map[int]models.Driver),
}
}
func (m RaceDetailModel) Init() tea.Cmd {
return m.spinner.Tick
}
func fetchSessions(client *api.OpenF1Client, meetingKey int) tea.Cmd {
return func() tea.Msg {
sessions, err := client.GetSessionsForMeeting(meetingKey)
return sessionsLoadedMsg{sessions: sessions, err: err}
}
}
func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
return tea.Batch(
func() tea.Msg {
results, err := client.GetSessionResult(sessionKey)
return sessionResultsLoadedMsg{results: results, err: err}
},
func() tea.Msg {
drivers, err := client.GetDriversForSession(sessionKey)
return sessionDriversLoadedMsg{drivers: drivers, err: err}
},
func() tea.Msg {
msgs, err := client.GetRaceControl(sessionKey)
return raceControlLoadedMsg{messages: msgs, err: err}
},
func() tea.Msg {
weather, err := client.GetWeather(sessionKey)
return weatherLoadedMsg{weather: weather, err: err}
},
)
}
func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case spinner.TickMsg:
if m.loadingSessions || m.loadingResults {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
}
case meetingSelectedMsg:
m.meeting = &msg.meeting
m.sessions = nil
m.results = nil
m.rcMsgs = nil
m.weather = nil
m.selectedSession = nil
m.sessionCursor = 0
m.loadingSessions = true
m.loadingResults = false
m.errSessions = nil
m.errResults = nil
m.rcReady = false
cmds = append(cmds, fetchSessions(m.client, int(msg.meeting.MeetingKey)), m.spinner.Tick)
case sessionsLoadedMsg:
m.loadingSessions = false
if msg.err != nil {
m.errSessions = msg.err
return m, nil
}
m.sessions = msg.sessions
// Auto-select Race session if available
for i, s := range m.sessions {
if s.SessionName == "Race" {
m.sessionCursor = i
break
}
}
case sessionResultsLoadedMsg:
if msg.err != nil {
m.errResults = msg.err
m.loadingResults = false
return m, nil
}
m.results = msg.results
if !m.loadingResults {
// Both results and drivers may arrive in any order
}
m.checkResultsLoaded()
case sessionDriversLoadedMsg:
if msg.err == nil {
for _, d := range msg.drivers {
m.drivers[d.DriverNumber] = d
}
}
m.checkResultsLoaded()
case raceControlLoadedMsg:
if msg.err == nil {
m.rcMsgs = msg.messages
m.updateRCViewport()
}
case weatherLoadedMsg:
if msg.err == nil {
m.weather = msg.weather
}
case tea.KeyMsg:
switch {
case matchKey(msg, GlobalKeys.Up):
if m.sessionCursor > 0 {
m.sessionCursor--
}
case matchKey(msg, GlobalKeys.Down):
if m.sessionCursor < len(m.sessions)-1 {
m.sessionCursor++
}
case matchKey(msg, GlobalKeys.Enter):
if len(m.sessions) > 0 && m.sessionCursor < len(m.sessions) {
sess := m.sessions[m.sessionCursor]
m.selectedSession = &sess
m.loadingResults = true
m.results = nil
m.rcMsgs = nil
m.weather = nil
m.drivers = make(map[int]models.Driver)
cmds = append(cmds, fetchSessionData(m.client, sess.SessionKey))
}
case matchKey(msg, RaceDetailKeys.ScrollUp):
m.rcView.LineUp(3)
case matchKey(msg, RaceDetailKeys.ScrollDown):
m.rcView.LineDown(3)
}
}
if m.rcReady {
var cmd tea.Cmd
m.rcView, cmd = m.rcView.Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m *RaceDetailModel) checkResultsLoaded() {
// Mark done once results arrive (drivers may still be loading but we show what we have)
if m.results != nil {
m.loadingResults = false
}
}
func (m *RaceDetailModel) updateRCViewport() {
content := m.renderRaceControlContent()
if m.rcReady {
m.rcView.SetContent(content)
m.rcView.GotoBottom()
}
}
func (m *RaceDetailModel) initViewport(w, h int) {
m.rcView = viewport.New(w, h)
m.rcReady = true
m.updateRCViewport()
}
func (m RaceDetailModel) View() string {
if m.meeting == nil {
return styleMuted.Render("\n Select a race from the Calendar tab (press 2).\n\n" +
helpBar("2 calendar", "q quit"))
}
// Title
title := styleBold.Render(m.meeting.MeetingOfficialName)
dates := formatMeetingDates(*m.meeting)
subtitle := styleMuted.Render(fmt.Sprintf("%s · %s · %s", m.meeting.Location, m.meeting.CountryName, dates))
header := lipgloss.JoinVertical(lipgloss.Left, title, subtitle) + "\n\n"
// Two-panel layout
leftWidth := int(float64(m.width) * 0.55)
rightWidth := m.width - leftWidth - 4
left := m.renderLeft(leftWidth)
right := m.renderRight(rightWidth)
panels := lipgloss.JoinHorizontal(lipgloss.Top,
stylePanelBorder.Width(leftWidth).Render(left),
stylePanelBorder.Width(rightWidth).Render(right),
)
help := helpBar("j/k sessions", "enter load session", "K/J scroll RC", "b back to calendar", "q quit")
return header + panels + "\n" + help
}
func (m RaceDetailModel) renderLeft(width int) string {
var sb strings.Builder
// Session list
sb.WriteString(styleHeader.Render("Sessions") + "\n")
if m.loadingSessions {
sb.WriteString(fmt.Sprintf(" %s Loading sessions…\n", m.spinner.View()))
} else if m.errSessions != nil {
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errSessions)))
} else {
for i, sess := range m.sessions {
var start string
if len(sess.DateStart) >= 10 {
t, err := time.Parse(time.RFC3339, sess.DateStart)
if err == nil {
start = t.Format("Mon Jan 2")
} else {
start = sess.DateStart[:10]
}
}
row := fmt.Sprintf(" %-12s %s", sess.SessionName, start)
if i == m.sessionCursor {
row = styleSelected.Render(row)
} else if m.selectedSession != nil && m.selectedSession.SessionKey == sess.SessionKey {
row = styleDeltaUp.Render(row)
}
sb.WriteString(row + "\n")
}
}
sb.WriteString("\n" + styleHeader.Render("Results") + "\n")
if m.loadingResults {
sb.WriteString(fmt.Sprintf(" %s Loading results…\n", m.spinner.View()))
} else if m.errResults != nil {
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults)))
} else if m.selectedSession == nil {
sb.WriteString(styleMuted.Render(" Press Enter to load session results.\n"))
} else if len(m.results) == 0 {
sb.WriteString(styleMuted.Render(" No results available.\n"))
} else {
sb.WriteString(m.renderResults(width - 4))
}
return sb.String()
}
func (m RaceDetailModel) renderResults(width int) string {
isRace := m.selectedSession != nil && m.selectedSession.SessionType == "Race"
const (
wPos = 3
wDRV = 4
wTeam = 16
wLaps = 4
wGap = 12
wPts = 4
)
var header string
if isRace {
header = styleBold.Render(
padLeft("Pos", wPos) + " " +
padRight("DRV", wDRV) + " " +
padRight("Team", wTeam) + " " +
padLeft("Laps", wLaps) + " " +
padLeft("Gap", wGap) + " " +
padLeft("Pts", wPts),
)
} else {
header = styleBold.Render(
padLeft("Pos", wPos) + " " +
padRight("DRV", wDRV) + " " +
padRight("Team", wTeam) + " " +
padLeft("Time", wGap),
)
}
var rows []string
rows = append(rows, header)
for _, r := range m.results {
d := m.drivers[r.DriverNumber]
acronym := d.NameAcronym
if acronym == "" {
acronym = fmt.Sprintf("#%d", r.DriverNumber)
}
teamName := d.TeamName
teamColor := d.TeamColour
teamStr := hexToStyle(teamColor).Render(padRight(truncate(teamName, wTeam), wTeam))
var row string
pos := fmt.Sprintf("%d", r.Position)
if r.DNF {
pos = "DNF"
} else if r.DNS {
pos = "DNS"
} else if r.DSQ {
pos = "DSQ"
}
if isRace {
row = fmt.Sprintf("%s %s %s %s %s %s",
padLeft(pos, wPos),
padRight(acronym, wDRV),
teamStr,
padLeft(fmt.Sprintf("%d", r.NumberOfLaps), wLaps),
padLeft(formatGap(r.GapToLeader), wGap),
padLeft(fmt.Sprintf("%.0f", r.Points), wPts),
)
} else {
row = fmt.Sprintf("%s %s %s %s",
padLeft(pos, wPos),
padRight(acronym, wDRV),
teamStr,
padLeft(formatDuration(r.Duration), wGap),
)
}
if r.DNF || r.DNS || r.DSQ {
row = styleMuted.Render(row)
}
rows = append(rows, row)
}
return strings.Join(rows, "\n")
}
func (m RaceDetailModel) renderRight(width int) string {
var sb strings.Builder
// Race control
sb.WriteString(styleHeader.Render("Race Control") + "\n")
if !m.rcReady || m.selectedSession == nil {
sb.WriteString(styleMuted.Render(" No session selected.\n"))
} else {
sb.WriteString(m.rcView.View() + "\n")
}
// Weather strip
sb.WriteString("\n" + styleHeader.Render("Weather") + "\n")
sb.WriteString(m.renderWeather(width))
return sb.String()
}
func (m RaceDetailModel) renderRaceControlContent() string {
if len(m.rcMsgs) == 0 {
return styleMuted.Render(" No race control messages.")
}
var lines []string
for _, rc := range m.rcMsgs {
t := "--:--"
if len(rc.Date) >= 16 {
pt, err := time.Parse(time.RFC3339, rc.Date)
if err == nil {
t = pt.Format("15:04")
} else {
t = rc.Date[11:16]
}
}
var flagStyle lipgloss.Style
switch rc.Flag {
case models.FlagGreen:
flagStyle = styleFlagGreen
case models.FlagYellow, models.FlagDoubleYellow:
flagStyle = styleFlagYellow
case models.FlagRed:
flagStyle = styleFlagRed
case models.FlagBlue:
flagStyle = styleFlagBlue
default:
flagStyle = styleMuted
}
prefix := flagStyle.Render(fmt.Sprintf("[%s]", t))
lines = append(lines, fmt.Sprintf("%s %s", prefix, rc.Message))
}
return strings.Join(lines, "\n")
}
func (m RaceDetailModel) renderWeather(width int) string {
if len(m.weather) == 0 {
return styleMuted.Render(" No weather data.")
}
// Use the latest weather snapshot
w := m.weather[len(m.weather)-1]
rain := "Dry"
if w.Rainfall > 0 {
rain = styleFlagBlue.Render("Rain")
}
return fmt.Sprintf(" Air: %.1f°C Track: %.1f°C %s Humidity: %.0f%% Wind: %s %.1fm/s",
w.AirTemperature, w.TrackTemperature, rain,
w.Humidity, windArrow(w.WindDirection), w.WindSpeed)
}
// SetSize updates the model's dimensions and initialises the race control viewport.
func (m *RaceDetailModel) SetSize(w, h int) {
m.width = w
m.height = h
// Right panel, minus header/weather/borders
rightWidth := int(float64(w)*0.45) - 6
rcHeight := h - 12 // approximate: title + sessions + header + weather + help
if rcHeight < 3 {
rcHeight = 3
}
if !m.rcReady {
m.initViewport(rightWidth, rcHeight)
} else {
m.rcView.Width = rightWidth
m.rcView.Height = rcHeight
}
}

287
internal/ui/standings.go Normal file
View File

@@ -0,0 +1,287 @@
package ui
import (
"fmt"
"strings"
"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"
)
type standingsView int
const (
standingsViewDriver standingsView = iota
standingsViewConstructor
)
type StandingsModel struct {
client *api.OpenF1Client
driverStandings []models.ChampionshipDriver
teamStandings []models.ChampionshipTeam
drivers map[int]models.Driver // driver_number → Driver
view standingsView
loading bool
err error
spinner spinner.Model
cursor int
width int
height int
}
func NewStandingsModel(client *api.OpenF1Client) StandingsModel {
s := spinner.New()
s.Spinner = spinner.MiniDot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
return StandingsModel{
client: client,
view: standingsViewDriver,
loading: true,
spinner: s,
drivers: make(map[int]models.Driver),
}
}
func (m StandingsModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
fetchDriverChampionship(m.client),
fetchTeamChampionship(m.client),
)
}
func fetchDriverChampionship(client *api.OpenF1Client) tea.Cmd {
return func() tea.Msg {
standings, err := client.GetLatestDriverChampionship()
return driverChampionshipLoadedMsg{standings: standings, err: err}
}
}
func fetchTeamChampionship(client *api.OpenF1Client) tea.Cmd {
return func() tea.Msg {
standings, err := client.GetLatestTeamChampionship()
return teamChampionshipLoadedMsg{standings: standings, err: err}
}
}
func fetchStandingsDrivers(client *api.OpenF1Client, sessionKey int) tea.Cmd {
return func() tea.Msg {
drivers, err := client.GetDriversForSession(sessionKey)
return standingsDriversLoadedMsg{drivers: drivers, err: err}
}
}
func (m StandingsModel) Update(msg tea.Msg) (StandingsModel, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
if m.loading {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
case driverChampionshipLoadedMsg:
if msg.err != nil {
m.err = msg.err
m.loading = false
return m, nil
}
m.driverStandings = msg.standings
// Phase 2: fetch drivers to join names
if len(msg.standings) > 0 {
return m, fetchStandingsDrivers(m.client, msg.standings[0].SessionKey)
}
m.loading = false
case teamChampionshipLoadedMsg:
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.teamStandings = msg.standings
case standingsDriversLoadedMsg:
if msg.err != nil {
m.err = msg.err
m.loading = false
return m, nil
}
for _, d := range msg.drivers {
m.drivers[d.DriverNumber] = d
}
m.loading = false
case tea.KeyMsg:
switch {
case matchKey(msg, StandingsKeys.DriverView):
m.view = standingsViewDriver
m.cursor = 0
case matchKey(msg, StandingsKeys.ConstructorView):
m.view = standingsViewConstructor
m.cursor = 0
case matchKey(msg, GlobalKeys.Up):
if m.cursor > 0 {
m.cursor--
}
case matchKey(msg, GlobalKeys.Down):
m.cursor++
}
}
return m, nil
}
func (m StandingsModel) View() string {
if m.loading {
return fmt.Sprintf("\n %s Loading championship standings…", m.spinner.View())
}
if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v", m.err))
}
var sb strings.Builder
// Toggle bar
dStyle, cStyle := styleInactiveTab, styleInactiveTab
if m.view == standingsViewDriver {
dStyle = styleActiveTab
} else {
cStyle = styleActiveTab
}
sb.WriteString(lipgloss.JoinHorizontal(lipgloss.Top,
dStyle.Render("d Drivers"),
cStyle.Render("c Constructors"),
))
sb.WriteString("\n\n")
if m.view == standingsViewDriver {
sb.WriteString(m.renderDriverStandings())
} else {
sb.WriteString(m.renderTeamStandings())
}
sb.WriteString("\n")
sb.WriteString(helpBar("d drivers", "c constructors", "j/k navigate", "q quit"))
return sb.String()
}
func (m StandingsModel) renderDriverStandings() string {
if len(m.driverStandings) == 0 {
return styleMuted.Render(" No standings data available.")
}
// Column widths
const (
wPos = 4
wAcronym = 5
wName = 22
wTeam = 22
wPoints = 8
wDelta = 5
)
header := styleBold.Render(
padRight("Pos", wPos) + " " +
padRight("DRV", wAcronym) + " " +
padRight("Name", wName) + " " +
padRight("Team", wTeam) + " " +
padLeft("Pts", wPoints) + " " +
padLeft("Δ", wDelta),
)
var rows []string
rows = append(rows, header)
maxCursor := len(m.driverStandings) - 1
if m.cursor > maxCursor {
_ = maxCursor // cursor clamping happens in Update
}
for i, s := range m.driverStandings {
d, ok := m.drivers[s.DriverNumber]
acronym, name, team, teamColor := "---", "Unknown Driver", "Unknown Team", ""
if ok {
acronym = d.NameAcronym
name = d.FullName
team = d.TeamName
teamColor = d.TeamColour
}
delta := renderDelta(s.PositionCurrent, s.PositionStart)
teamStr := hexToStyle(teamColor).Render(truncate(team, wTeam))
// Pad team to width (lipgloss rendering may shift widths, so use padRight on plain string then style)
teamPlain := padRight(truncate(team, wTeam), wTeam)
teamStr = hexToStyle(teamColor).Render(teamPlain)
row := fmt.Sprintf("%s %s %s %s %s %s",
padLeft(fmt.Sprintf("%d", s.PositionCurrent), wPos),
padRight(acronym, wAcronym),
padRight(truncate(name, wName), wName),
teamStr,
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), wPoints),
delta,
)
if i == m.cursor {
row = styleSelected.Render(row)
}
rows = append(rows, row)
}
return strings.Join(rows, "\n")
}
func (m StandingsModel) renderTeamStandings() string {
if len(m.teamStandings) == 0 {
return styleMuted.Render(" No constructor standings available.")
}
const (
wPos = 4
wTeam = 30
wPoints = 8
wDelta = 5
)
header := styleBold.Render(
padRight("Pos", wPos) + " " +
padRight("Constructor", wTeam) + " " +
padLeft("Pts", wPoints) + " " +
padLeft("Δ", wDelta),
)
var rows []string
rows = append(rows, header)
for i, s := range m.teamStandings {
delta := renderDelta(s.PositionCurrent, s.PositionStart)
row := fmt.Sprintf("%s %s %s %s",
padLeft(fmt.Sprintf("%d", s.PositionCurrent), wPos),
padRight(truncate(s.TeamName, wTeam), wTeam),
padLeft(fmt.Sprintf("%.0f", s.PointsCurrent), wPoints),
delta,
)
if i == m.cursor {
row = styleSelected.Render(row)
}
rows = append(rows, row)
}
return strings.Join(rows, "\n")
}
// matchKey checks if a KeyMsg matches a binding.
func matchKey(msg tea.KeyMsg, binding interface{ Keys() []string }) bool {
for _, k := range binding.Keys() {
if msg.String() == k {
return true
}
}
return false
}

103
internal/ui/styles.go Normal file
View File

@@ -0,0 +1,103 @@
package ui
import "github.com/charmbracelet/lipgloss"
// F1 brand colors
const (
colorF1Red = "#E8002D"
colorF1Black = "#15151E"
colorSubtle = "#3C3C4A"
colorMuted = "#6B6B7A"
colorWhite = "#FFFFFF"
colorGreen = "#39B54A"
// Tyre compounds
colorSoft = "#FF1E1E"
colorMedium = "#FFD700"
colorHard = "#EEEEEE"
colorInter = "#39B54A"
colorWet = "#0057FF"
)
var (
// Tab bar styles
styleActiveTab = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorWhite)).
Background(lipgloss.Color(colorF1Red)).
Padding(0, 2)
styleInactiveTab = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Padding(0, 2)
styleTabBar = lipgloss.NewStyle().
Background(lipgloss.Color(colorF1Black)).
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true).
BorderForeground(lipgloss.Color(colorSubtle))
// Panel / border styles
stylePanelBorder = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(colorSubtle)).
Padding(0, 1)
styleActivePanelBorder = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(colorF1Red)).
Padding(0, 1)
// Text styles
styleBold = lipgloss.NewStyle().Bold(true)
styleMuted = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted))
styleError = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Bold(true)
styleHeader = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colorWhite)).
Background(lipgloss.Color(colorSubtle)).
Padding(0, 1)
// Delta styles
styleDeltaUp = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorGreen)).
Bold(true)
styleDeltaDown = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Bold(true)
styleDeltaEqual = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted))
// Selected row style
styleSelected = lipgloss.NewStyle().
Background(lipgloss.Color(colorSubtle)).
Bold(true)
// Status indicators
stylePast = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted))
styleNext = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Bold(true)
// Flag colors for race control
styleFlagGreen = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen))
styleFlagYellow = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium))
styleFlagRed = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red))
styleFlagBlue = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet))
styleFlagWhite = lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite))
// Help bar
styleHelp = lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Padding(0, 1)
)

286
internal/ui/util.go Normal file
View File

@@ -0,0 +1,286 @@
package ui
import (
"fmt"
"math"
"strings"
"time"
"unicode/utf8"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/charmbracelet/lipgloss"
)
// formatSeconds converts a duration in seconds to a lap time string (e.g. "1:31.234").
func formatSeconds(s float64) string {
if s <= 0 {
return "--:--.---"
}
minutes := int(s) / 60
secs := s - float64(minutes*60)
return fmt.Sprintf("%d:%06.3f", minutes, secs)
}
// formatGap formats the gap_to_leader field which can be float64, string, or []interface{}.
func formatGap(v interface{}) string {
if v == nil {
return "LEADER"
}
switch val := v.(type) {
case float64:
if val == 0 {
return "LEADER"
}
return fmt.Sprintf("+%.3fs", val)
case string:
return val
case []interface{}:
// Qualifying: return best Q time
if len(val) == 0 {
return "--"
}
if f, ok := val[len(val)-1].(float64); ok {
return formatSeconds(f)
}
return "--"
}
return "--"
}
// formatDuration formats a session result duration (float64 or []float64 for qualifying).
func formatDuration(v interface{}) string {
if v == nil {
return "--"
}
switch val := v.(type) {
case float64:
return formatSeconds(val)
case []interface{}:
// Qualifying: return best Q time
if len(val) == 0 {
return "--"
}
if f, ok := val[len(val)-1].(float64); ok {
return formatSeconds(f)
}
return "--"
}
return "--"
}
// hexToStyle creates a lipgloss.Style with the given hex color as the foreground.
// The hex string may or may not have a leading '#'.
func hexToStyle(hex string) lipgloss.Style {
if hex == "" {
return lipgloss.NewStyle()
}
if !strings.HasPrefix(hex, "#") {
hex = "#" + hex
}
return lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
}
// sparkline generates a unicode block chart for lap times.
// Pit laps (nil duration) are rendered as spaces.
func sparkline(laps []models.Lap, width int) string {
const blocks = "▁▂▃▄▅▆▇█"
blockRunes := []rune(blocks)
if len(laps) == 0 {
return strings.Repeat(" ", width)
}
// Collect valid lap durations
var durations []float64
minDur, maxDur := math.MaxFloat64, 0.0
for _, lap := range laps {
if lap.LapDuration != nil && *lap.LapDuration > 0 {
d := *lap.LapDuration
durations = append(durations, d)
if d < minDur {
minDur = d
}
if d > maxDur {
maxDur = d
}
} else {
durations = append(durations, -1)
}
}
rng := maxDur - minDur
if rng == 0 {
rng = 1
}
var sb strings.Builder
count := 0
for _, d := range durations {
if count >= width {
break
}
if d < 0 {
sb.WriteRune(' ')
} else {
norm := (d - minDur) / rng
// Invert: fast laps = tall bar (higher index)
idx := int((1.0-norm)*float64(len(blockRunes)-1) + 0.5)
if idx < 0 {
idx = 0
}
if idx >= len(blockRunes) {
idx = len(blockRunes) - 1
}
sb.WriteRune(blockRunes[idx])
}
count++
}
result := sb.String()
resultLen := utf8.RuneCountInString(result)
if resultLen < width {
result += strings.Repeat(" ", width-resultLen)
}
return result
}
// windArrow maps a wind direction in degrees to a unicode arrow.
func windArrow(degrees int) string {
arrows := []string{"↑", "↗", "→", "↘", "↓", "↙", "←", "↖"}
idx := ((degrees + 22) / 45) % 8
return arrows[idx]
}
// tyreStyle returns a lipgloss.Style for a tyre compound.
func tyreStyle(c models.TyreCompound) lipgloss.Style {
switch c {
case models.CompoundSoft:
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorSoft)).Bold(true)
case models.CompoundMedium:
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)).Bold(true)
case models.CompoundHard:
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorHard)).Bold(true)
case models.CompoundIntermediate:
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorInter)).Bold(true)
case models.CompoundWet:
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet)).Bold(true)
default:
return lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
}
}
// tyreAbbrev returns a single-letter abbreviation for a tyre compound.
func tyreAbbrev(c models.TyreCompound) string {
switch c {
case models.CompoundSoft:
return "S"
case models.CompoundMedium:
return "M"
case models.CompoundHard:
return "H"
case models.CompoundIntermediate:
return "I"
case models.CompoundWet:
return "W"
default:
return "?"
}
}
// renderDelta returns a colored ▲N/▼N/= string for position change.
func renderDelta(current, start int) string {
diff := start - current // positive = gained positions
switch {
case diff > 0:
return styleDeltaUp.Render(fmt.Sprintf("▲%d", diff))
case diff < 0:
return styleDeltaDown.Render(fmt.Sprintf("▼%d", -diff))
default:
return styleDeltaEqual.Render("=")
}
}
// meetingStatus returns a status indicator for a meeting.
// isNext should be true only for the first upcoming meeting.
func meetingStatus(m models.Meeting, now time.Time, isNext bool) string {
end, err := time.Parse(time.RFC3339, m.DateEnd)
if err != nil {
// Fallback: try date-only parsing
end, err = time.Parse("2006-01-02", m.DateEnd[:min(len(m.DateEnd), 10)])
if err != nil {
return " "
}
end = end.Add(24 * time.Hour)
}
if end.Before(now) {
return stylePast.Render("✓")
}
if isNext {
return styleNext.Render("◎")
}
return " "
}
// truncate shortens a string to max runes, appending "…" if truncated.
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
if max <= 1 {
return "…"
}
return string(runes[:max-1]) + "…"
}
// padRight pads or truncates a string to exactly width runes.
func padRight(s string, width int) string {
runes := []rune(s)
if len(runes) >= width {
return string(runes[:width])
}
return s + strings.Repeat(" ", width-len(runes))
}
// padLeft left-pads a string to exactly width runes.
func padLeft(s string, width int) string {
runes := []rune(s)
if len(runes) >= width {
return string(runes[:width])
}
return strings.Repeat(" ", width-len(runes)) + s
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// countryFlag converts a country flag URL or code to an emoji flag.
// OpenF1 provides flag URLs; we do a best-effort mapping from country_code.
func countryFlag(countryCode string) string {
// Convert ISO 3166-1 alpha-2 to emoji regional indicators
code := strings.ToUpper(countryCode)
if len(code) != 2 {
return " "
}
// Each letter maps to a regional indicator symbol (U+1F1E6 = 'A')
r1 := rune(0x1F1E6 + int(code[0]-'A'))
r2 := rune(0x1F1E6 + int(code[1]-'A'))
return string(r1) + string(r2)
}
// helpBar renders a horizontal help bar for a set of key hints.
func helpBar(hints ...string) string {
return styleHelp.Render(strings.Join(hints, " "))
}

106
plan.md Normal file
View File

@@ -0,0 +1,106 @@
Great name! **box-box** it is 🏎️
Here's your getting started guide:
---
## The Stack
| 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 |
---
## Core Bubble Tea Concepts to Know
Bubble Tea follows the **Elm architecture** — just 3 things:
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 { ... }
```
---
## Project Structure
```
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
```
---
## How to Bootstrap It
```bash
mkdir box-box && cd box-box
go mod init github.com/yourusername/box-box
# 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?