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/championship/hub --- // Aggregated championship view: official points/positions enriched with derived // stats (wins, podiums, poles, recent form, teammate head-to-head) and a // per-round cumulative-points series, computed from season race results. type champHubDriver struct { DriverNumber int `json:"driver_number"` NameAcronym string `json:"name_acronym"` FullName string `json:"full_name"` TeamName string `json:"team_name"` TeamColour string `json:"team_colour"` Points float64 `json:"points"` Position int `json:"position"` Wins int `json:"wins"` Podiums int `json:"podiums"` Poles int `json:"poles"` Form []float64 `json:"form"` // last 5 races' points Cumulative []float64 `json:"cumulative"` // running total per completed round TeammateWins int `json:"teammate_wins"` TeammateLosses int `json:"teammate_losses"` } type champHubTeam struct { TeamName string `json:"team_name"` TeamColour string `json:"team_colour"` Points float64 `json:"points"` Position int `json:"position"` Wins int `json:"wins"` } type champHubResponse struct { Season int `json:"season"` Round int `json:"round"` TotalRounds int `json:"total_rounds"` RoundsLeft int `json:"rounds_left"` LastRace string `json:"last_race"` RoundLabels []string `json:"round_labels"` Drivers []champHubDriver `json:"drivers"` Teams []champHubTeam `json:"teams"` } // meetingRace bundles a GP meeting with its (already-fetched) race results and grid. type meetingRace struct { Meeting models.Meeting RaceSessionKey int Results []models.SessionResult Grid []models.StartingGrid } func (s *Server) handleChampionshipHub(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 } champ, err := s.client.GetDriverChampionshipForYear(year) if err != nil { writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) return } if len(champ) == 0 { writeJSON(w, champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}) return } teams, _ := s.client.GetTeamChampionshipForYear(year) driverInfo := map[int]models.Driver{} if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil { driverInfo = buildDriverMapFirst(ds) } sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart }) races := make([]meetingRace, 0, len(meetings)) for _, m := range meetings { sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey)) if serr != nil { continue } raceKey := 0 for _, sess := range sessions { if strings.EqualFold(sess.SessionName, "Race") { raceKey = sess.SessionKey break } } if raceKey == 0 { continue // not a GP meeting (e.g. pre-season testing) } results, _ := s.client.GetSessionResult(raceKey) grid, _ := s.client.GetStartingGrid(raceKey) races = append(races, meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}) } writeJSON(w, aggregateChampionshipHub(year, races, champ, teams, driverInfo)) } // aggregateChampionshipHub is the pure aggregation core (no network) so it can be // unit-tested with synthetic data. races must be ordered ascending by date and // contain only GP meetings (those with a Race session). func aggregateChampionshipHub( year int, races []meetingRace, champ []models.ChampionshipDriver, teams []models.ChampionshipTeam, driverInfo map[int]models.Driver, ) champHubResponse { type acc struct { wins, podiums, poles int form []float64 finishByRound map[int]int } accs := map[int]*acc{} getAcc := func(num int) *acc { a := accs[num] if a == nil { a = &acc{finishByRound: map[int]int{}} accs[num] = a } return a } completed := 0 lastRace := "" roundPoints := []map[int]float64{} // per completed round: driver -> race points for _, mr := range races { if len(mr.Results) == 0 { continue // round not completed yet } completed++ lastRace = mr.Meeting.MeetingName for _, g := range mr.Grid { if g.Position == 1 { getAcc(g.DriverNumber).poles++ } } rp := map[int]float64{} for _, res := range mr.Results { a := getAcc(res.DriverNumber) if res.Position == 1 { a.wins++ } if res.Position >= 1 && res.Position <= 3 { a.podiums++ } a.form = append(a.form, res.Points) a.finishByRound[completed] = res.Position rp[res.DriverNumber] += res.Points } roundPoints = append(roundPoints, rp) } roundLabels := make([]string, 0, completed) for i := 1; i <= completed; i++ { roundLabels = append(roundLabels, fmt.Sprintf("R%d", i)) } // Official totals are authoritative; reconcile the cumulative endpoint to them. champPts := map[int]float64{} for _, c := range champ { champPts[c.DriverNumber] = c.PointsCurrent } cumulative := map[int][]float64{} for num := range accs { running := 0.0 series := make([]float64, 0, completed) for i := 0; i < completed; i++ { running += roundPoints[i][num] series = append(series, running) } if completed > 0 { if off, ok := champPts[num]; ok { series[completed-1] = off } } cumulative[num] = series } // Teammate head-to-head: per round, the teammate finishing ahead wins. teamOf := func(num int) string { return driverInfo[num].TeamName } byTeam := map[string][]int{} for num := range accs { byTeam[teamOf(num)] = append(byTeam[teamOf(num)], num) } twins := map[int]int{} tloss := map[int]int{} for team, members := range byTeam { if team == "" || len(members) < 2 { continue } for round := 1; round <= completed; round++ { for i := 0; i < len(members); i++ { for j := i + 1; j < len(members); j++ { p1, ok1 := accs[members[i]].finishByRound[round] p2, ok2 := accs[members[j]].finishByRound[round] if !ok1 || !ok2 { continue } if p1 < p2 { twins[members[i]]++ tloss[members[j]]++ } else if p2 < p1 { twins[members[j]]++ tloss[members[i]]++ } } } } } sortedChamp := make([]models.ChampionshipDriver, len(champ)) copy(sortedChamp, champ) sort.Slice(sortedChamp, func(i, j int) bool { return sortedChamp[i].PositionCurrent < sortedChamp[j].PositionCurrent }) drivers := make([]champHubDriver, 0, len(sortedChamp)) for _, c := range sortedChamp { a := accs[c.DriverNumber] if a == nil { a = &acc{} } form := a.form if len(form) > 5 { form = form[len(form)-5:] } info := driverInfo[c.DriverNumber] drivers = append(drivers, champHubDriver{ DriverNumber: c.DriverNumber, NameAcronym: info.NameAcronym, FullName: info.FullName, TeamName: info.TeamName, TeamColour: info.TeamColour, Points: c.PointsCurrent, Position: c.PositionCurrent, Wins: a.wins, Podiums: a.podiums, Poles: a.poles, Form: form, Cumulative: cumulative[c.DriverNumber], TeammateWins: twins[c.DriverNumber], TeammateLosses: tloss[c.DriverNumber], }) } teamWins := map[string]int{} teamColour := map[string]string{} for num, a := range accs { teamWins[teamOf(num)] += a.wins if col := driverInfo[num].TeamColour; col != "" { teamColour[teamOf(num)] = col } } sortedTeams := make([]models.ChampionshipTeam, len(teams)) copy(sortedTeams, teams) sort.Slice(sortedTeams, func(i, j int) bool { return sortedTeams[i].PositionCurrent < sortedTeams[j].PositionCurrent }) teamsOut := make([]champHubTeam, 0, len(sortedTeams)) for _, t := range sortedTeams { teamsOut = append(teamsOut, champHubTeam{ TeamName: t.TeamName, TeamColour: teamColour[t.TeamName], Points: t.PointsCurrent, Position: t.PositionCurrent, Wins: teamWins[t.TeamName], }) } totalRounds := len(races) return champHubResponse{ Season: year, Round: completed, TotalRounds: totalRounds, RoundsLeft: totalRounds - completed, LastRace: lastRace, RoundLabels: roundLabels, Drivers: drivers, Teams: teamsOut, } } // --- /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 }