diff --git a/cmd/main.go b/cmd/main.go
index 71d5bf6..38a119f 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "flag"
"fmt"
"log"
"os"
@@ -8,15 +9,14 @@ import (
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/ui"
+ "github.com/AmanTahiliani/box-box/internal/web"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
- // Redirect all log output to a file so it never pollutes the TUI.
- if f, err := os.OpenFile("box-box.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err == nil {
- log.SetOutput(f)
- defer f.Close()
- }
+ webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
+ port := flag.Int("port", 8080, "Port for web server (used with --web)")
+ flag.Parse()
var client *api.OpenF1Client
if apiKey := os.Getenv("OPENF1_API_KEY"); apiKey != "" {
@@ -29,6 +29,20 @@ func main() {
// Clean up old file-based cache (one-time migration).
go api.CleanupOldFileCache()
+ if *webMode {
+ log.SetOutput(os.Stderr) // web mode logs to stderr, not file
+ fmt.Printf("box-box web → http://localhost:%d\n", *port)
+ srv := web.NewServer(client, *port)
+ log.Fatal(srv.Start())
+ return
+ }
+
+ // TUI mode: redirect logs to file so they don't pollute the terminal.
+ if f, err := os.OpenFile("box-box.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err == nil {
+ log.SetOutput(f)
+ defer f.Close()
+ }
+
model := ui.NewAppModel(client)
p := tea.NewProgram(
diff --git a/internal/web/api.go b/internal/web/api.go
new file mode 100644
index 0000000..f6a879c
--- /dev/null
+++ b/internal/web/api.go
@@ -0,0 +1,786 @@
+package web
+
+import (
+ "encoding/json"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/AmanTahiliani/box-box/internal/models"
+)
+
+// writeJSON writes v as JSON with status 200.
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(v)
+}
+
+// writeError writes a JSON error response.
+func writeError(w http.ResponseWriter, err error, status int, stale bool) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]any{"error": err.Error(), "stale": stale})
+}
+
+// --- /api/v1/meetings ---
+
+func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
+ year, _ := strconv.Atoi(r.URL.Query().Get("year"))
+ if year == 0 {
+ year = time.Now().Year()
+ }
+ meetings, err := s.client.GetMeetingsForYear(year)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, meetings)
+}
+
+// --- /api/v1/sessions ---
+
+func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
+ meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
+ if err != nil || meetingKey == 0 {
+ http.Error(w, "meeting_key required", http.StatusBadRequest)
+ return
+ }
+ sessions, err := s.client.GetSessionsForMeeting(meetingKey)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, sessions)
+}
+
+// --- /api/v1/drivers ---
+
+func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+ drivers, err := s.client.GetDriversForSession(sessionKey)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, drivers)
+}
+
+// --- /api/v1/results ---
+
+type resultWithDriver struct {
+ models.SessionResult
+ NameAcronym string `json:"name_acronym"`
+ FullName string `json:"full_name"`
+ TeamName string `json:"team_name"`
+ TeamColour string `json:"team_colour"`
+}
+
+func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+
+ var (
+ results []models.SessionResult
+ drivers []models.Driver
+ resultsErr error
+ wg sync.WaitGroup
+ )
+ wg.Add(2)
+ go func() { defer wg.Done(); results, resultsErr = s.client.GetSessionResult(sessionKey) }()
+ go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
+ wg.Wait()
+
+ if resultsErr != nil {
+ writeError(w, resultsErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+
+ driverMap := buildDriverMap(drivers)
+ enriched := make([]resultWithDriver, 0, len(results))
+ for _, res := range results {
+ e := resultWithDriver{SessionResult: res}
+ if d, ok := driverMap[res.DriverNumber]; ok {
+ e.NameAcronym = d.NameAcronym
+ e.FullName = d.FullName
+ e.TeamName = d.TeamName
+ e.TeamColour = d.TeamColour
+ }
+ enriched = append(enriched, e)
+ }
+ writeJSON(w, enriched)
+}
+
+// --- /api/v1/grid ---
+
+type gridWithDriver struct {
+ models.StartingGrid
+ NameAcronym string `json:"name_acronym"`
+ FullName string `json:"full_name"`
+ TeamName string `json:"team_name"`
+ TeamColour string `json:"team_colour"`
+}
+
+func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+
+ var (
+ grid []models.StartingGrid
+ drivers []models.Driver
+ gridErr error
+ wg sync.WaitGroup
+ )
+ wg.Add(2)
+ go func() { defer wg.Done(); grid, gridErr = s.client.GetStartingGrid(sessionKey) }()
+ go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
+ wg.Wait()
+
+ if gridErr != nil {
+ writeError(w, gridErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+
+ driverMap := buildDriverMap(drivers)
+ enriched := make([]gridWithDriver, 0, len(grid))
+ for _, g := range grid {
+ e := gridWithDriver{StartingGrid: g}
+ if d, ok := driverMap[g.DriverNumber]; ok {
+ e.NameAcronym = d.NameAcronym
+ e.FullName = d.FullName
+ e.TeamName = d.TeamName
+ e.TeamColour = d.TeamColour
+ }
+ enriched = append(enriched, e)
+ }
+ writeJSON(w, enriched)
+}
+
+// --- /api/v1/laps ---
+
+func (s *Server) handleLaps(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+
+ if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
+ driverNumber, err := strconv.Atoi(dnStr)
+ if err != nil || driverNumber == 0 {
+ http.Error(w, "invalid driver_number", http.StatusBadRequest)
+ return
+ }
+ laps, err := s.client.GetLapsForDriver(sessionKey, driverNumber)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, laps)
+ return
+ }
+
+ laps, err := s.client.GetLapsForSession(sessionKey)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, laps)
+}
+
+// --- /api/v1/weather ---
+
+func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+ weather, err := s.client.GetWeather(sessionKey)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, weather)
+}
+
+// --- /api/v1/race-control ---
+
+func (s *Server) handleRaceControl(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+ rc, err := s.client.GetRaceControl(sessionKey)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, rc)
+}
+
+// --- /api/v1/telemetry ---
+
+func (s *Server) handleTelemetry(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+ driverNumber, err := strconv.Atoi(r.URL.Query().Get("driver_number"))
+ if err != nil || driverNumber == 0 {
+ http.Error(w, "driver_number required", http.StatusBadRequest)
+ return
+ }
+ carData, err := s.client.GetCarData(sessionKey, driverNumber)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, carData)
+}
+
+// --- /api/v1/overtakes ---
+
+func (s *Server) handleOvertakes(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+ overtakes, err := s.client.GetOvertakesForSession(sessionKey)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, overtakes)
+}
+
+// --- /api/v1/team-radio ---
+
+func (s *Server) handleTeamRadio(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+ driverNumber, err := strconv.Atoi(r.URL.Query().Get("driver_number"))
+ if err != nil || driverNumber == 0 {
+ http.Error(w, "driver_number required", http.StatusBadRequest)
+ return
+ }
+ radios, err := s.client.GetTeamRadio(sessionKey, driverNumber)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, radios)
+}
+
+// --- /api/v1/championship/drivers ---
+
+type champDriverWithInfo struct {
+ models.ChampionshipDriver
+ NameAcronym string `json:"name_acronym"`
+ FullName string `json:"full_name"`
+ TeamName string `json:"team_name"`
+ TeamColour string `json:"team_colour"`
+}
+
+func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Request) {
+ year, _ := strconv.Atoi(r.URL.Query().Get("year"))
+ if year == 0 {
+ year = time.Now().Year()
+ }
+ champ, err := s.client.GetDriverChampionshipForYear(year)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ if len(champ) == 0 {
+ writeJSON(w, []any{})
+ return
+ }
+
+ drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey)
+ driverMap := buildDriverMap(drivers)
+
+ enriched := make([]champDriverWithInfo, 0, len(champ))
+ for _, c := range champ {
+ e := champDriverWithInfo{ChampionshipDriver: c}
+ if d, ok := driverMap[c.DriverNumber]; ok {
+ e.NameAcronym = d.NameAcronym
+ e.FullName = d.FullName
+ e.TeamName = d.TeamName
+ e.TeamColour = d.TeamColour
+ }
+ enriched = append(enriched, e)
+ }
+ writeJSON(w, enriched)
+}
+
+// --- /api/v1/championship/teams ---
+
+func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request) {
+ year, _ := strconv.Atoi(r.URL.Query().Get("year"))
+ if year == 0 {
+ year = time.Now().Year()
+ }
+ teams, err := s.client.GetTeamChampionshipForYear(year)
+ if err != nil {
+ writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+ writeJSON(w, teams)
+}
+
+// --- /api/v1/track-outline ---
+// Accepts circuit_key and year (the frontend has both from meeting+session data).
+
+type trackPoint struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+}
+
+type trackOutlineResponse struct {
+ CircuitKey int `json:"circuit_key"`
+ Points []trackPoint `json:"points"`
+}
+
+func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
+ circuitKey, err := strconv.Atoi(r.URL.Query().Get("circuit_key"))
+ if err != nil || circuitKey == 0 {
+ http.Error(w, "circuit_key required", http.StatusBadRequest)
+ return
+ }
+ year, _ := strconv.Atoi(r.URL.Query().Get("year"))
+ if year == 0 {
+ year = time.Now().Year()
+ }
+
+ locs, ok := s.client.Cache().GetTrackOutline(circuitKey, year)
+ if !ok || len(locs) == 0 {
+ writeJSON(w, map[string]any{"error": "track outline not available", "circuit_key": circuitKey})
+ return
+ }
+
+ // Normalize X/Y to [0, 1].
+ minX, maxX := locs[0].X, locs[0].X
+ minY, maxY := locs[0].Y, locs[0].Y
+ for _, l := range locs {
+ if l.X < minX {
+ minX = l.X
+ }
+ if l.X > maxX {
+ maxX = l.X
+ }
+ if l.Y < minY {
+ minY = l.Y
+ }
+ if l.Y > maxY {
+ maxY = l.Y
+ }
+ }
+ rangeX := maxX - minX
+ rangeY := maxY - minY
+ if rangeX == 0 {
+ rangeX = 1
+ }
+ if rangeY == 0 {
+ rangeY = 1
+ }
+
+ // Deduplicate points.
+ type key2 struct{ x, y float64 }
+ seen := make(map[key2]bool, len(locs))
+ points := make([]trackPoint, 0, len(locs))
+ for _, l := range locs {
+ p := trackPoint{
+ X: (l.X - minX) / rangeX,
+ Y: (l.Y - minY) / rangeY,
+ }
+ k := key2{p.X, p.Y}
+ if !seen[k] {
+ seen[k] = true
+ points = append(points, p)
+ }
+ }
+
+ writeJSON(w, trackOutlineResponse{CircuitKey: circuitKey, Points: points})
+}
+
+// --- /api/v1/strategy ---
+
+type scPeriod struct {
+ LapStart int `json:"lap_start"`
+ LapEnd int `json:"lap_end"`
+ Type string `json:"type"` // "SC" or "VSC"
+}
+
+type stintInfo struct {
+ StintNumber int `json:"stint_number"`
+ Compound string `json:"compound"`
+ LapStart int `json:"lap_start"`
+ LapEnd int `json:"lap_end"`
+ LapCount int `json:"lap_count"`
+ TyreAgeAtStart int `json:"tyre_age_at_start"`
+ IsNew bool `json:"is_new"`
+}
+
+type pitStopInfo struct {
+ LapNumber int `json:"lap_number"`
+ StopDuration float64 `json:"stop_duration"`
+ LaneDuration float64 `json:"lane_duration"`
+}
+
+type strategyDriver struct {
+ DriverNumber int `json:"driver_number"`
+ NameAcronym string `json:"name_acronym"`
+ TeamColour string `json:"team_colour"`
+ FinishPosition int `json:"finish_position"`
+ DNF bool `json:"dnf"`
+ DNS bool `json:"dns"`
+ DSQ bool `json:"dsq"`
+ Stints []stintInfo `json:"stints"`
+ PitStops []pitStopInfo `json:"pit_stops"`
+}
+
+type strategyResponse struct {
+ SessionKey int `json:"session_key"`
+ TotalLaps int `json:"total_laps"`
+ SCPeriods []scPeriod `json:"sc_periods"`
+ Drivers []strategyDriver `json:"drivers"`
+}
+
+func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+
+ var (
+ stints []models.Stint
+ pits []models.Pit
+ results []models.SessionResult
+ drivers []models.Driver
+ rc []models.RaceControl
+ stintsErr error
+ pitsErr error
+ resErr error
+ wg sync.WaitGroup
+ )
+ wg.Add(5)
+ go func() { defer wg.Done(); stints, stintsErr = s.client.GetStintsForSession(sessionKey) }()
+ go func() { defer wg.Done(); pits, pitsErr = s.client.GetPitStopsForSession(sessionKey) }()
+ go func() { defer wg.Done(); results, resErr = s.client.GetSessionResult(sessionKey) }()
+ go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
+ go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
+ wg.Wait()
+
+ if stintsErr != nil || pitsErr != nil || resErr != nil {
+ e := stintsErr
+ if e == nil {
+ e = pitsErr
+ }
+ if e == nil {
+ e = resErr
+ }
+ writeError(w, e, http.StatusInternalServerError, s.client.LastResponseWasStale())
+ return
+ }
+
+ // Non-race sessions have no stints.
+ if len(stints) == 0 {
+ writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
+ return
+ }
+
+ driverMap := buildDriverMap(drivers)
+
+ resultMap := make(map[int]models.SessionResult, len(results))
+ totalLaps := 0
+ for _, res := range results {
+ resultMap[res.DriverNumber] = res
+ if res.NumberOfLaps > totalLaps {
+ totalLaps = res.NumberOfLaps
+ }
+ }
+
+ stintMap := make(map[int][]models.Stint)
+ for _, st := range stints {
+ stintMap[st.DriverNumber] = append(stintMap[st.DriverNumber], st)
+ }
+
+ pitMap := make(map[int][]models.Pit)
+ for _, p := range pits {
+ pitMap[p.DriverNumber] = append(pitMap[p.DriverNumber], p)
+ }
+
+ // Collect all driver numbers.
+ seenDrivers := make(map[int]bool)
+ for _, st := range stints {
+ seenDrivers[st.DriverNumber] = true
+ }
+ for _, res := range results {
+ seenDrivers[res.DriverNumber] = true
+ }
+
+ stratDrivers := make([]strategyDriver, 0, len(seenDrivers))
+ for dn := range seenDrivers {
+ d := driverMap[dn]
+ res := resultMap[dn]
+
+ sd := strategyDriver{
+ DriverNumber: dn,
+ NameAcronym: d.NameAcronym,
+ TeamColour: d.TeamColour,
+ FinishPosition: res.Position,
+ DNF: res.DNF,
+ DNS: res.DNS,
+ DSQ: res.DSQ,
+ }
+
+ if res.DNS {
+ sd.Stints = []stintInfo{}
+ } else {
+ for _, st := range stintMap[dn] {
+ lapEnd := st.LapEnd
+ if res.DNF && lapEnd > res.NumberOfLaps && res.NumberOfLaps > 0 {
+ lapEnd = res.NumberOfLaps
+ }
+ sd.Stints = append(sd.Stints, stintInfo{
+ StintNumber: st.StintNumber,
+ Compound: string(st.Compound),
+ LapStart: st.LapStart,
+ LapEnd: lapEnd,
+ LapCount: lapEnd - st.LapStart + 1,
+ TyreAgeAtStart: st.TyreAgeAtStart,
+ IsNew: st.TyreAgeAtStart == 0,
+ })
+ }
+ }
+
+ for _, p := range pitMap[dn] {
+ sd.PitStops = append(sd.PitStops, pitStopInfo{
+ LapNumber: p.LapNumber,
+ StopDuration: p.StopDuration,
+ LaneDuration: p.LaneDuration,
+ })
+ }
+ stratDrivers = append(stratDrivers, sd)
+ }
+
+ // Sort by finish position (DNF/DNS/no-result last).
+ sort.Slice(stratDrivers, func(i, j int) bool {
+ pi, pj := stratDrivers[i].FinishPosition, stratDrivers[j].FinishPosition
+ if pi == 0 {
+ pi = 999
+ }
+ if pj == 0 {
+ pj = 999
+ }
+ return pi < pj
+ })
+
+ writeJSON(w, strategyResponse{
+ SessionKey: sessionKey,
+ TotalLaps: totalLaps,
+ SCPeriods: extractSCPeriods(rc),
+ Drivers: stratDrivers,
+ })
+}
+
+// extractSCPeriods parses race control messages to find SC/VSC deployment periods.
+func extractSCPeriods(rc []models.RaceControl) []scPeriod {
+ type pending struct {
+ lapStart int
+ scType string
+ }
+ var periods []scPeriod
+ var active *pending
+
+ for _, msg := range rc {
+ if msg.Category != models.CategorySafetyCar {
+ continue
+ }
+ text := strings.ToUpper(msg.Message)
+ lap := 0
+ if msg.LapNumber != nil {
+ lap = *msg.LapNumber
+ }
+
+ if strings.Contains(text, "DEPLOYED") {
+ scType := "SC"
+ if strings.Contains(text, "VIRTUAL") {
+ scType = "VSC"
+ }
+ active = &pending{lapStart: lap, scType: scType}
+ } else if active != nil && (strings.Contains(text, "IN THIS LAP") ||
+ strings.Contains(text, "ENDING") ||
+ strings.Contains(text, "WITHDRAWN") ||
+ strings.Contains(text, "RESUME")) {
+ periods = append(periods, scPeriod{
+ LapStart: active.lapStart,
+ LapEnd: lap,
+ Type: active.scType,
+ })
+ active = nil
+ }
+ }
+ // If SC was still active at end of data, close it with an estimate.
+ if active != nil && active.lapStart > 0 {
+ periods = append(periods, scPeriod{
+ LapStart: active.lapStart,
+ LapEnd: active.lapStart + 5,
+ Type: active.scType,
+ })
+ }
+ return periods
+}
+
+// --- /api/v1/laps/comparison ---
+
+type lapWithCompound struct {
+ models.Lap
+ Compound string `json:"compound"`
+}
+
+type comparisonDriver struct {
+ DriverNumber int `json:"driver_number"`
+ NameAcronym string `json:"name_acronym"`
+ TeamColour string `json:"team_colour"`
+ Laps []lapWithCompound `json:"laps"`
+}
+
+type lapsComparisonResponse struct {
+ SessionKey int `json:"session_key"`
+ SCPeriods []scPeriod `json:"sc_periods"`
+ PitLaps map[string][]int `json:"pit_laps"`
+ Drivers []comparisonDriver `json:"drivers"`
+}
+
+func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
+ sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
+ if err != nil || sessionKey == 0 {
+ http.Error(w, "session_key required", http.StatusBadRequest)
+ return
+ }
+
+ // Parse requested driver numbers (comma-separated).
+ var requestedDrivers []int
+ if drvParam := r.URL.Query().Get("drivers"); drvParam != "" {
+ for _, part := range strings.Split(drvParam, ",") {
+ if n, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && n > 0 {
+ requestedDrivers = append(requestedDrivers, n)
+ }
+ }
+ }
+
+ var (
+ allLaps []models.Lap
+ stints []models.Stint
+ pits []models.Pit
+ rc []models.RaceControl
+ wg sync.WaitGroup
+ )
+ wg.Add(4)
+ go func() { defer wg.Done(); allLaps, _ = s.client.GetLapsForSession(sessionKey) }()
+ go func() { defer wg.Done(); stints, _ = s.client.GetStintsForSession(sessionKey) }()
+ go func() { defer wg.Done(); pits, _ = s.client.GetPitStopsForSession(sessionKey) }()
+ go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
+ wg.Wait()
+
+ allDrivers, _ := s.client.GetDriversForSession(sessionKey)
+ driverMap := buildDriverMap(allDrivers)
+
+ // If no filter, default to first 3 unique driver numbers from lap data.
+ if len(requestedDrivers) == 0 {
+ seen := make(map[int]bool)
+ for _, l := range allLaps {
+ if !seen[l.DriverNumber] {
+ seen[l.DriverNumber] = true
+ requestedDrivers = append(requestedDrivers, l.DriverNumber)
+ }
+ if len(requestedDrivers) >= 3 {
+ break
+ }
+ }
+ }
+
+ // Build per-driver lap map.
+ lapMap := make(map[int][]models.Lap)
+ for _, l := range allLaps {
+ lapMap[l.DriverNumber] = append(lapMap[l.DriverNumber], l)
+ }
+
+ // Build stint map for compound lookup.
+ stintMap := make(map[int][]models.Stint)
+ for _, st := range stints {
+ stintMap[st.DriverNumber] = append(stintMap[st.DriverNumber], st)
+ }
+
+ // Build pit laps map.
+ pitLaps := make(map[string][]int)
+ for _, p := range pits {
+ key := strconv.Itoa(p.DriverNumber)
+ pitLaps[key] = append(pitLaps[key], p.LapNumber)
+ }
+
+ compDrivers := make([]comparisonDriver, 0, len(requestedDrivers))
+ for _, dn := range requestedDrivers {
+ d := driverMap[dn]
+ cd := comparisonDriver{
+ DriverNumber: dn,
+ NameAcronym: d.NameAcronym,
+ TeamColour: d.TeamColour,
+ Laps: make([]lapWithCompound, 0, len(lapMap[dn])),
+ }
+ for _, lap := range lapMap[dn] {
+ cd.Laps = append(cd.Laps, lapWithCompound{
+ Lap: lap,
+ Compound: compoundForLap(lap.LapNumber, stintMap[dn]),
+ })
+ }
+ compDrivers = append(compDrivers, cd)
+ }
+
+ writeJSON(w, lapsComparisonResponse{
+ SessionKey: sessionKey,
+ SCPeriods: extractSCPeriods(rc),
+ PitLaps: pitLaps,
+ Drivers: compDrivers,
+ })
+}
+
+// compoundForLap returns the tyre compound active on a given lap number.
+func compoundForLap(lapNum int, stints []models.Stint) string {
+ for _, st := range stints {
+ if lapNum >= st.LapStart && lapNum <= st.LapEnd {
+ return string(st.Compound)
+ }
+ }
+ return "UNKNOWN"
+}
+
+// buildDriverMap returns a map of driver_number → Driver.
+func buildDriverMap(drivers []models.Driver) map[int]models.Driver {
+ m := make(map[int]models.Driver, len(drivers))
+ for _, d := range drivers {
+ m[d.DriverNumber] = d
+ }
+ return m
+}
diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js
new file mode 100644
index 0000000..543bedb
--- /dev/null
+++ b/internal/web/assets/app.js
@@ -0,0 +1,527 @@
+/* ============================================================
+ box-box — Alpine.js page components
+ ============================================================ */
+
+// ---- Shared helpers ----
+
+// Convert ISO 3166-1 alpha-2 country code → emoji flag (mirrors TUI countryFlag).
+function flagEmoji(code) {
+ if (!code || code.length !== 2) return '🏁';
+ const offset = 0x1F1E6 - 65; // Regional Indicator A offset
+ return String.fromCodePoint(
+ code.toUpperCase().charCodeAt(0) + offset,
+ code.toUpperCase().charCodeAt(1) + offset
+ );
+}
+
+// Current F1 season year derived from today's date.
+function currentSeason() {
+ return new Date().getFullYear();
+}
+
+// ---- Root state: routing + live check ----
+
+function appState() {
+ return {
+ page: 'home',
+ isLive: false,
+ staleWarning: false,
+
+ init() {
+ this.parseRoute();
+ window.addEventListener('hashchange', () => this.parseRoute());
+ this.checkLive();
+ setInterval(() => this.checkLive(), 30000);
+ },
+
+ parseRoute() {
+ const hash = window.location.hash || '#/';
+ const path = hash.slice(1) || '/';
+ if (path === '/' || path === '') {
+ this.page = 'home';
+ } else if (path.startsWith('/race/')) {
+ this.page = 'race';
+ } else if (path === '/live') {
+ this.page = 'live';
+ } else if (path === '/standings') {
+ this.page = 'standings';
+ } else {
+ this.page = 'home';
+ }
+ },
+
+ async checkLive() {
+ try {
+ const r = await fetch('/api/v1/live/state');
+ if (!r.ok) return;
+ const data = await r.json();
+ this.isLive = !!data.is_live;
+ } catch (_) {}
+ },
+ };
+}
+
+// ---- Home page ----
+
+function homePage() {
+ return {
+ loading: true,
+ meetings: [],
+ champDrivers: [],
+ nextRace: null,
+ countdown: '',
+ season: currentSeason(),
+ _countdownTimer: null,
+
+ async init() {
+ this.loading = true;
+ await Promise.all([
+ this.loadMeetings(),
+ this.loadChampionship(),
+ ]);
+ this.loading = false;
+ this.startCountdown();
+ },
+
+ async loadMeetings() {
+ try {
+ const r = await fetch(`/api/v1/meetings?year=${this.season}`);
+ const data = await r.json();
+ this.meetings = Array.isArray(data) ? data : [];
+ // Find next race
+ const now = Date.now();
+ this.nextRace = this.meetings.find(m => new Date(m.date_start).getTime() > now) || null;
+ } catch (_) {}
+ },
+
+ async loadChampionship() {
+ try {
+ const r = await fetch(`/api/v1/championship/drivers?year=${this.season}`);
+ const data = await r.json();
+ this.champDrivers = Array.isArray(data)
+ ? [...data].sort((a, b) => a.position_current - b.position_current)
+ : [];
+ } catch (_) {}
+ },
+
+ startCountdown() {
+ if (!this.nextRace) return;
+ const target = new Date(this.nextRace.date_start).getTime();
+ const update = () => {
+ const diff = target - Date.now();
+ if (diff <= 0) { this.countdown = 'Race day!'; return; }
+ const d = Math.floor(diff / 86400000);
+ const h = Math.floor((diff % 86400000) / 3600000);
+ const m = Math.floor((diff % 3600000) / 60000);
+ const s = Math.floor((diff % 60000) / 1000);
+ this.countdown = d > 0
+ ? `${d}d ${pad(h)}:${pad(m)}:${pad(s)}`
+ : `${pad(h)}:${pad(m)}:${pad(s)}`;
+ };
+ update();
+ this._countdownTimer = setInterval(update, 1000);
+ },
+
+ barWidth(pts) {
+ if (!this.champDrivers.length) return 0;
+ const max = this.champDrivers[0].points_current;
+ return max > 0 ? Math.round((pts / max) * 100) : 0;
+ },
+
+ flagEmoji,
+ isPast(m) { return new Date(m.date_start).getTime() < Date.now(); },
+ fmtDate(d) { return d ? new Date(d).toLocaleDateString('en-GB', {day:'numeric',month:'short'}) : ''; },
+
+ destroy() { clearInterval(this._countdownTimer); },
+ };
+}
+
+// ---- Race page ----
+
+function racePage() {
+ return {
+ meetingKey: 0,
+ meetingName: '',
+ year: 2025,
+ sessions: [],
+ activeSession: null,
+ tab: 'results',
+ drivers: [],
+
+ // Results
+ results: [],
+ resultsLoading: false,
+
+ // Strategy
+ strategyData: null,
+ strategyLoading: false,
+ strategyNote: '',
+
+ // Laps comparison
+ lapsData: null,
+ lapsLoading: false,
+
+ // Track
+ trackData: null,
+ trackLoading: false,
+
+ // Telemetry
+ telemetryDrivers: [],
+ telemetryData: [],
+ telemetryLoading: false,
+
+ // Resize observers
+ _observers: [],
+
+ async init() {
+ const hash = window.location.hash || '';
+ const m = hash.match(/#\/race\/(\d+)/);
+ if (!m) return;
+ this.meetingKey = parseInt(m[1]);
+
+ // Load sessions
+ try {
+ const r = await fetch(`/api/v1/sessions?meeting_key=${this.meetingKey}`);
+ this.sessions = await r.json();
+ if (this.sessions.length) {
+ // Prefer Race session, else last session
+ const race = this.sessions.find(s => s.session_type === 'Race')
+ || this.sessions[this.sessions.length - 1];
+ await this.selectSession(race);
+ }
+ } catch (_) {}
+ },
+
+ async selectSession(sess) {
+ this.activeSession = sess;
+ // Extract year from date_start
+ this.year = sess.date_start ? parseInt(sess.date_start.slice(0, 4)) : 2025;
+ // Clear all lazy-loaded data
+ this.strategyData = null;
+ this.strategyNote = '';
+ this.lapsData = null;
+ this.trackData = null;
+ this.telemetryData = [];
+ this.telemetryDrivers = [];
+
+ await Promise.all([
+ this.loadResults(sess.session_key),
+ this.loadDrivers(sess.session_key),
+ ]);
+ },
+
+ async loadResults(sk) {
+ this.resultsLoading = true;
+ try {
+ const r = await fetch(`/api/v1/results?session_key=${sk}`);
+ const data = await r.json();
+ this.results = Array.isArray(data)
+ ? [...data].sort((a, b) => (a.position || 99) - (b.position || 99))
+ : [];
+ this.meetingName = '';
+ if (this.sessions.length) {
+ const sess = this.sessions.find(s => s.session_key === sk);
+ this.meetingName = sess?.meeting_key ? `Round — Meeting ${this.meetingKey}` : '';
+ }
+ } catch (_) { this.results = []; }
+ this.resultsLoading = false;
+ },
+
+ async loadDrivers(sk) {
+ try {
+ const r = await fetch(`/api/v1/drivers?session_key=${sk}`);
+ this.drivers = await r.json();
+ // Set default meeting name from sessions list
+ const sess = this.sessions.find(s => s.session_key === sk);
+ if (!this.meetingName && this.sessions.length) {
+ this.meetingName = `Meeting ${this.meetingKey}`;
+ }
+ } catch (_) { this.drivers = []; }
+ },
+
+ async loadStrategy() {
+ if (this.strategyData || this.strategyLoading || !this.activeSession) return;
+ this.strategyLoading = true;
+ try {
+ const r = await fetch(`/api/v1/strategy?session_key=${this.activeSession.session_key}`);
+ const data = await r.json();
+ if (data.note) {
+ this.strategyNote = data.note;
+ } else {
+ this.strategyData = data;
+ }
+ } catch (_) { this.strategyNote = 'Failed to load strategy data.'; }
+ this.strategyLoading = false;
+ },
+
+ renderStrategy() {
+ if (!this.strategyData) return;
+ this.$nextTick(() => Charts.renderStrategy('strategy-chart', this.strategyData));
+ },
+
+ async loadLaps() {
+ if (this.lapsData || this.lapsLoading || !this.activeSession) return;
+ this.lapsLoading = true;
+ try {
+ const r = await fetch(`/api/v1/laps/comparison?session_key=${this.activeSession.session_key}`);
+ this.lapsData = await r.json();
+ } catch (_) {}
+ this.lapsLoading = false;
+ },
+
+ renderLapTimes() {
+ if (!this.lapsData) return;
+ this.$nextTick(() => Charts.renderLapTimes('laps-chart', this.lapsData));
+ },
+
+ async loadTrack() {
+ if (this.trackData || this.trackLoading || !this.activeSession) return;
+ this.trackLoading = true;
+ try {
+ const ck = this.activeSession.circuit_key;
+ const yr = this.year;
+ const r = await fetch(`/api/v1/track-outline?circuit_key=${ck}&year=${yr}`);
+ const data = await r.json();
+ if (data.points && data.points.length > 0) {
+ this.trackData = data;
+ }
+ } catch (_) {}
+ this.trackLoading = false;
+ },
+
+ renderTrack() {
+ if (!this.trackData) return;
+ this.$nextTick(() => Track.render('track-chart', this.trackData.points, [], {}));
+ },
+
+ async toggleTelemetryDriver(driverNumber) {
+ if (this.telemetryDrivers.includes(driverNumber)) {
+ this.telemetryDrivers = this.telemetryDrivers.filter(n => n !== driverNumber);
+ } else {
+ if (this.telemetryDrivers.length >= 3) return; // max 3
+ this.telemetryDrivers.push(driverNumber);
+ }
+ await this.loadTelemetry();
+ },
+
+ async loadTelemetry() {
+ if (!this.activeSession || this.telemetryDrivers.length === 0) {
+ this.telemetryData = [];
+ return;
+ }
+ this.telemetryLoading = true;
+ const sk = this.activeSession.session_key;
+ const results = await Promise.all(
+ this.telemetryDrivers.map(dn =>
+ fetch(`/api/v1/telemetry?session_key=${sk}&driver_number=${dn}`)
+ .then(r => r.json())
+ .then(data => ({ driverNumber: dn, data: Array.isArray(data) ? data : [] }))
+ .catch(() => ({ driverNumber: dn, data: [] }))
+ )
+ );
+ this.telemetryData = results.map(r => {
+ const driver = this.drivers.find(d => d.driver_number === r.driverNumber);
+ return {
+ driverNumber: r.driverNumber,
+ nameAcronym: driver?.name_acronym || String(r.driverNumber),
+ teamColour: driver?.team_colour || '888888',
+ data: r.data,
+ };
+ });
+ this.telemetryLoading = false;
+ },
+
+ renderTelemetry() {
+ if (!this.telemetryData.length) return;
+ this.$nextTick(() => Charts.renderTelemetry('telemetry-chart', this.telemetryData));
+ },
+
+ fmtDuration(v) {
+ if (v === null || v === undefined) return '-';
+ if (Array.isArray(v)) return v.map(t => fmtSecs(t)).join(' / ');
+ return fmtSecs(v);
+ },
+ };
+}
+
+// ---- Live page ----
+
+function livePage() {
+ return {
+ isLive: false,
+ drivers: {},
+ driverInfo: {},
+ tyres: {},
+ stints: {},
+ rcMessages: [],
+ trackStatus: '',
+ currentLap: 0,
+ totalLaps: 0,
+ clock: '',
+ clockRefTime: null,
+ clockExtrapolating: false,
+ session: {},
+ _es: null,
+ _clockTimer: null,
+ clockDisplay: '',
+
+ init() {
+ this.connectSSE();
+ this._clockTimer = setInterval(() => this.updateClock(), 1000);
+ },
+
+ cleanup() {
+ if (this._es) this._es.close();
+ clearInterval(this._clockTimer);
+ },
+
+ connectSSE() {
+ const es = new EventSource('/api/v1/live/stream');
+ this._es = es;
+
+ es.addEventListener('snapshot', e => {
+ try {
+ const msg = JSON.parse(e.data);
+ this.isLive = !!msg.is_live;
+ if (msg.data) this.applySnapshot(msg.data);
+ } catch (_) {}
+ });
+
+ es.addEventListener('heartbeat', () => {});
+
+ es.onerror = () => {
+ this.isLive = false;
+ setTimeout(() => this.connectSSE(), 5000);
+ es.close();
+ };
+ },
+
+ applySnapshot(d) {
+ if (d.Drivers) this.drivers = d.Drivers;
+ if (d.DriverInfo) this.driverInfo = d.DriverInfo;
+ if (d.Tyres) this.tyres = d.Tyres;
+ if (d.Stints) this.stints = d.Stints;
+ if (d.RCMessages) this.rcMessages = d.RCMessages;
+ if (d.TrackStatus) this.trackStatus = d.TrackStatus;
+ if (d.CurrentLap) this.currentLap = d.CurrentLap;
+ if (d.TotalLaps) this.totalLaps = d.TotalLaps;
+ if (d.Clock) this.clock = d.Clock;
+ if (d.ClockRefTime) this.clockRefTime = new Date(d.ClockRefTime);
+ if (d.ClockExtrapolating !== undefined) this.clockExtrapolating = d.ClockExtrapolating;
+ if (d.Session) this.session = d.Session;
+ },
+
+ get sortedDrivers() {
+ return Object.values(this.drivers)
+ .filter(d => d.Position > 0)
+ .sort((a, b) => a.Position - b.Position);
+ },
+
+ driverTla(num) {
+ return this.driverInfo[num]?.Tla || num;
+ },
+
+ driverTeamColor(num) {
+ return this.driverInfo[num]?.TeamColour || '666666';
+ },
+
+ tyreLabel(num) {
+ const t = this.tyres[num];
+ if (!t) return '?';
+ return `${t.Compound?.charAt(0) || '?'} +${t.Age || 0}`;
+ },
+
+ tyreClass(num) {
+ const t = this.tyres[num];
+ if (!t) return 'tyre-unknown';
+ return 'tyre-' + (t.Compound || 'unknown').toLowerCase();
+ },
+
+ posDelta(d) {
+ if (!d.PrevPosition || d.PrevPosition === d.Position) return '';
+ return d.PrevPosition > d.Position ? '▲' : '▼';
+ },
+
+ trackStatusText() {
+ const map = {'1':'GREEN','2':'YELLOW','4':'SC','5':'RED','6':'VSC'};
+ return map[this.trackStatus] || this.trackStatus;
+ },
+
+ trackStatusClass() {
+ const map = {'1':'track-green','2':'track-yellow','4':'track-sc','5':'track-red','6':'track-vsc'};
+ return map[this.trackStatus] || '';
+ },
+
+ updateClock() {
+ if (!this.clock || !this.clockExtrapolating || !this.clockRefTime) {
+ this.clockDisplay = this.clock || '';
+ return;
+ }
+ // Extrapolate: remaining = clock - elapsed since clockRefTime
+ const [h, m, s] = this.clock.split(':').map(Number);
+ const totalSecs = h * 3600 + m * 60 + s;
+ const elapsed = (Date.now() - this.clockRefTime.getTime()) / 1000;
+ const remaining = Math.max(0, totalSecs - elapsed);
+ const rh = Math.floor(remaining / 3600);
+ const rm = Math.floor((remaining % 3600) / 60);
+ const rs = Math.floor(remaining % 60);
+ this.clockDisplay = `${pad(rh)}:${pad(rm)}:${pad(rs)}`;
+ },
+ };
+}
+
+// ---- Standings page ----
+
+function standingsPage() {
+ return {
+ year: currentSeason(),
+ view: 'drivers',
+ loading: false,
+ driverStandings: [],
+ teamStandings: [],
+
+ async init() {
+ await this.load();
+ },
+
+ async setYear(y) {
+ this.year = y;
+ await this.load();
+ },
+
+ async load() {
+ this.loading = true;
+ await Promise.all([this.loadDrivers(), this.loadTeams()]);
+ this.loading = false;
+ },
+
+ async loadDrivers() {
+ try {
+ const r = await fetch(`/api/v1/championship/drivers?year=${this.year}`);
+ const data = await r.json();
+ this.driverStandings = Array.isArray(data)
+ ? [...data].sort((a, b) => a.position_current - b.position_current)
+ : [];
+ } catch (_) { this.driverStandings = []; }
+ },
+
+ async loadTeams() {
+ try {
+ const r = await fetch(`/api/v1/championship/teams?year=${this.year}`);
+ const data = await r.json();
+ this.teamStandings = Array.isArray(data)
+ ? [...data].sort((a, b) => a.position_current - b.position_current)
+ : [];
+ } catch (_) { this.teamStandings = []; }
+ },
+ };
+}
+
+function pad(n) { return String(n).padStart(2, '0'); }
+
+function fmtSecs(s) {
+ if (!s) return '-';
+ const m = Math.floor(s / 60);
+ const rem = (s - m * 60).toFixed(3);
+ return `${m}:${rem.padStart(6, '0')}`;
+}
diff --git a/internal/web/assets/charts.js b/internal/web/assets/charts.js
new file mode 100644
index 0000000..18c4e62
--- /dev/null
+++ b/internal/web/assets/charts.js
@@ -0,0 +1,465 @@
+/* ============================================================
+ box-box — D3 visualization library
+ All functions: Charts.renderX(containerId, data)
+ Charts re-render on ResizeObserver via stored callbacks.
+ ============================================================ */
+
+const Charts = (() => {
+ // Compound → fill colour
+ const COMPOUND_COLOR = {
+ SOFT: '#FF3333',
+ MEDIUM: '#FFD700',
+ HARD: '#CCCCCC',
+ INTERMEDIATE: '#39B54A',
+ WET: '#0080FF',
+ UNKNOWN: '#666688',
+ };
+
+ const MARGIN = { top: 32, right: 24, bottom: 32, left: 60 };
+ const LABEL_W = 48; // width reserved for driver labels
+
+ // Tooltip element (shared)
+ let tooltip = null;
+ function getTooltip() {
+ if (!tooltip) {
+ tooltip = document.createElement('div');
+ tooltip.className = 'chart-tooltip';
+ tooltip.style.display = 'none';
+ document.body.appendChild(tooltip);
+ }
+ return tooltip;
+ }
+ function showTooltip(html, e) {
+ const t = getTooltip();
+ t.innerHTML = html;
+ t.style.display = 'block';
+ t.style.left = (e.clientX + 12) + 'px';
+ t.style.top = (e.clientY - 8) + 'px';
+ }
+ function hideTooltip() {
+ getTooltip().style.display = 'none';
+ }
+
+ // ResizeObserver registry: containerId → callback
+ const observers = {};
+ function observeResize(id, fn) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ if (observers[id]) observers[id].disconnect();
+ const obs = new ResizeObserver(() => fn());
+ obs.observe(el);
+ observers[id] = obs;
+ }
+
+ // ---- Strategy Gantt chart ----
+
+ function renderStrategy(containerId, data) {
+ const el = document.getElementById(containerId);
+ if (!el || !data || !data.drivers) return;
+ el.innerHTML = '';
+
+ const drivers = (data.drivers || []).filter(d => d.stints && d.stints.length > 0 || d.dns);
+ if (drivers.length === 0) { el.textContent = 'No strategy data.'; return; }
+
+ const totalLaps = data.total_laps || 60;
+ const rowH = 28;
+ const padding = 4;
+ const height = MARGIN.top + drivers.length * (rowH + padding) + MARGIN.bottom;
+ const width = Math.max(el.clientWidth || 800, 500);
+
+ const svg = d3.select(el).append('svg')
+ .attr('width', width).attr('height', height);
+
+ const xScale = d3.scaleLinear()
+ .domain([1, totalLaps])
+ .range([MARGIN.left + LABEL_W, width - MARGIN.right]);
+
+ const yScale = d3.scaleBand()
+ .domain(drivers.map(d => d.driver_number))
+ .range([MARGIN.top, height - MARGIN.bottom])
+ .padding(0.15);
+
+ // SC/VSC zones
+ (data.sc_periods || []).forEach(p => {
+ svg.append('rect')
+ .attr('x', xScale(p.lap_start))
+ .attr('y', MARGIN.top)
+ .attr('width', xScale(p.lap_end) - xScale(p.lap_start))
+ .attr('height', height - MARGIN.top - MARGIN.bottom)
+ .attr('fill', p.type === 'VSC' ? 'rgba(80,130,255,0.12)' : 'rgba(255,200,0,0.12)')
+ .attr('pointer-events', 'none');
+ });
+
+ // Grid lines
+ for (let lap = 5; lap <= totalLaps; lap += 5) {
+ svg.append('line')
+ .attr('x1', xScale(lap)).attr('x2', xScale(lap))
+ .attr('y1', MARGIN.top).attr('y2', height - MARGIN.bottom)
+ .attr('stroke', '#2D2D44').attr('stroke-width', 1);
+ }
+
+ // X-axis
+ const xAxis = d3.axisBottom(xScale).ticks(Math.min(20, Math.floor(totalLaps / 5))).tickFormat(d3.format('d'));
+ svg.append('g').attr('transform', `translate(0,${height - MARGIN.bottom})`).call(xAxis);
+
+ // Driver rows
+ drivers.forEach(driver => {
+ const y = yScale(driver.driver_number);
+ const rh = yScale.bandwidth();
+
+ // Label
+ svg.append('text')
+ .attr('x', MARGIN.left + LABEL_W - 6)
+ .attr('y', y + rh / 2 + 4)
+ .attr('text-anchor', 'end')
+ .attr('fill', `#${driver.team_colour || '888888'}`)
+ .attr('font-size', 11)
+ .attr('font-weight', '700')
+ .text(driver.name_acronym || driver.driver_number);
+
+ // DNS: single gray bar
+ if (driver.dns) {
+ svg.append('rect')
+ .attr('x', xScale(1)).attr('y', y + 2)
+ .attr('width', xScale(totalLaps) - xScale(1))
+ .attr('height', rh - 4)
+ .attr('fill', '#333355').attr('rx', 3);
+ svg.append('text')
+ .attr('x', (xScale(1) + xScale(totalLaps)) / 2).attr('y', y + rh / 2 + 4)
+ .attr('text-anchor', 'middle').attr('fill', '#8888aa').attr('font-size', 10)
+ .text('DNS');
+ return;
+ }
+
+ // Stints
+ (driver.stints || []).forEach(stint => {
+ const x1 = xScale(stint.lap_start);
+ const x2 = xScale(stint.lap_end);
+ const color = COMPOUND_COLOR[stint.compound] || COMPOUND_COLOR.UNKNOWN;
+
+ const bar = svg.append('rect')
+ .attr('x', x1).attr('y', y + 2)
+ .attr('width', Math.max(2, x2 - x1))
+ .attr('height', rh - 4)
+ .attr('fill', color).attr('rx', 3)
+ .attr('cursor', 'pointer');
+
+ // DNF: diagonal stripe overlay
+ if (driver.dnf && stint === driver.stints[driver.stints.length - 1]) {
+ bar.attr('opacity', 0.7);
+ // Add simple DNF label
+ svg.append('text')
+ .attr('x', x2 - 14).attr('y', y + rh / 2 + 4)
+ .attr('text-anchor', 'end').attr('fill', '#cc4422')
+ .attr('font-size', 9).attr('font-weight', '700').text('DNF');
+ }
+
+ bar.on('mousemove', e => {
+ const isNew = stint.is_new ? ' (new)' : ` (+${stint.tyre_age_at_start})`;
+ showTooltip(
+ `${driver.name_acronym} — Stint ${stint.stint_number}
` +
+ `${stint.compound}${isNew}
` +
+ `Laps ${stint.lap_start}–${stint.lap_end} (${stint.lap_count} laps)`, e
+ );
+ }).on('mouseleave', hideTooltip);
+ });
+
+ // Pit stops
+ (driver.pit_stops || []).forEach(pit => {
+ svg.append('circle')
+ .attr('cx', xScale(pit.lap_number))
+ .attr('cy', y + rh / 2)
+ .attr('r', 4)
+ .attr('fill', '#ffffff').attr('stroke', '#000').attr('stroke-width', 1)
+ .attr('cursor', 'pointer')
+ .on('mousemove', e => {
+ showTooltip(
+ `Pit lap ${pit.lap_number}
` +
+ `Stop: ${pit.stop_duration?.toFixed(2)}s Lane: ${pit.lane_duration?.toFixed(2)}s`, e
+ );
+ })
+ .on('mouseleave', hideTooltip);
+ });
+ });
+
+ observeResize(containerId, () => renderStrategy(containerId, data));
+ }
+
+ // ---- Lap time progression chart ----
+
+ function renderLapTimes(containerId, data) {
+ const el = document.getElementById(containerId);
+ if (!el || !data || !data.drivers) return;
+ el.innerHTML = '';
+
+ const drivers = data.drivers || [];
+ if (!drivers.length) { el.textContent = 'No lap time data.'; return; }
+
+ const ML = MARGIN.left + 10;
+ const MT = MARGIN.top;
+ const MB = MARGIN.bottom + 20;
+ const MR = MARGIN.right;
+ const width = Math.max(el.clientWidth || 800, 400);
+ const height = 300;
+
+ const svg = d3.select(el).append('svg')
+ .attr('width', width).attr('height', height);
+
+ // Collect all valid laps for domain calculation
+ const allLaps = drivers.flatMap(d =>
+ (d.laps || []).filter(l => l.lap_duration && !l.is_pit_out_lap)
+ .map(l => ({ lapNum: l.lap_number, t: l.lap_duration }))
+ );
+ if (!allLaps.length) { el.textContent = 'No lap data.'; return; }
+
+ const maxLap = d3.max(allLaps, l => l.lapNum) || 1;
+ const yMin = d3.min(allLaps, l => l.t);
+ const yMax = d3.max(allLaps, l => l.t);
+
+ const xScale = d3.scaleLinear().domain([1, maxLap]).range([ML, width - MR]);
+ const yScale = d3.scaleLinear()
+ .domain([yMin * 0.995, yMax * 1.005])
+ .range([height - MB, MT]);
+
+ // SC zones
+ (data.sc_periods || []).forEach(p => {
+ svg.append('rect')
+ .attr('x', xScale(p.lap_start)).attr('y', MT)
+ .attr('width', xScale(p.lap_end) - xScale(p.lap_start))
+ .attr('height', height - MT - MB)
+ .attr('fill', 'rgba(255,200,0,0.10)').attr('pointer-events', 'none');
+ });
+
+ // Axes
+ svg.append('g').attr('transform', `translate(0,${height - MB})`).call(
+ d3.axisBottom(xScale).ticks(Math.min(20, maxLap)).tickFormat(d3.format('d'))
+ );
+ svg.append('g').attr('transform', `translate(${ML},0)`).call(
+ d3.axisLeft(yScale).ticks(6).tickFormat(t => {
+ const m = Math.floor(t / 60);
+ const s = (t - m * 60).toFixed(1);
+ return `${m}:${s.padStart(4, '0')}`;
+ })
+ );
+
+ // Per-driver lines
+ const driverVisible = {};
+ drivers.forEach(d => { driverVisible[d.driver_number] = true; });
+
+ const lineGen = d3.line()
+ .defined(l => l.lap_duration != null && !l.is_pit_out_lap)
+ .x(l => xScale(l.lap_number))
+ .y(l => yScale(l.lap_duration));
+
+ const paths = {};
+ drivers.forEach(driver => {
+ const color = '#' + (driver.team_colour || '888888');
+ const path = svg.append('path')
+ .datum(driver.laps || [])
+ .attr('fill', 'none')
+ .attr('stroke', color)
+ .attr('stroke-width', 2)
+ .attr('d', lineGen);
+ paths[driver.driver_number] = path;
+
+ // Pit lap circles
+ const pitNums = new Set((data.pit_laps || {})[String(driver.driver_number)] || []);
+ (driver.laps || []).filter(l => pitNums.has(l.lap_number) && l.lap_duration).forEach(l => {
+ svg.append('circle')
+ .attr('cx', xScale(l.lap_number)).attr('cy', yScale(l.lap_duration))
+ .attr('r', 4).attr('fill', 'none').attr('stroke', color).attr('stroke-width', 2);
+ });
+ });
+
+ // Crosshair tooltip
+ const crosshair = svg.append('line')
+ .attr('y1', MT).attr('y2', height - MB)
+ .attr('stroke', '#8888aa').attr('stroke-width', 1).attr('display', 'none');
+
+ svg.append('rect')
+ .attr('x', ML).attr('y', MT)
+ .attr('width', width - ML - MR).attr('height', height - MT - MB)
+ .attr('fill', 'none').attr('pointer-events', 'all')
+ .on('mousemove', e => {
+ const [mx] = d3.pointer(e);
+ const lapNum = Math.round(xScale.invert(mx));
+ crosshair.attr('x1', xScale(lapNum)).attr('x2', xScale(lapNum)).attr('display', null);
+ const tips = drivers.map(d => {
+ const lap = (d.laps || []).find(l => l.lap_number === lapNum);
+ if (!lap || !lap.lap_duration) return '';
+ const m = Math.floor(lap.lap_duration / 60);
+ const s = (lap.lap_duration - m * 60).toFixed(3);
+ return `${d.name_acronym}: ${m}:${s.padStart(6,'0')}`;
+ }).filter(Boolean).join('
');
+ if (tips) showTooltip(`Lap ${lapNum}
${tips}`, e);
+ })
+ .on('mouseleave', () => { crosshair.attr('display', 'none'); hideTooltip(); });
+
+ // Legend with toggle
+ const legendG = svg.append('g').attr('transform', `translate(${ML},${MT - 20})`);
+ let lx = 0;
+ drivers.forEach(driver => {
+ const color = '#' + (driver.team_colour || '888888');
+ const g = legendG.append('g').attr('transform', `translate(${lx},0)`).attr('cursor', 'pointer');
+ g.append('rect').attr('width', 12).attr('height', 12).attr('fill', color).attr('rx', 2);
+ g.append('text').attr('x', 15).attr('y', 10).attr('fill', color).attr('font-size', 11)
+ .text(driver.name_acronym || driver.driver_number);
+ lx += 60;
+
+ g.on('click', () => {
+ const visible = !driverVisible[driver.driver_number];
+ driverVisible[driver.driver_number] = visible;
+ paths[driver.driver_number].attr('opacity', visible ? 1 : 0.15);
+ g.attr('opacity', visible ? 1 : 0.4);
+ });
+ });
+
+ observeResize(containerId, () => renderLapTimes(containerId, data));
+ }
+
+ // ---- Telemetry 4-panel chart ----
+
+ function renderTelemetry(containerId, driversData) {
+ const el = document.getElementById(containerId);
+ if (!el || !driversData || !driversData.length) return;
+ el.innerHTML = '';
+
+ const panelConfigs = [
+ { key: 'speed', label: 'Speed (km/h)', height: 120, yKey: 'Speed' },
+ { key: 'throttle', label: 'Throttle (%)', height: 80, yKey: 'Throttle' },
+ { key: 'brake', label: 'Brake (%)', height: 80, yKey: 'Brake' },
+ { key: 'gear', label: 'Gear', height: 60, yKey: 'NGear' },
+ ];
+
+ const GAP = 10;
+ const totalHeight = panelConfigs.reduce((s, p) => s + p.height, 0) + GAP * (panelConfigs.length - 1) + MARGIN.top + MARGIN.bottom;
+ const width = Math.max(el.clientWidth || 800, 400);
+ const ML = MARGIN.left + 10;
+ const MR = MARGIN.right;
+
+ const svg = d3.select(el).append('svg').attr('width', width).attr('height', totalHeight);
+
+ // Compute distances from speed + time for all drivers, pick max dist
+ const driverSeries = driversData.map(d => {
+ const pts = computeDistanceSeries(d.data || []);
+ return { ...d, pts };
+ });
+ const maxDist = d3.max(driverSeries, d => d.pts.length > 0 ? d.pts[d.pts.length - 1].dist : 0) || 1;
+
+ const xScale = d3.scaleLinear().domain([0, maxDist]).range([ML, width - MR]);
+
+ let yOffset = MARGIN.top;
+ panelConfigs.forEach((panel, pi) => {
+ const ph = panel.height;
+ const g = svg.append('g').attr('transform', `translate(0,${yOffset})`);
+
+ // Y domain
+ const allVals = driverSeries.flatMap(d => d.pts.map(p => p[panel.yKey] ?? 0));
+ let yMin = d3.min(allVals) ?? 0;
+ let yMax = d3.max(allVals) ?? 1;
+ if (panel.key === 'throttle' || panel.key === 'brake') { yMin = 0; yMax = 100; }
+ if (panel.key === 'gear') { yMin = 0; yMax = 8; }
+ const yScale = d3.scaleLinear().domain([yMin, yMax]).range([ph, 0]);
+
+ // Panel background
+ g.append('rect').attr('x', ML).attr('y', 0)
+ .attr('width', width - ML - MR).attr('height', ph)
+ .attr('fill', '#1B1B2F').attr('rx', 4);
+
+ // Label
+ g.append('text').attr('x', ML - 8).attr('y', ph / 2 + 4)
+ .attr('text-anchor', 'end').attr('fill', '#8888aa').attr('font-size', 10)
+ .text(panel.label);
+
+ // Y axis (right side of first panel)
+ if (pi === 0) {
+ g.append('g').attr('transform', `translate(${width - MR},0)`)
+ .call(d3.axisRight(yScale).ticks(4));
+ }
+
+ // Lines
+ const lineGen = d3.line()
+ .x(p => xScale(p.dist))
+ .y(p => yScale(p[panel.yKey] ?? 0))
+ .defined(p => p[panel.yKey] != null);
+
+ driverSeries.forEach(driver => {
+ if (!driver.pts.length) return;
+ g.append('path')
+ .datum(driver.pts)
+ .attr('fill', 'none')
+ .attr('stroke', '#' + (driver.teamColour || '888888'))
+ .attr('stroke-width', 1.5)
+ .attr('d', lineGen);
+ });
+
+ // X axis only on last panel
+ if (pi === panelConfigs.length - 1) {
+ g.append('g').attr('transform', `translate(0,${ph})`).call(
+ d3.axisBottom(xScale).ticks(8).tickFormat(d => `${Math.round(d / 1000)}k`)
+ );
+ }
+
+ yOffset += ph + GAP;
+ });
+
+ // Shared crosshair
+ const crosslines = panelConfigs.map((panel, pi) => {
+ const g = svg.select(`g:nth-of-type(${pi + 1})`);
+ return svg.append('line')
+ .attr('y1', MARGIN.top + panelConfigs.slice(0, pi).reduce((s, p) => s + p.height + GAP, 0))
+ .attr('y2', MARGIN.top + panelConfigs.slice(0, pi + 1).reduce((s, p) => s + p.height + GAP, 0) - GAP)
+ .attr('stroke', '#8888aa').attr('stroke-width', 1).attr('display', 'none');
+ });
+
+ svg.append('rect')
+ .attr('x', ML).attr('y', MARGIN.top)
+ .attr('width', width - ML - MR).attr('height', totalHeight - MARGIN.top - MARGIN.bottom)
+ .attr('fill', 'none').attr('pointer-events', 'all')
+ .on('mousemove', e => {
+ const [mx] = d3.pointer(e);
+ crosslines.forEach(l => l.attr('x1', mx).attr('x2', mx).attr('display', null));
+ const dist = xScale.invert(mx);
+ const tips = driverSeries.map(d => {
+ const pt = d.pts.find(p => p.dist >= dist);
+ if (!pt) return '';
+ return `${d.nameAcronym}: ` +
+ `${pt.Speed}km/h T${pt.Throttle}% B${pt.Brake}% G${pt.NGear}`;
+ }).filter(Boolean).join('
');
+ if (tips) showTooltip(tips, e);
+ })
+ .on('mouseleave', () => {
+ crosslines.forEach(l => l.attr('display', 'none'));
+ hideTooltip();
+ });
+
+ observeResize(containerId, () => renderTelemetry(containerId, driversData));
+ }
+
+ // Compute cumulative distance from car data samples.
+ function computeDistanceSeries(samples) {
+ if (!samples.length) return [];
+ const pts = [];
+ let dist = 0;
+ for (let i = 0; i < samples.length; i++) {
+ const s = samples[i];
+ if (i > 0) {
+ const prev = samples[i - 1];
+ const dt = (new Date(s.date) - new Date(prev.date)) / 1000; // seconds
+ if (dt > 0 && dt < 5) { // ignore large gaps
+ dist += (s.speed / 3.6) * dt; // speed in km/h → m/s * dt → metres
+ }
+ }
+ pts.push({
+ dist,
+ Speed: s.speed ?? 0,
+ Throttle: s.throttle ?? 0,
+ Brake: s.brake ?? 0,
+ NGear: s.n_gear ?? 0,
+ });
+ }
+ return pts;
+ }
+
+ return { renderStrategy, renderLapTimes, renderTelemetry };
+})();
diff --git a/internal/web/assets/index.html b/internal/web/assets/index.html
new file mode 100644
index 0000000..e5c6866
--- /dev/null
+++ b/internal/web/assets/index.html
@@ -0,0 +1,322 @@
+
+
+
+
+
+ box-box
+
+
+
+
+
+
+
+
+
+
+
Showing cached data — live session in progress (free tier)
+
+ Why?
+ During live F1 sessions the free-tier OpenF1 API restricts access.
+ box-box automatically serves the last cached data.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | POS | DRIVER | TEAM | TIME / GAP | PTS |
+
+
+
+
+
+ |
+
+ DNF
+ DNS
+ DSQ
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
No results available.
+
+
+
+
+
+
+
+
+
+
+
+
Track outline not available for this circuit.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No live session active. Check back during a race weekend.
+
+
+
+
+
+ Lap /
+
+
+
+
+
+
+ | POS | Δ | DRIVER | TYRE | LAST LAP | GAP | BEST |
+
+
+
+
+ |
+ |
+
+ PIT
+ OUT
+ |
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
RACE CONTROL
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | POS | DRIVER | TEAM | PTS |
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+ | POS | TEAM | PTS |
+
+
+
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/web/assets/style.css b/internal/web/assets/style.css
new file mode 100644
index 0000000..a44c84c
--- /dev/null
+++ b/internal/web/assets/style.css
@@ -0,0 +1,483 @@
+/* ============================================================
+ box-box web companion — dark F1 theme
+ Mirrors styles.go palette as CSS custom properties.
+ ============================================================ */
+
+:root {
+ --f1-red: #E10600;
+ --surface-0: #0d0d1a; /* page background */
+ --surface-1: #1B1B2F; /* card background */
+ --surface-2: #222236; /* elevated card */
+ --surface-3: #2D2D44; /* borders, dividers */
+ --text-1: #ffffff;
+ --text-2: #8888aa;
+ --text-3: #555577;
+
+ --soft: #FF3333;
+ --medium:#FFD700;
+ --hard: #CCCCCC;
+ --inter: #39B54A;
+ --wet: #0080FF;
+
+ font-family: system-ui, -apple-system, sans-serif;
+ font-size: 14px;
+ color: var(--text-1);
+ background: var(--surface-0);
+}
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+a { color: inherit; text-decoration: none; }
+
+/* ---- Nav ---- */
+.nav {
+ display: flex;
+ align-items: center;
+ gap: 2rem;
+ padding: 0.75rem 1.5rem;
+ background: var(--surface-1);
+ border-bottom: 1px solid var(--surface-3);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+.nav-brand {
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: var(--f1-red);
+ letter-spacing: 0.04em;
+}
+.nav-links {
+ display: flex;
+ gap: 1.5rem;
+}
+.nav-links a {
+ color: var(--text-2);
+ font-size: 0.85rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ transition: color 0.15s;
+}
+.nav-links a:hover,
+.nav-links a.active { color: var(--text-1); }
+.nav-links a.active { border-bottom: 2px solid var(--f1-red); padding-bottom: 2px; }
+
+/* ---- Live dot ---- */
+.live-dot {
+ display: inline-block;
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ background: #E10600;
+ animation: pulse 1.2s ease-in-out infinite;
+}
+.live-dot.large { width: 12px; height: 12px; }
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+/* ---- Stale banner ---- */
+.stale-banner {
+ background: #2a1800;
+ border-bottom: 1px solid #aa6600;
+ color: #ffcc66;
+ padding: 0.5rem 1.5rem;
+ font-size: 0.82rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+.stale-banner details summary { cursor: pointer; color: #aa8844; }
+.stale-banner details p { padding: 0.4rem 0; color: var(--text-2); }
+
+/* ---- Page layout ---- */
+.page-header {
+ padding: 1.25rem 1.5rem 0.75rem;
+ display: flex;
+ align-items: baseline;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+.page-title {
+ font-size: 1.25rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.back-btn {
+ color: var(--text-2);
+ font-size: 0.85rem;
+ white-space: nowrap;
+ transition: color 0.15s;
+}
+.back-btn:hover { color: var(--text-1); }
+.session-info { color: var(--text-2); font-size: 0.85rem; }
+
+/* ---- Cards ---- */
+.card {
+ background: var(--surface-1);
+ border: 1px solid var(--surface-3);
+ border-radius: 8px;
+ padding: 1rem 1.25rem;
+}
+.card-label {
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin-bottom: 0.6rem;
+}
+
+/* ---- Home hero ---- */
+.home-hero {
+ display: grid;
+ grid-template-columns: 1fr 320px;
+ gap: 1rem;
+ padding: 1rem 1.5rem;
+}
+@media (max-width: 900px) {
+ .home-hero { grid-template-columns: 1fr; }
+}
+
+/* Next race card */
+.next-race { text-align: center; }
+.next-race-flag { font-size: 3rem; margin: 0.25rem 0; }
+.next-race-name { font-size: 1.4rem; font-weight: 700; margin: 0.25rem 0; }
+.next-race-circuit { color: var(--text-2); font-size: 0.85rem; margin-bottom: 0.5rem; }
+.next-race-countdown {
+ font-size: 1.6rem;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+ color: var(--f1-red);
+ letter-spacing: 0.04em;
+}
+
+/* Championship sidebar */
+.champ-sidebar { display: flex; flex-direction: column; gap: 0.45rem; }
+.champ-row {
+ display: grid;
+ grid-template-columns: 20px 12px 36px 1fr 40px;
+ align-items: center;
+ gap: 0.4rem;
+}
+.champ-pos { color: var(--text-2); font-size: 0.8rem; text-align: right; }
+.champ-tla { font-weight: 600; font-size: 0.85rem; }
+.champ-bar-wrap {
+ background: var(--surface-3);
+ border-radius: 2px;
+ height: 6px;
+ overflow: hidden;
+}
+.champ-bar {
+ height: 100%;
+ background: var(--team-color, var(--f1-red));
+ border-radius: 2px;
+ transition: width 0.4s;
+}
+.champ-pts { font-size: 0.8rem; color: var(--text-2); text-align: right; }
+
+/* Team color swatch */
+.team-swatch {
+ display: inline-block;
+ width: 3px;
+ height: 14px;
+ background: var(--team-color, #666);
+ border-radius: 2px;
+ vertical-align: middle;
+ margin-right: 6px;
+}
+
+/* ---- Season meetings grid ---- */
+.section-label {
+ font-size: 0.7rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ padding: 0 1.5rem;
+ margin-bottom: 0.5rem;
+}
+.meetings-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 0.75rem;
+ padding: 0 1.5rem 2rem;
+}
+.meeting-card {
+ display: block;
+ background: var(--surface-1);
+ border: 1px solid var(--surface-3);
+ border-radius: 8px;
+ padding: 0.75rem 1rem;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+.meeting-card:hover { background: var(--surface-2); border-color: var(--text-3); }
+.meeting-card.past { opacity: 0.55; }
+.meeting-flag { font-size: 1.5rem; margin-bottom: 0.3rem; }
+.meeting-name { font-weight: 600; font-size: 0.9rem; line-height: 1.3; }
+.meeting-circuit { color: var(--text-2); font-size: 0.78rem; margin-top: 0.15rem; }
+.meeting-date { color: var(--text-3); font-size: 0.75rem; margin-top: 0.3rem; }
+
+/* ---- Session pills ---- */
+.session-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+}
+.pill {
+ background: var(--surface-2);
+ border: 1px solid var(--surface-3);
+ border-radius: 20px;
+ color: var(--text-2);
+ cursor: pointer;
+ font-size: 0.78rem;
+ font-weight: 600;
+ padding: 0.3rem 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+}
+.pill:hover { background: var(--surface-3); color: var(--text-1); }
+.pill.active {
+ background: var(--team-color, var(--f1-red));
+ border-color: transparent;
+ color: #fff;
+}
+
+/* ---- Tabs ---- */
+.tabs {
+ display: flex;
+ gap: 0;
+ border-bottom: 1px solid var(--surface-3);
+ padding: 0 1.5rem;
+ margin-bottom: 1rem;
+}
+.tab {
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--text-2);
+ cursor: pointer;
+ font-size: 0.82rem;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ padding: 0.6rem 1rem;
+ text-transform: uppercase;
+ transition: color 0.15s, border-color 0.15s;
+}
+.tab:hover { color: var(--text-1); }
+.tab.active { color: var(--text-1); border-bottom-color: var(--f1-red); }
+
+/* ---- Data table ---- */
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+ margin: 0 1.5rem 1.5rem;
+ width: calc(100% - 3rem);
+}
+.data-table th {
+ background: var(--surface-1);
+ border-bottom: 1px solid var(--surface-3);
+ color: var(--text-3);
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ text-transform: uppercase;
+}
+.data-table td {
+ border-bottom: 1px solid var(--surface-3);
+ padding: 0.5rem 0.75rem;
+ vertical-align: middle;
+}
+.data-table tr:hover td { background: var(--surface-1); }
+.pos-cell { font-weight: 700; display: flex; align-items: center; gap: 0.4rem; }
+
+/* ---- Badges ---- */
+.badge {
+ border-radius: 3px;
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ padding: 1px 5px;
+ text-transform: uppercase;
+}
+.badge.dnf { background: #442200; color: #ff8844; }
+.badge.dns { background: #333355; color: #8888cc; }
+.badge.dsq { background: #220000; color: #ff4444; }
+.badge.pit { background: #003366; color: #66aaff; }
+.badge.out { background: #330000; color: #ff6666; }
+
+/* ---- Tyre badges ---- */
+.tyre-badge {
+ border-radius: 3px;
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ padding: 1px 6px;
+}
+.tyre-soft { background: var(--soft); color: #fff; }
+.tyre-medium { background: var(--medium); color: #111; }
+.tyre-hard { background: var(--hard); color: #111; }
+.tyre-inter { background: var(--inter); color: #fff; }
+.tyre-wet { background: var(--wet); color: #fff; }
+.tyre-unknown { background: var(--surface-3); color: var(--text-2); }
+
+/* ---- Timing tower ---- */
+.live-meta {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ padding: 0.5rem 1.5rem 0.75rem;
+ font-size: 0.85rem;
+}
+.timing-tower {
+ width: calc(100% - 3rem);
+ margin: 0 1.5rem 1.5rem;
+ border-collapse: collapse;
+ font-size: 0.85rem;
+ font-variant-numeric: tabular-nums;
+}
+.timing-tower th {
+ background: var(--surface-1);
+ border-bottom: 1px solid var(--surface-3);
+ color: var(--text-3);
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ padding: 0.5rem 0.75rem;
+ text-align: left;
+ text-transform: uppercase;
+}
+.timing-tower td {
+ border-bottom: 1px solid var(--surface-3);
+ padding: 0.45rem 0.75rem;
+ vertical-align: middle;
+}
+.timing-tower .in-pit td { background: rgba(0,51,102,0.25); }
+.timing-tower .retired td { background: rgba(60,0,0,0.3); opacity: 0.7; }
+.timing-tower .pit-out td { background: rgba(0,80,0,0.2); }
+.timing-tower .pb { color: #aaffaa; }
+.timing-tower .ob { color: #cc44ff; }
+.pos-delta { color: var(--text-3); font-size: 0.75rem; }
+.clock { font-variant-numeric: tabular-nums; font-weight: 600; margin-left: auto; }
+
+/* Track status */
+.track-status {
+ border-radius: 4px;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ padding: 2px 8px;
+ text-transform: uppercase;
+}
+.track-green { background: #1a3a1a; color: #44cc44; }
+.track-yellow { background: #3a2a00; color: #ffcc00; }
+.track-red { background: #3a0000; color: #ff4444; }
+.track-sc { background: #3a2a00; color: #ffaa00; }
+.track-vsc { background: #1a2a3a; color: #6699ff; }
+
+/* ---- Race control panel ---- */
+.rc-panel {
+ background: var(--surface-1);
+ border: 1px solid var(--surface-3);
+ border-radius: 8px;
+ margin: 0 1.5rem 1.5rem;
+ padding: 0.75rem 1rem;
+}
+.rc-message {
+ border-bottom: 1px solid var(--surface-3);
+ display: flex;
+ gap: 0.75rem;
+ font-size: 0.82rem;
+ padding: 0.4rem 0;
+}
+.rc-message:last-child { border-bottom: none; }
+.rc-time { color: var(--text-3); flex-shrink: 0; font-size: 0.75rem; }
+.rc-flag { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; }
+.rc-flag { background: var(--surface-3); border-radius: 2px; padding: 1px 5px; }
+.rc-safetycarpanel .rc-flag,
+.rc-safetycarpanel { color: #ffcc00; }
+
+/* ---- Driver selector ---- */
+.driver-selector {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1.5rem 0.75rem;
+}
+.driver-selector label { color: var(--text-2); font-size: 0.8rem; margin-right: 0.25rem; }
+
+/* ---- Year selector ---- */
+.year-selector {
+ display: flex;
+ gap: 0.4rem;
+ margin-left: auto;
+}
+
+/* ---- Skeleton loading ---- */
+.skeleton-wrap { padding: 0.75rem 1.5rem; }
+.skeleton {
+ background: linear-gradient(
+ 90deg,
+ var(--surface-1) 25%,
+ var(--surface-2) 50%,
+ var(--surface-1) 75%
+ );
+ background-size: 200% 100%;
+ border-radius: 6px;
+ animation: shimmer 1.4s infinite;
+}
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* ---- Empty state ---- */
+.empty-msg {
+ color: var(--text-2);
+ font-size: 0.9rem;
+ padding: 2rem 1.5rem;
+ text-align: center;
+}
+.empty-msg.large { font-size: 1.1rem; padding: 4rem 1.5rem; }
+
+/* ---- D3 charts ---- */
+#strategy-chart,
+#laps-chart,
+#telemetry-chart,
+#track-chart {
+ padding: 0 1.5rem 1.5rem;
+}
+#strategy-chart svg,
+#laps-chart svg,
+#telemetry-chart svg,
+#track-chart svg { display: block; }
+
+/* D3 axis text */
+.tick text { fill: var(--text-2); font-size: 11px; }
+.domain, .tick line { stroke: var(--surface-3); }
+
+/* Tooltip */
+.chart-tooltip {
+ background: var(--surface-2);
+ border: 1px solid var(--surface-3);
+ border-radius: 6px;
+ color: var(--text-1);
+ font-size: 0.8rem;
+ padding: 6px 10px;
+ pointer-events: none;
+ position: fixed;
+ z-index: 200;
+}
diff --git a/internal/web/assets/track.js b/internal/web/assets/track.js
new file mode 100644
index 0000000..aafd632
--- /dev/null
+++ b/internal/web/assets/track.js
@@ -0,0 +1,110 @@
+/* ============================================================
+ box-box — SVG track map renderer
+ Track.render(containerId, outlinePoints, carPositions, driverInfo)
+ Track.updatePositions(containerId, carPositions)
+ ============================================================ */
+
+const Track = (() => {
+ // Store rendered state per container
+ const state = {};
+
+ function render(containerId, outlinePoints, carPositions, driverInfo) {
+ const el = document.getElementById(containerId);
+ if (!el) return;
+ el.innerHTML = '';
+
+ if (!outlinePoints || outlinePoints.length < 3) {
+ el.innerHTML = 'Track outline not available.
';
+ return;
+ }
+
+ const width = el.clientWidth || 500;
+ const height = el.clientHeight || 340;
+ const PAD = 32;
+
+ const xScale = d3.scaleLinear().domain([0, 1]).range([PAD, width - PAD]);
+ const yScale = d3.scaleLinear().domain([0, 1]).range([PAD, height - PAD]);
+
+ const svg = d3.select(el).append('svg')
+ .attr('width', width).attr('height', height);
+
+ // Track outline path
+ const lineGen = d3.line()
+ .x(p => xScale(p.x))
+ .y(p => yScale(p.y))
+ .curve(d3.curveCatmullRomClosed);
+
+ svg.append('path')
+ .datum(outlinePoints)
+ .attr('d', lineGen)
+ .attr('fill', 'none')
+ .attr('stroke', '#444466')
+ .attr('stroke-width', 8)
+ .attr('stroke-linecap', 'round');
+
+ svg.append('path')
+ .datum(outlinePoints)
+ .attr('d', lineGen)
+ .attr('fill', 'none')
+ .attr('stroke', '#2D2D44')
+ .attr('stroke-width', 4)
+ .attr('stroke-linecap', 'round');
+
+ // Car group (updated separately)
+ svg.append('g').attr('id', `${containerId}-cars`);
+
+ state[containerId] = { svg, xScale, yScale, driverInfo: driverInfo || {} };
+
+ // Initial positions
+ if (carPositions && Object.keys(carPositions).length) {
+ updatePositions(containerId, carPositions);
+ }
+ }
+
+ function updatePositions(containerId, carPositions) {
+ const s = state[containerId];
+ if (!s) return;
+
+ const { svg, xScale, yScale, driverInfo } = s;
+ const carsG = svg.select(`#${containerId}-cars`);
+
+ const cars = Object.entries(carPositions || {}).map(([num, pos]) => ({
+ num,
+ x: pos.x,
+ y: pos.y,
+ info: driverInfo[num] || {},
+ }));
+
+ // Bind data
+ const sel = carsG.selectAll('.car-marker').data(cars, d => d.num);
+
+ // Enter
+ const enter = sel.enter().append('g').attr('class', 'car-marker');
+ enter.append('circle').attr('r', 7);
+ enter.append('text')
+ .attr('text-anchor', 'middle')
+ .attr('dy', '0.35em')
+ .attr('font-size', 7)
+ .attr('font-weight', '700')
+ .attr('fill', '#fff')
+ .attr('pointer-events', 'none');
+
+ // Update (enter + update)
+ const merged = enter.merge(sel);
+ merged.transition().duration(500).ease(d3.easeLinear)
+ .attr('transform', d => `translate(${xScale(d.x)},${yScale(d.y)})`);
+
+ merged.select('circle')
+ .attr('fill', d => '#' + (d.info.TeamColour || '666666'))
+ .attr('stroke', '#111')
+ .attr('stroke-width', 1);
+
+ merged.select('text')
+ .text(d => d.info.Tla || d.num.slice(0, 3));
+
+ // Exit
+ sel.exit().remove();
+ }
+
+ return { render, updatePositions };
+})();
diff --git a/internal/web/live.go b/internal/web/live.go
new file mode 100644
index 0000000..14d5564
--- /dev/null
+++ b/internal/web/live.go
@@ -0,0 +1,220 @@
+package web
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/AmanTahiliani/box-box/internal/ui"
+)
+
+// sseClient is a connected SSE subscriber.
+type sseClient struct {
+ ch chan []byte // buffered; non-blocking sends
+ done chan struct{}
+}
+
+// sseEvent is an outbound SSE frame.
+type sseEvent struct {
+ name string
+ data []byte
+}
+
+// SSEHub manages SSE clients and broadcasts live events.
+type SSEHub struct {
+ register chan *sseClient
+ deregister chan *sseClient
+ broadcast chan sseEvent
+
+ mu sync.RWMutex
+ lastSnapshot *ui.LiveStreamData
+ isLive bool
+}
+
+func newSSEHub() *SSEHub {
+ return &SSEHub{
+ register: make(chan *sseClient, 16),
+ deregister: make(chan *sseClient, 16),
+ broadcast: make(chan sseEvent, 64),
+ }
+}
+
+// run is the hub's event loop. Must be called in a goroutine.
+func (h *SSEHub) run() {
+ clients := make(map[*sseClient]bool)
+ for {
+ select {
+ case c := <-h.register:
+ clients[c] = true
+ // Send catch-up snapshot so new clients see current state immediately.
+ h.mu.RLock()
+ snap := h.lastSnapshot
+ live := h.isLive
+ h.mu.RUnlock()
+ if snap != nil {
+ if data, err := json.Marshal(map[string]any{"data": snap, "is_live": live}); err == nil {
+ select {
+ case c.ch <- formatSSEFrame("snapshot", data):
+ default:
+ }
+ }
+ }
+
+ case c := <-h.deregister:
+ if clients[c] {
+ delete(clients, c)
+ close(c.ch)
+ }
+
+ case ev := <-h.broadcast:
+ frame := formatSSEFrame(ev.name, ev.data)
+ for c := range clients {
+ select {
+ case c.ch <- frame:
+ default:
+ // Slow client — drop frame rather than block.
+ }
+ }
+ }
+ }
+}
+
+func formatSSEFrame(event string, data []byte) []byte {
+ return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data))
+}
+
+// Snapshot returns the latest live data snapshot and whether a session is active.
+func (h *SSEHub) Snapshot() (*ui.LiveStreamData, bool) {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+ return h.lastSnapshot, h.isLive
+}
+
+// runLiveFeeds launches background goroutines for the F1 SignalR feed and keepalive.
+func (s *Server) runLiveFeeds() {
+ // Goroutine A: F1 SignalR bridge.
+ go s.signalRLoop()
+
+ // Goroutine B: SSE keepalive every 20s to prevent proxy timeouts.
+ go func() {
+ ticker := time.NewTicker(20 * time.Second)
+ defer ticker.Stop()
+ for range ticker.C {
+ s.hub.broadcast <- sseEvent{name: "heartbeat", data: []byte(`"ping"`)}
+ }
+ }()
+}
+
+// signalRLoop connects to the F1 live timing feed, processes updates, and
+// reconnects with exponential backoff on failure.
+func (s *Server) signalRLoop() {
+ backoff := 5 * time.Second
+ const maxBackoff = 2 * time.Minute
+
+ for {
+ if err := s.connectAndDrain(); err != nil {
+ log.Printf("web: live feed ended: %v", err)
+ }
+
+ s.hub.mu.Lock()
+ s.hub.isLive = false
+ s.hub.mu.Unlock()
+
+ log.Printf("web: live feed reconnecting in %v", backoff)
+ time.Sleep(backoff)
+ if backoff < maxBackoff {
+ backoff = min(backoff*2, maxBackoff)
+ }
+ }
+}
+
+// connectAndDrain establishes a SignalR connection and drains the data channel
+// until the feed goes silent for 60 seconds.
+func (s *Server) connectAndDrain() error {
+ dataChan := make(chan ui.LiveStreamData, 16)
+
+ if err := ui.ConnectToF1LiveTiming(dataChan); err != nil {
+ return err
+ }
+ log.Printf("web: live feed connected")
+
+ // Reset backoff on successful connect.
+ idleTimeout := 60 * time.Second
+ timer := time.NewTimer(idleTimeout)
+ defer timer.Stop()
+
+ for {
+ select {
+ case data := <-dataChan:
+ s.hub.mu.Lock()
+ s.hub.lastSnapshot = &data
+ s.hub.isLive = true
+ s.hub.mu.Unlock()
+
+ if payload, err := json.Marshal(map[string]any{"data": data, "is_live": true}); err == nil {
+ s.hub.broadcast <- sseEvent{name: "snapshot", data: payload}
+ }
+
+ if !timer.Stop() {
+ select {
+ case <-timer.C:
+ default:
+ }
+ }
+ timer.Reset(idleTimeout)
+
+ case <-timer.C:
+ return fmt.Errorf("idle timeout (%v)", idleTimeout)
+ }
+ }
+}
+
+// handleLiveState returns the current live data snapshot as JSON.
+func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
+ snap, isLive := s.hub.Snapshot()
+ writeJSON(w, map[string]any{
+ "is_live": isLive,
+ "data": snap,
+ })
+}
+
+// handleSSEStream is the persistent SSE endpoint for live data.
+func (s *Server) handleSSEStream(w http.ResponseWriter, r *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming not supported", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+ flusher.Flush()
+
+ client := &sseClient{
+ ch: make(chan []byte, 8),
+ done: make(chan struct{}),
+ }
+ s.hub.register <- client
+ defer func() { s.hub.deregister <- client }()
+
+ ctx := r.Context()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case msg, ok := <-client.ch:
+ if !ok {
+ return
+ }
+ if _, err := w.Write(msg); err != nil {
+ return
+ }
+ flusher.Flush()
+ }
+ }
+}
diff --git a/internal/web/server.go b/internal/web/server.go
new file mode 100644
index 0000000..d569b72
--- /dev/null
+++ b/internal/web/server.go
@@ -0,0 +1,107 @@
+package web
+
+import (
+ "embed"
+ "fmt"
+ "io/fs"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/AmanTahiliani/box-box/internal/api"
+)
+
+//go:embed assets
+var assetsFS embed.FS
+
+// Server is the box-box web companion HTTP server.
+type Server struct {
+ client *api.OpenF1Client
+ hub *SSEHub
+ addr string
+}
+
+// NewServer creates a new Server. Call Start() to begin serving.
+func NewServer(client *api.OpenF1Client, port int) *Server {
+ return &Server{
+ client: client,
+ hub: newSSEHub(),
+ addr: fmt.Sprintf(":%d", port),
+ }
+}
+
+// Start registers routes, launches background goroutines, and begins serving.
+func (s *Server) Start() error {
+ mux := http.NewServeMux()
+
+ // REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
+ // because Go's ServeMux uses longest-prefix matching.
+ mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
+ mux.HandleFunc("/api/v1/sessions", s.handleSessions)
+ mux.HandleFunc("/api/v1/drivers", s.handleDrivers)
+ mux.HandleFunc("/api/v1/results", s.handleResults)
+ mux.HandleFunc("/api/v1/grid", s.handleGrid)
+ mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
+ mux.HandleFunc("/api/v1/laps", s.handleLaps)
+ mux.HandleFunc("/api/v1/weather", s.handleWeather)
+ mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
+ mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
+ mux.HandleFunc("/api/v1/overtakes", s.handleOvertakes)
+ mux.HandleFunc("/api/v1/team-radio", s.handleTeamRadio)
+ mux.HandleFunc("/api/v1/championship/drivers", s.handleChampionshipDrivers)
+ mux.HandleFunc("/api/v1/championship/teams", s.handleChampionshipTeams)
+ mux.HandleFunc("/api/v1/track-outline", s.handleTrackOutline)
+ mux.HandleFunc("/api/v1/strategy", s.handleStrategy)
+ mux.HandleFunc("/api/v1/live/state", s.handleLiveState)
+ mux.HandleFunc("/api/v1/live/stream", s.handleSSEStream)
+
+ // Static files + SPA catchall
+ subFS, err := fs.Sub(assetsFS, "assets")
+ if err != nil {
+ return err
+ }
+ fileServer := http.FileServer(http.FS(subFS))
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ // If the path maps to a real asset file, serve it directly.
+ if r.URL.Path != "/" {
+ p := strings.TrimPrefix(r.URL.Path, "/")
+ if f, err := subFS.Open(p); err == nil {
+ f.Close()
+ fileServer.ServeHTTP(w, r)
+ return
+ }
+ }
+ // SPA catchall: all unknown paths serve index.html.
+ r2 := *r
+ r2.URL.Path = "/"
+ fileServer.ServeHTTP(w, &r2)
+ })
+
+ // Start background goroutines.
+ go s.hub.run()
+ go s.runLiveFeeds()
+
+ return http.ListenAndServe(s.addr, withCORS(withLogging(mux)))
+}
+
+// withCORS adds permissive CORS headers (localhost use only).
+func withCORS(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// withLogging logs each request to stderr.
+func withLogging(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ log.Printf("web: %s %s", r.Method, r.URL.RequestURI())
+ next.ServeHTTP(w, r)
+ })
+}