Add local-first race hub API

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

101
internal/query/convert.go Normal file
View File

@@ -0,0 +1,101 @@
package query
import (
"encoding/json"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
func meetingToModel(m store.Meeting) models.Meeting {
return models.Meeting{
MeetingKey: int32(m.MeetingKey),
MeetingName: m.MeetingName,
MeetingOfficialName: m.MeetingOfficialName,
Location: m.Location,
CountryCode: m.CountryCode,
CountryName: m.CountryName,
Circuit: models.Circuit{
CircuitKey: m.CircuitKey,
CircuitShortName: m.CircuitShortName,
},
GMTOffset: m.GMTOffset,
DateStart: m.DateStart,
DateEnd: m.DateEnd,
Year: m.Year,
}
}
func sessionToModel(s store.Session) models.Session {
return models.Session{
SessionKey: s.SessionKey,
MeetingKey: s.MeetingKey,
SessionName: s.SessionName,
SessionType: s.SessionType,
CircuitKey: s.CircuitKey,
DateStart: s.DateStart,
DateEnd: s.DateEnd,
GMTOffset: s.GMTOffset,
}
}
func driverToModel(sessionKey, meetingKey int, sd store.SessionDriver, d store.Driver) models.Driver {
teamName := sd.TeamName
if teamName == "" {
teamName = d.TeamName
}
teamColour := sd.TeamColour
if teamColour == "" {
teamColour = d.TeamColour
}
return models.Driver{
BroadcastName: d.BroadcastName,
DriverNumber: sd.DriverNumber,
FirstName: d.FirstName,
FullName: d.FullName,
HeadshotURL: d.HeadshotURL,
LastName: d.LastName,
MeetingKey: meetingKey,
NameAcronym: d.NameAcronym,
SessionKey: sessionKey,
TeamColour: teamColour,
TeamName: teamName,
}
}
func resultToModel(r store.SessionResult) models.SessionResult {
return models.SessionResult{
DNF: r.DNF,
DNS: r.DNS,
DSQ: r.DSQ,
DriverNumber: r.DriverNumber,
Duration: parseJSONValue(r.DurationJSON),
GapToLeader: parseJSONValue(r.GapToLeaderJSON),
NumberOfLaps: r.NumberOfLaps,
MeetingKey: r.MeetingKey,
Points: r.Points,
Position: r.Position,
SessionKey: r.SessionKey,
}
}
func gridToModel(g store.StartingGridEntry) models.StartingGrid {
return models.StartingGrid{
DriverNumber: g.DriverNumber,
LapDuration: g.LapDuration,
MeetingKey: g.MeetingKey,
Position: g.Position,
SessionKey: g.SessionKey,
}
}
func parseJSONValue(raw string) interface{} {
if raw == "" {
return nil
}
var v interface{}
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return raw
}
return v
}

73
internal/query/local.go Normal file
View File

@@ -0,0 +1,73 @@
package query
import (
"database/sql"
"errors"
"github.com/AmanTahiliani/box-box/internal/models"
)
// ListMeetingsByYear returns ingested meetings for a season.
func (s *Service) ListMeetingsByYear(year int) ([]models.Meeting, error) {
rows, err := s.store.ListMeetingsByYear(year)
if err != nil {
return nil, err
}
out := make([]models.Meeting, 0, len(rows))
for _, row := range rows {
out = append(out, meetingToModel(row))
}
return out, nil
}
// ListSessionsByMeeting returns ingested sessions for a meeting.
func (s *Service) ListSessionsByMeeting(meetingKey int) ([]models.Session, error) {
rows, err := s.store.ListSessionsByMeeting(meetingKey)
if err != nil {
return nil, err
}
out := make([]models.Session, 0, len(rows))
for _, row := range rows {
out = append(out, sessionToModel(row))
}
return out, nil
}
// ListDrivers returns ingested drivers for a session.
func (s *Service) ListDrivers(sessionKey int) ([]models.Driver, error) {
sess, err := s.store.GetSession(sessionKey)
if err != nil {
return nil, err
}
driverLinks, err := s.store.ListSessionDrivers(sessionKey)
if err != nil {
return nil, err
}
out := make([]models.Driver, 0, len(driverLinks))
for _, link := range driverLinks {
d, err := s.store.GetDriver(link.DriverNumber)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
out = append(out, driverToModel(sessionKey, sess.MeetingKey, link, d))
}
return out, nil
}
// ListResults returns ingested session results enriched with driver fields.
func (s *Service) ListResults(sessionKey int) ([]EnrichedResult, error) {
hub, err := s.GetRaceHub(sessionKey)
if err != nil {
return nil, err
}
return hub.Results, nil
}
// ListStartingGrid returns ingested starting grid rows enriched with driver fields.
func (s *Service) ListStartingGrid(sessionKey int) ([]EnrichedGrid, error) {
hub, err := s.GetRaceHub(sessionKey)
if err != nil {
return nil, err
}
return hub.StartingGrid, nil
}

