Files
box-box/internal/web/api.go
Aman Tahiliani 7263949260 feat(live): add web track map (#32)
Subscribe to SignalR Position.z and CarData.z, decode DEFLATE payloads, stream throttled positions over SSE, and render live car dots against cached track outline bounds with tap telemetry.

Alignment spike: no checked-in or locally cached real Position.z samples were available in this isolated worktree; verified both streams expose the official raw F1 X/Y/Z coordinate contract and implemented shared-bounds normalization against prefetched OpenF1 outlines. Fallback build-outline-from-stream was not taken.

Playwright remains out of scope for live rendering because BOXBOX_DISABLE_LIVE is used there; coverage is via parser, web handler/SSE, pure transform, and seeded component tests.
2026-07-03 19:34:09 -04:00

1608 lines
43 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/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
}
// 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
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.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
c.entries = map[int]champHubEntry{}
}
c.entries[year] = champHubEntry{resp: resp, 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 {
if resp, ok := s.hubCache.get(year, time.Now()); ok {
writeJSON(w, resp)
return
}
}
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.put(year, resp, time.Now(), champHubTTL(year, time.Now()))
writeJSON(w, resp)
return
}
if mode == sourceLocal {
writeJSON(w, resp)
return
}
}
resp, err := s.openF1ChampionshipHub(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
}
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(year int) (champHubResponse, error) {
meetings, err := s.client.GetMeetingsForYear(year)
if err != nil {
return champHubResponse{}, err
}
champ, err := s.client.GetDriverChampionshipForYear(year)
if err != nil {
return champHubResponse{}, err
}
if len(champ) == 0 {
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, nil
}
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 })
// 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.
var incomplete atomic.Bool
races := fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
if serr != nil {
incomplete.Store(true)
return meetingRace{}, false
}
raceKey := 0
for _, sess := range sessions {
if strings.EqualFold(sess.SessionName, "Race") {
raceKey = sess.SessionKey
break
}
}
if raceKey == 0 {
return meetingRace{}, false // not a GP meeting (e.g. pre-season testing)
}
results, rerr := s.client.GetSessionResult(raceKey)
grid, gerr := s.client.GetStartingGrid(raceKey)
if rerr != nil || gerr != nil {
incomplete.Store(true)
}
return meetingRace{Meeting: m, RaceSessionKey: raceKey, Results: results, Grid: grid}, true
})
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
ttl := champHubTTL(year, time.Now())
if incomplete.Load() {
ttl = champHubIncompleteTTL
}
s.hubCache.put(year, resp, time.Now(), ttl)
return resp, nil
}
// 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 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) {
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
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
}