Add React race hub frontend

This commit is contained in:
2026-05-25 01:10:44 -04:00
parent e08255db70
commit 32d500f5af
29 changed files with 5976 additions and 105 deletions

View File

@@ -0,0 +1,98 @@
import type { EnrichedResult, EnrichedGrid } from '../types'
import { DriverCell } from './DriverCell'
import { formatDuration, formatGap, positionClass, gridDelta, gridDeltaClass } from '../utils'
interface Props {
results: EnrichedResult[]
grid: EnrichedGrid[]
}
export function ClassificationTable({ results, grid }: Props) {
if (results.length === 0) {
return (
<div className="missing-notice">
Results not ingested. Run{' '}
<code>box-box --ingest-session &lt;key&gt;</code> to load this dataset.
</div>
)
}
const gridByDriver = Object.fromEntries(grid.map((g) => [g.driver_number, g.position]))
const isRace = results.some((r) => r.points > 0 || r.number_of_laps > 0)
return (
<div className="scroll-x">
<table className="data-table" style={{ minWidth: 520 }}>
<thead>
<tr>
<th className="c" style={{ width: 28 }}>P</th>
<th>Driver</th>
<th className="hide-mobile">Team</th>
{isRace && <th className="c hide-mobile">Grid</th>}
{isRace && <th className="c hide-mobile">Δ</th>}
<th className="r">Time / Gap</th>
{isRace && <th className="r hide-mobile">Pts</th>}
</tr>
</thead>
<tbody>
{results.map((r) => {
const gridPos = gridByDriver[r.driver_number] ?? 0
const timeStr = r.dnf
? null
: r.dns
? null
: r.dsq
? null
: r.position === 1
? formatDuration(r.duration)
: formatGap(r.gap_to_leader)
return (
<tr key={r.driver_number}>
<td className="c">
<span className={positionClass(r.position)}>{r.position}</span>
</td>
<td>
<DriverCell
acronym={r.name_acronym || String(r.driver_number)}
number={r.driver_number}
colour={r.team_colour}
/>
</td>
<td className="hide-mobile" style={{ color: 'var(--text-2)', fontSize: 11 }}>
{r.team_name}
</td>
{isRace && (
<td className="c mono hide-mobile" style={{ color: 'var(--text-3)' }}>
{gridPos || '—'}
</td>
)}
{isRace && (
<td className="c hide-mobile">
<span className={gridDeltaClass(r.position, gridPos)}>
{gridDelta(r.position, gridPos)}
</span>
</td>
)}
<td className="r">
{r.dnf && <span className="status-dnf">DNF</span>}
{r.dns && <span className="status-dns">DNS</span>}
{r.dsq && <span className="status-dsq">DSQ</span>}
{!r.dnf && !r.dns && !r.dsq && (
<span style={{ fontFamily: 'var(--f-mono)' }}>{timeStr ?? '—'}</span>
)}
</td>
{isRace && (
<td className="r hide-mobile" style={{ fontWeight: r.points > 0 ? 700 : 400, color: r.points > 0 ? 'var(--text)' : 'var(--text-3)' }}>
{r.points > 0 ? r.points : '—'}
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import type { DatasetInfo } from '../types'
const DATASET_LABELS: Record<string, string> = {
meeting: 'meeting',
session: 'session',
drivers: 'drivers',
results: 'results',
starting_grid: 'grid',
}
interface Props {
datasets: Record<string, DatasetInfo>
}
export function DatasetStrip({ datasets }: Props) {
const keys = Object.keys(DATASET_LABELS)
return (
<div className="dataset-strip">
{keys.map((key) => {
const info = datasets[key]
const available = info?.status === 'available'
return (
<div key={key} className="ds-item" title={info ? `${info.source} · ${info.count ?? 0} rows` : 'missing'}>
<div className={`ds-dot ${available ? 'ds-dot-local' : 'ds-dot-missing'}`} />
<span>{DATASET_LABELS[key]}</span>
{available && info.count != null && info.count > 0 && (
<span style={{ opacity: 0.5 }}>·{info.count}</span>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { teamColor } from '../utils'
interface Props {
acronym: string
number: number
colour: string
}
export function DriverCell({ acronym, number, colour }: Props) {
return (
<div className="drv-cell">
<div className="drv-bar" style={{ background: teamColor(colour) }} />
<span className="drv-code">{acronym}</span>
<span className="drv-num">{number}</span>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import { Link } from '@tanstack/react-router'
export function Nav() {
return (
<nav className="app-nav">
<Link to="/" className="nav-logo">
box<em>-</em>box
</Link>
<div className="nav-links">
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub
</Link>
</div>
</nav>
)
}

View File

@@ -0,0 +1,38 @@
import type { Meeting, Session, RaceHub } from '../types'
import { formatDate } from '../utils'
interface Props {
meeting?: Meeting
session?: Session
source: RaceHub['source']
}
function SourceBadge({ source }: { source: RaceHub['source'] }) {
if (source === 'local') return <span className="badge badge-local">Local</span>
if (source === 'partial') return <span className="badge badge-partial">Partial</span>
return <span className="badge badge-none">No data</span>
}
export function RaceHubHeader({ meeting, session, source }: Props) {
const meetingName = meeting?.meeting_name ?? 'Unknown Meeting'
const sessionName = session?.session_name ?? 'Unknown Session'
const dateStr = formatDate(session?.date_start ?? meeting?.date_start)
const location = meeting ? `${meeting.location} · ${meeting.country_name}` : null
return (
<div className="rh-header">
<div className="rh-title-group">
<div className="rh-meeting">{meetingName}</div>
<div className="rh-session">{sessionName}</div>
<div className="rh-meta">
{dateStr && <span className="rh-meta-item">{dateStr}</span>}
{location && <span className="rh-meta-item" style={{ opacity: 0.6 }}>·</span>}
{location && <span className="rh-meta-item">{location}</span>}
</div>
</div>
<div style={{ flexShrink: 0, paddingTop: 2 }}>
<SourceBadge source={source} />
</div>
</div>
)
}

View File

@@ -0,0 +1,57 @@
import type { EnrichedGrid } from '../types'
import { DriverCell } from './DriverCell'
import { formatLapTime } from '../utils'
interface Props {
grid: EnrichedGrid[]
}
export function StartingGridTable({ grid }: Props) {
if (grid.length === 0) {
return (
<div className="missing-notice">
Starting grid not ingested. Run{' '}
<code>box-box --ingest-session &lt;key&gt;</code> to load this dataset.
</div>
)
}
return (
<div className="scroll-x">
<table className="data-table" style={{ minWidth: 380 }}>
<thead>
<tr>
<th className="c" style={{ width: 28 }}>P</th>
<th>Driver</th>
<th className="hide-mobile">Team</th>
<th className="r">Lap Time</th>
</tr>
</thead>
<tbody>
{grid.map((g) => (
<tr key={g.driver_number}>
<td className="c">
<span style={{ fontFamily: 'var(--f-mono)', color: 'var(--text-2)' }}>
{g.position}
</span>
</td>
<td>
<DriverCell
acronym={g.name_acronym || String(g.driver_number)}
number={g.driver_number}
colour={g.team_colour}
/>
</td>
<td className="hide-mobile" style={{ color: 'var(--text-2)', fontSize: 11 }}>
{g.team_name}
</td>
<td className="r" style={{ fontFamily: 'var(--f-mono)' }}>
{g.lap_duration != null ? formatLapTime(g.lap_duration) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}