mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Add local-first race hub API
This commit is contained in:
15
cmd/main.go
15
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
103
documentations/refactor/13-phase-5-react-race-hub.md
Normal file
103
documentations/refactor/13-phase-5-react-race-hub.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
103
documentations/refactor/claude-phase-5-react-race-hub-prompt.md
Normal file
103
documentations/refactor/claude-phase-5-react-race-hub-prompt.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
101
internal/query/convert.go
Normal file
101
internal/query/convert.go
Normal file
@@ -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
|
||||
}
|
||||
73
internal/query/local.go
Normal file
73
internal/query/local.go
Normal file
@@ -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
|
||||
}
|
||||
66
internal/query/metadata.go
Normal file
66
internal/query/metadata.go
Normal file
@@ -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
|
||||
}
|
||||
198
internal/query/query_test.go
Normal file
198
internal/query/query_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
155
internal/query/racehub.go
Normal file
155
internal/query/racehub.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
46
internal/web/racehub.go
Normal file
46
internal/web/racehub.go
Normal file
@@ -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{},
|
||||
}
|
||||
}
|
||||
152
internal/web/racehub_test.go
Normal file
152
internal/web/racehub_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
24
internal/web/source.go
Normal file
24
internal/web/source.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user