mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
feat(#22): Driver profile page
Implemented by claude via .agents/dev dispatch.
This commit is contained in:
@@ -797,11 +797,6 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -816,16 +811,39 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
||||
driverInfo = buildDriverMapFirst(ds)
|
||||
}
|
||||
|
||||
races, incomplete, err := s.fetchSeasonRaces(year)
|
||||
if err != nil {
|
||||
return champHubResponse{}, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
s.hubCache.put(year, resp, time.Now(), ttl)
|
||||
return resp, 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 (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
|
||||
meetings, err := s.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 })
|
||||
|
||||
// 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) {
|
||||
var failed 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)
|
||||
failed.Store(true)
|
||||
return meetingRace{}, false
|
||||
}
|
||||
raceKey := 0
|
||||
@@ -841,18 +859,11 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
||||
results, rerr := s.client.GetSessionResult(raceKey)
|
||||
grid, gerr := s.client.GetStartingGrid(raceKey)
|
||||
if rerr != nil || gerr != nil {
|
||||
incomplete.Store(true)
|
||||
failed.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
|
||||
return races, failed.Load(), nil
|
||||
}
|
||||
|
||||
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be
|
||||
|
||||
177
internal/web/driversummary.go
Normal file
177
internal/web/driversummary.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
// --- /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.
|
||||
|
||||
type driverSummaryRound struct {
|
||||
MeetingKey int `json:"meeting_key"`
|
||||
MeetingName string `json:"meeting_name"`
|
||||
CountryCode string `json:"country_code"`
|
||||
CountryName string `json:"country_name"`
|
||||
// RacePosition and GridPosition are 0 when the driver has no entry for the
|
||||
// round (did not enter, or data missing).
|
||||
RacePosition int `json:"race_position"`
|
||||
GridPosition int `json:"grid_position"`
|
||||
// QualiPosition is derived from the starting grid in v1 (full qualifying
|
||||
// session results are deferred); omitted when unknown.
|
||||
QualiPosition int `json:"quali_position,omitempty"`
|
||||
Points float64 `json:"points"`
|
||||
DNF bool `json:"dnf"`
|
||||
DNS bool `json:"dns"`
|
||||
DSQ bool `json:"dsq"`
|
||||
}
|
||||
|
||||
type driverSummaryResponse struct {
|
||||
Season int `json:"season"`
|
||||
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"`
|
||||
HeadshotURL string `json:"headshot_url"`
|
||||
Points float64 `json:"points"`
|
||||
Position int `json:"position"`
|
||||
Wins int `json:"wins"`
|
||||
Podiums int `json:"podiums"`
|
||||
Poles int `json:"poles"`
|
||||
Form []float64 `json:"form"`
|
||||
Cumulative []float64 `json:"cumulative"`
|
||||
RoundLabels []string `json:"round_labels"`
|
||||
Rounds []driverSummaryRound `json:"rounds"`
|
||||
}
|
||||
|
||||
func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
||||
driverNumber, err := strconv.Atoi(r.URL.Query().Get("driver_number"))
|
||||
if err != nil || driverNumber <= 0 {
|
||||
http.Error(w, "driver_number required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
var entry *models.ChampionshipDriver
|
||||
for i := range champ {
|
||||
if champ[i].DriverNumber == driverNumber {
|
||||
entry = &champ[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if entry == nil {
|
||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
driverInfo := map[int]models.Driver{}
|
||||
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
||||
driverInfo = buildDriverMapFirst(ds)
|
||||
}
|
||||
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
|
||||
driverInfo[driverNumber] = d
|
||||
}
|
||||
|
||||
races, _, err := s.fetchSeasonRaces(year)
|
||||
if err != nil {
|
||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
||||
// aggregateDriverSummary is the pure aggregation core (no network) so it can be
|
||||
// unit-tested with synthetic data. It reuses the championship-hub aggregation
|
||||
// for the derived season stats (wins, podiums, poles, form, cumulative) and
|
||||
// adds the per-round result rows. races must be ordered ascending by date and
|
||||
// contain only GP meetings. Returns ok=false when the driver is not in champ.
|
||||
func aggregateDriverSummary(
|
||||
year, driverNumber int,
|
||||
races []meetingRace,
|
||||
champ []models.ChampionshipDriver,
|
||||
driverInfo map[int]models.Driver,
|
||||
) (driverSummaryResponse, bool) {
|
||||
hub := aggregateChampionshipHub(year, races, champ, nil, driverInfo)
|
||||
|
||||
var hd *champHubDriver
|
||||
for i := range hub.Drivers {
|
||||
if hub.Drivers[i].DriverNumber == driverNumber {
|
||||
hd = &hub.Drivers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if hd == nil {
|
||||
return driverSummaryResponse{}, false
|
||||
}
|
||||
|
||||
rounds := make([]driverSummaryRound, 0, len(races))
|
||||
for _, mr := range races {
|
||||
if len(mr.Results) == 0 {
|
||||
continue // round not completed yet
|
||||
}
|
||||
round := driverSummaryRound{
|
||||
MeetingKey: int(mr.Meeting.MeetingKey),
|
||||
MeetingName: mr.Meeting.MeetingName,
|
||||
CountryCode: mr.Meeting.CountryCode,
|
||||
CountryName: mr.Meeting.CountryName,
|
||||
}
|
||||
for _, res := range mr.Results {
|
||||
if res.DriverNumber == driverNumber {
|
||||
round.RacePosition = res.Position
|
||||
round.Points = res.Points
|
||||
round.DNF = res.DNF
|
||||
round.DNS = res.DNS
|
||||
round.DSQ = res.DSQ
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, g := range mr.Grid {
|
||||
if g.DriverNumber == driverNumber {
|
||||
round.GridPosition = g.Position
|
||||
round.QualiPosition = g.Position
|
||||
break
|
||||
}
|
||||
}
|
||||
rounds = append(rounds, round)
|
||||
}
|
||||
|
||||
return driverSummaryResponse{
|
||||
Season: year,
|
||||
DriverNumber: hd.DriverNumber,
|
||||
NameAcronym: hd.NameAcronym,
|
||||
FullName: hd.FullName,
|
||||
TeamName: hd.TeamName,
|
||||
TeamColour: hd.TeamColour,
|
||||
HeadshotURL: driverInfo[driverNumber].HeadshotURL,
|
||||
Points: hd.Points,
|
||||
Position: hd.Position,
|
||||
Wins: hd.Wins,
|
||||
Podiums: hd.Podiums,
|
||||
Poles: hd.Poles,
|
||||
Form: hd.Form,
|
||||
Cumulative: hd.Cumulative,
|
||||
RoundLabels: hub.RoundLabels,
|
||||
Rounds: rounds,
|
||||
}, true
|
||||
}
|
||||
136
internal/web/driversummary_test.go
Normal file
136
internal/web/driversummary_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
func driverSummaryFixtures() ([]meetingRace, []models.ChampionshipDriver, map[int]models.Driver) {
|
||||
driverInfo := map[int]models.Driver{
|
||||
1: {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull", TeamColour: "3671c6", HeadshotURL: "https://example.com/ver.png"},
|
||||
3: {DriverNumber: 3, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes", TeamColour: "27f4d2"},
|
||||
}
|
||||
|
||||
champ := []models.ChampionshipDriver{
|
||||
{DriverNumber: 1, PointsCurrent: 50, PositionCurrent: 1, SessionKey: 99},
|
||||
{DriverNumber: 3, PointsCurrent: 33, PositionCurrent: 2, SessionKey: 99},
|
||||
}
|
||||
|
||||
// Round 1: VER P1 from pole, HAM P2 from P3.
|
||||
// Round 2: VER P1 from P2, HAM P3 from pole.
|
||||
// Round 3: not yet run — must not appear in rounds.
|
||||
races := []meetingRace{
|
||||
{
|
||||
Meeting: models.Meeting{MeetingKey: 1201, MeetingName: "Bahrain GP", CountryCode: "BHR", CountryName: "Bahrain"},
|
||||
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 2, 18)},
|
||||
Grid: []models.StartingGrid{{DriverNumber: 1, Position: 1}, {DriverNumber: 3, Position: 3}},
|
||||
},
|
||||
{
|
||||
Meeting: models.Meeting{MeetingKey: 1202, MeetingName: "Saudi GP", CountryCode: "SAU", CountryName: "Saudi Arabia"},
|
||||
Results: []models.SessionResult{raceResult(1, 1, 25), raceResult(3, 3, 15)},
|
||||
Grid: []models.StartingGrid{{DriverNumber: 3, Position: 1}, {DriverNumber: 1, Position: 2}},
|
||||
},
|
||||
{Meeting: models.Meeting{MeetingKey: 1203, MeetingName: "Australia GP"}},
|
||||
}
|
||||
return races, champ, driverInfo
|
||||
}
|
||||
|
||||
func TestAggregateDriverSummary(t *testing.T) {
|
||||
races, champ, driverInfo := driverSummaryFixtures()
|
||||
|
||||
resp, ok := aggregateDriverSummary(2025, 1, races, champ, driverInfo)
|
||||
if !ok {
|
||||
t.Fatal("aggregateDriverSummary ok = false, want true")
|
||||
}
|
||||
|
||||
if resp.Season != 2025 || resp.DriverNumber != 1 {
|
||||
t.Errorf("season/driver = %d/%d, want 2025/1", resp.Season, resp.DriverNumber)
|
||||
}
|
||||
if resp.NameAcronym != "VER" || resp.FullName != "Max Verstappen" || resp.TeamName != "Red Bull" || resp.TeamColour != "3671c6" {
|
||||
t.Errorf("driver identity = %s %s %s %s", resp.NameAcronym, resp.FullName, resp.TeamName, resp.TeamColour)
|
||||
}
|
||||
if resp.HeadshotURL != "https://example.com/ver.png" {
|
||||
t.Errorf("headshot = %q", resp.HeadshotURL)
|
||||
}
|
||||
if resp.Points != 50 || resp.Position != 1 {
|
||||
t.Errorf("points/position = %v/%d, want 50/1", resp.Points, resp.Position)
|
||||
}
|
||||
if resp.Wins != 2 || resp.Podiums != 2 || resp.Poles != 1 {
|
||||
t.Errorf("wins/podiums/poles = %d/%d/%d, want 2/2/1", resp.Wins, resp.Podiums, resp.Poles)
|
||||
}
|
||||
if len(resp.Form) != 2 || resp.Form[0] != 25 || resp.Form[1] != 25 {
|
||||
t.Errorf("form = %v, want [25 25]", resp.Form)
|
||||
}
|
||||
// Cumulative reconciles the last value to the official total.
|
||||
if len(resp.Cumulative) != 2 || resp.Cumulative[0] != 25 || resp.Cumulative[1] != 50 {
|
||||
t.Errorf("cumulative = %v, want [25 50]", resp.Cumulative)
|
||||
}
|
||||
if len(resp.RoundLabels) != 2 || resp.RoundLabels[0] != "R1" {
|
||||
t.Errorf("round labels = %v, want [R1 R2]", resp.RoundLabels)
|
||||
}
|
||||
|
||||
// Only completed rounds appear.
|
||||
if len(resp.Rounds) != 2 {
|
||||
t.Fatalf("rounds = %d, want 2", len(resp.Rounds))
|
||||
}
|
||||
r1 := resp.Rounds[0]
|
||||
if r1.MeetingKey != 1201 || r1.MeetingName != "Bahrain GP" || r1.CountryCode != "BHR" || r1.CountryName != "Bahrain" {
|
||||
t.Errorf("round 1 meeting = %+v", r1)
|
||||
}
|
||||
if r1.RacePosition != 1 || r1.GridPosition != 1 || r1.QualiPosition != 1 || r1.Points != 25 {
|
||||
t.Errorf("round 1 result = %+v, want P1 from P1 with 25 pts", r1)
|
||||
}
|
||||
r2 := resp.Rounds[1]
|
||||
if r2.RacePosition != 1 || r2.GridPosition != 2 || r2.Points != 25 {
|
||||
t.Errorf("round 2 result = %+v, want P1 from P2 with 25 pts", r2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateDriverSummaryDriverMissingFromRound(t *testing.T) {
|
||||
races, champ, driverInfo := driverSummaryFixtures()
|
||||
// Drop HAM from round 2's results and grid: the round still appears with
|
||||
// zero-valued positions so round indexing stays aligned with the season.
|
||||
races[1].Results = []models.SessionResult{raceResult(1, 1, 25)}
|
||||
races[1].Grid = []models.StartingGrid{{DriverNumber: 1, Position: 2}}
|
||||
|
||||
resp, ok := aggregateDriverSummary(2025, 3, races, champ, driverInfo)
|
||||
if !ok {
|
||||
t.Fatal("aggregateDriverSummary ok = false, want true")
|
||||
}
|
||||
if len(resp.Rounds) != 2 {
|
||||
t.Fatalf("rounds = %d, want 2", len(resp.Rounds))
|
||||
}
|
||||
r2 := resp.Rounds[1]
|
||||
if r2.RacePosition != 0 || r2.GridPosition != 0 || r2.QualiPosition != 0 || r2.Points != 0 {
|
||||
t.Errorf("round 2 for absent driver = %+v, want zero values", r2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateDriverSummaryUnknownDriver(t *testing.T) {
|
||||
races, champ, driverInfo := driverSummaryFixtures()
|
||||
if _, ok := aggregateDriverSummary(2025, 44, races, champ, driverInfo); ok {
|
||||
t.Error("unknown driver should return ok = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleDriverSummaryBadRequest(t *testing.T) {
|
||||
srv := testServer(t, nil)
|
||||
|
||||
// Missing, non-numeric, zero, and negative driver_number are all rejected.
|
||||
for _, url := range []string{
|
||||
"/api/v1/driver/summary?year=2025",
|
||||
"/api/v1/driver/summary?year=2025&driver_number=abc",
|
||||
"/api/v1/driver/summary?year=2025&driver_number=0",
|
||||
"/api/v1/driver/summary?year=2025&driver_number=-4",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleDriverSummary(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status = %d, want 400", url, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,9 @@ func (s *Server) routes() (http.Handler, error) {
|
||||
mux.HandleFunc("/api/v1/news", s.handleNews)
|
||||
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
|
||||
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
|
||||
// /api/v1/driver/summary before /api/v1/drivers: distinct prefixes today,
|
||||
// but keep the more specific driver/* paths registered first.
|
||||
mux.HandleFunc("/api/v1/driver/summary", s.handleDriverSummary)
|
||||
mux.HandleFunc("/api/v1/drivers", s.handleDrivers)
|
||||
mux.HandleFunc("/api/v1/results", s.handleResults)
|
||||
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
||||
|
||||
Reference in New Issue
Block a user