Add analytics data foundation

This commit is contained in:
2026-05-25 02:04:16 -04:00
parent 5470b0df38
commit c7438f0ed8
23 changed files with 2167 additions and 94 deletions

8
.gitignore vendored
View File

@@ -23,3 +23,11 @@ frontend/dist/
# Local Claude workspace settings
.claude/
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/

View 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.

View File

@@ -63,8 +63,11 @@ not implementation tickets yet.
next frontend slice for strategy, position, and richer Race Hub views.
- [15 Phase 7 Analytics Data Foundation](15-phase-7-analytics-data-foundation.md):
backend slice for laps, stints, pits, race control, weather, and positions.
- [Cursor Phase 7 Prompt](cursor-phase-7-analytics-data-foundation-prompt.md):
current handoff prompt for the next Cursor backend phase.
- [16 Phase 8 Analytics Visuals](16-phase-8-analytics-visuals.md): frontend
slice for turning the newly available analytics datasets into useful Race Hub
views.
- [Claude Phase 8 Prompt](claude-phase-8-analytics-visuals-prompt.md): current
handoff prompt for the next Claude frontend phase.
## External References

View File

@@ -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.

View File

@@ -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.

View File

@@ -81,4 +81,77 @@ export interface RaceHub {
drivers: Driver[]
results: EnrichedResult[]
starting_grid: EnrichedGrid[]
stints: Stint[]
pit_stops: PitStop[]
positions: PositionSample[]
race_control: RaceControlMessage[]
weather: WeatherSample[]
laps: Lap[]
}
export interface Stint {
session_key: number
driver_number: number
meeting_key: number
stint_number: number
compound: string
lap_start: number
lap_end: number
tyre_age_at_start: number
}
export interface PitStop {
session_key: number
driver_number: number
meeting_key: number
lap_number: number
date: string
pit_duration: number
lane_duration: number
stop_duration: number
}
export interface PositionSample {
session_key: number
driver_number: number
meeting_key: number
date: string
position: number
}
export interface RaceControlMessage {
session_key: number
meeting_key: number
date: string
category: string
flag: string
message: string
scope: string
driver_number: number | null
lap_number: number | null
sector: number | null
qualifying_phase: number | null
}
export interface WeatherSample {
session_key: number
meeting_key: number
date: string
air_temperature: number
track_temperature: number
humidity: number
pressure: number
rainfall: number
wind_direction: number
wind_speed: number
}
export interface Lap {
session_key: number
driver_number: number
meeting_key: number
lap_number: number
date_start: string
lap_duration: number | null
is_pit_out_lap: boolean
}

View File

@@ -7,7 +7,7 @@ export default defineConfig({
port: process.env.PORT ? parseInt(process.env.PORT) : 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
target: `http://localhost:${process.env.BOXBOX_API_PORT ?? '8080'}`,
changeOrigin: true,
},
},

View File

