mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
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:
87
frontend/src/components/ChapterStrip.tsx
Normal file
87
frontend/src/components/ChapterStrip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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<number | null>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(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<SVGRectElement>) => {
|
||||
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 (
|
||||
<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-main">
|
||||
{hasChartData ? (
|
||||
|
||||
82
frontend/src/lib/chapters.ts
Normal file
82
frontend/src/lib/chapters.ts
Normal 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 (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)
|
||||
}
|
||||
154
frontend/src/styles/chapters.css
Normal file
154
frontend/src/styles/chapters.css
Normal 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;
|
||||
}
|
||||
87
frontend/src/test/ChapterStrip.test.tsx
Normal file
87
frontend/src/test/ChapterStrip.test.tsx
Normal 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('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)
|
||||
})
|
||||
})
|
||||
63
frontend/src/test/chapters.test.ts
Normal file
63
frontend/src/test/chapters.test.ts
Normal 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('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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user