diff --git a/cmd/main.go b/cmd/main.go index 0c458bb..50a3771 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,7 +19,7 @@ func main() { webMode := flag.Bool("web", false, "Start web companion server instead of TUI") 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") - 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") 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)") diff --git a/documentations/refactor/20-phase-12-data-library-ui.md b/documentations/refactor/20-phase-12-data-library-ui.md new file mode 100644 index 0000000..c13c23f --- /dev/null +++ b/documentations/refactor/20-phase-12-data-library-ui.md @@ -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. diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md index 5df808e..82ea209 100644 --- a/documentations/refactor/README.md +++ b/documentations/refactor/README.md @@ -73,8 +73,10 @@ not implementation tickets yet. adding local-first season/weekend navigation around Race Hub. - [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. -- [Cursor Phase 11 Prompt](cursor-phase-11-weekend-ingestion-prompt.md): - current handoff prompt for the next backend phase. +- [20 Phase 12 Data Library UI](20-phase-12-data-library-ui.md): frontend slice + 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 diff --git a/documentations/refactor/cursor-phase-11-weekend-ingestion-prompt.md b/documentations/refactor/cursor-phase-11-weekend-ingestion-prompt.md deleted file mode 100644 index 34c5b54..0000000 --- a/documentations/refactor/cursor-phase-11-weekend-ingestion-prompt.md +++ /dev/null @@ -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. diff --git a/documentations/refactor/cursor-phase-12-data-library-ui-prompt.md b/documentations/refactor/cursor-phase-12-data-library-ui-prompt.md new file mode 100644 index 0000000..496a163 --- /dev/null +++ b/documentations/refactor/cursor-phase-12-data-library-ui-prompt.md @@ -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=&source=local` +- `GET /api/v1/weekend?meeting_key=` +- `GET /api/v1/race-hub?session_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 ` + - `box-box --ingest-session ` +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. diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index 269549d..7d02900 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -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. type Summary struct { - ScopeType string `json:"scope_type"` - ScopeKey string `json:"scope_key"` - Status string `json:"status"` - DryRun bool `json:"dry_run"` - Meetings int `json:"meetings"` - Sessions int `json:"sessions"` - 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"` + ScopeType string `json:"scope_type"` + ScopeKey string `json:"scope_key"` + Status string `json:"status"` + DryRun bool `json:"dry_run"` + Meetings int `json:"meetings"` + Sessions int `json:"sessions"` + 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"` + SessionSummaries []SessionSummary `json:"session_summaries,omitempty"` + Errors []string `json:"errors,omitempty"` } // Service orchestrates OpenF1-to-store ingestion workflows. @@ -130,7 +138,7 @@ func (s *Service) IngestYear(year int) (Summary, error) { 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) { summary := Summary{ ScopeType: "meeting", @@ -205,9 +213,37 @@ func (s *Service) IngestMeeting(meetingKey int) (Summary, error) { 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.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 } @@ -287,28 +323,48 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) { 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) driverFetch, drivers, err := fetchWithRetry(s, func() (FetchResult, []models.Driver, error) { return s.source.FetchDriversForSession(sessionKey) }) if err != nil { - return s.finishFailed(runID, summary, err) + return summary, err } summary.RawPayloads++ if !s.opts.DryRun { inserted, err := s.storeRaw(driverFetch, &meetingKey, &sk) if err != nil { - return s.finishFailed(runID, summary, err) + return summary, err } if inserted { summary.RawInserted++ } for _, d := range drivers { 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 { - return s.finishFailed(runID, summary, err) + return summary, err } summary.Drivers++ } @@ -322,20 +378,20 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) { return s.source.FetchSessionResult(sessionKey) }) if err != nil { - return s.finishFailed(runID, summary, err) + return summary, err } summary.RawPayloads++ if !s.opts.DryRun { inserted, err := s.storeRaw(resultFetch, &meetingKey, &sk) if err != nil { - return s.finishFailed(runID, summary, err) + return summary, err } if inserted { summary.RawInserted++ } for _, r := range results { if err := s.store.UpsertSessionResult(sessionResultToStore(r)); err != nil { - return s.finishFailed(runID, summary, err) + return summary, err } summary.SessionResults++ } @@ -349,20 +405,20 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) { return s.source.FetchStartingGrid(sessionKey) }) if err != nil { - return s.finishFailed(runID, summary, err) + return summary, err } summary.RawPayloads++ if !s.opts.DryRun { inserted, err := s.storeRaw(gridFetch, &meetingKey, &sk) if err != nil { - return s.finishFailed(runID, summary, err) + return summary, err } if inserted { summary.RawInserted++ } for _, g := range grid { if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil { - return s.finishFailed(runID, summary, err) + return summary, err } summary.StartingGrid++ } @@ -372,30 +428,55 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) { s.delay() 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 { - return s.finishFailed(runID, summary, err) + return summary, err } 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 { - return s.finishFailed(runID, summary, err) + return summary, err } 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 { - return s.finishFailed(runID, summary, err) + return summary, err } summary.Status = statusForDryRun(s.opts.DryRun) - s.finishRun(runID, summary) - s.opts.Progress.Summary(summary) 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) { if s.opts.DryRun { return 0, nil diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index e5496c1..951bb59 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -28,6 +28,7 @@ type fakeSource struct { weather map[int][]models.Weather laps map[int][]models.Lap failOn string + failSession map[int]string liveLockout bool } @@ -59,6 +60,16 @@ func (f *fakeSource) maybeFail(endpoint string) error { 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 { body, _ := json.Marshal(payload) return FetchResult{ @@ -103,7 +114,7 @@ func (f *fakeSource) FetchSessionsForSessionKey(sessionKey int) (FetchResult, [] } 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 } 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) { - if err := f.maybeFail("session_result"); err != nil { + if err := f.maybeFailSession(sessionKey, "session_result"); err != nil { return FetchResult{}, nil, err } 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) { - if err := f.maybeFail("starting_grid"); err != nil { + if err := f.maybeFailSession(sessionKey, "starting_grid"); err != nil { return FetchResult{}, nil, err } 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) { - if err := f.maybeFail("stints"); err != nil { + if err := f.maybeFailSession(sessionKey, "stints"); err != nil { return FetchResult{}, nil, err } 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) { - if err := f.maybeFail("pit"); err != nil { + if err := f.maybeFailSession(sessionKey, "pit"); err != nil { return FetchResult{}, nil, err } 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) { - if err := f.maybeFail("position"); err != nil { + if err := f.maybeFailSession(sessionKey, "position"); err != nil { return FetchResult{}, nil, err } 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) { - if err := f.maybeFail("race_control"); err != nil { + if err := f.maybeFailSession(sessionKey, "race_control"); err != nil { return FetchResult{}, nil, err } 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) { - if err := f.maybeFail("weather"); err != nil { + if err := f.maybeFailSession(sessionKey, "weather"); err != nil { return FetchResult{}, nil, err } 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) { - if err := f.maybeFail("laps"); err != nil { + if err := f.maybeFailSession(sessionKey, "laps"); err != nil { return FetchResult{}, nil, err } data := f.laps[sessionKey] @@ -261,6 +272,45 @@ func testSessionFixtures() (int, int, *fakeSource) { 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 { return &v } @@ -476,3 +526,82 @@ func TestIngestYearAndMeeting(t *testing.T) { 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) + } +} diff --git a/internal/ingest/progress.go b/internal/ingest/progress.go index c359ee8..41f5da9 100644 --- a/internal/ingest/progress.go +++ b/internal/ingest/progress.go @@ -40,9 +40,40 @@ func (p *Progress) Summary(summary Summary) { if summary.StartingGrid > 0 { 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 { 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 { fmt.Fprintf(p.w, " error: %s\n", errMsg) }