View File

@@ -0,0 +1,66 @@
package query
const (
DatasetStatusAvailable = "available"
DatasetStatusMissing = "missing"
DataSourceLocal = "local"
DataSourceNone = "none"
DataSourceOpenF1 = "openf1"
ResponseSourceLocal = "local"
ResponseSourceNone = "none"
ResponseSourcePartial = "partial"
)
// DatasetInfo describes availability of a single dataset.
type DatasetInfo struct {
Status string `json:"status"`
Source string `json:"source"`
Count int `json:"count,omitempty"`
}
func availableLocal(count int) DatasetInfo {
return DatasetInfo{
Status: DatasetStatusAvailable,
Source: DataSourceLocal,
Count: count,
}
}
func missingDataset() DatasetInfo {
return DatasetInfo{
Status: DatasetStatusMissing,
Source: DataSourceNone,
Count: 0,
}
}
func responseSource(datasets map[string]DatasetInfo) string {
if len(datasets) == 0 {
return ResponseSourceNone
}
hasLocal := false
allMissing := true
for _, info := range datasets {
if info.Status == DatasetStatusAvailable && info.Source == DataSourceLocal {
hasLocal = true
allMissing = false
} else if info.Status == DatasetStatusAvailable {
allMissing = false
}
}
if allMissing {
return ResponseSourceNone
}
if hasLocal {
for _, info := range datasets {
if info.Status == DatasetStatusMissing {
return ResponseSourcePartial
}
}
return ResponseSourceLocal
}
return ResponseSourceNone
}

View File

