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