diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx index 1519c59..5fbf1c8 100644 --- a/frontend/src/test/CommandCenterPage.test.tsx +++ b/frontend/src/test/CommandCenterPage.test.tsx @@ -156,6 +156,7 @@ describe('CommandCenterPage', () => { race_control: [], weather: [], laps: [], + chapters: [], }) }) diff --git a/frontend/src/test/RaceHubPage.test.tsx b/frontend/src/test/RaceHubPage.test.tsx index 4e9eb1c..3e90681 100644 --- a/frontend/src/test/RaceHubPage.test.tsx +++ b/frontend/src/test/RaceHubPage.test.tsx @@ -155,6 +155,7 @@ const raceHub: RaceHub = { is_pit_out_lap: false, }, ], + chapters: [], } const weekend: Weekend = { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 22befb4..81d9a56 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -91,6 +91,17 @@ export interface RaceHub { race_control: RaceControlMessage[] weather: WeatherSample[] laps: Lap[] + chapters: Chapter[] +} + +export interface Chapter { + kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string + title: string + start_lap: number + end_lap: number + start_time?: string + end_time?: string + driver_numbers: number[] } export interface Stint { diff --git a/internal/chapters/chapters.go b/internal/chapters/chapters.go new file mode 100644 index 0000000..d5a937c --- /dev/null +++ b/internal/chapters/chapters.go @@ -0,0 +1,628 @@ +package chapters + +import ( + "fmt" + "math" + "sort" + "strings" + "time" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +const ( + KindStart = "start" + KindSafetyCar = "safety_car" + KindVirtualSafetyCar = "virtual_safety_car" + KindRedFlag = "red_flag" + KindPitPhase = "pit_phase" + KindDecisiveSwing = "decisive_swing" + KindFinish = "finish" + + pitPhaseWindowLaps = 3 + pitPhaseShare = 0.30 + minPitPhaseStops = 2 + maxDecisiveSwings = 3 + decisiveAfterLap = 5 + structuralPriority = 110 + flagPriority = 100 + pitPhasePriority = 50 + decisivePriority = 40 +) + +type RaceControl = models.RaceControl +type PositionSample = models.Position +type Lap = models.Lap + +// Chapter is a deterministic replay segment derived from timing and race-control data. +type Chapter struct { + Kind string `json:"kind"` + Title string `json:"title"` + StartLap int `json:"start_lap"` + EndLap int `json:"end_lap"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` + DriverNumbers []int `json:"driver_numbers"` +} + +// Detect builds replay chapters from already-loaded race-hub datasets. +func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter { + totalLaps = normalizeTotalLaps(totalLaps, laps, rc) + if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 { + return []Chapter{} + } + if totalLaps <= 0 { + totalLaps = 1 + } + + lapIndex := buildLapIndex(laps) + chapters := []Chapter{ + { + Kind: KindStart, + Title: "Start", + StartLap: 1, + EndLap: minInt(1, totalLaps), + StartTime: lapIndex.lapStart(1), + EndTime: lapIndex.lapEnd(1), + }, + } + chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...) + chapters = append(chapters, detectPitPhases(laps, lapIndex)...) + chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...) + chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps)) + + return resolveConflicts(chapters) +} + +func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int { + for _, l := range laps { + if l.LapNumber > totalLaps { + totalLaps = l.LapNumber + } + } + for _, msg := range rc { + if msg.LapNumber != nil && *msg.LapNumber > totalLaps { + totalLaps = *msg.LapNumber + } + } + return totalLaps +} + +type lapIndex struct { + byLap map[int]string + events []lapEvent +} + +type lapEvent struct { + lap int + at time.Time +} + +func buildLapIndex(laps []Lap) lapIndex { + idx := lapIndex{byLap: map[int]string{}} + for _, l := range laps { + if l.LapNumber <= 0 || l.DateStart == "" { + continue + } + if _, ok := idx.byLap[l.LapNumber]; !ok { + idx.byLap[l.LapNumber] = l.DateStart + } + at, ok := parseTime(l.DateStart) + if ok { + idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at}) + } + } + sort.Slice(idx.events, func(i, j int) bool { + if idx.events[i].at.Equal(idx.events[j].at) { + return idx.events[i].lap < idx.events[j].lap + } + return idx.events[i].at.Before(idx.events[j].at) + }) + return idx +} + +func (idx lapIndex) lapStart(lap int) string { + return idx.byLap[lap] +} + +func (idx lapIndex) lapEnd(lap int) string { + if v := idx.byLap[lap+1]; v != "" { + return v + } + return idx.byLap[lap] +} + +func (idx lapIndex) lapForTime(raw string) int { + at, ok := parseTime(raw) + if !ok || len(idx.events) == 0 { + return 0 + } + lap := 0 + for _, event := range idx.events { + if event.at.After(at) { + break + } + lap = event.lap + } + if lap == 0 { + return idx.events[0].lap + } + return lap +} + +type flagState struct { + startLap int + startTime string +} + +func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter { + var chapters []Chapter + active := map[string]flagState{} + for _, msg := range rc { + kind, ok := flagKind(msg) + if !ok && greenFlagClear(msg) { + for activeKind, st := range active { + lap := messageLap(msg, idx) + if lap <= 0 { + lap = st.startLap + } + endLap := clampLap(lap, st.startLap, totalLaps) + chapters = append(chapters, Chapter{ + Kind: activeKind, + Title: flagTitle(activeKind, st.startLap, endLap), + StartLap: st.startLap, + EndLap: endLap, + StartTime: st.startTime, + EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)), + }) + delete(active, activeKind) + } + continue + } + if !ok { + continue + } + lap := messageLap(msg, idx) + if lap <= 0 { + lap = 1 + } + if flagCleared(msg) { + st, ok := active[kind] + if !ok { + continue + } + endLap := clampLap(lap, st.startLap, totalLaps) + chapters = append(chapters, Chapter{ + Kind: kind, + Title: flagTitle(kind, st.startLap, endLap), + StartLap: st.startLap, + EndLap: endLap, + StartTime: st.startTime, + EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)), + }) + delete(active, kind) + continue + } + if flagStarted(msg) { + active[kind] = flagState{ + startLap: clampLap(lap, 1, totalLaps), + startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)), + } + } + } + for kind, st := range active { + endLap := totalLaps + chapters = append(chapters, Chapter{ + Kind: kind, + Title: flagTitle(kind, st.startLap, endLap), + StartLap: st.startLap, + EndLap: endLap, + StartTime: st.startTime, + EndTime: idx.lapEnd(endLap), + }) + } + return chapters +} + +func flagKind(msg RaceControl) (string, bool) { + text := upperText(string(msg.Category), string(msg.Flag), msg.Message) + if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") { + return KindVirtualSafetyCar, true + } + if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) { + return KindRedFlag, true + } + if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar { + return KindSafetyCar, true + } + return "", false +} + +func flagStarted(msg RaceControl) bool { + text := upperText(string(msg.Category), string(msg.Flag), msg.Message) + if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") { + return false + } + return strings.Contains(text, "DEPLOY") || + strings.Contains(text, "RED FLAG") || + strings.Contains(text, "VIRTUAL SAFETY CAR") || + strings.Contains(text, "VSC") || + strings.Contains(text, "SAFETY CAR") || + string(msg.Flag) == string(models.FlagRed) +} + +func flagCleared(msg RaceControl) bool { + text := upperText(string(msg.Category), string(msg.Flag), msg.Message) + return strings.Contains(text, "CLEAR") || + strings.Contains(text, "ENDING") || + strings.Contains(text, "IN THIS LAP") || + strings.Contains(text, "GREEN") +} + +func greenFlagClear(msg RaceControl) bool { + text := upperText(string(msg.Flag), msg.Message) + return strings.Contains(text, "GREEN") +} + +func flagTitle(kind string, startLap, endLap int) string { + name := "Flag period" + switch kind { + case KindSafetyCar: + name = "Safety Car" + case KindVirtualSafetyCar: + name = "Virtual Safety Car" + case KindRedFlag: + name = "Red Flag" + } + return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap) +} + +func detectPitPhases(laps []Lap, idx lapIndex) []Chapter { + type pitOut struct { + lap int + driver int + } + var stops []pitOut + for _, l := range laps { + if l.IsPitOutLap && l.LapNumber > 0 { + stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber}) + } + } + if len(stops) < minPitPhaseStops { + return nil + } + sort.Slice(stops, func(i, j int) bool { + if stops[i].lap == stops[j].lap { + return stops[i].driver < stops[j].driver + } + return stops[i].lap < stops[j].lap + }) + needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare)) + if needed < minPitPhaseStops { + needed = minPitPhaseStops + } + + var windows []Chapter + for i := 0; i < len(stops); i++ { + start := stops[i].lap + end := start + pitPhaseWindowLaps - 1 + drivers := map[int]bool{} + count := 0 + for _, stop := range stops { + if stop.lap < start || stop.lap > end { + continue + } + count++ + drivers[stop.driver] = true + } + if count < needed { + continue + } + ch := Chapter{ + Kind: KindPitPhase, + Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end), + StartLap: start, + EndLap: end, + StartTime: idx.lapStart(start), + EndTime: idx.lapEnd(end), + DriverNumbers: sortedDriverNumbers(drivers), + } + if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 { + last := &windows[len(windows)-1] + if ch.EndLap > last.EndLap { + last.EndLap = ch.EndLap + last.EndTime = idx.lapEnd(last.EndLap) + } + drivers := sliceToSet(last.DriverNumbers) + for _, driver := range ch.DriverNumbers { + drivers[driver] = true + } + last.DriverNumbers = sortedDriverNumbers(drivers) + last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap) + continue + } + windows = append(windows, ch) + } + return windows +} + +type swingCandidate struct { + chapter Chapter + significance int +} + +func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter { + if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap { + return nil + } + snapshots := buildPositionSnapshots(positions, idx, totalLaps) + if len(snapshots) == 0 { + return nil + } + final := snapshots[totalLaps] + if len(final) == 0 { + for lap := totalLaps - 1; lap >= 1; lap-- { + if len(snapshots[lap]) > 0 { + final = snapshots[lap] + break + } + } + } + var candidates []swingCandidate + seenDriver := map[int]bool{} + for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ { + prev := snapshots[lap-1] + curr := snapshots[lap] + if len(prev) == 0 || len(curr) == 0 { + continue + } + for driver, pos := range curr { + prevPos, ok := prev[driver] + if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] { + continue + } + finalPos, ok := final[driver] + if !ok || finalPos > pos { + continue + } + overtaken := driverAtPosition(curr, prevPos, driver) + drivers := []int{driver} + if overtaken != 0 { + drivers = append(drivers, overtaken) + } + candidates = append(candidates, swingCandidate{ + chapter: Chapter{ + Kind: KindDecisiveSwing, + Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap), + StartLap: lap, + EndLap: lap, + StartTime: idx.lapStart(lap), + EndTime: idx.lapEnd(lap), + DriverNumbers: drivers, + }, + significance: (prevPos-pos)*10 + (6 - pos), + }) + seenDriver[driver] = true + } + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].significance == candidates[j].significance { + return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap + } + return candidates[i].significance > candidates[j].significance + }) + if len(candidates) > maxDecisiveSwings { + candidates = candidates[:maxDecisiveSwings] + } + out := make([]Chapter, 0, len(candidates)) + for _, c := range candidates { + out = append(out, c.chapter) + } + return out +} + +func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int { + byLap := map[int][]PositionSample{} + for _, p := range positions { + if p.Position <= 0 { + continue + } + lap := idx.lapForTime(p.Date) + if lap <= 0 || lap > totalLaps { + continue + } + byLap[lap] = append(byLap[lap], p) + } + last := map[int]int{} + snapshots := map[int]map[int]int{} + for lap := 1; lap <= totalLaps; lap++ { + for _, p := range byLap[lap] { + last[p.DriverNumber] = p.Position + } + if len(last) == 0 { + continue + } + cp := make(map[int]int, len(last)) + for driver, pos := range last { + cp[driver] = pos + } + snapshots[lap] = cp + } + return snapshots +} + +func driverAtPosition(snapshot map[int]int, pos int, exclude int) int { + for driver, driverPos := range snapshot { + if driver != exclude && driverPos == pos { + return driver + } + } + return 0 +} + +func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter { + finishLap := totalLaps + finishTime := idx.lapEnd(totalLaps) + for _, msg := range rc { + text := upperText(string(msg.Flag), msg.Message) + if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) { + if lap := messageLap(msg, idx); lap > 0 { + finishLap = lap + } + finishTime = firstNonEmpty(msg.Date, finishTime) + } + } + startLap := finishLap - 1 + if startLap < 1 { + startLap = 1 + } + return Chapter{ + Kind: KindFinish, + Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap), + StartLap: startLap, + EndLap: finishLap, + StartTime: idx.lapStart(startLap), + EndTime: finishTime, + } +} + +func resolveConflicts(chapters []Chapter) []Chapter { + normalized := make([]Chapter, 0, len(chapters)) + for _, ch := range chapters { + if ch.StartLap <= 0 { + ch.StartLap = 1 + } + if ch.EndLap <= 0 { + ch.EndLap = ch.StartLap + } + if ch.EndLap < ch.StartLap { + ch.EndLap = ch.StartLap + } + if ch.DriverNumbers == nil { + ch.DriverNumbers = []int{} + } + normalized = append(normalized, ch) + } + sort.SliceStable(normalized, func(i, j int) bool { + if normalized[i].StartLap == normalized[j].StartLap { + return priority(normalized[i].Kind) > priority(normalized[j].Kind) + } + return normalized[i].StartLap < normalized[j].StartLap + }) + + out := make([]Chapter, 0, len(normalized)) + for _, ch := range normalized { + if len(out) == 0 { + out = append(out, ch) + continue + } + last := &out[len(out)-1] + if ch.StartLap > last.EndLap { + out = append(out, ch) + continue + } + if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) { + continue + } + if priority(ch.Kind) > priority(last.Kind) { + if last.StartLap < ch.StartLap { + last.EndLap = ch.StartLap - 1 + out = append(out, ch) + } else { + *last = ch + } + continue + } + if ch.EndLap > last.EndLap { + ch.StartLap = last.EndLap + 1 + if ch.StartLap <= ch.EndLap { + out = append(out, ch) + } + } + } + return out +} + +func isFlag(kind string) bool { + return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag +} + +func priority(kind string) int { + switch kind { + case KindStart, KindFinish: + return structuralPriority + case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag: + return flagPriority + case KindPitPhase: + return pitPhasePriority + case KindDecisiveSwing: + return decisivePriority + default: + return 0 + } +} + +func messageLap(msg RaceControl, idx lapIndex) int { + if msg.LapNumber != nil && *msg.LapNumber > 0 { + return *msg.LapNumber + } + return idx.lapForTime(msg.Date) +} + +func clampLap(lap, minLap, maxLap int) int { + if lap < minLap { + return minLap + } + if maxLap > 0 && lap > maxLap { + return maxLap + } + return lap +} + +func parseTime(raw string) (time.Time, bool) { + if raw == "" { + return time.Time{}, false + } + at, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{}, false + } + return at, true +} + +func upperText(parts ...string) string { + return strings.ToUpper(strings.Join(parts, " ")) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func sortedDriverNumbers(drivers map[int]bool) []int { + out := make([]int, 0, len(drivers)) + for driver := range drivers { + out = append(out, driver) + } + sort.Ints(out) + return out +} + +func sliceToSet(values []int) map[int]bool { + out := make(map[int]bool, len(values)) + for _, value := range values { + out[value] = true + } + return out +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/chapters/chapters_test.go b/internal/chapters/chapters_test.go new file mode 100644 index 0000000..fb58637 --- /dev/null +++ b/internal/chapters/chapters_test.go @@ -0,0 +1,218 @@ +package chapters + +import ( + "fmt" + "testing" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +func TestDetectStructuralChapters(t *testing.T) { + chapters := Detect(nil, nil, testLaps(10, nil), 10) + + if len(chapters) < 2 { + t.Fatalf("chapters len = %d, want at least start and finish", len(chapters)) + } + if got := chapters[0]; got.Kind != KindStart || got.StartLap != 1 || got.EndLap != 1 { + t.Fatalf("start chapter = %+v, want L1-L1", got) + } + got := chapters[len(chapters)-1] + if got.Kind != KindFinish || got.StartLap != 9 || got.EndLap != 10 { + t.Fatalf("finish chapter = %+v, want L9-L10", got) + } +} + +func TestDetectReturnsEmptyWithoutData(t *testing.T) { + if chapters := Detect(nil, nil, nil, 0); len(chapters) != 0 { + t.Fatalf("chapters = %+v, want empty", chapters) + } +} + +func TestDetectFlagPeriods(t *testing.T) { + tests := []struct { + name string + start RaceControl + end RaceControl + wantKind string + wantTitle string + }{ + { + name: "safety car", + start: rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"), + end: rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"), + wantKind: KindSafetyCar, + wantTitle: "Safety Car (L12-L15)", + }, + { + name: "virtual safety car", + start: rc(22, models.CategoryOther, "", "VSC DEPLOYED"), + end: rc(24, models.CategoryOther, "", "VSC ENDING"), + wantKind: KindVirtualSafetyCar, + wantTitle: "Virtual Safety Car (L22-L24)", + }, + { + name: "red flag", + start: rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"), + end: rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"), + wantKind: KindRedFlag, + wantTitle: "Red Flag (L31-L33)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chapters := Detect([]RaceControl{tt.start, tt.end}, nil, testLaps(40, nil), 40) + got := findKind(chapters, tt.wantKind) + if got == nil { + t.Fatalf("chapters = %+v, want %s", chapters, tt.wantKind) + } + if got.StartLap != rcLap(tt.start) || got.EndLap != rcLap(tt.end) || got.Title != tt.wantTitle { + t.Fatalf("flag chapter = %+v, want %s", *got, tt.wantTitle) + } + }) + } +} + +func TestDetectPitPhaseFromPitOutLapCluster(t *testing.T) { + pitOuts := map[int][]int{ + 5: {1}, + 10: {2}, + 20: {3}, + 21: {4}, + 22: {5}, + 30: {6}, + 35: {7}, + 40: {8}, + 45: {9}, + 50: {10}, + } + chapters := Detect(nil, nil, testLaps(55, pitOuts), 55) + + got := findKind(chapters, KindPitPhase) + if got == nil { + t.Fatalf("chapters = %+v, want pit phase", chapters) + } + if got.StartLap != 20 || got.EndLap != 22 { + t.Fatalf("pit phase = %+v, want L20-L22", *got) + } + if len(got.DriverNumbers) != 3 || got.DriverNumbers[0] != 3 || got.DriverNumbers[2] != 5 { + t.Fatalf("pit phase drivers = %v, want [3 4 5]", got.DriverNumbers) + } +} + +func TestDetectDecisiveSwingPersistsToFinish(t *testing.T) { + positions := []PositionSample{ + pos(1, 1, 1), + pos(1, 16, 4), + pos(1, 55, 3), + pos(6, 16, 3), + pos(6, 55, 4), + pos(8, 44, 5), + pos(8, 63, 6), + pos(10, 44, 6), + } + + chapters := Detect(nil, positions, testLaps(12, nil), 12) + got := findKind(chapters, KindDecisiveSwing) + if got == nil { + t.Fatalf("chapters = %+v, want decisive swing", chapters) + } + if got.StartLap != 6 || got.EndLap != 6 { + t.Fatalf("swing lap = %+v, want L6", *got) + } + if len(got.DriverNumbers) != 2 || got.DriverNumbers[0] != 16 || got.DriverNumbers[1] != 55 { + t.Fatalf("swing drivers = %v, want [16 55]", got.DriverNumbers) + } +} + +func TestDetectFlagPeriodsWinOverConflictingChapters(t *testing.T) { + pitOuts := map[int][]int{ + 12: {1, 2}, + 13: {3, 4}, + 14: {5, 6}, + } + rcs := []RaceControl{ + rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"), + rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"), + } + + chapters := Detect(rcs, nil, testLaps(20, pitOuts), 20) + if got := findKind(chapters, KindSafetyCar); got == nil || got.StartLap != 12 || got.EndLap != 15 { + t.Fatalf("chapters = %+v, want safety car L12-L15", chapters) + } + if got := findKind(chapters, KindPitPhase); got != nil { + t.Fatalf("pit phase = %+v, want omitted under safety car", *got) + } + for i := 1; i < len(chapters); i++ { + if chapters[i].StartLap <= chapters[i-1].EndLap { + t.Fatalf("chapters overlap at %d: %+v then %+v", i, chapters[i-1], chapters[i]) + } + } +} + +func rcLap(r RaceControl) int { + if r.LapNumber == nil { + return 0 + } + return *r.LapNumber +} + +func findKind(chapters []Chapter, kind string) *Chapter { + for i := range chapters { + if chapters[i].Kind == kind { + return &chapters[i] + } + } + return nil +} + +func testLaps(total int, pitOuts map[int][]int) []Lap { + var laps []Lap + for lap := 1; lap <= total; lap++ { + drivers := []int{1} + if pitDrivers := pitOuts[lap]; len(pitDrivers) > 0 { + drivers = pitDrivers + } + for _, driver := range drivers { + laps = append(laps, Lap{ + DriverNumber: driver, + LapNumber: lap, + DateStart: lapTime(lap), + IsPitOutLap: containsDriver(pitOuts[lap], driver), + }) + } + } + return laps +} + +func rc(lap int, category models.RaceControlCategory, flag models.Flag, message string) RaceControl { + return RaceControl{ + Category: category, + Flag: flag, + Message: message, + LapNumber: &lap, + Date: lapTime(lap), + } +} + +func pos(lap int, driver int, position int) PositionSample { + return PositionSample{ + DriverNumber: driver, + Position: position, + Date: lapTime(lap), + } +} + +func lapTime(lap int) string { + minute := lap - 1 + return fmt.Sprintf("2025-05-25T13:%02d:00Z", minute) +} + +func containsDriver(drivers []int, driver int) bool { + for _, candidate := range drivers { + if candidate == driver { + return true + } + } + return false +} diff --git a/internal/query/racehub.go b/internal/query/racehub.go index 1924599..5d15884 100644 --- a/internal/query/racehub.go +++ b/internal/query/racehub.go @@ -4,6 +4,7 @@ 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" ) @@ -52,6 +53,7 @@ type RaceHub struct { 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. @@ -80,6 +82,7 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) { RaceControl: []models.RaceControl{}, Weather: []models.Weather{}, Laps: []models.Lap{}, + Chapters: []chapters.Chapter{}, } sess, err := s.store.GetSession(sessionKey) @@ -248,6 +251,22 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) { 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 +} diff --git a/internal/web/racehub.go b/internal/web/racehub.go index 3f43955..db8725e 100644 --- a/internal/web/racehub.go +++ b/internal/web/racehub.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "github.com/AmanTahiliani/box-box/internal/chapters" "github.com/AmanTahiliani/box-box/internal/models" "github.com/AmanTahiliani/box-box/internal/query" ) @@ -42,5 +43,6 @@ func emptyRaceHub(sessionKey int) query.RaceHub { Drivers: []models.Driver{}, Results: []query.EnrichedResult{}, StartingGrid: []query.EnrichedGrid{}, + Chapters: []chapters.Chapter{}, } } diff --git a/internal/web/racehub_test.go b/internal/web/racehub_test.go index fd95b7f..50755d5 100644 --- a/internal/web/racehub_test.go +++ b/internal/web/racehub_test.go @@ -119,6 +119,66 @@ func TestHandleRaceHubWithLocalData(t *testing.T) { } } +func TestHandleRaceHubIncludesChapters(t *testing.T) { + st := openTestStore(t) + seedRaceHubStore(t, st) + sessionKey := 9472 + meetingKey := 1229 + + for lap := 1; lap <= 12; lap++ { + if err := st.UpsertLap(store.Lap{ + SessionKey: sessionKey, + DriverNumber: 1, + MeetingKey: meetingKey, + LapNumber: lap, + DateStart: time.Date(2025, 5, 25, 13, lap-1, 0, 0, time.UTC).Format(time.RFC3339), + LapDuration: 75, + }); err != nil { + t.Fatalf("UpsertLap(%d) error = %v", lap, err) + } + } + for _, sample := range []store.PositionSample{ + {SessionKey: sessionKey, DriverNumber: 1, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 1}, + {SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 4}, + {SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:00:00Z", Position: 3}, + {SessionKey: sessionKey, DriverNumber: 16, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 3}, + {SessionKey: sessionKey, DriverNumber: 55, MeetingKey: meetingKey, Date: "2025-05-25T13:05:00Z", Position: 4}, + } { + if err := st.UpsertPositionSample(sample); err != nil { + t.Fatalf("UpsertPositionSample() error = %v", err) + } + } + + 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 len(hub.Chapters) == 0 { + t.Fatalf("chapters = %+v, want generated chapters", hub.Chapters) + } + if hub.Chapters[0].Kind != "start" { + t.Fatalf("first chapter = %+v, want start", hub.Chapters[0]) + } + foundSwing := false + for _, chapter := range hub.Chapters { + if chapter.Kind == "decisive_swing" { + foundSwing = true + } + } + if !foundSwing { + t.Fatalf("chapters = %+v, want decisive_swing", hub.Chapters) + } +} + func TestHandleMeetingsSourceLocal(t *testing.T) { st := openTestStore(t) seedRaceHubStore(t, st)