mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
TUI Build
This commit is contained in:
89
CLAUDE.md
Normal file
89
CLAUDE.md
Normal 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`
|
||||||
28
cmd/main.go
28
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
26
go.mod
26
go.mod
@@ -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
48
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=
|
||||||
|
|||||||
@@ -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
262
internal/ui/app.go
Normal 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
206
internal/ui/calendar.go
Normal 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
355
internal/ui/driver.go
Normal 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
90
internal/ui/keys.go
Normal 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
92
internal/ui/messages.go
Normal 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
463
internal/ui/racedetail.go
Normal 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
287
internal/ui/standings.go
Normal 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
103
internal/ui/styles.go
Normal 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
286
internal/ui/util.go
Normal 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
106
plan.md
Normal 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?
|
||||||
Reference in New Issue
Block a user