Add weekend ingestion orchestration

This commit is contained in:
2026-05-25 02:38:27 -04:00
parent 2c9db0213c
commit f2339a00a9
8 changed files with 404 additions and 128 deletions

View File

@@ -19,7 +19,7 @@ func main() {
webMode := flag.Bool("web", false, "Start web companion server instead of TUI") webMode := flag.Bool("web", false, "Start web companion server instead of TUI")
port := flag.Int("port", 8080, "Port for web server (used with --web)") port := flag.Int("port", 8080, "Port for web server (used with --web)")
ingestYear := flag.Int("ingest-year", 0, "Ingest OpenF1 meetings for a season year") ingestYear := flag.Int("ingest-year", 0, "Ingest OpenF1 meetings for a season year")
ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest OpenF1 sessions for a meeting key") ingestMeeting := flag.Int("ingest-meeting", 0, "Ingest meeting metadata and Race Hub datasets for all sessions")
ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key") ingestSession := flag.Int("ingest-session", 0, "Ingest Race Hub datasets for a session key")
dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows") dryRun := flag.Bool("dry-run", false, "Preview ingestion without writing domain rows")
dbPath := flag.String("db", "", "Domain database path (default: ~/.local/share/box-box/boxbox.db)") dbPath := flag.String("db", "", "Domain database path (default: ~/.local/share/box-box/boxbox.db)")

View File

@@ -0,0 +1,40 @@
# Phase 12 Data Library UI
## Purpose
The app can now ingest full weekends and browse local seasons, meetings, and
sessions. Phase 12 should make local data health visible in the Web UI so users
can understand what is stored, what is partial, and what command to run next.
This is a frontend slice. Keep it practical and built on the APIs already
available.
## Scope
Add a Data Library style surface that shows:
- local seasons and meetings;
- sessions per meeting;
- dataset coverage per session;
- clear empty states;
- suggested CLI commands for ingestion/backfill.
This can be a new route or a tab/section reachable from the existing Race Hub
shell, depending on the current router structure.
## Guardrails
- Do not fetch OpenF1 from React.
- Do not trigger ingestion from the browser.
- Keep Race Hub direct links working.
- Reuse existing local navigation APIs unless a small backend gap is genuinely
blocking.
- Keep styling dense, operational, and restrained.
## Acceptance Criteria
- User can inspect local data coverage without opening a specific session.
- Partial weekends/sessions are visibly distinct from complete ones.
- Empty database state explains the relevant CLI command.
- Existing Race Hub navigation continues to work.
- Frontend tests/build/e2e pass.

View File

@@ -73,8 +73,10 @@ not implementation tickets yet.
adding local-first season/weekend navigation around Race Hub. adding local-first season/weekend navigation around Race Hub.
- [19 Phase 11 Weekend Ingestion](19-phase-11-weekend-ingestion.md): backend - [19 Phase 11 Weekend Ingestion](19-phase-11-weekend-ingestion.md): backend
slice for making one command ingest a whole race weekend into the local DB. slice for making one command ingest a whole race weekend into the local DB.
- [Cursor Phase 11 Prompt](cursor-phase-11-weekend-ingestion-prompt.md): - [20 Phase 12 Data Library UI](20-phase-12-data-library-ui.md): frontend slice
current handoff prompt for the next backend phase. for showing local ingestion coverage and next CLI actions.
- [Cursor Phase 12 Prompt](cursor-phase-12-data-library-ui-prompt.md): current
handoff prompt for the next frontend phase.
## External References ## External References

View File

@@ -1,78 +0,0 @@
# Prompt For Cursor: Phase 11 Weekend Ingestion
You are working in the `box-box` repository on Phase 11. The Web UI can now
browse local years, meetings, and sessions. Your task is to make the CLI able
to populate a whole race weekend/meeting in one backend ingestion flow.
## Read First
Open these files first:
- `documentations/refactor/19-phase-11-weekend-ingestion.md`
- `cmd/main.go`
- `internal/ingest/ingest.go`
- `internal/ingest/ingest_test.go`
- `internal/ingest/openf1.go`
- `internal/store/meetings.go`
- `internal/query/navigation.go`
Only open older docs if you are blocked.
## Goal
Make meeting/weekend ingestion useful for the local-first Web UI. A user should
be able to ingest a meeting and have all sessions for that meeting populated
with Race Hub datasets where available.
## Current Shape
The project already has:
- `--ingest-year`
- `--ingest-meeting`
- `--ingest-session`
- session-level Race Hub ingestion datasets;
- local navigation APIs and UI that depend on ingested meeting/session data.
Confirm the exact current behavior before editing. If `--ingest-meeting`
currently only stores meeting/session metadata, extend it or add a clearly named
flag. Prefer the least surprising CLI behavior.
## Work To Do
1. Add meeting/weekend orchestration that fetches sessions for a meeting and
ingests Race Hub datasets for each session.
2. Preserve single-session ingestion behavior.
3. Return/report per-session summaries clearly.
4. Keep raw payload provenance for all fetched endpoints.
5. Make partial failures visible without erasing successful session data.
6. Add focused offline tests with fake sources.
## Guardrails
- Do not fetch OpenF1 from React.
- Do not introduce background ingestion from normal page views.
- Do not persist high-volume car telemetry in this phase.
- Do not break existing e2e seed behavior.
- Keep completed historical sessions as the default mental model.
## Verification
Run:
```bash
go test ./internal/ingest/... ./internal/store/... ./internal/query/... ./internal/web/...
go build -o /private/tmp/box-box ./cmd/main.go
cd frontend && npm test -- --run
cd frontend && npm run build
npm run test:e2e
```
## Report Back
Summarize:
- files changed;
- CLI behavior added or changed;
- tests run and results;
- follow-up risks, especially around OpenF1 rate limits or partial sessions.

View File

@@ -0,0 +1,71 @@
# Prompt For Cursor: Phase 12 Data Library UI
You are working in the `box-box` repository on Phase 12. Build a practical Web
UI surface for inspecting local data coverage and ingestion status.
## Read First
Open these files first:
- `documentations/refactor/20-phase-12-data-library-ui.md`
- `frontend/src/components/LocalDataNavigator.tsx`
- `frontend/src/pages/RaceHubPage.tsx`
- `frontend/src/api.ts`
- `frontend/src/types.ts`
- `frontend/src/main.tsx`
- `frontend/src/styles/app.css`
- `tests/race-hub.spec.ts`
Only open older docs if you are blocked.
## Goal
Let a user inspect what is in the local database and understand what is missing
without needing to open every Race Hub session manually.
## APIs Available
- `GET /api/v1/seasons`
- `GET /api/v1/meetings?year=<year>&source=local`
- `GET /api/v1/weekend?meeting_key=<key>`
- `GET /api/v1/race-hub?session_key=<key>`
Do not fetch OpenF1 from React.
## Work To Do
1. Add a Data Library route or reachable section in the React app.
2. Show local years/meetings/sessions and dataset coverage.
3. Make partial vs complete sessions visually clear.
4. Include copyable/reference CLI commands, such as:
- `box-box --ingest-year 2025`
- `box-box --ingest-meeting <meeting_key>`
- `box-box --ingest-session <session_key>`
5. Preserve direct Race Hub navigation.
6. Add focused frontend tests and update e2e if stable.
## Design Notes
- Keep it operational and table-like.
- Avoid decorative cards and generic dashboard clutter.
- Use the existing dataset/status visual language where possible.
- Mobile should remain usable.
## Verification
Run:
```bash
cd frontend && npm test -- --run
cd frontend && npm run build
npm run test:e2e
```
## Report Back
Summarize:
- files changed;
- Data Library behavior added;
- tests run and results;
- follow-up API or design gaps.

View File

@@ -31,26 +31,34 @@ func DefaultOptions() Options {
} }
} }
// SessionSummary captures the outcome of ingesting one session within a meeting run.
type SessionSummary struct {
SessionKey int `json:"session_key"`
SessionName string `json:"session_name,omitempty"`
Summary Summary `json:"summary"`
}
// Summary captures the outcome of an ingestion run. // Summary captures the outcome of an ingestion run.
type Summary struct { type Summary struct {
ScopeType string `json:"scope_type"` ScopeType string `json:"scope_type"`
ScopeKey string `json:"scope_key"` ScopeKey string `json:"scope_key"`
Status string `json:"status"` Status string `json:"status"`
DryRun bool `json:"dry_run"` DryRun bool `json:"dry_run"`
Meetings int `json:"meetings"` Meetings int `json:"meetings"`
Sessions int `json:"sessions"` Sessions int `json:"sessions"`
Drivers int `json:"drivers"` Drivers int `json:"drivers"`
SessionResults int `json:"session_results"` SessionResults int `json:"session_results"`
StartingGrid int `json:"starting_grid"` StartingGrid int `json:"starting_grid"`
Stints int `json:"stints"` Stints int `json:"stints"`
PitStops int `json:"pit_stops"` PitStops int `json:"pit_stops"`
Positions int `json:"positions"` Positions int `json:"positions"`
RaceControl int `json:"race_control"` RaceControl int `json:"race_control"`
Weather int `json:"weather"` Weather int `json:"weather"`
Laps int `json:"laps"` Laps int `json:"laps"`
RawPayloads int `json:"raw_payloads"` RawPayloads int `json:"raw_payloads"`
RawInserted int `json:"raw_inserted"` RawInserted int `json:"raw_inserted"`
Errors []string `json:"errors,omitempty"` SessionSummaries []SessionSummary `json:"session_summaries,omitempty"`
Errors []string `json:"errors,omitempty"`
} }
// Service orchestrates OpenF1-to-store ingestion workflows. // Service orchestrates OpenF1-to-store ingestion workflows.
@@ -130,7 +138,7 @@ func (s *Service) IngestYear(year int) (Summary, error) {
return summary, nil return summary, nil
} }
// IngestMeeting fetches meeting metadata and all sessions for a meeting key. // IngestMeeting fetches meeting metadata, all sessions, and Race Hub datasets for each session.
func (s *Service) IngestMeeting(meetingKey int) (Summary, error) { func (s *Service) IngestMeeting(meetingKey int) (Summary, error) {
summary := Summary{ summary := Summary{
ScopeType: "meeting", ScopeType: "meeting",
@@ -205,9 +213,37 @@ func (s *Service) IngestMeeting(meetingKey int) (Summary, error) {
summary.Sessions++ summary.Sessions++
} }
summary.Status = statusForDryRun(s.opts.DryRun) sessionFailures := 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)
ss := SessionSummary{
SessionKey: sess.SessionKey,
SessionName: sess.SessionName,
Summary: sessSummary,
}
if err != nil {
sessionFailures++
ss.Summary.Status = "failed"
ss.Summary.Errors = append(ss.Summary.Errors, err.Error())
summary.Errors = append(summary.Errors, fmt.Sprintf(
"session %d (%s): %v", sess.SessionKey, sess.SessionName, err,
))
}
summary.SessionSummaries = append(summary.SessionSummaries, ss)
summary.mergeCounts(sessSummary)
}
summary.Status = meetingStatus(sessionFailures, len(sessions), s.opts.DryRun)
s.finishRun(runID, summary) s.finishRun(runID, summary)
s.opts.Progress.Summary(summary) s.opts.Progress.Summary(summary)
if sessionFailures > 0 {
return summary, fmt.Errorf(
"meeting %d: %d of %d session(s) failed",
meetingKey, sessionFailures, len(sessions),
)
}
return summary, nil return summary, nil
} }
@@ -287,28 +323,48 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
summary.Sessions++ summary.Sessions++
} }
datasetSummary, err := s.ingestSessionDatasets(sessionKey, meetingKey)
summary.mergeCounts(datasetSummary)
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Status = statusForDryRun(s.opts.DryRun)
s.finishRun(runID, summary)
s.opts.Progress.Summary(summary)
return summary, nil
}
func (s *Service) ingestSessionDatasets(sessionKey, meetingKey int) (Summary, error) {
summary := Summary{
ScopeType: "session",
ScopeKey: fmt.Sprintf("%d", sessionKey),
DryRun: s.opts.DryRun,
}
sk := sessionKey
s.opts.Progress.Step("fetching drivers for session %d", sessionKey) s.opts.Progress.Step("fetching drivers for session %d", sessionKey)
driverFetch, drivers, err := fetchWithRetry(s, func() (FetchResult, []models.Driver, error) { driverFetch, drivers, err := fetchWithRetry(s, func() (FetchResult, []models.Driver, error) {
return s.source.FetchDriversForSession(sessionKey) return s.source.FetchDriversForSession(sessionKey)
}) })
if err != nil { if err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
summary.RawPayloads++ summary.RawPayloads++
if !s.opts.DryRun { if !s.opts.DryRun {
inserted, err := s.storeRaw(driverFetch, &meetingKey, &sk) inserted, err := s.storeRaw(driverFetch, &meetingKey, &sk)
if err != nil { if err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
if inserted { if inserted {
summary.RawInserted++ summary.RawInserted++
} }
for _, d := range drivers { for _, d := range drivers {
if err := s.store.UpsertDriver(driverToStore(d)); err != nil { if err := s.store.UpsertDriver(driverToStore(d)); err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
if err := s.store.UpsertSessionDriver(sessionDriverToStore(d)); err != nil { if err := s.store.UpsertSessionDriver(sessionDriverToStore(d)); err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
summary.Drivers++ summary.Drivers++
} }
@@ -322,20 +378,20 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
return s.source.FetchSessionResult(sessionKey) return s.source.FetchSessionResult(sessionKey)
}) })
if err != nil { if err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
summary.RawPayloads++ summary.RawPayloads++
if !s.opts.DryRun { if !s.opts.DryRun {
inserted, err := s.storeRaw(resultFetch, &meetingKey, &sk) inserted, err := s.storeRaw(resultFetch, &meetingKey, &sk)
if err != nil { if err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
if inserted { if inserted {
summary.RawInserted++ summary.RawInserted++
} }
for _, r := range results { for _, r := range results {
if err := s.store.UpsertSessionResult(sessionResultToStore(r)); err != nil { if err := s.store.UpsertSessionResult(sessionResultToStore(r)); err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
summary.SessionResults++ summary.SessionResults++
} }
@@ -349,20 +405,20 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
return s.source.FetchStartingGrid(sessionKey) return s.source.FetchStartingGrid(sessionKey)
}) })
if err != nil { if err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
summary.RawPayloads++ summary.RawPayloads++
if !s.opts.DryRun { if !s.opts.DryRun {
inserted, err := s.storeRaw(gridFetch, &meetingKey, &sk) inserted, err := s.storeRaw(gridFetch, &meetingKey, &sk)
if err != nil { if err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
if inserted { if inserted {
summary.RawInserted++ summary.RawInserted++
} }
for _, g := range grid { for _, g := range grid {
if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil { if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
summary.StartingGrid++ summary.StartingGrid++
} }
@@ -372,30 +428,55 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
s.delay() s.delay()
if err := s.ingestStints(&summary, meetingKey, sk); err != nil { if err := s.ingestStints(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
if err := s.ingestPitStops(&summary, meetingKey, sk); err != nil { if err := s.ingestPitStops(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
if err := s.ingestPositions(&summary, meetingKey, sk); err != nil { if err := s.ingestPositions(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
if err := s.ingestRaceControl(&summary, meetingKey, sk); err != nil { if err := s.ingestRaceControl(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
if err := s.ingestWeather(&summary, meetingKey, sk); err != nil { if err := s.ingestWeather(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
if err := s.ingestLaps(&summary, meetingKey, sk); err != nil { if err := s.ingestLaps(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err) return summary, err
} }
summary.Status = statusForDryRun(s.opts.DryRun) summary.Status = statusForDryRun(s.opts.DryRun)
s.finishRun(runID, summary)
s.opts.Progress.Summary(summary)
return summary, nil return summary, nil
} }
func (s *Summary) mergeCounts(other Summary) {
s.Drivers += other.Drivers
s.SessionResults += other.SessionResults
s.StartingGrid += other.StartingGrid
s.Stints += other.Stints
s.PitStops += other.PitStops
s.Positions += other.Positions
s.RaceControl += other.RaceControl
s.Weather += other.Weather
s.Laps += other.Laps
s.RawPayloads += other.RawPayloads
s.RawInserted += other.RawInserted
}
func meetingStatus(sessionFailures, sessionTotal int, dryRun bool) string {
if dryRun {
return "dry_run"
}
if sessionFailures == 0 {
return "completed"
}
if sessionFailures == sessionTotal {
return "failed"
}
return "partial"
}
func (s *Service) beginRun(scopeType, scopeKey string) (int64, error) { func (s *Service) beginRun(scopeType, scopeKey string) (int64, error) {
if s.opts.DryRun { if s.opts.DryRun {
return 0, nil return 0, nil

View File

@@ -28,6 +28,7 @@ type fakeSource struct {
weather map[int][]models.Weather weather map[int][]models.Weather
laps map[int][]models.Lap laps map[int][]models.Lap
failOn string failOn string
failSession map[int]string
liveLockout bool liveLockout bool
} }
@@ -59,6 +60,16 @@ func (f *fakeSource) maybeFail(endpoint string) error {
return nil return nil
} }
func (f *fakeSource) maybeFailSession(sessionKey int, endpoint string) error {
if err := f.maybeFail(endpoint); err != nil {
return err
}
if f.failSession != nil && f.failSession[sessionKey] == endpoint {
return errors.New("simulated fetch failure")
}
return nil
}
func (f *fakeSource) wrap(endpoint, requestKey string, payload any) FetchResult { func (f *fakeSource) wrap(endpoint, requestKey string, payload any) FetchResult {
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
return FetchResult{ return FetchResult{
@@ -103,7 +114,7 @@ func (f *fakeSource) FetchSessionsForSessionKey(sessionKey int) (FetchResult, []
} }
func (f *fakeSource) FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error) { func (f *fakeSource) FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error) {
if err := f.maybeFail("drivers"); err != nil { if err := f.maybeFailSession(sessionKey, "drivers"); err != nil {
return FetchResult{}, nil, err return FetchResult{}, nil, err
} }
data := f.drivers[sessionKey] data := f.drivers[sessionKey]
@@ -111,7 +122,7 @@ func (f *fakeSource) FetchDriversForSession(sessionKey int) (FetchResult, []mode
} }
func (f *fakeSource) FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error) { func (f *fakeSource) FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error) {
if err := f.maybeFail("session_result"); err != nil { if err := f.maybeFailSession(sessionKey, "session_result"); err != nil {
return FetchResult{}, nil, err return FetchResult{}, nil, err
} }
data := f.results[sessionKey] data := f.results[sessionKey]
@@ -119,7 +130,7 @@ func (f *fakeSource) FetchSessionResult(sessionKey int) (FetchResult, []models.S
} }
func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error) { func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error) {
if err := f.maybeFail("starting_grid"); err != nil { if err := f.maybeFailSession(sessionKey, "starting_grid"); err != nil {
return FetchResult{}, nil, err return FetchResult{}, nil, err
} }
data := f.grid[sessionKey] data := f.grid[sessionKey]
@@ -127,7 +138,7 @@ func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.St
} }
func (f *fakeSource) FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error) { func (f *fakeSource) FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error) {
if err := f.maybeFail("stints"); err != nil { if err := f.maybeFailSession(sessionKey, "stints"); err != nil {
return FetchResult{}, nil, err return FetchResult{}, nil, err
} }
data := f.stints[sessionKey] data := f.stints[sessionKey]
@@ -135,7 +146,7 @@ func (f *fakeSource) FetchStintsForSession(sessionKey int) (FetchResult, []model
} }
func (f *fakeSource) FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error) { func (f *fakeSource) FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error) {
if err := f.maybeFail("pit"); err != nil { if err := f.maybeFailSession(sessionKey, "pit"); err != nil {
return FetchResult{}, nil, err return FetchResult{}, nil, err
} }
data := f.pitStops[sessionKey] data := f.pitStops[sessionKey]
@@ -143,7 +154,7 @@ func (f *fakeSource) FetchPitStopsForSession(sessionKey int) (FetchResult, []mod
} }
func (f *fakeSource) FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error) { func (f *fakeSource) FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error) {
if err := f.maybeFail("position"); err != nil { if err := f.maybeFailSession(sessionKey, "position"); err != nil {
return FetchResult{}, nil, err return FetchResult{}, nil, err
} }
data := f.positions[sessionKey] data := f.positions[sessionKey]
@@ -151,7 +162,7 @@ func (f *fakeSource) FetchPositionsForSession(sessionKey int) (FetchResult, []mo
} }
func (f *fakeSource) FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error) { func (f *fakeSource) FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error) {
if err := f.maybeFail("race_control"); err != nil { if err := f.maybeFailSession(sessionKey, "race_control"); err != nil {
return FetchResult{}, nil, err return FetchResult{}, nil, err
} }
data := f.raceControl[sessionKey] data := f.raceControl[sessionKey]
@@ -159,7 +170,7 @@ func (f *fakeSource) FetchRaceControlForSession(sessionKey int) (FetchResult, []
} }
func (f *fakeSource) FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error) { func (f *fakeSource) FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error) {
if err := f.maybeFail("weather"); err != nil { if err := f.maybeFailSession(sessionKey, "weather"); err != nil {
return FetchResult{}, nil, err return FetchResult{}, nil, err
} }
data := f.weather[sessionKey] data := f.weather[sessionKey]
@@ -167,7 +178,7 @@ func (f *fakeSource) FetchWeatherForSession(sessionKey int) (FetchResult, []mode
} }
func (f *fakeSource) FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error) { func (f *fakeSource) FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error) {
if err := f.maybeFail("laps"); err != nil { if err := f.maybeFailSession(sessionKey, "laps"); err != nil {
return FetchResult{}, nil, err return FetchResult{}, nil, err
} }
data := f.laps[sessionKey] data := f.laps[sessionKey]
@@ -261,6 +272,45 @@ func testSessionFixtures() (int, int, *fakeSource) {
return meetingKey, sessionKey, src return meetingKey, sessionKey, src
} }
func testWeekendFixtures() (int, int, int, *fakeSource) {
meetingKey, raceKey, src := testSessionFixtures()
const qualiKey = 9473
src.sessionsByMeeting[meetingKey] = []models.Session{
{SessionKey: qualiKey, MeetingKey: meetingKey, SessionName: "Qualifying", SessionType: "Qualifying", CircuitKey: 10},
{SessionKey: raceKey, MeetingKey: meetingKey, SessionName: "Race", SessionType: "Race", CircuitKey: 10},
}
src.sessionsByKey[qualiKey] = []models.Session{src.sessionsByMeeting[meetingKey][0]}
src.drivers[qualiKey] = []models.Driver{
{
DriverNumber: 1,
FullName: "Max Verstappen",
SessionKey: qualiKey,
MeetingKey: meetingKey,
TeamName: "Red Bull Racing",
TeamColour: "3671C6",
},
{
DriverNumber: 44,
FullName: "Lewis Hamilton",
SessionKey: qualiKey,
MeetingKey: meetingKey,
TeamName: "Ferrari",
TeamColour: "E8002D",
},
}
src.results[qualiKey] = []models.SessionResult{
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1},
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, GapToLeader: 0.2},
}
src.grid[qualiKey] = []models.StartingGrid{
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, LapDuration: 71.1},
{SessionKey: qualiKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, LapDuration: 71.3},
}
return meetingKey, qualiKey, raceKey, src
}
func ptrFloat64(v float64) *float64 { func ptrFloat64(v float64) *float64 {
return &v return &v
} }
@@ -476,3 +526,82 @@ func TestIngestYearAndMeeting(t *testing.T) {
t.Fatalf("meeting sessions = %d, want 1", meetingSummary.Sessions) t.Fatalf("meeting sessions = %d, want 1", meetingSummary.Sessions)
} }
} }
func TestIngestMeetingWeekendIngestsAllSessions(t *testing.T) {
meetingKey, qualiKey, raceKey, src := testWeekendFixtures()
st := openTestStore(t)
opts := DefaultOptions()
opts.RequestDelay = 0
svc := NewService(st, src, opts)
summary, err := svc.IngestMeeting(meetingKey)
if err != nil {
t.Fatalf("IngestMeeting() error = %v", err)
}
if summary.Status != "completed" {
t.Fatalf("summary.Status = %q, want completed", summary.Status)
}
if summary.Sessions != 2 {
t.Fatalf("summary.Sessions = %d, want 2", summary.Sessions)
}
if len(summary.SessionSummaries) != 2 {
t.Fatalf("session summaries = %d, want 2", len(summary.SessionSummaries))
}
if summary.Drivers != 4 {
t.Fatalf("summary.Drivers = %d, want 4 (2 per session)", summary.Drivers)
}
if summary.SessionResults != 4 {
t.Fatalf("summary.SessionResults = %d, want 4", summary.SessionResults)
}
for _, sk := range []int{qualiKey, raceKey} {
drivers, err := st.ListSessionDrivers(sk)
if err != nil || len(drivers) != 2 {
t.Fatalf("session %d drivers = %+v, err = %v, want 2", sk, drivers, err)
}
results, err := st.ListSessionResults(sk)
if err != nil || len(results) != 2 {
t.Fatalf("session %d results = %+v, err = %v, want 2", sk, results, err)
}
}
}
func TestIngestMeetingPartialFailurePreservesSuccessfulSessions(t *testing.T) {
meetingKey, qualiKey, raceKey, src := testWeekendFixtures()
src.failSession = map[int]string{qualiKey: "session_result"}
st := openTestStore(t)
opts := DefaultOptions()
opts.RequestDelay = 0
svc := NewService(st, src, opts)
summary, err := svc.IngestMeeting(meetingKey)
if err == nil {
t.Fatal("IngestMeeting() expected partial failure error, got nil")
}
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 entry", summary.Errors)
}
raceDrivers, err := st.ListSessionDrivers(raceKey)
if err != nil || len(raceDrivers) != 2 {
t.Fatalf("race session drivers = %+v, err = %v, want 2 preserved", raceDrivers, err)
}
qualiResults, err := st.ListSessionResults(qualiKey)
if err != nil {
t.Fatalf("ListSessionResults(quali) error = %v", err)
}
if len(qualiResults) != 0 {
t.Fatalf("quali session_results after failure = %d, want 0", len(qualiResults))
}
meetings, err := st.ListMeetingsByYear(2025)
if err != nil || len(meetings) != 1 {
t.Fatalf("meetings after partial failure = %+v, err = %v, want 1 meeting preserved", meetings, err)
}
}

View File

@@ -40,9 +40,40 @@ func (p *Progress) Summary(summary Summary) {
if summary.StartingGrid > 0 { if summary.StartingGrid > 0 {
fmt.Fprintf(p.w, " starting grid: %d\n", summary.StartingGrid) fmt.Fprintf(p.w, " starting grid: %d\n", summary.StartingGrid)
} }
if summary.Stints > 0 {
fmt.Fprintf(p.w, " stints: %d\n", summary.Stints)
}
if summary.PitStops > 0 {
fmt.Fprintf(p.w, " pit stops: %d\n", summary.PitStops)
}
if summary.Positions > 0 {
fmt.Fprintf(p.w, " positions: %d\n", summary.Positions)
}
if summary.RaceControl > 0 {
fmt.Fprintf(p.w, " race control: %d\n", summary.RaceControl)
}
if summary.Weather > 0 {
fmt.Fprintf(p.w, " weather: %d\n", summary.Weather)
}
if summary.Laps > 0 {
fmt.Fprintf(p.w, " laps: %d\n", summary.Laps)
}
if summary.RawPayloads > 0 { if summary.RawPayloads > 0 {
fmt.Fprintf(p.w, " raw payloads fetched: %d (inserted: %d)\n", summary.RawPayloads, summary.RawInserted) fmt.Fprintf(p.w, " raw payloads fetched: %d (inserted: %d)\n", summary.RawPayloads, summary.RawInserted)
} }
for _, ss := range summary.SessionSummaries {
fmt.Fprintf(p.w, " session %d (%s): status=%s", ss.SessionKey, ss.SessionName, ss.Summary.Status)
if ss.Summary.Drivers > 0 || ss.Summary.SessionResults > 0 {
fmt.Fprintf(p.w, " drivers=%d results=%d", ss.Summary.Drivers, ss.Summary.SessionResults)
}
if ss.Summary.RawPayloads > 0 {
fmt.Fprintf(p.w, " raw=%d", ss.Summary.RawPayloads)
}
fmt.Fprintln(p.w)
for _, errMsg := range ss.Summary.Errors {
fmt.Fprintf(p.w, " error: %s\n", errMsg)
}
}
for _, errMsg := range summary.Errors { for _, errMsg := range summary.Errors {
fmt.Fprintf(p.w, " error: %s\n", errMsg) fmt.Fprintf(p.w, " error: %s\n", errMsg)
} }