mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
fix(race-story): sync chapter highlight and empty-state E2E (#68)
Clamp active-chapter detection to the same scrub bounds as chapter clicks, keep an explicit selection on click for out-of-window timestamps, point E2E at the empty-state card, and move Race Story empty-state styles out of app.css. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,8 @@ interface Props {
|
|||||||
tRange: number
|
tRange: number
|
||||||
tourActive: boolean
|
tourActive: boolean
|
||||||
tourChapterIndex: number | null
|
tourChapterIndex: number | null
|
||||||
|
/** Explicit selection from a chapter click; wins over scrub-derived active. */
|
||||||
|
selectedChapterIndex?: number | null
|
||||||
onChapterClick: (index: number, scrub: number) => void
|
onChapterClick: (index: number, scrub: number) => void
|
||||||
onTourToggle: () => void
|
onTourToggle: () => void
|
||||||
}
|
}
|
||||||
@@ -28,12 +30,15 @@ export function ChapterStrip({
|
|||||||
tRange,
|
tRange,
|
||||||
tourActive,
|
tourActive,
|
||||||
tourChapterIndex,
|
tourChapterIndex,
|
||||||
|
selectedChapterIndex = null,
|
||||||
onChapterClick,
|
onChapterClick,
|
||||||
onTourToggle,
|
onTourToggle,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
const activeIndex = activeChapterIndex(chapters, scrubTime, tMin, tRange)
|
||||||
const highlightedIndex = tourActive ? tourChapterIndex : activeIndex
|
const highlightedIndex = tourActive
|
||||||
|
? tourChapterIndex
|
||||||
|
: (selectedChapterIndex ?? activeIndex)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (highlightedIndex === null || !scrollRef.current) return
|
if (highlightedIndex === null || !scrollRef.current) return
|
||||||
@@ -81,9 +86,7 @@ export function ChapterStrip({
|
|||||||
>
|
>
|
||||||
{chapters.map((chapter, index) => {
|
{chapters.map((chapter, index) => {
|
||||||
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
|
const scrub = chapterStartScrub(chapter, tMin, tRange) ?? index / Math.max(chapters.length - 1, 1)
|
||||||
const isActive = tourActive
|
const isActive = highlightedIndex === index
|
||||||
? tourChapterIndex === index
|
|
||||||
: activeIndex === index
|
|
||||||
const headline = chapter.headline || chapter.title
|
const headline = chapter.headline || chapter.title
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
const [playbackSpeed, setPlaybackSpeed] = useState(10)
|
||||||
const [chapterTourActive, setChapterTourActive] = useState(false)
|
const [chapterTourActive, setChapterTourActive] = useState(false)
|
||||||
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
const [tourChapterIndex, setTourChapterIndex] = useState<number | null>(null)
|
||||||
|
const [selectedChapterIndex, setSelectedChapterIndex] = 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 })
|
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) => {
|
const jumpToChapter = (index: number, scrub: number) => {
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
|
setSelectedChapterIndex(index)
|
||||||
setScrubTime(scrub)
|
setScrubTime(scrub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearChapterSelection = () => {
|
||||||
|
setSelectedChapterIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
const toggleChapterTour = () => {
|
const toggleChapterTour = () => {
|
||||||
if (chapterTourActive) {
|
if (chapterTourActive) {
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
@@ -150,6 +156,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
}
|
}
|
||||||
if (!chartTiming || chapters.length === 0) return
|
if (!chartTiming || chapters.length === 0) return
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
|
setSelectedChapterIndex(null)
|
||||||
setChapterTourActive(true)
|
setChapterTourActive(true)
|
||||||
setTourChapterIndex(0)
|
setTourChapterIndex(0)
|
||||||
const startScrub = chapterStartScrub(chapters[0], chartTiming.tMin, chartTiming.tRange) ?? 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<SVGRectElement>) => {
|
const handlePointerMove = (e: React.PointerEvent<SVGRectElement>) => {
|
||||||
setIsPlaying(false)
|
setIsPlaying(false)
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
|
clearChapterSelection()
|
||||||
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
|
||||||
@@ -540,7 +548,10 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
fill="transparent"
|
fill="transparent"
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerLeave={() => {
|
onPointerLeave={() => {
|
||||||
if (!isPlaying) setScrubTime(null)
|
if (!isPlaying) {
|
||||||
|
clearChapterSelection()
|
||||||
|
setScrubTime(null)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||||
/>
|
/>
|
||||||
@@ -552,6 +563,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setScrubTime((current) => current ?? 0)
|
setScrubTime((current) => current ?? 0)
|
||||||
stopChapterTour()
|
stopChapterTour()
|
||||||
|
clearChapterSelection()
|
||||||
setIsPlaying((current) => !current)
|
setIsPlaying((current) => !current)
|
||||||
}}
|
}}
|
||||||
aria-pressed={isPlaying}
|
aria-pressed={isPlaying}
|
||||||
@@ -599,6 +611,7 @@ export function RaceStoryCanvas({ data }: Props) {
|
|||||||
tRange={chartTiming.tRange}
|
tRange={chartTiming.tRange}
|
||||||
tourActive={chapterTourActive}
|
tourActive={chapterTourActive}
|
||||||
tourChapterIndex={tourChapterIndex}
|
tourChapterIndex={tourChapterIndex}
|
||||||
|
selectedChapterIndex={selectedChapterIndex}
|
||||||
onChapterClick={jumpToChapter}
|
onChapterClick={jumpToChapter}
|
||||||
onTourToggle={toggleChapterTour}
|
onTourToggle={toggleChapterTour}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export function chapterEndScrub(
|
|||||||
return Math.max(0, Math.min(1, (ms - tMin) / tRange))
|
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(
|
export function activeChapterIndex(
|
||||||
chapters: Chapter[],
|
chapters: Chapter[],
|
||||||
scrubTime: number | null,
|
scrubTime: number | null,
|
||||||
@@ -61,15 +64,13 @@ export function activeChapterIndex(
|
|||||||
tRange: number,
|
tRange: number,
|
||||||
): number | null {
|
): number | null {
|
||||||
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
if (scrubTime === null || chapters.length === 0 || tRange <= 0) return null
|
||||||
const chartMs = tMin + scrubTime * tRange
|
|
||||||
for (let i = 0; i < chapters.length; i++) {
|
for (let i = 0; i < chapters.length; i++) {
|
||||||
const ch = chapters[i]
|
const start = chapterStartScrub(chapters[i], tMin, tRange)
|
||||||
const startMs = ch.start_time ? new Date(ch.start_time).getTime() : NaN
|
const end = chapterEndScrub(chapters[i], tMin, tRange)
|
||||||
const endRaw = ch.end_time ?? ch.start_time
|
if (start === null || end === null) continue
|
||||||
const endMs = endRaw ? new Date(endRaw).getTime() : NaN
|
const lo = Math.min(start, end)
|
||||||
if (!Number.isNaN(startMs) && !Number.isNaN(endMs) && chartMs >= startMs && chartMs <= endMs) {
|
const hi = Math.max(start, end)
|
||||||
return i
|
if (scrubTime >= lo && scrubTime <= hi) return i
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -520,17 +520,6 @@ a { color: inherit; text-decoration: none; }
|
|||||||
color: var(--text-3);
|
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 {
|
.loading-state {
|
||||||
padding: var(--s7) 0;
|
padding: var(--s7) 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -196,6 +196,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chapter-strip-empty-card .empty-state-icon {
|
.chapter-strip-empty-card .empty-state-icon {
|
||||||
|
display: block;
|
||||||
margin: 0 auto var(--s3);
|
margin: 0 auto var(--s3);
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.rs-replay-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.replay-map-empty-card .empty-state-icon {
|
.replay-map-empty-card .empty-state-icon {
|
||||||
|
display: block;
|
||||||
margin: 0 auto var(--s3);
|
margin: 0 auto var(--s3);
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,4 +103,26 @@ describe('ChapterStrip', () => {
|
|||||||
expect(index).toBe(1)
|
expect(index).toBe(1)
|
||||||
expect(scrub).toBeCloseTo(0.6, 2)
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ChapterStrip
|
||||||
|
chapters={chapters}
|
||||||
|
scrubTime={0}
|
||||||
|
tMin={tMin}
|
||||||
|
tRange={tRange}
|
||||||
|
tourActive={false}
|
||||||
|
tourChapterIndex={null}
|
||||||
|
selectedChapterIndex={1}
|
||||||
|
onChapterClick={vi.fn()}
|
||||||
|
onTourToggle={vi.fn()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('chapter-card-1')).toHaveClass('active')
|
||||||
|
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -187,6 +187,46 @@ describe('RaceStoryCanvas replay map', () => {
|
|||||||
expect(screen.getByTestId('chapter-card-0')).not.toHaveClass('active')
|
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', () => {
|
it('renders the empty-state card when positions are unavailable', () => {
|
||||||
renderCanvas({
|
renderCanvas({
|
||||||
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
|
datasets: { positions: { status: 'missing', source: 'local', count: 0 } },
|
||||||
|
|||||||
@@ -60,6 +60,41 @@ describe('chapters lib', () => {
|
|||||||
expect(activeChapterIndex(sampleChapters, scrub, tMin, tRange)).toBe(1)
|
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', () => {
|
it('splits 90s evenly across chapters', () => {
|
||||||
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
|
expect(chapterTourDurations(sampleChapters)).toEqual([45_000, 45_000])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,7 +39,17 @@ test.describe('Race Hub Weekend Workspace', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole('img', { name: 'Position evolution chart' }),
|
page.getByRole('img', { name: 'Position evolution chart' }),
|
||||||
).toBeVisible()
|
).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 }) => {
|
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()
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||||
await page.getByRole('tab', { name: 'Race Story' }).click()
|
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()
|
await expect(page.locator('[data-testid="position-chart"]')).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user