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