Files
box-box/internal/query/racehub.go
AmanTahiliani 7212eb9b44 feat: detect race replay chapters
Add deterministic server-side chapter detection for starts, flag periods, pit phases, decisive top-five swings, and finishes, then expose chapters on the race-hub payload.

Spike result: race-hub already loads race control, positions, and laps in one read model. The requested Detect signature does not include pit stops, so pit phases use IsPitOutLap clusters as the local deterministic pit-stop proxy.
2026-07-11 18:04:44 -04:00

273 lines
7.9 KiB
Go

package query
import (
"database/sql"
"errors"
"github.com/AmanTahiliani/box-box/internal/chapters"
"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"`
Stints []models.Stint `json:"stints"`
PitStops []models.Pit `json:"pit_stops"`
Positions []models.Position `json:"positions"`
RaceControl []models.RaceControl `json:"race_control"`
Weather []models.Weather `json:"weather"`
Laps []models.Lap `json:"laps"`
Chapters []chapters.Chapter `json:"chapters"`
}
// 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(),
"stints": missingDataset(),
"pit_stops": missingDataset(),
"positions": missingDataset(),
"race_control": missingDataset(),
"weather": missingDataset(),
"laps": missingDataset(),
},
Drivers: []models.Driver{},
Results: []EnrichedResult{},
StartingGrid: []EnrichedGrid{},
Stints: []models.Stint{},
PitStops: []models.Pit{},
Positions: []models.Position{},
RaceControl: []models.RaceControl{},
Weather: []models.Weather{},
Laps: []models.Lap{},
Chapters: []chapters.Chapter{},
}
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)
}
if sess.IsCancelled || (hub.Meeting != nil && hub.Meeting.IsCancelled) {
hub.Source = ResponseSourceCancelled
hub.Datasets = cancelledDatasets()
return hub, nil
}
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))
} else if !isGridExpected(sessionModel.SessionType, sessionModel.SessionName) {
hub.Datasets["starting_grid"] = skippedNA()
}
stints, err := s.store.ListStints(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(stints) > 0 {
hub.Stints = make([]models.Stint, 0, len(stints))
for _, st := range stints {
hub.Stints = append(hub.Stints, stintToModel(st))
}
hub.Datasets["stints"] = availableLocal(len(hub.Stints))
}
pitStops, err := s.store.ListPitStops(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(pitStops) > 0 {
hub.PitStops = make([]models.Pit, 0, len(pitStops))
for _, p := range pitStops {
hub.PitStops = append(hub.PitStops, pitStopToModel(p))
}
hub.Datasets["pit_stops"] = availableLocal(len(hub.PitStops))
}
positions, err := s.store.ListPositionSamples(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(positions) > 0 {
hub.Positions = make([]models.Position, 0, len(positions))
for _, p := range positions {
hub.Positions = append(hub.Positions, positionToModel(p))
}
hub.Datasets["positions"] = availableLocal(len(hub.Positions))
}
raceControl, err := s.store.ListRaceControlMessages(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(raceControl) > 0 {
hub.RaceControl = make([]models.RaceControl, 0, len(raceControl))
for _, rc := range raceControl {
hub.RaceControl = append(hub.RaceControl, raceControlToModel(rc))
}
hub.Datasets["race_control"] = availableLocal(len(hub.RaceControl))
}
weather, err := s.store.ListWeatherSamples(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(weather) > 0 {
hub.Weather = make([]models.Weather, 0, len(weather))
for _, w := range weather {
hub.Weather = append(hub.Weather, weatherToModel(w))
}
hub.Datasets["weather"] = availableLocal(len(hub.Weather))
}
laps, err := s.store.ListLaps(sessionKey)
if err != nil {
return RaceHub{}, err
}
if len(laps) > 0 {
hub.Laps = make([]models.Lap, 0, len(laps))
for _, l := range laps {
hub.Laps = append(hub.Laps, lapToModel(l))
}
hub.Datasets["laps"] = availableLocal(len(hub.Laps))
}
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
hub.Source = responseSource(hub.Datasets)
return hub, nil
}
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
total := 0
for _, result := range results {
if result.NumberOfLaps > total {
total = result.NumberOfLaps
}
}
for _, lap := range laps {
if lap.LapNumber > total {
total = lap.LapNumber
}
}
return total
}