diff --git a/frontend/index.html b/frontend/index.html index c2bd919..70677b4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@ + box-box diff --git a/frontend/public/favicon.jpg b/frontend/public/favicon.jpg new file mode 100644 index 0000000..ca6d66f Binary files /dev/null and b/frontend/public/favicon.jpg differ diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0975cfe..0b51ae7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -62,7 +62,9 @@ export async function fetchWeekend(meetingKey: number): Promise { } export async function fetchChampionshipHub(year?: number): Promise { - 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}`) diff --git a/internal/query/championship.go b/internal/query/championship.go new file mode 100644 index 0000000..556b11b --- /dev/null +++ b/internal/query/championship.go @@ -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 +} diff --git a/internal/query/query_test.go b/internal/query/query_test.go index 2858b52..ac32c6a 100644 --- a/internal/query/query_test.go +++ b/internal/query/query_test.go @@ -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) diff --git a/internal/web/api.go b/internal/web/api.go index 57cd5c1..c6facce 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -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