From 2599283c175291deefce7e04ff0e75157930d88d Mon Sep 17 00:00:00 2001 From: amantahiliani Date: Tue, 3 Mar 2026 00:48:05 -0500 Subject: [PATCH] TUI Build --- CLAUDE.md | 89 ++++++++ cmd/main.go | 28 ++- go.mod | 26 +++ go.sum | 48 ++++ internal/api/openf1.go | 44 ++-- internal/ui/app.go | 262 +++++++++++++++++++++ internal/ui/calendar.go | 206 +++++++++++++++++ internal/ui/driver.go | 355 +++++++++++++++++++++++++++++ internal/ui/keys.go | 90 ++++++++ internal/ui/messages.go | 92 ++++++++ internal/ui/racedetail.go | 463 ++++++++++++++++++++++++++++++++++++++ internal/ui/standings.go | 287 +++++++++++++++++++++++ internal/ui/styles.go | 103 +++++++++ internal/ui/util.go | 286 +++++++++++++++++++++++ plan.md | 106 +++++++++ 15 files changed, 2465 insertions(+), 20 deletions(-) create mode 100644 CLAUDE.md create mode 100644 internal/ui/app.go create mode 100644 internal/ui/calendar.go create mode 100644 internal/ui/driver.go create mode 100644 internal/ui/keys.go create mode 100644 internal/ui/messages.go create mode 100644 internal/ui/racedetail.go create mode 100644 internal/ui/standings.go create mode 100644 internal/ui/styles.go create mode 100644 internal/ui/util.go create mode 100644 plan.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3d10113 --- /dev/null +++ b/CLAUDE.md @@ -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` diff --git a/cmd/main.go b/cmd/main.go index 1d619dd..c3bf27a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod index 3d30f2a..4629db6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,29 @@ module github.com/AmanTahiliani/box-box 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 +) diff --git a/go.sum b/go.sum index e69de29..ae9cc6e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/api/openf1.go b/internal/api/openf1.go index dfb4ea9..126f3fe 100644 --- a/internal/api/openf1.go +++ b/internal/api/openf1.go @@ -115,35 +115,41 @@ func (c *OpenF1Client) GetTeamChampionship(sessionKey int) ([]models.Championshi return result, nil } -// GetLatestDriverChampionship returns championship standings for the most recent -// race session. Note: requires the caller to supply a recent race session_key; -// the OpenF1 API does not support a "latest" shortcut for this endpoint. -func (c *OpenF1Client) GetLatestDriverChampionship() ([]models.ChampionshipDriver, error) { - body, err := c.get(fmt.Sprintf("%s/v1/championship_drivers?session_key=latest", c.url)) +// getLatestRaceSessionKey returns the session_key of the most recent Race session +// by fetching sessions filtered to session_name=Race and returning the last one. +func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) { + body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race", c.url)) if err != nil { - return nil, err + return 0, err } defer body.Close() - var result []models.ChampionshipDriver - if err := json.NewDecoder(body).Decode(&result); err != nil { - return nil, err + var sessions []models.Session + if err := json.NewDecoder(body).Decode(&sessions); err != nil { + 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) { - body, err := c.get(fmt.Sprintf("%s/v1/championship_teams?session_key=latest", c.url)) + sessionKey, err := c.getLatestRaceSessionKey() if err != nil { - return nil, err + return nil, fmt.Errorf("could not resolve latest race session: %w", err) } - defer body.Close() - - var result []models.ChampionshipTeam - if err := json.NewDecoder(body).Decode(&result); err != nil { - return nil, err - } - return result, nil + return c.GetTeamChampionship(sessionKey) } func (c *OpenF1Client) GetSessionResult(sessionKey int) ([]models.SessionResult, error) { diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..e001ba4 --- /dev/null +++ b/internal/ui/app.go @@ -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)) +} diff --git a/internal/ui/calendar.go b/internal/ui/calendar.go new file mode 100644 index 0000000..1dbfede --- /dev/null +++ b/internal/ui/calendar.go @@ -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()) +} diff --git a/internal/ui/driver.go b/internal/ui/driver.go new file mode 100644 index 0000000..5a3fbec --- /dev/null +++ b/internal/ui/driver.go @@ -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") +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 0000000..6e22fee --- /dev/null +++ b/internal/ui/keys.go @@ -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"), + ), +} diff --git a/internal/ui/messages.go b/internal/ui/messages.go new file mode 100644 index 0000000..ef2589f --- /dev/null +++ b/internal/ui/messages.go @@ -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 +} diff --git a/internal/ui/racedetail.go b/internal/ui/racedetail.go new file mode 100644 index 0000000..00e3a42 --- /dev/null +++ b/internal/ui/racedetail.go @@ -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 + } +} diff --git a/internal/ui/standings.go b/internal/ui/standings.go new file mode 100644 index 0000000..e576531 --- /dev/null +++ b/internal/ui/standings.go @@ -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 +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..fca4f38 --- /dev/null +++ b/internal/ui/styles.go @@ -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) +) diff --git a/internal/ui/util.go b/internal/ui/util.go new file mode 100644 index 0000000..d7555b7 --- /dev/null +++ b/internal/ui/util.go @@ -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, " ")) +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..a60ce82 --- /dev/null +++ b/plan.md @@ -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?