mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-10-06 18:51:56 -04:00
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:
@@ -58,35 +58,67 @@ function SectionState({
|
|||||||
return <>{children}</>
|
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({
|
function TrackOutlineCard({
|
||||||
|
meeting,
|
||||||
outline,
|
outline,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
accent,
|
accent,
|
||||||
}: {
|
}: {
|
||||||
|
meeting: Meeting
|
||||||
outline: TrackOutline | null | undefined
|
outline: TrackOutline | null | undefined
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: Error | null
|
error: Error | null
|
||||||
accent: string
|
accent: string
|
||||||
}) {
|
}) {
|
||||||
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
||||||
|
const identity = circuitIdentity(meeting)
|
||||||
|
|
||||||
return (
|
let body: ReactNode
|
||||||
<section className="preview-card" data-testid="preview-track-card">
|
if (loading) {
|
||||||
<h2 className="preview-card-title">Circuit</h2>
|
body = <div className="preview-section-state">Loading…</div>
|
||||||
<SectionState
|
} else if (error) {
|
||||||
loading={loading}
|
body = (
|
||||||
error={error}
|
<div className="preview-section-state error">
|
||||||
empty={!outline || !outlinePath}
|
{error instanceof Error ? error.message : 'Failed to load'}
|
||||||
emptyMessage="Track outline unavailable for this circuit"
|
</div>
|
||||||
>
|
)
|
||||||
|
} else if (outlinePath) {
|
||||||
|
body = (
|
||||||
<div className="preview-track-stage" style={{ ['--preview-accent' as string]: accent }}>
|
<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">
|
<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-shadow" d={outlinePath} />
|
||||||
<path className="preview-track-outline" d={outlinePath} />
|
<path className="preview-track-outline" d={outlinePath} />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</SectionState>
|
)
|
||||||
|
} 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>
|
||||||
|
{body}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -408,6 +440,7 @@ export function RacePreviewPage() {
|
|||||||
|
|
||||||
<div className="preview-grid">
|
<div className="preview-grid">
|
||||||
<TrackOutlineCard
|
<TrackOutlineCard
|
||||||
|
meeting={previewMeeting}
|
||||||
outline={trackOutlineQuery.data}
|
outline={trackOutlineQuery.data}
|
||||||
loading={trackOutlineQuery.isLoading}
|
loading={trackOutlineQuery.isLoading}
|
||||||
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
||||||
|
|||||||
@@ -203,6 +203,47 @@
|
|||||||
stroke-linejoin: round;
|
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 {
|
.preview-podium {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -223,6 +223,8 @@ describe('RacePreviewPage', () => {
|
|||||||
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
|
expect(screen.getByTestId('preview-header')).toHaveTextContent('Monaco')
|
||||||
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
|
expect(screen.getByTestId('preview-schedule')).toHaveTextContent('FP1')
|
||||||
expect(screen.getByTestId('preview-track-card')).toBeInTheDocument()
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
|
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('VER')
|
||||||
@@ -273,5 +275,75 @@ describe('RacePreviewPage', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('preview-last-year-card')).toHaveTextContent('First time on the calendar')
|
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user