Upgrades to Live and API Caching

This commit is contained in:
2026-03-27 00:30:53 -04:00
parent 7c2fcfef8d
commit 360bde4602
19 changed files with 838 additions and 225 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Binaries
cmd/cmd
box-box
# SQLite database files
*.db
*.db-wal
*.db-shm
# Old file cache
.cache/

BIN
box-box

Binary file not shown.

View File

@@ -11,7 +11,17 @@ import (
) )
func main() { func main() {
client := api.NewOpenF1Client("https://api.openf1.org", 15*time.Second) var client *api.OpenF1Client
if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" {
client = api.NewOpenF1ClientWithKey("https://api.openf1.org", 15*time.Second, apiKey)
} else {
client = api.NewOpenF1Client("https://api.openf1.org", 15*time.Second)
}
defer client.Close()
// Clean up old file-based cache (one-time migration).
go api.CleanupOldFileCache()
model := ui.NewAppModel(client) model := ui.NewAppModel(client)
p := tea.NewProgram( p := tea.NewProgram(

0
debug.log Normal file
View File

23
go.mod
View File

@@ -2,21 +2,28 @@ module github.com/AmanTahiliani/box-box
go 1.25.6 go 1.25.6
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/websocket v1.5.3
github.com/sahilm/fuzzy v0.1.1
modernc.org/sqlite v1.47.0
)
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 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/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/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
@@ -24,9 +31,13 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.3.8 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )

54
go.sum
View File

@@ -22,10 +22,20 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 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 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -40,15 +50,55 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 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= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -1,13 +1,14 @@
package api package api
import ( import (
"crypto/sha256" "database/sql"
"encoding/hex"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
_ "modernc.org/sqlite"
) )
// CacheStats tracks cache hit/miss statistics. // CacheStats tracks cache hit/miss statistics.
@@ -16,54 +17,76 @@ type CacheStats struct {
Misses int64 Misses int64
} }
// FileCache implements a file-backed HTTP response cache with TTL expiry. // Cache implements a SQLite-backed HTTP response cache with TTL expiry.
type FileCache struct { // The database is a single file stored in the user's cache directory.
dir string type Cache struct {
db *sql.DB
stats CacheStats stats CacheStats
} }
// Default TTL values. // Default TTL values.
const ( const (
// CacheTTLShort is for current-season, frequently changing data (meetings, sessions, results). // CacheTTLShort is for live/telemetry data that changes every few seconds.
CacheTTLShort = 15 * time.Minute CacheTTLShort = 15 * time.Minute
// CacheTTLMedium is for semi-stable data (championship standings, driver lists). // CacheTTLMedium is for semi-stable data (championship standings, driver lists).
CacheTTLMedium = 1 * time.Hour CacheTTLMedium = 1 * time.Hour
// CacheTTLLong is for historical data that rarely changes (past season data). // CacheTTLLong is for historical data that rarely changes (past season data).
CacheTTLLong = 24 * time.Hour CacheTTLLong = 24 * time.Hour
// CacheTTLForever is for data that will never change (completed past-season results).
CacheTTLForever = 0
) )
func NewFileCache() *FileCache { // NewCache creates a SQLite-backed cache. The database file is placed in the
var cacheDir string // user's OS cache directory under box-box/cache.db. No setup is required — the
userCacheDir, err := os.UserCacheDir() // schema is created automatically on first run.
if err == nil { func NewCache() *Cache {
cacheDir = filepath.Join(userCacheDir, "box-box", "openf1") dbPath := cacheDBPath()
} else {
// Fallback to a local .cache directory // Ensure the parent directory exists.
cacheDir = ".cache/box-box/openf1" _ = os.MkdirAll(filepath.Dir(dbPath), 0755)
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
// Fall back to in-memory if the file can't be opened.
db, _ = sql.Open("sqlite", ":memory:")
} }
// Ensure the cache directory exists // Limit connections — SQLite is single-writer.
_ = os.MkdirAll(cacheDir, 0755) db.SetMaxOpenConns(1)
return &FileCache{ // Create the table if it doesn't exist.
dir: cacheDir, _, _ = db.Exec(`
} CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
data BLOB NOT NULL,
created_at INTEGER NOT NULL
)
`)
// Create an index on created_at for efficient expiry cleanup.
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_cache_created_at ON cache(created_at)`)
return &Cache{db: db}
} }
func (c *FileCache) getCachePath(key string) string { // cacheDBPath returns the path to the cache database file.
hash := sha256.Sum256([]byte(key)) func cacheDBPath() string {
filename := hex.EncodeToString(hash[:]) + ".json" userCacheDir, err := os.UserCacheDir()
return filepath.Join(c.dir, filename) if err == nil {
return filepath.Join(userCacheDir, "box-box", "cache.db")
}
return filepath.Join(".cache", "box-box", "cache.db")
} }
// ttlForURL determines the appropriate TTL based on the URL pattern. // ttlForURL determines the appropriate TTL based on the URL pattern.
// Returns 0 (CacheTTLForever) for historical data that will never change.
func ttlForURL(url string) time.Duration { func ttlForURL(url string) time.Duration {
// Historical data (specific year queries for past years) // Historical data — completed past seasons never change.
if strings.Contains(url, "year=2023") || strings.Contains(url, "year=2024") { if strings.Contains(url, "year=2023") || strings.Contains(url, "year=2024") {
return CacheTTLLong return CacheTTLForever
} }
// Frequently changing endpoints // Live telemetry endpoints — change every few seconds during a session.
if strings.Contains(url, "/position") || if strings.Contains(url, "/position") ||
strings.Contains(url, "/intervals") || strings.Contains(url, "/intervals") ||
strings.Contains(url, "/car_data") || strings.Contains(url, "/car_data") ||
@@ -71,88 +94,127 @@ func ttlForURL(url string) time.Duration {
return CacheTTLShort return CacheTTLShort
} }
// Semi-stable data // Semi-stable data — standings and driver info.
if strings.Contains(url, "/championship") || if strings.Contains(url, "/championship") ||
strings.Contains(url, "/drivers") { strings.Contains(url, "/drivers") {
return CacheTTLMedium return CacheTTLMedium
} }
// Default: medium TTL for everything else // Default: medium TTL for everything else.
return CacheTTLMedium return CacheTTLMedium
} }
// Get retrieves data from the cache. Returns nil, false if not found or expired. // Get retrieves data from the cache. Returns nil, false if not found or expired.
func (c *FileCache) Get(key string) ([]byte, bool) { func (c *Cache) Get(key string) ([]byte, bool) {
path := c.getCachePath(key) var data []byte
info, err := os.Stat(path) var createdAt int64
err := c.db.QueryRow(
`SELECT data, created_at FROM cache WHERE key = ?`, key,
).Scan(&data, &createdAt)
if err != nil { if err != nil {
atomic.AddInt64(&c.stats.Misses, 1) atomic.AddInt64(&c.stats.Misses, 1)
return nil, false return nil, false
} }
// Check TTL based on file modification time // Check TTL (0 = never expires).
ttl := ttlForURL(key) ttl := ttlForURL(key)
if time.Since(info.ModTime()) > ttl { if ttl > 0 {
// Expired — remove the stale file age := time.Since(time.Unix(createdAt, 0))
_ = os.Remove(path) if age > ttl {
atomic.AddInt64(&c.stats.Misses, 1) // Expired — delete and return miss.
return nil, false _, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
} atomic.AddInt64(&c.stats.Misses, 1)
return nil, false
data, err := os.ReadFile(path) }
if err != nil {
atomic.AddInt64(&c.stats.Misses, 1)
return nil, false
} }
atomic.AddInt64(&c.stats.Hits, 1) atomic.AddInt64(&c.stats.Hits, 1)
return data, true return data, true
} }
// Set saves data to the cache. // Set stores data in the cache, replacing any existing entry for the same key.
func (c *FileCache) Set(key string, data []byte) error { func (c *Cache) Set(key string, data []byte) error {
path := c.getCachePath(key) _, err := c.db.Exec(
return os.WriteFile(path, data, 0644) `INSERT OR REPLACE INTO cache (key, data, created_at) VALUES (?, ?, ?)`,
key, data, time.Now().Unix(),
)
return err
} }
// Stats returns current cache hit/miss stats. // Stats returns current cache hit/miss stats.
func (c *FileCache) Stats() CacheStats { func (c *Cache) Stats() CacheStats {
return CacheStats{ return CacheStats{
Hits: atomic.LoadInt64(&c.stats.Hits), Hits: atomic.LoadInt64(&c.stats.Hits),
Misses: atomic.LoadInt64(&c.stats.Misses), Misses: atomic.LoadInt64(&c.stats.Misses),
} }
} }
// Clear removes all cached files. // Clear removes all cached entries.
func (c *FileCache) Clear() error { func (c *Cache) Clear() error {
entries, err := os.ReadDir(c.dir) _, err := c.db.Exec(`DELETE FROM cache`)
if err != nil { return err
return err }
}
for _, entry := range entries { // Size returns the number of cached entries and total data size in bytes.
if strings.HasSuffix(entry.Name(), ".json") { func (c *Cache) Size() (int, int64) {
_ = os.Remove(filepath.Join(c.dir, entry.Name())) var count int
} var totalSize int64
_ = c.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(LENGTH(data)), 0) FROM cache`).Scan(&count, &totalSize)
return count, totalSize
}
// Prune removes expired entries from the cache. This can be called periodically
// to keep the database lean. It does not touch entries with CacheTTLForever.
func (c *Cache) Prune() error {
// Remove anything older than CacheTTLLong that isn't permanent.
// We can't perfectly distinguish by URL here, so we prune entries older
// than the longest non-permanent TTL. Permanent entries are re-set on each
// access, so their created_at stays fresh. As a safe cutoff, prune anything
// older than 7 days that hasn't been refreshed — this catches stale entries
// while keeping truly permanent historical data (which gets re-stored on use).
cutoff := time.Now().Add(-7 * 24 * time.Hour).Unix()
_, err := c.db.Exec(`DELETE FROM cache WHERE created_at < ?`, cutoff)
return err
}
// Close closes the database connection.
func (c *Cache) Close() error {
if c.db != nil {
return c.db.Close()
} }
return nil return nil
} }
// Size returns the number of cached files and total size in bytes. // CleanupOldFileCache removes the old file-based cache directory. Since file
func (c *FileCache) Size() (int, int64) { // cache entries used SHA-256 hashed filenames (not reversible), we can't
entries, err := os.ReadDir(c.dir) // migrate them — just clean up. New fetches will repopulate the SQLite cache.
func CleanupOldFileCache() {
oldDir := oldFileCacheDir()
entries, err := os.ReadDir(oldDir)
if err != nil { if err != nil {
return 0, 0 return
} }
count := 0
var totalSize int64
for _, entry := range entries { for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".json") { if strings.HasSuffix(entry.Name(), ".json") {
count++ _ = os.Remove(filepath.Join(oldDir, entry.Name()))
info, err := entry.Info()
if err == nil {
totalSize += info.Size()
}
} }
} }
return count, totalSize
// Remove the old directory if empty.
remaining, _ := os.ReadDir(oldDir)
if len(remaining) == 0 {
_ = os.Remove(oldDir)
}
}
func oldFileCacheDir() string {
userCacheDir, err := os.UserCacheDir()
if err == nil {
return filepath.Join(userCacheDir, "box-box", "openf1")
}
return filepath.Join(".cache", "box-box", "openf1")
} }