@@ -42,6 +42,12 @@ type Summary struct {
Drivers int `json:"drivers"`
SessionResults int `json:"session_results"`
StartingGrid int `json:"starting_grid"`
Stints int `json:"stints"`
PitStops int `json:"pit_stops"`
Positions int `json:"positions"`
RaceControl int `json:"race_control"`
Weather int `json:"weather"`
Laps int `json:"laps"`
RawPayloads int `json:"raw_payloads"`
RawInserted int `json:"raw_inserted"`
Errors []string `json:"errors,omitempty"`
@@ -363,6 +369,26 @@ func (s *Service) IngestSession(sessionKey int) (Summary, error) {
} else {
summary.StartingGrid = len(grid)
}
s.delay()
if err := s.ingestStints(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err)
}
if err := s.ingestPitStops(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err)
}
if err := s.ingestPositions(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err)
}
if err := s.ingestRaceControl(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err)
}
if err := s.ingestWeather(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err)
}
if err := s.ingestLaps(&summary, meetingKey, sk); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Status = statusForDryRun(s.opts.DryRun)
s.finishRun(runID, summary)
@@ -416,6 +442,150 @@ func (s *Service) delay() {
}
}
func (s *Service) ingestStints(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching stints for session %d", sessionKey)
fetch, stints, err := fetchWithRetry(s, func() (FetchResult, []models.Stint, error) {
return s.source.FetchStintsForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, st := range stints {
if err := s.store.UpsertStint(stintToStore(st)); err != nil {
return err
}
summary.Stints++
}
return nil
}, func() { summary.Stints = len(stints) })
}
func (s *Service) ingestPitStops(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching pit stops for session %d", sessionKey)
fetch, pits, err := fetchWithRetry(s, func() (FetchResult, []models.Pit, error) {
return s.source.FetchPitStopsForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, p := range pits {
if err := s.store.UpsertPitStop(pitStopToStore(p)); err != nil {
return err
}
summary.PitStops++
}
return nil
}, func() { summary.PitStops = len(pits) })
}
func (s *Service) ingestPositions(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching positions for session %d", sessionKey)
fetch, positions, err := fetchWithRetry(s, func() (FetchResult, []models.Position, error) {
return s.source.FetchPositionsForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, p := range positions {
if err := s.store.UpsertPositionSample(positionToStore(p)); err != nil {
return err
}
summary.Positions++
}
return nil
}, func() { summary.Positions = len(positions) })
}
func (s *Service) ingestRaceControl(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching race control for session %d", sessionKey)
fetch, messages, err := fetchWithRetry(s, func() (FetchResult, []models.RaceControl, error) {
return s.source.FetchRaceControlForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, rc := range messages {
if err := s.store.UpsertRaceControlMessage(raceControlToStore(rc)); err != nil {
return err
}
summary.RaceControl++
}
return nil
}, func() { summary.RaceControl = len(messages) })
}
func (s *Service) ingestWeather(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching weather for session %d", sessionKey)
fetch, samples, err := fetchWithRetry(s, func() (FetchResult, []models.Weather, error) {
return s.source.FetchWeatherForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, w := range samples {
if err := s.store.UpsertWeatherSample(weatherToStore(w)); err != nil {
return err
}
summary.Weather++
}
return nil
}, func() { summary.Weather = len(samples) })
}
func (s *Service) ingestLaps(summary *Summary, meetingKey, sessionKey int) error {
s.opts.Progress.Step("fetching laps for session %d", sessionKey)
fetch, laps, err := fetchWithRetry(s, func() (FetchResult, []models.Lap, error) {
return s.source.FetchLapsForSession(sessionKey)
})
if err != nil {
return err
}
return s.storeAnalyticsFetch(summary, fetch, meetingKey, sessionKey, func() error {
for _, l := range laps {
if err := s.store.UpsertLap(lapToStore(l)); err != nil {
return err
}
summary.Laps++
}
return nil
}, func() { summary.Laps = len(laps) })
}
func (s *Service) storeAnalyticsFetch(
summary *Summary,
fetch FetchResult,
meetingKey, sessionKey int,
storeRows func() error,
setDryRunCount func(),
) error {
mk := meetingKey
sk := sessionKey
summary.RawPayloads++
if s.opts.DryRun {
setDryRunCount()
s.delay()
return nil
}
inserted, err := s.storeRaw(fetch, &mk, &sk)
if err != nil {
return err
}
if inserted {
summary.RawInserted++
}
if err := storeRows(); err != nil {
return err
}
s.delay()
return nil
}
func statusForDryRun(dryRun bool) string {
if dryRun {
return "dry_run"

View File

@@ -21,6 +21,12 @@ type fakeSource struct {
drivers map[int][]models.Driver
results map[int][]models.SessionResult
grid map[int][]models.StartingGrid
stints map[int][]models.Stint
pitStops map[int][]models.Pit
positions map[int][]models.Position
raceControl map[int][]models.RaceControl
weather map[int][]models.Weather
laps map[int][]models.Lap
failOn string
liveLockout bool
}
@@ -34,6 +40,12 @@ func newFakeSource() *fakeSource {
drivers: make(map[int][]models.Driver),
results: make(map[int][]models.SessionResult),
grid: make(map[int][]models.StartingGrid),
stints: make(map[int][]models.Stint),
pitStops: make(map[int][]models.Pit),
positions: make(map[int][]models.Position),
raceControl: make(map[int][]models.RaceControl),
weather: make(map[int][]models.Weather),
laps: make(map[int][]models.Lap),
}
}
@@ -114,6 +126,54 @@ func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.St
return f.wrap("starting_grid", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func (f *fakeSource) FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error) {
if err := f.maybeFail("stints"); err != nil {
return FetchResult{}, nil, err
}
data := f.stints[sessionKey]
return f.wrap("stints", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func (f *fakeSource) FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error) {
if err := f.maybeFail("pit"); err != nil {
return FetchResult{}, nil, err
}
data := f.pitStops[sessionKey]
return f.wrap("pit", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func (f *fakeSource) FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error) {
if err := f.maybeFail("position"); err != nil {
return FetchResult{}, nil, err
}
data := f.positions[sessionKey]
return f.wrap("position", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func (f *fakeSource) FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error) {
if err := f.maybeFail("race_control"); err != nil {
return FetchResult{}, nil, err
}
data := f.raceControl[sessionKey]
return f.wrap("race_control", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func (f *fakeSource) FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error) {
if err := f.maybeFail("weather"); err != nil {
return FetchResult{}, nil, err
}
data := f.weather[sessionKey]
return f.wrap("weather", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func (f *fakeSource) FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error) {
if err := f.maybeFail("laps"); err != nil {
return FetchResult{}, nil, err
}
data := f.laps[sessionKey]
return f.wrap("laps", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func openTestStore(t *testing.T) *store.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "ingest.db")
@@ -176,10 +236,35 @@ func testSessionFixtures() (int, int, *fakeSource) {
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, LapDuration: 71.234},
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, LapDuration: 71.456},
}
src.stints[sessionKey] = []models.Stint{
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, StintNumber: 1, Compound: models.CompoundMedium, LapStart: 1, LapEnd: 30},
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, StintNumber: 1, Compound: models.CompoundSoft, LapStart: 1, LapEnd: 18},
}
src.pitStops[sessionKey] = []models.Pit{
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, LapNumber: 19, Date: "2025-05-25T14:00:00+00:00", StopDuration: 2.4},
}
src.positions[sessionKey] = []models.Position{
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Date: "2025-05-25T13:05:00+00:00", Position: 1},
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Date: "2025-05-25T13:10:00+00:00", Position: 1},
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, Date: "2025-05-25T13:05:00+00:00", Position: 2},
}
src.raceControl[sessionKey] = []models.RaceControl{
{SessionKey: sessionKey, MeetingKey: meetingKey, Date: "2025-05-25T13:01:00+00:00", Category: models.CategoryFlag, Flag: models.FlagGreen, Message: "Green light"},
}
src.weather[sessionKey] = []models.Weather{
{SessionKey: sessionKey, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00+00:00", AirTemperature: 22.0, TrackTemperature: 34.0},
}
src.laps[sessionKey] = []models.Lap{
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, LapNumber: 1, LapDuration: ptrFloat64(75.1)},
}
return meetingKey, sessionKey, src
}
func ptrFloat64(v float64) *float64 {
return &v
}
func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
_, sessionKey, src := testSessionFixtures()
st := openTestStore(t)
@@ -198,8 +283,11 @@ func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
if summary.Drivers != 2 || summary.SessionResults != 2 || summary.StartingGrid != 2 {
t.Fatalf("summary counts = %+v, want 2 drivers/results/grid", summary)
}
if summary.RawPayloads != 5 {
t.Fatalf("summary.RawPayloads = %d, want 5", summary.RawPayloads)
if summary.Stints != 2 || summary.PitStops != 1 || summary.Positions != 3 {
t.Fatalf("summary analytics counts = %+v, want stints=2 pits=1 positions=3", summary)
}
if summary.RawPayloads != 11 {
t.Fatalf("summary.RawPayloads = %d, want 11", summary.RawPayloads)
}
drivers, err := st.ListSessionDrivers(sessionKey)
@@ -230,8 +318,17 @@ func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
if err != nil {
t.Fatalf("ListRawPayloadsBySession() error = %v", err)
}
if len(raw) != 5 {
t.Fatalf("raw payloads = %d, want 5", len(raw))
if len(raw) != 11 {
t.Fatalf("raw payloads = %d, want 11", len(raw))
}
stints, err := st.ListStints(sessionKey)
if err != nil || len(stints) != 2 {
t.Fatalf("stints = %+v, err = %v, want 2", stints, err)
}
positions, err := st.ListPositionSamples(sessionKey)
if err != nil || len(positions) != 3 {
t.Fatalf("positions = %+v, err = %v, want 3", positions, err)
}
}

View File

@@ -30,6 +30,12 @@ type Source interface {
FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error)
FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error)
FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error)
FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error)
FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error)
FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error)
FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error)
FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error)
FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error)
}
// OpenF1Source adapts OpenF1Client for ingestion using strict fetches.
@@ -77,6 +83,36 @@ func (s *OpenF1Source) FetchStartingGrid(sessionKey int) (FetchResult, []models.
return s.fetchStartingGrid(url, "starting_grid", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) FetchStintsForSession(sessionKey int) (FetchResult, []models.Stint, error) {
url := fmt.Sprintf("%s/v1/stints?session_key=%d", s.client.BaseURL(), sessionKey)
return s.fetchStints(url, "stints", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) FetchPitStopsForSession(sessionKey int) (FetchResult, []models.Pit, error) {
url := fmt.Sprintf("%s/v1/pit?session_key=%d", s.client.BaseURL(), sessionKey)
return s.fetchPitStops(url, "pit", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) FetchPositionsForSession(sessionKey int) (FetchResult, []models.Position, error) {
url := fmt.Sprintf("%s/v1/position?session_key=%d", s.client.BaseURL(), sessionKey)
return s.fetchPositions(url, "position", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) FetchRaceControlForSession(sessionKey int) (FetchResult, []models.RaceControl, error) {
url := fmt.Sprintf("%s/v1/race_control?session_key=%d", s.client.BaseURL(), sessionKey)
return s.fetchRaceControl(url, "race_control", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) FetchWeatherForSession(sessionKey int) (FetchResult, []models.Weather, error) {
url := fmt.Sprintf("%s/v1/weather?session_key=%d", s.client.BaseURL(), sessionKey)
return s.fetchWeather(url, "weather", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) FetchLapsForSession(sessionKey int) (FetchResult, []models.Lap, error) {
url := fmt.Sprintf("%s/v1/laps?session_key=%d", s.client.BaseURL(), sessionKey)
return s.fetchLaps(url, "laps", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) fetchMeetings(url, endpoint, requestKey string) (FetchResult, []models.Meeting, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
@@ -167,6 +203,114 @@ func (s *OpenF1Source) fetchStartingGrid(url, endpoint, requestKey string) (Fetc
}, result, nil
}
func (s *OpenF1Source) fetchStints(url, endpoint, requestKey string) (FetchResult, []models.Stint, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.Stint
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func (s *OpenF1Source) fetchPitStops(url, endpoint, requestKey string) (FetchResult, []models.Pit, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.Pit
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func (s *OpenF1Source) fetchPositions(url, endpoint, requestKey string) (FetchResult, []models.Position, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.Position
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func (s *OpenF1Source) fetchRaceControl(url, endpoint, requestKey string) (FetchResult, []models.RaceControl, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.RaceControl
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func (s *OpenF1Source) fetchWeather(url, endpoint, requestKey string) (FetchResult, []models.Weather, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.Weather
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func (s *OpenF1Source) fetchLaps(url, endpoint, requestKey string) (FetchResult, []models.Lap, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.Lap
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func meetingToStore(m models.Meeting) store.Meeting {
return store.Meeting{
MeetingKey: int(m.MeetingKey),
@@ -247,6 +391,101 @@ func startingGridToStore(g models.StartingGrid) store.StartingGridEntry {
}
}
func stintToStore(st models.Stint) store.Stint {
return store.Stint{
SessionKey: st.SessionKey,
DriverNumber: st.DriverNumber,
MeetingKey: st.MeetingKey,
StintNumber: st.StintNumber,
Compound: string(st.Compound),
LapStart: st.LapStart,
LapEnd: st.LapEnd,
TyreAgeAtStart: st.TyreAgeAtStart,
}
}
func pitStopToStore(p models.Pit) store.PitStop {
stopDuration := p.StopDuration
if stopDuration == 0 {
stopDuration = p.PitDuration
}
return store.PitStop{
SessionKey: p.SessionKey,
DriverNumber: p.DriverNumber,
MeetingKey: p.MeetingKey,
LapNumber: p.LapNumber,
Date: p.Date,
PitDuration: p.PitDuration,
LaneDuration: p.LaneDuration,
StopDuration: stopDuration,
}
}
func positionToStore(p models.Position) store.PositionSample {
return store.PositionSample{
SessionKey: p.SessionKey,
DriverNumber: p.DriverNumber,
MeetingKey: p.MeetingKey,
Date: p.Date,
Position: p.Position,
}
}
func raceControlToStore(rc models.RaceControl) store.RaceControlMessage {
return store.RaceControlMessage{
SessionKey: rc.SessionKey,
MeetingKey: rc.MeetingKey,
Date: rc.Date,
Category: string(rc.Category),
Flag: string(rc.Flag),
Message: rc.Message,
Scope: rc.Scope,
DriverNumber: rc.DriverNumber,
LapNumber: rc.LapNumber,
Sector: rc.Sector,
QualifyingPhase: rc.QualifyingPhase,
}
}
func weatherToStore(w models.Weather) store.WeatherSample {
return store.WeatherSample{
SessionKey: w.SessionKey,
MeetingKey: w.MeetingKey,
Date: w.Date,
AirTemperature: w.AirTemperature,
TrackTemperature: w.TrackTemperature,
Humidity: w.Humidity,
Pressure: w.Pressure,
Rainfall: w.Rainfall,
WindDirection: w.WindDirection,
WindSpeed: w.WindSpeed,
}
}
func lapToStore(l models.Lap) store.Lap {
lap := store.Lap{
SessionKey: l.SessionKey,
DriverNumber: l.DriverNumber,
MeetingKey: l.MeetingKey,
LapNumber: l.LapNumber,
DateStart: l.DateStart,
IsPitOutLap: l.IsPitOutLap,
}
if l.LapDuration != nil {
lap.LapDuration = *l.LapDuration
}
if l.DurationSector1 != nil {
lap.DurationSector1 = *l.DurationSector1
}
if l.DurationSector2 != nil {
lap.DurationSector2 = *l.DurationSector2
}
if l.DurationSector3 != nil {
lap.DurationSector3 = *l.DurationSector3
}
return lap
}
func jsonField(v any) string {
if v == nil {
return ""

View File

@@ -99,3 +99,102 @@ func parseJSONValue(raw string) interface{} {
}
return v
}
func stintToModel(st store.Stint) models.Stint {
return models.Stint{
SessionKey: st.SessionKey,
DriverNumber: st.DriverNumber,
MeetingKey: st.MeetingKey,
StintNumber: st.StintNumber,
Compound: models.TyreCompound(st.Compound),
LapStart: st.LapStart,
LapEnd: st.LapEnd,
TyreAgeAtStart: st.TyreAgeAtStart,
}
}
func pitStopToModel(p store.PitStop) models.Pit {
stopDuration := p.StopDuration
if stopDuration == 0 {
stopDuration = p.PitDuration
}
return models.Pit{
SessionKey: p.SessionKey,
DriverNumber: p.DriverNumber,
MeetingKey: p.MeetingKey,
LapNumber: p.LapNumber,
Date: p.Date,
PitDuration: p.PitDuration,
LaneDuration: p.LaneDuration,
StopDuration: stopDuration,
}
}
func positionToModel(p store.PositionSample) models.Position {
return models.Position{
SessionKey: p.SessionKey,
DriverNumber: p.DriverNumber,
MeetingKey: p.MeetingKey,
Date: p.Date,
Position: p.Position,
}
}
func raceControlToModel(rc store.RaceControlMessage) models.RaceControl {
return models.RaceControl{
SessionKey: rc.SessionKey,
MeetingKey: rc.MeetingKey,
Date: rc.Date,
Category: models.RaceControlCategory(rc.Category),
Flag: models.Flag(rc.Flag),
Message: rc.Message,
Scope: rc.Scope,
DriverNumber: rc.DriverNumber,
LapNumber: rc.LapNumber,
Sector: rc.Sector,
QualifyingPhase: rc.QualifyingPhase,
}
}
func weatherToModel(w store.WeatherSample) models.Weather {
return models.Weather{
SessionKey: w.SessionKey,
MeetingKey: w.MeetingKey,
Date: w.Date,
AirTemperature: w.AirTemperature,
TrackTemperature: w.TrackTemperature,
Humidity: w.Humidity,
Pressure: w.Pressure,
Rainfall: w.Rainfall,
WindDirection: w.WindDirection,
WindSpeed: w.WindSpeed,
}
}
func lapToModel(l store.Lap) models.Lap {
lap := models.Lap{
SessionKey: l.SessionKey,
DriverNumber: l.DriverNumber,
MeetingKey: l.MeetingKey,
LapNumber: l.LapNumber,
DateStart: l.DateStart,
IsPitOutLap: l.IsPitOutLap,
}
if l.LapDuration != 0 {
d := l.LapDuration
lap.LapDuration = &d
}
if l.DurationSector1 != 0 {
d := l.DurationSector1
lap.DurationSector1 = &d
}
if l.DurationSector2 != 0 {
d := l.DurationSector2
lap.DurationSector2 = &d
}
if l.DurationSector3 != 0 {
d := l.DurationSector3
lap.DurationSector3 = &d
}
return lap
}

View File

@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
@@ -150,8 +151,8 @@ func TestGetRaceHubCompleteData(t *testing.T) {
if err != nil {
t.Fatalf("GetRaceHub() error = %v", err)
}
if hub.Source != ResponseSourceLocal {
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourceLocal)
if hub.Source != ResponseSourcePartial {
t.Fatalf("Source = %q, want %q (core datasets complete, analytics missing)", hub.Source, ResponseSourcePartial)
}
if len(hub.Results) != 1 {
t.Fatalf("Results len = %d, want 1", len(hub.Results))
@@ -185,6 +186,47 @@ func TestListMeetingsByYear(t *testing.T) {
}
}
func TestGetRaceHubAnalyticsDatasets(t *testing.T) {
svc := openTestService(t)
seedRaceHubData(t, svc.store)
sessionKey := 9472
meetingKey := 1229
if err := svc.store.UpsertStint(store.Stint{
SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey,
StintNumber: 1, Compound: "MEDIUM", LapStart: 1, LapEnd: 30,
}); err != nil {
t.Fatalf("UpsertStint() error = %v", err)
}
if err := svc.store.UpsertPositionSample(store.PositionSample{
SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey,
Date: "2025-05-25T13:05:00+00:00", Position: 1,
}); err != nil {
t.Fatalf("UpsertPositionSample() error = %v", err)
}
hub, err := svc.GetRaceHub(sessionKey)
if err != nil {
t.Fatalf("GetRaceHub() error = %v", err)
}
if hub.Datasets["stints"].Status != DatasetStatusAvailable {
t.Fatalf("stints status = %+v, want available", hub.Datasets["stints"])
}
if hub.Datasets["positions"].Status != DatasetStatusAvailable {
t.Fatalf("positions status = %+v, want available", hub.Datasets["positions"])
}
if len(hub.Stints) != 1 || hub.Stints[0].Compound != models.CompoundMedium {
t.Fatalf("Stints = %+v, want one MEDIUM stint", hub.Stints)
}
if len(hub.Positions) != 1 {
t.Fatalf("Positions len = %d, want 1", len(hub.Positions))
}
if hub.Datasets["pit_stops"].Status != DatasetStatusMissing {
t.Fatalf("pit_stops status = %+v, want missing", hub.Datasets["pit_stops"])
}
}
func TestListDriversRequiresSession(t *testing.T) {
svc := openTestService(t)

View File

@@ -46,6 +46,12 @@ type RaceHub struct {
Drivers []models.Driver `json:"drivers"`
Results []EnrichedResult `json:"results"`
StartingGrid []EnrichedGrid `json:"starting_grid"`
Stints []models.Stint `json:"stints"`
PitStops []models.Pit `json:"pit_stops"`
Positions []models.Position `json:"positions"`
RaceControl []models.RaceControl `json:"race_control"`
Weather []models.Weather `json:"weather"`
Laps []models.Lap `json:"laps"`
}
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
@@ -58,10 +64,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
"drivers": missingDataset(),
"results": missingDataset(),
"starting_grid": missingDataset(),
"stints": missingDataset(),
"pit_stops": missingDataset(),
"positions": missingDataset(),
"race_control": missingDataset(),
"weather": missingDataset(),
"laps": missingDataset(),
},
Drivers: []models.Driver{},
Results: []EnrichedResult{},
StartingGrid: []EnrichedGrid{},
Stints: []models.Stint{},
PitStops: []models.Pit{},
Positions: []models.Position{},
RaceControl: []models.RaceControl{},
Weather: []models.Weather{},
Laps: []models.Lap{},
}
sess, err := s.store.GetSession(sessionKey)
@@ -150,6 +168,78 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
hub.Datasets["starting_grid"] = availableLocal(len(enriched))
}
stints, err := s.store.ListStints(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(stints) > 0 {
hub.Stints = make([]models.Stint, 0, len(stints))
for _, st := range stints {
hub.Stints = append(hub.Stints, stintToModel(st))
}
hub.Datasets["stints"] = availableLocal(len(hub.Stints))
}
pitStops, err := s.store.ListPitStops(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(pitStops) > 0 {
hub.PitStops = make([]models.Pit, 0, len(pitStops))
for _, p := range pitStops {
hub.PitStops = append(hub.PitStops, pitStopToModel(p))
}
hub.Datasets["pit_stops"] = availableLocal(len(hub.PitStops))
}
positions, err := s.store.ListPositionSamples(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(positions) > 0 {
hub.Positions = make([]models.Position, 0, len(positions))
for _, p := range positions {
hub.Positions = append(hub.Positions, positionToModel(p))
}
hub.Datasets["positions"] = availableLocal(len(hub.Positions))
}
raceControl, err := s.store.ListRaceControlMessages(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(raceControl) > 0 {
hub.RaceControl = make([]models.RaceControl, 0, len(raceControl))
for _, rc := range raceControl {
hub.RaceControl = append(hub.RaceControl, raceControlToModel(rc))
}
hub.Datasets["race_control"] = availableLocal(len(hub.RaceControl))
}
weather, err := s.store.ListWeatherSamples(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(weather) > 0 {
hub.Weather = make([]models.Weather, 0, len(weather))
for _, w := range weather {
hub.Weather = append(hub.Weather, weatherToModel(w))
}
hub.Datasets["weather"] = availableLocal(len(hub.Weather))
}
laps, err := s.store.ListLaps(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(laps) > 0 {
hub.Laps = make([]models.Lap, 0, len(laps))
for _, l := range laps {
hub.Laps = append(hub.Laps, lapToModel(l))
}
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
}
hub.Source = responseSource(hub.Datasets)
return hub, nil
}

450
internal/store/analytics.go Normal file
View 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,
&sector,
&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()
}

View 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);

View File

@@ -4,16 +4,16 @@ import "time"
// RawPayload stores a fetched source payload with provenance metadata.
type RawPayload struct {
ID int64
Source string
Endpoint string
RequestKey string
MeetingKey *int
SessionKey *int
Payload string
PayloadHash string
FetchedAt time.Time
ProvenanceJSON string
ID int64
Source string
Endpoint string
RequestKey string
MeetingKey *int
SessionKey *int
Payload string
PayloadHash string
FetchedAt time.Time
ProvenanceJSON string
}
// IngestionRun tracks a scoped ingestion attempt.
@@ -104,3 +104,79 @@ type StartingGridEntry struct {
Position int
LapDuration float64
}
// Stint is a tyre stint for a driver in a session.
type Stint struct {
SessionKey int
DriverNumber int
MeetingKey int
StintNumber int
Compound string
LapStart int
LapEnd int
TyreAgeAtStart int
}
// PitStop is a pit stop event for a driver in a session.
type PitStop struct {
SessionKey int
DriverNumber int
MeetingKey int
LapNumber int
Date string
PitDuration float64
LaneDuration float64
StopDuration float64
}
// PositionSample is a position update for a driver in a session.
type PositionSample struct {
SessionKey int
DriverNumber int
MeetingKey int
Date string
Position int
}
// RaceControlMessage is a race control message for a session.
type RaceControlMessage struct {
SessionKey int
MeetingKey int
Date string
Category string
Flag string
Message string
Scope string
DriverNumber *int
LapNumber *int
Sector *int
QualifyingPhase *int
}
// WeatherSample is a weather reading for a session.
type WeatherSample struct {
SessionKey int
MeetingKey int
Date string
AirTemperature float64
TrackTemperature float64
Humidity float64
Pressure float64
Rainfall int
WindDirection int
WindSpeed float64
}
// Lap is a completed lap for a driver in a session.
type Lap struct {
SessionKey int
DriverNumber int
MeetingKey int
LapNumber int
DateStart string
LapDuration float64
IsPitOutLap bool
DurationSector1 float64
DurationSector2 float64
DurationSector3 float64
}

View File

@@ -28,8 +28,8 @@ func TestOpenAppliesMigrations(t *testing.T) {
if err != nil {
t.Fatalf("SchemaVersion() error = %v", err)
}
if version != 1 {
t.Fatalf("SchemaVersion() = %d, want 1", version)
if version != 2 {
t.Fatalf("SchemaVersion() = %d, want 2", version)
}
tables := []string{
@@ -42,6 +42,12 @@ func TestOpenAppliesMigrations(t *testing.T) {
"session_drivers",
"session_results",
"starting_grid",
"stints",
"pit_stops",
"positions",
"race_control",
"weather",
"laps",
}
for _, table := range tables {
var name string
@@ -72,6 +78,12 @@ func TestMigrationsAreIdempotent(t *testing.T) {
if count != 1 {
t.Fatalf("schema_migrations count = %d, want 1", count)
}
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 2`).Scan(&count); err != nil {
t.Fatalf("count schema_migrations v2: %v", err)
}
if count != 1 {
t.Fatalf("schema_migrations v2 count = %d, want 1", count)
}
}
func TestRawPayloadInsertAndRead(t *testing.T) {
@@ -412,6 +424,110 @@ func TestSessionResultAndStartingGridUpsertRead(t *testing.T) {
}
}
func TestAnalyticsUpsertRead(t *testing.T) {
s := openTestStore(t)
meetingKey := 1229
sessionKey := 9472
stint := Stint{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
StintNumber: 1,
Compound: "SOFT",
LapStart: 1,
LapEnd: 20,
TyreAgeAtStart: 0,
}
if err := s.UpsertStint(stint); err != nil {
t.Fatalf("UpsertStint() error = %v", err)
}
stints, err := s.ListStints(sessionKey)
if err != nil || len(stints) != 1 || stints[0].Compound != "SOFT" {
t.Fatalf("ListStints() = %+v, err = %v", stints, err)
}
pit := PitStop{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
LapNumber: 21,
Date: "2025-05-25T14:00:00+00:00",
StopDuration: 2.5,
}
if err := s.UpsertPitStop(pit); err != nil {
t.Fatalf("UpsertPitStop() error = %v", err)
}
pits, err := s.ListPitStops(sessionKey)
if err != nil || len(pits) != 1 {
t.Fatalf("ListPitStops() = %+v, err = %v", pits, err)
}
pos := PositionSample{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
Date: "2025-05-25T14:05:00+00:00",
Position: 1,
}
if err := s.UpsertPositionSample(pos); err != nil {
t.Fatalf("UpsertPositionSample() error = %v", err)
}
positions, err := s.ListPositionSamples(sessionKey)
if err != nil || len(positions) != 1 {
t.Fatalf("ListPositionSamples() = %+v, err = %v", positions, err)
}
rc := RaceControlMessage{
SessionKey: sessionKey,
MeetingKey: meetingKey,
Date: "2025-05-25T14:10:00+00:00",
Category: "Flag",
Flag: "YELLOW",
Message: "Yellow flag sector 1",
Scope: "Track",
}
if err := s.UpsertRaceControlMessage(rc); err != nil {
t.Fatalf("UpsertRaceControlMessage() error = %v", err)
}
messages, err := s.ListRaceControlMessages(sessionKey)
if err != nil || len(messages) != 1 {
t.Fatalf("ListRaceControlMessages() = %+v, err = %v", messages, err)
}
weather := WeatherSample{
SessionKey: sessionKey,
MeetingKey: meetingKey,
Date: "2025-05-25T14:00:00+00:00",
AirTemperature: 22.5,
TrackTemperature: 35.0,
Humidity: 45.0,
}
if err := s.UpsertWeatherSample(weather); err != nil {
t.Fatalf("UpsertWeatherSample() error = %v", err)
}
samples, err := s.ListWeatherSamples(sessionKey)
if err != nil || len(samples) != 1 {
t.Fatalf("ListWeatherSamples() = %+v, err = %v", samples, err)
}
lap := Lap{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
LapNumber: 1,
LapDuration: 75.123,
}
if err := s.UpsertLap(lap); err != nil {
t.Fatalf("UpsertLap() error = %v", err)
}
laps, err := s.ListLaps(sessionKey)
if err != nil || len(laps) != 1 {
t.Fatalf("ListLaps() = %+v, err = %v", laps, err)
}
}
func TestWithTxRollback(t *testing.T) {
s := openTestStore(t)

View File

@@ -6,6 +6,7 @@ import (
"io/fs"
"log"
"net/http"
"os"
"strings"
"github.com/AmanTahiliani/box-box/internal/api"
@@ -88,7 +89,9 @@ func (s *Server) Start() error {
// Start background goroutines.
go s.hub.run()
go s.runLiveFeeds()
if os.Getenv("BOXBOX_DISABLE_LIVE") != "1" {
go s.runLiveFeeds()
}
return http.ListenAndServe(s.addr, withCORS(withLogging(mux)))
}

96
package-lock.json generated Normal file
View 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
View 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
View 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
View 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
View 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()
})
})