@@ -0,0 +1,198 @@
package query
import (
"database/sql"
"path/filepath"
"testing"
"github.com/AmanTahiliani/box-box/internal/store"
)
func openTestService(t *testing.T) *Service {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
st, err := store.Open(path)
if err != nil {
t.Fatalf("store.Open() error = %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return NewService(st)
}
func seedRaceHubData(t *testing.T, st *store.Store) {
t.Helper()
meetingKey := 1229
sessionKey := 9472
if err := st.UpsertMeeting(store.Meeting{
MeetingKey: meetingKey,
MeetingName: "Monaco",
MeetingOfficialName: "FORMULA 1 GRAND PRIX DE MONACO 2025",
Location: "Monaco",
CountryCode: "MON",
CountryName: "Monaco",
CircuitKey: 10,
CircuitShortName: "Monaco",
Year: 2025,
DateStart: "2025-05-23T00:00:00+00:00",
DateEnd: "2025-05-25T00:00:00+00:00",
}); err != nil {
t.Fatalf("UpsertMeeting() error = %v", err)
}
if err := st.UpsertSession(store.Session{
SessionKey: sessionKey,
MeetingKey: meetingKey,
SessionName: "Race",
SessionType: "Race",
CircuitKey: 10,
DateStart: "2025-05-25T13:00:00+00:00",
DateEnd: "2025-05-25T15:00:00+00:00",
}); err != nil {
t.Fatalf("UpsertSession() error = %v", err)
}
if err := st.UpsertDriver(store.Driver{
DriverNumber: 1,
FullName: "Max Verstappen",
NameAcronym: "VER",
TeamName: "Red Bull Racing",
TeamColour: "3671C6",
}); err != nil {
t.Fatalf("UpsertDriver() error = %v", err)
}
if err := st.UpsertSessionDriver(store.SessionDriver{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
TeamName: "Red Bull Racing",
TeamColour: "3671C6",
}); err != nil {
t.Fatalf("UpsertSessionDriver() error = %v", err)
}
if err := st.UpsertStartingGridEntry(store.StartingGridEntry{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
Position: 1,
LapDuration: 71.234,
}); err != nil {
t.Fatalf("UpsertStartingGridEntry() error = %v", err)
}
}
func TestGetRaceHubMissingSession(t *testing.T) {
svc := openTestService(t)
hub, err := svc.GetRaceHub(9472)
if err != nil {
t.Fatalf("GetRaceHub() error = %v", err)
}
if hub.Source != ResponseSourceNone {
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourceNone)
}
if hub.Datasets["session"].Status != DatasetStatusMissing {
t.Fatalf("session status = %q, want %q", hub.Datasets["session"].Status, DatasetStatusMissing)
}
if hub.Meeting != nil || hub.Session != nil {
t.Fatal("expected no meeting/session for missing session")
}
}
func TestGetRaceHubPartialData(t *testing.T) {
svc := openTestService(t)
seedRaceHubData(t, svc.store)
hub, err := svc.GetRaceHub(9472)
if err != nil {
t.Fatalf("GetRaceHub() error = %v", err)
}
if hub.Source != ResponseSourcePartial {
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourcePartial)
}
if hub.Session == nil || hub.Meeting == nil {
t.Fatal("expected meeting and session")
}
if len(hub.Drivers) != 1 || hub.Drivers[0].NameAcronym != "VER" {
t.Fatalf("Drivers = %+v, want one VER entry", hub.Drivers)
}
if hub.Datasets["results"].Status != DatasetStatusMissing {
t.Fatalf("results status = %q, want %q", hub.Datasets["results"].Status, DatasetStatusMissing)
}
if len(hub.StartingGrid) != 1 {
t.Fatalf("StartingGrid len = %d, want 1", len(hub.StartingGrid))
}
if hub.StartingGrid[0].NameAcronym != "VER" {
t.Fatalf("StartingGrid driver = %+v, want enriched VER", hub.StartingGrid[0])
}
}
func TestGetRaceHubCompleteData(t *testing.T) {
svc := openTestService(t)
seedRaceHubData(t, svc.store)
if err := svc.store.UpsertSessionResult(store.SessionResult{
SessionKey: 9472,
DriverNumber: 1,
MeetingKey: 1229,
Position: 1,
Points: 25,
NumberOfLaps: 78,
DurationJSON: "5234.567",
GapToLeaderJSON: "0",
}); err != nil {
t.Fatalf("UpsertSessionResult() error = %v", err)
}
hub, err := svc.GetRaceHub(9472)
if err != nil {
t.Fatalf("GetRaceHub() error = %v", err)
}
if hub.Source != ResponseSourceLocal {
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourceLocal)
}
if len(hub.Results) != 1 {
t.Fatalf("Results len = %d, want 1", len(hub.Results))
}
if hub.Results[0].FullName != "Max Verstappen" {
t.Fatalf("Results[0].FullName = %q, want Max Verstappen", hub.Results[0].FullName)
}
if hub.Datasets["results"].Count != 1 {
t.Fatalf("results count = %d, want 1", hub.Datasets["results"].Count)
}
}
func TestListMeetingsByYear(t *testing.T) {
svc := openTestService(t)
seedRaceHubData(t, svc.store)
meetings, err := svc.ListMeetingsByYear(2025)
if err != nil {
t.Fatalf("ListMeetingsByYear() error = %v", err)
}
if len(meetings) != 1 || meetings[0].MeetingName != "Monaco" {
t.Fatalf("ListMeetingsByYear() = %+v, want Monaco meeting", meetings)
}
empty, err := svc.ListMeetingsByYear(2024)
if err != nil {
t.Fatalf("ListMeetingsByYear(2024) error = %v", err)
}
if len(empty) != 0 {
t.Fatalf("ListMeetingsByYear(2024) len = %d, want 0", len(empty))
}
}
func TestListDriversRequiresSession(t *testing.T) {
svc := openTestService(t)
_, err := svc.ListDrivers(9472)
if err == nil {
t.Fatal("ListDrivers() error = nil, want sql.ErrNoRows")
}
if err != sql.ErrNoRows {
t.Fatalf("ListDrivers() error = %v, want sql.ErrNoRows", err)
}
}

