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>
This commit is contained in:
2026-07-11 18:23:11 -04:00
parent 96d741424c
commit 5b112fb012
11 changed files with 1038 additions and 1 deletions

View File

@@ -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 (
<div className="chapter-strip" data-testid="chapter-strip">
<p className="chapter-strip-empty">No story chapters for this session.</p>
</div>
)
}
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
return (
<div className="chapter-strip" data-testid="chapter-strip">
<div className="chapter-strip-header">
<span className="chapter-strip-title">Race chapters</span>
<div className="chapter-strip-actions">
<button
type="button"
className={`chapter-tour-btn ${tourActive ? 'active' : ''}`}
onClick={onTourToggle}
aria-pressed={tourActive}
>
{tourActive ? 'Exit 90s' : '90s tour'}
</button>
</div>
</div>
<div className="chapter-strip-scroll" role="list" aria-label="Race story 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 (
<button
key={`${chapter.kind}-${chapter.start_lap}-${index}`}
type="button"
role="listitem"
className={`chapter-card ${isActive ? (tourActive ? 'tour-active' : 'active') : ''}`}
onClick={() => onChapterClick(index, scrub)}
aria-current={isActive ? 'true' : undefined}
data-testid={`chapter-card-${index}`}
>
<div className="chapter-card-top">
<span className={`chapter-kind chapter-kind--${chapter.kind}`}>
{chapterKindLabel(chapter.kind)}
</span>
<span className="chapter-lap-range">{chapterLapRange(chapter)}</span>
</div>
<span className="chapter-headline">{headline}</span>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -1,9 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' 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 { fetchReplayFrames, fetchTrackOutline } from '../api'
import { ReplayTrackMap } from './ReplayTrackMap' import { ReplayTrackMap } from './ReplayTrackMap'
import { ChapterStrip } from './ChapterStrip'
import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils' import { gridDelta, gridDeltaClass, formatDuration, formatGap } from '../utils'
import { chapterEndScrub, chapterStartScrub, chapterTourDurations } from '../lib/chapters'
const CHAPTER_TOUR_MS = 90_000
interface Props { interface Props {
data: { data: {
@@ -17,6 +21,7 @@ interface Props {
session?: Session session?: Session
meeting?: Meeting meeting?: Meeting
drivers?: Driver[] drivers?: Driver[]
chapters?: Chapter[]
} }
} }
@@ -32,6 +37,7 @@ export function RaceStoryCanvas({ data }: Props) {
session, session,
meeting, meeting,
drivers = [], drivers = [],
chapters = [],
} = data } = data
const hasPositions = datasets['positions']?.status === 'available' const hasPositions = datasets['positions']?.status === 'available'
@@ -40,7 +46,10 @@ export function RaceStoryCanvas({ data }: Props) {
const [mapOpen, setMapOpen] = useState(false) const [mapOpen, setMapOpen] = useState(false)
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(10) const [playbackSpeed, setPlaybackSpeed] = useState(10)
const [chapterTourActive, setChapterTourActive] = useState(false)
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
const svgRef = useRef<SVGSVGElement>(null) const svgRef = useRef<SVGSVGElement>(null)
const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 })
// Position Evolution Chart Logic // Position Evolution Chart Logic
const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions]) const allTimes = useMemo(() => [...new Set(positions.map((p) => p.date))].sort(), [positions])
@@ -87,6 +96,85 @@ export function RaceStoryCanvas({ data }: Props) {
return () => cancelAnimationFrame(frame) return () => cancelAnimationFrame(frame)
}, [chartTiming, isPlaying, playbackSpeed]) }, [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 replayTMs = useMemo(() => {
const replay = replayQuery.data const replay = replayQuery.data
const frames = replay?.frames ?? [] const frames = replay?.frames ?? []
@@ -210,6 +298,7 @@ export function RaceStoryCanvas({ data }: Props) {
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => { const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
setIsPlaying(false) setIsPlaying(false)
stopChapterTour()
if (!svgRef.current) return if (!svgRef.current) return
const rect = svgRef.current.getBoundingClientRect() const rect = svgRef.current.getBoundingClientRect()
const x = e.clientX - rect.left const x = e.clientX - rect.left
@@ -373,6 +462,7 @@ export function RaceStoryCanvas({ data }: Props) {
className={`rs-tool-btn ${isPlaying ? 'active' : ''}`} className={`rs-tool-btn ${isPlaying ? 'active' : ''}`}
onClick={() => { onClick={() => {
setScrubTime((current) => current ?? 0) setScrubTime((current) => current ?? 0)
stopChapterTour()
setIsPlaying((current) => !current) setIsPlaying((current) => !current)
}} }}
> >
@@ -404,6 +494,18 @@ export function RaceStoryCanvas({ data }: Props) {
return ( return (
<div className="race-story-canvas"> <div className="race-story-canvas">
{hasChartData && chartTiming && chapters.length > 0 && (
<ChapterStrip
chapters={chapters}
scrubTime={scrubTime}
tMin={chartTiming.tMin}
tRange={chartTiming.tRange}
tourActive={chapterTourActive}
tourChapterIndex={tourChapterIndex}
onChapterClick={jumpToChapter}
onTourToggle={toggleChapterTour}
/>
)}
<div className="rs-replay-shell"> <div className="rs-replay-shell">
<div className="rs-replay-main"> <div className="rs-replay-main">
{hasChartData ? ( {hasChartData ? (

View File

@@ -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 (01) 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 (01) 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)
}

View File

@@ -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;
}

View File

@@ -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(
<QueryClientProvider client={queryClient}>
<ChapterStrip
chapters={chapters}
scrubTime={overrides.scrubTime ?? null}
tMin={tMin}
tRange={tRange}
tourActive={false}
tourChapterIndex={null}
onChapterClick={onChapterClick}
onTourToggle={vi.fn()}
/>
</QueryClientProvider>,
)
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('L12L15')).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)
})
})

View File

@@ -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('L12L15')
})
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])
})
})

View File

@@ -97,6 +97,7 @@ export interface RaceHub {
export interface Chapter { export interface Chapter {
kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string kind: 'start' | 'safety_car' | 'virtual_safety_car' | 'red_flag' | 'pit_phase' | 'decisive_swing' | 'finish' | string
title: string title: string
headline: string
start_lap: number start_lap: number
end_lap: number end_lap: number
start_time?: string start_time?: string

View File

@@ -38,6 +38,7 @@ type Lap = models.Lap
type Chapter struct { type Chapter struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Title string `json:"title"` Title string `json:"title"`
Headline string `json:"headline"`
StartLap int `json:"start_lap"` StartLap int `json:"start_lap"`
EndLap int `json:"end_lap"` EndLap int `json:"end_lap"`
StartTime string `json:"start_time,omitempty"` StartTime string `json:"start_time,omitempty"`

View File

@@ -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%dL%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%dL%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%dL%d", names[0], names[1], ch.StartLap, ch.EndLap),
fmt.Sprintf("Undercut window opens — %s leads the pit rush on L%dL%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%dL%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
}

View File

@@ -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 L20L22"
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
}

View File

@@ -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.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) hub.Source = responseSource(hub.Datasets)
return hub, nil 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 { func totalLaps(results []EnrichedResult, laps []models.Lap) int {
total := 0 total := 0
for _, result := range results { for _, result := range results {