mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Speed up championship hub loading
This commit is contained in:
@@ -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
BIN
frontend/public/favicon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
@@ -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}`)
|
||||
|
||||
177
internal/query/championship.go
Normal file
177
internal/query/championship.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user