Expand React race hub analytics

This commit is contained in:
2026-05-25 01:20:04 -04:00
parent 32d500f5af
commit 5470b0df38
13 changed files with 593 additions and 95 deletions

View File

@@ -0,0 +1,69 @@
import type { DatasetInfo } from '../types'
interface Props {
datasets: Record<string, DatasetInfo>
}
const KNOWN_DATASETS: { key: string; label: string }[] = [
{ key: 'meeting', label: 'Meeting' },
{ key: 'session', label: 'Session' },
{ key: 'drivers', label: 'Drivers' },
{ key: 'results', label: 'Results' },
{ key: 'starting_grid', label: 'Starting Grid' },
]
export function DatasetStatusView({ datasets }: Props) {
const entries = KNOWN_DATASETS.map(({ key, label }) => ({
key,
label,
info: datasets[key] as DatasetInfo | undefined,
}))
const available = entries.filter((e) => e.info?.status === 'available').length
const total = entries.length
return (
<div>
<div className="ds-legend">
<span>
{available}/{total} datasets available locally
</span>
{available < total && (
<span>
Re-run <code>box-box --ingest-session &lt;key&gt;</code> after backend
support exists for missing datasets.
</span>
)}
</div>
<table className="data-table" style={{ maxWidth: 480 }}>
<thead>
<tr>
<th>Dataset</th>
<th>Status</th>
<th className="r">Records</th>
</tr>
</thead>
<tbody>
{entries.map(({ key, label, info }) => (
<tr key={key}>
<td className="mono" style={{ color: 'var(--text-2)' }}>
{label}
</td>
<td>
{info?.status === 'available' ? (
<span className="badge badge-local">Local</span>
) : (
<span className="badge badge-none">Missing</span>
)}
</td>
<td className="r mono" style={{ color: 'var(--text-3)' }}>
{info?.count != null ? info.count : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,80 @@
import type { EnrichedResult, EnrichedGrid } from '../types'
import { gridDelta, gridDeltaClass } from '../utils'
interface Props {
results: EnrichedResult[]
grid: EnrichedGrid[]
hasPositions: boolean
}
export function PositionEvolutionView({ results, grid, hasPositions }: Props) {
if (!hasPositions) {
return (
<div>
<div className="analysis-notice">
<strong>Lap-by-lap positions not available.</strong> The backend does not
yet expose position samples in <code>/api/v1/race-hub</code>. Evolution
charts require per-driver position per lap.
</div>
{results.length > 0 && grid.length > 0 && (
<>
<div className="sec-header" style={{ marginTop: 'var(--s5)' }}>
<span className="sec-title">Grid Finish</span>
<span className="sec-meta">net positions gained/lost</span>
</div>
<table className="data-table" style={{ maxWidth: 420 }}>
<thead>
<tr>
<th>Driver</th>
<th className="hide-mobile">Team</th>
<th className="c">Grid</th>
<th className="c">Finish</th>
<th className="r">Δ</th>
</tr>
</thead>
<tbody>
{results.map((r) => {
const gridPos =
grid.find((g) => g.driver_number === r.driver_number)?.position ?? 0
return (
<tr key={r.driver_number}>
<td>
<span
style={{
fontFamily: 'var(--f-mono)',
fontWeight: 700,
color: r.team_colour ? `#${r.team_colour}` : 'var(--text)',
}}
>
{r.name_acronym || r.driver_number}
</span>
</td>
<td className="hide-mobile" style={{ color: 'var(--text-2)', fontSize: 11 }}>
{r.team_name}
</td>
<td className="c mono" style={{ color: 'var(--text-3)' }}>
{gridPos || '—'}
</td>
<td className="c mono">{r.position}</td>
<td className="r">
<span className={gridDeltaClass(r.position, gridPos)}>
{gridDelta(r.position, gridPos)}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</>
)}
</div>
)
}
// Placeholder for when position samples are available
return (
<div className="missing-notice">Position evolution chart: not yet implemented.</div>
)
}

View File

@@ -0,0 +1,66 @@
import type { EnrichedResult } from '../types'
interface Props {
results: EnrichedResult[]
hasStints: boolean
}
export function StrategyView({ results, hasStints }: Props) {
if (!hasStints) {
return (
<div>
<div className="analysis-notice">
<strong>Stints not available.</strong> The backend does not yet expose
tyre compound and stint ranges in <code>/api/v1/race-hub</code>. Strategy
charts require per-driver stints: compound, lap_start, lap_end.
</div>
{results.length > 0 && (
<>
<div className="sec-header" style={{ marginTop: 'var(--s5)' }}>
<span className="sec-title">Laps Completed</span>
<span className="sec-meta">from results hint at pit count</span>
</div>
<table className="data-table" style={{ maxWidth: 360 }}>
<thead>
<tr>
<th className="c" style={{ width: 28 }}>P</th>
<th>Driver</th>
<th className="r">Laps</th>
</tr>
</thead>
<tbody>
{results.map((r) => (
<tr key={r.driver_number}>
<td className="c mono" style={{ color: 'var(--text-3)' }}>
{r.position}
</td>
<td>
<span
style={{
fontFamily: 'var(--f-mono)',
fontWeight: 700,
color: r.team_colour ? `#${r.team_colour}` : 'var(--text)',
}}
>
{r.name_acronym || r.driver_number}
</span>
</td>
<td className="r mono" style={{ color: r.number_of_laps > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{r.number_of_laps > 0 ? r.number_of_laps : '—'}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
)
}
// Placeholder for when stints data is available
return (
<div className="missing-notice">Strategy chart: not yet implemented.</div>
)
}

View File

@@ -0,0 +1,32 @@
export type Tab = 'results' | 'grid' | 'strategy' | 'positions' | 'datasets'
const TABS: { id: Tab; label: string }[] = [
{ id: 'results', label: 'Results' },
{ id: 'grid', label: 'Grid' },
{ id: 'strategy', label: 'Strategy' },
{ id: 'positions', label: 'Positions' },
{ id: 'datasets', label: 'Datasets' },
]
interface Props {
active: Tab
onChange: (tab: Tab) => void
}
export function TabBar({ active, onChange }: Props) {
return (
<div className="tab-bar" role="tablist">
{TABS.map((t) => (
<button
key={t.id}
role="tab"
aria-selected={active === t.id}
className={`tab-btn${active === t.id ? ' active' : ''}`}
onClick={() => onChange(t.id)}
>
{t.label}
</button>
))}
</div>
)
}

View File

@@ -6,6 +6,10 @@ import { RaceHubHeader } from '../components/RaceHubHeader'
import { DatasetStrip } from '../components/DatasetStrip'
import { ClassificationTable } from '../components/ClassificationTable'
import { StartingGridTable } from '../components/StartingGridTable'
import { TabBar, type Tab } from '../components/TabBar'
import { DatasetStatusView } from '../components/DatasetStatusView'
import { StrategyView } from '../components/StrategyView'
import { PositionEvolutionView } from '../components/PositionEvolutionView'
interface Props {
sessionKey: number
@@ -14,6 +18,7 @@ interface Props {
export function RaceHubPage({ sessionKey }: Props) {
const navigate = useNavigate()
const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '')
const [activeTab, setActiveTab] = useState<Tab>('results')
const { data, isLoading, isError, error } = useQuery({
queryKey: ['race-hub', sessionKey],
@@ -85,25 +90,65 @@ export function RaceHubPage({ sessionKey }: Props) {
<DatasetStrip datasets={data.datasets} />
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Final Classification</span>
{data.results.length > 0 && (
<span className="sec-meta">{data.results.length} drivers</span>
)}
</div>
<ClassificationTable results={data.results} grid={data.starting_grid} />
</div>
<TabBar active={activeTab} onChange={setActiveTab} />
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Starting Grid</span>
{data.starting_grid.length > 0 && (
<span className="sec-meta">{data.starting_grid.length} positions</span>
)}
{activeTab === 'results' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Final Classification</span>
{data.results.length > 0 && (
<span className="sec-meta">{data.results.length} drivers</span>
)}
</div>
<ClassificationTable results={data.results} grid={data.starting_grid} />
</div>
<StartingGridTable grid={data.starting_grid} />
</div>
)}
{activeTab === 'grid' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Starting Grid</span>
{data.starting_grid.length > 0 && (
<span className="sec-meta">{data.starting_grid.length} positions</span>
)}
</div>
<StartingGridTable grid={data.starting_grid} />
</div>
)}
{activeTab === 'strategy' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Race Strategy</span>
</div>
<StrategyView
results={data.results}
hasStints={data.datasets['stints']?.status === 'available'}
/>
</div>
)}
{activeTab === 'positions' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Position Evolution</span>
</div>
<PositionEvolutionView
results={data.results}
grid={data.starting_grid}
hasPositions={data.datasets['positions']?.status === 'available'}
/>
</div>
)}
{activeTab === 'datasets' && (
<div className="data-section">
<div className="sec-header">
<span className="sec-title">Dataset Status</span>
</div>
<DatasetStatusView datasets={data.datasets} />
</div>
)}
</>
)}
</div>

View File

@@ -363,6 +363,65 @@ a { color: inherit; text-decoration: none; }
color: var(--text-3);
}
/* ── Tab bar ── */
.tab-bar {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: var(--s5);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tab-bar::-webkit-scrollbar { display: none; }
.tab-btn {
padding: 8px 14px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-3);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
transition: color 0.1s;
margin-bottom: -1px;
flex-shrink: 0;
}
.tab-btn:hover { color: var(--text-2); }
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
/* ── Dataset status view ── */
.ds-legend {
display: flex;
align-items: center;
gap: var(--s5);
margin-bottom: var(--s5);
font-size: 12px;
color: var(--text-2);
}
.ds-legend code {
font-family: var(--f-mono);
font-size: 11px;
color: var(--text-3);
}
/* ── Strategy / position views ── */
.analysis-notice {
padding: var(--s4) var(--s5);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--border-2);
font-size: 12px;
color: var(--text-2);
margin-bottom: var(--s5);
max-width: 560px;
}
.analysis-notice strong { color: var(--text); }
.analysis-notice code { font-family: var(--f-mono); font-size: 11px; color: var(--text-3); }
/* ── Mobile ── */
@media (max-width: 640px) {
.page { padding: var(--s3); }

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { DatasetStatusView } from '../components/DatasetStatusView'
import type { DatasetInfo } from '../types'
const allAvailable: 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 },
}
const partial: Record<string, DatasetInfo> = {
meeting: { status: 'available', source: 'local', count: 1 },
session: { status: 'available', source: 'local', count: 1 },
drivers: { status: 'missing', source: 'none' },
results: { status: 'missing', source: 'none' },
starting_grid: { status: 'missing', source: 'none' },
}
describe('DatasetStatusView', () => {
it('shows 5/5 when all available', () => {
render(<DatasetStatusView datasets={allAvailable} />)
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
})
it('shows local badges for available datasets', () => {
render(<DatasetStatusView datasets={allAvailable} />)
const localBadges = screen.getAllByText('Local')
expect(localBadges).toHaveLength(5)
})
it('shows missing badges for missing datasets', () => {
render(<DatasetStatusView datasets={partial} />)
const missingBadges = screen.getAllByText('Missing')
expect(missingBadges).toHaveLength(3)
})
it('shows the ingest command when data is missing', () => {
render(<DatasetStatusView datasets={partial} />)
expect(screen.getByText(/ingest-session/i)).toBeInTheDocument()
})
it('does not show ingest command when all available', () => {
render(<DatasetStatusView datasets={allAvailable} />)
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
})
it('shows record counts', () => {
render(<DatasetStatusView datasets={allAvailable} />)
const twenties = screen.getAllByText('20')
expect(twenties.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TabBar } from '../components/TabBar'
describe('TabBar', () => {
it('renders all 5 tabs', () => {
render(<TabBar active="results" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Results' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Grid' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Positions' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Datasets' })).toBeInTheDocument()
})
it('marks the active tab with aria-selected', () => {
render(<TabBar active="grid" onChange={() => {}} />)
expect(screen.getByRole('tab', { name: 'Grid' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', { name: 'Results' })).toHaveAttribute('aria-selected', 'false')
})
it('applies active class only to the active tab', () => {
render(<TabBar active="strategy" onChange={() => {}} />)
const strategy = screen.getByRole('tab', { name: 'Strategy' })
const results = screen.getByRole('tab', { name: 'Results' })
expect(strategy.className).toContain('active')
expect(results.className).not.toContain('active')
})
it('calls onChange with the correct tab id when clicked', () => {
const onChange = vi.fn()
render(<TabBar active="results" onChange={onChange} />)
fireEvent.click(screen.getByRole('tab', { name: 'Datasets' }))
expect(onChange).toHaveBeenCalledWith('datasets')
})
})