From e08255db704d92ecd2048dd24f13c4785f1d1887 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Mon, 25 May 2026 00:49:13 -0400 Subject: [PATCH] Add local-first race hub API --- cmd/main.go | 15 +- .../refactor/08-v1-scope-and-phasing.md | 5 + .../refactor/13-phase-5-react-race-hub.md | 103 +++++++++ documentations/refactor/README.md | 6 +- .../claude-phase-5-react-race-hub-prompt.md | 103 +++++++++ ...rsor-phase-4-local-first-web-api-prompt.md | 110 ---------- internal/query/convert.go | 101 +++++++++ internal/query/local.go | 73 +++++++ internal/query/metadata.go | 66 ++++++ internal/query/query_test.go | 198 ++++++++++++++++++ internal/query/racehub.go | 155 ++++++++++++++ internal/web/api.go | 179 +++++++++++++++- internal/web/racehub.go | 46 ++++ internal/web/racehub_test.go | 152 ++++++++++++++ internal/web/server.go | 13 +- internal/web/source.go | 24 +++ 16 files changed, 1231 insertions(+), 118 deletions(-) create mode 100644 documentations/refactor/13-phase-5-react-race-hub.md create mode 100644 documentations/refactor/claude-phase-5-react-race-hub-prompt.md delete mode 100644 documentations/refactor/cursor-phase-4-local-first-web-api-prompt.md create mode 100644 internal/query/convert.go create mode 100644 internal/query/local.go create mode 100644 internal/query/metadata.go create mode 100644 internal/query/query_test.go create mode 100644 internal/query/racehub.go create mode 100644 internal/web/racehub.go create mode 100644 internal/web/racehub_test.go create mode 100644 internal/web/source.go diff --git a/cmd/main.go b/cmd/main.go index 0631959..0c458bb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -61,7 +61,20 @@ func main() { if *webMode { log.SetOutput(os.Stderr) // web mode logs to stderr, not file fmt.Printf("box-box web → http://localhost:%d\n", *port) - srv := web.NewServer(client, *port) + + var domainStore *store.Store + db := *dbPath + if db == "" { + db = store.DefaultDBPath() + } + if st, err := store.Open(db); err != nil { + log.Printf("web: domain database unavailable (%s): %v", db, err) + } else { + domainStore = st + defer domainStore.Close() + } + + srv := web.NewServer(client, *port, domainStore) log.Fatal(srv.Start()) return } diff --git a/documentations/refactor/08-v1-scope-and-phasing.md b/documentations/refactor/08-v1-scope-and-phasing.md index cbb76ed..b02e97f 100644 --- a/documentations/refactor/08-v1-scope-and-phasing.md +++ b/documentations/refactor/08-v1-scope-and-phasing.md @@ -140,6 +140,11 @@ TUI requirements during v1: - Use TanStack Query for server data. - Use D3 for strategy, position evolution, and lap comparison visuals. - Keep the old Web UI available until the replacement route is credible. +- This is the first frontend phase. Use Claude for this phase. +- See [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md) for the + implementation brief and + [Claude Phase 5 Prompt](claude-phase-5-react-race-hub-prompt.md) for the + frontend-agent handoff. ## Ingestion Rate-Limit Defaults diff --git a/documentations/refactor/13-phase-5-react-race-hub.md b/documentations/refactor/13-phase-5-react-race-hub.md new file mode 100644 index 0000000..704b5d5 --- /dev/null +++ b/documentations/refactor/13-phase-5-react-race-hub.md @@ -0,0 +1,103 @@ +# Phase 5 React Race Hub + +## Purpose + +Phase 5 begins the production Web UI. The backend now has the foundation needed +for a local-first Race Hub: live timing is shared, a domain store exists, +ingestion can populate it, and `/api/v1/race-hub` can read from local data with +dataset metadata. + +This is the point to switch from Cursor to Claude for frontend/UI work. + +## Manager Decision + +Start with a focused React Race Hub slice, not a full app rewrite. The goal is +to prove the chosen frontend stack, visual language, responsive layout, and API +contract against the new local-first backend. + +Keep the old Web UI available until the React route is credible. + +## Scope + +Add a Vite + React + TypeScript frontend foundation and build a first Race Hub +route around: + +- meeting/session header; +- dataset/source status strip; +- classification table; +- starting grid table; +- driver/team color treatment; +- missing dataset states; +- compact Race Hub navigation shell; +- responsive desktop, tablet, and phone layouts. + +Use `/api/v1/race-hub?session_key=...` as the primary API. + +## Stack Defaults + +- Vite +- React +- TypeScript +- TanStack Query +- TanStack Router, unless integration cost argues for React Router +- D3 only for bespoke charts later; do not use it for basic layout tables +- Vitest for component/unit tests +- Playwright for at least one smoke path if practical + +## Visual Direction + +Follow the existing mockups in `documentations/refactor/screens/`, but treat +them as direction, not rigid specs. + +The UI should feel like an F1 operations room: + +- dense but readable; +- technical, not generic SaaS; +- restrained use of panels; +- no card sludge; +- no decorative gradient blobs; +- strong timing-table ergonomics; +- team colors used as data, not wallpaper; +- mobile views designed directly, not merely squeezed desktop. + +## Integration Policy + +Do not rip out the existing static Web UI on day one. Add the React app in a way +that can coexist while the route is built and tested. + +Acceptable approaches: + +- add a Vite app under a dedicated frontend directory and document the dev flow; +- serve built assets from Go only after the React slice is stable; +- expose a `/react` or equivalent route temporarily if needed. + +The implementation should avoid large backend changes except for tiny API +contract fixes discovered while integrating. + +## Non-Goals + +Do not include these in Phase 5: + +- full replacement of every existing Web screen; +- live timing React rewrite; +- ingest UI; +- settings UI; +- full season/calendar rebuild; +- new backend ingestion features; +- persistence of live SignalR events. + +## Acceptance Criteria + +Phase 5 is complete when: + +- the React app can run locally; +- a Race Hub screen loads from `/api/v1/race-hub`; +- available and missing datasets are visibly distinct; +- the layout is usable on desktop and phone widths; +- tests or smoke checks cover the Race Hub happy path; +- the old Web UI still works. + +## Next Phase After This + +Phase 6 should expand the React app around the Race Hub: strategy chart, +position evolution, lap comparison, and richer Data Library/status workflows. diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md index 6774b47..2c7d728 100644 --- a/documentations/refactor/README.md +++ b/documentations/refactor/README.md @@ -57,8 +57,10 @@ not implementation tickets yet. coding slice for OpenF1-to-store ingestion orchestration. - [12 Phase 4 Local-First Web API](12-phase-4-local-first-web-api.md): fourth coding slice for store-backed Race Hub read models and Web API metadata. -- [Cursor Phase 4 Prompt](cursor-phase-4-local-first-web-api-prompt.md): - current handoff prompt for the next Cursor backend phase. +- [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md): first frontend + implementation slice for the production Web UI. +- [Claude Phase 5 Prompt](claude-phase-5-react-race-hub-prompt.md): current + handoff prompt for Claude to begin React/frontend work. ## External References diff --git a/documentations/refactor/claude-phase-5-react-race-hub-prompt.md b/documentations/refactor/claude-phase-5-react-race-hub-prompt.md new file mode 100644 index 0000000..6ae5688 --- /dev/null +++ b/documentations/refactor/claude-phase-5-react-race-hub-prompt.md @@ -0,0 +1,103 @@ +# Claude Prompt: Phase 5 React Race Hub + +You are the frontend/UI lead for `box-box`, an F1 local-first command center. + +Backend Phases 1-4 are complete: + +- `internal/live` owns shared official F1 SignalR live timing. +- `internal/store` owns the local SQLite domain DB. +- `internal/ingest` can ingest OpenF1 data into the local store. +- `internal/query` and Web API expose local-first Race Hub data at: + + ```text + GET /api/v1/race-hub?session_key=9472 + ``` + +Your task is Phase 5: begin the production React Web UI with a focused Race Hub +slice. + +## Read First + +Read these files before editing: + +- `CLAUDE.md` +- `documentations/refactor/04-web-ui-product.md` +- `documentations/refactor/05-frontend-stack.md` +- `documentations/refactor/06-visual-design-direction.md` +- `documentations/refactor/12-phase-4-local-first-web-api.md` +- `documentations/refactor/13-phase-5-react-race-hub.md` +- `documentations/refactor/screens/index.html` +- `documentations/refactor/screens/race-hub.html` +- `documentations/refactor/screens/live-timing.html` +- `documentations/refactor/screens/mobile-live.html` +- `internal/web/racehub.go` +- `internal/query/racehub.go` + +## Goal + +Add the first production React frontend slice for Race Hub. Keep the existing +Go-served static Web UI working while the React route matures. + +## Required Work + +1. Add a Vite + React + TypeScript frontend foundation. +2. Use TanStack Query for API loading. +3. Use TanStack Router unless there is a clear reason to choose React Router. +4. Build a Race Hub screen backed by: + + ```text + /api/v1/race-hub?session_key=... + ``` + +5. Show: + - meeting/session header; + - source/dataset status strip; + - classification table; + - starting grid table; + - driver/team identity treatment; + - missing/partial dataset states; + - desktop and phone-responsive layouts. +6. Keep old Web UI routes/assets intact. +7. Add at least basic tests or a smoke check. +8. Document how to run the React dev server and how it connects to the Go API. + +## Design Guardrails + +- Make it feel F1-native and operational, not like a generic SaaS dashboard. +- Avoid card sludge. +- Avoid decorative gradients, blobs, fake hero sections, and meaningless chrome. +- Prefer dense, scan-friendly timing-wall ergonomics. +- Use team colors as structured data accents. +- Do not put cards inside cards. +- Build mobile intentionally; do not just squeeze desktop. +- Use icons where appropriate, but do not overdecorate. + +## Backend Guardrails + +- Do not rewrite ingestion. +- Do not persist live SignalR data. +- Do not replace all Web endpoints. +- Make only small API tweaks if integration reveals a real contract problem. +- Preserve the existing static Web UI until the React slice is credible. + +## Verification + +Run the relevant frontend checks you add, plus: + +```bash +go build -o /tmp/box-box ./cmd/main.go +``` + +If dependencies need to be installed, use the repo's package manager choice and +record the commands in your final response. + +## Final Response + +Report: + +- frontend package/files added; +- dev command and URL; +- API endpoint used; +- tests/smoke checks run; +- screenshots or notes about desktop/mobile behavior if available; +- any backend contract issues discovered. diff --git a/documentations/refactor/cursor-phase-4-local-first-web-api-prompt.md b/documentations/refactor/cursor-phase-4-local-first-web-api-prompt.md deleted file mode 100644 index da8d068..0000000 --- a/documentations/refactor/cursor-phase-4-local-first-web-api-prompt.md +++ /dev/null @@ -1,110 +0,0 @@ -# Cursor Prompt: Phase 4 Local-First Web API - -You are working in the `box-box` repository. - -Phases 1-3 are complete: - -- `internal/live` owns shared live timing. -- `internal/store` owns the SQLite domain DB. -- `internal/ingest` can ingest initial OpenF1 data into the store. - -Your task is Phase 4: add local-first backend read models and Web API support. -This is still a backend phase. Do not start the React/frontend implementation. - -## Read First - -Read these files before editing: - -- `CLAUDE.md` -- `documentations/refactor/08-v1-scope-and-phasing.md` -- `documentations/refactor/12-phase-4-local-first-web-api.md` -- `internal/store/*` -- `internal/ingest/*` -- `internal/web/server.go` -- `internal/web/api.go` -- `cmd/main.go` - -## Goal - -Expose a store-backed Race Hub API that can return ingested data without making -fresh OpenF1 calls. Missing datasets must be explicit in response metadata. - -## Required Work - -1. Add a read-model layer, preferably `internal/query`. -2. Implement a Race Hub read model for a single `session_key`. -3. Include: - - meeting; - - session; - - drivers; - - session results enriched with driver/team fields; - - starting grid enriched with driver/team fields; - - dataset availability metadata. -4. Add a Web endpoint: - - ```text - GET /api/v1/race-hub?session_key=9472 - ``` - -5. Wire Web mode to optionally open the domain DB: - - ```bash - go run cmd/main.go --web --db /path/to/boxbox.db - ``` - -6. Web mode must still start when the DB is absent or empty. -7. Add offline tests using temp SQLite stores. -8. Preserve existing TUI and live behavior. - -## Optional Work - -If straightforward, make these existing endpoints support local-first reads: - -- `/api/v1/meetings` -- `/api/v1/sessions` -- `/api/v1/drivers` -- `/api/v1/results` -- `/api/v1/grid` - -Use query controls such as: - -```text -?source=local -?source=auto -``` - -Do not break the current OpenF1-backed behavior of existing endpoints. - -## Guardrails - -- Do not add React, Vite, TanStack, or frontend app code. -- Do not trigger ingestion from normal Web browsing. -- Do not persist SignalR live data. -- Do not rewrite every API endpoint. -- Do not add laps/stints/pits/weather/race-control read models unless you also - add tested store tables for them. -- Keep tests offline. - -## Testing - -Run: - -```bash -go test ./internal/query/... ./internal/web/... ./internal/store/... -go build -o /tmp/box-box ./cmd/main.go -go test ./... -``` - -If `go test ./...` fails only because existing `internal/api` integration tests -cannot reach OpenF1, report that separately as unrelated. - -## Final Response - -Report: - -- packages/files changed; -- endpoint(s) added; -- response metadata shape; -- tests run and results; -- any known limitations; -- whether Phase 5 can begin frontend work. diff --git a/internal/query/convert.go b/internal/query/convert.go new file mode 100644 index 0000000..11417e5 --- /dev/null +++ b/internal/query/convert.go @@ -0,0 +1,101 @@ +package query + +import ( + "encoding/json" + + "github.com/AmanTahiliani/box-box/internal/models" + "github.com/AmanTahiliani/box-box/internal/store" +) + +func meetingToModel(m store.Meeting) models.Meeting { + return models.Meeting{ + MeetingKey: int32(m.MeetingKey), + MeetingName: m.MeetingName, + MeetingOfficialName: m.MeetingOfficialName, + Location: m.Location, + CountryCode: m.CountryCode, + CountryName: m.CountryName, + Circuit: models.Circuit{ + CircuitKey: m.CircuitKey, + CircuitShortName: m.CircuitShortName, + }, + GMTOffset: m.GMTOffset, + DateStart: m.DateStart, + DateEnd: m.DateEnd, + Year: m.Year, + } +} + +func sessionToModel(s store.Session) models.Session { + return models.Session{ + SessionKey: s.SessionKey, + MeetingKey: s.MeetingKey, + SessionName: s.SessionName, + SessionType: s.SessionType, + CircuitKey: s.CircuitKey, + DateStart: s.DateStart, + DateEnd: s.DateEnd, + GMTOffset: s.GMTOffset, + } +} + +func driverToModel(sessionKey, meetingKey int, sd store.SessionDriver, d store.Driver) models.Driver { + teamName := sd.TeamName + if teamName == "" { + teamName = d.TeamName + } + teamColour := sd.TeamColour + if teamColour == "" { + teamColour = d.TeamColour + } + return models.Driver{ + BroadcastName: d.BroadcastName, + DriverNumber: sd.DriverNumber, + FirstName: d.FirstName, + FullName: d.FullName, + HeadshotURL: d.HeadshotURL, + LastName: d.LastName, + MeetingKey: meetingKey, + NameAcronym: d.NameAcronym, + SessionKey: sessionKey, + TeamColour: teamColour, + TeamName: teamName, + } +} + +func resultToModel(r store.SessionResult) models.SessionResult { + return models.SessionResult{ + DNF: r.DNF, + DNS: r.DNS, + DSQ: r.DSQ, + DriverNumber: r.DriverNumber, + Duration: parseJSONValue(r.DurationJSON), + GapToLeader: parseJSONValue(r.GapToLeaderJSON), + NumberOfLaps: r.NumberOfLaps, + MeetingKey: r.MeetingKey, + Points: r.Points, + Position: r.Position, + SessionKey: r.SessionKey, + } +} + +func gridToModel(g store.StartingGridEntry) models.StartingGrid { + return models.StartingGrid{ + DriverNumber: g.DriverNumber, + LapDuration: g.LapDuration, + MeetingKey: g.MeetingKey, + Position: g.Position, + SessionKey: g.SessionKey, + } +} + +func parseJSONValue(raw string) interface{} { + if raw == "" { + return nil + } + var v interface{} + if err := json.Unmarshal([]byte(raw), &v); err != nil { + return raw + } + return v +} diff --git a/internal/query/local.go b/internal/query/local.go new file mode 100644 index 0000000..f1391dc --- /dev/null +++ b/internal/query/local.go @@ -0,0 +1,73 @@ +package query + +import ( + "database/sql" + "errors" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +// ListMeetingsByYear returns ingested meetings for a season. +func (s *Service) ListMeetingsByYear(year int) ([]models.Meeting, error) { + rows, err := s.store.ListMeetingsByYear(year) + if err != nil { + return nil, err + } + out := make([]models.Meeting, 0, len(rows)) + for _, row := range rows { + out = append(out, meetingToModel(row)) + } + return out, nil +} + +// ListSessionsByMeeting returns ingested sessions for a meeting. +func (s *Service) ListSessionsByMeeting(meetingKey int) ([]models.Session, error) { + rows, err := s.store.ListSessionsByMeeting(meetingKey) + if err != nil { + return nil, err + } + out := make([]models.Session, 0, len(rows)) + for _, row := range rows { + out = append(out, sessionToModel(row)) + } + return out, nil +} + +// ListDrivers returns ingested drivers for a session. +func (s *Service) ListDrivers(sessionKey int) ([]models.Driver, error) { + sess, err := s.store.GetSession(sessionKey) + if err != nil { + return nil, err + } + driverLinks, err := s.store.ListSessionDrivers(sessionKey) + if err != nil { + return nil, err + } + out := make([]models.Driver, 0, len(driverLinks)) + for _, link := range driverLinks { + d, err := s.store.GetDriver(link.DriverNumber) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + out = append(out, driverToModel(sessionKey, sess.MeetingKey, link, d)) + } + return out, nil +} + +// ListResults returns ingested session results enriched with driver fields. +func (s *Service) ListResults(sessionKey int) ([]EnrichedResult, error) { + hub, err := s.GetRaceHub(sessionKey) + if err != nil { + return nil, err + } + return hub.Results, nil +} + +// ListStartingGrid returns ingested starting grid rows enriched with driver fields. +func (s *Service) ListStartingGrid(sessionKey int) ([]EnrichedGrid, error) { + hub, err := s.GetRaceHub(sessionKey) + if err != nil { + return nil, err + } + return hub.StartingGrid, nil +} diff --git a/internal/query/metadata.go b/internal/query/metadata.go new file mode 100644 index 0000000..9e38fe2 --- /dev/null +++ b/internal/query/metadata.go @@ -0,0 +1,66 @@ +package query + +const ( + DatasetStatusAvailable = "available" + DatasetStatusMissing = "missing" + + DataSourceLocal = "local" + DataSourceNone = "none" + DataSourceOpenF1 = "openf1" + + ResponseSourceLocal = "local" + ResponseSourceNone = "none" + ResponseSourcePartial = "partial" +) + +// DatasetInfo describes availability of a single dataset. +type DatasetInfo struct { + Status string `json:"status"` + Source string `json:"source"` + Count int `json:"count,omitempty"` +} + +func availableLocal(count int) DatasetInfo { + return DatasetInfo{ + Status: DatasetStatusAvailable, + Source: DataSourceLocal, + Count: count, + } +} + +func missingDataset() DatasetInfo { + return DatasetInfo{ + Status: DatasetStatusMissing, + Source: DataSourceNone, + Count: 0, + } +} + +func responseSource(datasets map[string]DatasetInfo) string { + if len(datasets) == 0 { + return ResponseSourceNone + } + + hasLocal := false + allMissing := true + for _, info := range datasets { + if info.Status == DatasetStatusAvailable && info.Source == DataSourceLocal { + hasLocal = true + allMissing = false + } else if info.Status == DatasetStatusAvailable { + allMissing = false + } + } + if allMissing { + return ResponseSourceNone + } + if hasLocal { + for _, info := range datasets { + if info.Status == DatasetStatusMissing { + return ResponseSourcePartial + } + } + return ResponseSourceLocal + } + return ResponseSourceNone +} diff --git a/internal/query/query_test.go b/internal/query/query_test.go new file mode 100644 index 0000000..5b55cdb --- /dev/null +++ b/internal/query/query_test.go @@ -0,0 +1,198 @@ +package query + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/AmanTahiliani/box-box/internal/store" +) + +func openTestService(t *testing.T) *Service { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "test.db") + + st, err := store.Open(path) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return NewService(st) +} + +func seedRaceHubData(t *testing.T, st *store.Store) { + t.Helper() + + meetingKey := 1229 + sessionKey := 9472 + + if err := 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", + }); err != nil { + t.Fatalf("UpsertMeeting() error = %v", err) + } + if err := st.UpsertSession(store.Session{ + SessionKey: sessionKey, + MeetingKey: meetingKey, + SessionName: "Race", + SessionType: "Race", + CircuitKey: 10, + DateStart: "2025-05-25T13:00:00+00:00", + DateEnd: "2025-05-25T15:00:00+00:00", + }); err != nil { + t.Fatalf("UpsertSession() error = %v", err) + } + if err := st.UpsertDriver(store.Driver{ + DriverNumber: 1, + FullName: "Max Verstappen", + NameAcronym: "VER", + TeamName: "Red Bull Racing", + TeamColour: "3671C6", + }); err != nil { + t.Fatalf("UpsertDriver() error = %v", err) + } + if err := st.UpsertSessionDriver(store.SessionDriver{ + SessionKey: sessionKey, + DriverNumber: 1, + MeetingKey: meetingKey, + TeamName: "Red Bull Racing", + TeamColour: "3671C6", + }); err != nil { + t.Fatalf("UpsertSessionDriver() error = %v", err) + } + if err := st.UpsertStartingGridEntry(store.StartingGridEntry{ + SessionKey: sessionKey, + DriverNumber: 1, + MeetingKey: meetingKey, + Position: 1, + LapDuration: 71.234, + }); err != nil { + t.Fatalf("UpsertStartingGridEntry() error = %v", err) + } +} + +func TestGetRaceHubMissingSession(t *testing.T) { + svc := openTestService(t) + + hub, err := svc.GetRaceHub(9472) + if err != nil { + t.Fatalf("GetRaceHub() error = %v", err) + } + if hub.Source != ResponseSourceNone { + t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourceNone) + } + if hub.Datasets["session"].Status != DatasetStatusMissing { + t.Fatalf("session status = %q, want %q", hub.Datasets["session"].Status, DatasetStatusMissing) + } + if hub.Meeting != nil || hub.Session != nil { + t.Fatal("expected no meeting/session for missing session") + } +} + +func TestGetRaceHubPartialData(t *testing.T) { + svc := openTestService(t) + seedRaceHubData(t, svc.store) + + hub, err := svc.GetRaceHub(9472) + if err != nil { + t.Fatalf("GetRaceHub() error = %v", err) + } + if hub.Source != ResponseSourcePartial { + t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourcePartial) + } + if hub.Session == nil || hub.Meeting == nil { + t.Fatal("expected meeting and session") + } + if len(hub.Drivers) != 1 || hub.Drivers[0].NameAcronym != "VER" { + t.Fatalf("Drivers = %+v, want one VER entry", hub.Drivers) + } + if hub.Datasets["results"].Status != DatasetStatusMissing { + t.Fatalf("results status = %q, want %q", hub.Datasets["results"].Status, DatasetStatusMissing) + } + if len(hub.StartingGrid) != 1 { + t.Fatalf("StartingGrid len = %d, want 1", len(hub.StartingGrid)) + } + if hub.StartingGrid[0].NameAcronym != "VER" { + t.Fatalf("StartingGrid driver = %+v, want enriched VER", hub.StartingGrid[0]) + } +} + +func TestGetRaceHubCompleteData(t *testing.T) { + svc := openTestService(t) + seedRaceHubData(t, svc.store) + + if err := svc.store.UpsertSessionResult(store.SessionResult{ + SessionKey: 9472, + DriverNumber: 1, + MeetingKey: 1229, + Position: 1, + Points: 25, + NumberOfLaps: 78, + DurationJSON: "5234.567", + GapToLeaderJSON: "0", + }); err != nil { + t.Fatalf("UpsertSessionResult() error = %v", err) + } + + hub, err := svc.GetRaceHub(9472) + if err != nil { + t.Fatalf("GetRaceHub() error = %v", err) + } + if hub.Source != ResponseSourceLocal { + t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourceLocal) + } + if len(hub.Results) != 1 { + t.Fatalf("Results len = %d, want 1", len(hub.Results)) + } + if hub.Results[0].FullName != "Max Verstappen" { + t.Fatalf("Results[0].FullName = %q, want Max Verstappen", hub.Results[0].FullName) + } + if hub.Datasets["results"].Count != 1 { + t.Fatalf("results count = %d, want 1", hub.Datasets["results"].Count) + } +} + +func TestListMeetingsByYear(t *testing.T) { + svc := openTestService(t) + seedRaceHubData(t, svc.store) + + meetings, err := svc.ListMeetingsByYear(2025) + if err != nil { + t.Fatalf("ListMeetingsByYear() error = %v", err) + } + if len(meetings) != 1 || meetings[0].MeetingName != "Monaco" { + t.Fatalf("ListMeetingsByYear() = %+v, want Monaco meeting", meetings) + } + + empty, err := svc.ListMeetingsByYear(2024) + if err != nil { + t.Fatalf("ListMeetingsByYear(2024) error = %v", err) + } + if len(empty) != 0 { + t.Fatalf("ListMeetingsByYear(2024) len = %d, want 0", len(empty)) + } +} + +func TestListDriversRequiresSession(t *testing.T) { + svc := openTestService(t) + + _, err := svc.ListDrivers(9472) + if err == nil { + t.Fatal("ListDrivers() error = nil, want sql.ErrNoRows") + } + if err != sql.ErrNoRows { + t.Fatalf("ListDrivers() error = %v, want sql.ErrNoRows", err) + } +} diff --git a/internal/query/racehub.go b/internal/query/racehub.go new file mode 100644 index 0000000..c62f716 --- /dev/null +++ b/internal/query/racehub.go @@ -0,0 +1,155 @@ +package query + +import ( + "database/sql" + "errors" + + "github.com/AmanTahiliani/box-box/internal/models" + "github.com/AmanTahiliani/box-box/internal/store" +) + +// Service assembles store-backed read models. +type Service struct { + store *store.Store +} + +// NewService creates a query service over a domain store. +func NewService(st *store.Store) *Service { + return &Service{store: st} +} + +// EnrichedResult is a session result with driver identity fields. +type EnrichedResult struct { + models.SessionResult + NameAcronym string `json:"name_acronym"` + FullName string `json:"full_name"` + TeamName string `json:"team_name"` + TeamColour string `json:"team_colour"` +} + +// EnrichedGrid is a starting grid row with driver identity fields. +type EnrichedGrid struct { + models.StartingGrid + NameAcronym string `json:"name_acronym"` + FullName string `json:"full_name"` + TeamName string `json:"team_name"` + TeamColour string `json:"team_colour"` +} + +// RaceHub is the local-first Race Hub read model for one session. +type RaceHub struct { + Source string `json:"source"` + SessionKey int `json:"session_key"` + Datasets map[string]DatasetInfo `json:"datasets"` + Meeting *models.Meeting `json:"meeting,omitempty"` + Session *models.Session `json:"session,omitempty"` + Drivers []models.Driver `json:"drivers"` + Results []EnrichedResult `json:"results"` + StartingGrid []EnrichedGrid `json:"starting_grid"` +} + +// GetRaceHub loads ingested Race Hub datasets for a session from the local store. +func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) { + hub := RaceHub{ + SessionKey: sessionKey, + Datasets: map[string]DatasetInfo{ + "meeting": missingDataset(), + "session": missingDataset(), + "drivers": missingDataset(), + "results": missingDataset(), + "starting_grid": missingDataset(), + }, + Drivers: []models.Driver{}, + Results: []EnrichedResult{}, + StartingGrid: []EnrichedGrid{}, + } + + sess, err := s.store.GetSession(sessionKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + hub.Source = ResponseSourceNone + return hub, nil + } + return RaceHub{}, err + } + + sessionModel := sessionToModel(sess) + hub.Session = &sessionModel + hub.Datasets["session"] = availableLocal(1) + + meeting, err := s.store.GetMeeting(sess.MeetingKey) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + return RaceHub{}, err + } + } else { + meetingModel := meetingToModel(meeting) + hub.Meeting = &meetingModel + hub.Datasets["meeting"] = availableLocal(1) + } + + driverLinks, err := s.store.ListSessionDrivers(sessionKey) + if err != nil { + return RaceHub{}, err + } + if len(driverLinks) > 0 { + drivers := make([]models.Driver, 0, len(driverLinks)) + for _, link := range driverLinks { + d, err := s.store.GetDriver(link.DriverNumber) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return RaceHub{}, err + } + drivers = append(drivers, driverToModel(sessionKey, sess.MeetingKey, link, d)) + } + hub.Drivers = drivers + hub.Datasets["drivers"] = availableLocal(len(drivers)) + } + + driverByNumber := make(map[int]models.Driver, len(hub.Drivers)) + for _, d := range hub.Drivers { + driverByNumber[d.DriverNumber] = d + } + + results, err := s.store.ListSessionResults(sessionKey) + if err != nil { + return RaceHub{}, err + } + if len(results) > 0 { + enriched := make([]EnrichedResult, 0, len(results)) + for _, r := range results { + e := EnrichedResult{SessionResult: resultToModel(r)} + if d, ok := driverByNumber[r.DriverNumber]; ok { + e.NameAcronym = d.NameAcronym + e.FullName = d.FullName + e.TeamName = d.TeamName + e.TeamColour = d.TeamColour + } + enriched = append(enriched, e) + } + hub.Results = enriched + hub.Datasets["results"] = availableLocal(len(enriched)) + } + + grid, err := s.store.ListStartingGrid(sessionKey) + if err != nil { + return RaceHub{}, err + } + if len(grid) > 0 { + enriched := make([]EnrichedGrid, 0, len(grid)) + for _, g := range grid { + e := EnrichedGrid{StartingGrid: gridToModel(g)} + if d, ok := driverByNumber[g.DriverNumber]; ok { + e.NameAcronym = d.NameAcronym + e.FullName = d.FullName + e.TeamName = d.TeamName + e.TeamColour = d.TeamColour + } + enriched = append(enriched, e) + } + hub.StartingGrid = enriched + hub.Datasets["starting_grid"] = availableLocal(len(enriched)) + } + + hub.Source = responseSource(hub.Datasets) + return hub, nil +} diff --git a/internal/web/api.go b/internal/web/api.go index f6a879c..8b6d09c 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -1,7 +1,9 @@ package web import ( + "database/sql" "encoding/json" + "errors" "net/http" "sort" "strconv" @@ -10,6 +12,7 @@ import ( "time" "github.com/AmanTahiliani/box-box/internal/models" + "github.com/AmanTahiliani/box-box/internal/query" ) // writeJSON writes v as JSON with status 200. @@ -32,6 +35,34 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) { if year == 0 { year = time.Now().Year() } + + switch parseSourceMode(r) { + case sourceLocal: + if !s.hasLocalQuery() { + writeJSON(w, []models.Meeting{}) + return + } + meetings, err := s.query.ListMeetingsByYear(year) + if err != nil { + writeError(w, err, http.StatusInternalServerError, false) + return + } + writeJSON(w, meetings) + return + case sourceAuto: + if s.hasLocalQuery() { + meetings, err := s.query.ListMeetingsByYear(year) + if err != nil { + writeError(w, err, http.StatusInternalServerError, false) + return + } + if len(meetings) > 0 { + writeJSON(w, meetings) + return + } + } + } + meetings, err := s.client.GetMeetingsForYear(year) if err != nil { writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) @@ -48,6 +79,34 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { http.Error(w, "meeting_key required", http.StatusBadRequest) return } + + switch parseSourceMode(r) { + case sourceLocal: + if !s.hasLocalQuery() { + writeJSON(w, []models.Session{}) + return + } + sessions, err := s.query.ListSessionsByMeeting(meetingKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, false) + return + } + writeJSON(w, sessions) + return + case sourceAuto: + if s.hasLocalQuery() { + sessions, err := s.query.ListSessionsByMeeting(meetingKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, false) + return + } + if len(sessions) > 0 { + writeJSON(w, sessions) + return + } + } + } + sessions, err := s.client.GetSessionsForMeeting(meetingKey) if err != nil { writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) @@ -64,6 +123,38 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) { http.Error(w, "session_key required", http.StatusBadRequest) return } + + switch parseSourceMode(r) { + case sourceLocal: + if !s.hasLocalQuery() { + writeJSON(w, []models.Driver{}) + return + } + drivers, err := s.query.ListDrivers(sessionKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeJSON(w, []models.Driver{}) + return + } + writeError(w, err, http.StatusInternalServerError, false) + return + } + writeJSON(w, drivers) + return + case sourceAuto: + if s.hasLocalQuery() { + drivers, err := s.query.ListDrivers(sessionKey) + if err == nil && len(drivers) > 0 { + writeJSON(w, drivers) + return + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + writeError(w, err, http.StatusInternalServerError, false) + return + } + } + } + drivers, err := s.client.GetDriversForSession(sessionKey) if err != nil { writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale()) @@ -89,6 +180,33 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) { return } + switch parseSourceMode(r) { + case sourceLocal: + if !s.hasLocalQuery() { + writeJSON(w, []resultWithDriver{}) + return + } + results, err := s.query.ListResults(sessionKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, false) + return + } + writeJSON(w, enrichedResultsToAPI(results)) + return + case sourceAuto: + if s.hasLocalQuery() { + results, err := s.query.ListResults(sessionKey) + if err == nil && len(results) > 0 { + writeJSON(w, enrichedResultsToAPI(results)) + return + } + if err != nil { + writeError(w, err, http.StatusInternalServerError, false) + return + } + } + } + var ( results []models.SessionResult drivers []models.Driver @@ -137,6 +255,33 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) { return } + switch parseSourceMode(r) { + case sourceLocal: + if !s.hasLocalQuery() { + writeJSON(w, []gridWithDriver{}) + return + } + grid, err := s.query.ListStartingGrid(sessionKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, false) + return + } + writeJSON(w, enrichedGridToAPI(grid)) + return + case sourceAuto: + if s.hasLocalQuery() { + grid, err := s.query.ListStartingGrid(sessionKey) + if err == nil && len(grid) > 0 { + writeJSON(w, enrichedGridToAPI(grid)) + return + } + if err != nil { + writeError(w, err, http.StatusInternalServerError, false) + return + } + } + } + var ( grid []models.StartingGrid drivers []models.Driver @@ -667,9 +812,9 @@ type comparisonDriver struct { } type lapsComparisonResponse struct { - SessionKey int `json:"session_key"` - SCPeriods []scPeriod `json:"sc_periods"` - PitLaps map[string][]int `json:"pit_laps"` + SessionKey int `json:"session_key"` + SCPeriods []scPeriod `json:"sc_periods"` + PitLaps map[string][]int `json:"pit_laps"` Drivers []comparisonDriver `json:"drivers"` } @@ -784,3 +929,31 @@ func buildDriverMap(drivers []models.Driver) map[int]models.Driver { } return m } + +func enrichedResultsToAPI(results []query.EnrichedResult) []resultWithDriver { + out := make([]resultWithDriver, 0, len(results)) + for _, res := range results { + out = append(out, resultWithDriver{ + SessionResult: res.SessionResult, + NameAcronym: res.NameAcronym, + FullName: res.FullName, + TeamName: res.TeamName, + TeamColour: res.TeamColour, + }) + } + return out +} + +func enrichedGridToAPI(grid []query.EnrichedGrid) []gridWithDriver { + out := make([]gridWithDriver, 0, len(grid)) + for _, g := range grid { + out = append(out, gridWithDriver{ + StartingGrid: g.StartingGrid, + NameAcronym: g.NameAcronym, + FullName: g.FullName, + TeamName: g.TeamName, + TeamColour: g.TeamColour, + }) + } + return out +} diff --git a/internal/web/racehub.go b/internal/web/racehub.go new file mode 100644 index 0000000..3f43955 --- /dev/null +++ b/internal/web/racehub.go @@ -0,0 +1,46 @@ +package web + +import ( + "net/http" + "strconv" + + "github.com/AmanTahiliani/box-box/internal/models" + "github.com/AmanTahiliani/box-box/internal/query" +) + +func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) { + sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key")) + if err != nil || sessionKey == 0 { + http.Error(w, "session_key required", http.StatusBadRequest) + return + } + + if !s.hasLocalQuery() { + writeJSON(w, emptyRaceHub(sessionKey)) + return + } + + hub, err := s.query.GetRaceHub(sessionKey) + if err != nil { + writeError(w, err, http.StatusInternalServerError, false) + return + } + writeJSON(w, hub) +} + +func emptyRaceHub(sessionKey int) query.RaceHub { + return query.RaceHub{ + Source: query.ResponseSourceNone, + SessionKey: sessionKey, + Datasets: map[string]query.DatasetInfo{ + "meeting": query.DatasetInfo{Status: query.DatasetStatusMissing, Source: query.DataSourceNone, Count: 0}, + "session": query.DatasetInfo{Status: query.DatasetStatusMissing, Source: query.DataSourceNone, Count: 0}, + "drivers": query.DatasetInfo{Status: query.DatasetStatusMissing, Source: query.DataSourceNone, Count: 0}, + "results": query.DatasetInfo{Status: query.DatasetStatusMissing, Source: query.DataSourceNone, Count: 0}, + "starting_grid": query.DatasetInfo{Status: query.DatasetStatusMissing, Source: query.DataSourceNone, Count: 0}, + }, + Drivers: []models.Driver{}, + Results: []query.EnrichedResult{}, + StartingGrid: []query.EnrichedGrid{}, + } +} diff --git a/internal/web/racehub_test.go b/internal/web/racehub_test.go new file mode 100644 index 0000000..905b500 --- /dev/null +++ b/internal/web/racehub_test.go @@ -0,0 +1,152 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/AmanTahiliani/box-box/internal/api" + "github.com/AmanTahiliani/box-box/internal/query" + "github.com/AmanTahiliani/box-box/internal/store" +) + +func testServer(t *testing.T, st *store.Store) *Server { + t.Helper() + client := api.NewOpenF1Client("https://api.openf1.org", 15*time.Second) + t.Cleanup(func() { _ = client.Close() }) + return NewServer(client, 8080, st) +} + +func openTestStore(t *testing.T) *store.Store { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + st, err := store.Open(path) + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func seedRaceHubStore(t *testing.T, st *store.Store) { + t.Helper() + meetingKey := 1229 + sessionKey := 9472 + + if err := st.UpsertMeeting(store.Meeting{ + MeetingKey: meetingKey, + MeetingName: "Monaco", + Year: 2025, + }); err != nil { + t.Fatalf("UpsertMeeting() error = %v", err) + } + if err := st.UpsertSession(store.Session{ + SessionKey: sessionKey, + MeetingKey: meetingKey, + SessionName: "Race", + SessionType: "Race", + }); err != nil { + t.Fatalf("UpsertSession() error = %v", err) + } + if err := st.UpsertDriver(store.Driver{ + DriverNumber: 1, + FullName: "Max Verstappen", + NameAcronym: "VER", + }); err != nil { + t.Fatalf("UpsertDriver() error = %v", err) + } + if err := st.UpsertSessionDriver(store.SessionDriver{ + SessionKey: sessionKey, + DriverNumber: 1, + MeetingKey: meetingKey, + }); err != nil { + t.Fatalf("UpsertSessionDriver() error = %v", err) + } +} + +func TestHandleRaceHubWithoutStore(t *testing.T) { + srv := testServer(t, nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil) + rec := httptest.NewRecorder() + + srv.handleRaceHub(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var hub query.RaceHub + if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil { + t.Fatalf("decode response: %v", err) + } + if hub.Source != query.ResponseSourceNone { + t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone) + } + if hub.Datasets["session"].Status != query.DatasetStatusMissing { + t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"]) + } +} + +func TestHandleRaceHubWithLocalData(t *testing.T) { + st := openTestStore(t) + seedRaceHubStore(t, st) + srv := testServer(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil) + rec := httptest.NewRecorder() + srv.handleRaceHub(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var hub query.RaceHub + if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil { + t.Fatalf("decode response: %v", err) + } + if hub.Session == nil || hub.Meeting == nil { + t.Fatal("expected meeting and session in response") + } + if hub.Datasets["drivers"].Status != query.DatasetStatusAvailable { + t.Fatalf("drivers dataset = %+v, want available", hub.Datasets["drivers"]) + } + if hub.Datasets["results"].Status != query.DatasetStatusMissing { + t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"]) + } +} + +func TestHandleMeetingsSourceLocal(t *testing.T) { + st := openTestStore(t) + seedRaceHubStore(t, st) + srv := testServer(t, st) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/meetings?year=2025&source=local", nil) + rec := httptest.NewRecorder() + srv.handleMeetings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var meetings []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &meetings); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(meetings) != 1 { + t.Fatalf("meetings len = %d, want 1", len(meetings)) + } +} + +func TestHandleRaceHubRequiresSessionKey(t *testing.T) { + srv := testServer(t, nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub", nil) + rec := httptest.NewRecorder() + + srv.handleRaceHub(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index d569b72..bf9cd5e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -9,6 +9,8 @@ import ( "strings" "github.com/AmanTahiliani/box-box/internal/api" + "github.com/AmanTahiliani/box-box/internal/query" + "github.com/AmanTahiliani/box-box/internal/store" ) //go:embed assets @@ -17,17 +19,23 @@ var assetsFS embed.FS // Server is the box-box web companion HTTP server. type Server struct { client *api.OpenF1Client + query *query.Service hub *SSEHub addr string } // NewServer creates a new Server. Call Start() to begin serving. -func NewServer(client *api.OpenF1Client, port int) *Server { - return &Server{ +// When st is non-nil, local-first read models are available from the domain DB. +func NewServer(client *api.OpenF1Client, port int, st *store.Store) *Server { + s := &Server{ client: client, hub: newSSEHub(), addr: fmt.Sprintf(":%d", port), } + if st != nil { + s.query = query.NewService(st) + } + return s } // Start registers routes, launches background goroutines, and begins serving. @@ -36,6 +44,7 @@ func (s *Server) Start() error { // REST API — /api/v1/laps/comparison must be registered before /api/v1/laps // because Go's ServeMux uses longest-prefix matching. + mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub) mux.HandleFunc("/api/v1/meetings", s.handleMeetings) mux.HandleFunc("/api/v1/sessions", s.handleSessions) mux.HandleFunc("/api/v1/drivers", s.handleDrivers) diff --git a/internal/web/source.go b/internal/web/source.go new file mode 100644 index 0000000..196a4df --- /dev/null +++ b/internal/web/source.go @@ -0,0 +1,24 @@ +package web + +import "net/http" + +const ( + sourceOpenF1 = "openf1" + sourceLocal = "local" + sourceAuto = "auto" +) + +func parseSourceMode(r *http.Request) string { + switch r.URL.Query().Get("source") { + case sourceLocal: + return sourceLocal + case sourceAuto: + return sourceAuto + default: + return sourceOpenF1 + } +} + +func (s *Server) hasLocalQuery() bool { + return s.query != nil +}