Add local navigation API

This commit is contained in:
2026-05-25 02:25:06 -04:00
parent b5d87775a6
commit 1661f8dec3
13 changed files with 637 additions and 91 deletions

View File

@@ -0,0 +1,47 @@
# Phase 10 Navigation UI
## Purpose
Phase 9 added local-first navigation APIs for seasons, weekends, sessions, and
dataset coverage. Phase 10 should use those APIs in the React Web UI so users
can browse ingested data instead of manually typing a `session_key`.
This is a frontend slice. Keep it functional and restrained; full visual polish
can come after the navigation workflow exists.
## Scope
Add React UI for:
- available seasons from `/api/v1/seasons`;
- locally ingested meetings for a selected year;
- one weekend view from `/api/v1/weekend?meeting_key=...`;
- session selection that routes into existing Race Hub views.
The existing Race Hub analytics views should stay intact.
## Product Behavior
- If local data exists, users should be able to reach Race Hub without knowing a
raw session key.
- Empty local database states should be explicit and calm.
- Weekend/session rows should show dataset coverage so users understand why a
session may be partial.
- Race Hub should continue accepting `session_key` in the URL for direct links.
## Guardrails
- Do not fetch OpenF1 directly from React.
- Do not redesign every screen.
- Do not remove the manual session key entry yet; keep it as a fallback.
- Do not add a large UI framework or chart dependency.
- Keep mobile and iPad usable.
## Acceptance Criteria
- Users can select a local year, meeting, and session.
- Selecting a session opens Race Hub for that session.
- Empty states are covered.
- Existing Race Hub e2e tests continue passing.
- Add focused frontend tests for navigation behavior where practical.
- Frontend tests and build pass.

View File

@@ -69,8 +69,10 @@ not implementation tickets yet.
- [17 Phase 9 Navigation Data API](17-phase-9-navigation-data-api.md): backend
slice for local-first season/weekend/session navigation so users do not need
raw session keys.
- [Cursor Phase 9 Prompt](cursor-phase-9-navigation-data-api-prompt.md):
current handoff prompt for the next Cursor backend phase.
- [18 Phase 10 Navigation UI](18-phase-10-navigation-ui.md): frontend slice for
adding local-first season/weekend navigation around Race Hub.
- [Cursor Phase 10 Prompt](cursor-phase-10-navigation-ui-prompt.md): current
handoff prompt for the next frontend phase.
## External References

View File

@@ -0,0 +1,83 @@
# Prompt For Cursor: Phase 10 Navigation UI
You are working in the `box-box` repository on Phase 10. This is a frontend
phase, but keep it pragmatic and low-context: build functional local-first
navigation around the existing Race Hub without redesigning the whole app.
## Read First
Open these files first:
- `documentations/refactor/18-phase-10-navigation-ui.md`
- `frontend/src/pages/RaceHubPage.tsx`
- `frontend/src/api.ts`
- `frontend/src/types.ts`
- `frontend/src/main.tsx`
- `frontend/src/styles.css`
- `tests/race-hub.spec.ts`
Only open older docs if you are blocked.
## Backend APIs Available
- `GET /api/v1/seasons`
- returns local years, newest first, e.g. `[2025]`.
- `GET /api/v1/meetings?year=2025&source=local`
- returns locally ingested meetings for the year.
- `GET /api/v1/weekend?meeting_key=1229`
- returns meeting metadata, sessions, `default_session_key`, and per-session
dataset coverage.
Use `source=local` for meetings so React does not fall back to OpenF1.
## Goal
Let users browse local data into Race Hub without knowing a raw `session_key`.
## Work To Do
1. Add TypeScript types and API functions for seasons, local meetings, and
weekend details.
2. Add a simple local data navigator in the React app:
- year selector/list;
- meetings for selected year;
- sessions for selected weekend;
- dataset coverage hints.
3. Selecting a session should navigate to `/race-hub?session_key=<key>`.
4. Keep the current manual session key entry as a fallback.
5. Preserve the existing Race Hub tabs and analytics views.
6. Add focused tests where practical.
7. Update Playwright coverage if a stable seeded navigation path is easy.
## Design Notes
- Keep it dense and operational, not a marketing page.
- Avoid card-heavy dashboard sludge.
- Reuse existing type, spacing, tab, and table conventions where possible.
- Mobile should remain usable.
## Do Not Do
- Do not fetch OpenF1 from React.
- Do not remove direct `session_key` routing.
- Do not introduce a new UI framework.
- Do not touch backend unless you find a blocking API bug.
## Verification
Run:
```bash
cd frontend && npm test -- --run
cd frontend && npm run build
npm run test:e2e
```
## Report Back
Summarize:
- files changed;
- navigation behavior added;
- tests run and results;
- follow-up polish or data needs.

