Files
box-box/frontend/src/components/LocalDataNavigator.tsx

269 lines
9.8 KiB
TypeScript
Raw Normal View History

2026-05-25 02:31:35 -04:00
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
2026-05-25 02:45:02 -04:00
import { formatCoverageHint } from '../lib/coverage'
import { SourceBadge } from './SourceBadge'
import { SessionCoverageDots } from './SessionCoverageDots'
import type { Meeting, WeekendSession } from '../types'
2026-05-25 02:31:35 -04:00
function formatMeetingDates(meeting: Meeting): string {
const start = meeting.date_start?.slice(0, 10)
const end = meeting.date_end?.slice(0, 10)
if (start && end && start !== end) return `${start} ${end}`
return start || end || '—'
}
2026-05-25 02:45:02 -04:00
function sessionSourceBadge(source: WeekendSession['source']) {
return <SourceBadge source={source} />
}
2026-05-25 02:31:35 -04:00
interface Props {
onSelectSession?: (sessionKey: number) => void
}
export function LocalDataNavigator({ onSelectSession }: Props) {
const navigate = useNavigate()
const [selectedYear, setSelectedYear] = useState<number | null>(null)
const [selectedMeetingKey, setSelectedMeetingKey] = useState<number | null>(null)
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const meetingsQuery = useQuery({
queryKey: ['meetings', selectedYear],
queryFn: () => fetchLocalMeetings(selectedYear!),
enabled: selectedYear != null,
})
const weekendQuery = useQuery({
queryKey: ['weekend', selectedMeetingKey],
queryFn: () => fetchWeekend(selectedMeetingKey!),
enabled: selectedMeetingKey != null,
})
useEffect(() => {
if (seasonsQuery.data?.length && selectedYear == null) {
setSelectedYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, selectedYear])
function handleSelectSession(sessionKey: number) {
if (onSelectSession) {
onSelectSession(sessionKey)
return
}
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
}
function handleSelectYear(year: number) {
setSelectedYear(year)
setSelectedMeetingKey(null)
}
function handleSelectMeeting(meetingKey: number) {
setSelectedMeetingKey((prev) => (prev === meetingKey ? null : meetingKey))
}
if (seasonsQuery.isLoading) {
return <div className="nav-panel loading-state">loading local seasons</div>
}
if (seasonsQuery.isError) {
return (
<div className="nav-panel error-box">
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
</div>
)
}
const seasons = seasonsQuery.data ?? []
if (seasons.length === 0) {
return (
<div className="nav-panel" data-testid="local-nav-empty">
<div className="nav-panel-title">Local Data</div>
<div className="empty-state" style={{ padding: 'var(--s5) 0' }}>
<div className="empty-state-title">No ingested seasons yet</div>
<div className="empty-state-desc">
Ingest a session with <code>box-box --ingest-session &lt;key&gt;</code>, then browse
here or enter a session key below.
</div>
</div>
</div>
)
}
const meetings = meetingsQuery.data ?? []
const weekend = weekendQuery.data
return (
<div className="nav-panel" data-testid="local-nav">
<div className="nav-panel-head">
<span className="nav-panel-title">Local Data</span>
<div className="year-list" role="listbox" aria-label="Season">
{seasons.map((year) => (
<button
key={year}
type="button"
role="option"
aria-selected={year === selectedYear}
className={`year-btn ${year === selectedYear ? 'active' : ''}`}
onClick={() => handleSelectYear(year)}
>
{year}
</button>
))}
</div>
</div>
{meetingsQuery.isLoading && (
<div className="nav-section-meta">loading meetings</div>
)}
{meetingsQuery.isError && (
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
{meetingsQuery.error instanceof Error ? meetingsQuery.error.message : 'Failed to load meetings'}
</div>
)}
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
<div className="nav-section-meta">No meetings ingested for {selectedYear}.</div>
)}
{meetings.length > 0 && (
<div className="nav-section">
<div className="sec-header">
<span className="sec-title">Meetings</span>
<span className="sec-meta">{meetings.length}</span>
</div>
<div className="scroll-x">
<table className="data-table nav-table">
<thead>
<tr>
<th>Grand Prix</th>
<th className="hide-mobile">Country</th>
<th className="hide-mobile">Dates</th>
<th className="r">Open</th>
</tr>
</thead>
<tbody>
{meetings.map((meeting) => {
const selected = meeting.meeting_key === selectedMeetingKey
return (
<tr
key={meeting.meeting_key}
className={selected ? 'nav-row-selected' : ''}
data-testid={`meeting-row-${meeting.meeting_key}`}
>
<td>
<span style={{ fontWeight: 600 }}>{meeting.meeting_name}</span>
{meeting.circuit_short_name && meeting.circuit_short_name !== meeting.meeting_name && (
<span className="nav-sub">{meeting.circuit_short_name}</span>
)}
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}>
{meeting.country_code || meeting.country_name}
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
{formatMeetingDates(meeting)}
</td>
<td className="r">
<button
type="button"
className={`nav-action-btn ${selected ? 'active' : ''}`}
aria-expanded={selected}
onClick={() => handleSelectMeeting(meeting.meeting_key)}
>
{selected ? 'Hide' : 'Sessions'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{selectedMeetingKey != null && weekendQuery.isLoading && (
<div className="nav-section-meta">loading sessions</div>
)}
{selectedMeetingKey != null && weekendQuery.isError && (
<div className="error-box" style={{ marginTop: 'var(--s4)' }}>
{weekendQuery.error instanceof Error ? weekendQuery.error.message : 'Failed to load weekend'}
</div>
)}
{weekend && (
<div className="nav-section" data-testid="weekend-sessions">
<div className="sec-header">
<span className="sec-title">{weekend.meeting.meeting_name} Sessions</span>
<span className="sec-meta">{weekend.sessions.length}</span>
</div>
{weekend.sessions.length === 0 ? (
<div className="nav-section-meta">No sessions stored for this meeting.</div>
) : (
<div className="scroll-x">
<table className="data-table nav-table">
<thead>
<tr>
<th>Session</th>
<th className="hide-mobile">Type</th>
<th>Coverage</th>
<th>Source</th>
<th className="r">Open</th>
</tr>
</thead>
<tbody>
{weekend.sessions.map(({ session, source, datasets }) => {
const coverage = formatCoverageHint(datasets)
const isDefault = session.session_key === weekend.default_session_key
return (
<tr key={session.session_key} data-testid={`session-row-${session.session_key}`}>
<td>
<span style={{ fontWeight: 600 }}>{session.session_name}</span>
{isDefault && <span className="nav-sub">default</span>}
<span className="nav-sub mono">{session.session_key}</span>
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
{session.session_type}
</td>
<td>
<span className="mono" style={{ color: 'var(--text-2)' }}>
{coverage}
</span>
<SessionCoverageDots datasets={datasets} />
</td>
2026-05-25 02:45:02 -04:00
<td>{sessionSourceBadge(source)}</td>
2026-05-25 02:31:35 -04:00
<td className="r">
<button
type="button"
className="nav-action-btn nav-action-primary"
data-testid={`open-session-${session.session_key}`}
onClick={() => handleSelectSession(session.session_key)}
>
Race Hub
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)
}
2026-05-25 02:45:02 -04:00
// Re-export helpers used by tests
export { countRaceHubDatasets, formatCoverageHint } from '../lib/coverage'