From c7438f0ed80f736e78d930017b2fed7dbbd20d41 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 02:04:16 -0400 Subject: [PATCH] Add analytics data foundation --- .gitignore | 8 + .../refactor/16-phase-8-analytics-visuals.md | 65 +++ documentations/refactor/README.md | 7 +- ...claude-phase-8-analytics-visuals-prompt.md | 105 ++++ ...hase-7-analytics-data-foundation-prompt.md | 72 --- frontend/src/types.ts | 73 +++ frontend/vite.config.ts | 2 +- internal/ingest/ingest.go | 170 +++++++ internal/ingest/ingest_test.go | 105 +++- internal/ingest/openf1.go | 239 ++++++++++ internal/query/convert.go | 99 ++++ internal/query/query_test.go | 46 +- internal/query/racehub.go | 90 ++++ internal/store/analytics.go | 450 ++++++++++++++++++ internal/store/migrations/002_analytics.sql | 94 ++++ internal/store/models.go | 96 +++- internal/store/store_test.go | 120 ++++- internal/web/server.go | 5 +- package-lock.json | 96 ++++ package.json | 16 + playwright.config.ts | 38 ++ scripts/seed-e2e-db/main.go | 219 +++++++++ tests/race-hub.spec.ts | 46 ++ 23 files changed, 2167 insertions(+), 94 deletions(-) create mode 100644 documentations/refactor/16-phase-8-analytics-visuals.md create mode 100644 documentations/refactor/claude-phase-8-analytics-visuals-prompt.md delete mode 100644 documentations/refactor/cursor-phase-7-analytics-data-foundation-prompt.md create mode 100644 internal/store/analytics.go create mode 100644 internal/store/migrations/002_analytics.sql create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 scripts/seed-e2e-db/main.go create mode 100644 tests/race-hub.spec.ts diff --git a/.gitignore b/.gitignore index aaffb43..bc1fc54 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,11 @@ frontend/dist/ # Local Claude workspace settings .claude/ + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/documentations/refactor/16-phase-8-analytics-visuals.md b/documentations/refactor/16-phase-8-analytics-visuals.md new file mode 100644 index 0000000..48a4a76 --- /dev/null +++ b/documentations/refactor/16-phase-8-analytics-visuals.md @@ -0,0 +1,65 @@ +# Phase 8 Analytics Visuals + +## Purpose + +Phase 7 added the backend data foundation for Race Hub analytics: stints, pit +stops, positions, race control, weather, and laps now flow through the local +SQLite store, ingestion, query layer, and `/api/v1/race-hub`. + +Phase 8 returns to frontend work. The goal is to replace the Strategy and +Position placeholder states with useful, production-minded views that consume +the real local-first analytics arrays now present in the Race Hub payload. + +## Scope + +Build the first real analytics views for: + +- race strategy from stints and pit stops; +- position evolution from position samples; +- lightweight supporting context from race control, weather, and laps where it + improves the view without making the screen noisy. + +The work should stay inside the React Race Hub surface. Do not redesign the +whole application shell in this phase. + +## Frontend Work + +Expected changes: + +- pass `stints`, `pit_stops`, `positions`, `race_control`, `weather`, and `laps` + into the relevant Race Hub components; +- replace "chart not yet implemented" placeholders with real visual treatment; +- preserve honest missing-data states for sessions that only have core datasets; +- keep the design dense, technical, and F1-native; +- add focused component/unit tests for available and missing analytics data; +- update Playwright coverage so seeded analytics views prove the real data path + works. + +## Visual Direction + +Prefer timing-wall clarity over dashboard decoration: + +- stint bars should be compact and scan-friendly; +- team colors should identify drivers without overpowering compound colors; +- compound colors should be disciplined and legible; +- position evolution should make gain/loss and driver comparison obvious; +- avoid decorative cards, giant empty panels, vague gradients, and generic SaaS + chart chrome. + +## Guardrails + +- Do not fetch OpenF1 directly from React. +- Do not add a heavy charting library unless the local interaction genuinely + needs it; SVG/CSS is enough for this first slice. +- Do not hide missing datasets behind fake mock data in runtime views. +- Keep mobile and iPad layouts usable, not just desktop-polished. +- Keep backend changes out of scope unless a clear API bug is discovered. + +## Acceptance Criteria + +- Strategy tab renders real stint/pit information when analytics data exists. +- Position tab renders real position information when position samples exist. +- Missing-data sessions still show clear unavailable states. +- Existing Race Hub views keep working. +- Frontend tests and build pass. +- Playwright Race Hub e2e passes against the seeded local database. diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md index 861bb38..7a48099 100644 --- a/documentations/refactor/README.md +++ b/documentations/refactor/README.md @@ -63,8 +63,11 @@ not implementation tickets yet. next frontend slice for strategy, position, and richer Race Hub views. - [15 Phase 7 Analytics Data Foundation](15-phase-7-analytics-data-foundation.md): backend slice for laps, stints, pits, race control, weather, and positions. -- [Cursor Phase 7 Prompt](cursor-phase-7-analytics-data-foundation-prompt.md): - current handoff prompt for the next Cursor backend phase. +- [16 Phase 8 Analytics Visuals](16-phase-8-analytics-visuals.md): frontend + slice for turning the newly available analytics datasets into useful Race Hub + views. +- [Claude Phase 8 Prompt](claude-phase-8-analytics-visuals-prompt.md): current + handoff prompt for the next Claude frontend phase. ## External References diff --git a/documentations/refactor/claude-phase-8-analytics-visuals-prompt.md b/documentations/refactor/claude-phase-8-analytics-visuals-prompt.md new file mode 100644 index 0000000..3b834c3 --- /dev/null +++ b/documentations/refactor/claude-phase-8-analytics-visuals-prompt.md @@ -0,0 +1,105 @@ +# Prompt For Claude: Phase 8 Analytics Visuals + +You are working in the `box-box` repository as the frontend/UI engineer for +Phase 8. The backend data foundation is now in place. Your task is to turn the +Race Hub Strategy and Position tabs from placeholders into real, useful +frontend views powered by `/api/v1/race-hub`. + +## Context + +Read these docs first: + +- `documentations/refactor/README.md` +- `documentations/refactor/14-phase-6-react-race-hub-analytics.md` +- `documentations/refactor/15-phase-7-analytics-data-foundation.md` +- `documentations/refactor/16-phase-8-analytics-visuals.md` +- `documentations/refactor/06-visual-design-direction.md` + +The current React app lives in `frontend/`. The backend Race Hub payload now +includes: + +- `stints` +- `pit_stops` +- `positions` +- `race_control` +- `weather` +- `laps` +- dataset metadata under `datasets` + +There is also a deterministic Playwright seed at +`scripts/seed-e2e-db/main.go` and e2e coverage in `tests/race-hub.spec.ts`. + +## Objective + +Replace the "chart not yet implemented" states in: + +- `frontend/src/components/StrategyView.tsx` +- `frontend/src/components/PositionEvolutionView.tsx` + +with real views that consume the analytics arrays from the Race Hub response. + +## Product Expectations + +Strategy should show, at minimum: + +- per-driver stint bars; +- compound labels/colors; +- lap ranges; +- pit stop markers or nearby pit stop context; +- a compact fallback table if the viewport is narrow. + +Position Evolution should show, at minimum: + +- per-driver position progression from position samples; +- grid-to-finish context when results and grid are present; +- clear gain/loss language; +- enough labeling that the view is understandable without a legend-heavy mess. + +Use SVG/CSS for the first implementation unless you have a strong reason to add +a charting library. This phase is about a high-quality first native view, not a +large dependency decision. + +## Design Direction + +Keep it F1-native and operational: + +- dense but readable; +- restrained surfaces; +- strong typographic hierarchy; +- team color as identity, compound color as data; +- no decorative gradient blobs; +- no generic SaaS dashboard cards everywhere; +- no fake runtime mock data. + +## Implementation Notes + +- Update `RaceHubPage.tsx` to pass the new arrays into the components. +- Use the existing `frontend/src/types.ts` contracts. +- Preserve missing-data states for session `9000` in the e2e seed. +- Update component tests or add new tests where the logic deserves coverage. +- Update Playwright tests so they assert real analytics UI for seeded session + `9472`, not placeholder text. +- If you discover a backend contract issue, document it clearly instead of + silently working around it in the UI. + +## Verification + +Run: + +```bash +npm test -- --run +npm run build +cd .. && npm run test:e2e +``` + +The e2e command starts a seeded local database and local web/API servers. It +should not require OpenF1 network access. + +## Deliverable + +Implement the Phase 8 frontend slice and report: + +- files changed; +- key UI behavior added; +- tests run and results; +- any follow-up risks or design refinements you recommend. diff --git a/documentations/refactor/cursor-phase-7-analytics-data-foundation-prompt.md b/documentations/refactor/cursor-phase-7-analytics-data-foundation-prompt.md deleted file mode 100644 index d497ee8..0000000 --- a/documentations/refactor/cursor-phase-7-analytics-data-foundation-prompt.md +++ /dev/null @@ -1,72 +0,0 @@ -# Cursor Prompt: Phase 7 Analytics Data Foundation - -You are working in the `box-box` repository. - -Phase 6 added React Race Hub analytics tabs, but Strategy and Position Evolution -still show honest missing states because the backend does not expose stints or -position samples in `/api/v1/race-hub`. - -Your task is Phase 7: expand the local-first backend data foundation for Race -Hub analytics. - -## Read First - -- `CLAUDE.md` -- `documentations/refactor/15-phase-7-analytics-data-foundation.md` -- `internal/store/*` -- `internal/ingest/*` -- `internal/query/racehub.go` -- `internal/api/openf1.go` -- `internal/models/types.go` -- `frontend/src/components/StrategyView.tsx` -- `frontend/src/components/PositionEvolutionView.tsx` - -## Goal - -Add backend support for the datasets needed by strategy and position views, -prioritizing stints and positions. - -## Required Work - -1. Add a new SQLite migration for selected analytics tables. -2. Add store structs, upserts, and reads. -3. Extend session ingestion to fetch and store: - - stints; - - pit stops if straightforward; - - positions if volume is acceptable; - - race control and weather if scoped cleanly. -4. Store raw payloads for every fetched endpoint. -5. Extend `internal/query.RaceHub` with available analytics datasets. -6. Update dataset metadata counts. -7. Add offline tests with fake OpenF1 source data. - -## Guardrails - -- Do not fake frontend data. -- Do not fetch OpenF1 from React. -- Do not add high-volume car telemetry. -- Keep migrations idempotent. -- Keep existing Phase 5/6 React behavior working. - -## Verification - -Run: - -```bash -go test ./internal/store/... ./internal/ingest/... ./internal/query/... ./internal/web/... -go build -o /tmp/box-box ./cmd/main.go -cd frontend && npm test -- --run && npm run build -``` - -If `go test ./...` fails only on OpenF1 network integration tests, report it as -unrelated. - -## Final Response - -Report: - -- tables added; -- datasets ingested; -- Race Hub API fields added; -- tests/builds run; -- whether frontend Strategy/Position tabs now have real data available. diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 202fc6c..6bb2803 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -81,4 +81,77 @@ export interface RaceHub { drivers: Driver[] results: EnrichedResult[] starting_grid: EnrichedGrid[] + stints: Stint[] + pit_stops: PitStop[] + positions: PositionSample[] + race_control: RaceControlMessage[] + weather: WeatherSample[] + laps: Lap[] +} + +export interface Stint { + session_key: number + driver_number: number + meeting_key: number + stint_number: number + compound: string + lap_start: number + lap_end: number + tyre_age_at_start: number +} + +export interface PitStop { + session_key: number + driver_number: number + meeting_key: number + lap_number: number + date: string + pit_duration: number + lane_duration: number + stop_duration: number +} + +export interface PositionSample { + session_key: number + driver_number: number + meeting_key: number + date: string + position: number +} + +export interface RaceControlMessage { + session_key: number + meeting_key: number + date: string + category: string + flag: string + message: string + scope: string + driver_number: number | null + lap_number: number | null + sector: number | null + qualifying_phase: number | null +} + +export interface WeatherSample { + session_key: number + meeting_key: number + date: string + air_temperature: number + track_temperature: number + humidity: number + pressure: number + rainfall: number + wind_direction: number + wind_speed: number +} + +export interface Lap { + session_key: number + driver_number: number + meeting_key: number + lap_number: number + date_start: string + lap_duration: number | null + is_pit_out_lap: boolean } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c738cce..7432925 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ port: process.env.PORT ? parseInt(process.env.PORT) : 5173, proxy: { '/api': { - target: 'http://localhost:8080', + target: `http://localhost:${process.env.BOXBOX_API_PORT ?? '8080'}`, changeOrigin: true, }, }, diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index d20b290..269549d 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -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" diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 44dfeb9..e5496c1 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -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) } } diff --git a/internal/ingest/openf1.go b/internal/ingest/openf1.go index a339a43..1103c2d 100644 --- a/internal/ingest/openf1.go +++ b/internal/ingest/openf1.go @@ -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 "" diff --git a/internal/query/convert.go b/internal/query/convert.go index 11417e5..8d6a971 100644 --- a/internal/query/convert.go +++ b/internal/query/convert.go @@ -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 +} diff --git a/internal/query/query_test.go b/internal/query/query_test.go index 5b55cdb..137a2b3 100644 --- a/internal/query/query_test.go +++ b/internal/query/query_test.go @@ -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) diff --git a/internal/query/racehub.go b/internal/query/racehub.go index c62f716..3f0b4df 100644 --- a/internal/query/racehub.go +++ b/internal/query/racehub.go @@ -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 } diff --git a/internal/store/analytics.go b/internal/store/analytics.go new file mode 100644 index 0000000..9d2cdb8 --- /dev/null +++ b/internal/store/analytics.go @@ -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() +} diff --git a/internal/store/migrations/002_analytics.sql b/internal/store/migrations/002_analytics.sql new file mode 100644 index 0000000..ba02ef9 --- /dev/null +++ b/internal/store/migrations/002_analytics.sql @@ -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); diff --git a/internal/store/models.go b/internal/store/models.go index 65a4710..30f1619 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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 +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a6ae5eb..bcb310d 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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) diff --git a/internal/web/server.go b/internal/web/server.go index bf9cd5e..c8f0ac5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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))) } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1d05367 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "box-box", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "box-box", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/node": "^25.9.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..13853c0 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "box-box", + "version": "0.1.0", + "private": true, + "description": "Repository-level test tooling for box-box.", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report" + }, + "type": "commonjs", + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/node": "^25.9.1" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..7fe9c28 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from '@playwright/test' + +const E2E_DB = '.playwright/boxbox-e2e.db' +const API_PORT = process.env.BOXBOX_API_PORT ?? '18080' +const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173' + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'github' : 'html', + use: { + baseURL: `http://localhost:${WEB_PORT}`, + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: [ + { + command: `go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db ${E2E_DB} --port ${API_PORT}`, + url: `http://localhost:${API_PORT}/api/v1/race-hub?session_key=9472`, + reuseExistingServer: false, + timeout: 120_000, + }, + { + command: `BOXBOX_API_PORT=${API_PORT} npm run dev --prefix frontend -- --port ${WEB_PORT} --strictPort`, + url: `http://localhost:${WEB_PORT}`, + reuseExistingServer: false, + timeout: 120_000, + }, + ], +}) diff --git a/scripts/seed-e2e-db/main.go b/scripts/seed-e2e-db/main.go new file mode 100644 index 0000000..9789a69 --- /dev/null +++ b/scripts/seed-e2e-db/main.go @@ -0,0 +1,219 @@ +// seed-e2e-db creates a deterministic SQLite database for Playwright e2e tests. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/AmanTahiliani/box-box/internal/store" +) + +func main() { + dbPath := flag.String("db", ".playwright/boxbox-e2e.db", "path to e2e database file") + flag.Parse() + + if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "mkdir: %v\n", err) + os.Exit(1) + } + _ = os.Remove(*dbPath) + + st, err := store.Open(*dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "open db: %v\n", err) + os.Exit(1) + } + defer st.Close() + + const meetingKey = 1229 + const fullSessionKey = 9472 + const coreOnlySessionKey = 9000 + + if err := seedMeeting(st, meetingKey); err != nil { + fail(err) + } + if err := seedSession(st, fullSessionKey, meetingKey, "Race"); err != nil { + fail(err) + } + if err := seedSession(st, coreOnlySessionKey, meetingKey, "Core Only"); err != nil { + fail(err) + } + + if err := seedDrivers(st, fullSessionKey, meetingKey); err != nil { + fail(err) + } + if err := seedDrivers(st, coreOnlySessionKey, meetingKey); err != nil { + fail(err) + } + + if err := seedCoreResults(st, fullSessionKey, meetingKey); err != nil { + fail(err) + } + if err := seedCoreResults(st, coreOnlySessionKey, meetingKey); err != nil { + fail(err) + } + + if err := seedAnalytics(st, fullSessionKey, meetingKey); err != nil { + fail(err) + } + + fmt.Printf("seeded e2e db at %s\n", *dbPath) +} + +func fail(err error) { + fmt.Fprintf(os.Stderr, "seed error: %v\n", err) + os.Exit(1) +} + +func seedMeeting(st *store.Store, meetingKey int) error { + return st.UpsertMeeting(store.Meeting{ + MeetingKey: meetingKey, + MeetingName: "Monaco", + MeetingOfficialName: "FORMULA 1 GRAND PRIX DE MONACO 2025", + Location: "Monaco", + CountryCode: "MON", + CountryName: "Monaco", + CircuitKey: 10, + CircuitShortName: "Monaco", + Year: 2025, + DateStart: "2025-05-23T00:00:00+00:00", + DateEnd: "2025-05-25T00:00:00+00:00", + }) +} + +func seedSession(st *store.Store, sessionKey, meetingKey int, name string) error { + return st.UpsertSession(store.Session{ + SessionKey: sessionKey, + MeetingKey: meetingKey, + SessionName: name, + SessionType: "Race", + CircuitKey: 10, + DateStart: "2025-05-25T13:00:00+00:00", + DateEnd: "2025-05-25T15:00:00+00:00", + }) +} + +func seedDrivers(st *store.Store, sessionKey, meetingKey int) error { + drivers := []store.Driver{ + { + DriverNumber: 1, + FullName: "Max Verstappen", + NameAcronym: "VER", + TeamName: "Red Bull Racing", + TeamColour: "3671C6", + }, + { + DriverNumber: 44, + FullName: "Lewis Hamilton", + NameAcronym: "HAM", + TeamName: "Ferrari", + TeamColour: "E8002D", + }, + } + for _, d := range drivers { + if err := st.UpsertDriver(d); err != nil { + return err + } + if err := st.UpsertSessionDriver(store.SessionDriver{ + SessionKey: sessionKey, + DriverNumber: d.DriverNumber, + MeetingKey: meetingKey, + TeamName: d.TeamName, + TeamColour: d.TeamColour, + }); err != nil { + return err + } + } + return nil +} + +func seedCoreResults(st *store.Store, sessionKey, meetingKey int) error { + results := []store.SessionResult{ + { + SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, + Position: 1, Points: 25, NumberOfLaps: 78, + }, + { + SessionKey: sessionKey, DriverNumber: 44, MeetingKey: meetingKey, + Position: 2, Points: 18, NumberOfLaps: 78, + }, + } + for _, r := range results { + if err := st.UpsertSessionResult(r); err != nil { + return err + } + } + + grid := []store.StartingGridEntry{ + {SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Position: 1, LapDuration: 71.234}, + {SessionKey: sessionKey, DriverNumber: 44, MeetingKey: meetingKey, Position: 2, LapDuration: 71.456}, + } + for _, g := range grid { + if err := st.UpsertStartingGridEntry(g); err != nil { + return err + } + } + return nil +} + +func seedAnalytics(st *store.Store, sessionKey, meetingKey int) error { + stints := []store.Stint{ + { + SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, + StintNumber: 1, Compound: "MEDIUM", LapStart: 1, LapEnd: 30, + }, + { + SessionKey: sessionKey, DriverNumber: 44, MeetingKey: meetingKey, + StintNumber: 1, Compound: "SOFT", LapStart: 1, LapEnd: 18, + }, + } + for _, stnt := range stints { + if err := st.UpsertStint(stnt); err != nil { + return err + } + } + + if err := st.UpsertPitStop(store.PitStop{ + SessionKey: sessionKey, DriverNumber: 44, MeetingKey: meetingKey, + LapNumber: 19, Date: "2025-05-25T14:00:00+00:00", StopDuration: 2.4, + }); err != nil { + return err + } + + positions := []store.PositionSample{ + {SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00+00:00", Position: 1}, + {SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:10:00+00:00", Position: 1}, + {SessionKey: sessionKey, DriverNumber: 44, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00+00:00", Position: 2}, + } + for _, p := range positions { + if err := st.UpsertPositionSample(p); err != nil { + return err + } + } + + if err := st.UpsertRaceControlMessage(store.RaceControlMessage{ + SessionKey: sessionKey, MeetingKey: meetingKey, + Date: "2025-05-25T13:01:00+00:00", Category: "Flag", Flag: "GREEN", + Message: "Green light", Scope: "Track", + }); err != nil { + return err + } + + if err := st.UpsertWeatherSample(store.WeatherSample{ + SessionKey: sessionKey, MeetingKey: meetingKey, + Date: "2025-05-25T13:00:00+00:00", AirTemperature: 22.0, TrackTemperature: 34.0, + }); err != nil { + return err + } + + if err := st.UpsertLap(store.Lap{ + SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, + LapNumber: 1, LapDuration: 75.1, + }); err != nil { + return err + } + + return nil +} diff --git a/tests/race-hub.spec.ts b/tests/race-hub.spec.ts new file mode 100644 index 0000000..16e0ab2 --- /dev/null +++ b/tests/race-hub.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '@playwright/test' + +const FULL_SESSION = 9472 +const CORE_ONLY_SESSION = 9000 + +test.describe('Race Hub', () => { + test('loads classification for a seeded session', async ({ page }) => { + await page.goto(`/race-hub?session_key=${FULL_SESSION}`) + + await expect(page.getByText('Final Classification')).toBeVisible() + await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible() + await expect(page.locator('.drv-code', { hasText: 'HAM' })).toBeVisible() + }) + + test('strategy tab shows chart placeholder when stints are available', async ({ page }) => { + await page.goto(`/race-hub?session_key=${FULL_SESSION}`) + await page.getByRole('tab', { name: 'Strategy' }).click() + + await expect(page.getByText('Strategy chart: not yet implemented.')).toBeVisible() + await expect(page.getByText('Stints not available.')).not.toBeVisible() + }) + + test('positions tab shows chart placeholder when positions are available', async ({ page }) => { + await page.goto(`/race-hub?session_key=${FULL_SESSION}`) + await page.getByRole('tab', { name: 'Positions' }).click() + + await expect(page.getByText('Position evolution chart: not yet implemented.')).toBeVisible() + await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible() + }) + + test('strategy tab shows missing notice when stints are unavailable', async ({ page }) => { + await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) + await page.getByRole('tab', { name: 'Strategy' }).click() + + await expect(page.getByText('Stints not available.')).toBeVisible() + await expect(page.getByText('Strategy chart: not yet implemented.')).not.toBeVisible() + }) + + test('positions tab shows missing notice when positions are unavailable', async ({ page }) => { + await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) + await page.getByRole('tab', { name: 'Positions' }).click() + + await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible() + await expect(page.getByText('Position evolution chart: not yet implemented.')).not.toBeVisible() + }) +})