diff --git a/frontend/src/components/ChapterStrip.tsx b/frontend/src/components/ChapterStrip.tsx index 1a3b801..f76346f 100644 --- a/frontend/src/components/ChapterStrip.tsx +++ b/frontend/src/components/ChapterStrip.tsx @@ -17,6 +17,8 @@ interface Props { tRange: number tourActive: boolean tourChapterIndex: number | null + /** Explicit selection from a chapter click; wins over scrub-derived active. */ + selectedChapterIndex?: number | null onChapterClick: (index: number, scrub: number) => void onTourToggle: () => void } @@ -28,12 +30,15 @@ export function ChapterStrip({ tRange, tourActive, tourChapterIndex, + selectedChapterIndex = null, onChapterClick, onTourToggle, }: Props) { const scrollRef = useRef(null) const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange) - const highlightedIndex = tourActive ? tourChapterIndex : activeIndex + const highlightedIndex = tourActive + ? tourChapterIndex + : (selectedChapterIndex ?? activeIndex) useEffect(() => { if (highlightedIndex === null || !scrollRef.current) return @@ -81,9 +86,7 @@ export function ChapterStrip({ > {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 isActive = highlightedIndex === index const headline = chapter.headline || chapter.title return ( diff --git a/frontend/src/components/RaceStoryCanvas.tsx b/frontend/src/components/RaceStoryCanvas.tsx index 0e12ec2..dd646ef 100644 --- a/frontend/src/components/RaceStoryCanvas.tsx +++ b/frontend/src/components/RaceStoryCanvas.tsx @@ -65,6 +65,7 @@ export function RaceStoryCanvas({ data }: Props) { const [playbackSpeed, setPlaybackSpeed] = useState(10) const [chapterTourActive, setChapterTourActive] = useState(false) const [tourChapterIndex, setTourChapterIndex] = useState(null) + const [selectedChapterIndex, setSelectedChapterIndex] = useState(null) const svgRef = useRef(null) const tourRef = useRef({ chapterIndex: 0, startedAt: 0, durationMs: 0, startScrub: 0, endScrub: 0 }) @@ -140,9 +141,14 @@ export function RaceStoryCanvas({ data }: Props) { const jumpToChapter = (index: number, scrub: number) => { setIsPlaying(false) stopChapterTour() + setSelectedChapterIndex(index) setScrubTime(scrub) } + const clearChapterSelection = () => { + setSelectedChapterIndex(null) + } + const toggleChapterTour = () => { if (chapterTourActive) { stopChapterTour() @@ -150,6 +156,7 @@ export function RaceStoryCanvas({ data }: Props) { } if (!chartTiming || chapters.length === 0) return setIsPlaying(false) + setSelectedChapterIndex(null) setChapterTourActive(true) setTourChapterIndex(0) const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 0 @@ -359,6 +366,7 @@ export function RaceStoryCanvas({ data }: Props) { const handlePointerMove = (e: React.PointerEvent) => { setIsPlaying(false) stopChapterTour() + clearChapterSelection() if (!svgRef.current) return const rect = svgRef.current.getBoundingClientRect() const x = e.clientX - rect.left @@ -540,7 +548,10 @@ export function RaceStoryCanvas({ data }: Props) { fill="transparent" onPointerMove={handlePointerMove} onPointerLeave={() => { - if (!isPlaying) setScrubTime(null) + if (!isPlaying) { + clearChapterSelection() + setScrubTime(null) + } }} style={{ cursor: 'crosshair', touchAction: 'none' }} /> @@ -552,6 +563,7 @@ export function RaceStoryCanvas({ data }: Props) { onClick={() => { setScrubTime((current) => current ?? 0) stopChapterTour() + clearChapterSelection() setIsPlaying((current) => !current) }} aria-pressed={isPlaying} @@ -599,6 +611,7 @@ export function RaceStoryCanvas({ data }: Props) { tRange={chartTiming.tRange} tourActive={chapterTourActive} tourChapterIndex={tourChapterIndex} + selectedChapterIndex={selectedChapterIndex} onChapterClick={jumpToChapter} onTourToggle={toggleChapterTour} /> diff --git a/frontend/src/lib/chapters.ts b/frontend/src/lib/chapters.ts index c8f45c5..694962c 100644 --- a/frontend/src/lib/chapters.ts +++ b/frontend/src/lib/chapters.ts @@ -53,7 +53,10 @@ export function chapterEndScrub( return Math.max(0, Math.min(1, (ms - tMin) / tRange)) } -/** Index of the chapter containing the current scrub position, if any. */ +/** Index of the chapter containing the current scrub position, if any. + * Uses the same 0–1 clamped bounds as chapterStartScrub/chapterEndScrub so + * chapters whose timestamps fall outside the position-sample window still + * activate when the scrubber is parked at the clamped edge. */ export function activeChapterIndex( chapters: Chapter[], scrubTime: number | null, @@ -61,15 +64,13 @@ export function activeChapterIndex( 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 - } + const start = chapterStartScrub(chapters[i], tMin, tRange) + const end = chapterEndScrub(chapters[i], tMin, tRange) + if (start === null || end === null) continue + const lo = Math.min(start, end) + const hi = Math.max(start, end) + if (scrubTime >= lo && scrubTime <= hi) return i } return null } diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 32e0005..797c6c0 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -520,17 +520,6 @@ a { color: inherit; text-decoration: none; } color: var(--text-3); } -.empty-state-icon { - display: block; - margin: 0 auto var(--s3); - color: var(--text-3); -} - -.race-story-empty-card { - padding: var(--s6) var(--s5); - margin: 0; -} - .loading-state { padding: var(--s7) 0; text-align: center; diff --git a/frontend/src/styles/chapters.css b/frontend/src/styles/chapters.css index f131c36..9cc6007 100644 --- a/frontend/src/styles/chapters.css +++ b/frontend/src/styles/chapters.css @@ -196,6 +196,7 @@ } .chapter-strip-empty-card .empty-state-icon { + display: block; margin: 0 auto var(--s3); color: var(--text-3); } diff --git a/frontend/src/styles/race-story.css b/frontend/src/styles/race-story.css index e151b7a..5fd1704 100644 --- a/frontend/src/styles/race-story.css +++ b/frontend/src/styles/race-story.css @@ -1,3 +1,14 @@ +.race-story-empty-card { + padding: var(--s6) var(--s5); + margin: 0; +} + +.race-story-empty-card .empty-state-icon { + display: block; + margin: 0 auto var(--s3); + color: var(--text-3); +} + .rs-replay-shell { display: grid; grid-template-columns: 1fr; diff --git a/frontend/src/styles/replay-map.css b/frontend/src/styles/replay-map.css index 29f5ebd..ca7e5c0 100644 --- a/frontend/src/styles/replay-map.css +++ b/frontend/src/styles/replay-map.css @@ -85,6 +85,7 @@ } .replay-map-empty-card .empty-state-icon { + display: block; margin: 0 auto var(--s3); color: var(--text-3); } diff --git a/frontend/src/test/ChapterStrip.test.tsx b/frontend/src/test/ChapterStrip.test.tsx index c3cc5a7..636adb9 100644 --- a/frontend/src/test/ChapterStrip.test.tsx +++ b/frontend/src/test/ChapterStrip.test.tsx @@ -103,4 +103,26 @@ describe('ChapterStrip', () => { expect(index).toBe(1) expect(scrub).toBeCloseTo(0.6, 2) }) + + it('highlights an explicitly selected chapter even when scrub is outside its raw window', () => { + // Scrub parked at chart start; chapter 1's raw times are mid-race, but selection wins. + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) + expect(screen.getByTestId('chapter-card-1')).toHaveClass('active') + expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active') + }) }) diff --git a/frontend/src/test/RaceStoryCanvas.test.tsx b/frontend/src/test/RaceStoryCanvas.test.tsx index 7de9ef5..2b0ce1d 100644 --- a/frontend/src/test/RaceStoryCanvas.test.tsx +++ b/frontend/src/test/RaceStoryCanvas.test.tsx @@ -187,6 +187,46 @@ describe('RaceStoryCanvas replay map', () => { expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active') }) + it('highlights out-of-window chapters after click (clamped scrub + selection)', async () => { + // Position samples start at 13:05; start chapter ends at 13:01 — outside the window. + renderCanvas({ + positions: [ + { session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:05:00Z', position: 1 }, + { session_key: 99, driver_number: 1, meeting_key: 1, date: '2025-05-25T13:10:00Z', position: 1 }, + ], + chapters: [ + { + kind: 'start', + title: 'Start', + headline: 'Lights out before samples', + start_lap: 1, + end_lap: 1, + start_time: '2025-05-25T13:00:00Z', + end_time: '2025-05-25T13:01:00Z', + driver_numbers: [], + }, + { + kind: 'finish', + title: 'Finish', + headline: 'Flag after samples', + start_lap: 78, + end_lap: 78, + start_time: '2025-05-25T13:20:00Z', + end_time: '2025-05-25T13:21:00Z', + driver_numbers: [], + }, + ], + }) + + fireEvent.click(screen.getByTestId('chapter-card-0')) + await waitFor(() => expect(screen.getByTestId('chapter-card-0')).toHaveClass('active')) + expect(screen.getByTestId('chapter-card-1')).not.toHaveClass('active') + + fireEvent.click(screen.getByTestId('chapter-card-1')) + await waitFor(() => expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')) + expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active') + }) + it('renders the empty-state card when positions are unavailable', () => { renderCanvas({ datasets: { positions: { status: 'missing', source: 'local', count: 0 } }, diff --git a/frontend/src/test/chapters.test.ts b/frontend/src/test/chapters.test.ts index 290d936..3e16d50 100644 --- a/frontend/src/test/chapters.test.ts +++ b/frontend/src/test/chapters.test.ts @@ -60,6 +60,41 @@ describe('chapters lib', () => { expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1) }) + it('activates chapters whose timestamps clamp outside the position window', () => { + // Position samples only cover 13:05–13:10; chapters sit before/after that window. + const tMin = new Date('2025-05-25T13:05:00Z').getTime() + const tMax = new Date('2025-05-25T13:10:00Z').getTime() + const tRange = tMax - tMin + const outOfWindow: Chapter[] = [ + { + kind: 'start', + title: 'Start', + headline: 'Lights out', + start_lap: 1, + end_lap: 1, + start_time: '2025-05-25T13:00:00Z', + end_time: '2025-05-25T13:01:00Z', + driver_numbers: [], + }, + { + kind: 'finish', + title: 'Finish', + headline: 'Chequered flag', + start_lap: 78, + end_lap: 78, + start_time: '2025-05-25T13:20:00Z', + end_time: '2025-05-25T13:21:00Z', + driver_numbers: [], + }, + ] + + expect(chapterStartScrub(outOfWindow[0], tMin, tRange)).toBe(0) + expect(chapterStartScrub(outOfWindow[1], tMin, tRange)).toBe(1) + expect(activeChapterIndex(outOfWindow, 0, tMin, tRange)).toBe(0) + expect(activeChapterIndex(outOfWindow, 1, tMin, tRange)).toBe(1) + expect(activeChapterIndex(outOfWindow, 0.5, tMin, tRange)).toBeNull() + }) + it('splits 90s evenly across chapters', () => { expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000]) }) diff --git a/tests/race-hub.spec.ts b/tests/race-hub.spec.ts index 54e9cf3..66b0b43 100644 --- a/tests/race-hub.spec.ts +++ b/tests/race-hub.spec.ts @@ -39,7 +39,17 @@ test.describe('Race Hub Weekend Workspace', () => { await expect( page.getByRole('img', { name: 'Position evolution chart' }), ).toBeVisible() - await expect(page.getByText('Lap-by-lap positions not available.')).not.toBeVisible() + await expect(page.getByTestId('race-story-no-positions')).not.toBeVisible() + }) + + test('Race Story highlights a chapter card when clicked', async ({ page }) => { + await page.goto(`/race-hub?session_key=${FULL_SESSION}`) + await page.getByRole('tab', { name: 'Race Story' }).click() + + const firstCard = page.getByTestId('chapter-card-0') + await expect(firstCard).toBeVisible() + await firstCard.click() + await expect(firstCard).toHaveClass(/active/) }) test('strategy tab renders stint chart when stints are available', async ({ page }) => { @@ -58,13 +68,15 @@ test.describe('Race Hub Weekend Workspace', () => { await expect(page.locator('[data-testid="strategy-chart"]')).not.toBeVisible() }) - test('Race Story shows missing notice when positions are unavailable', async ({ + test('Race Story shows empty-state card when positions are unavailable', async ({ page, }) => { await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`) await page.getByRole('tab', { name: 'Race Story' }).click() - await expect(page.getByText('Lap-by-lap positions not available.')).toBeVisible() + const empty = page.getByTestId('race-story-no-positions') + await expect(empty).toBeVisible() + await expect(empty.getByText('Lap-by-lap positions not available')).toBeVisible() await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible() })