mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Add analytics data foundation
This commit is contained in:
@@ -42,6 +42,12 @@ type Summary struct {
|
||||
Drivers int `json:"drivers"`
|
||||
SessionResults int `json:"session_results"`
|
||||
StartingGrid int `json:"starting_grid"`
|
||||
Stints int `json:"stints"`
|
||||
PitStops int `json:"pit_stops"`
|
||||
Positions int `json:"positions"`
|
||||
RaceControl int `json:"race_control"`
|
||||
Weather int `json:"weather"`
|
||||
Laps int `json:"laps"`
|
||||
RawPayloads int `json:"raw_payloads"`
|
||||
RawInserted int `json:"raw_inserted"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
@@ -363,6 +369,26 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
||||
} else {
|
||||
summary.StartingGrid = len(grid)
|
||||
}
|
||||
s.delay()
|
||||
|
||||
if err := s.ingestStints(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
}
|
||||
if err := s.ingestPitStops(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
}
|
||||
if err := s.ingestPositions(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
}
|
||||
if err := s.ingestRaceControl(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
}
|
||||
if err := s.ingestWeather(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
}
|
||||
if err := s.ingestLaps(&summary, meetingKey, sk); err != nil {
|
||||
return s.finishFailed(runID, summary, err)
|
||||
}
|
||||
|
||||
summary.Status = statusForDryRun(s.opts.DryRun)
|
||||
s.finishRun(runID, summary)
|
||||
@@ -416,6 +442,150 @@ func (s *Service) delay() {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return s.source.FetchStintsForSession(sessionKey)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
||||
for _, st := range stints {
|
||||
if err := s.store.UpsertStint(stintToStore(st)); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.Stints++
|
||||
}
|
||||
return nil
|
||||
}, func() { summary.Stints = len(stints) })
|
||||
}
|
||||
|
||||
func (s *Service) ingestPitStops(summary *Summary, meetingKey, sessionKey int) error {
|
||||
s.opts.Progress.Step("fetching pit stops for session %d", sessionKey)
|
||||
fetch, pits, err := fetchWithRetry(s, func() (FetchResult, []models.Pit, error) {
|
||||
return s.source.FetchPitStopsForSession(sessionKey)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
||||
for _, p := range pits {
|
||||
if err := s.store.UpsertPitStop(pitStopToStore(p)); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.PitStops++
|
||||
}
|
||||
return nil
|
||||
}, func() { summary.PitStops = len(pits) })
|
||||
}
|
||||
|
||||
func (s *Service) ingestPositions(summary *Summary, meetingKey, sessionKey int) error {
|
||||
s.opts.Progress.Step("fetching positions for session %d", sessionKey)
|
||||
fetch, positions, err := fetchWithRetry(s, func() (FetchResult, []models.Position, error) {
|
||||
return s.source.FetchPositionsForSession(sessionKey)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
||||
for _, p := range positions {
|
||||
if err := s.store.UpsertPositionSample(positionToStore(p)); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.Positions++
|
||||
}
|
||||
return nil
|
||||
}, func() { summary.Positions = len(positions) })
|
||||
}
|
||||
|
||||
func (s *Service) ingestRaceControl(summary *Summary, meetingKey, sessionKey int) error {
|
||||
s.opts.Progress.Step("fetching race control for session %d", sessionKey)
|
||||
fetch, messages, err := fetchWithRetry(s, func() (FetchResult, []models.RaceControl, error) {
|
||||
return s.source.FetchRaceControlForSession(sessionKey)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
||||
for _, rc := range messages {
|
||||
if err := s.store.UpsertRaceControlMessage(raceControlToStore(rc)); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.RaceControl++
|
||||
}
|
||||
return nil
|
||||
}, func() { summary.RaceControl = len(messages) })
|
||||
}
|
||||
|
||||
func (s *Service) ingestWeather(summary *Summary, meetingKey, sessionKey int) error {
|
||||
s.opts.Progress.Step("fetching weather for session %d", sessionKey)
|
||||
fetch, samples, err := fetchWithRetry(s, func() (FetchResult, []models.Weather, error) {
|
||||
return s.source.FetchWeatherForSession(sessionKey)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
||||
for _, w := range samples {
|
||||
if err := s.store.UpsertWeatherSample(weatherToStore(w)); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.Weather++
|
||||
}
|
||||
return nil
|
||||
}, func() { summary.Weather = len(samples) })
|
||||
}
|
||||
|
||||
func (s *Service) ingestLaps(summary *Summary, meetingKey, sessionKey int) error {
|
||||
s.opts.Progress.Step("fetching laps for session %d", sessionKey)
|
||||
fetch, laps, err := fetchWithRetry(s, func() (FetchResult, []models.Lap, error) {
|
||||
return s.source.FetchLapsForSession(sessionKey)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
|
||||
for _, l := range laps {
|
||||
if err := s.store.UpsertLap(lapToStore(l)); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.Laps++
|
||||
}
|
||||
return nil
|
||||
}, func() { summary.Laps = len(laps) })
|
||||
}
|
||||
|
||||
func (s *Service) storeAnalyticsFetch(
|
||||
summary *Summary,
|
||||
fetch FetchResult,
|
||||
meetingKey, sessionKey int,
|
||||
storeRows func() error,
|
||||
setDryRunCount func(),
|
||||
) error {
|
||||
mk := meetingKey
|
||||
sk := sessionKey
|
||||
summary.RawPayloads++
|
||||
if s.opts.DryRun {
|
||||
setDryRunCount()
|
||||
s.delay()
|
||||
return nil
|
||||
}
|
||||
|
||||
inserted, err := s.storeRaw(fetch, &mk, &sk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inserted {
|
||||
summary.RawInserted++
|
||||
}
|
||||
if err := storeRows(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.delay()
|
||||
return nil
|
||||
}
|
||||
|
||||
func statusForDryRun(dryRun bool) string {
|
||||
if dryRun {
|
||||
return "dry_run"
|
||||
|
||||
@@ -21,6 +21,12 @@ type fakeSource struct {
|
||||
drivers map[int][]models.Driver
|
||||
results map[int][]models.SessionResult
|
||||
grid map[int][]models.StartingGrid
|
||||
stints map[int][]models.Stint
|
||||
pitStops map[int][]models.Pit
|
||||
positions map[int][]models.Position
|
||||
raceControl map[int][]models.RaceControl
|
||||
weather map[int][]models.Weather
|
||||
laps map[int][]models.Lap
|
||||
failOn string
|
||||
liveLockout bool
|
||||
}
|
||||
@@ -34,6 +40,12 @@ func newFakeSource() *fakeSource {
|
||||
drivers: make(map[int][]models.Driver),
|
||||
results: make(map[int][]models.SessionResult),
|
||||
grid: make(map[int][]models.StartingGrid),
|
||||
stints: make(map[int][]models.Stint),
|
||||
pitStops: make(map[int][]models.Pit),
|
||||
positions: make(map[int][]models.Position),
|
||||
raceControl: make(map[int][]models.RaceControl),
|
||||
weather: make(map[int][]models.Weather),
|
||||
laps: make(map[int][]models.Lap),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +126,54 @@ func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.St
|
||||
return f.wrap("starting_grid", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error) {
|
||||
if err := f.maybeFail("stints"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.stints[sessionKey]
|
||||
return f.wrap("stints", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error) {
|
||||
if err := f.maybeFail("pit"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.pitStops[sessionKey]
|
||||
return f.wrap("pit", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error) {
|
||||
if err := f.maybeFail("position"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.positions[sessionKey]
|
||||
return f.wrap("position", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error) {
|
||||
if err := f.maybeFail("race_control"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.raceControl[sessionKey]
|
||||
return f.wrap("race_control", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error) {
|
||||
if err := f.maybeFail("weather"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.weather[sessionKey]
|
||||
return f.wrap("weather", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error) {
|
||||
if err := f.maybeFail("laps"); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
data := f.laps[sessionKey]
|
||||
return f.wrap("laps", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
|
||||
}
|
||||
|
||||
func openTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "ingest.db")
|
||||
@@ -176,10 +236,35 @@ func testSessionFixtures() (int, int, *fakeSource) {
|
||||
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, LapDuration: 71.234},
|
||||
{SessionKey: sessionKey, 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},
|
||||
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, StintNumber: 1, Compound: models.CompoundSoft, LapStart: 1, LapEnd: 18},
|
||||
}
|
||||
src.pitStops[sessionKey] = []models.Pit{
|
||||
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, LapNumber: 19, Date: "2025-05-25T14:00:00+00:00", StopDuration: 2.4},
|
||||
}
|
||||
src.positions[sessionKey] = []models.Position{
|
||||
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Date: "2025-05-25T13:05:00+00:00", Position: 1},
|
||||
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Date: "2025-05-25T13:10:00+00:00", Position: 1},
|
||||
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, Date: "2025-05-25T13:05:00+00:00", Position: 2},
|
||||
}
|
||||
src.raceControl[sessionKey] = []models.RaceControl{
|
||||
{SessionKey: sessionKey, MeetingKey: meetingKey, Date: "2025-05-25T13:01:00+00:00", Category: models.CategoryFlag, Flag: models.FlagGreen, Message: "Green light"},
|
||||
}
|
||||
src.weather[sessionKey] = []models.Weather{
|
||||
{SessionKey: sessionKey, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00+00:00", AirTemperature: 22.0, TrackTemperature: 34.0},
|
||||
}
|
||||
src.laps[sessionKey] = []models.Lap{
|
||||
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, LapNumber: 1, LapDuration: ptrFloat64(75.1)},
|
||||
}
|
||||
|
||||
return meetingKey, sessionKey, src
|
||||
}
|
||||
|
||||
func ptrFloat64(v float64) *float64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
|
||||
_, sessionKey, src := testSessionFixtures()
|
||||
st := openTestStore(t)
|
||||
@@ -198,8 +283,11 @@ func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
|
||||
if summary.Drivers != 2 || summary.SessionResults != 2 || summary.StartingGrid != 2 {
|
||||
t.Fatalf("summary counts = %+v, want 2 drivers/results/grid", summary)
|
||||
}
|
||||
if summary.RawPayloads != 5 {
|
||||
t.Fatalf("summary.RawPayloads = %d, want 5", summary.RawPayloads)
|
||||
if summary.Stints != 2 || summary.PitStops != 1 || summary.Positions != 3 {
|
||||
t.Fatalf("summary analytics counts = %+v, want stints=2 pits=1 positions=3", summary)
|
||||
}
|
||||
if summary.RawPayloads != 11 {
|
||||
t.Fatalf("summary.RawPayloads = %d, want 11", summary.RawPayloads)
|
||||
}
|
||||
|
||||
drivers, err := st.ListSessionDrivers(sessionKey)
|
||||
@@ -230,8 +318,17 @@ func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ListRawPayloadsBySession() error = %v", err)
|
||||
}
|
||||
if len(raw) != 5 {
|
||||
t.Fatalf("raw payloads = %d, want 5", len(raw))
|
||||
if len(raw) != 11 {
|
||||
t.Fatalf("raw payloads = %d, want 11", len(raw))
|
||||
}
|
||||
|
||||
stints, err := st.ListStints(sessionKey)
|
||||
if err != nil || len(stints) != 2 {
|
||||
t.Fatalf("stints = %+v, err = %v, want 2", stints, err)
|
||||
}
|
||||
positions, err := st.ListPositionSamples(sessionKey)
|
||||
if err != nil || len(positions) != 3 {
|
||||
t.Fatalf("positions = %+v, err = %v, want 3", positions, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ type Source interface {
|
||||
FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error)
|
||||
FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error)
|
||||
FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error)
|
||||
FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error)
|
||||
FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error)
|
||||
FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error)
|
||||
FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error)
|
||||
FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error)
|
||||
FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error)
|
||||
}
|
||||
|
||||
// OpenF1Source adapts OpenF1Client for ingestion using strict fetches.
|
||||
@@ -77,6 +83,36 @@ func (s *OpenF1Source) FetchStartingGrid(sessionKey int) (FetchResult, []models.
|
||||
return s.fetchStartingGrid(url, "starting_grid", fmt.Sprintf("session_key=%d", sessionKey))
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error) {
|
||||
url := fmt.Sprintf("%s/v1/stints?session_key=%d", s.client.BaseURL(), sessionKey)
|
||||
return s.fetchStints(url, "stints", fmt.Sprintf("session_key=%d", sessionKey))
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error) {
|
||||
url := fmt.Sprintf("%s/v1/pit?session_key=%d", s.client.BaseURL(), sessionKey)
|
||||
return s.fetchPitStops(url, "pit", fmt.Sprintf("session_key=%d", sessionKey))
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error) {
|
||||
url := fmt.Sprintf("%s/v1/position?session_key=%d", s.client.BaseURL(), sessionKey)
|
||||
return s.fetchPositions(url, "position", fmt.Sprintf("session_key=%d", sessionKey))
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error) {
|
||||
url := fmt.Sprintf("%s/v1/race_control?session_key=%d", s.client.BaseURL(), sessionKey)
|
||||
return s.fetchRaceControl(url, "race_control", fmt.Sprintf("session_key=%d", sessionKey))
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error) {
|
||||
url := fmt.Sprintf("%s/v1/weather?session_key=%d", s.client.BaseURL(), sessionKey)
|
||||
return s.fetchWeather(url, "weather", fmt.Sprintf("session_key=%d", sessionKey))
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error) {
|
||||
url := fmt.Sprintf("%s/v1/laps?session_key=%d", s.client.BaseURL(), sessionKey)
|
||||
return s.fetchLaps(url, "laps", fmt.Sprintf("session_key=%d", sessionKey))
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) fetchMeetings(url, endpoint, requestKey string) (FetchResult, []models.Meeting, error) {
|
||||
body, err := s.client.FetchStrict(url)
|
||||
if err != nil {
|
||||
@@ -167,6 +203,114 @@ func (s *OpenF1Source) fetchStartingGrid(url, endpoint, requestKey string) (Fetc
|
||||
}, result, nil
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) fetchStints(url, endpoint, requestKey string) (FetchResult, []models.Stint, error) {
|
||||
body, err := s.client.FetchStrict(url)
|
||||
if err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
var result []models.Stint
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
return FetchResult{
|
||||
Endpoint: endpoint,
|
||||
RequestKey: requestKey,
|
||||
URL: url,
|
||||
Body: body,
|
||||
FetchedAt: time.Now(),
|
||||
}, result, nil
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) fetchPitStops(url, endpoint, requestKey string) (FetchResult, []models.Pit, error) {
|
||||
body, err := s.client.FetchStrict(url)
|
||||
if err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
var result []models.Pit
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
return FetchResult{
|
||||
Endpoint: endpoint,
|
||||
RequestKey: requestKey,
|
||||
URL: url,
|
||||
Body: body,
|
||||
FetchedAt: time.Now(),
|
||||
}, result, nil
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) fetchPositions(url, endpoint, requestKey string) (FetchResult, []models.Position, error) {
|
||||
body, err := s.client.FetchStrict(url)
|
||||
if err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
var result []models.Position
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
return FetchResult{
|
||||
Endpoint: endpoint,
|
||||
RequestKey: requestKey,
|
||||
URL: url,
|
||||
Body: body,
|
||||
FetchedAt: time.Now(),
|
||||
}, result, nil
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) fetchRaceControl(url, endpoint, requestKey string) (FetchResult, []models.RaceControl, error) {
|
||||
body, err := s.client.FetchStrict(url)
|
||||
if err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
var result []models.RaceControl
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
return FetchResult{
|
||||
Endpoint: endpoint,
|
||||
RequestKey: requestKey,
|
||||
URL: url,
|
||||
Body: body,
|
||||
FetchedAt: time.Now(),
|
||||
}, result, nil
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) fetchWeather(url, endpoint, requestKey string) (FetchResult, []models.Weather, error) {
|
||||
body, err := s.client.FetchStrict(url)
|
||||
if err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
var result []models.Weather
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
return FetchResult{
|
||||
Endpoint: endpoint,
|
||||
RequestKey: requestKey,
|
||||
URL: url,
|
||||
Body: body,
|
||||
FetchedAt: time.Now(),
|
||||
}, result, nil
|
||||
}
|
||||
|
||||
func (s *OpenF1Source) fetchLaps(url, endpoint, requestKey string) (FetchResult, []models.Lap, error) {
|
||||
body, err := s.client.FetchStrict(url)
|
||||
if err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
var result []models.Lap
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return FetchResult{}, nil, err
|
||||
}
|
||||
return FetchResult{
|
||||
Endpoint: endpoint,
|
||||
RequestKey: requestKey,
|
||||
URL: url,
|
||||
Body: body,
|
||||
FetchedAt: time.Now(),
|
||||
}, result, nil
|
||||
}
|
||||
|
||||
func meetingToStore(m models.Meeting) store.Meeting {
|
||||
return store.Meeting{
|
||||
MeetingKey: int(m.MeetingKey),
|
||||
@@ -247,6 +391,101 @@ func startingGridToStore(g models.StartingGrid) store.StartingGridEntry {
|
||||
}
|
||||
}
|
||||
|
||||
func stintToStore(st models.Stint) store.Stint {
|
||||
return store.Stint{
|
||||
SessionKey: st.SessionKey,
|
||||
DriverNumber: st.DriverNumber,
|
||||
MeetingKey: st.MeetingKey,
|
||||
StintNumber: st.StintNumber,
|
||||
Compound: string(st.Compound),
|
||||
LapStart: st.LapStart,
|
||||
LapEnd: st.LapEnd,
|
||||
TyreAgeAtStart: st.TyreAgeAtStart,
|
||||
}
|
||||
}
|
||||
|
||||
func pitStopToStore(p models.Pit) store.PitStop {
|
||||
stopDuration := p.StopDuration
|
||||
if stopDuration == 0 {
|
||||
stopDuration = p.PitDuration
|
||||
}
|
||||
return store.PitStop{
|
||||
SessionKey: p.SessionKey,
|
||||
DriverNumber: p.DriverNumber,
|
||||
MeetingKey: p.MeetingKey,
|
||||
LapNumber: p.LapNumber,
|
||||
Date: p.Date,
|
||||
PitDuration: p.PitDuration,
|
||||
LaneDuration: p.LaneDuration,
|
||||
StopDuration: stopDuration,
|
||||
}
|
||||
}
|
||||
|
||||
func positionToStore(p models.Position) store.PositionSample {
|
||||
return store.PositionSample{
|
||||
SessionKey: p.SessionKey,
|
||||
DriverNumber: p.DriverNumber,
|
||||
MeetingKey: p.MeetingKey,
|
||||
Date: p.Date,
|
||||
Position: p.Position,
|
||||
}
|
||||
}
|
||||
|
||||
func raceControlToStore(rc models.RaceControl) store.RaceControlMessage {
|
||||
return store.RaceControlMessage{
|
||||
SessionKey: rc.SessionKey,
|
||||
MeetingKey: rc.MeetingKey,
|
||||
Date: rc.Date,
|
||||
Category: string(rc.Category),
|
||||
Flag: string(rc.Flag),
|
||||
Message: rc.Message,
|
||||
Scope: rc.Scope,
|
||||
DriverNumber: rc.DriverNumber,
|
||||
LapNumber: rc.LapNumber,
|
||||
Sector: rc.Sector,
|
||||
QualifyingPhase: rc.QualifyingPhase,
|
||||
}
|
||||
}
|
||||
|
||||
func weatherToStore(w models.Weather) store.WeatherSample {
|
||||
return store.WeatherSample{
|
||||
SessionKey: w.SessionKey,
|
||||
MeetingKey: w.MeetingKey,
|
||||
Date: w.Date,
|
||||
AirTemperature: w.AirTemperature,
|
||||
TrackTemperature: w.TrackTemperature,
|
||||
Humidity: w.Humidity,
|
||||
Pressure: w.Pressure,
|
||||
Rainfall: w.Rainfall,
|
||||
WindDirection: w.WindDirection,
|
||||
WindSpeed: w.WindSpeed,
|
||||
}
|
||||
}
|
||||
|
||||
func lapToStore(l models.Lap) store.Lap {
|
||||
lap := store.Lap{
|
||||
SessionKey: l.SessionKey,
|
||||
DriverNumber: l.DriverNumber,
|
||||
MeetingKey: l.MeetingKey,
|
||||
LapNumber: l.LapNumber,
|
||||
DateStart: l.DateStart,
|
||||
IsPitOutLap: l.IsPitOutLap,
|
||||
}
|
||||
if l.LapDuration != nil {
|
||||
lap.LapDuration = *l.LapDuration
|
||||
}
|
||||
if l.DurationSector1 != nil {
|
||||
lap.DurationSector1 = *l.DurationSector1
|
||||
}
|
||||
if l.DurationSector2 != nil {
|
||||
lap.DurationSector2 = *l.DurationSector2
|
||||
}
|
||||
if l.DurationSector3 != nil {
|
||||
lap.DurationSector3 = *l.DurationSector3
|
||||
}
|
||||
return lap
|
||||
}
|
||||
|
||||
func jsonField(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
|
||||
@@ -99,3 +99,102 @@ func parseJSONValue(raw string) interface{} {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func stintToModel(st store.Stint) models.Stint {
|
||||
return models.Stint{
|
||||
SessionKey: st.SessionKey,
|
||||
DriverNumber: st.DriverNumber,
|
||||
MeetingKey: st.MeetingKey,
|
||||
StintNumber: st.StintNumber,
|
||||
Compound: models.TyreCompound(st.Compound),
|
||||
LapStart: st.LapStart,
|
||||
LapEnd: st.LapEnd,
|
||||
TyreAgeAtStart: st.TyreAgeAtStart,
|
||||
}
|
||||
}
|
||||
|
||||
func pitStopToModel(p store.PitStop) models.Pit {
|
||||
stopDuration := p.StopDuration
|
||||
if stopDuration == 0 {
|
||||
stopDuration = p.PitDuration
|
||||
}
|
||||
return models.Pit{
|
||||
SessionKey: p.SessionKey,
|
||||
DriverNumber: p.DriverNumber,
|
||||
MeetingKey: p.MeetingKey,
|
||||
LapNumber: p.LapNumber,
|
||||
Date: p.Date,
|
||||
PitDuration: p.PitDuration,
|
||||
LaneDuration: p.LaneDuration,
|
||||
StopDuration: stopDuration,
|
||||
}
|
||||
}
|
||||
|
||||
func positionToModel(p store.PositionSample) models.Position {
|
||||
return models.Position{
|
||||
SessionKey: p.SessionKey,
|
||||
DriverNumber: p.DriverNumber,
|
||||
MeetingKey: p.MeetingKey,
|
||||
Date: p.Date,
|
||||
Position: p.Position,
|
||||
}
|
||||
}
|
||||
|
||||
func raceControlToModel(rc store.RaceControlMessage) models.RaceControl {
|
||||
return models.RaceControl{
|
||||
SessionKey: rc.SessionKey,
|
||||
MeetingKey: rc.MeetingKey,
|
||||
Date: rc.Date,
|
||||
Category: models.RaceControlCategory(rc.Category),
|
||||
Flag: models.Flag(rc.Flag),
|
||||
Message: rc.Message,
|
||||
Scope: rc.Scope,
|
||||
DriverNumber: rc.DriverNumber,
|
||||
LapNumber: rc.LapNumber,
|
||||
Sector: rc.Sector,
|
||||
QualifyingPhase: rc.QualifyingPhase,
|
||||
}
|
||||
}
|
||||
|
||||
func weatherToModel(w store.WeatherSample) models.Weather {
|
||||
return models.Weather{
|
||||
SessionKey: w.SessionKey,
|
||||
MeetingKey: w.MeetingKey,
|
||||
Date: w.Date,
|
||||
AirTemperature: w.AirTemperature,
|
||||
TrackTemperature: w.TrackTemperature,
|
||||
Humidity: w.Humidity,
|
||||
Pressure: w.Pressure,
|
||||
Rainfall: w.Rainfall,
|
||||
WindDirection: w.WindDirection,
|
||||
WindSpeed: w.WindSpeed,
|
||||
}
|
||||
}
|
||||
|
||||
func lapToModel(l store.Lap) models.Lap {
|
||||
lap := models.Lap{
|
||||
SessionKey: l.SessionKey,
|
||||
DriverNumber: l.DriverNumber,
|
||||
MeetingKey: l.MeetingKey,
|
||||
LapNumber: l.LapNumber,
|
||||
DateStart: l.DateStart,
|
||||
IsPitOutLap: l.IsPitOutLap,
|
||||
}
|
||||
if l.LapDuration != 0 {
|
||||
d := l.LapDuration
|
||||
lap.LapDuration = &d
|
||||
}
|
||||
if l.DurationSector1 != 0 {
|
||||
d := l.DurationSector1
|
||||
lap.DurationSector1 = &d
|
||||
}
|
||||
if l.DurationSector2 != 0 {
|
||||
d := l.DurationSector2
|
||||
lap.DurationSector2 = &d
|
||||
}
|
||||
if l.DurationSector3 != 0 {
|
||||
d := l.DurationSector3
|
||||
lap.DurationSector3 = &d
|
||||
}
|
||||
return lap
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
@@ -150,8 +151,8 @@ func TestGetRaceHubCompleteData(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetRaceHub() error = %v", err)
|
||||
}
|
||||
if hub.Source != ResponseSourceLocal {
|
||||
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourceLocal)
|
||||
if hub.Source != ResponseSourcePartial {
|
||||
t.Fatalf("Source = %q, want %q (core datasets complete, analytics missing)", hub.Source, ResponseSourcePartial)
|
||||
}
|
||||
if len(hub.Results) != 1 {
|
||||
t.Fatalf("Results len = %d, want 1", len(hub.Results))
|
||||
@@ -185,6 +186,47 @@ func TestListMeetingsByYear(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRaceHubAnalyticsDatasets(t *testing.T) {
|
||||
svc := openTestService(t)
|
||||
seedRaceHubData(t, svc.store)
|
||||
|
||||
sessionKey := 9472
|
||||
meetingKey := 1229
|
||||
|
||||
if err := svc.store.UpsertStint(store.Stint{
|
||||
SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey,
|
||||
StintNumber: 1, Compound: "MEDIUM", LapStart: 1, LapEnd: 30,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertStint() error = %v", err)
|
||||
}
|
||||
if err := svc.store.UpsertPositionSample(store.PositionSample{
|
||||
SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey,
|
||||
Date: "2025-05-25T13:05:00+00:00", Position: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertPositionSample() error = %v", err)
|
||||
}
|
||||
|
||||
hub, err := svc.GetRaceHub(sessionKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRaceHub() error = %v", err)
|
||||
}
|
||||
if hub.Datasets["stints"].Status != DatasetStatusAvailable {
|
||||
t.Fatalf("stints status = %+v, want available", hub.Datasets["stints"])
|
||||
}
|
||||
if hub.Datasets["positions"].Status != DatasetStatusAvailable {
|
||||
t.Fatalf("positions status = %+v, want available", hub.Datasets["positions"])
|
||||
}
|
||||
if len(hub.Stints) != 1 || hub.Stints[0].Compound != models.CompoundMedium {
|
||||
t.Fatalf("Stints = %+v, want one MEDIUM stint", hub.Stints)
|
||||
}
|
||||
if len(hub.Positions) != 1 {
|
||||
t.Fatalf("Positions len = %d, want 1", len(hub.Positions))
|
||||
}
|
||||
if hub.Datasets["pit_stops"].Status != DatasetStatusMissing {
|
||||
t.Fatalf("pit_stops status = %+v, want missing", hub.Datasets["pit_stops"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDriversRequiresSession(t *testing.T) {
|
||||
svc := openTestService(t)
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ type RaceHub struct {
|
||||
Drivers []models.Driver `json:"drivers"`
|
||||
Results []EnrichedResult `json:"results"`
|
||||
StartingGrid []EnrichedGrid `json:"starting_grid"`
|
||||
Stints []models.Stint `json:"stints"`
|
||||
PitStops []models.Pit `json:"pit_stops"`
|
||||
Positions []models.Position `json:"positions"`
|
||||
RaceControl []models.RaceControl `json:"race_control"`
|
||||
Weather []models.Weather `json:"weather"`
|
||||
Laps []models.Lap `json:"laps"`
|
||||
}
|
||||
|
||||
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
||||
@@ -58,10 +64,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
"drivers": missingDataset(),
|
||||
"results": missingDataset(),
|
||||
"starting_grid": missingDataset(),
|
||||
"stints": missingDataset(),
|
||||
"pit_stops": missingDataset(),
|
||||
"positions": missingDataset(),
|
||||
"race_control": missingDataset(),
|
||||
"weather": missingDataset(),
|
||||
"laps": missingDataset(),
|
||||
},
|
||||
Drivers: []models.Driver{},
|
||||
Results: []EnrichedResult{},
|
||||
StartingGrid: []EnrichedGrid{},
|
||||
Stints: []models.Stint{},
|
||||
PitStops: []models.Pit{},
|
||||
Positions: []models.Position{},
|
||||
RaceControl: []models.RaceControl{},
|
||||
Weather: []models.Weather{},
|
||||
Laps: []models.Lap{},
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(sessionKey)
|
||||
@@ -150,6 +168,78 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
hub.Datasets["starting_grid"] = availableLocal(len(enriched))
|
||||
}
|
||||
|
||||
stints, err := s.store.ListStints(sessionKey)
|
||||
if err != nil {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
if len(stints) > 0 {
|
||||
hub.Stints = make([]models.Stint, 0, len(stints))
|
||||
for _, st := range stints {
|
||||
hub.Stints = append(hub.Stints, stintToModel(st))
|
||||
}
|
||||
hub.Datasets["stints"] = availableLocal(len(hub.Stints))
|
||||
}
|
||||
|
||||
pitStops, err := s.store.ListPitStops(sessionKey)
|
||||
if err != nil {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
if len(pitStops) > 0 {
|
||||
hub.PitStops = make([]models.Pit, 0, len(pitStops))
|
||||
for _, p := range pitStops {
|
||||
hub.PitStops = append(hub.PitStops, pitStopToModel(p))
|
||||
}
|
||||
hub.Datasets["pit_stops"] = availableLocal(len(hub.PitStops))
|
||||
}
|
||||
|
||||
positions, err := s.store.ListPositionSamples(sessionKey)
|
||||
if err != nil {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
if len(positions) > 0 {
|
||||
hub.Positions = make([]models.Position, 0, len(positions))
|
||||
for _, p := range positions {
|
||||
hub.Positions = append(hub.Positions, positionToModel(p))
|
||||
}
|
||||
hub.Datasets["positions"] = availableLocal(len(hub.Positions))
|
||||
}
|
||||
|
||||
raceControl, err := s.store.ListRaceControlMessages(sessionKey)
|
||||
if err != nil {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
if len(raceControl) > 0 {
|
||||
hub.RaceControl = make([]models.RaceControl, 0, len(raceControl))
|
||||
for _, rc := range raceControl {
|
||||
hub.RaceControl = append(hub.RaceControl, raceControlToModel(rc))
|
||||
}
|
||||
hub.Datasets["race_control"] = availableLocal(len(hub.RaceControl))
|
||||
}
|
||||
|
||||
weather, err := s.store.ListWeatherSamples(sessionKey)
|
||||
if err != nil {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
if len(weather) > 0 {
|
||||
hub.Weather = make([]models.Weather, 0, len(weather))
|
||||
for _, w := range weather {
|
||||
hub.Weather = append(hub.Weather, weatherToModel(w))
|
||||
}
|
||||
hub.Datasets["weather"] = availableLocal(len(hub.Weather))
|
||||
}
|
||||
|
||||
laps, err := s.store.ListLaps(sessionKey)
|
||||
if err != nil {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
if len(laps) > 0 {
|
||||
hub.Laps = make([]models.Lap, 0, len(laps))
|
||||
for _, l := range laps {
|
||||
hub.Laps = append(hub.Laps, lapToModel(l))
|
||||
}
|
||||
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
|
||||
}
|
||||
|
||||
hub.Source = responseSource(hub.Datasets)
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
450
internal/store/analytics.go
Normal file
450
internal/store/analytics.go
Normal file
@@ -0,0 +1,450 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// UpsertStint inserts or updates a stint row.
|
||||
func (s *Store) UpsertStint(st Stint) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO stints (
|
||||
session_key, driver_number, meeting_key, stint_number,
|
||||
compound, lap_start, lap_end, tyre_age_at_start
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key, driver_number, stint_number) DO UPDATE SET
|
||||
meeting_key = excluded.meeting_key,
|
||||
compound = excluded.compound,
|
||||
lap_start = excluded.lap_start,
|
||||
lap_end = excluded.lap_end,
|
||||
tyre_age_at_start = excluded.tyre_age_at_start
|
||||
`,
|
||||
st.SessionKey,
|
||||
st.DriverNumber,
|
||||
st.MeetingKey,
|
||||
st.StintNumber,
|
||||
st.Compound,
|
||||
st.LapStart,
|
||||
st.LapEnd,
|
||||
st.TyreAgeAtStart,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert stint: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListStints returns stints for a session ordered by driver and stint number.
|
||||
func (s *Store) ListStints(sessionKey int) ([]Stint, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT session_key, driver_number, meeting_key, stint_number,
|
||||
compound, lap_start, lap_end, tyre_age_at_start
|
||||
FROM stints
|
||||
WHERE session_key = ?
|
||||
ORDER BY driver_number ASC, stint_number ASC
|
||||
`, sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Stint
|
||||
for rows.Next() {
|
||||
var st Stint
|
||||
if err := rows.Scan(
|
||||
&st.SessionKey,
|
||||
&st.DriverNumber,
|
||||
&st.MeetingKey,
|
||||
&st.StintNumber,
|
||||
&st.Compound,
|
||||
&st.LapStart,
|
||||
&st.LapEnd,
|
||||
&st.TyreAgeAtStart,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertPitStop inserts or updates a pit stop row.
|
||||
func (s *Store) UpsertPitStop(p PitStop) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO pit_stops (
|
||||
session_key, driver_number, meeting_key, lap_number, date,
|
||||
pit_duration, lane_duration, stop_duration
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key, driver_number, date) DO UPDATE SET
|
||||
meeting_key = excluded.meeting_key,
|
||||
lap_number = excluded.lap_number,
|
||||
pit_duration = excluded.pit_duration,
|
||||
lane_duration = excluded.lane_duration,
|
||||
stop_duration = excluded.stop_duration
|
||||
`,
|
||||
p.SessionKey,
|
||||
p.DriverNumber,
|
||||
p.MeetingKey,
|
||||
p.LapNumber,
|
||||
p.Date,
|
||||
nullableZeroFloat(p.PitDuration),
|
||||
nullableZeroFloat(p.LaneDuration),
|
||||
nullableZeroFloat(p.StopDuration),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert pit stop: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPitStops returns pit stops for a session ordered by date.
|
||||
func (s *Store) ListPitStops(sessionKey int) ([]PitStop, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT session_key, driver_number, meeting_key, lap_number, date,
|
||||
pit_duration, lane_duration, stop_duration
|
||||
FROM pit_stops
|
||||
WHERE session_key = ?
|
||||
ORDER BY date ASC, driver_number ASC
|
||||
`, sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []PitStop
|
||||
for rows.Next() {
|
||||
var p PitStop
|
||||
var pitDuration, laneDuration, stopDuration sql.NullFloat64
|
||||
if err := rows.Scan(
|
||||
&p.SessionKey,
|
||||
&p.DriverNumber,
|
||||
&p.MeetingKey,
|
||||
&p.LapNumber,
|
||||
&p.Date,
|
||||
&pitDuration,
|
||||
&laneDuration,
|
||||
&stopDuration,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pitDuration.Valid {
|
||||
p.PitDuration = pitDuration.Float64
|
||||
}
|
||||
if laneDuration.Valid {
|
||||
p.LaneDuration = laneDuration.Float64
|
||||
}
|
||||
if stopDuration.Valid {
|
||||
p.StopDuration = stopDuration.Float64
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertPositionSample inserts or updates a position sample row.
|
||||
func (s *Store) UpsertPositionSample(p PositionSample) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO positions (
|
||||
session_key, driver_number, meeting_key, date, position
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key, driver_number, date) DO UPDATE SET
|
||||
meeting_key = excluded.meeting_key,
|
||||
position = excluded.position
|
||||
`,
|
||||
p.SessionKey,
|
||||
p.DriverNumber,
|
||||
p.MeetingKey,
|
||||
p.Date,
|
||||
p.Position,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert position: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPositionSamples returns position samples for a session ordered by date.
|
||||
func (s *Store) ListPositionSamples(sessionKey int) ([]PositionSample, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT session_key, driver_number, meeting_key, date, position
|
||||
FROM positions
|
||||
WHERE session_key = ?
|
||||
ORDER BY date ASC, driver_number ASC
|
||||
`, sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []PositionSample
|
||||
for rows.Next() {
|
||||
var p PositionSample
|
||||
if err := rows.Scan(
|
||||
&p.SessionKey,
|
||||
&p.DriverNumber,
|
||||
&p.MeetingKey,
|
||||
&p.Date,
|
||||
&p.Position,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertRaceControlMessage inserts or updates a race control message row.
|
||||
func (s *Store) UpsertRaceControlMessage(rc RaceControlMessage) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO race_control (
|
||||
session_key, meeting_key, date, category, flag, message, scope,
|
||||
driver_number, lap_number, sector, qualifying_phase
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key, date, message) DO UPDATE SET
|
||||
meeting_key = excluded.meeting_key,
|
||||
category = excluded.category,
|
||||
flag = excluded.flag,
|
||||
scope = excluded.scope,
|
||||
driver_number = excluded.driver_number,
|
||||
lap_number = excluded.lap_number,
|
||||
sector = excluded.sector,
|
||||
qualifying_phase = excluded.qualifying_phase
|
||||
`,
|
||||
rc.SessionKey,
|
||||
rc.MeetingKey,
|
||||
rc.Date,
|
||||
rc.Category,
|
||||
nullString(rc.Flag),
|
||||
rc.Message,
|
||||
nullString(rc.Scope),
|
||||
nullableInt(rc.DriverNumber),
|
||||
nullableInt(rc.LapNumber),
|
||||
nullableInt(rc.Sector),
|
||||
nullableInt(rc.QualifyingPhase),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert race control: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRaceControlMessages returns race control messages for a session.
|
||||
func (s *Store) ListRaceControlMessages(sessionKey int) ([]RaceControlMessage, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT session_key, meeting_key, date, category, flag, message, scope,
|
||||
driver_number, lap_number, sector, qualifying_phase
|
||||
FROM race_control
|
||||
WHERE session_key = ?
|
||||
ORDER BY date ASC
|
||||
`, sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []RaceControlMessage
|
||||
for rows.Next() {
|
||||
var rc RaceControlMessage
|
||||
var flag, scope sql.NullString
|
||||
var driverNumber, lapNumber, sector, qualifyingPhase sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&rc.SessionKey,
|
||||
&rc.MeetingKey,
|
||||
&rc.Date,
|
||||
&rc.Category,
|
||||
&flag,
|
||||
&rc.Message,
|
||||
&scope,
|
||||
&driverNumber,
|
||||
&lapNumber,
|
||||
§or,
|
||||
&qualifyingPhase,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rc.Flag = flag.String
|
||||
rc.Scope = scope.String
|
||||
rc.DriverNumber = nullIntPtr(driverNumber)
|
||||
rc.LapNumber = nullIntPtr(lapNumber)
|
||||
rc.Sector = nullIntPtr(sector)
|
||||
rc.QualifyingPhase = nullIntPtr(qualifyingPhase)
|
||||
out = append(out, rc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertWeatherSample inserts or updates a weather sample row.
|
||||
func (s *Store) UpsertWeatherSample(w WeatherSample) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO weather (
|
||||
session_key, meeting_key, date, air_temperature, track_temperature,
|
||||
humidity, pressure, rainfall, wind_direction, wind_speed
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key, date) DO UPDATE SET
|
||||
meeting_key = excluded.meeting_key,
|
||||
air_temperature = excluded.air_temperature,
|
||||
track_temperature = excluded.track_temperature,
|
||||
humidity = excluded.humidity,
|
||||
pressure = excluded.pressure,
|
||||
rainfall = excluded.rainfall,
|
||||
wind_direction = excluded.wind_direction,
|
||||
wind_speed = excluded.wind_speed
|
||||
`,
|
||||
w.SessionKey,
|
||||
w.MeetingKey,
|
||||
w.Date,
|
||||
nullableZeroFloat(w.AirTemperature),
|
||||
nullableZeroFloat(w.TrackTemperature),
|
||||
nullableZeroFloat(w.Humidity),
|
||||
nullableZeroFloat(w.Pressure),
|
||||
w.Rainfall,
|
||||
nullableZeroInt(w.WindDirection),
|
||||
nullableZeroFloat(w.WindSpeed),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert weather: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWeatherSamples returns weather samples for a session ordered by date.
|
||||
func (s *Store) ListWeatherSamples(sessionKey int) ([]WeatherSample, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT session_key, meeting_key, date, air_temperature, track_temperature,
|
||||
humidity, pressure, rainfall, wind_direction, wind_speed
|
||||
FROM weather
|
||||
WHERE session_key = ?
|
||||
ORDER BY date ASC
|
||||
`, sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []WeatherSample
|
||||
for rows.Next() {
|
||||
var w WeatherSample
|
||||
var airTemp, trackTemp, humidity, pressure, windSpeed sql.NullFloat64
|
||||
var windDirection sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&w.SessionKey,
|
||||
&w.MeetingKey,
|
||||
&w.Date,
|
||||
&airTemp,
|
||||
&trackTemp,
|
||||
&humidity,
|
||||
&pressure,
|
||||
&w.Rainfall,
|
||||
&windDirection,
|
||||
&windSpeed,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if airTemp.Valid {
|
||||
w.AirTemperature = airTemp.Float64
|
||||
}
|
||||
if trackTemp.Valid {
|
||||
w.TrackTemperature = trackTemp.Float64
|
||||
}
|
||||
if humidity.Valid {
|
||||
w.Humidity = humidity.Float64
|
||||
}
|
||||
if pressure.Valid {
|
||||
w.Pressure = pressure.Float64
|
||||
}
|
||||
if windDirection.Valid {
|
||||
w.WindDirection = int(windDirection.Int64)
|
||||
}
|
||||
if windSpeed.Valid {
|
||||
w.WindSpeed = windSpeed.Float64
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpsertLap inserts or updates a lap row.
|
||||
func (s *Store) UpsertLap(l Lap) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO laps (
|
||||
session_key, driver_number, meeting_key, lap_number, date_start,
|
||||
lap_duration, is_pit_out_lap, duration_sector1, duration_sector2, duration_sector3
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key, driver_number, lap_number) DO UPDATE SET
|
||||
meeting_key = excluded.meeting_key,
|
||||
date_start = excluded.date_start,
|
||||
lap_duration = excluded.lap_duration,
|
||||
is_pit_out_lap = excluded.is_pit_out_lap,
|
||||
duration_sector1 = excluded.duration_sector1,
|
||||
duration_sector2 = excluded.duration_sector2,
|
||||
duration_sector3 = excluded.duration_sector3
|
||||
`,
|
||||
l.SessionKey,
|
||||
l.DriverNumber,
|
||||
l.MeetingKey,
|
||||
l.LapNumber,
|
||||
nullString(l.DateStart),
|
||||
nullableZeroFloat(l.LapDuration),
|
||||
boolInt(l.IsPitOutLap),
|
||||
nullableZeroFloat(l.DurationSector1),
|
||||
nullableZeroFloat(l.DurationSector2),
|
||||
nullableZeroFloat(l.DurationSector3),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert lap: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListLaps returns laps for a session ordered by driver and lap number.
|
||||
func (s *Store) ListLaps(sessionKey int) ([]Lap, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT session_key, driver_number, meeting_key, lap_number, date_start,
|
||||
lap_duration, is_pit_out_lap, duration_sector1, duration_sector2, duration_sector3
|
||||
FROM laps
|
||||
WHERE session_key = ?
|
||||
ORDER BY driver_number ASC, lap_number ASC
|
||||
`, sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Lap
|
||||
for rows.Next() {
|
||||
var l Lap
|
||||
var dateStart sql.NullString
|
||||
var lapDuration, s1, s2, s3 sql.NullFloat64
|
||||
var isPitOut int
|
||||
if err := rows.Scan(
|
||||
&l.SessionKey,
|
||||
&l.DriverNumber,
|
||||
&l.MeetingKey,
|
||||
&l.LapNumber,
|
||||
&dateStart,
|
||||
&lapDuration,
|
||||
&isPitOut,
|
||||
&s1,
|
||||
&s2,
|
||||
&s3,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.DateStart = dateStart.String
|
||||
if lapDuration.Valid {
|
||||
l.LapDuration = lapDuration.Float64
|
||||
}
|
||||
l.IsPitOutLap = isPitOut != 0
|
||||
if s1.Valid {
|
||||
l.DurationSector1 = s1.Float64
|
||||
}
|
||||
if s2.Valid {
|
||||
l.DurationSector2 = s2.Float64
|
||||
}
|
||||
if s3.Valid {
|
||||
l.DurationSector3 = s3.Float64
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
94
internal/store/migrations/002_analytics.sql
Normal file
94
internal/store/migrations/002_analytics.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
CREATE TABLE IF NOT EXISTS stints (
|
||||
session_key INTEGER NOT NULL,
|
||||
driver_number INTEGER NOT NULL,
|
||||
meeting_key INTEGER NOT NULL,
|
||||
stint_number INTEGER NOT NULL,
|
||||
compound TEXT NOT NULL,
|
||||
lap_start INTEGER NOT NULL,
|
||||
lap_end INTEGER NOT NULL,
|
||||
tyre_age_at_start INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (session_key, driver_number, stint_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stints_session ON stints (session_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_stints_meeting ON stints (meeting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pit_stops (
|
||||
session_key INTEGER NOT NULL,
|
||||
driver_number INTEGER NOT NULL,
|
||||
meeting_key INTEGER NOT NULL,
|
||||
lap_number INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
pit_duration REAL,
|
||||
lane_duration REAL,
|
||||
stop_duration REAL,
|
||||
PRIMARY KEY (session_key, driver_number, date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pit_stops_session ON pit_stops (session_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_pit_stops_meeting ON pit_stops (meeting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS positions (
|
||||
session_key INTEGER NOT NULL,
|
||||
driver_number INTEGER NOT NULL,
|
||||
meeting_key INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_key, driver_number, date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_positions_session ON positions (session_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_positions_meeting ON positions (meeting_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_positions_session_driver ON positions (session_key, driver_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS race_control (
|
||||
session_key INTEGER NOT NULL,
|
||||
meeting_key INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
flag TEXT,
|
||||
message TEXT NOT NULL,
|
||||
scope TEXT,
|
||||
driver_number INTEGER,
|
||||
lap_number INTEGER,
|
||||
sector INTEGER,
|
||||
qualifying_phase INTEGER,
|
||||
PRIMARY KEY (session_key, date, message)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_race_control_session ON race_control (session_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_race_control_meeting ON race_control (meeting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weather (
|
||||
session_key INTEGER NOT NULL,
|
||||
meeting_key INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
air_temperature REAL,
|
||||
track_temperature REAL,
|
||||
humidity REAL,
|
||||
pressure REAL,
|
||||
rainfall INTEGER NOT NULL DEFAULT 0,
|
||||
wind_direction INTEGER,
|
||||
wind_speed REAL,
|
||||
PRIMARY KEY (session_key, date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_weather_session ON weather (session_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_weather_meeting ON weather (meeting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS laps (
|
||||
session_key INTEGER NOT NULL,
|
||||
driver_number INTEGER NOT NULL,
|
||||
meeting_key INTEGER NOT NULL,
|
||||
lap_number INTEGER NOT NULL,
|
||||
date_start TEXT,
|
||||
lap_duration REAL,
|
||||
is_pit_out_lap INTEGER NOT NULL DEFAULT 0,
|
||||
duration_sector1 REAL,
|
||||
duration_sector2 REAL,
|
||||
duration_sector3 REAL,
|
||||
PRIMARY KEY (session_key, driver_number, lap_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_laps_session ON laps (session_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_laps_meeting ON laps (meeting_key);
|
||||
@@ -4,16 +4,16 @@ import "time"
|
||||
|
||||
// RawPayload stores a fetched source payload with provenance metadata.
|
||||
type RawPayload struct {
|
||||
ID int64
|
||||
Source string
|
||||
Endpoint string
|
||||
RequestKey string
|
||||
MeetingKey *int
|
||||
SessionKey *int
|
||||
Payload string
|
||||
PayloadHash string
|
||||
FetchedAt time.Time
|
||||
ProvenanceJSON string
|
||||
ID int64
|
||||
Source string
|
||||
Endpoint string
|
||||
RequestKey string
|
||||
MeetingKey *int
|
||||
SessionKey *int
|
||||
Payload string
|
||||
PayloadHash string
|
||||
FetchedAt time.Time
|
||||
ProvenanceJSON string
|
||||
}
|
||||
|
||||
// IngestionRun tracks a scoped ingestion attempt.
|
||||
@@ -104,3 +104,79 @@ type StartingGridEntry struct {
|
||||
Position int
|
||||
LapDuration float64
|
||||
}
|
||||
|
||||
// Stint is a tyre stint for a driver in a session.
|
||||
type Stint struct {
|
||||
SessionKey int
|
||||
DriverNumber int
|
||||
MeetingKey int
|
||||
StintNumber int
|
||||
Compound string
|
||||
LapStart int
|
||||
LapEnd int
|
||||
TyreAgeAtStart int
|
||||
}
|
||||
|
||||
// PitStop is a pit stop event for a driver in a session.
|
||||
type PitStop struct {
|
||||
SessionKey int
|
||||
DriverNumber int
|
||||
MeetingKey int
|
||||
LapNumber int
|
||||
Date string
|
||||
PitDuration float64
|
||||
LaneDuration float64
|
||||
StopDuration float64
|
||||
}
|
||||
|
||||
// PositionSample is a position update for a driver in a session.
|
||||
type PositionSample struct {
|
||||
SessionKey int
|
||||
DriverNumber int
|
||||
MeetingKey int
|
||||
Date string
|
||||
Position int
|
||||
}
|
||||
|
||||
// RaceControlMessage is a race control message for a session.
|
||||
type RaceControlMessage struct {
|
||||
SessionKey int
|
||||
MeetingKey int
|
||||
Date string
|
||||
Category string
|
||||
Flag string
|
||||
Message string
|
||||
Scope string
|
||||
DriverNumber *int
|
||||
LapNumber *int
|
||||
Sector *int
|
||||
QualifyingPhase *int
|
||||
}
|
||||
|
||||
// WeatherSample is a weather reading for a session.
|
||||
type WeatherSample struct {
|
||||
SessionKey int
|
||||
MeetingKey int
|
||||
Date string
|
||||
AirTemperature float64
|
||||
TrackTemperature float64
|
||||
Humidity float64
|
||||
Pressure float64
|
||||
Rainfall int
|
||||
WindDirection int
|
||||
WindSpeed float64
|
||||
}
|
||||
|
||||
// Lap is a completed lap for a driver in a session.
|
||||
type Lap struct {
|
||||
SessionKey int
|
||||
DriverNumber int
|
||||
MeetingKey int
|
||||
LapNumber int
|
||||
DateStart string
|
||||
LapDuration float64
|
||||
IsPitOutLap bool
|
||||
DurationSector1 float64
|
||||
DurationSector2 float64
|
||||
DurationSector3 float64
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaVersion() error = %v", err)
|
||||
}
|
||||
if version != 1 {
|
||||
t.Fatalf("SchemaVersion() = %d, want 1", version)
|
||||
if version != 2 {
|
||||
t.Fatalf("SchemaVersion() = %d, want 2", version)
|
||||
}
|
||||
|
||||
tables := []string{
|
||||
@@ -42,6 +42,12 @@ func TestOpenAppliesMigrations(t *testing.T) {
|
||||
"session_drivers",
|
||||
"session_results",
|
||||
"starting_grid",
|
||||
"stints",
|
||||
"pit_stops",
|
||||
"positions",
|
||||
"race_control",
|
||||
"weather",
|
||||
"laps",
|
||||
}
|
||||
for _, table := range tables {
|
||||
var name string
|
||||
@@ -72,6 +78,12 @@ func TestMigrationsAreIdempotent(t *testing.T) {
|
||||
if count != 1 {
|
||||
t.Fatalf("schema_migrations count = %d, want 1", count)
|
||||
}
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 2`).Scan(&count); err != nil {
|
||||
t.Fatalf("count schema_migrations v2: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("schema_migrations v2 count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawPayloadInsertAndRead(t *testing.T) {
|
||||
@@ -412,6 +424,110 @@ func TestSessionResultAndStartingGridUpsertRead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsUpsertRead(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
|
||||
meetingKey := 1229
|
||||
sessionKey := 9472
|
||||
|
||||
stint := Stint{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
StintNumber: 1,
|
||||
Compound: "SOFT",
|
||||
LapStart: 1,
|
||||
LapEnd: 20,
|
||||
TyreAgeAtStart: 0,
|
||||
}
|
||||
if err := s.UpsertStint(stint); err != nil {
|
||||
t.Fatalf("UpsertStint() error = %v", err)
|
||||
}
|
||||
stints, err := s.ListStints(sessionKey)
|
||||
if err != nil || len(stints) != 1 || stints[0].Compound != "SOFT" {
|
||||
t.Fatalf("ListStints() = %+v, err = %v", stints, err)
|
||||
}
|
||||
|
||||
pit := PitStop{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
LapNumber: 21,
|
||||
Date: "2025-05-25T14:00:00+00:00",
|
||||
StopDuration: 2.5,
|
||||
}
|
||||
if err := s.UpsertPitStop(pit); err != nil {
|
||||
t.Fatalf("UpsertPitStop() error = %v", err)
|
||||
}
|
||||
pits, err := s.ListPitStops(sessionKey)
|
||||
if err != nil || len(pits) != 1 {
|
||||
t.Fatalf("ListPitStops() = %+v, err = %v", pits, err)
|
||||
}
|
||||
|
||||
pos := PositionSample{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
Date: "2025-05-25T14:05:00+00:00",
|
||||
Position: 1,
|
||||
}
|
||||
if err := s.UpsertPositionSample(pos); err != nil {
|
||||
t.Fatalf("UpsertPositionSample() error = %v", err)
|
||||
}
|
||||
positions, err := s.ListPositionSamples(sessionKey)
|
||||
if err != nil || len(positions) != 1 {
|
||||
t.Fatalf("ListPositionSamples() = %+v, err = %v", positions, err)
|
||||
}
|
||||
|
||||
rc := RaceControlMessage{
|
||||
SessionKey: sessionKey,
|
||||
MeetingKey: meetingKey,
|
||||
Date: "2025-05-25T14:10:00+00:00",
|
||||
Category: "Flag",
|
||||
Flag: "YELLOW",
|
||||
Message: "Yellow flag sector 1",
|
||||
Scope: "Track",
|
||||
}
|
||||
if err := s.UpsertRaceControlMessage(rc); err != nil {
|
||||
t.Fatalf("UpsertRaceControlMessage() error = %v", err)
|
||||
}
|
||||
messages, err := s.ListRaceControlMessages(sessionKey)
|
||||
if err != nil || len(messages) != 1 {
|
||||
t.Fatalf("ListRaceControlMessages() = %+v, err = %v", messages, err)
|
||||
}
|
||||
|
||||
weather := WeatherSample{
|
||||
SessionKey: sessionKey,
|
||||
MeetingKey: meetingKey,
|
||||
Date: "2025-05-25T14:00:00+00:00",
|
||||
AirTemperature: 22.5,
|
||||
TrackTemperature: 35.0,
|
||||
Humidity: 45.0,
|
||||
}
|
||||
if err := s.UpsertWeatherSample(weather); err != nil {
|
||||
t.Fatalf("UpsertWeatherSample() error = %v", err)
|
||||
}
|
||||
samples, err := s.ListWeatherSamples(sessionKey)
|
||||
if err != nil || len(samples) != 1 {
|
||||
t.Fatalf("ListWeatherSamples() = %+v, err = %v", samples, err)
|
||||
}
|
||||
|
||||
lap := Lap{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
LapNumber: 1,
|
||||
LapDuration: 75.123,
|
||||
}
|
||||
if err := s.UpsertLap(lap); err != nil {
|
||||
t.Fatalf("UpsertLap() error = %v", err)
|
||||
}
|
||||
laps, err := s.ListLaps(sessionKey)
|
||||
if err != nil || len(laps) != 1 {
|
||||
t.Fatalf("ListLaps() = %+v, err = %v", laps, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTxRollback(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/api"
|
||||
@@ -88,7 +89,9 @@ func (s *Server) Start() error {
|
||||
|
||||
// Start background goroutines.
|
||||
go s.hub.run()
|
||||
go s.runLiveFeeds()
|
||||
if os.Getenv("BOXBOX_DISABLE_LIVE") != "1" {
|
||||
go s.runLiveFeeds()
|
||||
}
|
||||
|
||||
return http.ListenAndServe(s.addr, withCORS(withLogging(mux)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user