diff --git a/frontend/src/components/ChapterStrip.tsx b/frontend/src/components/ChapterStrip.tsx new file mode 100644 index 0000000..13b504e --- /dev/null +++ b/frontend/src/components/ChapterStrip.tsx @@ -0,0 +1,87 @@ +import type { Chapter } from '../types' +import { + activeChapterIndex, + chapterKindLabel, + chapterLapRange, + chapterStartScrub, +} from '../lib/chapters' +import '../styles/chapters.css' + +interface Props { + chapters: Chapter[] + scrubTime: number | null + tMin: number + tRange: number + tourActive: boolean + tourChapterIndex: number | null + onChapterClick: (index: number, scrub: number) => void + onTourToggle: () => void +} + +export function ChapterStrip({ + chapters, + scrubTime, + tMin, + tRange, + tourActive, + tourChapterIndex, + onChapterClick, + onTourToggle, +}: Props) { + if (chapters.length === 0) { + return ( +
+

No story chapters for this session.

+
+ ) + } + + const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange) + + return ( +
+
+ Race chapters +
+ +
+
+
+ {chapters.map((chapter, index) => { + const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1) + const isActive = tourActive + ? tourChapterIndex === index + : activeIndex === index + const headline = chapter.headline || chapter.title + + return ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/RaceStoryCanvas.tsx b/frontend/src/components/RaceStoryCanvas.tsx index 4380b19..df54579 100644 --- a/frontend/src/components/RaceStoryCanvas.tsx +++ b/frontend/src/components/RaceStoryCanvas.tsx @@ -1,9 +1,13 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session } from '../types' +import type { Driver, EnrichedResult, EnrichedGrid, PositionSample, Lap, Meeting, Session, Chapter } from '../types' import { fetchReplayFrames, fetchTrackOutline } from '../api' import { ReplayTrackMap } from './ReplayTrackMap' +import { ChapterStrip } from './ChapterStrip' import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils' +import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters' + +const CHAPTER_TOUR_MS = 90_000 interface Props { data: { @@ -17,6 +21,7 @@ interface Props { session?: Session meeting?: Meeting drivers?: Driver[] + chapters?: Chapter[] } } @@ -32,6 +37,7 @@ export function RaceStoryCanvas({ data }: Props) { session, meeting, drivers = [], + chapters = [], } = data const hasPositions = datasets['positions']?.status === 'available' @@ -40,7 +46,10 @@ export function RaceStoryCanvas({ data }: Props) { const [mapOpen, setMapOpen] = useState(false) const [isPlaying, setIsPlaying] = useState(false) const [playbackSpeed, setPlaybackSpeed] = useState(10) + const [chapterTourActive, setChapterTourActive] = useState(false) + const [tourChapterIndex, setTourChapterIndex] = useState(null) const svgRef = useRef(null) + const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 }) // Position Evolution Chart Logic const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions]) @@ -87,6 +96,85 @@ export function RaceStoryCanvas({ data }: Props) { return () => cancelAnimationFrame(frame) }, [chartTiming, isPlaying, playbackSpeed]) + const stopChapterTour = () => { + setChapterTourActive(false) + setTourChapterIndex(null) + } + + const jumpToChapter = (index: number, scrub: number) => { + setIsPlaying(false) + stopChapterTour() + setScrubTime(scrub) + } + + const toggleChapterTour = () => { + if (chapterTourActive) { + stopChapterTour() + return + } + if (!chartTiming || chapters.length === 0) return + setIsPlaying(false) + setChapterTourActive(true) + setTourChapterIndex(0) + const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0 + setScrubTime(startScrub) + const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS) + tourRef.current = { + chapterIndex: 0, + startedAt: performance.now(), + durationMs: durations[0] ?? CHAPTER_TOUR_MS / chapters.length, + startScrub, + endScrub: chapterEndScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? startScrub, + } + } + + useEffect(() => { + if (!chapterTourActive || !chartTiming || chapters.length === 0) return + + let frame = 0 + const tick = (now: number) => { + const state = tourRef.current + const elapsed = now - state.startedAt + const progress = Math.min(1, elapsed / Math.max(state.durationMs, 1)) + const scrub = state.startScrub + (state.endScrub - state.startScrub) * progress + setScrubTime(scrub) + setTourChapterIndex(state.chapterIndex) + + if (progress >= 1) { + const nextIndex = state.chapterIndex + 1 + if (nextIndex >= chapters.length) { + stopChapterTour() + return + } + const durations = chapterTourDurations(chapters, CHAPTER_TOUR_MS) + const startScrub = chapterStartScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? 0 + const endScrub = chapterEndScrub(chapters[nextIndex], chartTiming.tMin, chartTiming.tRange) ?? startScrub + tourRef.current = { + chapterIndex: nextIndex, + startedAt: now, + durationMs: durations[nextIndex] ?? CHAPTER_TOUR_MS / chapters.length, + startScrub, + endScrub, + } + } + frame = requestAnimationFrame(tick) + } + + frame = requestAnimationFrame(tick) + return () => cancelAnimationFrame(frame) + }, [chapterTourActive, chartTiming, chapters]) + + useEffect(() => { + if (!chapterTourActive) return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + stopChapterTour() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [chapterTourActive]) + const replayTMs = useMemo(() => { const replay = replayQuery.data const frames = replay?.frames ?? [] @@ -210,6 +298,7 @@ export function RaceStoryCanvas({ data }: Props) { const handlePointerMove = (e: React.PointerEvent) => { setIsPlaying(false) + stopChapterTour() if (!svgRef.current) return const rect = svgRef.current.getBoundingClientRect() const x = e.clientX - rect.left @@ -373,6 +462,7 @@ export function RaceStoryCanvas({ data }: Props) { className={`rs-tool-btn ${isPlaying ? 'active' : ''}`} onClick={() => { setScrubTime((current) => current ?? 0) + stopChapterTour() setIsPlaying((current) => !current) }} > @@ -404,6 +494,18 @@ export function RaceStoryCanvas({ data }: Props) { return (
+ {hasChartData && chartTiming && chapters.length > 0 && ( + + )}
{hasChartData ? ( diff --git a/frontend/src/lib/chapters.ts b/frontend/src/lib/chapters.ts new file mode 100644 index 0000000..2ced668 --- /dev/null +++ b/frontend/src/lib/chapters.ts @@ -0,0 +1,82 @@ +import type { Chapter } from '../types' + +export function chapterKindLabel(kind: string): string { + switch (kind) { + case 'start': + return 'GO' + case 'safety_car': + return 'SC' + case 'virtual_safety_car': + return 'VSC' + case 'red_flag': + return 'RF' + case 'pit_phase': + return 'PIT' + case 'decisive_swing': + return '▲' + case 'finish': + return 'FIN' + default: + return kind.slice(0, 3).toUpperCase() + } +} + +export function chapterLapRange(chapter: Chapter): string { + if (chapter.start_lap === chapter.end_lap) { + return `L${chapter.start_lap}` + } + return `L${chapter.start_lap}–L${chapter.end_lap}` +} + +/** Normalized scrub position (0–1) for a chapter's start time on the chart axis. */ +export function chapterStartScrub( + chapter: Chapter, + tMin: number, + tRange: number, +): number | null { + if (!chapter.start_time || tRange <= 0) return null + const ms = new Date(chapter.start_time).getTime() + if (Number.isNaN(ms)) return null + return Math.max(0, Math.min(1, (ms - tMin) / tRange)) +} + +/** Normalized scrub position (0–1) for a chapter's end time on the chart axis. */ +export function chapterEndScrub( + chapter: Chapter, + tMin: number, + tRange: number, +): number | null { + const raw = chapter.end_time ?? chapter.start_time + if (!raw || tRange <= 0) return null + const ms = new Date(raw).getTime() + if (Number.isNaN(ms)) return null + return Math.max(0, Math.min(1, (ms - tMin) / tRange)) +} + +/** Index of the chapter containing the current scrub position, if any. */ +export function activeChapterIndex( + chapters: Chapter[], + scrubTime: number | null, + tMin: number, + tRange: number, +): number | null { + if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null + const chartMs = tMin + scrubTime * tRange + for (let i = 0; i < chapters.length; i++) { + const ch = chapters[i] + const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN + const endRaw = ch.end_time ?? ch.start_time + const endMs = endRaw ? new Date(endRaw).getTime() : NaN + if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) { + return i + } + } + return null +} + +/** Duration in ms each chapter should play during a ~90s tour. */ +export function chapterTourDurations(chapters: Chapter[], totalMs = 90_000): number[] { + if (chapters.length === 0) return [] + const perChapter = totalMs / chapters.length + return chapters.map(() => perChapter) +} diff --git a/frontend/src/styles/chapters.css b/frontend/src/styles/chapters.css new file mode 100644 index 0000000..f841341 --- /dev/null +++ b/frontend/src/styles/chapters.css @@ -0,0 +1,154 @@ +.chapter-strip { + display: flex; + flex-direction: column; + gap: var(--s3); +} + +.chapter-strip-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s4); +} + +.chapter-strip-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-3); +} + +.chapter-strip-actions { + display: flex; + align-items: center; + gap: var(--s2); +} + +.chapter-tour-btn { + font-family: var(--f-mono); + font-size: 11px; + padding: var(--s1) var(--s3); + border: 1px solid var(--border); + border-radius: var(--s1); + background: var(--surface); + color: var(--text-2); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.chapter-tour-btn:hover, +.chapter-tour-btn.active { + background: var(--surface-h); + border-color: var(--border-2); + color: var(--text); +} + +.chapter-strip-scroll { + display: flex; + gap: var(--s3); + overflow-x: auto; + padding-bottom: var(--s2); + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; +} + +.chapter-card { + flex: 0 0 min(240px, 72vw); + display: flex; + flex-direction: column; + gap: var(--s2); + padding: var(--s3) var(--s4); + border: 1px solid var(--border); + border-radius: var(--s2); + background: var(--surface); + text-align: left; + cursor: pointer; + scroll-snap-align: start; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.chapter-card:hover { + background: var(--surface-h); + border-color: var(--border-2); +} + +.chapter-card.active { + border-color: var(--accent, var(--red)); + background: var(--surface-h); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent); +} + +.chapter-card.tour-active { + border-color: var(--yellow); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--yellow) 40%, transparent); +} + +.chapter-card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s3); +} + +.chapter-kind { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 20px; + padding: 0 var(--s2); + border-radius: var(--s1); + font-family: var(--f-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + background: var(--bg); + color: var(--text-2); + border: 1px solid var(--border); +} + +.chapter-card.active .chapter-kind { + color: var(--text); + border-color: var(--border-2); +} + +.chapter-kind--safety_car, +.chapter-kind--virtual_safety_car { + color: var(--orange, #f90); +} + +.chapter-kind--red_flag { + color: var(--red); +} + +.chapter-kind--decisive_swing { + color: var(--green, #22c55e); +} + +.chapter-kind--finish { + color: var(--yellow); +} + +.chapter-lap-range { + font-family: var(--f-mono); + font-size: 10px; + color: var(--text-3); + white-space: nowrap; +} + +.chapter-headline { + font-size: 13px; + line-height: 1.35; + color: var(--text); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.chapter-strip-empty { + font-size: 12px; + color: var(--text-3); + padding: var(--s3) 0; +} diff --git a/frontend/src/test/ChapterStrip.test.tsx b/frontend/src/test/ChapterStrip.test.tsx new file mode 100644 index 0000000..bbd5907 --- /dev/null +++ b/frontend/src/test/ChapterStrip.test.tsx @@ -0,0 +1,87 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChapterStrip } from '../components/ChapterStrip' +import type { Chapter } from '../types' + +const chapters: Chapter[] = [ + { + kind: 'start', + title: 'Start', + headline: 'Lights out — the field charges into Turn 1', + start_lap: 1, + end_lap: 1, + start_time: '2025-05-25T13:00:00Z', + end_time: '2025-05-25T13:01:00Z', + driver_numbers: [], + }, + { + kind: 'safety_car', + title: 'Safety Car (L12-L15)', + headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits', + start_lap: 12, + end_lap: 15, + start_time: '2025-05-25T13:12:00Z', + end_time: '2025-05-25T13:15:00Z', + driver_numbers: [55], + }, +] + +const tMin = new Date('2025-05-25T13:00:00Z').getTime() +const tMax = new Date('2025-05-25T13:20:00Z').getTime() +const tRange = tMax - tMin + +function renderStrip( + overrides: Partial<{ + scrubTime: number | null + onChapterClick: (index: number, scrub: number) => void + }> = {}, +) { + const onChapterClick = overrides.onChapterClick ?? vi.fn() + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) + return { onChapterClick } +} + +describe('ChapterStrip', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders chapter headlines and lap ranges', () => { + renderStrip() + expect(screen.getByTestId('chapter-strip')).toBeInTheDocument() + expect(screen.getByText('Lights out — the field charges into Turn 1')).toBeInTheDocument() + expect(screen.getByText('L12–L15')).toBeInTheDocument() + }) + + it('highlights the active chapter from scrub time', () => { + const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange + renderStrip({ scrubTime: scrub }) + expect(screen.getByTestId('chapter-card-1')).toHaveClass('active') + expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active') + }) + + it('calls click handler to jump scrubber', () => { + const onChapterClick = vi.fn<(index: number, scrub: number) => void>() + renderStrip({ onChapterClick }) + fireEvent.click(screen.getByTestId('chapter-card-1')) + expect(onChapterClick).toHaveBeenCalledTimes(1) + const [index, scrub] = onChapterClick.mock.calls[0] + expect(index).toBe(1) + expect(scrub).toBeCloseTo(0.6, 2) + }) +}) diff --git a/frontend/src/test/chapters.test.ts b/frontend/src/test/chapters.test.ts new file mode 100644 index 0000000..4fb0ed6 --- /dev/null +++ b/frontend/src/test/chapters.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { + activeChapterIndex, + chapterKindLabel, + chapterLapRange, + chapterStartScrub, + chapterTourDurations, +} from '../lib/chapters' +import type { Chapter } from '../types' + +const sampleChapters: Chapter[] = [ + { + kind: 'start', + title: 'Start', + headline: 'Lights out — the field charges into Turn 1', + start_lap: 1, + end_lap: 1, + start_time: '2025-05-25T13:00:00Z', + end_time: '2025-05-25T13:01:00Z', + driver_numbers: [], + }, + { + kind: 'safety_car', + title: 'Safety Car (L12-L15)', + headline: 'Sainz incident brings out the Safety Car — leaders dive for the pits', + start_lap: 12, + end_lap: 15, + start_time: '2025-05-25T13:12:00Z', + end_time: '2025-05-25T13:15:00Z', + driver_numbers: [55], + }, +] + +describe('chapters lib', () => { + it('labels chapter kinds', () => { + expect(chapterKindLabel('safety_car')).toBe('SC') + expect(chapterKindLabel('finish')).toBe('FIN') + }) + + it('formats lap ranges', () => { + expect(chapterLapRange(sampleChapters[0])).toBe('L1') + expect(chapterLapRange(sampleChapters[1])).toBe('L12–L15') + }) + + it('maps chapter start time to scrub position', () => { + const tMin = new Date('2025-05-25T13:00:00Z').getTime() + const tMax = new Date('2025-05-25T13:20:00Z').getTime() + const scrub = chapterStartScrub(sampleChapters[1], tMin, tMax - tMin) + expect(scrub).toBeCloseTo(0.6, 2) + }) + + it('finds active chapter from scrub time', () => { + const tMin = new Date('2025-05-25T13:00:00Z').getTime() + const tMax = new Date('2025-05-25T13:20:00Z').getTime() + const tRange = tMax - tMin + const scrub = (new Date('2025-05-25T13:13:00Z').getTime() - tMin) / tRange + expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1) + }) + + it('splits 90s evenly across chapters', () => { + expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000]) + }) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c14f54c..1b7cacd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -97,6 +97,7 @@ export interface RaceHub { export interface Chapter { kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string title: string + headline: string start_lap: number end_lap: number start_time?: string diff --git a/internal/chapters/chapters.go b/internal/chapters/chapters.go index d5a937c..b7e50de 100644 --- a/internal/chapters/chapters.go +++ b/internal/chapters/chapters.go @@ -38,6 +38,7 @@ type Lap = models.Lap type Chapter struct { Kind string `json:"kind"` Title string `json:"title"` + Headline string `json:"headline"` StartLap int `json:"start_lap"` EndLap int `json:"end_lap"` StartTime string `json:"start_time,omitempty"` diff --git a/internal/chapters/headlines.go b/internal/chapters/headlines.go new file mode 100644 index 0000000..76ae653 --- /dev/null +++ b/internal/chapters/headlines.go @@ -0,0 +1,233 @@ +package chapters + +import ( + "fmt" + "strings" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +// DriverIdentityInput carries session driver fields used for headline templates. +type DriverIdentityInput struct { + DriverNumber int + NameAcronym string + FullName string + TeamName string +} + +type driverIdentity struct { + display string + team string + acronym string +} + +// BuildDriverMap indexes driver identity from session drivers and enriched results. +func BuildDriverMap(drivers []models.Driver, results []DriverIdentityInput) map[int]driverIdentity { + out := map[int]driverIdentity{} + for _, d := range drivers { + if d.DriverNumber <= 0 { + continue + } + out[d.DriverNumber] = driverIdentity{ + display: driverDisplayName(d.LastName, d.FullName, d.NameAcronym, d.BroadcastName), + team: d.TeamName, + acronym: firstNonEmpty(d.NameAcronym, d.BroadcastName), + } + } + for _, r := range results { + if r.DriverNumber <= 0 { + continue + } + if _, ok := out[r.DriverNumber]; ok { + continue + } + out[r.DriverNumber] = driverIdentity{ + display: driverDisplayName("", r.FullName, r.NameAcronym, ""), + team: r.TeamName, + acronym: r.NameAcronym, + } + } + return out +} + +// ApplyHeadlines fills Headline on each chapter using deterministic templates. +func ApplyHeadlines(chapters []Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) []Chapter { + out := make([]Chapter, len(chapters)) + copy(out, chapters) + for i := range out { + out[i].Headline = headlineFor(out[i], drivers, rc, winnerNumber) + } + return out +} + +func headlineFor(ch Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) string { + switch ch.Kind { + case KindStart: + return pickVariant(ch.StartLap, + "Lights out — the field charges into Turn 1", + "Race start — Lap 1 shuffle at the front", + ) + case KindSafetyCar: + if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" { + return pickVariant(ch.StartLap, + fmt.Sprintf("%s incident brings out the Safety Car — leaders dive for the pits", name), + fmt.Sprintf("Safety Car deployed after %s stops on track", name), + ) + } + case KindVirtualSafetyCar: + if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" { + return pickVariant(ch.StartLap, + fmt.Sprintf("%s off track triggers the Virtual Safety Car", name), + fmt.Sprintf("Virtual Safety Car — %s loses control on Lap %d", name, ch.StartLap), + ) + } + return pickVariant(ch.StartLap, + fmt.Sprintf("Virtual Safety Car deployed on Lap %d", ch.StartLap), + fmt.Sprintf("VSC period — field backs off on L%d–L%d", ch.StartLap, ch.EndLap), + ) + case KindRedFlag: + if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" { + return pickVariant(ch.StartLap, + fmt.Sprintf("%s crash forces a red flag on Lap %d", name, ch.StartLap), + fmt.Sprintf("Red flag after %s incident on Lap %d", name, ch.StartLap), + ) + } + return pickVariant(ch.StartLap, + fmt.Sprintf("Red flag — session halted on Lap %d", ch.StartLap), + fmt.Sprintf("Race suspended under red flag on L%d–L%d", ch.StartLap, ch.EndLap), + ) + case KindPitPhase: + names := driverNames(ch.DriverNumbers, drivers, 3) + if len(names) >= 2 { + return pickVariant(ch.StartLap, + fmt.Sprintf("Mass pit-window scramble — %s and %s box on L%d–L%d", names[0], names[1], ch.StartLap, ch.EndLap), + fmt.Sprintf("Undercut window opens — %s leads the pit rush on L%d–L%d", names[0], ch.StartLap, ch.EndLap), + ) + } + if len(names) == 1 { + return pickVariant(ch.StartLap, + fmt.Sprintf("%s pits under green — strategy window opens on L%d", names[0], ch.StartLap), + fmt.Sprintf("Pit phase on L%d–L%d — %s among the first to stop", ch.StartLap, ch.EndLap, names[0]), + ) + } + case KindDecisiveSwing: + if len(ch.DriverNumbers) >= 2 { + attacker := driverName(ch, drivers, ch.DriverNumbers[0]) + defender := driverName(ch, drivers, ch.DriverNumbers[1]) + pos := swingPosition(ch.Title) + if attacker != "" && defender != "" && pos > 0 { + return pickVariant(ch.StartLap, + fmt.Sprintf("%s overtakes %s for P%d", attacker, defender, pos), + fmt.Sprintf("%s charges past %s into P%d on Lap %d", attacker, defender, pos, ch.StartLap), + ) + } + } + if len(ch.DriverNumbers) >= 1 { + attacker := driverName(ch, drivers, ch.DriverNumbers[0]) + pos := swingPosition(ch.Title) + if attacker != "" && pos > 0 { + return pickVariant(ch.StartLap, + fmt.Sprintf("%s moves up to P%d on Lap %d", attacker, pos, ch.StartLap), + fmt.Sprintf("Decisive swing — %s climbs to P%d", attacker, pos), + ) + } + } + case KindFinish: + winner := driverName(ch, drivers, winnerNumber) + if winner != "" { + return pickVariant(ch.StartLap, + fmt.Sprintf("%s crosses the line to take the win", winner), + fmt.Sprintf("Chequered flag — %s wins the race", winner), + ) + } + } + return ch.Title +} + +func pickVariant(seed int, variants ...string) string { + if len(variants) == 0 { + return "" + } + if seed < 0 { + seed = -seed + } + return variants[seed%len(variants)] +} + +func driverName(_ Chapter, drivers map[int]driverIdentity, number int) string { + if number <= 0 { + return "" + } + if id, ok := drivers[number]; ok && id.display != "" { + return id.display + } + return "" +} + +func driverNames(numbers []int, drivers map[int]driverIdentity, limit int) []string { + out := make([]string, 0, limit) + for _, number := range numbers { + if name := driverName(Chapter{}, drivers, number); name != "" { + out = append(out, name) + if len(out) >= limit { + break + } + } + } + return out +} + +func driverDisplayName(lastName, fullName, acronym, broadcast string) string { + if lastName != "" { + return lastName + } + if fullName != "" { + parts := strings.Fields(fullName) + if len(parts) > 0 { + return parts[len(parts)-1] + } + return fullName + } + return firstNonEmpty(acronym, broadcast) +} + +func incidentDriver(rc []RaceControl, startLap int) int { + for _, msg := range rc { + if msg.DriverNumber == nil || *msg.DriverNumber <= 0 { + continue + } + lap := 0 + if msg.LapNumber != nil { + lap = *msg.LapNumber + } + if lap < startLap-1 || lap > startLap+1 { + continue + } + text := upperText(msg.Message, string(msg.Category)) + if strings.Contains(text, "DEPLOY") || + strings.Contains(text, "CLEAR") || + strings.Contains(text, "ENDING") || + strings.Contains(text, "GREEN") || + strings.Contains(text, "CHEQUER") { + continue + } + return *msg.DriverNumber + } + return 0 +} + +func swingPosition(title string) int { + // Title format: "Decisive swing: #16 to P3 (L6)" + idx := strings.Index(title, "P") + if idx < 0 || idx+1 >= len(title) { + return 0 + } + pos := 0 + for i := idx + 1; i < len(title); i++ { + if title[i] < '0' || title[i] > '9' { + break + } + pos = pos*10 + int(title[i]-'0') + } + return pos +} diff --git a/internal/chapters/headlines_test.go b/internal/chapters/headlines_test.go new file mode 100644 index 0000000..006ec41 --- /dev/null +++ b/internal/chapters/headlines_test.go @@ -0,0 +1,199 @@ +package chapters + +import ( + "testing" + + "github.com/AmanTahiliani/box-box/internal/models" +) + +func TestHeadlineStart(t *testing.T) { + ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1} + got := headlineFor(ch, nil, nil, 0) + want := "Race start — Lap 1 shuffle at the front" + if got != want { + t.Fatalf("headline = %q, want %q", got, want) + } +} + +func TestHeadlineStartVariantByLap(t *testing.T) { + ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 2, EndLap: 2} + got := headlineFor(ch, nil, nil, 0) + want := "Lights out — the field charges into Turn 1" + if got != want { + t.Fatalf("headline = %q, want %q", got, want) + } +} + +func TestHeadlineSafetyCarWithDriver(t *testing.T) { + drivers := BuildDriverMap(nil, []DriverIdentityInput{ + {DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"}, + }) + rc := []RaceControl{ + { + DriverNumber: intPtr(55), + LapNumber: intPtr(12), + Message: "CAR 55 STOPPED ON TRACK", + }, + rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"), + rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"), + } + ch := Chapter{ + Kind: KindSafetyCar, + Title: "Safety Car (L12-L15)", + StartLap: 12, + EndLap: 15, + } + got := headlineFor(ch, drivers, rc, 0) + want := "Sainz incident brings out the Safety Car — leaders dive for the pits" + if got != want { + t.Fatalf("headline = %q, want %q", got, want) + } +} + +func TestHeadlineSafetyCarFallbackWithoutDriver(t *testing.T) { + ch := Chapter{ + Kind: KindSafetyCar, + Title: "Safety Car (L12-L15)", + StartLap: 12, + EndLap: 15, + } + got := headlineFor(ch, nil, nil, 0) + if got != ch.Title { + t.Fatalf("headline = %q, want fallback %q", got, ch.Title) + } +} + +func TestHeadlineVirtualSafetyCar(t *testing.T) { + drivers := BuildDriverMap(nil, []DriverIdentityInput{ + {DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"}, + }) + rc := []RaceControl{ + { + DriverNumber: intPtr(16), + LapNumber: intPtr(22), + Message: "CAR 16 OFF TRACK", + }, + rc(22, models.CategoryOther, "", "VSC DEPLOYED"), + rc(24, models.CategoryOther, "", "VSC ENDING"), + } + ch := Chapter{ + Kind: KindVirtualSafetyCar, + Title: "Virtual Safety Car (L22-L24)", + StartLap: 22, + EndLap: 24, + } + got := headlineFor(ch, drivers, rc, 0) + want := "Leclerc off track triggers the Virtual Safety Car" + if got != want { + t.Fatalf("headline = %q, want %q", got, want) + } +} + +func TestHeadlineRedFlag(t *testing.T) { + drivers := BuildDriverMap(nil, []DriverIdentityInput{ + {DriverNumber: 63, NameAcronym: "RUS", FullName: "George Russell", TeamName: "Mercedes"}, + }) + rc := []RaceControl{ + { + DriverNumber: intPtr(63), + LapNumber: intPtr(31), + Message: "INCIDENT INVOLVING CAR 63", + }, + rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"), + rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"), + } + ch := Chapter{ + Kind: KindRedFlag, + Title: "Red Flag (L31-L33)", + StartLap: 31, + EndLap: 33, + } + got := headlineFor(ch, drivers, rc, 0) + want := "Red flag after Russell incident on Lap 31" + if got != want { + t.Fatalf("headline = %q, want %q", got, want) + } +} + +func TestHeadlinePitPhase(t *testing.T) { + drivers := BuildDriverMap(nil, []DriverIdentityInput{ + {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"}, + {DriverNumber: 44, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes"}, + {DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"}, + }) + ch := Chapter{ + Kind: KindPitPhase, + Title: "Pit phase (L20-L22)", + StartLap: 20, + EndLap: 22, + DriverNumbers: []int{1, 44, 16}, + } + got := headlineFor(ch, drivers, nil, 0) + want := "Mass pit-window scramble — Verstappen and Hamilton box on L20–L22" + if got != want { + t.Fatalf("headline = %q, want %q", got, want) + } +} + +func TestHeadlineDecisiveSwing(t *testing.T) { + drivers := BuildDriverMap(nil, []DriverIdentityInput{ + {DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"}, + {DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"}, + }) + ch := Chapter{ + Kind: KindDecisiveSwing, + Title: "Decisive swing: #16 to P3 (L6)", + StartLap: 6, + EndLap: 6, + DriverNumbers: []int{16, 55}, + } + got := headlineFor(ch, drivers, nil, 0) + want := "Leclerc overtakes Sainz for P3" + if got != want { + t.Fatalf("headline = %q, want %q", got, want) + } +} + +func TestHeadlineFinish(t *testing.T) { + drivers := BuildDriverMap(nil, []DriverIdentityInput{ + {DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"}, + }) + ch := Chapter{ + Kind: KindFinish, + Title: "Finish (L57-L58)", + StartLap: 57, + EndLap: 58, + } + got := headlineFor(ch, drivers, nil, 1) + want := "Chequered flag — Verstappen wins the race" + if got != want { + t.Fatalf("headline = %q, want %q", got, want) + } +} + +func TestHeadlineFinishFallbackWithoutWinner(t *testing.T) { + ch := Chapter{ + Kind: KindFinish, + Title: "Finish (L57-L58)", + StartLap: 57, + EndLap: 58, + } + got := headlineFor(ch, nil, nil, 0) + if got != ch.Title { + t.Fatalf("headline = %q, want fallback %q", got, ch.Title) + } +} + +func TestApplyHeadlinesPreservesChapterFields(t *testing.T) { + input := []Chapter{ + {Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1}, + } + got := ApplyHeadlines(input, nil, nil, 0) + if len(got) != 1 || got[0].StartLap != 1 || got[0].Headline == "" { + t.Fatalf("ApplyHeadlines = %+v, want headline on start chapter", got) + } +} + +func intPtr(v int) *int { + return &v +} diff --git a/internal/query/racehub.go b/internal/query/racehub.go index 5d15884..c5b8f1e 100644 --- a/internal/query/racehub.go +++ b/internal/query/racehub.go @@ -252,10 +252,38 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) { } 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 {