mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Upgrades to Live and API Caching
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Binaries
|
||||||
|
cmd/cmd
|
||||||
|
box-box
|
||||||
|
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Old file cache
|
||||||
|
.cache/
|
||||||
12
cmd/main.go
12
cmd/main.go
@@ -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(
|
||||||
|
|||||||
23
go.mod
23
go.mod
@@ -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
54
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// Expired — delete and return miss.
|
||||||
|
_, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
|
||||||
atomic.AddInt64(&c.stats.Misses, 1)
|
atomic.AddInt64(&c.stats.Misses, 1)
|
||||||
return nil, false
|
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 {
|
|
||||||
if strings.HasSuffix(entry.Name(), ".json") {
|
// Size returns the number of cached entries and total data size in bytes.
|
||||||
_ = os.Remove(filepath.Join(c.dir, entry.Name()))
|
func (c *Cache) Size() (int, int64) {
|
||||||
}
|
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the old directory if empty.
|
||||||
|
remaining, _ := os.ReadDir(oldDir)
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
_ = os.Remove(oldDir)
|
||||||
}
|
}
|
||||||
return count, totalSize
|
}
|
||||||
|
|
||||||
|
func oldFileCacheDir() string {
|
||||||
|
userCacheDir, err := os.UserCacheDir()
|
||||||
|
if err == nil {
|
||||||
|
return filepath.Join(userCacheDir, "box-box", "openf1")
|
||||||
|
}
|
||||||
|
return filepath.Join(".cache", "box-box", "openf1")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,7 +36,7 @@ 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"`
|
||||||
@@ -44,12 +45,18 @@ type F1TimingLine struct {
|
|||||||
} `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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +145,9 @@ type LiveStreamData struct {
|
|||||||
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
|
||||||
|
ClockRefTime time.Time // UTC when Clock was accurate
|
||||||
|
ClockExtrapolating bool // true = actively counting down
|
||||||
Stints map[string][]LiveStintData
|
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)
|
||||||
@@ -232,6 +250,8 @@ func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
|
|||||||
CurrentLap: currentLap,
|
CurrentLap: currentLap,
|
||||||
TotalLaps: totalLaps,
|
TotalLaps: totalLaps,
|
||||||
Clock: clock,
|
Clock: clock,
|
||||||
|
ClockRefTime: clockRefTime,
|
||||||
|
ClockExtrapolating: clockExtrapolating,
|
||||||
Stints: cpyStints,
|
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)
|
||||||
@@ -283,9 +307,24 @@ 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,7 +632,10 @@ 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 {
|
||||||
|
if sec.Value == "" {
|
||||||
|
d.Sectors[i] = LiveSectorData{} // clear = new lap starting
|
||||||
|
} else {
|
||||||
d.Sectors[i] = LiveSectorData{
|
d.Sectors[i] = LiveSectorData{
|
||||||
Value: sec.Value,
|
Value: sec.Value,
|
||||||
PersonalFastest: sec.PersonalFastest,
|
PersonalFastest: sec.PersonalFastest,
|
||||||
@@ -568,10 +644,38 @@ func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -630,6 +774,8 @@ type OfficialLiveModel struct {
|
|||||||
currentLap int
|
currentLap int
|
||||||
totalLaps int
|
totalLaps int
|
||||||
clock string
|
clock string
|
||||||
|
clockRefTime time.Time
|
||||||
|
clockExtrapolating bool
|
||||||
stints map[string][]LiveStintData
|
stints map[string][]LiveStintData
|
||||||
err error
|
err error
|
||||||
|
|
||||||
@@ -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).
|
||||||
|
merged := make(map[string]LiveDriverData, len(m.drivers)+len(m.driverInfo))
|
||||||
|
for num, d := range m.drivers {
|
||||||
|
merged[num] = d
|
||||||
|
}
|
||||||
|
for num := range m.driverInfo {
|
||||||
|
if _, exists := merged[num]; !exists {
|
||||||
|
merged[num] = LiveDriverData{RacingNumber: num}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var positioned, unpositioned []LiveDriverData
|
||||||
|
for _, d := range merged {
|
||||||
if d.Position > 0 {
|
if d.Position > 0 {
|
||||||
drivers = append(drivers, d)
|
positioned = append(positioned, d)
|
||||||
|
} else {
|
||||||
|
unpositioned = append(unpositioned, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(drivers, func(i, j int) bool {
|
|
||||||
return drivers[i].Position < drivers[j].Position
|
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))
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type RaceDetailModel struct {
|
|||||||
|
|
||||||
loadingSessions bool
|
loadingSessions bool
|
||||||
loadingResults bool
|
loadingResults bool
|
||||||
|
driversLoaded bool
|
||||||
|
secondaryLoading bool
|
||||||
errSessions error
|
errSessions error
|
||||||
errResults error
|
errResults error
|
||||||
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user