View File

@@ -1,89 +0,0 @@
# Prompt For Cursor: Phase 9 Navigation Data API
You are working in the `box-box` repository as the backend engineer for Phase
9. Please keep this phase focused: add local-first navigation APIs so the
frontend can later stop requiring users to know raw `session_key` values.
## Read First
Open these files first:
- `documentations/refactor/17-phase-9-navigation-data-api.md`
- `internal/query/racehub.go`
- `internal/web/racehub.go`
- `internal/web/server.go`
- `internal/store/store.go`
- `internal/store/models.go`
- `internal/store/store_test.go`
- `scripts/seed-e2e-db/main.go`
Only open older planning docs if you need context.
## Goal
Implement local-first Web API read models for season/weekend/session
navigation. These endpoints must read from the SQLite domain database only.
They must not fetch OpenF1 on demand.
## Suggested API Shape
Use boring, stable names unless the codebase suggests a better convention:
- `GET /api/v1/seasons`
- returns years available in the local domain DB.
- `GET /api/v1/meetings?year=2025`
- returns locally ingested meetings for that year.
- `GET /api/v1/weekend?meeting_key=1229`
- returns meeting metadata, sessions, and per-session dataset coverage.
Dataset coverage should reuse the Race Hub dataset vocabulary where practical:
- meeting
- session
- drivers
- results
- starting_grid
- stints
- pit_stops
- positions
- race_control
- weather
- laps
## Implementation Notes
- Add query-layer structs/methods in `internal/query`; keep HTTP handlers thin.
- Add store read methods only where needed.
- Empty DB should return valid empty arrays, not 500s.
- Missing meeting should return a clear 404 from the web handler.
- Add tests against temp SQLite databases.
- If you touch the e2e seed, keep session `9472` as full data and `9000` as
core-only data.
## Do Not Do
- Do not build the React navigation UI yet.
- Do not add remote OpenF1 calls to these endpoints.
- Do not change the existing Race Hub response shape.
- Do not add live timing persistence in this phase.
## Verification
Run:
```bash
go test ./internal/store/... ./internal/query/... ./internal/web/...
go build -o /private/tmp/box-box ./cmd/main.go
cd frontend && npm test -- --run
cd frontend && npm run build
npm run test:e2e
```
## Report Back
Summarize:
- files changed;
- endpoint shapes added;
- tests run and results;
- follow-up risks or frontend handoff notes.

View File

@@ -0,0 +1,57 @@
package query
import "github.com/AmanTahiliani/box-box/internal/store"
func emptyDatasetMap() map[string]DatasetInfo {
return map[string]DatasetInfo{
"meeting": missingDataset(),
"session": missingDataset(),
"drivers": missingDataset(),
"results": missingDataset(),
"starting_grid": missingDataset(),
"stints": missingDataset(),
"pit_stops": missingDataset(),
"positions": missingDataset(),
"race_control": missingDataset(),
"weather": missingDataset(),
"laps": missingDataset(),
}
}
func datasetsFromCounts(meetingAvailable, sessionAvailable bool, counts store.SessionDatasetCounts) map[string]DatasetInfo {
ds := emptyDatasetMap()
if meetingAvailable {
ds["meeting"] = availableLocal(1)
}
if sessionAvailable {
ds["session"] = availableLocal(1)
}
if counts.Drivers > 0 {
ds["drivers"] = availableLocal(counts.Drivers)
}
if counts.Results > 0 {
ds["results"] = availableLocal(counts.Results)
}
if counts.StartingGrid > 0 {
ds["starting_grid"] = availableLocal(counts.StartingGrid)
}
if counts.Stints > 0 {
ds["stints"] = availableLocal(counts.Stints)
}
if counts.PitStops > 0 {
ds["pit_stops"] = availableLocal(counts.PitStops)
}
if counts.Positions > 0 {
ds["positions"] = availableLocal(counts.Positions)
}
if counts.RaceControl > 0 {
ds["race_control"] = availableLocal(counts.RaceControl)
}
if counts.Weather > 0 {
ds["weather"] = availableLocal(counts.Weather)
}
if counts.Laps > 0 {
ds["laps"] = availableLocal(counts.Laps)
}
return ds
}