155
internal/query/racehub.go Normal file
View File

@@ -0,0 +1,155 @@
package query
import (
"database/sql"
"errors"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
// Service assembles store-backed read models.
type Service struct {
store *store.Store
}
// NewService creates a query service over a domain store.
func NewService(st *store.Store) *Service {
return &Service{store: st}
}
// EnrichedResult is a session result with driver identity fields.
type EnrichedResult struct {
models.SessionResult
NameAcronym string `json:"name_acronym"`
FullName string `json:"full_name"`
TeamName string `json:"team_name"`
TeamColour string `json:"team_colour"`
}
// EnrichedGrid is a starting grid row with driver identity fields.
type EnrichedGrid struct {
models.StartingGrid
NameAcronym string `json:"name_acronym"`
FullName string `json:"full_name"`
TeamName string `json:"team_name"`
TeamColour string `json:"team_colour"`
}
// RaceHub is the local-first Race Hub read model for one session.
type RaceHub struct {
Source string `json:"source"`
SessionKey int `json:"session_key"`
Datasets map[string]DatasetInfo `json:"datasets"`
Meeting *models.Meeting `json:"meeting,omitempty"`
Session *models.Session `json:"session,omitempty"`
Drivers []models.Driver `json:"drivers"`
Results []EnrichedResult `json:"results"`
StartingGrid []EnrichedGrid `json:"starting_grid"`
}
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
hub := RaceHub{
SessionKey: sessionKey,
Datasets: map[string]DatasetInfo{
"meeting": missingDataset(),
"session": missingDataset(),
"drivers": missingDataset(),
"results": missingDataset(),
"starting_grid": missingDataset(),
},
Drivers: []models.Driver{},
Results: []EnrichedResult{},
StartingGrid: []EnrichedGrid{},
}
sess, err := s.store.GetSession(sessionKey)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
hub.Source = ResponseSourceNone
return hub, nil
}
return RaceHub{}, err
}
sessionModel := sessionToModel(sess)
hub.Session = &sessionModel
hub.Datasets["session"] = availableLocal(1)
meeting, err := s.store.GetMeeting(sess.MeetingKey)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return RaceHub{}, err
}
} else {
meetingModel := meetingToModel(meeting)
hub.Meeting = &meetingModel
hub.Datasets["meeting"] = availableLocal(1)
}
driverLinks, err := s.store.ListSessionDrivers(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(driverLinks) > 0 {
drivers := make([]models.Driver, 0, len(driverLinks))
for _, link := range driverLinks {
d, err := s.store.GetDriver(link.DriverNumber)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return RaceHub{}, err
}
drivers = append(drivers, driverToModel(sessionKey, sess.MeetingKey, link, d))
}
hub.Drivers = drivers
hub.Datasets["drivers"] = availableLocal(len(drivers))
}
driverByNumber := make(map[int]models.Driver, len(hub.Drivers))
for _, d := range hub.Drivers {
driverByNumber[d.DriverNumber] = d
}
results, err := s.store.ListSessionResults(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(results) > 0 {
enriched := make([]EnrichedResult, 0, len(results))
for _, r := range results {
e := EnrichedResult{SessionResult: resultToModel(r)}
if d, ok := driverByNumber[r.DriverNumber]; ok {
e.NameAcronym = d.NameAcronym
e.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
}
enriched = append(enriched, e)
}
hub.Results = enriched
hub.Datasets["results"] = availableLocal(len(enriched))
}
grid, err := s.store.ListStartingGrid(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(grid) > 0 {
enriched := make([]EnrichedGrid, 0, len(grid))
for _, g := range grid {
e := EnrichedGrid{StartingGrid: gridToModel(g)}
if d, ok := driverByNumber[g.DriverNumber]; ok {
e.NameAcronym = d.NameAcronym
e.FullName = d.FullName
e.TeamName = d.TeamName
e.TeamColour = d.TeamColour
}
enriched = append(enriched, e)
}
hub.StartingGrid = enriched
hub.Datasets["starting_grid"] = availableLocal(len(enriched))
}
hub.Source = responseSource(hub.Datasets)
return hub, nil
}