Add local data navigation UI

This commit is contained in:
2026-05-25 02:31:35 -04:00
parent 1661f8dec3
commit 2c9db0213c
11 changed files with 758 additions and 98 deletions

View File

@@ -1,4 +1,4 @@
import type { RaceHub } from './types'
import type { Meeting, RaceHub, Weekend } from './types'
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
const res = await fetch(`/api/v1/race-hub?session_key=${sessionKey}`)
@@ -7,3 +7,29 @@ export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
}
return res.json()
}
export async function fetchSeasons(): Promise<number[]> {
const res = await fetch('/api/v1/seasons')
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const years = await res.json()
return Array.isArray(years) ? years : []
}
export async function fetchLocalMeetings(year: number): Promise<Meeting[]> {
const res = await fetch(`/api/v1/meetings?year=${year}&source=local`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
const meetings = await res.json()
return Array.isArray(meetings) ? meetings : []
}
export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
const res = await fetch(`/api/v1/weekend?meeting_key=${meetingKey}`)
if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`)
}
return res.json()
}

View File

@@ -0,0 +1,305 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import type { DatasetInfo, Meeting, WeekendSession } from '../types'
const RACE_HUB_DATASETS = [
'meeting',
'session',
'drivers',
'results',
'starting_grid',
'stints',
'pit_stops',
'positions',
'race_control',
'weather',
'laps',
] as const
export function countRaceHubDatasets(datasets: Record<string, DatasetInfo>): { available: number; total: number } {
const total = RACE_HUB_DATASETS.length
const available = RACE_HUB_DATASETS.filter((key) => datasets[key]?.status === 'available').length
return { available, total }
}
export function formatCoverageHint(datasets: Record<string, DatasetInfo>): string {
const { available, total } = countRaceHubDatasets(datasets)
return `${available}/${total}`
}
function sourceBadge(source: WeekendSession['source']) {
switch (source) {
case 'local':
return <span className="badge badge-local">Local</span>
case 'partial':
return <span className="badge badge-partial">Partial</span>
default:
return <span className="badge badge-none">None</span>
}
}
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 || '—'
}
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>
<td>{sourceBadge(source)}</td>
<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>
)
}
function SessionCoverageDots({ datasets }: { datasets: Record<string, DatasetInfo> }) {
return (
<span className="coverage-dots" aria-hidden="true">
{RACE_HUB_DATASETS.map((key) => {
const available = datasets[key]?.status === 'available'
return <span key={key} className={`coverage-dot ${available ? 'on' : 'off'}`} />
})}
</span>
)
}

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchRaceHub } from '../api'
import { LocalDataNavigator } from '../components/LocalDataNavigator'
import { RaceHubHeader } from '../components/RaceHubHeader'
import { DatasetStrip } from '../components/DatasetStrip'
import { ClassificationTable } from '../components/ClassificationTable'
@@ -20,6 +21,10 @@ export function RaceHubPage({ sessionKey }: Props) {
const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '')
const [activeTab, setActiveTab] = useState<Tab>('results')
useEffect(() => {
setInputVal(sessionKey > 0 ? String(sessionKey) : '')
}, [sessionKey])
const { data, isLoading, isError, error } = useQuery({
queryKey: ['race-hub', sessionKey],
queryFn: () => fetchRaceHub(sessionKey),
@@ -55,17 +60,8 @@ export function RaceHubPage({ sessionKey }: Props) {
)}
</form>
{/* Prompt when no key entered */}
{sessionKey === 0 && (
<div className="empty-state">
<div className="empty-state-title">Enter a session key to load Race Hub data</div>
<div className="empty-state-desc">
Example: <code>9472</code> (Monaco GP 2025 Race)<br />
Ingest data first with{' '}
<code>box-box --ingest-session &lt;key&gt;</code>
</div>
</div>
)}
{/* Local data browser when no session loaded */}
{sessionKey === 0 && <LocalDataNavigator />}
{/* Loading */}
{sessionKey > 0 && isLoading && (

View File

@@ -83,6 +83,123 @@ a { color: inherit; text-decoration: none; }
padding: var(--s5) var(--s6);
}
/* ── Local data navigator ── */
.nav-panel {
margin-bottom: var(--s5);
padding-bottom: var(--s5);
border-bottom: 1px solid var(--border);
}
.nav-panel-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s4);
margin-bottom: var(--s5);
}
.nav-panel-title {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
}
.year-list {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.year-btn {
padding: 3px 10px;
font-family: var(--f-mono);
font-size: 12px;
font-weight: 700;
color: var(--text-2);
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: 2px;
cursor: pointer;
}
.year-btn:hover { color: var(--text); border-color: var(--text-3); }
.year-btn.active {
color: var(--text);
border-color: var(--red);
background: rgba(225, 6, 0, 0.08);
}
.nav-section { margin-bottom: var(--s5); }
.nav-section:last-child { margin-bottom: 0; }
.nav-section-meta {
font-size: 12px;
font-family: var(--f-mono);
color: var(--text-3);
padding: var(--s3) 0;
}
.nav-table td { white-space: normal; }
.nav-row-selected { background: var(--surface-h); }
.nav-sub {
display: block;
font-size: 10px;
color: var(--text-3);
margin-top: 2px;
}
.nav-action-btn {
padding: 3px 10px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-2);
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: 2px;
cursor: pointer;
}
.nav-action-btn:hover { color: var(--text); border-color: var(--text-3); }
.nav-action-btn.active {
color: var(--text);
border-color: var(--border-2);
background: var(--surface-2);
}
.nav-action-primary {
color: #fff;
background: var(--red);
border-color: var(--red);
}
.nav-action-primary:hover {
color: #fff;
background: #c50500;
border-color: #c50500;
}
.coverage-dots {
display: inline-flex;
gap: 3px;
margin-left: var(--s3);
vertical-align: middle;
}
.coverage-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-3);
opacity: 0.35;
}
.coverage-dot.on {
background: var(--green);
opacity: 1;
}
/* ── Session input bar ── */
.session-bar {
display: flex;

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { LocalDataNavigator, countRaceHubDatasets, formatCoverageHint } from '../components/LocalDataNavigator'
import type { DatasetInfo, Meeting, Weekend } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(),
fetchWeekend: vi.fn(),
}))
import { fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const meeting: Meeting = {
meeting_key: 1229,
meeting_name: 'Monaco',
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
location: 'Monaco',
country_name: 'Monaco',
country_code: 'MON',
country_flag: '',
circuit_short_name: 'Monaco',
date_start: '2025-05-23T00:00:00+00:00',
date_end: '2025-05-25T00:00:00+00:00',
year: 2025,
}
const fullDatasets: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'available', source: 'local', count: 20 },
results: { status: 'available', source: 'local', count: 20 },
starting_grid: { status: 'available', source: 'local', count: 20 },
stints: { status: 'available', source: 'local', count: 2 },
pit_stops: { status: 'available', source: 'local', count: 1 },
positions: { status: 'available', source: 'local', count: 3 },
race_control: { status: 'available', source: 'local', count: 1 },
weather: { status: 'available', source: 'local', count: 1 },
laps: { status: 'available', source: 'local', count: 1 },
}
const weekend: Weekend = {
source: 'local',
meeting_key: 1229,
meeting,
default_session_key: 9472,
sessions: [
{
session: {
session_key: 9472,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1229,
date_start: '2025-05-25T13:00:00+00:00',
date_end: '2025-05-25T15:00:00+00:00',
gmt_offset: '02:00:00',
},
source: 'local',
datasets: fullDatasets,
},
],
}
function renderWithProviders(onSelectSession?: (sessionKey: number) => void) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<LocalDataNavigator onSelectSession={onSelectSession} />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('navigation helpers', () => {
it('counts available Race Hub datasets', () => {
expect(countRaceHubDatasets(fullDatasets)).toEqual({ available: 11, total: 11 })
expect(countRaceHubDatasets({ meeting: { status: 'available', source: 'local' } })).toEqual({
available: 1,
total: 11,
})
})
it('formats coverage hint', () => {
expect(formatCoverageHint(fullDatasets)).toBe('11/11')
})
})
describe('LocalDataNavigator', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows empty state when no seasons exist', async () => {
mockFetchSeasons.mockResolvedValue([])
renderWithProviders()
expect(await screen.findByTestId('local-nav-empty')).toBeInTheDocument()
expect(screen.getByText(/No ingested seasons yet/i)).toBeInTheDocument()
})
it('loads meetings for the first season by default', async () => {
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
renderWithProviders()
expect(await screen.findByTestId('local-nav')).toBeInTheDocument()
await waitFor(() => {
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
})
expect(screen.getByText('Monaco')).toBeInTheDocument()
})
it('loads weekend sessions and calls onSelectSession', async () => {
const onSelectSession = vi.fn()
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
renderWithProviders(onSelectSession)
await screen.findByText('Monaco')
fireEvent.click(screen.getByRole('button', { name: 'Sessions' }))
expect(await screen.findByTestId('weekend-sessions')).toBeInTheDocument()
expect(screen.getByText('11/11')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('open-session-9472'))
expect(onSelectSession).toHaveBeenCalledWith(9472)
})
})

View File

@@ -155,3 +155,17 @@ export interface Lap {
lap_duration: number | null
is_pit_out_lap: boolean
}
export interface WeekendSession {
session: Session
source: 'local' | 'partial' | 'none'
datasets: Record<string, DatasetInfo>
}
export interface Weekend {
source: 'local' | 'partial' | 'none'
meeting_key: number
meeting: Meeting
sessions: WeekendSession[]
default_session_key?: number
}