Speed up championship hub loading

This commit is contained in:
2026-07-03 01:11:31 -04:00
parent c989aace10
commit 127628e4dd
6 changed files with 303 additions and 10 deletions

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/jpeg" href="/favicon.jpg" />
<title>box-box</title>
</head>
<body>

BIN
frontend/public/favicon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

View File

@@ -62,7 +62,9 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
}
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
const url = year ? `/api/v1/championship/hub?year=${year}` : '/api/v1/championship/hub'
const params = new URLSearchParams({ source: 'auto' })
if (year) params.set('year', year.toString())
const url = `/api/v1/championship/hub?${params.toString()}`
const res = await fetch(url)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)

View File

@@ -0,0 +1,177 @@
package query
import (
"database/sql"
"errors"
"sort"
"strings"
"github.com/AmanTahiliani/box-box/internal/models"
)
// ChampionshipRace is the local data needed by the web championship hub for one GP.
type ChampionshipRace struct {
Meeting models.Meeting
RaceSessionKey int
Results []models.SessionResult
Grid []models.StartingGrid
}
// ChampionshipInputs are local-first inputs for the championship hub aggregation.
type ChampionshipInputs struct {
Races []ChampionshipRace
Champ []models.ChampionshipDriver
Teams []models.ChampionshipTeam
DriverMap map[int]models.Driver
}
// GetChampionshipInputs derives championship hub inputs from ingested season data.
func (s *Service) GetChampionshipInputs(year int) (ChampionshipInputs, error) {
meetings, err := s.ListMeetingsByYear(year)
if err != nil {
return ChampionshipInputs{}, err
}
inputs := ChampionshipInputs{
Races: []ChampionshipRace{},
Champ: []models.ChampionshipDriver{},
Teams: []models.ChampionshipTeam{},
DriverMap: map[int]models.Driver{},
}
pointsByDriver := map[int]float64{}
pointsByTeam := map[string]float64{}
latestSessionKey := 0
latestMeetingKey := 0
for _, meeting := range meetings {
sessions, err := s.ListSessionsByMeeting(int(meeting.MeetingKey))
if err != nil {
return ChampionshipInputs{}, err
}
raceKey := 0
for _, sess := range sessions {
if strings.EqualFold(sess.SessionName, "Race") {
raceKey = sess.SessionKey
break
}
}
if raceKey == 0 {
continue
}
results, err := s.store.ListSessionResults(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
grid, err := s.store.ListStartingGrid(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
race := ChampionshipRace{
Meeting: meeting,
RaceSessionKey: raceKey,
Results: make([]models.SessionResult, 0, len(results)),
Grid: make([]models.StartingGrid, 0, len(grid)),
}
for _, result := range results {
race.Results = append(race.Results, resultToModel(result))
}
for _, entry := range grid {
race.Grid = append(race.Grid, gridToModel(entry))
}
inputs.Races = append(inputs.Races, race)
if len(results) == 0 {
continue
}
latestSessionKey = raceKey
latestMeetingKey = int(meeting.MeetingKey)
drivers, err := s.driversForSession(raceKey)
if err != nil {
return ChampionshipInputs{}, err
}
for _, d := range drivers {
inputs.DriverMap[d.DriverNumber] = d
}
for _, result := range results {
pointsByDriver[result.DriverNumber] += result.Points
team := inputs.DriverMap[result.DriverNumber].TeamName
if team != "" {
pointsByTeam[team] += result.Points
}
}
}
inputs.Champ = derivedDriverStandings(pointsByDriver, latestMeetingKey, latestSessionKey)
inputs.Teams = derivedTeamStandings(pointsByTeam, latestMeetingKey, latestSessionKey)
return inputs, nil
}
func (s *Service) driversForSession(sessionKey int) ([]models.Driver, error) {
sess, err := s.store.GetSession(sessionKey)
if err != nil {
return nil, err
}
links, err := s.store.ListSessionDrivers(sessionKey)
if err != nil {
return nil, err
}
drivers := make([]models.Driver, 0, len(links))
for _, link := range links {
d, err := s.store.GetDriver(link.DriverNumber)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
drivers = append(drivers, driverToModel(sessionKey, sess.MeetingKey, link, d))
}
return drivers, nil
}
func derivedDriverStandings(points map[int]float64, meetingKey, sessionKey int) []models.ChampionshipDriver {
drivers := make([]models.ChampionshipDriver, 0, len(points))
for num, pts := range points {
drivers = append(drivers, models.ChampionshipDriver{
DriverNumber: num,
MeetingKey: meetingKey,
PointsCurrent: pts,
SessionKey: sessionKey,
})
}
sort.Slice(drivers, func(i, j int) bool {
if drivers[i].PointsCurrent == drivers[j].PointsCurrent {
return drivers[i].DriverNumber < drivers[j].DriverNumber
}
return drivers[i].PointsCurrent > drivers[j].PointsCurrent
})
for i := range drivers {
drivers[i].PositionCurrent = i + 1
}
return drivers
}
func derivedTeamStandings(points map[string]float64, meetingKey, sessionKey int) []models.ChampionshipTeam {
teams := make([]models.ChampionshipTeam, 0, len(points))
for name, pts := range points {
teams = append(teams, models.ChampionshipTeam{
TeamName: name,
MeetingKey: meetingKey,
PointsCurrent: pts,
SessionKey: sessionKey,
})
}
sort.Slice(teams, func(i, j int) bool {
if teams[i].PointsCurrent == teams[j].PointsCurrent {
return teams[i].TeamName < teams[j].TeamName
}
return teams[i].PointsCurrent > teams[j].PointsCurrent
})
for i := range teams {
teams[i].PositionCurrent = i + 1
}
return teams
}

View File

@@ -166,6 +166,68 @@ func TestGetRaceHubCompleteData(t *testing.T) {
}
}
func TestGetChampionshipInputsDerivesLocalStandings(t *testing.T) {
svc := openTestService(t)
seedRaceHubData(t, svc.store)
if err := svc.store.UpsertDriver(store.Driver{
DriverNumber: 44,
FullName: "Lewis Hamilton",
NameAcronym: "HAM",
TeamName: "Ferrari",
TeamColour: "E8002D",
}); err != nil {
t.Fatalf("UpsertDriver() error = %v", err)
}
if err := svc.store.UpsertSessionDriver(store.SessionDriver{
SessionKey: 9472,
DriverNumber: 44,
MeetingKey: 1229,
TeamName: "Ferrari",
TeamColour: "E8002D",
}); err != nil {
t.Fatalf("UpsertSessionDriver() error = %v", err)
}
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9472,
DriverNumber: 1,
MeetingKey: 1229,
Position: 1,
Points: 25,
}); err != nil {
t.Fatalf("UpsertSessionResult() error = %v", err)
}
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9472,
DriverNumber: 44,
MeetingKey: 1229,
Position: 2,
Points: 18,
}); err != nil {
t.Fatalf("UpsertSessionResult() error = %v", err)
}
inputs, err := svc.GetChampionshipInputs(2025)
if err != nil {
t.Fatalf("GetChampionshipInputs() error = %v", err)
}
if len(inputs.Races) != 1 || inputs.Races[0].RaceSessionKey != 9472 {
t.Fatalf("Races = %+v, want Monaco race", inputs.Races)
}
if len(inputs.Champ) != 2 {
t.Fatalf("Champ len = %d, want 2", len(inputs.Champ))
}
if inputs.Champ[0].DriverNumber != 1 || inputs.Champ[0].PointsCurrent != 25 || inputs.Champ[0].PositionCurrent != 1 {
t.Fatalf("leader = %+v, want VER 25 P1", inputs.Champ[0])
}
if got := inputs.DriverMap[44]; got.NameAcronym != "HAM" || got.TeamName != "Ferrari" {
t.Fatalf("DriverMap[44] = %+v, want HAM Ferrari", got)
}
if len(inputs.Teams) != 2 || inputs.Teams[0].TeamName != "Red Bull Racing" || inputs.Teams[0].PointsCurrent != 25 {
t.Fatalf("Teams = %+v, want Red Bull Racing leading with 25", inputs.Teams)
}
}
func TestListMeetingsByYear(t *testing.T) {
svc := openTestService(t)
seedRaceHubData(t, svc.store)

View File

@@ -737,26 +737,77 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
if year == 0 {
year = time.Now().Year()
}
mode := parseSourceMode(r)
if resp, ok := s.hubCache.get(year, time.Now()); ok {
writeJSON(w, resp)
return
if mode != sourceLocal {
if resp, ok := s.hubCache.get(year, time.Now()); ok {
writeJSON(w, resp)
return
}
}
meetings, err := s.client.GetMeetingsForYear(year)
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 {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
return
return champHubResponse{}, err
}
if len(champ) == 0 {
writeJSON(w, champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}})
return
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, nil
}
teams, _ := s.client.GetTeamChampionshipForYear(year)
@@ -801,7 +852,7 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
ttl = champHubIncompleteTTL
}
s.hubCache.put(year, resp, time.Now(), ttl)
writeJSON(w, resp)
return resp, nil
}
// aggregateChampionshipHub is the pure aggregation core (no network) so it can be