View File

@@ -0,0 +1,133 @@
package query
import (
"database/sql"
"errors"
"github.com/AmanTahiliani/box-box/internal/models"
)
// ErrMeetingNotFound is returned when a meeting is not in the local store.
var ErrMeetingNotFound = errors.New("meeting not found")
// WeekendSession is one session within a meeting with dataset coverage.
type WeekendSession struct {
Session models.Session `json:"session"`
Source string `json:"source"`
Datasets map[string]DatasetInfo `json:"datasets"`
}
// Weekend is the local-first read model for one race weekend.
type Weekend struct {
Source string `json:"source"`
MeetingKey int `json:"meeting_key"`
Meeting models.Meeting `json:"meeting"`
Sessions []WeekendSession `json:"sessions"`
DefaultSessionKey int `json:"default_session_key,omitempty"`
}
// ListSeasons returns years with ingested meetings, newest first.
func (s *Service) ListSeasons() ([]int, error) {
return s.store.ListYears()
}
// GetWeekend loads meeting metadata, sessions, and per-session dataset coverage.
func (s *Service) GetWeekend(meetingKey int) (Weekend, error) {
meeting, err := s.store.GetMeeting(meetingKey)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Weekend{}, ErrMeetingNotFound
}
return Weekend{}, err
}
sessions, err := s.store.ListSessionsByMeeting(meetingKey)
if err != nil {
return Weekend{}, err
}
out := Weekend{
Source: ResponseSourceNone,
MeetingKey: meetingKey,
Meeting: meetingToModel(meeting),
Sessions: []WeekendSession{},
}
if len(sessions) == 0 {
return out, nil
}
out.Sessions = make([]WeekendSession, 0, len(sessions))
for _, sess := range sessions {
counts, err := s.store.CountSessionDatasets(sess.SessionKey)
if err != nil {
return Weekend{}, err
}
datasets := datasetsFromCounts(true, true, counts)
out.Sessions = append(out.Sessions, WeekendSession{
Session: sessionToModel(sess),
Source: responseSource(datasets),
Datasets: datasets,
})
}
out.Source = weekendSource(out.Sessions)
out.DefaultSessionKey = pickDefaultSession(out.Sessions)
return out, nil
}
func weekendSource(sessions []WeekendSession) string {
if len(sessions) == 0 {
return ResponseSourceNone
}
hasLocal := false
allMissing := true
for _, sess := range sessions {
if sess.Source == ResponseSourceNone {
continue
}
allMissing = false
if sess.Source == ResponseSourceLocal || sess.Source == ResponseSourcePartial {
hasLocal = true
}
}
if allMissing {
return ResponseSourceNone
}
for _, sess := range sessions {
if sess.Source == ResponseSourcePartial || sess.Source == ResponseSourceNone {
return ResponseSourcePartial
}
}
if hasLocal {
return ResponseSourceLocal
}
return ResponseSourceNone
}
func pickDefaultSession(sessions []WeekendSession) int {
if len(sessions) == 0 {
return 0
}
bestIdx := 0
bestScore := datasetScore(sessions[0].Datasets)
for i := 1; i < len(sessions); i++ {
score := datasetScore(sessions[i].Datasets)
if score >= bestScore {
bestScore = score
bestIdx = i
}
}
return sessions[bestIdx].Session.SessionKey
}
func datasetScore(datasets map[string]DatasetInfo) int {
score := 0
for _, info := range datasets {
if info.Status == DatasetStatusAvailable {
score++
}
}
return score
}

View File

