feat(#105): design Preview circuit-outline unavailable state

Keep the Circuit card when GPS outline is missing: show meeting identity and an honest local-cache explanation instead of a one-line empty section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 15:36:19 -04:00
parent 21b29bec6a
commit a1e6b6af35
3 changed files with 159 additions and 13 deletions

View File

@@ -58,35 +58,67 @@ function SectionState({
return <>{children}</>
}
function circuitIdentity(meeting: Meeting): { name: string; place: string } {
const name = meeting.circuit_short_name?.trim() || meeting.meeting_name
const location = meeting.location?.trim() ?? ''
const country = meeting.country_name?.trim() ?? ''
const parts: string[] = []
if (location) parts.push(location)
if (country && country !== location) parts.push(country)
return { name, place: parts.join(' · ') }
}
function TrackOutlineCard({
meeting,
outline,
loading,
error,
accent,
}: {
meeting: Meeting
outline: TrackOutline | null | undefined
loading: boolean
error: Error | null
accent: string
}) {
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
const identity = circuitIdentity(meeting)
let body: ReactNode
if (loading) {
body = <div className="preview-section-state">Loading…</div>
} else if (error) {
body = (
<div className="preview-section-state error">
{error instanceof Error ? error.message : 'Failed to load'}
</div>
)
} else if (outlinePath) {
body = (
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
<path className="preview-track-shadow" d={outlinePath} />
<path className="preview-track-outline" d={outlinePath} />
</svg>
</div>
)
} else {
body = (
<div className="preview-track-fallback" data-testid="preview-track-fallback">
<div className="preview-track-fallback-glyph" aria-hidden />
<p className="preview-track-fallback-name">{identity.name}</p>
{identity.place && <p className="preview-track-fallback-place">{identity.place}</p>}
<p className="preview-track-fallback-hint">
A GPS track outline is not available in the local cache.
</p>
</div>
)
}
return (
<section className="preview-card" data-testid="preview-track-card">
<h2 className="preview-card-title">Circuit</h2>
<SectionState
loading={loading}
error={error}
empty={!outline || !outlinePath}
emptyMessage="Track outline unavailable for this circuit"
>
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
<svg className="preview-track-svg" viewBox="0 0 100 100" role="img" aria-label="Circuit outline">
<path className="preview-track-shadow" d={outlinePath} />
<path className="preview-track-outline" d={outlinePath} />
</svg>
</div>
</SectionState>
{body}
</section>
)
}
@@ -408,6 +440,7 @@ export function RacePreviewPage() {
<div className="preview-grid">
<TrackOutlineCard
meeting={previewMeeting}
outline={trackOutlineQuery.data}
loading={trackOutlineQuery.isLoading}
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}

View File

@@ -203,6 +203,47 @@
stroke-linejoin: round;
}
.preview-track-fallback {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--s2);
min-height: 180px;
padding: var(--s5);
}
.preview-track-fallback-glyph {
width: 88px;
height: 52px;
margin-bottom: var(--s2);
border: 2px dashed rgba(255, 255, 255, 0.14);
border-radius: 42% 50% 46% 54%;
}
.preview-track-fallback-name {
font-size: 1.2rem;
font-weight: 600;
color: var(--text-1);
margin: 0;
}
.preview-track-fallback-place {
font-size: 0.85rem;
color: var(--text-2);
margin: 0;
}
.preview-track-fallback-hint {
font-size: 0.8rem;
line-height: 1.5;
color: var(--text-3);
max-width: 22rem;
margin: var(--s2) 0 0;
}
.preview-podium {
display: flex;
flex-direction: column;

View File

@@ -223,6 +223,8 @@ describe('RacePreviewPage', () => {
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
expect(screen.getByRole('img', { name: 'Circuit outline' })).toBeInTheDocument()
expect(screen.queryByTestId('preview-track-fallback')).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
@@ -273,5 +275,75 @@ describe('RacePreviewPage', () => {
await waitFor(() => {
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
})
const fallback = screen.getByTestId('preview-track-fallback')
expect(screen.getByTestId('preview-track-card')).toContainElement(fallback)
expect(fallback).toHaveTextContent('Monaco')
expect(fallback).toHaveTextContent('A GPS track outline is not available in the local cache.')
expect(screen.queryByRole('img', { name: 'Circuit outline' })).not.toBeInTheDocument()
})
it('shows a designed circuit fallback when the track outline is unavailable', async () => {
const italianGp: Meeting = {
...upcomingMeeting,
meeting_name: 'Italian Grand Prix',
meeting_official_name: 'Formula 1 Pirelli Gran Premio d\'Italia',
location: 'Monza',
country_name: 'Italy',
country_code: 'ITA',
circuit_short_name: 'Monza',
}
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2099) return [italianGp]
if (year === 2098) return [{ ...italianGp, meeting_key: 90, year: 2098 }]
return []
})
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey === 100) return sessions
if (meetingKey === 90) return [priorRaceSession]
return []
})
mockFetchTrackOutline.mockResolvedValue(null)
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-track-fallback')).toBeInTheDocument()
})
const card = screen.getByTestId('preview-track-card')
const fallback = screen.getByTestId('preview-track-fallback')
expect(card).toContainElement(fallback)
expect(card).toHaveTextContent('Circuit')
expect(fallback).toHaveTextContent('Monza')
expect(fallback).toHaveTextContent('Italy')
expect(fallback).toHaveTextContent('A GPS track outline is not available in the local cache.')
expect(fallback).not.toHaveTextContent(/unknown/i)
expect(screen.queryByRole('img', { name: 'Circuit outline' })).not.toBeInTheDocument()
expect(card).not.toHaveTextContent('Track outline unavailable for this circuit')
})
it('keeps a thrown outline request distinct from the designed unavailable state', async () => {
mockFetchMeetings.mockImplementation(async (year: number) => {
if (year === 2099) return [upcomingMeeting]
if (year === 2098) return [priorMeeting]
return []
})
mockFetchSessions.mockImplementation(async (meetingKey: number) => {
if (meetingKey === 100) return sessions
if (meetingKey === 90) return [priorRaceSession]
return []
})
mockFetchTrackOutline.mockRejectedValue(new Error('outline request failed'))
renderPage()
await waitFor(() => {
expect(screen.getByTestId('preview-track-card')).toHaveTextContent('outline request failed')
})
expect(screen.queryByTestId('preview-track-fallback')).not.toBeInTheDocument()
expect(screen.queryByRole('img', { name: 'Circuit outline' })).not.toBeInTheDocument()
})
})