View File

@@ -7,15 +7,27 @@ import (
type OpenF1Client struct { type OpenF1Client struct {
url string url string
apiKey string
httpClient *http.Client httpClient *http.Client
cache *FileCache cache *Cache
} }
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client { func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
return &OpenF1Client{ return &OpenF1Client{
url: url, url: url,
httpClient: &http.Client{Timeout: timeout}, httpClient: &http.Client{Timeout: timeout},
cache: NewFileCache(), cache: NewCache(),
}
}
// NewOpenF1ClientWithKey creates a client that authenticates with a Bearer token.
// This allows access during live sessions (paid tier).
func NewOpenF1ClientWithKey(url string, timeout time.Duration, apiKey string) *OpenF1Client {
return &OpenF1Client{
url: url,
apiKey: apiKey,
httpClient: &http.Client{Timeout: timeout},
cache: NewCache(),
} }
} }
@@ -28,3 +40,8 @@ func (c *OpenF1Client) CacheStats() CacheStats {
func (c *OpenF1Client) CacheSize() (int, int64) { func (c *OpenF1Client) CacheSize() (int, int64) {
return c.cache.Size() return c.cache.Size()
} }
// Close releases resources held by the client (closes the cache database).
func (c *OpenF1Client) Close() error {
return c.cache.Close()
}

View File

@@ -8,10 +8,23 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
) )
// ErrLiveSessionLocked is returned when the OpenF1 API blocks free-tier access
// during a live F1 session. All endpoints (including historical data) return 401
// from ~30 min before a session starts until ~30 min after it ends.
var ErrLiveSessionLocked = errors.New("live F1 session in progress — API access is restricted to authenticated users until the session ends")
// IsLiveSessionError reports whether err (or any error in its chain) is the
// live-session lockout error from the OpenF1 API.
func IsLiveSessionError(err error) bool {
return errors.Is(err, ErrLiveSessionLocked)
}
// get performs a GET request and returns the response body, or an error if the // get performs a GET request and returns the response body, or an error if the
// status code is not 200 OK. It checks a local file cache before making the request. // status code is not 200 OK. It checks a local file cache before making the request.
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) { func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
@@ -19,13 +32,35 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(cachedData)), nil return io.NopCloser(bytes.NewReader(cachedData)), nil
} }
resp, err := c.httpClient.Get(url) req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// Try to parse the JSON error body for a better message.
if resp.StatusCode == http.StatusUnauthorized {
body, _ := io.ReadAll(resp.Body)
var apiErr struct {
Detail string `json:"detail"`
}
if json.Unmarshal(body, &apiErr) == nil && apiErr.Detail != "" {
detail := strings.ToLower(apiErr.Detail)
if strings.Contains(detail, "live") && strings.Contains(detail, "session") {
return nil, fmt.Errorf("%w", ErrLiveSessionLocked)
}
return nil, fmt.Errorf("openf1 API: %s", apiErr.Detail)
}
}
return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url) return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url)
} }
@@ -130,8 +165,8 @@ func (c *OpenF1Client) GetTeamChampionship(sessionKey int) ([]models.Championshi
return result, nil return result, nil
} }
// getLatestRaceSessionKey returns the session_key of the most recent Race session // getLatestRaceSessionKey returns the session_key of the most recent completed
// by fetching sessions filtered to session_name=Race and returning the last one. // Race session across all years.
func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) { func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) {
body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race", c.url)) body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race", c.url))
if err != nil { if err != nil {
@@ -146,16 +181,31 @@ func (c *OpenF1Client) getLatestRaceSessionKey() (int, error) {
if len(sessions) == 0 { if len(sessions) == 0 {
return 0, errors.New("no Race sessions found") return 0, errors.New("no Race sessions found")
} }
return sessions[len(sessions)-1].SessionKey, nil
// Walk backwards to find the most recent completed race.
now := time.Now()
for i := len(sessions) - 1; i >= 0; i-- {
s := sessions[i]
if s.DateEnd != "" {
endTime, err := time.Parse(time.RFC3339, s.DateEnd)
if err == nil && endTime.Before(now) {
return s.SessionKey, nil
}
} else if s.DateStart != "" {
startTime, err := time.Parse(time.RFC3339, s.DateStart)
if err == nil && startTime.Add(3*time.Hour).Before(now) {
return s.SessionKey, nil
}
}
}
return 0, errors.New("no completed Race sessions found")
} }
// getLatestRaceSessionKeyForYear returns the session_key of the most recent Race session // getLatestRaceSessionKeyForYear returns the session_key of the most recent
// for a specific year. // completed Race session for a specific year. It walks backwards through the
// year's races to find one whose date_end is in the past (i.e. has results).
func (c *OpenF1Client) getLatestRaceSessionKeyForYear(year int) (int, error) { func (c *OpenF1Client) getLatestRaceSessionKeyForYear(year int) (int, error) {
// The OpenF1 API doesn't support a direct year filter on sessions yet (verified by docs/common patterns)
// so we'll fetch meetings for that year first, then find the latest session.
// Actually, session endpoint does support year filter according to some versions of docs.
// Let's try year filter first as it's more efficient.
body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race&year=%d", c.url, year)) body, err := c.get(fmt.Sprintf("%s/v1/sessions?session_name=Race&year=%d", c.url, year))
if err != nil { if err != nil {
return 0, err return 0, err
@@ -169,7 +219,26 @@ func (c *OpenF1Client) getLatestRaceSessionKeyForYear(year int) (int, error) {
if len(sessions) == 0 { if len(sessions) == 0 {
return 0, fmt.Errorf("no Race sessions found for year %d", year) return 0, fmt.Errorf("no Race sessions found for year %d", year)
} }
return sessions[len(sessions)-1].SessionKey, nil
// Walk backwards to find the most recent completed race.
now := time.Now()
for i := len(sessions) - 1; i >= 0; i-- {
s := sessions[i]
if s.DateEnd != "" {
endTime, err := time.Parse(time.RFC3339, s.DateEnd)
if err == nil && endTime.Before(now) {
return s.SessionKey, nil
}
} else if s.DateStart != "" {
// Fallback: if no DateEnd, check DateStart + 3 hours as a rough estimate.
startTime, err := time.Parse(time.RFC3339, s.DateStart)
if err == nil && startTime.Add(3*time.Hour).Before(now) {
return s.SessionKey, nil
}
}
}
return 0, fmt.Errorf("no completed Race sessions found for year %d", year)
} }
// GetLatestDriverChampionship returns championship standings for the most recent // GetLatestDriverChampionship returns championship standings for the most recent

View File

@@ -262,6 +262,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
case loadSecondaryDataMsg:
var cmd tea.Cmd
m.raceDetail, cmd = m.raceDetail.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
case driverListLoadedMsg: case driverListLoadedMsg:
var cmd tea.Cmd var cmd tea.Cmd
m.driver, cmd = m.driver.Update(msg) m.driver, cmd = m.driver.Update(msg)

View File

@@ -171,8 +171,7 @@ func (m CalendarModel) View() string {
return fmt.Sprintf("\n %s Loading %d calendar...", m.spinner.View(), m.year) return fmt.Sprintf("\n %s Loading %d calendar...", m.spinner.View(), m.year)
} }
if m.err != nil { if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) + return renderErrorView(m.err)
helpBar("r retry", "q quit")
} }
if len(m.meetings) == 0 { if len(m.meetings) == 0 {
return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.\n", m.year)) return styleMuted.Render(fmt.Sprintf("\n No meetings found for %d.\n", m.year))

View File

@@ -134,6 +134,12 @@ func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) {
case tickCountdownMsg: case tickCountdownMsg:
return m, tickCountdown() return m, tickCountdown()
case tea.KeyMsg:
if matchKey(msg, GlobalKeys.Retry) && m.err != nil {
m.err = nil
m.loading = true
return m, m.Init()
}
} }
return m, nil return m, nil
} }
@@ -143,7 +149,7 @@ func (m DashboardModel) View() string {
return fmt.Sprintf("\n %s Loading dashboard...", m.spinner.View()) return fmt.Sprintf("\n %s Loading dashboard...", m.spinner.View())
} }
if m.err != nil { if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v\n", m.err)) return renderErrorView(m.err)
} }
if m.next == nil { if m.next == nil {

View File

@@ -388,8 +388,7 @@ func (m DriverModel) View() string {
return fmt.Sprintf("\n %s Loading drivers...", m.spinner.View()) return fmt.Sprintf("\n %s Loading drivers...", m.spinner.View())
} }
if m.err != nil { if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) + return renderErrorView(m.err)
helpBar("r retry", "q quit")
} }
switch m.view { switch m.view {

View File

@@ -185,7 +185,7 @@ func (m LiveModel) View() string {
return fmt.Sprintf("\n %s Loading live telemetry...", m.spinner.View()) return fmt.Sprintf("\n %s Loading live telemetry...", m.spinner.View())
} }
if m.err != nil && len(m.positions) == 0 { if m.err != nil && len(m.positions) == 0 {
return styleError.Render(fmt.Sprintf("\n Error: %v\n", m.err)) return renderErrorView(m.err)
} }
if m.session == nil { if m.session == nil {
return styleMuted.Render("\n No active session found.\n") return styleMuted.Render("\n No active session found.\n")

View File

@@ -114,3 +114,9 @@ type driverSelectedMsg struct {
driver models.Driver driver models.Driver
sessionKey int sessionKey int
} }
// loadSecondaryDataMsg triggers loading of secondary session data (race control, weather, overtakes)
// after the primary data (results, drivers) has arrived.
type loadSecondaryDataMsg struct {
sessionKey int
}

View File

@@ -8,6 +8,7 @@ import (
"net/url" "net/url"
"sort" "sort"
"strings" "strings"
"time"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -35,21 +36,27 @@ type F1TimingLine struct {
IntervalToPositionAhead struct { IntervalToPositionAhead struct {
Value interface{} `json:"Value"` Value interface{} `json:"Value"`
} `json:"IntervalToPositionAhead"` } `json:"IntervalToPositionAhead"`
Position string `json:"Position"` Position interface{} `json:"Position"`
RacingNumber string `json:"RacingNumber"` RacingNumber string `json:"RacingNumber"`
LastLapTime struct { LastLapTime struct {
Value string `json:"Value"` Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"` PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"` OverallFastest bool `json:"OverallFastest"`
} `json:"LastLapTime"` } `json:"LastLapTime"`
BestLapTime struct { BestLapTime struct {
Value string `json:"Value"` Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
Lap int `json:"Lap"`
} `json:"BestLapTime"` } `json:"BestLapTime"`
InPit interface{} `json:"InPit"` InPit interface{} `json:"InPit"`
PitOut interface{} `json:"PitOut"` PitOut interface{} `json:"PitOut"`
Retired interface{} `json:"Retired"` Retired interface{} `json:"Retired"`
KnockedOut interface{} `json:"KnockedOut"`
Cutoff interface{} `json:"Cutoff"`
NumberOfLaps interface{} `json:"NumberOfLaps"` NumberOfLaps interface{} `json:"NumberOfLaps"`
Sectors map[string]json.RawMessage `json:"Sectors"` Sectors map[string]json.RawMessage `json:"Sectors"`
Speeds map[string]json.RawMessage `json:"Speeds"`
} }
type F1DriverListEntry struct { type F1DriverListEntry struct {
@@ -108,10 +115,17 @@ type LiveDriverData struct {
LastLapPB bool // personal best LastLapPB bool // personal best
LastLapOB bool // overall best LastLapOB bool // overall best
BestLapTime string BestLapTime string
BestLapPB bool // just set a new personal best
BestLapOB bool // overall fastest in session
BestLapNum int // lap number when best was set
InPit bool InPit bool
PitOut bool PitOut bool
Retired bool Retired bool
KnockedOut bool // eliminated in qualifying
Cutoff bool // currently in elimination zone (danger zone)
OnFlyingLap bool // currently running a timed lap (derived from sector state)
NumberOfLaps int NumberOfLaps int
SpeedTrap string // fastest recorded speed at speed trap
Sectors [3]LiveSectorData Sectors [3]LiveSectorData
} }
@@ -122,17 +136,19 @@ type LiveStintData struct {
} }
type LiveStreamData struct { type LiveStreamData struct {
Drivers map[string]LiveDriverData Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData Tyres map[string]LiveTyreData
RCMessages []LiveRCMessage RCMessages []LiveRCMessage
Weather LiveWeatherData Weather LiveWeatherData
Session LiveSessionMeta Session LiveSessionMeta
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int CurrentLap int
TotalLaps int TotalLaps int
Clock string // "HH:MM:SS" remaining Clock string // "HH:MM:SS" remaining at ClockRefTime
Stints map[string][]LiveStintData ClockRefTime time.Time // UTC when Clock was accurate
ClockExtrapolating bool // true = actively counting down
Stints map[string][]LiveStintData
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -197,6 +213,8 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
var trackStatus string var trackStatus string
var currentLap, totalLaps int var currentLap, totalLaps int
var clock string var clock string
var clockRefTime time.Time
var clockExtrapolating bool
sendUpdate := func() { sendUpdate := func() {
cpyDrivers := make(map[string]LiveDriverData) cpyDrivers := make(map[string]LiveDriverData)
@@ -222,17 +240,19 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
select { select {
case dataChan <- LiveStreamData{ case dataChan <- LiveStreamData{
Drivers: cpyDrivers, Drivers: cpyDrivers,
DriverInfo: cpyInfo, DriverInfo: cpyInfo,
Tyres: cpyTyres, Tyres: cpyTyres,
RCMessages: cpyRC, RCMessages: cpyRC,
Weather: weather, Weather: weather,
Session: session, Session: session,
TrackStatus: trackStatus, TrackStatus: trackStatus,
CurrentLap: currentLap, CurrentLap: currentLap,
TotalLaps: totalLaps, TotalLaps: totalLaps,
Clock: clock, Clock: clock,
Stints: cpyStints, ClockRefTime: clockRefTime,
ClockExtrapolating: clockExtrapolating,
Stints: cpyStints,
}: }:
default: default:
} }
@@ -248,6 +268,10 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
} }
if json.Unmarshal(data, &td) == nil { if json.Unmarshal(data, &td) == nil {
for num, lineRaw := range td.Lines { for num, lineRaw := range td.Lines {
// Debug: dump first driver's raw JSON to see field types
if num == "1" || num == "81" || num == "44" {
log.Printf("[DEBUG TimingData] driver=%s raw=%s", num, string(lineRaw))
}
var line F1TimingLine var line F1TimingLine
if json.Unmarshal(lineRaw, &line) == nil { if json.Unmarshal(lineRaw, &line) == nil {
updateDriver(drivers, num, line) updateDriver(drivers, num, line)
@@ -282,10 +306,25 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
} }
case "ExtrapolatedClock": case "ExtrapolatedClock":
var ec struct { var ec struct {
Remaining string `json:"Remaining"` Remaining string `json:"Remaining"`
Utc string `json:"Utc"`
Extrapolating bool `json:"Extrapolating"`
} }
if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" { if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" {
clock = ec.Remaining clock = ec.Remaining
clockExtrapolating = ec.Extrapolating
if ec.Utc != "" {
// Try RFC3339 first, then with milliseconds
if t, err := time.Parse(time.RFC3339, ec.Utc); err == nil {
clockRefTime = t
} else if t, err := time.Parse("2006-01-02T15:04:05.999Z", ec.Utc); err == nil {
clockRefTime = t
} else {
clockRefTime = time.Now()
}
} else {
clockRefTime = time.Now()
}
updated = true updated = true
} }
case "TrackStatus": case "TrackStatus":
@@ -423,11 +462,15 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
} }
if len(driverStints) > 0 { if len(driverStints) > 0 {
stints[num] = driverStints stints[num] = driverStints
// Update tyre age from latest stint // Always sync tyre from latest stint
if t, ok := tyres[num]; ok { lastStint := driverStints[len(driverStints)-1]
t.Age = driverStints[len(driverStints)-1].Laps t := tyres[num]
tyres[num] = t t.Age = lastStint.Laps
if t.Compound == "" && lastStint.Compound != "" {
t.Compound = lastStint.Compound
t.New = lastStint.New
} }
tyres[num] = t
updated = true updated = true
} }
} }
@@ -512,19 +555,28 @@ func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLi
} }
} }
if line.Position != "" { if line.Position != nil {
var newPos int var newPos int
fmt.Sscanf(line.Position, "%d", &newPos) switch v := line.Position.(type) {
case string:
fmt.Sscanf(v, "%d", &newPos)
case float64:
newPos = int(v)
}
if newPos > 0 && newPos != d.Position { if newPos > 0 && newPos != d.Position {
d.PrevPosition = d.Position d.PrevPosition = d.Position
d.Position = newPos d.Position = newPos
} }
} }
if line.GapToLeader != nil { if line.GapToLeader != nil {
d.GapToLeader = fmt.Sprintf("%v", line.GapToLeader) if s := extractStringVal(line.GapToLeader); s != "" {
d.GapToLeader = s
}
} }
if line.IntervalToPositionAhead.Value != nil { if line.IntervalToPositionAhead.Value != nil {
d.Interval = fmt.Sprintf("%v", line.IntervalToPositionAhead.Value) if s := extractStringVal(line.IntervalToPositionAhead.Value); s != "" {
d.Interval = s
}
} }
if line.LastLapTime.Value != "" { if line.LastLapTime.Value != "" {
d.LastLapTime = line.LastLapTime.Value d.LastLapTime = line.LastLapTime.Value
@@ -533,6 +585,11 @@ func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLi
} }
if line.BestLapTime.Value != "" { if line.BestLapTime.Value != "" {
d.BestLapTime = line.BestLapTime.Value d.BestLapTime = line.BestLapTime.Value
d.BestLapPB = line.BestLapTime.PersonalFastest
d.BestLapOB = line.BestLapTime.OverallFastest
if line.BestLapTime.Lap > 0 {
d.BestLapNum = line.BestLapTime.Lap
}
} }
if line.InPit != nil { if line.InPit != nil {
d.InPit = toBool(line.InPit) d.InPit = toBool(line.InPit)
@@ -543,13 +600,29 @@ func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLi
if line.Retired != nil { if line.Retired != nil {
d.Retired = toBool(line.Retired) d.Retired = toBool(line.Retired)
} }
if line.KnockedOut != nil {
d.KnockedOut = toBool(line.KnockedOut)
}
if line.Cutoff != nil {
d.Cutoff = toBool(line.Cutoff)
}
if line.NumberOfLaps != nil { if line.NumberOfLaps != nil {
if v, ok := toInt(line.NumberOfLaps); ok { if v, ok := toInt(line.NumberOfLaps); ok {
d.NumberOfLaps = v d.NumberOfLaps = v
} }
} }
// Parse sector times // Parse speed trap (ST = highest speed on track)
if st, ok := line.Speeds["ST"]; ok {
var sp struct {
Value string `json:"Value"`
}
if json.Unmarshal(st, &sp) == nil && sp.Value != "" {
d.SpeedTrap = sp.Value
}
}
// Parse sector times — handle empty Value as a sector clear (new lap starting)
for idx, sRaw := range line.Sectors { for idx, sRaw := range line.Sectors {
i := 0 i := 0
fmt.Sscanf(idx, "%d", &i) fmt.Sscanf(idx, "%d", &i)
@@ -559,19 +632,50 @@ func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLi
PersonalFastest bool `json:"PersonalFastest"` PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"` OverallFastest bool `json:"OverallFastest"`
} }
if json.Unmarshal(sRaw, &sec) == nil && sec.Value != "" { if json.Unmarshal(sRaw, &sec) == nil {
d.Sectors[i] = LiveSectorData{ if sec.Value == "" {
Value: sec.Value, d.Sectors[i] = LiveSectorData{} // clear = new lap starting
PersonalFastest: sec.PersonalFastest, } else {
OverallFastest: sec.OverallFastest, d.Sectors[i] = LiveSectorData{
Value: sec.Value,
PersonalFastest: sec.PersonalFastest,
OverallFastest: sec.OverallFastest,
}
} }
} }
} }
} }
// Derive: driver is on a flying lap if S1 or S2 populated but S3 not yet
d.OnFlyingLap = !d.InPit && !d.Retired &&
(d.Sectors[0].Value != "" || d.Sectors[1].Value != "") &&
d.Sectors[2].Value == ""
drivers[num] = d drivers[num] = d
} }
// extractStringVal extracts a string from a timing value that may arrive as a
// plain string, a float64, or a {"Value": "..."} object from the SignalR feed.
func extractStringVal(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case float64:
if val == 0 {
return ""
}
return fmt.Sprintf("+%.3f", val)
case map[string]interface{}:
if s, ok := val["Value"].(string); ok {
return s
}
}
return ""
}
func toBool(v interface{}) bool { func toBool(v interface{}) bool {
switch val := v.(type) { switch val := v.(type) {
case bool: case bool:
@@ -611,10 +715,50 @@ func listenForWSData(sub chan LiveStreamData) tea.Cmd {
} }
} }
type clockTickMsg time.Time
func clockTick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return clockTickMsg(t)
})
}
func parseGap(val string) string { func parseGap(val string) string {
return val return val
} }
// compoundAbbrevStyle returns the single-letter abbreviation and lipgloss style for a tyre compound string.
func compoundAbbrevStyle(compound string) (string, lipgloss.Style) {
switch {
case strings.Contains(compound, "SOFT") || compound == "C4" || compound == "C5":
return "S", lipgloss.NewStyle().Foreground(lipgloss.Color(colorSoft)).Bold(true)
case strings.Contains(compound, "MEDIUM") || compound == "C3":
return "M", lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)).Bold(true)
case strings.Contains(compound, "HARD") || compound == "C1" || compound == "C2":
return "H", lipgloss.NewStyle().Foreground(lipgloss.Color(colorHard)).Bold(true)
case strings.Contains(compound, "INTER"):
return "I", lipgloss.NewStyle().Foreground(lipgloss.Color(colorInter)).Bold(true)
case strings.Contains(compound, "WET"):
return "W", lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet)).Bold(true)
default:
abbrev := "?"
if compound != "" {
abbrev = string([]rune(compound)[0])
}
return abbrev, styleMuted
}
}
// parseHHMMSS parses "H:MM:SS" or "HH:MM:SS" into a time.Duration.
func parseHHMMSS(s string) (time.Duration, error) {
var h, m, sec int
_, err := fmt.Sscanf(s, "%d:%d:%d", &h, &m, &sec)
if err != nil {
return 0, err
}
return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec)*time.Second, nil
}
type OfficialLiveModel struct { type OfficialLiveModel struct {
width int width int
height int height int
@@ -626,12 +770,14 @@ type OfficialLiveModel struct {
rcMessages []LiveRCMessage rcMessages []LiveRCMessage
weather LiveWeatherData weather LiveWeatherData
session LiveSessionMeta session LiveSessionMeta
trackStatus string trackStatus string
currentLap int currentLap int
totalLaps int totalLaps int
clock string clock string
stints map[string][]LiveStintData clockRefTime time.Time
err error clockExtrapolating bool
stints map[string][]LiveStintData
err error
// UI state // UI state
cursor int cursor int
@@ -659,20 +805,91 @@ func (m OfficialLiveModel) Init() tea.Cmd {
if err != nil { if err != nil {
return func() tea.Msg { return err } return func() tea.Msg { return err }
} }
return listenForWSData(m.dataChan) return tea.Batch(listenForWSData(m.dataChan), clockTick())
}
// displayClock returns the session clock, counting down locally between feed updates.
func (m OfficialLiveModel) displayClock() string {
if m.clock == "" {
return ""
}
if !m.clockExtrapolating || m.clockRefTime.IsZero() {
return m.clock
}
remaining, err := parseHHMMSS(m.clock)
if err != nil {
return m.clock
}
elapsed := time.Since(m.clockRefTime)
actual := remaining - elapsed
if actual < 0 {
actual = 0
}
h := int(actual.Hours())
mnt := int(actual.Minutes()) % 60
sec := int(actual.Seconds()) % 60
return fmt.Sprintf("%02d:%02d:%02d", h, mnt, sec)
} }
func (m OfficialLiveModel) sortedDrivers() []LiveDriverData { func (m OfficialLiveModel) sortedDrivers() []LiveDriverData {
var drivers []LiveDriverData // Merge timing data with driver list so all known drivers appear,
for _, d := range m.drivers { // even those who have not set a lap time yet (Position == 0).
if d.Position > 0 { merged := make(map[string]LiveDriverData, len(m.drivers)+len(m.driverInfo))
drivers = append(drivers, d) for num, d := range m.drivers {
merged[num] = d
}
for num := range m.driverInfo {
if _, exists := merged[num]; !exists {
merged[num] = LiveDriverData{RacingNumber: num}
} }
} }
sort.Slice(drivers, func(i, j int) bool {
return drivers[i].Position < drivers[j].Position var positioned, unpositioned []LiveDriverData
for _, d := range merged {
if d.Position > 0 {
positioned = append(positioned, d)
} else {
unpositioned = append(unpositioned, d)
}
}
sort.Slice(positioned, func(i, j int) bool {
return positioned[i].Position < positioned[j].Position
}) })
return drivers sort.Slice(unpositioned, func(i, j int) bool {
var ni, nj int
fmt.Sscanf(unpositioned[i].RacingNumber, "%d", &ni)
fmt.Sscanf(unpositioned[j].RacingNumber, "%d", &nj)
return ni < nj
})
return append(positioned, unpositioned...)
}
// isPracticeOrQuali returns true for Free Practice, Qualifying, and Sprint Qualifying.
// In these sessions the timing tower shows BEST lap time as the primary column.
func (m OfficialLiveModel) isPracticeOrQuali() bool {
t := strings.ToLower(m.session.SessionType)
return strings.Contains(t, "practice") ||
strings.Contains(t, "qualifying") ||
strings.Contains(t, "sprint") ||
t == "fp1" || t == "fp2" || t == "fp3" ||
t == "q" || t == "sq"
}
// overallBestLapTime returns the string of the overall fastest BestLapTime across all drivers.
// Uses lexicographic comparison which is valid for M:SS.mmm formatted times.
func (m OfficialLiveModel) overallBestLapTime() string {
best := ""
for _, d := range m.drivers {
if d.BestLapTime == "" {
continue
}
if best == "" || d.BestLapTime < best {
best = d.BestLapTime
}
}
return best
} }
func (m OfficialLiveModel) visibleRows() int { func (m OfficialLiveModel) visibleRows() int {
@@ -735,6 +952,9 @@ func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) {
case error: case error:
m.err = msg m.err = msg
return m, nil return m, nil
case clockTickMsg:
// Re-render every second so the local countdown stays smooth
return m, clockTick()
case wsDataMsg: case wsDataMsg:
m.drivers = msg.Drivers m.drivers = msg.Drivers
m.driverInfo = msg.DriverInfo m.driverInfo = msg.DriverInfo
@@ -746,6 +966,8 @@ func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) {
m.currentLap = msg.CurrentLap m.currentLap = msg.CurrentLap
m.totalLaps = msg.TotalLaps m.totalLaps = msg.TotalLaps
m.clock = msg.Clock m.clock = msg.Clock
m.clockRefTime = msg.ClockRefTime
m.clockExtrapolating = msg.ClockExtrapolating
m.stints = msg.Stints m.stints = msg.Stints
m.updateRCViewport() m.updateRCViewport()
return m, listenForWSData(m.dataChan) return m, listenForWSData(m.dataChan)
@@ -872,7 +1094,11 @@ func (m OfficialLiveModel) View() string {
func (m OfficialLiveModel) renderLiveHeader(w int) string { func (m OfficialLiveModel) renderLiveHeader(w int) string {
var sb strings.Builder var sb strings.Builder
sessionType := m.session.SessionType // Prefer specific session name (e.g. "FP1", "Q3") over generic type
sessionType := m.session.SessionName
if sessionType == "" {
sessionType = m.session.SessionType
}
if sessionType == "" { if sessionType == "" {
sessionType = "LIVE" sessionType = "LIVE"
} }
@@ -912,8 +1138,8 @@ func (m OfficialLiveModel) renderLiveHeader(w int) string {
parts = append(parts, label) parts = append(parts, label)
} }
if m.clock != "" { if clk := m.displayClock(); clk != "" {
parts = append(parts, styleCountdown.Render(m.clock)) parts = append(parts, styleCountdown.Render(clk))
} }
sb.WriteString("\n " + strings.Join(parts, " ") + "\n") sb.WriteString("\n " + strings.Join(parts, " ") + "\n")
@@ -988,13 +1214,23 @@ func (m OfficialLiveModel) renderTimingTower(w int) string {
var sb strings.Builder var sb strings.Builder
drivers := m.sortedDrivers() drivers := m.sortedDrivers()
fpq := m.isPracticeOrQuali()
var header string var header string
if m.showSectors { if m.showSectors {
timeLabel := "LAST"
if fpq {
timeLabel = "BEST"
}
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s", header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4), padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4),
padRight("TYRE", 5), padRight("LAST", 10), padRight("TYRE", 5), padRight(timeLabel, 10),
padRight("S1", 8), padRight("S2", 8), padRight("S3", 8), padRight("S1", 8), padRight("S2", 8), padRight("S3", 8),
padRight("GAP", 10)) padRight("GAP", 10))
} else if fpq {
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4),
padRight("TYRE", 5), padRight("AGE", 3), padRight("", 1),
padRight("BEST", 10), padRight("LAST", 10), padRight("GAP", 10))
} else { } else {
header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s", header = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s",
padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4), padRight("P", 3), padRight("Δ", 2), padRight("", 1), padRight("TLA", 4),
@@ -1010,8 +1246,9 @@ func (m OfficialLiveModel) renderTimingTower(w int) string {
endIdx = len(drivers) endIdx = len(drivers)
} }
overallBest := m.overallBestLapTime()
for i := m.scroll; i < endIdx; i++ { for i := m.scroll; i < endIdx; i++ {
sb.WriteString(m.renderDriverRow(drivers[i], i) + "\n") sb.WriteString(m.renderDriverRow(drivers[i], i, fpq, overallBest) + "\n")
} }
if len(drivers) > visible { if len(drivers) > visible {
@@ -1021,7 +1258,7 @@ func (m OfficialLiveModel) renderTimingTower(w int) string {
return sb.String() return sb.String()
} }
func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int) string { func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int, fpq bool, overallBest string) string {
info, hasInfo := m.driverInfo[d.RacingNumber] info, hasInfo := m.driverInfo[d.RacingNumber]
tla := d.RacingNumber tla := d.RacingNumber
teamColor := colorMuted teamColor := colorMuted
@@ -1042,7 +1279,13 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int) string {
} }
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃") colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
tlaStr := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(teamColor)).Render(padRight(tla, 4))
// In qualifying, dim knocked-out drivers
tlaStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(teamColor))
if d.KnockedOut {
tlaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
}
tlaStr := tlaStyle.Render(padRight(tla, 4))
tyreStr := m.renderTyreIndicator(d.RacingNumber) tyreStr := m.renderTyreIndicator(d.RacingNumber)
if d.Retired { if d.Retired {
@@ -1052,6 +1295,9 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int) string {
if idx == m.cursor { if idx == m.cursor {
return styleSelected.Render(row) return styleSelected.Render(row)
} }
if d.KnockedOut {
return styleMuted.Render(row)
}
return row return row
} }
@@ -1059,7 +1305,7 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int) string {
row := fmt.Sprintf(" %s %s %s %s %s %s %s", row := fmt.Sprintf(" %s %s %s %s %s %s %s",
posStr, deltaStr, colorBar, tlaStr, tyreStr, posStr, deltaStr, colorBar, tlaStr, tyreStr,
styleSafetyCar.Render(padRight("PIT", 10)), styleSafetyCar.Render(padRight("PIT", 10)),
m.renderGapStr(d)) m.renderGapStr(d, fpq))
if idx == m.cursor { if idx == m.cursor {
return styleSelected.Render(row) return styleSelected.Render(row)
} }
@@ -1067,41 +1313,86 @@ func (m OfficialLiveModel) renderDriverRow(d LiveDriverData, idx int) string {
} }
// Last lap time coloring // Last lap time coloring
lastLap := padRight(d.LastLapTime, 10) lastLapRaw := d.LastLapTime
if d.LastLapTime != "" { lastLap := padRight(lastLapRaw, 10)
if lastLapRaw != "" {
if d.LastLapOB { if d.LastLapOB {
lastLap = stylePurple.Render(padRight(d.LastLapTime, 10)) lastLap = padRightVisible(stylePurple.Render(lastLapRaw), 10)
} else if d.LastLapPB { } else if d.LastLapPB {
lastLap = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(padRight(d.LastLapTime, 10)) lastLap = padRightVisible(lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(lastLapRaw), 10)
} }
} }
var row string var row string
if m.showSectors { if m.showSectors {
timeCol := lastLap
if fpq {
timeCol = m.renderBestLapTime(d, overallBest)
}
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s", row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
posStr, deltaStr, colorBar, tlaStr, tyreStr, posStr, deltaStr, colorBar, tlaStr, tyreStr,
lastLap, timeCol,
m.renderSector(d.Sectors[0]), m.renderSector(d.Sectors[0]),
m.renderSector(d.Sectors[1]), m.renderSector(d.Sectors[1]),
m.renderSector(d.Sectors[2]), m.renderSector(d.Sectors[2]),
m.renderGapStr(d)) m.renderGapStr(d, fpq))
} else if fpq {
// FP / Qualifying: show BEST lap as primary, LAST as secondary
bestLap := m.renderBestLapTime(d, overallBest)
flyingIndicator := " "
if d.OnFlyingLap {
flyingIndicator = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)).Render("◎")
}
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s %s",
posStr, deltaStr, colorBar, tlaStr, tyreStr,
m.renderTyreAge(d.RacingNumber),
flyingIndicator,
bestLap,
lastLap,
m.renderGapStr(d, fpq))
// Highlight danger zone (cutoff) in qualifying
if d.Cutoff && idx != m.cursor {
row = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOrange)).Render(row)
}
} else { } else {
// Race mode: LAST + GAP + INT
row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s", row = fmt.Sprintf(" %s %s %s %s %s %s %s %s %s",
posStr, deltaStr, colorBar, tlaStr, tyreStr, posStr, deltaStr, colorBar, tlaStr, tyreStr,
m.renderTyreAge(d.RacingNumber), m.renderTyreAge(d.RacingNumber),
lastLap, lastLap,
m.renderGapStr(d), m.renderGapStr(d, fpq),
m.renderIntvStr(d)) m.renderIntvStr(d))
} }
if idx == m.cursor { if idx == m.cursor {
return styleSelected.Render(row) return styleSelected.Render(row)
} }
if d.KnockedOut {
return styleMuted.Render(row)
}
return row return row
} }
func (m OfficialLiveModel) renderGapStr(d LiveDriverData) string { // renderBestLapTime renders a driver's session best lap time with appropriate coloring.
func (m OfficialLiveModel) renderBestLapTime(d LiveDriverData, overallBest string) string {
if d.BestLapTime == "" {
return padRightVisible(styleMuted.Render("no time"), 10)
}
isOverallBest := overallBest != "" && d.BestLapTime == overallBest
if isOverallBest || d.BestLapOB {
return padRightVisible(stylePurple.Render(d.BestLapTime), 10)
}
if d.BestLapPB {
return padRightVisible(lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)).Render(d.BestLapTime), 10)
}
return padRightVisible(styleBold.Render(d.BestLapTime), 10)
}
func (m OfficialLiveModel) renderGapStr(d LiveDriverData, fpq bool) string {
if d.Position == 1 { if d.Position == 1 {
if fpq {
return padRightVisible(styleLeader.Render("P1"), 10)
}
return padRightVisible(styleLeader.Render("LEADER"), 10) return padRightVisible(styleLeader.Render("LEADER"), 10)
} }
if g := parseGap(d.GapToLeader); g != "" { if g := parseGap(d.GapToLeader); g != "" {
@@ -1127,22 +1418,7 @@ func (m OfficialLiveModel) renderTyreIndicator(num string) string {
} }
compound := strings.ToUpper(tyre.Compound) compound := strings.ToUpper(tyre.Compound)
abbrev := "?" abbrev, style := compoundAbbrevStyle(compound)
var style lipgloss.Style
switch {
case strings.Contains(compound, "SOFT"):
abbrev, style = "S", lipgloss.NewStyle().Foreground(lipgloss.Color(colorSoft)).Bold(true)
case strings.Contains(compound, "MEDIUM"):
abbrev, style = "M", lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)).Bold(true)
case strings.Contains(compound, "HARD"):
abbrev, style = "H", lipgloss.NewStyle().Foreground(lipgloss.Color(colorHard)).Bold(true)
case strings.Contains(compound, "INTER"):
abbrev, style = "I", lipgloss.NewStyle().Foreground(lipgloss.Color(colorInter)).Bold(true)
case strings.Contains(compound, "WET"):
abbrev, style = "W", lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet)).Bold(true)
default:
style = styleMuted
}
newMark := " " newMark := " "
if tyre.New { if tyre.New {
@@ -1284,22 +1560,7 @@ func (m OfficialLiveModel) renderDriverDetail(w int) string {
sb.WriteString(" ") sb.WriteString(" ")
for i, st := range driverStints { for i, st := range driverStints {
compound := strings.ToUpper(st.Compound) compound := strings.ToUpper(st.Compound)
abbrev := "?" abbrev, style := compoundAbbrevStyle(compound)
var style lipgloss.Style
switch {
case strings.Contains(compound, "SOFT"):
abbrev, style = "S", lipgloss.NewStyle().Foreground(lipgloss.Color(colorSoft)).Bold(true)
case strings.Contains(compound, "MEDIUM"):
abbrev, style = "M", lipgloss.NewStyle().Foreground(lipgloss.Color(colorMedium)).Bold(true)
case strings.Contains(compound, "HARD"):
abbrev, style = "H", lipgloss.NewStyle().Foreground(lipgloss.Color(colorHard)).Bold(true)
case strings.Contains(compound, "INTER"):
abbrev, style = "I", lipgloss.NewStyle().Foreground(lipgloss.Color(colorInter)).Bold(true)
case strings.Contains(compound, "WET"):
abbrev, style = "W", lipgloss.NewStyle().Foreground(lipgloss.Color(colorWet)).Bold(true)
default:
style = styleMuted
}
newMark := "" newMark := ""
if st.New { if st.New {
newMark = "*" newMark = "*"
@@ -1314,12 +1575,19 @@ func (m OfficialLiveModel) renderDriverDetail(w int) string {
if d.BestLapTime != "" { if d.BestLapTime != "" {
sb.WriteString(styleMuted.Render(" Best: ") + styleBold.Render(d.BestLapTime)) sb.WriteString(styleMuted.Render(" Best: ") + styleBold.Render(d.BestLapTime))
if d.LastLapOB { if d.BestLapOB || d.LastLapOB {
sb.WriteString(" " + stylePurple.Render("FL")) sb.WriteString(" " + stylePurple.Render("FL"))
} }
if d.BestLapNum > 0 {
sb.WriteString(styleMuted.Render(fmt.Sprintf(" (L%d)", d.BestLapNum)))
}
sb.WriteString("\n") sb.WriteString("\n")
} }
if d.SpeedTrap != "" {
sb.WriteString(styleMuted.Render(" Speed: ") + styleWeatherValue.Render(d.SpeedTrap+"km/h") + "\n")
}
sb.WriteString(fmt.Sprintf(" %s P%d %s %d laps\n", sb.WriteString(fmt.Sprintf(" %s P%d %s %d laps\n",
styleMuted.Render("Pos:"), d.Position, styleMuted.Render("Pos:"), d.Position,
styleMuted.Render("Laps:"), d.NumberOfLaps)) styleMuted.Render("Laps:"), d.NumberOfLaps))

View File

@@ -29,10 +29,12 @@ type RaceDetailModel struct {
resultsCursor int resultsCursor int
resultsScroll int resultsScroll int
loadingSessions bool loadingSessions bool
loadingResults bool loadingResults bool
errSessions error driversLoaded bool
errResults error secondaryLoading bool
errSessions error
errResults error
spinner spinner.Model spinner spinner.Model
rcView viewport.Model rcView viewport.Model
@@ -65,6 +67,8 @@ func fetchSessions(client *api.OpenF1Client, meetingKey int) tea.Cmd {
} }
} }
// fetchSessionData fetches primary data (results + drivers) for a session.
// Secondary data (race control, weather, overtakes) is loaded after primary data arrives.
func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd { func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
return tea.Batch( return tea.Batch(
func() tea.Msg { func() tea.Msg {
@@ -75,6 +79,12 @@ func fetchSessionData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
drivers, err := client.GetDriversForSession(sessionKey) drivers, err := client.GetDriversForSession(sessionKey)
return sessionDriversLoadedMsg{drivers: drivers, err: err} return sessionDriversLoadedMsg{drivers: drivers, err: err}
}, },
)
}
// fetchSecondaryData fetches lower-priority data (race control, weather, overtakes).
func fetchSecondaryData(client *api.OpenF1Client, sessionKey int) tea.Cmd {
return tea.Batch(
func() tea.Msg { func() tea.Msg {
msgs, err := client.GetRaceControl(sessionKey) msgs, err := client.GetRaceControl(sessionKey)
return raceControlLoadedMsg{messages: msgs, err: err} return raceControlLoadedMsg{messages: msgs, err: err}
@@ -117,6 +127,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.resultsScroll = 0 m.resultsScroll = 0
m.loadingSessions = true m.loadingSessions = true
m.loadingResults = false m.loadingResults = false
m.driversLoaded = false
m.secondaryLoading = false
m.errSessions = nil m.errSessions = nil
m.errResults = nil m.errResults = nil
m.rcReady = false m.rcReady = false
@@ -142,6 +154,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
sess := m.sessions[raceIdx] sess := m.sessions[raceIdx]
m.selectedSession = &sess m.selectedSession = &sess
m.loadingResults = true m.loadingResults = true
m.driversLoaded = false
m.secondaryLoading = false
m.results = nil m.results = nil
m.rcMsgs = nil m.rcMsgs = nil
m.weather = nil m.weather = nil
@@ -154,6 +168,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
sess := m.sessions[lastIdx] sess := m.sessions[lastIdx]
m.selectedSession = &sess m.selectedSession = &sess
m.loadingResults = true m.loadingResults = true
m.driversLoaded = false
m.secondaryLoading = false
m.drivers = make(map[int]models.Driver) m.drivers = make(map[int]models.Driver)
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey)) cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
} }
@@ -167,7 +183,9 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.results = msg.results m.results = msg.results
m.resultsCursor = 0 m.resultsCursor = 0
m.resultsScroll = 0 m.resultsScroll = 0
m.checkResultsLoaded() if cmd := m.checkPrimaryLoaded(); cmd != nil {
cmds = append(cmds, cmd)
}
case sessionDriversLoadedMsg: case sessionDriversLoadedMsg:
if msg.err == nil { if msg.err == nil {
@@ -175,7 +193,16 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
m.drivers[d.DriverNumber] = d m.drivers[d.DriverNumber] = d
} }
} }
m.checkResultsLoaded() m.driversLoaded = true
if cmd := m.checkPrimaryLoaded(); cmd != nil {
cmds = append(cmds, cmd)
}
case loadSecondaryDataMsg:
// Only load secondary data if it's still for the currently selected session
if m.selectedSession != nil && m.selectedSession.SessionKey == msg.sessionKey {
cmds = append(cmds, fetchSecondaryData(m.client, msg.sessionKey))
}
case raceControlLoadedMsg: case raceControlLoadedMsg:
if msg.err == nil { if msg.err == nil {
@@ -203,6 +230,8 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
} else if m.errResults != nil && m.selectedSession != nil { } else if m.errResults != nil && m.selectedSession != nil {
m.errResults = nil m.errResults = nil
m.loadingResults = true m.loadingResults = true
m.driversLoaded = false
m.secondaryLoading = false
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, m.selectedSession.SessionKey)) cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, m.selectedSession.SessionKey))
} }
case matchKey(msg, GlobalKeys.Up): case matchKey(msg, GlobalKeys.Up):
@@ -248,6 +277,9 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
sess := m.sessions[m.sessionCursor] sess := m.sessions[m.sessionCursor]
m.selectedSession = &sess m.selectedSession = &sess
m.loadingResults = true m.loadingResults = true
m.driversLoaded = false
m.secondaryLoading = false
m.errResults = nil
m.results = nil m.results = nil
m.rcMsgs = nil m.rcMsgs = nil
m.weather = nil m.weather = nil
@@ -264,10 +296,42 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
case matchKey(msg, RaceDetailKeys.PrevSession): case matchKey(msg, RaceDetailKeys.PrevSession):
if m.sessionCursor > 0 { if m.sessionCursor > 0 {
m.sessionCursor-- m.sessionCursor--
sess := m.sessions[m.sessionCursor]
if m.selectedSession == nil || m.selectedSession.SessionKey != sess.SessionKey {
m.selectedSession = &sess
m.loadingResults = true
m.driversLoaded = false
m.secondaryLoading = false
m.errResults = nil
m.results = nil
m.rcMsgs = nil
m.weather = nil
m.overtakes = nil
m.resultsCursor = 0
m.resultsScroll = 0
m.drivers = make(map[int]models.Driver)
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
}
} }
case matchKey(msg, RaceDetailKeys.NextSession): case matchKey(msg, RaceDetailKeys.NextSession):
if m.sessionCursor < len(m.sessions)-1 { if m.sessionCursor < len(m.sessions)-1 {
m.sessionCursor++ m.sessionCursor++
sess := m.sessions[m.sessionCursor]
if m.selectedSession == nil || m.selectedSession.SessionKey != sess.SessionKey {
m.selectedSession = &sess
m.loadingResults = true
m.driversLoaded = false
m.secondaryLoading = false
m.errResults = nil
m.results = nil
m.rcMsgs = nil
m.weather = nil
m.overtakes = nil
m.resultsCursor = 0
m.resultsScroll = 0
m.drivers = make(map[int]models.Driver)
cmds = append(cmds, m.spinner.Tick, fetchSessionData(m.client, sess.SessionKey))
}
} }
} }
} }
@@ -281,10 +345,19 @@ func (m RaceDetailModel) Update(msg tea.Msg) (RaceDetailModel, tea.Cmd) {
return m, tea.Batch(cmds...) return m, tea.Batch(cmds...)
} }
func (m *RaceDetailModel) checkResultsLoaded() { // checkPrimaryLoaded checks if both results and drivers have arrived.
if m.results != nil { // If so, marks loading complete and returns a command to trigger secondary data loading.
func (m *RaceDetailModel) checkPrimaryLoaded() tea.Cmd {
if m.results != nil && m.driversLoaded {
m.loadingResults = false m.loadingResults = false
if m.selectedSession != nil && !m.secondaryLoading {
m.secondaryLoading = true
return func() tea.Msg {
return loadSecondaryDataMsg{sessionKey: m.selectedSession.SessionKey}
}
}
} }
return nil
} }
func (m RaceDetailModel) resultsVisibleRows() int { func (m RaceDetailModel) resultsVisibleRows() int {
@@ -395,7 +468,7 @@ func (m RaceDetailModel) View() string {
sb.WriteString(panels + "\n") sb.WriteString(panels + "\n")
} }
sb.WriteString(helpBar("[/] sessions", "enter load", "j/k results", "g/G top/bottom", "K/J scroll RC", "b back", "q quit")) sb.WriteString(helpBar("[/] sessions", "j/k results", "g/G top/bottom", "K/J scroll RC", "b back", "q quit"))
return sb.String() return sb.String()
} }
@@ -404,7 +477,7 @@ func (m RaceDetailModel) renderSessionPills() string {
return fmt.Sprintf(" %s Loading sessions...", m.spinner.View()) return fmt.Sprintf(" %s Loading sessions...", m.spinner.View())
} }
if m.errSessions != nil { if m.errSessions != nil {
return styleError.Render(fmt.Sprintf(" Error: %v", m.errSessions)) return renderErrorView(m.errSessions)
} }
var pills []string var pills []string
@@ -444,11 +517,11 @@ func (m RaceDetailModel) renderResults(width int) string {
return sb.String() return sb.String()
} }
if m.errResults != nil { if m.errResults != nil {
sb.WriteString(styleError.Render(fmt.Sprintf(" Error: %v\n", m.errResults))) sb.WriteString(renderErrorView(m.errResults))
return sb.String() return sb.String()
} }
if m.selectedSession == nil { if m.selectedSession == nil {
sb.WriteString(styleMuted.Render(" Press Enter to load session results.\n")) sb.WriteString(styleMuted.Render(" Use [ ] to select a session.\n"))
return sb.String() return sb.String()
} }
if len(m.results) == 0 { if len(m.results) == 0 {

View File

@@ -216,8 +216,7 @@ func (m StandingsModel) View() string {
return fmt.Sprintf("\n %s Loading %d championship standings...", m.spinner.View(), m.year) return fmt.Sprintf("\n %s Loading %d championship standings...", m.spinner.View(), m.year)
} }
if m.err != nil { if m.err != nil {
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", m.err)) + return renderErrorView(m.err)
helpBar("r retry", "q quit")
} }
var sb strings.Builder var sb strings.Builder

View File

@@ -7,6 +7,7 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/models"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -459,3 +460,29 @@ func teamColorBar(teamColor string) string {
} }
return lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃") return lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
} }
// renderErrorView returns a formatted error view. If the error is the OpenF1
// live-session lockout, it shows a special informational banner instead of a
// raw error string.
func renderErrorView(err error) string {
if api.IsLiveSessionError(err) {
title := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorF1Red)).
Bold(true).
Render(" LIVE SESSION IN PROGRESS")
body := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorWhite)).
Render(" The OpenF1 API restricts all access (including historical data)\n during live F1 sessions. This applies to the free tier.")
hint := lipgloss.NewStyle().
Foreground(lipgloss.Color(colorMuted)).
Render(" Access will be restored ~30 minutes after the session ends.\n Set OPENF1_API_KEY to bypass this restriction (paid tier).")
return fmt.Sprintf("\n%s\n\n%s\n\n%s\n\n", title, body, hint) +
helpBar("r retry", "q quit")
}
return styleError.Render(fmt.Sprintf("\n Error: %v\n\n", err)) +
helpBar("r retry", "q quit")
}