@@ -2,6 +2,7 @@ package query
import (
"database/sql"
"errors"
"path/filepath"
"testing"
@@ -238,3 +239,77 @@ func TestListDriversRequiresSession(t *testing.T) {
t.Fatalf("ListDrivers() error = %v, want sql.ErrNoRows", err)
}
}
func TestListSeasonsEmpty(t *testing.T) {
svc := openTestService(t)
years, err := svc.ListSeasons()
if err != nil {
t.Fatalf("ListSeasons() error = %v", err)
}
if len(years) != 0 {
t.Fatalf("ListSeasons() = %v, want empty", years)
}
}
func TestListSeasonsWithData(t *testing.T) {
svc := openTestService(t)
seedRaceHubData(t, svc.store)
years, err := svc.ListSeasons()
if err != nil {
t.Fatalf("ListSeasons() error = %v", err)
}
if len(years) != 1 || years[0] != 2025 {
t.Fatalf("ListSeasons() = %v, want [2025]", years)
}
}
func TestGetWeekendMissingMeeting(t *testing.T) {
svc := openTestService(t)
_, err := svc.GetWeekend(1229)
if err == nil {
t.Fatal("GetWeekend() error = nil, want ErrMeetingNotFound")
}
if !errors.Is(err, ErrMeetingNotFound) {
t.Fatalf("GetWeekend() error = %v, want ErrMeetingNotFound", err)
}
}
func TestGetWeekendWithSessions(t *testing.T) {
svc := openTestService(t)
seedRaceHubData(t, svc.store)
if err := svc.store.UpsertSession(store.Session{
SessionKey: 9000,
MeetingKey: 1229,
SessionName: "Core Only",
SessionType: "Race",
DateStart: "2025-05-24T13:00:00+00:00",
}); err != nil {
t.Fatalf("UpsertSession() error = %v", err)
}
if err := svc.store.UpsertSessionDriver(store.SessionDriver{
SessionKey: 9000, DriverNumber: 1, MeetingKey: 1229,
}); err != nil {
t.Fatalf("UpsertSessionDriver() error = %v", err)
}
weekend, err := svc.GetWeekend(1229)
if err != nil {
t.Fatalf("GetWeekend() error = %v", err)
}
if weekend.Meeting.MeetingName != "Monaco" {
t.Fatalf("MeetingName = %q, want Monaco", weekend.Meeting.MeetingName)
}
if len(weekend.Sessions) != 2 {
t.Fatalf("Sessions len = %d, want 2", len(weekend.Sessions))
}
if weekend.DefaultSessionKey != 9472 {
t.Fatalf("DefaultSessionKey = %d, want 9472 (full data session)", weekend.DefaultSessionKey)
}
if weekend.Sessions[0].Datasets["drivers"].Status != DatasetStatusAvailable {
t.Fatalf("first session drivers = %+v, want available", weekend.Sessions[0].Datasets["drivers"])
}
}

50
internal/store/counts.go Normal file
View File

@@ -0,0 +1,50 @@
package store
import "database/sql"
// SessionDatasetCounts holds row counts for session-scoped datasets.
type SessionDatasetCounts struct {
Drivers int
Results int
StartingGrid int
Stints int
PitStops int
Positions int
RaceControl int
Weather int
Laps int
}
// CountSessionDatasets returns row counts for ingested session datasets.
func (s *Store) CountSessionDatasets(sessionKey int) (SessionDatasetCounts, error) {
var c SessionDatasetCounts
err := s.db.QueryRow(`
SELECT
(SELECT COUNT(*) FROM session_drivers WHERE session_key = ?),
(SELECT COUNT(*) FROM session_results WHERE session_key = ?),
(SELECT COUNT(*) FROM starting_grid WHERE session_key = ?),
(SELECT COUNT(*) FROM stints WHERE session_key = ?),
(SELECT COUNT(*) FROM pit_stops WHERE session_key = ?),
(SELECT COUNT(*) FROM positions WHERE session_key = ?),
(SELECT COUNT(*) FROM race_control WHERE session_key = ?),
(SELECT COUNT(*) FROM weather WHERE session_key = ?),
(SELECT COUNT(*) FROM laps WHERE session_key = ?)
`,
sessionKey, sessionKey, sessionKey, sessionKey, sessionKey,
sessionKey, sessionKey, sessionKey, sessionKey,
).Scan(
&c.Drivers,
&c.Results,
&c.StartingGrid,
&c.Stints,
&c.PitStops,
&c.Positions,
&c.RaceControl,
&c.Weather,
&c.Laps,
)
if err == sql.ErrNoRows {
return SessionDatasetCounts{}, nil
}
return c, err
}

View File

