Files
box-box/internal/query/racehub.go
AmanTahiliani 5b112fb012 feat: add narrative chapter headlines and race-hub chapter strip (#21)
Generate deterministic template headlines server-side for each replay chapter
kind, expose Chapter.headline in the race-hub payload, and render a horizontal
chapter strip on the story view with active-chapter highlighting, click-to-jump,
and a 90-second tour mode built on the existing scrubber playback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:23:11 -04:00

301 lines
8.6 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.Chapters = chapters.ApplyHeadlines(
hub.Chapters,
chapters.BuildDriverMap(hub.Drivers, driverIdentityInputs(hub.Results)),
hub.RaceControl,
winnerDriverNumber(hub.Results),
)
hub.Source = responseSource(hub.Datasets)
return hub, nil
}
func driverIdentityInputs(results []EnrichedResult) []chapters.DriverIdentityInput {
out := make([]chapters.DriverIdentityInput, 0, len(results))
for _, r := range results {
out = append(out, chapters.DriverIdentityInput{
DriverNumber: r.DriverNumber,
NameAcronym: r.NameAcronym,
FullName: r.FullName,
TeamName: r.TeamName,
})
}
return out
}
func winnerDriverNumber(results []EnrichedResult) int {
for _, r := range results {
if r.Position == 1 {
return r.DriverNumber
}
}
return 0
}
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
}