mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
feat(#76): bound primary-route fetches and local-first driver summary
Add shared apiFetch timeouts/abort/typed errors with in-flight dedupe, a RouteState UI for loading/empty/timeout/error+retry, and wire it through Weekend (Data Health), Championship, Driver Profile, Briefing, Live, and Race Hub. Driver summary is local-first with bounded optional OpenF1 enrichment so a hung remote call cannot block the profile. Spike note: parseSourceMode defaults to openf1; driver summary now treats omitted ?source= as auto so local season data is preferred. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,9 +10,14 @@ import (
|
||||
)
|
||||
|
||||
// --- /api/v1/driver/summary ---
|
||||
// Per-driver season summary: championship standing plus per-round race results,
|
||||
// aggregated server-side from the same sources as the championship hub. Caching
|
||||
// relies on the OpenF1 client's HTTP cache TTLs — no extra layer here.
|
||||
// Per-driver season summary: championship standing plus per-round race results.
|
||||
// Current-season identity/results are local-first from the domain DB. Optional
|
||||
// OpenF1 enrichment (headshot / polished identity) is bounded so it cannot hang
|
||||
// the profile when remote data is slow or unavailable.
|
||||
|
||||
// driverEnrichmentTimeout bounds optional remote enrichment so a hung OpenF1
|
||||
// call never blocks a local-first profile response. Overridable in tests.
|
||||
var driverEnrichmentTimeout = 2 * time.Second
|
||||
|
||||
type driverSummaryRound struct {
|
||||
MeetingKey int `json:"meeting_key"`
|
||||
@@ -49,6 +54,11 @@ type driverSummaryResponse struct {
|
||||
Cumulative []float64 `json:"cumulative"`
|
||||
RoundLabels []string `json:"round_labels"`
|
||||
Rounds []driverSummaryRound `json:"rounds"`
|
||||
// Source is "local" when served from the domain DB, else "openf1".
|
||||
Source string `json:"source,omitempty"`
|
||||
// Enrichment is "full" when optional remote identity landed, "limited"
|
||||
// when it timed out/failed, or "none" when no enrichment was attempted.
|
||||
Enrichment string `json:"enrichment,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -61,12 +71,92 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||
if year == 0 {
|
||||
year = time.Now().Year()
|
||||
}
|
||||
mode := parseSourceMode(r)
|
||||
// Driver summary is local-first for current-season identity/results. When the
|
||||
// caller omits ?source=, prefer auto (local then OpenF1) rather than the
|
||||
// package default of openf1-only.
|
||||
if r.URL.Query().Get("source") == "" {
|
||||
mode = sourceAuto
|
||||
}
|
||||
|
||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
||||
if mode == sourceLocal || mode == sourceAuto {
|
||||
resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
|
||||
if lerr != nil {
|
||||
writeError(w, lerr, http.StatusInternalServerError, false)
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
if mode != sourceLocal {
|
||||
s.tryEnrichDriverSummary(&resp, sessionKey)
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
return
|
||||
}
|
||||
if mode == sourceLocal {
|
||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.openF1DriverSummary(year, driverNumber)
|
||||
if err != nil {
|
||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||
return
|
||||
}
|
||||
if resp == nil {
|
||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryResponse, int, bool, error) {
|
||||
if !s.hasLocalQuery() {
|
||||
return driverSummaryResponse{}, 0, false, nil
|
||||
}
|
||||
inputs, err := s.query.GetChampionshipInputs(year)
|
||||
if err != nil {
|
||||
return driverSummaryResponse{}, 0, false, err
|
||||
}
|
||||
if len(inputs.Champ) == 0 {
|
||||
return driverSummaryResponse{}, 0, 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,
|
||||
})
|
||||
}
|
||||
|
||||
resp, ok := aggregateDriverSummary(year, driverNumber, races, inputs.Champ, inputs.DriverMap)
|
||||
if !ok {
|
||||
return driverSummaryResponse{}, 0, false, nil
|
||||
}
|
||||
resp.Source = "local"
|
||||
resp.Enrichment = "none"
|
||||
|
||||
sessionKey := 0
|
||||
for _, c := range inputs.Champ {
|
||||
if c.DriverNumber == driverNumber && c.SessionKey > 0 {
|
||||
sessionKey = c.SessionKey
|
||||
break
|
||||
}
|
||||
if sessionKey == 0 && c.SessionKey > 0 {
|
||||
sessionKey = c.SessionKey
|
||||
}
|
||||
}
|
||||
return resp, sessionKey, true, nil
|
||||
}
|
||||
|
||||
func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResponse, error) {
|
||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entry *models.ChampionshipDriver
|
||||
for i := range champ {
|
||||
if champ[i].DriverNumber == driverNumber {
|
||||
@@ -75,12 +165,12 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if entry == nil {
|
||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||
return
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
driverInfo := map[int]models.Driver{}
|
||||
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
||||
sessionKey := champ[0].SessionKey
|
||||
if ds, derr := s.client.GetDriversForSession(sessionKey); derr == nil {
|
||||
driverInfo = buildDriverMapFirst(ds)
|
||||
}
|
||||
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
|
||||
@@ -89,16 +179,69 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
races, _, err := s.fetchSeasonRaces(year)
|
||||
if err != nil {
|
||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, ok := aggregateDriverSummary(year, driverNumber, races, champ, driverInfo)
|
||||
if !ok {
|
||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||
return nil, nil
|
||||
}
|
||||
resp.Source = "openf1"
|
||||
resp.Enrichment = "full"
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// tryEnrichDriverSummary optionally fills headshot / polished identity from
|
||||
// OpenF1. It never blocks longer than driverEnrichmentTimeout — on timeout or
|
||||
// failure the local profile remains intact with enrichment=limited.
|
||||
func (s *Server) tryEnrichDriverSummary(resp *driverSummaryResponse, sessionKey int) {
|
||||
if resp == nil || s.client == nil || sessionKey <= 0 {
|
||||
if resp != nil && resp.Enrichment == "none" {
|
||||
// No session to enrich from — leave as none (local identity only).
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
|
||||
type enrichResult struct {
|
||||
driver models.Driver
|
||||
ok bool
|
||||
}
|
||||
|
||||
done := make(chan enrichResult, 1)
|
||||
go func() {
|
||||
d, ok := s.championshipDriverInfo(sessionKey, resp.DriverNumber, nil)
|
||||
done <- enrichResult{driver: d, ok: ok}
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-done:
|
||||
if !result.ok {
|
||||
resp.Enrichment = "limited"
|
||||
return
|
||||
}
|
||||
applyDriverEnrichment(resp, result.driver)
|
||||
resp.Enrichment = "full"
|
||||
case <-time.After(driverEnrichmentTimeout):
|
||||
resp.Enrichment = "limited"
|
||||
}
|
||||
}
|
||||
|
||||
func applyDriverEnrichment(resp *driverSummaryResponse, d models.Driver) {
|
||||
if d.HeadshotURL != "" {
|
||||
resp.HeadshotURL = d.HeadshotURL
|
||||
}
|
||||
if d.FullName != "" {
|
||||
resp.FullName = d.FullName
|
||||
}
|
||||
if d.NameAcronym != "" {
|
||||
resp.NameAcronym = d.NameAcronym
|
||||
}
|
||||
if d.TeamName != "" {
|
||||
resp.TeamName = d.TeamName
|
||||
}
|
||||
if d.TeamColour != "" {
|
||||
resp.TeamColour = d.TeamColour
|
||||
}
|
||||
}
|
||||
|
||||
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/api"
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
|
||||
@@ -134,3 +138,179 @@ func TestHandleDriverSummaryBadRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedDriverSummaryStore(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
meetingKey := 1201
|
||||
sessionKey := 9901
|
||||
|
||||
if err := st.UpsertMeeting(store.Meeting{
|
||||
MeetingKey: meetingKey,
|
||||
MeetingName: "Bahrain GP",
|
||||
CountryCode: "BHR",
|
||||
CountryName: "Bahrain",
|
||||
Year: 2025,
|
||||
DateStart: "2025-03-02",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertMeeting: %v", err)
|
||||
}
|
||||
if err := st.UpsertSession(store.Session{
|
||||
SessionKey: sessionKey,
|
||||
MeetingKey: meetingKey,
|
||||
SessionName: "Race",
|
||||
SessionType: "Race",
|
||||
DateStart: "2025-03-02T15:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSession: %v", err)
|
||||
}
|
||||
if err := st.UpsertDriver(store.Driver{
|
||||
DriverNumber: 1,
|
||||
FullName: "Max Verstappen",
|
||||
NameAcronym: "VER",
|
||||
TeamName: "Red Bull",
|
||||
TeamColour: "3671c6",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertDriver: %v", err)
|
||||
}
|
||||
if err := st.UpsertSessionDriver(store.SessionDriver{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
FullName: "Max Verstappen",
|
||||
NameAcronym: "VER",
|
||||
TeamName: "Red Bull",
|
||||
TeamColour: "3671c6",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSessionDriver: %v", err)
|
||||
}
|
||||
if err := st.UpsertSessionResult(store.SessionResult{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
Position: 1,
|
||||
Points: 25,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSessionResult: %v", err)
|
||||
}
|
||||
if err := st.UpsertStartingGridEntry(store.StartingGridEntry{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
Position: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertStartingGridEntry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
|
||||
prev := driverEnrichmentTimeout
|
||||
driverEnrichmentTimeout = 40 * time.Millisecond
|
||||
t.Cleanup(func() { driverEnrichmentTimeout = prev })
|
||||
|
||||
st := openTestStore(t)
|
||||
seedDriverSummaryStore(t, st)
|
||||
|
||||
// Enrichment seam: OpenF1 hangs until released. Local summary must still return.
|
||||
release := make(chan struct{})
|
||||
hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
hang.Close()
|
||||
})
|
||||
|
||||
client := api.NewOpenF1Client(hang.URL, 15*time.Second)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
srv := NewServer(client, 8080, st)
|
||||
|
||||
start := time.Now()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleDriverSummary(rec, req)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("handler blocked on enrichment for %v", elapsed)
|
||||
}
|
||||
|
||||
var resp driverSummaryResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Source != "local" {
|
||||
t.Errorf("source = %q, want local", resp.Source)
|
||||
}
|
||||
if resp.Enrichment != "limited" {
|
||||
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
|
||||
}
|
||||
if resp.DriverNumber != 1 || resp.NameAcronym != "VER" || resp.Points != 25 {
|
||||
t.Errorf("local identity/results missing: %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) {
|
||||
prev := driverEnrichmentTimeout
|
||||
driverEnrichmentTimeout = 200 * time.Millisecond
|
||||
t.Cleanup(func() { driverEnrichmentTimeout = prev })
|
||||
|
||||
st := openTestStore(t)
|
||||
seedDriverSummaryStore(t, st)
|
||||
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(fail.Close)
|
||||
|
||||
client := api.NewOpenF1Client(fail.URL, 2*time.Second)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
srv := NewServer(client, 8080, st)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=auto", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleDriverSummary(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp driverSummaryResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Source != "local" {
|
||||
t.Errorf("source = %q, want local", resp.Source)
|
||||
}
|
||||
if resp.Enrichment != "limited" {
|
||||
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
|
||||
}
|
||||
if resp.FullName != "Max Verstappen" {
|
||||
t.Errorf("full_name = %q, want local identity", resp.FullName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDriverSummarySourceLocalOnly(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
seedDriverSummaryStore(t, st)
|
||||
|
||||
// Even with a broken OpenF1 client, source=local must succeed from the DB.
|
||||
fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(fail.Close)
|
||||
client := api.NewOpenF1Client(fail.URL, time.Second)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
srv := NewServer(client, 8080, st)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/driver/summary?year=2025&driver_number=1&source=local", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleDriverSummary(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user