mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Assemble /preview from existing schedule, track-outline, results, and championship hub endpoints with client-side preview.ts helpers, section loading/empty states, and Vitest coverage for lib + page render paths. Co-authored-by: Cursor <cursoragent@cursor.com>
443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import {
|
|
fetchChampionshipHub,
|
|
fetchMeetings,
|
|
fetchResults,
|
|
fetchSeasons,
|
|
fetchSessions,
|
|
fetchStartingGrid,
|
|
fetchTrackOutline,
|
|
} from '../api'
|
|
import { countryAccent, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
|
import {
|
|
buildTitleFightContext,
|
|
countdownTargetSession,
|
|
extractPodium,
|
|
extractPole,
|
|
findPriorYearMeetingByCircuit,
|
|
findRaceSession,
|
|
formatPointsGap,
|
|
isSprintWeekend,
|
|
pickPreviewMeeting,
|
|
RACE_WIN_POINTS,
|
|
SPRINT_WEEKEND_MAX_POINTS,
|
|
} from '../lib/preview'
|
|
import { formatCountdown, formatSessionScheduleTime, sessionStartTime, sortSessionsByStart } from '../lib/schedule'
|
|
import { buildOutlinePath } from '../lib/trackmap'
|
|
import { teamColor } from '../utils'
|
|
import type { Meeting, Session, TrackOutline } from '../types'
|
|
import '../styles/preview.css'
|
|
|
|
function SectionState({
|
|
loading,
|
|
error,
|
|
empty,
|
|
emptyMessage,
|
|
children,
|
|
}: {
|
|
loading?: boolean
|
|
error?: Error | null
|
|
empty?: boolean
|
|
emptyMessage?: string
|
|
children: ReactNode
|
|
}) {
|
|
if (loading) {
|
|
return <div className="preview-section-state">Loading…</div>
|
|
}
|
|
if (error) {
|
|
return (
|
|
<div className="preview-section-state error">
|
|
{error instanceof Error ? error.message : 'Failed to load'}
|
|
</div>
|
|
)
|
|
}
|
|
if (empty) {
|
|
return <div className="preview-section-state">{emptyMessage ?? 'No data available'}</div>
|
|
}
|
|
return <>{children}</>
|
|
}
|
|
|
|
function TrackOutlineCard({
|
|
outline,
|
|
loading,
|
|
error,
|
|
accent,
|
|
}: {
|
|
outline: TrackOutline | null | undefined
|
|
loading: boolean
|
|
error: Error | null
|
|
accent: string
|
|
}) {
|
|
const outlinePath = useMemo(() => buildOutlinePath(outline?.points ?? []), [outline])
|
|
|
|
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>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function LastYearCard({
|
|
year,
|
|
loading,
|
|
error,
|
|
podium,
|
|
pole,
|
|
isFirstTime,
|
|
}: {
|
|
year: number | null
|
|
loading: boolean
|
|
error: Error | null
|
|
podium: ReturnType<typeof extractPodium>
|
|
pole: ReturnType<typeof extractPole>
|
|
isFirstTime: boolean
|
|
}) {
|
|
return (
|
|
<section className="preview-card" data-testid="preview-last-year-card">
|
|
<h2 className="preview-card-title">Last year here</h2>
|
|
{year != null && !isFirstTime && <p className="preview-card-sub">{year} race weekend</p>}
|
|
<SectionState
|
|
loading={loading}
|
|
error={error}
|
|
empty={isFirstTime || podium.length === 0}
|
|
emptyMessage={
|
|
isFirstTime
|
|
? 'First time on the calendar — no prior race at this circuit'
|
|
: 'No race results available for the previous visit'
|
|
}
|
|
>
|
|
<div className="preview-podium">
|
|
{podium.map((place) => (
|
|
<div key={place.position} className="preview-podium-row">
|
|
<span className="preview-podium-pos">P{place.position}</span>
|
|
<span className="preview-podium-code" style={{ color: teamColor(place.team_colour) }}>
|
|
{place.name_acronym}
|
|
</span>
|
|
<span className="preview-podium-team">{place.team_name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{pole && (
|
|
<p className="preview-pole">
|
|
Pole: <strong style={{ color: teamColor(pole.team_colour) }}>{pole.name_acronym}</strong>{' '}
|
|
({pole.team_name})
|
|
</p>
|
|
)}
|
|
</SectionState>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function TitleFightCard({
|
|
loading,
|
|
error,
|
|
drivers,
|
|
sprintWeekend,
|
|
season,
|
|
}: {
|
|
loading: boolean
|
|
error: Error | null
|
|
drivers: ReturnType<typeof buildTitleFightContext>
|
|
sprintWeekend: boolean
|
|
season: number | null
|
|
}) {
|
|
return (
|
|
<section className="preview-card preview-card--wide" data-testid="preview-title-fight-card">
|
|
<h2 className="preview-card-title">Title fight context</h2>
|
|
{season != null && <p className="preview-card-sub">{season} drivers' championship · top 3</p>}
|
|
<SectionState
|
|
loading={loading}
|
|
error={error}
|
|
empty={drivers.length === 0}
|
|
emptyMessage="Championship standings not available"
|
|
>
|
|
<table className="preview-title-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Driver</th>
|
|
<th>Pts</th>
|
|
<th>Gap</th>
|
|
<th>If win (+{RACE_WIN_POINTS})</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{drivers.map((d) => (
|
|
<tr key={d.name_acronym}>
|
|
<td>
|
|
<span style={{ color: teamColor(d.team_colour), fontWeight: 600 }}>{d.name_acronym}</span>
|
|
</td>
|
|
<td className="mono">{d.points}</td>
|
|
<td className="mono">{formatPointsGap(d.gapToLeader)}</td>
|
|
<td className="mono">
|
|
{d.position === 1 ? 'LEADER' : formatPointsGap(d.gapAfterRaceWin)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{sprintWeekend && (
|
|
<p className="preview-sprint-note" data-testid="preview-sprint-note">
|
|
Sprint weekend: up to {SPRINT_WEEKEND_MAX_POINTS} pts available (race win {RACE_WIN_POINTS} + sprint win
|
|
8). Max gain would reduce gaps by {SPRINT_WEEKEND_MAX_POINTS} vs a standard race win.
|
|
</p>
|
|
)}
|
|
</SectionState>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function PreviewHeader({
|
|
meeting,
|
|
sessions,
|
|
countdownSession,
|
|
now,
|
|
accent,
|
|
}: {
|
|
meeting: Meeting
|
|
sessions: Session[]
|
|
countdownSession: Session | null
|
|
now: Date
|
|
accent: string
|
|
}) {
|
|
const countdownTarget = countdownSession ? sessionStartTime(countdownSession) : null
|
|
|
|
return (
|
|
<header
|
|
className="preview-header"
|
|
data-testid="preview-header"
|
|
style={{ ['--preview-accent' as string]: accent }}
|
|
>
|
|
<div className="preview-header-accent" />
|
|
<div className="preview-header-body">
|
|
<div className="preview-header-top">
|
|
<div>
|
|
<h1 className="preview-gp-name">
|
|
{countryFlag(meeting) && <span className="preview-gp-flag">{countryFlag(meeting)}</span>}
|
|
{meeting.meeting_name}
|
|
</h1>
|
|
<p className="preview-circuit">
|
|
{meeting.circuit_short_name}
|
|
{meeting.location ? ` · ${meeting.location}` : ''}
|
|
{formatGpDateRange(meeting) ? ` · ${formatGpDateRange(meeting)}` : ''}
|
|
</p>
|
|
</div>
|
|
{countdownTarget && (
|
|
<div className="preview-countdown" data-testid="preview-countdown">
|
|
<span className="preview-countdown-label">
|
|
{countdownSession?.session_name ?? 'Next session'}
|
|
</span>
|
|
<span className="preview-countdown-value">{formatCountdown(countdownTarget, now)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{sessions.length > 0 && (
|
|
<div className="preview-schedule" data-testid="preview-schedule">
|
|
{sessions.map((session) => (
|
|
<div key={session.session_key} className="preview-schedule-item">
|
|
<span className="preview-schedule-name">{session.session_name}</span>
|
|
<span className="preview-schedule-time">{formatSessionScheduleTime(session.date_start)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
)
|
|
}
|
|
|
|
export function RacePreviewPage() {
|
|
const [now, setNow] = useState(() => Date.now())
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
|
return () => window.clearInterval(timer)
|
|
}, [])
|
|
|
|
const nowDate = useMemo(() => new Date(now), [now])
|
|
|
|
const seasonsQuery = useQuery({
|
|
queryKey: ['seasons'],
|
|
queryFn: fetchSeasons,
|
|
})
|
|
|
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
|
|
|
const meetingsQuery = useQuery({
|
|
queryKey: ['meetings', latestSeason, 'auto'],
|
|
queryFn: () => fetchMeetings(latestSeason!, 'auto'),
|
|
enabled: latestSeason != null,
|
|
})
|
|
|
|
const previewMeeting = useMemo(
|
|
() => pickPreviewMeeting(meetingsQuery.data ?? [], nowDate),
|
|
[meetingsQuery.data, nowDate],
|
|
)
|
|
|
|
const sessionsQuery = useQuery({
|
|
queryKey: ['sessions', previewMeeting?.meeting_key, 'auto'],
|
|
queryFn: () => fetchSessions(previewMeeting!.meeting_key, 'auto'),
|
|
enabled: previewMeeting != null,
|
|
})
|
|
|
|
const sessions = useMemo(
|
|
() => sortSessionsByStart(sessionsQuery.data ?? []),
|
|
[sessionsQuery.data],
|
|
)
|
|
|
|
const countdownSession = useMemo(() => countdownTargetSession(sessions, nowDate), [sessions, nowDate])
|
|
const sprintWeekend = useMemo(() => isSprintWeekend(sessions), [sessions])
|
|
|
|
const priorYear = previewMeeting ? previewMeeting.year - 1 : null
|
|
|
|
const priorMeetingsQuery = useQuery({
|
|
queryKey: ['meetings', priorYear, 'auto'],
|
|
queryFn: () => fetchMeetings(priorYear!, 'auto'),
|
|
enabled: priorYear != null && previewMeeting != null,
|
|
})
|
|
|
|
const priorMeeting = useMemo(
|
|
() => findPriorYearMeetingByCircuit(priorMeetingsQuery.data ?? [], previewMeeting?.circuit_key),
|
|
[priorMeetingsQuery.data, previewMeeting?.circuit_key],
|
|
)
|
|
|
|
const priorSessionsQuery = useQuery({
|
|
queryKey: ['sessions', priorMeeting?.meeting_key, 'auto'],
|
|
queryFn: () => fetchSessions(priorMeeting!.meeting_key, 'auto'),
|
|
enabled: priorMeeting != null,
|
|
})
|
|
|
|
const priorRaceSession = useMemo(
|
|
() => findRaceSession(priorSessionsQuery.data ?? []),
|
|
[priorSessionsQuery.data],
|
|
)
|
|
|
|
const priorResultsQuery = useQuery({
|
|
queryKey: ['results', priorRaceSession?.session_key, 'auto'],
|
|
queryFn: () => fetchResults(priorRaceSession!.session_key, 'auto'),
|
|
enabled: priorRaceSession != null,
|
|
})
|
|
|
|
const priorGridQuery = useQuery({
|
|
queryKey: ['grid', priorRaceSession?.session_key, 'auto'],
|
|
queryFn: () => fetchStartingGrid(priorRaceSession!.session_key, 'auto'),
|
|
enabled: priorRaceSession != null,
|
|
})
|
|
|
|
const trackOutlineQuery = useQuery({
|
|
queryKey: ['track-outline', previewMeeting?.circuit_key, previewMeeting?.year],
|
|
queryFn: () => fetchTrackOutline(previewMeeting!.circuit_key!, previewMeeting!.year),
|
|
enabled: previewMeeting?.circuit_key != null && previewMeeting.circuit_key > 0,
|
|
})
|
|
|
|
const championshipQuery = useQuery({
|
|
queryKey: ['championship-hub', latestSeason],
|
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
|
enabled: latestSeason != null,
|
|
})
|
|
|
|
const podium = useMemo(() => extractPodium(priorResultsQuery.data ?? []), [priorResultsQuery.data])
|
|
const pole = useMemo(() => extractPole(priorGridQuery.data ?? []), [priorGridQuery.data])
|
|
const titleFight = useMemo(
|
|
() => (championshipQuery.data ? buildTitleFightContext(championshipQuery.data) : []),
|
|
[championshipQuery.data],
|
|
)
|
|
|
|
const accent = countryAccent(previewMeeting)
|
|
const isFirstTimeCircuit = priorMeeting == null && priorYear != null && !priorMeetingsQuery.isLoading
|
|
|
|
if (seasonsQuery.isLoading || meetingsQuery.isLoading) {
|
|
return <div className="page loading-state" data-testid="preview-loading">loading preview…</div>
|
|
}
|
|
|
|
if (seasonsQuery.isError || meetingsQuery.isError) {
|
|
const err = seasonsQuery.error ?? meetingsQuery.error
|
|
return (
|
|
<div className="page error-box" data-testid="preview-error">
|
|
{err instanceof Error ? err.message : 'Failed to load preview'}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!previewMeeting) {
|
|
return (
|
|
<div className="preview-page" data-testid="preview-season-over">
|
|
<div className="preview-empty">
|
|
<span className="preview-empty-eyebrow mono">box-box · race preview</span>
|
|
<h1 className="preview-empty-title">Season complete</h1>
|
|
<p className="preview-empty-sub">
|
|
No upcoming races on the {latestSeason ?? 'current'} calendar. Check back when the next season schedule is
|
|
published.
|
|
</p>
|
|
</div>
|
|
{titleFight.length > 0 && (
|
|
<TitleFightCard
|
|
loading={championshipQuery.isLoading}
|
|
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
|
drivers={titleFight}
|
|
sprintWeekend={false}
|
|
season={latestSeason}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="preview-page" data-testid="preview-page">
|
|
<PreviewHeader
|
|
meeting={previewMeeting}
|
|
sessions={sessions}
|
|
countdownSession={countdownSession}
|
|
now={nowDate}
|
|
accent={accent}
|
|
/>
|
|
|
|
<div className="preview-grid">
|
|
<TrackOutlineCard
|
|
outline={trackOutlineQuery.data}
|
|
loading={trackOutlineQuery.isLoading}
|
|
error={trackOutlineQuery.isError ? (trackOutlineQuery.error as Error) : null}
|
|
accent={accent}
|
|
/>
|
|
|
|
<LastYearCard
|
|
year={priorMeeting?.year ?? priorYear}
|
|
loading={priorMeetingsQuery.isLoading || priorSessionsQuery.isLoading || priorResultsQuery.isLoading}
|
|
error={
|
|
priorMeetingsQuery.isError
|
|
? (priorMeetingsQuery.error as Error)
|
|
: priorResultsQuery.isError
|
|
? (priorResultsQuery.error as Error)
|
|
: null
|
|
}
|
|
podium={podium}
|
|
pole={pole}
|
|
isFirstTime={isFirstTimeCircuit}
|
|
/>
|
|
</div>
|
|
|
|
<TitleFightCard
|
|
loading={championshipQuery.isLoading}
|
|
error={championshipQuery.isError ? (championshipQuery.error as Error) : null}
|
|
drivers={titleFight}
|
|
sprintWeekend={sprintWeekend}
|
|
season={latestSeason}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|