From 17c94d83ade055ca57612665785a1d31b7c0af52 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 10:03:57 -0400 Subject: [PATCH] Tighten race result ingestion ordering --- frontend/src/components/StrategyView.tsx | 6 +- frontend/src/utils.ts | 9 ++ internal/api/openf1.go | 1 + internal/ingest/ingest.go | 118 +++++++++++++++++------ internal/ingest/ingest_test.go | 66 ++++++++++++- internal/models/sort.go | 20 ++++ internal/models/sort_test.go | 21 ++++ internal/store/results.go | 2 +- internal/store/store_test.go | 50 ++++++++++ 9 files changed, 258 insertions(+), 35 deletions(-) create mode 100644 internal/models/sort.go create mode 100644 internal/models/sort_test.go diff --git a/frontend/src/components/StrategyView.tsx b/frontend/src/components/StrategyView.tsx index 4676634..3dee6a4 100644 --- a/frontend/src/components/StrategyView.tsx +++ b/frontend/src/components/StrategyView.tsx @@ -1,4 +1,5 @@ import type { EnrichedResult, Stint, PitStop } from '../types' +import { compareFinishPosition } from '../utils' const COMPOUND_COLORS: Record = { SOFT: '#e8002d', @@ -84,7 +85,10 @@ export function StrategyView({ results, stints, pit_stops, hasStints }: Props) { ) } - const sortedDrivers = [...results].sort((a, b) => a.position - b.position) + const sortedDrivers = [...results].sort((a, b) => { + const cmp = compareFinishPosition(a.position, b.position) + return cmp !== 0 ? cmp : a.driver_number - b.driver_number + }) const totalLaps = Math.max( ...stints.map((s) => s.lap_end), ...results.map((r) => r.number_of_laps), diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index d50c2f5..3ee3aaa 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -64,3 +64,12 @@ export function positionClass(pos: number): string { if (pos === 3) return 'pos-p3' return 'pos-n' } + +/** Classified finish order; position 0 (DNF/DNS) sorts last. */ +export function finishPositionOrder(pos: number): number { + return pos > 0 ? pos : 9999 +} + +export function compareFinishPosition(a: number, b: number): number { + return finishPositionOrder(a) - finishPositionOrder(b) +} diff --git a/internal/api/openf1.go b/internal/api/openf1.go index 21dbc53..3c4d700 100644 --- a/internal/api/openf1.go +++ b/internal/api/openf1.go @@ -358,6 +358,7 @@ func (c *OpenF1Client) GetSessionResult(sessionKey int) ([]models.SessionResult, if err := json.NewDecoder(body).Decode(&result); err != nil { return nil, err } + models.SortSessionResults(result) return result, nil } diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index c7fdca5..d2e7e1c 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -217,7 +217,7 @@ func (s *Service) IngestMeeting(meetingKey int) (Summary, error) { partialSessions := 0 for _, sess := range sessions { s.opts.Progress.Step("ingesting Race Hub datasets for session %d (%s)", sess.SessionKey, sess.SessionName) - sessSummary, err := s.ingestSessionDatasets(sess.SessionKey, meetingKey) + sessSummary, err := s.ingestSessionDatasets(sess) ss := SessionSummary{ SessionKey: sess.SessionKey, SessionName: sess.SessionName, @@ -332,7 +332,7 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) { summary.Sessions++ } - datasetSummary, err := s.ingestSessionDatasets(sessionKey, meetingKey) + datasetSummary, err := s.ingestSessionDatasets(sess) summary.mergeCounts(datasetSummary) summary.Errors = append(summary.Errors, datasetSummary.Errors...) if err != nil { @@ -345,7 +345,9 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) { return summary, nil } -func (s *Service) ingestSessionDatasets(sessionKey, meetingKey int) (Summary, error) { +func (s *Service) ingestSessionDatasets(sess models.Session) (Summary, error) { + sessionKey := sess.SessionKey + meetingKey := sess.MeetingKey summary := Summary{ ScopeType: "session", ScopeKey: fmt.Sprintf("%d", sessionKey), @@ -410,37 +412,13 @@ func (s *Service) ingestSessionDatasets(sessionKey, meetingKey int) (Summary, er } s.delay() - s.opts.Progress.Step("fetching starting grid for session %d", sessionKey) - gridFetch, grid, err := fetchWithRetry(s, func() (FetchResult, []models.StartingGrid, error) { - return s.source.FetchStartingGrid(sessionKey) - }) - if err != nil { - return summary, err - } - summary.RawPayloads++ - if !s.opts.DryRun { - inserted, err := s.storeRaw(gridFetch, &meetingKey, &sk) - if err != nil { - return summary, err - } - if inserted { - summary.RawInserted++ - } - for _, g := range grid { - if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil { - return summary, err - } - summary.StartingGrid++ - } - } else { - summary.StartingGrid = len(grid) - } - s.delay() - optionalIngests := []struct { name string run func(*Summary, int, int) error }{ + {name: "starting_grid", run: func(summary *Summary, meetingKey, sessionKey int) error { + return s.ingestStartingGrid(summary, sess) + }}, {name: "stints", run: s.ingestStints}, {name: "pit_stops", run: s.ingestPitStops}, {name: "positions", run: s.ingestPositions}, @@ -534,6 +512,86 @@ func (s *Service) delay() { } } +func (s *Service) ingestStartingGrid(summary *Summary, sess models.Session) error { + sessionKey := sess.SessionKey + meetingKey := sess.MeetingKey + sourceSessionKey, err := s.startingGridSourceSessionKey(sess) + if err != nil { + return err + } + + if sourceSessionKey == sessionKey { + s.opts.Progress.Step("fetching starting grid for session %d", sessionKey) + } else { + s.opts.Progress.Step("fetching starting grid for session %d from qualifying session %d", sessionKey, sourceSessionKey) + } + fetch, grid, err := fetchWithRetry(s, func() (FetchResult, []models.StartingGrid, error) { + return s.source.FetchStartingGrid(sourceSessionKey) + }) + if err != nil { + return err + } + if sourceSessionKey != sessionKey { + fetch.RequestKey = fmt.Sprintf("%s;target_session_key=%d", fetch.RequestKey, sessionKey) + } + + mk := meetingKey + sk := sessionKey + summary.RawPayloads++ + if s.opts.DryRun { + summary.StartingGrid = len(grid) + s.delay() + return nil + } + + inserted, err := s.storeRaw(fetch, &mk, &sk) + if err != nil { + return err + } + if inserted { + summary.RawInserted++ + } + for _, g := range grid { + g.SessionKey = sessionKey + g.MeetingKey = meetingKey + if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil { + return err + } + summary.StartingGrid++ + } + s.delay() + return nil +} + +func (s *Service) startingGridSourceSessionKey(sess models.Session) (int, error) { + if !isRaceSession(sess) { + return sess.SessionKey, nil + } + + _, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) { + return s.source.FetchSessionsForMeeting(sess.MeetingKey) + }) + if err != nil { + return 0, err + } + s.delay() + + for _, candidate := range sessions { + if isQualifyingSession(candidate) { + return candidate.SessionKey, nil + } + } + return 0, fmt.Errorf("no qualifying session found for meeting %d", sess.MeetingKey) +} + +func isRaceSession(sess models.Session) bool { + return strings.EqualFold(sess.SessionType, "Race") || strings.EqualFold(sess.SessionName, "Race") +} + +func isQualifyingSession(sess models.Session) bool { + return strings.EqualFold(sess.SessionType, "Qualifying") || strings.EqualFold(sess.SessionName, "Qualifying") +} + func (s *Service) ingestStints(summary *Summary, meetingKey, sessionKey int) error { s.opts.Progress.Step("fetching stints for session %d", sessionKey) fetch, stints, err := fetchWithRetry(s, func() (FetchResult, []models.Stint, error) { diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index a0f5354..490c8b2 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -199,6 +199,7 @@ func openTestStore(t *testing.T) *store.Store { func testSessionFixtures() (int, int, *fakeSource) { const meetingKey = 1229 const sessionKey = 9472 + const qualifyingKey = 9471 src := newFakeSource() src.meetingsByKey[meetingKey] = []models.Meeting{{ @@ -221,6 +222,16 @@ func testSessionFixtures() (int, int, *fakeSource) { SessionType: "Race", CircuitKey: 10, }} + src.sessionsByMeeting[meetingKey] = []models.Session{ + { + SessionKey: qualifyingKey, + MeetingKey: meetingKey, + SessionName: "Qualifying", + SessionType: "Qualifying", + CircuitKey: 10, + }, + src.sessionsByKey[sessionKey][0], + } src.drivers[sessionKey] = []models.Driver{ { DriverNumber: 1, @@ -243,9 +254,9 @@ func testSessionFixtures() (int, int, *fakeSource) { {SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, Points: 25, NumberOfLaps: 78}, {SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, Points: 18, NumberOfLaps: 78, GapToLeader: 1.5}, } - src.grid[sessionKey] = []models.StartingGrid{ - {SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, LapDuration: 71.234}, - {SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, LapDuration: 71.456}, + src.grid[qualifyingKey] = []models.StartingGrid{ + {SessionKey: qualifyingKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, LapDuration: 71.234}, + {SessionKey: qualifyingKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, LapDuration: 71.456}, } src.stints[sessionKey] = []models.Stint{ {SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, StintNumber: 1, Compound: models.CompoundMedium, LapStart: 1, LapEnd: 30}, @@ -363,6 +374,11 @@ func TestIngestSessionWritesDomainAndRawRows(t *testing.T) { if len(grid) != 2 { t.Fatalf("starting grid = %d, want 2", len(grid)) } + for _, g := range grid { + if g.SessionKey != sessionKey { + t.Fatalf("starting grid row session_key = %d, want race session %d", g.SessionKey, sessionKey) + } + } raw, err := st.ListRawPayloadsBySession(sessionKey) if err != nil { @@ -371,6 +387,18 @@ func TestIngestSessionWritesDomainAndRawRows(t *testing.T) { if len(raw) != 11 { t.Fatalf("raw payloads = %d, want 11", len(raw)) } + foundGridRaw := false + for _, p := range raw { + if p.Endpoint == "starting_grid" { + foundGridRaw = true + if p.RequestKey != "session_key=9471;target_session_key=9472" { + t.Fatalf("starting grid raw request key = %q, want qualifying source and race target", p.RequestKey) + } + } + } + if !foundGridRaw { + t.Fatal("raw payloads missing starting_grid") + } stints, err := st.ListStints(sessionKey) if err != nil || len(stints) != 2 { @@ -521,6 +549,38 @@ func TestOptionalAnalyticsErrorMakesSessionPartialAndContinues(t *testing.T) { } } +func TestStartingGridErrorMakesSessionPartialAndContinues(t *testing.T) { + _, sessionKey, src := testSessionFixtures() + src.failOn = "starting_grid" + st := openTestStore(t) + + opts := DefaultOptions() + opts.RequestDelay = 0 + svc := NewService(st, src, opts) + + summary, err := svc.IngestSession(sessionKey) + if err != nil { + t.Fatalf("IngestSession() error = %v, want nil partial result", err) + } + if summary.Status != "partial" { + t.Fatalf("summary.Status = %q, want partial", summary.Status) + } + if len(summary.Errors) != 1 { + t.Fatalf("summary.Errors = %v, want 1 starting_grid error", summary.Errors) + } + if summary.StartingGrid != 0 { + t.Fatalf("summary.StartingGrid = %d, want 0 for failed starting_grid", summary.StartingGrid) + } + if summary.Stints != 2 || summary.PitStops != 1 || summary.Positions != 3 || summary.RaceControl != 1 || summary.Weather != 1 || summary.Laps != 1 { + t.Fatalf("analytics counts after starting_grid failure = %+v, want remaining analytics preserved", summary) + } + + stints, err := st.ListStints(sessionKey) + if err != nil || len(stints) != 2 { + t.Fatalf("stints = %+v, err = %v, want 2 preserved", stints, err) + } +} + func TestLiveSessionLockoutSurfacesControlledFailure(t *testing.T) { _, sessionKey, src := testSessionFixtures() src.liveLockout = true diff --git a/internal/models/sort.go b/internal/models/sort.go new file mode 100644 index 0000000..6356e80 --- /dev/null +++ b/internal/models/sort.go @@ -0,0 +1,20 @@ +package models + +import "sort" + +// SortSessionResults orders results by classified position, with position 0 (DNF/DNS) last. +func SortSessionResults(results []SessionResult) { + sort.Slice(results, func(i, j int) bool { + pi, pj := results[i].Position, results[j].Position + if pi == 0 { + pi = 9999 + } + if pj == 0 { + pj = 9999 + } + if pi != pj { + return pi < pj + } + return results[i].DriverNumber < results[j].DriverNumber + }) +} diff --git a/internal/models/sort_test.go b/internal/models/sort_test.go new file mode 100644 index 0000000..2bc4a3c --- /dev/null +++ b/internal/models/sort_test.go @@ -0,0 +1,21 @@ +package models + +import "testing" + +func TestSortSessionResults(t *testing.T) { + results := []SessionResult{ + {DriverNumber: 55, Position: 0, DNF: true}, + {DriverNumber: 10, Position: 0, DNS: true}, + {DriverNumber: 1, Position: 1}, + {DriverNumber: 44, Position: 2}, + } + + SortSessionResults(results) + + want := []int{1, 44, 10, 55} + for i, dn := range want { + if results[i].DriverNumber != dn { + t.Fatalf("results[%d].DriverNumber = %d, want %d", i, results[i].DriverNumber, dn) + } + } +} diff --git a/internal/store/results.go b/internal/store/results.go index 5761eb0..5f81e95 100644 --- a/internal/store/results.go +++ b/internal/store/results.go @@ -183,7 +183,7 @@ func (s *Store) ListSessionResults(sessionKey int) ([]SessionResult, error) { number_of_laps, duration_json, gap_to_leader_json, dnf, dns, dsq FROM session_results WHERE session_key = ? - ORDER BY position ASC, driver_number ASC + ORDER BY CASE WHEN position > 0 THEN position ELSE 9999 END ASC, driver_number ASC `, sessionKey) if err != nil { return nil, err diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 423cd04..2b5d537 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -443,6 +443,56 @@ func TestSessionResultAndStartingGridUpsertRead(t *testing.T) { } } +func TestListSessionResultsSortsNonFinishersLast(t *testing.T) { + s := openTestStore(t) + + meetingKey := 1230 + sessionKey := 9473 + + if err := s.UpsertMeeting(Meeting{ + MeetingKey: meetingKey, + MeetingName: "Test GP", + Year: 2025, + }); err != nil { + t.Fatalf("UpsertMeeting() error = %v", err) + } + if err := s.UpsertSession(Session{ + SessionKey: sessionKey, + MeetingKey: meetingKey, + SessionName: "Race", + SessionType: "Race", + }); err != nil { + t.Fatalf("UpsertSession() error = %v", err) + } + + entries := []SessionResult{ + {SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Position: 0, DNF: true}, + {SessionKey: sessionKey, DriverNumber: 10, MeetingKey: meetingKey, Position: 0, DNS: true}, + {SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Position: 1, Points: 25}, + {SessionKey: sessionKey, DriverNumber: 44, MeetingKey: meetingKey, Position: 2, Points: 18}, + } + for _, r := range entries { + if err := s.UpsertSessionResult(r); err != nil { + t.Fatalf("UpsertSessionResult(%d) error = %v", r.DriverNumber, err) + } + } + + results, err := s.ListSessionResults(sessionKey) + if err != nil { + t.Fatalf("ListSessionResults() error = %v", err) + } + if len(results) != len(entries) { + t.Fatalf("ListSessionResults() len = %d, want %d", len(results), len(entries)) + } + + wantOrder := []int{1, 44, 10, 55} + for i, want := range wantOrder { + if results[i].DriverNumber != want { + t.Fatalf("results[%d].DriverNumber = %d, want %d", i, results[i].DriverNumber, want) + } + } +} + func TestAnalyticsUpsertRead(t *testing.T) { s := openTestStore(t)