diff --git a/documentations/refactor/18-phase-10-navigation-ui.md b/documentations/refactor/18-phase-10-navigation-ui.md new file mode 100644 index 0000000..667ce72 --- /dev/null +++ b/documentations/refactor/18-phase-10-navigation-ui.md @@ -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. diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md index c990198..64b82be 100644 --- a/documentations/refactor/README.md +++ b/documentations/refactor/README.md @@ -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 diff --git a/documentations/refactor/cursor-phase-10-navigation-ui-prompt.md b/documentations/refactor/cursor-phase-10-navigation-ui-prompt.md new file mode 100644 index 0000000..83b64f8 --- /dev/null +++ b/documentations/refactor/cursor-phase-10-navigation-ui-prompt.md @@ -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=`. +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. diff --git a/documentations/refactor/cursor-phase-9-navigation-data-api-prompt.md b/documentations/refactor/cursor-phase-9-navigation-data-api-prompt.md deleted file mode 100644 index dec3425..0000000 --- a/documentations/refactor/cursor-phase-9-navigation-data-api-prompt.md +++ /dev/null @@ -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. diff --git a/internal/query/datasets.go b/internal/query/datasets.go new file mode 100644 index 0000000..e4bc96d --- /dev/null +++ b/internal/query/datasets.go @@ -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 +} diff --git a/internal/query/navigation.go b/internal/query/navigation.go new file mode 100644 index 0000000..e3325a7 --- /dev/null +++ b/internal/query/navigation.go @@ -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 +} diff --git a/internal/query/query_test.go b/internal/query/query_test.go index 137a2b3..2858b52 100644 --- a/internal/query/query_test.go +++ b/internal/query/query_test.go @@ -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"]) + } +} diff --git a/internal/store/counts.go b/internal/store/counts.go new file mode 100644 index 0000000..8471c64 --- /dev/null +++ b/internal/store/counts.go @@ -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 +} diff --git a/internal/store/meetings.go b/internal/store/meetings.go index f0d97a7..2e32dbd 100644 --- a/internal/store/meetings.go +++ b/internal/store/meetings.go @@ -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(` diff --git a/internal/store/store_test.go b/internal/store/store_test.go index bcb310d..423cd04 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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) diff --git a/internal/web/navigation.go b/internal/web/navigation.go new file mode 100644 index 0000000..d6b9830 --- /dev/null +++ b/internal/web/navigation.go @@ -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) +} diff --git a/internal/web/racehub_test.go b/internal/web/racehub_test.go index 905b500..b3d3eb6 100644 --- a/internal/web/racehub_test.go +++ b/internal/web/racehub_test.go @@ -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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index c8f0ac5..7283b90 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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)