mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Expand React race hub analytics
This commit is contained in:
69
frontend/src/components/DatasetStatusView.tsx
Normal file
69
frontend/src/components/DatasetStatusView.tsx
Normal 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 <key></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>
|
||||
)
|
||||
}
|
||||
80
frontend/src/components/PositionEvolutionView.tsx
Normal file
80
frontend/src/components/PositionEvolutionView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
frontend/src/components/StrategyView.tsx
Normal file
66
frontend/src/components/StrategyView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
frontend/src/components/TabBar.tsx
Normal file
32
frontend/src/components/TabBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user