mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -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 {
|
if *webMode {
|
||||||
log.SetOutput(os.Stderr) // web mode logs to stderr, not file
|
log.SetOutput(os.Stderr) // web mode logs to stderr, not file
|
||||||
fmt.Printf("box-box web → http://localhost:%d\n", *port)
|
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())
|
log.Fatal(srv.Start())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ TUI requirements during v1:
|
|||||||
- Use TanStack Query for server data.
|
- Use TanStack Query for server data.
|
||||||
- Use D3 for strategy, position evolution, and lap comparison visuals.
|
- Use D3 for strategy, position evolution, and lap comparison visuals.
|
||||||
- Keep the old Web UI available until the replacement route is credible.
|
- 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
|
## 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.
|
coding slice for OpenF1-to-store ingestion orchestration.
|
||||||
- [12 Phase 4 Local-First Web API](12-phase-4-local-first-web-api.md): fourth
|
- [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.
|
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):
|
- [13 Phase 5 React Race Hub](13-phase-5-react-race-hub.md): first frontend
|
||||||
current handoff prompt for the next Cursor backend phase.
|
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
|
## 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
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
)
|
)
|
||||||
|
|
||||||
// writeJSON writes v as JSON with status 200.
|
// 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 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
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)
|
meetings, err := s.client.GetMeetingsForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
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)
|
http.Error(w, "meeting_key required", http.StatusBadRequest)
|
||||||
return
|
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)
|
sessions, err := s.client.GetSessionsForMeeting(meetingKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
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)
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
return
|
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)
|
drivers, err := s.client.GetDriversForSession(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
||||||
@@ -89,6 +180,33 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 (
|
var (
|
||||||
results []models.SessionResult
|
results []models.SessionResult
|
||||||
drivers []models.Driver
|
drivers []models.Driver
|
||||||
@@ -137,6 +255,33 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 (
|
var (
|
||||||
grid []models.StartingGrid
|
grid []models.StartingGrid
|
||||||
drivers []models.Driver
|
drivers []models.Driver
|
||||||
@@ -667,9 +812,9 @@ type comparisonDriver struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type lapsComparisonResponse struct {
|
type lapsComparisonResponse struct {
|
||||||
SessionKey int `json:"session_key"`
|
SessionKey int `json:"session_key"`
|
||||||
SCPeriods []scPeriod `json:"sc_periods"`
|
SCPeriods []scPeriod `json:"sc_periods"`
|
||||||
PitLaps map[string][]int `json:"pit_laps"`
|
PitLaps map[string][]int `json:"pit_laps"`
|
||||||
Drivers []comparisonDriver `json:"drivers"`
|
Drivers []comparisonDriver `json:"drivers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,3 +929,31 @@ func buildDriverMap(drivers []models.Driver) map[int]models.Driver {
|
|||||||
}
|
}
|
||||||
return m
|
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"
|
"strings"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/api"
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets
|
//go:embed assets
|
||||||
@@ -17,17 +19,23 @@ var assetsFS embed.FS
|
|||||||
// Server is the box-box web companion HTTP server.
|
// Server is the box-box web companion HTTP server.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
client *api.OpenF1Client
|
client *api.OpenF1Client
|
||||||
|
query *query.Service
|
||||||
hub *SSEHub
|
hub *SSEHub
|
||||||
addr string
|
addr string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new Server. Call Start() to begin serving.
|
// NewServer creates a new Server. Call Start() to begin serving.
|
||||||
func NewServer(client *api.OpenF1Client, port int) *Server {
|
// When st is non-nil, local-first read models are available from the domain DB.
|
||||||
return &Server{
|
func NewServer(client *api.OpenF1Client, port int, st *store.Store) *Server {
|
||||||
|
s := &Server{
|
||||||
client: client,
|
client: client,
|
||||||
hub: newSSEHub(),
|
hub: newSSEHub(),
|
||||||
addr: fmt.Sprintf(":%d", port),
|
addr: fmt.Sprintf(":%d", port),
|
||||||
}
|
}
|
||||||
|
if st != nil {
|
||||||
|
s.query = query.NewService(st)
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start registers routes, launches background goroutines, and begins serving.
|
// 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
|
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
|
||||||
// because Go's ServeMux uses longest-prefix matching.
|
// 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/meetings", s.handleMeetings)
|
||||||
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
|
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
|
||||||
mux.HandleFunc("/api/v1/drivers", s.handleDrivers)
|
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