mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Add analytics data foundation
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -23,3 +23,11 @@ frontend/dist/
|
|||||||
|
|
||||||
# Local Claude workspace settings
|
# Local Claude workspace settings
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
/playwright/.auth/
|
||||||
|
|||||||
65
documentations/refactor/16-phase-8-analytics-visuals.md
Normal file
65
documentations/refactor/16-phase-8-analytics-visuals.md
Normal file
@@ -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.
|
||||||
@@ -63,8 +63,11 @@ not implementation tickets yet.
|
|||||||
next frontend slice for strategy, position, and richer Race Hub views.
|
next frontend slice for strategy, position, and richer Race Hub views.
|
||||||
- [15 Phase 7 Analytics Data Foundation](15-phase-7-analytics-data-foundation.md):
|
- [15 Phase 7 Analytics Data Foundation](15-phase-7-analytics-data-foundation.md):
|
||||||
backend slice for laps, stints, pits, race control, weather, and positions.
|
backend slice for laps, stints, pits, race control, weather, and positions.
|
||||||
- [Cursor Phase 7 Prompt](cursor-phase-7-analytics-data-foundation-prompt.md):
|
- [16 Phase 8 Analytics Visuals](16-phase-8-analytics-visuals.md): frontend
|
||||||
current handoff prompt for the next Cursor backend phase.
|
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
|
## External References
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
|
||||||
@@ -81,4 +81,77 @@ export interface RaceHub {
|
|||||||
drivers: Driver[]
|
drivers: Driver[]
|
||||||
results: EnrichedResult[]
|
results: EnrichedResult[]
|
||||||
starting_grid: EnrichedGrid[]
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
|||||||
port: process.env.PORT ? parseInt(process.env.PORT) : 5173,
|
port: process.env.PORT ? parseInt(process.env.PORT) : 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8080',
|
target: `http://localhost:${process.env.BOXBOX_API_PORT ?? '8080'}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ type Summary struct {
|
|||||||
Drivers int `json:"drivers"`
|
Drivers int `json:"drivers"`
|
||||||
SessionResults int `json:"session_results"`
|
SessionResults int `json:"session_results"`
|
||||||
StartingGrid int `json:"starting_grid"`
|
StartingGrid int `json:"starting_grid"`
|
||||||
|
Stints int `json:"stints"`
|
||||||
|
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"`
|
RawPayloads int `json:"raw_payloads"`
|
||||||
RawInserted int `json:"raw_inserted"`
|
RawInserted int `json:"raw_inserted"`
|
||||||
Errors []string `json:"errors,omitempty"`
|
Errors []string `json:"errors,omitempty"`
|
||||||
@@ -363,6 +369,26 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
|
|||||||
} else {
|
} else {
|
||||||
summary.StartingGrid = len(grid)
|
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)
|
summary.Status = statusForDryRun(s.opts.DryRun)
|
||||||
s.finishRun(runID, summary)
|
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 {
|
func statusForDryRun(dryRun bool) string {
|
||||||
if dryRun {
|
if dryRun {
|
||||||
return "dry_run"
|
return "dry_run"
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ type fakeSource struct {
|
|||||||
drivers map[int][]models.Driver
|
drivers map[int][]models.Driver
|
||||||
results map[int][]models.SessionResult
|
results map[int][]models.SessionResult
|
||||||
grid map[int][]models.StartingGrid
|
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
|
failOn string
|
||||||
liveLockout bool
|
liveLockout bool
|
||||||
}
|
}
|
||||||
@@ -34,6 +40,12 @@ func newFakeSource() *fakeSource {
|
|||||||
drivers: make(map[int][]models.Driver),
|
drivers: make(map[int][]models.Driver),
|
||||||
results: make(map[int][]models.SessionResult),
|
results: make(map[int][]models.SessionResult),
|
||||||
grid: make(map[int][]models.StartingGrid),
|
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
|
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 {
|
func openTestStore(t *testing.T) *store.Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(t.TempDir(), "ingest.db")
|
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: 1, Position: 1, LapDuration: 71.234},
|
||||||
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, LapDuration: 71.456},
|
{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
|
return meetingKey, sessionKey, src
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptrFloat64(v float64) *float64 {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
|
func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
|
||||||
_, sessionKey, src := testSessionFixtures()
|
_, sessionKey, src := testSessionFixtures()
|
||||||
st := openTestStore(t)
|
st := openTestStore(t)
|
||||||
@@ -198,8 +283,11 @@ func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
|
|||||||
if summary.Drivers != 2 || summary.SessionResults != 2 || summary.StartingGrid != 2 {
|
if summary.Drivers != 2 || summary.SessionResults != 2 || summary.StartingGrid != 2 {
|
||||||
t.Fatalf("summary counts = %+v, want 2 drivers/results/grid", summary)
|
t.Fatalf("summary counts = %+v, want 2 drivers/results/grid", summary)
|
||||||
}
|
}
|
||||||
if summary.RawPayloads != 5 {
|
if summary.Stints != 2 || summary.PitStops != 1 || summary.Positions != 3 {
|
||||||
t.Fatalf("summary.RawPayloads = %d, want 5", summary.RawPayloads)
|
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)
|
drivers, err := st.ListSessionDrivers(sessionKey)
|
||||||
@@ -230,8 +318,17 @@ func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListRawPayloadsBySession() error = %v", err)
|
t.Fatalf("ListRawPayloadsBySession() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(raw) != 5 {
|
if len(raw) != 11 {
|
||||||
t.Fatalf("raw payloads = %d, want 5", len(raw))
|
t.Fatalf("raw payloads = %d, want 11", len(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
stints, err := st.ListStints(sessionKey)
|
||||||
|
if err != nil || len(stints) != 2 {
|
||||||
|
t.Fatalf("stints = %+v, err = %v, want 2", stints, err)
|
||||||
|
}
|
||||||
|
positions, err := st.ListPositionSamples(sessionKey)
|
||||||
|
if err != nil || len(positions) != 3 {
|
||||||
|
t.Fatalf("positions = %+v, err = %v, want 3", positions, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ type Source interface {
|
|||||||
FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error)
|
FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error)
|
||||||
FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error)
|
FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error)
|
||||||
FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, 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.
|
// 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))
|
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) {
|
func (s *OpenF1Source) fetchMeetings(url, endpoint, requestKey string) (FetchResult, []models.Meeting, error) {
|
||||||
body, err := s.client.FetchStrict(url)
|
body, err := s.client.FetchStrict(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,6 +203,114 @@ func (s *OpenF1Source) fetchStartingGrid(url, endpoint, requestKey string) (Fetc
|
|||||||
}, result, nil
|
}, 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 {
|
func meetingToStore(m models.Meeting) store.Meeting {
|
||||||
return store.Meeting{
|
return store.Meeting{
|
||||||
MeetingKey: int(m.MeetingKey),
|
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 {
|
func jsonField(v any) string {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -99,3 +99,102 @@ func parseJSONValue(raw string) interface{} {
|
|||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stintToModel(st store.Stint) models.Stint {
|
||||||
|
return models.Stint{
|
||||||
|
SessionKey: st.SessionKey,
|
||||||
|
DriverNumber: st.DriverNumber,
|
||||||
|
MeetingKey: st.MeetingKey,
|
||||||
|
StintNumber: st.StintNumber,
|
||||||
|
Compound: models.TyreCompound(st.Compound),
|
||||||
|
LapStart: st.LapStart,
|
||||||
|
LapEnd: st.LapEnd,
|
||||||
|
TyreAgeAtStart: st.TyreAgeAtStart,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pitStopToModel(p store.PitStop) models.Pit {
|
||||||
|
stopDuration := p.StopDuration
|
||||||
|
if stopDuration == 0 {
|
||||||
|
stopDuration = p.PitDuration
|
||||||
|
}
|
||||||
|
return models.Pit{
|
||||||
|
SessionKey: p.SessionKey,
|
||||||
|
DriverNumber: p.DriverNumber,
|
||||||
|
MeetingKey: p.MeetingKey,
|
||||||
|
LapNumber: p.LapNumber,
|
||||||
|
Date: p.Date,
|
||||||
|
PitDuration: p.PitDuration,
|
||||||
|
LaneDuration: p.LaneDuration,
|
||||||
|
StopDuration: stopDuration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func positionToModel(p store.PositionSample) models.Position {
|
||||||
|
return models.Position{
|
||||||
|
SessionKey: p.SessionKey,
|
||||||
|
DriverNumber: p.DriverNumber,
|
||||||
|
MeetingKey: p.MeetingKey,
|
||||||
|
Date: p.Date,
|
||||||
|
Position: p.Position,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func raceControlToModel(rc store.RaceControlMessage) models.RaceControl {
|
||||||
|
return models.RaceControl{
|
||||||
|
SessionKey: rc.SessionKey,
|
||||||
|
MeetingKey: rc.MeetingKey,
|
||||||
|
Date: rc.Date,
|
||||||
|
Category: models.RaceControlCategory(rc.Category),
|
||||||
|
Flag: models.Flag(rc.Flag),
|
||||||
|
Message: rc.Message,
|
||||||
|
Scope: rc.Scope,
|
||||||
|
DriverNumber: rc.DriverNumber,
|
||||||
|
LapNumber: rc.LapNumber,
|
||||||
|
Sector: rc.Sector,
|
||||||
|
QualifyingPhase: rc.QualifyingPhase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func weatherToModel(w store.WeatherSample) models.Weather {
|
||||||
|
return models.Weather{
|
||||||
|
SessionKey: w.SessionKey,
|
||||||
|
MeetingKey: w.MeetingKey,
|
||||||
|
Date: w.Date,
|
||||||
|
AirTemperature: w.AirTemperature,
|
||||||
|
TrackTemperature: w.TrackTemperature,
|
||||||
|
Humidity: w.Humidity,
|
||||||
|
Pressure: w.Pressure,
|
||||||
|
Rainfall: w.Rainfall,
|
||||||
|
WindDirection: w.WindDirection,
|
||||||
|
WindSpeed: w.WindSpeed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lapToModel(l store.Lap) models.Lap {
|
||||||
|
lap := models.Lap{
|
||||||
|
SessionKey: l.SessionKey,
|
||||||
|
DriverNumber: l.DriverNumber,
|
||||||
|
MeetingKey: l.MeetingKey,
|
||||||
|
LapNumber: l.LapNumber,
|
||||||
|
DateStart: l.DateStart,
|
||||||
|
IsPitOutLap: l.IsPitOutLap,
|
||||||
|
}
|
||||||
|
if l.LapDuration != 0 {
|
||||||
|
d := l.LapDuration
|
||||||
|
lap.LapDuration = &d
|
||||||
|
}
|
||||||
|
if l.DurationSector1 != 0 {
|
||||||
|
d := l.DurationSector1
|
||||||
|
lap.DurationSector1 = &d
|
||||||
|
}
|
||||||
|
if l.DurationSector2 != 0 {
|
||||||
|
d := l.DurationSector2
|
||||||
|
lap.DurationSector2 = &d
|
||||||
|
}
|
||||||
|
if l.DurationSector3 != 0 {
|
||||||
|
d := l.DurationSector3
|
||||||
|
lap.DurationSector3 = &d
|
||||||
|
}
|
||||||
|
return lap
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/store"
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -150,8 +151,8 @@ func TestGetRaceHubCompleteData(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetRaceHub() error = %v", err)
|
t.Fatalf("GetRaceHub() error = %v", err)
|
||||||
}
|
}
|
||||||
if hub.Source != ResponseSourceLocal {
|
if hub.Source != ResponseSourcePartial {
|
||||||
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourceLocal)
|
t.Fatalf("Source = %q, want %q (core datasets complete, analytics missing)", hub.Source, ResponseSourcePartial)
|
||||||
}
|
}
|
||||||
if len(hub.Results) != 1 {
|
if len(hub.Results) != 1 {
|
||||||
t.Fatalf("Results len = %d, want 1", len(hub.Results))
|
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) {
|
func TestListDriversRequiresSession(t *testing.T) {
|
||||||
svc := openTestService(t)
|
svc := openTestService(t)
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ type RaceHub struct {
|
|||||||
Drivers []models.Driver `json:"drivers"`
|
Drivers []models.Driver `json:"drivers"`
|
||||||
Results []EnrichedResult `json:"results"`
|
Results []EnrichedResult `json:"results"`
|
||||||
StartingGrid []EnrichedGrid `json:"starting_grid"`
|
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.
|
// 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(),
|
"drivers": missingDataset(),
|
||||||
"results": missingDataset(),
|
"results": missingDataset(),
|
||||||
"starting_grid": missingDataset(),
|
"starting_grid": missingDataset(),
|
||||||
|
"stints": missingDataset(),
|
||||||
|
"pit_stops": missingDataset(),
|
||||||
|
"positions": missingDataset(),
|
||||||
|
"race_control": missingDataset(),
|
||||||
|
"weather": missingDataset(),
|
||||||
|
"laps": missingDataset(),
|
||||||
},
|
},
|
||||||
Drivers: []models.Driver{},
|
Drivers: []models.Driver{},
|
||||||
Results: []EnrichedResult{},
|
Results: []EnrichedResult{},
|
||||||
StartingGrid: []EnrichedGrid{},
|
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)
|
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))
|
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)
|
hub.Source = responseSource(hub.Datasets)
|
||||||
return hub, nil
|
return hub, nil
|
||||||
}
|
}
|
||||||
|
|||||||
450
internal/store/analytics.go
Normal file
450
internal/store/analytics.go
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpsertStint inserts or updates a stint row.
|
||||||
|
func (s *Store) UpsertStint(st Stint) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO stints (
|
||||||
|
session_key, driver_number, meeting_key, stint_number,
|
||||||
|
compound, lap_start, lap_end, tyre_age_at_start
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_key, driver_number, stint_number) DO UPDATE SET
|
||||||
|
meeting_key = excluded.meeting_key,
|
||||||
|
compound = excluded.compound,
|
||||||
|
lap_start = excluded.lap_start,
|
||||||
|
lap_end = excluded.lap_end,
|
||||||
|
tyre_age_at_start = excluded.tyre_age_at_start
|
||||||
|
`,
|
||||||
|
st.SessionKey,
|
||||||
|
st.DriverNumber,
|
||||||
|
st.MeetingKey,
|
||||||
|
st.StintNumber,
|
||||||
|
st.Compound,
|
||||||
|
st.LapStart,
|
||||||
|
st.LapEnd,
|
||||||
|
st.TyreAgeAtStart,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert stint: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStints returns stints for a session ordered by driver and stint number.
|
||||||
|
func (s *Store) ListStints(sessionKey int) ([]Stint, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT session_key, driver_number, meeting_key, stint_number,
|
||||||
|
compound, lap_start, lap_end, tyre_age_at_start
|
||||||
|
FROM stints
|
||||||
|
WHERE session_key = ?
|
||||||
|
ORDER BY driver_number ASC, stint_number ASC
|
||||||
|
`, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Stint
|
||||||
|
for rows.Next() {
|
||||||
|
var st Stint
|
||||||
|
if err := rows.Scan(
|
||||||
|
&st.SessionKey,
|
||||||
|
&st.DriverNumber,
|
||||||
|
&st.MeetingKey,
|
||||||
|
&st.StintNumber,
|
||||||
|
&st.Compound,
|
||||||
|
&st.LapStart,
|
||||||
|
&st.LapEnd,
|
||||||
|
&st.TyreAgeAtStart,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertPitStop inserts or updates a pit stop row.
|
||||||
|
func (s *Store) UpsertPitStop(p PitStop) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO pit_stops (
|
||||||
|
session_key, driver_number, meeting_key, lap_number, date,
|
||||||
|
pit_duration, lane_duration, stop_duration
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_key, driver_number, date) DO UPDATE SET
|
||||||
|
meeting_key = excluded.meeting_key,
|
||||||
|
lap_number = excluded.lap_number,
|
||||||
|
pit_duration = excluded.pit_duration,
|
||||||
|
lane_duration = excluded.lane_duration,
|
||||||
|
stop_duration = excluded.stop_duration
|
||||||
|
`,
|
||||||
|
p.SessionKey,
|
||||||
|
p.DriverNumber,
|
||||||
|
p.MeetingKey,
|
||||||
|
p.LapNumber,
|
||||||
|
p.Date,
|
||||||
|
nullableZeroFloat(p.PitDuration),
|
||||||
|
nullableZeroFloat(p.LaneDuration),
|
||||||
|
nullableZeroFloat(p.StopDuration),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert pit stop: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPitStops returns pit stops for a session ordered by date.
|
||||||
|
func (s *Store) ListPitStops(sessionKey int) ([]PitStop, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT session_key, driver_number, meeting_key, lap_number, date,
|
||||||
|
pit_duration, lane_duration, stop_duration
|
||||||
|
FROM pit_stops
|
||||||
|
WHERE session_key = ?
|
||||||
|
ORDER BY date ASC, driver_number ASC
|
||||||
|
`, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []PitStop
|
||||||
|
for rows.Next() {
|
||||||
|
var p PitStop
|
||||||
|
var pitDuration, laneDuration, stopDuration sql.NullFloat64
|
||||||
|
if err := rows.Scan(
|
||||||
|
&p.SessionKey,
|
||||||
|
&p.DriverNumber,
|
||||||
|
&p.MeetingKey,
|
||||||
|
&p.LapNumber,
|
||||||
|
&p.Date,
|
||||||
|
&pitDuration,
|
||||||
|
&laneDuration,
|
||||||
|
&stopDuration,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pitDuration.Valid {
|
||||||
|
p.PitDuration = pitDuration.Float64
|
||||||
|
}
|
||||||
|
if laneDuration.Valid {
|
||||||
|
p.LaneDuration = laneDuration.Float64
|
||||||
|
}
|
||||||
|
if stopDuration.Valid {
|
||||||
|
p.StopDuration = stopDuration.Float64
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertPositionSample inserts or updates a position sample row.
|
||||||
|
func (s *Store) UpsertPositionSample(p PositionSample) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO positions (
|
||||||
|
session_key, driver_number, meeting_key, date, position
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_key, driver_number, date) DO UPDATE SET
|
||||||
|
meeting_key = excluded.meeting_key,
|
||||||
|
position = excluded.position
|
||||||
|
`,
|
||||||
|
p.SessionKey,
|
||||||
|
p.DriverNumber,
|
||||||
|
p.MeetingKey,
|
||||||
|
p.Date,
|
||||||
|
p.Position,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert position: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPositionSamples returns position samples for a session ordered by date.
|
||||||
|
func (s *Store) ListPositionSamples(sessionKey int) ([]PositionSample, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT session_key, driver_number, meeting_key, date, position
|
||||||
|
FROM positions
|
||||||
|
WHERE session_key = ?
|
||||||
|
ORDER BY date ASC, driver_number ASC
|
||||||
|
`, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []PositionSample
|
||||||
|
for rows.Next() {
|
||||||
|
var p PositionSample
|
||||||
|
if err := rows.Scan(
|
||||||
|
&p.SessionKey,
|
||||||
|
&p.DriverNumber,
|
||||||
|
&p.MeetingKey,
|
||||||
|
&p.Date,
|
||||||
|
&p.Position,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertRaceControlMessage inserts or updates a race control message row.
|
||||||
|
func (s *Store) UpsertRaceControlMessage(rc RaceControlMessage) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO race_control (
|
||||||
|
session_key, meeting_key, date, category, flag, message, scope,
|
||||||
|
driver_number, lap_number, sector, qualifying_phase
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_key, date, message) DO UPDATE SET
|
||||||
|
meeting_key = excluded.meeting_key,
|
||||||
|
category = excluded.category,
|
||||||
|
flag = excluded.flag,
|
||||||
|
scope = excluded.scope,
|
||||||
|
driver_number = excluded.driver_number,
|
||||||
|
lap_number = excluded.lap_number,
|
||||||
|
sector = excluded.sector,
|
||||||
|
qualifying_phase = excluded.qualifying_phase
|
||||||
|
`,
|
||||||
|
rc.SessionKey,
|
||||||
|
rc.MeetingKey,
|
||||||
|
rc.Date,
|
||||||
|
rc.Category,
|
||||||
|
nullString(rc.Flag),
|
||||||
|
rc.Message,
|
||||||
|
nullString(rc.Scope),
|
||||||
|
nullableInt(rc.DriverNumber),
|
||||||
|
nullableInt(rc.LapNumber),
|
||||||
|
nullableInt(rc.Sector),
|
||||||
|
nullableInt(rc.QualifyingPhase),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert race control: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRaceControlMessages returns race control messages for a session.
|
||||||
|
func (s *Store) ListRaceControlMessages(sessionKey int) ([]RaceControlMessage, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT session_key, meeting_key, date, category, flag, message, scope,
|
||||||
|
driver_number, lap_number, sector, qualifying_phase
|
||||||
|
FROM race_control
|
||||||
|
WHERE session_key = ?
|
||||||
|
ORDER BY date ASC
|
||||||
|
`, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []RaceControlMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var rc RaceControlMessage
|
||||||
|
var flag, scope sql.NullString
|
||||||
|
var driverNumber, lapNumber, sector, qualifyingPhase sql.NullInt64
|
||||||
|
if err := rows.Scan(
|
||||||
|
&rc.SessionKey,
|
||||||
|
&rc.MeetingKey,
|
||||||
|
&rc.Date,
|
||||||
|
&rc.Category,
|
||||||
|
&flag,
|
||||||
|
&rc.Message,
|
||||||
|
&scope,
|
||||||
|
&driverNumber,
|
||||||
|
&lapNumber,
|
||||||
|
§or,
|
||||||
|
&qualifyingPhase,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rc.Flag = flag.String
|
||||||
|
rc.Scope = scope.String
|
||||||
|
rc.DriverNumber = nullIntPtr(driverNumber)
|
||||||
|
rc.LapNumber = nullIntPtr(lapNumber)
|
||||||
|
rc.Sector = nullIntPtr(sector)
|
||||||
|
rc.QualifyingPhase = nullIntPtr(qualifyingPhase)
|
||||||
|
out = append(out, rc)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertWeatherSample inserts or updates a weather sample row.
|
||||||
|
func (s *Store) UpsertWeatherSample(w WeatherSample) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO weather (
|
||||||
|
session_key, meeting_key, date, air_temperature, track_temperature,
|
||||||
|
humidity, pressure, rainfall, wind_direction, wind_speed
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_key, date) DO UPDATE SET
|
||||||
|
meeting_key = excluded.meeting_key,
|
||||||
|
air_temperature = excluded.air_temperature,
|
||||||
|
track_temperature = excluded.track_temperature,
|
||||||
|
humidity = excluded.humidity,
|
||||||
|
pressure = excluded.pressure,
|
||||||
|
rainfall = excluded.rainfall,
|
||||||
|
wind_direction = excluded.wind_direction,
|
||||||
|
wind_speed = excluded.wind_speed
|
||||||
|
`,
|
||||||
|
w.SessionKey,
|
||||||
|
w.MeetingKey,
|
||||||
|
w.Date,
|
||||||
|
nullableZeroFloat(w.AirTemperature),
|
||||||
|
nullableZeroFloat(w.TrackTemperature),
|
||||||
|
nullableZeroFloat(w.Humidity),
|
||||||
|
nullableZeroFloat(w.Pressure),
|
||||||
|
w.Rainfall,
|
||||||
|
nullableZeroInt(w.WindDirection),
|
||||||
|
nullableZeroFloat(w.WindSpeed),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert weather: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWeatherSamples returns weather samples for a session ordered by date.
|
||||||
|
func (s *Store) ListWeatherSamples(sessionKey int) ([]WeatherSample, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT session_key, meeting_key, date, air_temperature, track_temperature,
|
||||||
|
humidity, pressure, rainfall, wind_direction, wind_speed
|
||||||
|
FROM weather
|
||||||
|
WHERE session_key = ?
|
||||||
|
ORDER BY date ASC
|
||||||
|
`, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []WeatherSample
|
||||||
|
for rows.Next() {
|
||||||
|
var w WeatherSample
|
||||||
|
var airTemp, trackTemp, humidity, pressure, windSpeed sql.NullFloat64
|
||||||
|
var windDirection sql.NullInt64
|
||||||
|
if err := rows.Scan(
|
||||||
|
&w.SessionKey,
|
||||||
|
&w.MeetingKey,
|
||||||
|
&w.Date,
|
||||||
|
&airTemp,
|
||||||
|
&trackTemp,
|
||||||
|
&humidity,
|
||||||
|
&pressure,
|
||||||
|
&w.Rainfall,
|
||||||
|
&windDirection,
|
||||||
|
&windSpeed,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if airTemp.Valid {
|
||||||
|
w.AirTemperature = airTemp.Float64
|
||||||
|
}
|
||||||
|
if trackTemp.Valid {
|
||||||
|
w.TrackTemperature = trackTemp.Float64
|
||||||
|
}
|
||||||
|
if humidity.Valid {
|
||||||
|
w.Humidity = humidity.Float64
|
||||||
|
}
|
||||||
|
if pressure.Valid {
|
||||||
|
w.Pressure = pressure.Float64
|
||||||
|
}
|
||||||
|
if windDirection.Valid {
|
||||||
|
w.WindDirection = int(windDirection.Int64)
|
||||||
|
}
|
||||||
|
if windSpeed.Valid {
|
||||||
|
w.WindSpeed = windSpeed.Float64
|
||||||
|
}
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertLap inserts or updates a lap row.
|
||||||
|
func (s *Store) UpsertLap(l Lap) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO laps (
|
||||||
|
session_key, driver_number, meeting_key, lap_number, date_start,
|
||||||
|
lap_duration, is_pit_out_lap, duration_sector1, duration_sector2, duration_sector3
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_key, driver_number, lap_number) DO UPDATE SET
|
||||||
|
meeting_key = excluded.meeting_key,
|
||||||
|
date_start = excluded.date_start,
|
||||||
|
lap_duration = excluded.lap_duration,
|
||||||
|
is_pit_out_lap = excluded.is_pit_out_lap,
|
||||||
|
duration_sector1 = excluded.duration_sector1,
|
||||||
|
duration_sector2 = excluded.duration_sector2,
|
||||||
|
duration_sector3 = excluded.duration_sector3
|
||||||
|
`,
|
||||||
|
l.SessionKey,
|
||||||
|
l.DriverNumber,
|
||||||
|
l.MeetingKey,
|
||||||
|
l.LapNumber,
|
||||||
|
nullString(l.DateStart),
|
||||||
|
nullableZeroFloat(l.LapDuration),
|
||||||
|
boolInt(l.IsPitOutLap),
|
||||||
|
nullableZeroFloat(l.DurationSector1),
|
||||||
|
nullableZeroFloat(l.DurationSector2),
|
||||||
|
nullableZeroFloat(l.DurationSector3),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("upsert lap: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListLaps returns laps for a session ordered by driver and lap number.
|
||||||
|
func (s *Store) ListLaps(sessionKey int) ([]Lap, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT session_key, driver_number, meeting_key, lap_number, date_start,
|
||||||
|
lap_duration, is_pit_out_lap, duration_sector1, duration_sector2, duration_sector3
|
||||||
|
FROM laps
|
||||||
|
WHERE session_key = ?
|
||||||
|
ORDER BY driver_number ASC, lap_number ASC
|
||||||
|
`, sessionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Lap
|
||||||
|
for rows.Next() {
|
||||||
|
var l Lap
|
||||||
|
var dateStart sql.NullString
|
||||||
|
var lapDuration, s1, s2, s3 sql.NullFloat64
|
||||||
|
var isPitOut int
|
||||||
|
if err := rows.Scan(
|
||||||
|
&l.SessionKey,
|
||||||
|
&l.DriverNumber,
|
||||||
|
&l.MeetingKey,
|
||||||
|
&l.LapNumber,
|
||||||
|
&dateStart,
|
||||||
|
&lapDuration,
|
||||||
|
&isPitOut,
|
||||||
|
&s1,
|
||||||
|
&s2,
|
||||||
|
&s3,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
l.DateStart = dateStart.String
|
||||||
|
if lapDuration.Valid {
|
||||||
|
l.LapDuration = lapDuration.Float64
|
||||||
|
}
|
||||||
|
l.IsPitOutLap = isPitOut != 0
|
||||||
|
if s1.Valid {
|
||||||
|
l.DurationSector1 = s1.Float64
|
||||||
|
}
|
||||||
|
if s2.Valid {
|
||||||
|
l.DurationSector2 = s2.Float64
|
||||||
|
}
|
||||||
|
if s3.Valid {
|
||||||
|
l.DurationSector3 = s3.Float64
|
||||||
|
}
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
94
internal/store/migrations/002_analytics.sql
Normal file
94
internal/store/migrations/002_analytics.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS stints (
|
||||||
|
session_key INTEGER NOT NULL,
|
||||||
|
driver_number INTEGER NOT NULL,
|
||||||
|
meeting_key INTEGER NOT NULL,
|
||||||
|
stint_number INTEGER NOT NULL,
|
||||||
|
compound TEXT NOT NULL,
|
||||||
|
lap_start INTEGER NOT NULL,
|
||||||
|
lap_end INTEGER NOT NULL,
|
||||||
|
tyre_age_at_start INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (session_key, driver_number, stint_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stints_session ON stints (session_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stints_meeting ON stints (meeting_key);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pit_stops (
|
||||||
|
session_key INTEGER NOT NULL,
|
||||||
|
driver_number INTEGER NOT NULL,
|
||||||
|
meeting_key INTEGER NOT NULL,
|
||||||
|
lap_number INTEGER NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
pit_duration REAL,
|
||||||
|
lane_duration REAL,
|
||||||
|
stop_duration REAL,
|
||||||
|
PRIMARY KEY (session_key, driver_number, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pit_stops_session ON pit_stops (session_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pit_stops_meeting ON pit_stops (meeting_key);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS positions (
|
||||||
|
session_key INTEGER NOT NULL,
|
||||||
|
driver_number INTEGER NOT NULL,
|
||||||
|
meeting_key INTEGER NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (session_key, driver_number, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_positions_session ON positions (session_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_positions_meeting ON positions (meeting_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_positions_session_driver ON positions (session_key, driver_number);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS race_control (
|
||||||
|
session_key INTEGER NOT NULL,
|
||||||
|
meeting_key INTEGER NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
flag TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
scope TEXT,
|
||||||
|
driver_number INTEGER,
|
||||||
|
lap_number INTEGER,
|
||||||
|
sector INTEGER,
|
||||||
|
qualifying_phase INTEGER,
|
||||||
|
PRIMARY KEY (session_key, date, message)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_race_control_session ON race_control (session_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_race_control_meeting ON race_control (meeting_key);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS weather (
|
||||||
|
session_key INTEGER NOT NULL,
|
||||||
|
meeting_key INTEGER NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
air_temperature REAL,
|
||||||
|
track_temperature REAL,
|
||||||
|
humidity REAL,
|
||||||
|
pressure REAL,
|
||||||
|
rainfall INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wind_direction INTEGER,
|
||||||
|
wind_speed REAL,
|
||||||
|
PRIMARY KEY (session_key, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weather_session ON weather (session_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weather_meeting ON weather (meeting_key);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS laps (
|
||||||
|
session_key INTEGER NOT NULL,
|
||||||
|
driver_number INTEGER NOT NULL,
|
||||||
|
meeting_key INTEGER NOT NULL,
|
||||||
|
lap_number INTEGER NOT NULL,
|
||||||
|
date_start TEXT,
|
||||||
|
lap_duration REAL,
|
||||||
|
is_pit_out_lap INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration_sector1 REAL,
|
||||||
|
duration_sector2 REAL,
|
||||||
|
duration_sector3 REAL,
|
||||||
|
PRIMARY KEY (session_key, driver_number, lap_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_laps_session ON laps (session_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_laps_meeting ON laps (meeting_key);
|
||||||
@@ -4,16 +4,16 @@ import "time"
|
|||||||
|
|
||||||
// RawPayload stores a fetched source payload with provenance metadata.
|
// RawPayload stores a fetched source payload with provenance metadata.
|
||||||
type RawPayload struct {
|
type RawPayload struct {
|
||||||
ID int64
|
ID int64
|
||||||
Source string
|
Source string
|
||||||
Endpoint string
|
Endpoint string
|
||||||
RequestKey string
|
RequestKey string
|
||||||
MeetingKey *int
|
MeetingKey *int
|
||||||
SessionKey *int
|
SessionKey *int
|
||||||
Payload string
|
Payload string
|
||||||
PayloadHash string
|
PayloadHash string
|
||||||
FetchedAt time.Time
|
FetchedAt time.Time
|
||||||
ProvenanceJSON string
|
ProvenanceJSON string
|
||||||
}
|
}
|
||||||
|
|
||||||
// IngestionRun tracks a scoped ingestion attempt.
|
// IngestionRun tracks a scoped ingestion attempt.
|
||||||
@@ -104,3 +104,79 @@ type StartingGridEntry struct {
|
|||||||
Position int
|
Position int
|
||||||
LapDuration float64
|
LapDuration float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stint is a tyre stint for a driver in a session.
|
||||||
|
type Stint struct {
|
||||||
|
SessionKey int
|
||||||
|
DriverNumber int
|
||||||
|
MeetingKey int
|
||||||
|
StintNumber int
|
||||||
|
Compound string
|
||||||
|
LapStart int
|
||||||
|
LapEnd int
|
||||||
|
TyreAgeAtStart int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PitStop is a pit stop event for a driver in a session.
|
||||||
|
type PitStop struct {
|
||||||
|
SessionKey int
|
||||||
|
DriverNumber int
|
||||||
|
MeetingKey int
|
||||||
|
LapNumber int
|
||||||
|
Date string
|
||||||
|
PitDuration float64
|
||||||
|
LaneDuration float64
|
||||||
|
StopDuration float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PositionSample is a position update for a driver in a session.
|
||||||
|
type PositionSample struct {
|
||||||
|
SessionKey int
|
||||||
|
DriverNumber int
|
||||||
|
MeetingKey int
|
||||||
|
Date string
|
||||||
|
Position int
|
||||||
|
}
|
||||||
|
|
||||||
|
// RaceControlMessage is a race control message for a session.
|
||||||
|
type RaceControlMessage struct {
|
||||||
|
SessionKey int
|
||||||
|
MeetingKey int
|
||||||
|
Date string
|
||||||
|
Category string
|
||||||
|
Flag string
|
||||||
|
Message string
|
||||||
|
Scope string
|
||||||
|
DriverNumber *int
|
||||||
|
LapNumber *int
|
||||||
|
Sector *int
|
||||||
|
QualifyingPhase *int
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeatherSample is a weather reading for a session.
|
||||||
|
type WeatherSample struct {
|
||||||
|
SessionKey int
|
||||||
|
MeetingKey int
|
||||||
|
Date string
|
||||||
|
AirTemperature float64
|
||||||
|
TrackTemperature float64
|
||||||
|
Humidity float64
|
||||||
|
Pressure float64
|
||||||
|
Rainfall int
|
||||||
|
WindDirection int
|
||||||
|
WindSpeed float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lap is a completed lap for a driver in a session.
|
||||||
|
type Lap struct {
|
||||||
|
SessionKey int
|
||||||
|
DriverNumber int
|
||||||
|
MeetingKey int
|
||||||
|
LapNumber int
|
||||||
|
DateStart string
|
||||||
|
LapDuration float64
|
||||||
|
IsPitOutLap bool
|
||||||
|
DurationSector1 float64
|
||||||
|
DurationSector2 float64
|
||||||
|
DurationSector3 float64
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SchemaVersion() error = %v", err)
|
t.Fatalf("SchemaVersion() error = %v", err)
|
||||||
}
|
}
|
||||||
if version != 1 {
|
if version != 2 {
|
||||||
t.Fatalf("SchemaVersion() = %d, want 1", version)
|
t.Fatalf("SchemaVersion() = %d, want 2", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
tables := []string{
|
tables := []string{
|
||||||
@@ -42,6 +42,12 @@ func TestOpenAppliesMigrations(t *testing.T) {
|
|||||||
"session_drivers",
|
"session_drivers",
|
||||||
"session_results",
|
"session_results",
|
||||||
"starting_grid",
|
"starting_grid",
|
||||||
|
"stints",
|
||||||
|
"pit_stops",
|
||||||
|
"positions",
|
||||||
|
"race_control",
|
||||||
|
"weather",
|
||||||
|
"laps",
|
||||||
}
|
}
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
var name string
|
var name string
|
||||||
@@ -72,6 +78,12 @@ func TestMigrationsAreIdempotent(t *testing.T) {
|
|||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Fatalf("schema_migrations count = %d, want 1", count)
|
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) {
|
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) {
|
func TestWithTxRollback(t *testing.T) {
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/api"
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
@@ -88,7 +89,9 @@ func (s *Server) Start() error {
|
|||||||
|
|
||||||
// Start background goroutines.
|
// Start background goroutines.
|
||||||
go s.hub.run()
|
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)))
|
return http.ListenAndServe(s.addr, withCORS(withLogging(mux)))
|
||||||
}
|
}
|
||||||
|
|||||||
96
package-lock.json
generated
Normal file
96
package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
package.json
Normal file
16
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
playwright.config.ts
Normal file
38
playwright.config.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
219
scripts/seed-e2e-db/main.go
Normal file
219
scripts/seed-e2e-db/main.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
46
tests/race-hub.spec.ts
Normal file
46
tests/race-hub.spec.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user