Add local-first race hub API

This commit is contained in:
2026-05-25 00:49:13 -04:00
parent 517c6b987b
commit e08255db70
16 changed files with 1231 additions and 118 deletions

View File

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

View File

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

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

View File

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

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

View File

@@ -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
View 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
View 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
}

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

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

View File

@@ -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
View 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{},
}
}

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

View File

@@ -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
View 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
}