package web import ( "context" "database/sql" "encoding/json" "errors" "fmt" "net/http" "sort" "strconv" "strings" "sync" "time" readability "codeberg.org/readeck/go-readability/v2" "github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/query" ) // 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() } switch parseSourceMode(r) { case sourceLocal: if !s.hasLocalQuery() { writeJSON(w, []models.Meeting{}) return } meetings, err := s.query.ListMeetingsByYear(year) if err != nil { writeError(w, err, http.StatusInternalServerError, false) return } writeJSON(w, meetings) return case sourceAuto: if s.hasLocalQuery() { meetings, err := s.query.ListMeetingsByYear(year) if err != nil { writeError(w, err, http.StatusInternalServerError, false) return } if len(meetings) > 0 { writeJSON(w, meetings) return } } } 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 } switch parseSourceMode(r) { case sourceLocal: if !s.hasLocalQuery() { writeJSON(w, []models.Session{}) return } sessions, err := s.query.ListSessionsByMeeting(meetingKey) if err != nil { writeError(w, err, http.StatusInternalServerError, false) return } writeJSON(w, sessions) return case sourceAuto: if s.hasLocalQuery() { sessions, err := s.query.ListSessionsByMeeting(meetingKey) if err != nil { writeError(w, err, http.StatusInternalServerError, false) return } if len(sessions) > 0 { writeJSON(w, sessions) return } } } sessions, err := s.client.GetSessionsForMeeting(meetingKey) if err != nil { writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) return } writeJSON(w, sessions) } // --- /api/v1/news --- func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) { if !s.hasLocalQuery() { writeJSON(w, []query.NewsItem{}) return } limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) source := strings.TrimSpace(r.URL.Query().Get("source")) items, err := s.query.ListNews(limit, source) if err != nil { writeError(w, err, http.StatusInternalServerError, false) return } writeJSON(w, items) } // --- /api/v1/news/article --- type articleResponse struct { Title string `json:"title"` Byline string `json:"byline,omitempty"` Excerpt string `json:"excerpt,omitempty"` ImageURL string `json:"image_url,omitempty"` Content string `json:"content"` SiteName string `json:"site_name,omitempty"` } func (s *Server) handleNewsArticle(w http.ResponseWriter, r *http.Request) { rawURL := strings.TrimSpace(r.URL.Query().Get("url")) if rawURL == "" { http.Error(w, "url required", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() withCtx := readability.RequestWith(func(req *http.Request) { *req = *req.WithContext(ctx) }) article, err := readability.FromURL(rawURL, 20*time.Second, withCtx) if err != nil { writeError(w, fmt.Errorf("article fetch: %w", err), http.StatusBadGateway, false) return } var buf strings.Builder if article.Node != nil { if rerr := article.RenderHTML(&buf); rerr != nil { writeError(w, rerr, http.StatusInternalServerError, false) return } } writeJSON(w, articleResponse{ Title: article.Title(), Byline: article.Byline(), Excerpt: article.Excerpt(), ImageURL: article.ImageURL(), Content: buf.String(), SiteName: article.SiteName(), }) } // --- /api/v1/news/read --- func (s *Server) handleNewsRead(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.hasLocalQuery() { w.WriteHeader(http.StatusNoContent) return } var body struct { URL string `json:"url"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.URL == "" { http.Error(w, "url required", http.StatusBadRequest) return } if err := s.query.MarkNewsRead(body.URL); err != nil { writeError(w, err, http.StatusInternalServerError, false) return } w.WriteHeader(http.StatusNoContent) } // --- /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 } switch parseSourceMode(r) { case sourceLocal: if !s.hasLocalQuery() { writeJSON(w, []models.Driver{}) return } drivers, err := s.query.ListDrivers(sessionKey) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, []models.Driver{}) return } writeError(w, err, http.StatusInternalServerError, false) return } writeJSON(w, drivers) return case sourceAuto: if s.hasLocalQuery() { drivers, err := s.query.ListDrivers(sessionKey) if err == nil && len(drivers) > 0 { writeJSON(w, drivers) return } if err != nil && !errors.Is(err, sql.ErrNoRows) { writeError(w, err, http.StatusInternalServerError, false) 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 } switch parseSourceMode(r) { case sourceLocal: if !s.hasLocalQuery() { writeJSON(w, []resultWithDriver{}) return } results, err := s.query.ListResults(sessionKey) if err != nil { writeError(w, err, http.StatusInternalServerError, false) return } writeJSON(w, enrichedResultsToAPI(results)) return case sourceAuto: if s.hasLocalQuery() { results, err := s.query.ListResults(sessionKey) if err == nil && len(results) > 0 { writeJSON(w, enrichedResultsToAPI(results)) return } if err != nil { writeError(w, err, http.StatusInternalServerError, false) 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 } switch parseSourceMode(r) { case sourceLocal: if !s.hasLocalQuery() { writeJSON(w, []gridWithDriver{}) return } grid, err := s.query.ListStartingGrid(sessionKey) if err != nil { writeError(w, err, http.StatusInternalServerError, false) return } writeJSON(w, enrichedGridToAPI(grid)) return case sourceAuto: if s.hasLocalQuery() { grid, err := s.query.ListStartingGrid(sessionKey) if err == nil && len(grid) > 0 { writeJSON(w, enrichedGridToAPI(grid)) return } if err != nil { writeError(w, err, http.StatusInternalServerError, false) 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 := buildDriverMapFirst(drivers) enriched := make([]champDriverWithInfo, 0, len(champ)) for _, c := range champ { e := champDriverWithInfo{ChampionshipDriver: c} d, ok := s.championshipDriverInfo(c.SessionKey, c.DriverNumber, driverMap) if ok { e.NameAcronym = d.NameAcronym e.FullName = d.FullName e.TeamName = d.TeamName e.TeamColour = d.TeamColour } enriched = append(enriched, e) } writeJSON(w, enriched) } func (s *Server) championshipDriverInfo(sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) { if d, err := s.client.GetDriver(sessionKey, driverNumber); err == nil && d != nil { return *d, true } d, ok := fallback[driverNumber] return d, ok } // --- /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 } func buildDriverMapFirst(drivers []models.Driver) map[int]models.Driver { m := make(map[int]models.Driver, len(drivers)) for _, d := range drivers { if _, exists := m[d.DriverNumber]; !exists { m[d.DriverNumber] = d } } return m } func enrichedResultsToAPI(results []query.EnrichedResult) []resultWithDriver { out := make([]resultWithDriver, 0, len(results)) for _, res := range results { out = append(out, resultWithDriver{ SessionResult: res.SessionResult, NameAcronym: res.NameAcronym, FullName: res.FullName, TeamName: res.TeamName, TeamColour: res.TeamColour, }) } return out } func enrichedGridToAPI(grid []query.EnrichedGrid) []gridWithDriver { out := make([]gridWithDriver, 0, len(grid)) for _, g := range grid { out = append(out, gridWithDriver{ StartingGrid: g.StartingGrid, NameAcronym: g.NameAcronym, FullName: g.FullName, TeamName: g.TeamName, TeamColour: g.TeamColour, }) } return out }