@@ -100,6 +100,30 @@ func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
return m, nil
}
// ListYears returns distinct meeting years ordered newest first.
func (s *Store) ListYears() ([]int, error) {
rows, err := s.db.Query(`
SELECT DISTINCT year
FROM meetings
WHERE year > 0
ORDER BY year DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var years []int
for rows.Next() {
var year int
if err := rows.Scan(&year); err != nil {
return nil, err
}
years = append(years, year)
}
return years, rows.Err()
}
// ListMeetingsByYear returns meetings for a season ordered by start date.
func (s *Store) ListMeetingsByYear(year int) ([]Meeting, error) {
rows, err := s.db.Query(`

View File

@@ -319,6 +319,25 @@ func TestMeetingSessionDriverUpsertsAreIdempotent(t *testing.T) {
t.Fatalf("ListSessionsByMeeting() len = %d, want 1", len(sessions))
}
years, err := s.ListYears()
if err != nil {
t.Fatalf("ListYears() error = %v", err)
}
if len(years) != 1 || years[0] != 2025 {
t.Fatalf("ListYears() = %v, want [2025]", years)
}
counts, err := s.CountSessionDatasets(session.SessionKey)
if err != nil {
t.Fatalf("CountSessionDatasets() error = %v", err)
}
if counts.Drivers != 1 {
t.Fatalf("CountSessionDatasets().Drivers = %d, want 1", counts.Drivers)
}
if counts.Results != 0 {
t.Fatalf("CountSessionDatasets().Results = %d, want 0", counts.Results)
}
sessionDrivers, err := s.ListSessionDrivers(session.SessionKey)
if err != nil {
t.Fatalf("ListSessionDrivers() error = %v", err)

View File

@@ -0,0 +1,50 @@
package web
import (
"errors"
"net/http"
"strconv"
"github.com/AmanTahiliani/box-box/internal/query"
)
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
if !s.hasLocalQuery() {
writeJSON(w, []int{})
return
}
years, err := s.query.ListSeasons()
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
if years == nil {
years = []int{}
}
writeJSON(w, years)
}
func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
if err != nil || meetingKey == 0 {
http.Error(w, "meeting_key required", http.StatusBadRequest)
return
}
if !s.hasLocalQuery() {
writeError(w, query.ErrMeetingNotFound, http.StatusNotFound, false)
return
}
weekend, err := s.query.GetWeekend(meetingKey)
if err != nil {
if errors.Is(err, query.ErrMeetingNotFound) {
writeError(w, err, http.StatusNotFound, false)
return
}
writeError(w, err, http.StatusInternalServerError, false)
return
}
writeJSON(w, weekend)
}

View File

@@ -150,3 +150,96 @@ func TestHandleRaceHubRequiresSessionKey(t *testing.T) {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestHandleSeasonsEmpty(t *testing.T) {
srv := testServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/seasons", nil)
rec := httptest.NewRecorder()
srv.handleSeasons(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var years []int
if err := json.Unmarshal(rec.Body.Bytes(), &years); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(years) != 0 {
t.Fatalf("years = %v, want empty", years)
}
}
func TestHandleSeasonsWithData(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/seasons", nil)
rec := httptest.NewRecorder()
srv.handleSeasons(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var years []int
if err := json.Unmarshal(rec.Body.Bytes(), &years); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(years) != 1 || years[0] != 2025 {
t.Fatalf("years = %v, want [2025]", years)
}
}
func TestHandleWeekendNotFound(t *testing.T) {
st := openTestStore(t)
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/weekend?meeting_key=1229", nil)
rec := httptest.NewRecorder()
srv.handleWeekend(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestHandleWeekendWithData(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/weekend?meeting_key=1229", nil)
rec := httptest.NewRecorder()
srv.handleWeekend(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var weekend query.Weekend
if err := json.Unmarshal(rec.Body.Bytes(), &weekend); err != nil {
t.Fatalf("decode response: %v", err)
}
if weekend.Meeting.MeetingName != "Monaco" {
t.Fatalf("meeting = %q, want Monaco", weekend.Meeting.MeetingName)
}
if len(weekend.Sessions) != 1 {
t.Fatalf("sessions len = %d, want 1", len(weekend.Sessions))
}
if weekend.DefaultSessionKey != 9472 {
t.Fatalf("default_session_key = %d, want 9472", weekend.DefaultSessionKey)
}
}
func TestHandleWeekendRequiresMeetingKey(t *testing.T) {
srv := testServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/weekend", nil)
rec := httptest.NewRecorder()
srv.handleWeekend(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}

View File

@@ -46,6 +46,8 @@ 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/seasons", s.handleSeasons)
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
mux.HandleFunc("/api/v1/drivers", s.handleDrivers)