Files
box-box/internal/web/api.go

1791 lines
49 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
readability "codeberg.org/readeck/go-readability/v2"
"github.com/AmanTahiliani/box-box/internal/api"
"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:
markLocalResponse(w, false)
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 {
markLocalResponse(w, false)
writeJSON(w, meetings)
return
}
}
}
client := s.client.Scoped()
meetings, err := client.GetMeetingsForYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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:
markLocalResponse(w, false)
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 {
markLocalResponse(w, false)
writeJSON(w, sessions)
return
}
}
}
client := s.client.Scoped()
sessions, err := client.GetSessionsForMeeting(meetingKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
writeJSON(w, sessions)
}
// --- /api/v1/news ---
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
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:
markLocalResponse(w, false)
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 {
markLocalResponse(w, false)
writeJSON(w, drivers)
return
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
writeError(w, err, http.StatusInternalServerError, false)
return
}
}
}
client := s.client.Scoped()
drivers, err := client.GetDriversForSession(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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:
markLocalResponse(w, false)
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 {
markLocalResponse(w, false)
writeJSON(w, enrichedResultsToAPI(results))
return
}
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
}
}
var (
results []models.SessionResult
drivers []models.Driver
resultsErr error
driversErr error
wg sync.WaitGroup
)
client := s.client.Scoped()
wg.Add(2)
go func() { defer wg.Done(); results, resultsErr = client.GetSessionResult(sessionKey) }()
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
wg.Wait()
if resultsErr != nil {
writeError(w, resultsErr, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil
enriched := make([]resultWithDriver, 0, len(results))
for _, res := range results {
e := resultWithDriver{SessionResult: res}
if d, ok := driverMap[res.DriverNumber]; ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym
e.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
} else {
incomplete = true
}
enriched = append(enriched, e)
}
resultsFreshness := "fresh"
if len(results) == 0 {
resultsFreshness = "limited"
} else if incomplete {
resultsFreshness = "partial"
}
markOpenF1Availability(w, client, resultsFreshness)
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:
markLocalResponse(w, false)
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 {
markLocalResponse(w, false)
writeJSON(w, enrichedGridToAPI(grid))
return
}
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
}
}
var (
grid []models.StartingGrid
drivers []models.Driver
gridErr error
driversErr error
wg sync.WaitGroup
)
client := s.client.Scoped()
wg.Add(2)
go func() { defer wg.Done(); grid, gridErr = client.GetStartingGrid(sessionKey) }()
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
wg.Wait()
if gridErr != nil {
writeError(w, gridErr, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil
enriched := make([]gridWithDriver, 0, len(grid))
for _, g := range grid {
e := gridWithDriver{StartingGrid: g}
if d, ok := driverMap[g.DriverNumber]; ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym
e.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
} else {
incomplete = true
}
enriched = append(enriched, e)
}
gridFreshness := "fresh"
if len(grid) == 0 {
gridFreshness = "limited"
} else if incomplete {
gridFreshness = "partial"
}
markOpenF1Availability(w, client, gridFreshness)
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
}
client := s.client.Scoped()
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 := client.GetLapsForDriver(sessionKey, driverNumber)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
writeJSON(w, laps)
return
}
laps, err := client.GetLapsForSession(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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
}
client := s.client.Scoped()
weather, err := client.GetWeather(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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
}
client := s.client.Scoped()
rc, err := client.GetRaceControl(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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
}
client := s.client.Scoped()
carData, err := client.GetCarData(sessionKey, driverNumber)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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
}
client := s.client.Scoped()
overtakes, err := client.GetOvertakesForSession(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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
}
client := s.client.Scoped()
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1Response(w, client)
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()
}
client := s.client.Scoped()
champ, err := client.GetDriverChampionshipForYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
if len(champ) == 0 {
markOpenF1Availability(w, client, "limited")
writeJSON(w, []any{})
return
}
drivers, driversErr := client.GetDriversForSession(champ[0].SessionKey)
driverMap := buildDriverMapFirst(drivers)
incomplete := driversErr != nil
enriched := make([]champDriverWithInfo, 0, len(champ))
for _, c := range champ {
e := champDriverWithInfo{ChampionshipDriver: c}
d, ok := championshipDriverInfo(client, c.SessionKey, c.DriverNumber, driverMap)
if ok && hasDriverPresentation(d) {
e.NameAcronym = d.NameAcronym
e.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
} else {
incomplete = true
}
enriched = append(enriched, e)
}
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, enriched)
}
func championshipDriverInfo(client *api.OpenF1Client, sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
if d, err := 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()
}
client := s.client.Scoped()
teams, err := client.GetTeamChampionshipForYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
if len(teams) == 0 {
markOpenF1Availability(w, client, "limited")
} else {
markOpenF1Response(w, client)
}
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
RoundPositions []int `json:"round_positions"` // finishing position per completed round (0 = no result)
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
}
// champHubWorkers bounds the concurrent per-meeting fetches for the hub.
const champHubWorkers = 5
// champHubCurrentTTL / champHubPastTTL control how long an aggregated hub
// response stays cached: short for the in-progress season, long for past
// seasons whose results are final.
const (
champHubCurrentTTL = 15 * time.Minute
champHubPastTTL = 24 * time.Hour
// champHubIncompleteTTL keeps a partially-fetched aggregate around just
// long enough to absorb page-load bursts while retrying soon after.
champHubIncompleteTTL = 2 * time.Minute
)
// champHubTTL returns the in-memory cache TTL for a season's hub response.
func champHubTTL(year int, now time.Time) time.Duration {
if year >= now.Year() {
return champHubCurrentTTL
}
return champHubPastTTL
}
type champHubEntry struct {
resp champHubResponse
source string
freshness string
expires time.Time
}
// champHubCache is an in-memory cache of aggregated hub responses keyed by
// year. The zero value is ready to use.
type champHubCache struct {
mu sync.Mutex
entries map[int]champHubEntry
}
func (c *champHubCache) get(year int, now time.Time) (champHubResponse, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[year]
if !ok || now.After(e.expires) {
return champHubResponse{}, false
}
return e.resp, true
}
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
c.putWithMetadata(year, resp, "local", "local", now, ttl)
}
func (c *champHubCache) getWithMetadata(year int, now time.Time) (champHubResponse, string, string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[year]
if !ok || now.After(e.expires) {
return champHubResponse{}, "", "", false
}
return e.resp, e.source, e.freshness, true
}
func (c *champHubCache) putWithMetadata(year int, resp champHubResponse, source, freshness string, now time.Time, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
c.entries = map[int]champHubEntry{}
}
c.entries[year] = champHubEntry{resp: resp, source: source, freshness: freshness, expires: now.Add(ttl)}
}
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
// The returned slice preserves the input meeting order regardless of
// completion order; meetings for which fetch reports ok=false are skipped.
func fetchMeetingRaces(meetings []models.Meeting, workers int, fetch func(models.Meeting) (meetingRace, bool)) []meetingRace {
if workers < 1 {
workers = 1
}
slots := make([]*meetingRace, len(meetings))
sem := make(chan struct{}, workers)
var wg sync.WaitGroup
for i, m := range meetings {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if mr, ok := fetch(m); ok {
slots[i] = &mr
}
}()
}
wg.Wait()
races := make([]meetingRace, 0, len(meetings))
for _, mr := range slots {
if mr != nil {
races = append(races, *mr)
}
}
return races
}
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()
}
mode := parseSourceMode(r)
if mode == sourceLocal || mode == sourceAuto {
resp, ok, err := s.localChampionshipHub(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
if ok {
s.hubCache.putWithMetadata(year, resp, "local", "local", time.Now(), champHubTTL(year, time.Now()))
markLocalResponse(w, false)
writeJSON(w, resp)
return
}
if mode == sourceLocal {
markDataResponse(w, "none", "limited")
writeJSON(w, resp)
return
}
}
// At this point an auto request has no usable domain aggregate and an
// explicit OpenF1 request must not be satisfied by a local cache entry.
if resp, source, freshness, ok := s.hubCache.getWithMetadata(year, time.Now()); ok && source == "openf1" {
markDataResponse(w, source, freshness)
writeJSON(w, resp)
return
}
client := s.client.Scoped()
resp, incomplete, err := s.openF1ChampionshipHub(client, year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
markOpenF1AggregateResponse(w, client, incomplete)
writeJSON(w, resp)
}
func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error) {
if !s.hasLocalQuery() {
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, false, nil
}
inputs, err := s.query.GetChampionshipInputs(year)
if err != nil {
return champHubResponse{}, false, err
}
if len(inputs.Champ) == 0 {
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, false, nil
}
races := make([]meetingRace, 0, len(inputs.Races))
for _, race := range inputs.Races {
races = append(races, meetingRace{
Meeting: race.Meeting,
RaceSessionKey: race.RaceSessionKey,
Results: race.Results,
Grid: race.Grid,
})
}
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
}
func (s *Server) openF1ChampionshipHub(client *api.OpenF1Client, year int) (champHubResponse, bool, error) {
champ, err := client.GetDriverChampionshipForYear(year)
if err != nil {
return champHubResponse{}, false, err
}
if len(champ) == 0 {
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, true, nil
}
teams, teamsErr := client.GetTeamChampionshipForYear(year)
driverInfo := map[int]models.Driver{}
driversIncomplete := false
if ds, derr := client.GetDriversForSession(champ[0].SessionKey); derr == nil {
driverInfo = buildDriverMapFirst(ds)
} else {
driversIncomplete = true
}
for _, standing := range champ {
if !hasDriverPresentation(driverInfo[standing.DriverNumber]) {
driversIncomplete = true
break
}
}
races, incomplete, err := fetchSeasonRaces(client, year)
if err != nil {
return champHubResponse{}, false, err
}
incomplete = incomplete || teamsErr != nil || driversIncomplete
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now())
if incomplete {
// Any per-meeting fetch failure (network, rate limit) yields an incomplete
// aggregate: serve it so the page still renders, but cache it only briefly
// so a partial view of the season doesn't stick around for the full TTL.
ttl = champHubIncompleteTTL
}
freshness := "fresh"
if client.LastResponseWasStale() {
freshness = "stale"
} else if incomplete {
freshness = "partial"
}
s.hubCache.putWithMetadata(year, resp, "openf1", freshness, time.Now(), ttl)
return resp, incomplete, nil
}
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
// with its race results and starting grid fetched from OpenF1. incomplete
// reports whether any per-meeting fetch failed, so callers can avoid caching a
// partial view of the season for long.
func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace, incomplete bool, err error) {
meetings, err := client.GetMeetingsForYear(year)
if err != nil {
return nil, false, err
}
sort.Slice(meetings, func(i, j int) bool { return meetings[i].DateStart < meetings[j].DateStart })
var failed atomic.Bool
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
sessions, serr := client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil {
failed.Store(true)
return meetingRace{}, false
}
raceKey := 0
for _, sess := range sessions {
if strings.EqualFold(sess.SessionName, "Race") {
raceKey = sess.SessionKey
break
}
}
if raceKey == 0 {
if isKnownNonChampionshipMeeting(m, sessions) {
return meetingRace{}, false
}
failed.Store(true)
// The meeting list does not identify non-championship events. Skipping
// a meeting without a Race may be expected (testing), but the aggregate
// is not proven complete and must be labelled partial.
return meetingRace{}, false
}
results, rerr := client.GetSessionResult(raceKey)
grid, gerr := client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil {
failed.Store(true)
}
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
})
return races, failed.Load(), nil
}
func isKnownNonChampionshipMeeting(meeting models.Meeting, sessions []models.Session) bool {
if hasTestingToken(meeting.MeetingName + " " + meeting.MeetingOfficialName) {
return true
}
for _, session := range sessions {
if hasTestingToken(session.SessionName + " " + session.SessionType) {
return true
}
}
return false
}
func hasTestingToken(value string) bool {
for _, token := range strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
}) {
if token == "test" || token == "tests" || token == "testing" {
return true
}
}
return false
}
// 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:]
}
roundPositions := make([]int, completed)
for round := 1; round <= completed; round++ {
roundPositions[round-1] = a.finishByRound[round]
}
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],
RoundPositions: roundPositions,
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 trackBounds struct {
MinX float64 `json:"minX"`
MaxX float64 `json:"maxX"`
MinY float64 `json:"minY"`
MaxY float64 `json:"maxY"`
}
type trackOutlineResponse struct {
CircuitKey int `json:"circuit_key"`
Points []trackPoint `json:"points"`
Bounds trackBounds `json:"bounds"`
}
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
markLocalResponse(w, false)
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
if year == 0 {
year = time.Now().Year()
}
circuitKey, err := strconv.Atoi(r.URL.Query().Get("circuit_key"))
if err != nil {
circuitKey = 0
}
if circuitKey == 0 {
circuitKey = s.resolveCircuitKey(year, r.URL.Query().Get("meeting_name"), r.URL.Query().Get("circuit_name"))
}
if circuitKey == 0 {
http.Error(w, "circuit_key or live meeting identity required", http.StatusBadRequest)
return
}
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,
Bounds: trackBounds{MinX: minX, MaxX: maxX, MinY: minY, MaxY: maxY},
})
}
func (s *Server) resolveCircuitKey(year int, meetingName, circuitName string) int {
if !s.hasLocalQuery() {
return 0
}
meetings, err := s.query.ListMeetingsByYear(year)
if err != nil {
return 0
}
wantMeeting := normalizeTrackIdentity(meetingName)
wantCircuit := normalizeTrackIdentity(circuitName)
bestScore := 0
bestCircuitKey := 0
for _, m := range meetings {
if m.CircuitKey == 0 {
continue
}
score := identityScore(wantMeeting, m.MeetingName, m.MeetingOfficialName, m.Location)
score += identityScore(wantCircuit, m.CircuitShortName, m.Location, m.MeetingName)
if score > bestScore {
bestScore = score
bestCircuitKey = m.CircuitKey
}
}
if bestScore == 0 {
return 0
}
return bestCircuitKey
}
func identityScore(want string, candidates ...string) int {
if want == "" {
return 0
}
best := 0
for _, candidate := range candidates {
got := normalizeTrackIdentity(candidate)
if got == "" {
continue
}
switch {
case got == want:
if best < 4 {
best = 4
}
case strings.Contains(got, want) || strings.Contains(want, got):
if best < 2 {
best = 2
}
}
}
return best
}
func normalizeTrackIdentity(s string) string {
s = strings.ToLower(s)
replacer := strings.NewReplacer(
"grand prix", "",
" gp", "",
"circuit", "",
"autodromo", "",
"autódromo", "",
"international", "",
"street", "",
" ", "",
"-", "",
"_", "",
".", "",
",", "",
"'", "",
"", "",
)
return strings.TrimSpace(replacer.Replace(s))
}
// --- /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
driversErr error
rcErr error
wg sync.WaitGroup
)
client := s.client.Scoped()
wg.Add(5)
go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
go func() { defer wg.Done(); results, resErr = client.GetSessionResult(sessionKey) }()
go func() { defer wg.Done(); drivers, driversErr = client.GetDriversForSession(sessionKey) }()
go func() { defer wg.Done(); rc, rcErr = 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, client.LastResponseWasStale())
return
}
// Empty strategy data may mean a non-race session or a race still settling.
if len(stints) == 0 {
// Without session-type evidence, an empty primary strategy dataset is
// not enough to prove "not applicable" (it may still be settling).
markOpenF1Availability(w, client, "limited")
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
return
}
driverMap := buildDriverMap(drivers)
incomplete := driversErr != nil || rcErr != nil
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]
if !hasDriverPresentation(d) {
incomplete = true
}
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
})
markOpenF1AggregateResponse(w, client, incomplete)
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
lapsErr error
stintsErr error
pitsErr error
rcErr error
wg sync.WaitGroup
)
client := s.client.Scoped()
wg.Add(4)
go func() { defer wg.Done(); allLaps, lapsErr = client.GetLapsForSession(sessionKey) }()
go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
go func() { defer wg.Done(); rc, rcErr = client.GetRaceControl(sessionKey) }()
wg.Wait()
if lapsErr != nil {
writeError(w, lapsErr, http.StatusInternalServerError, client.LastResponseWasStale())
return
}
allDrivers, driversErr := client.GetDriversForSession(sessionKey)
driverMap := buildDriverMap(allDrivers)
incomplete := stintsErr != nil || pitsErr != nil || rcErr != nil || driversErr != nil
// 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]
if !hasDriverPresentation(d) {
incomplete = true
}
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)
}
freshness := "fresh"
if len(allLaps) == 0 {
freshness = "limited"
} else if incomplete {
freshness = "partial"
}
markOpenF1Availability(w, client, freshness)
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
}