mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Add React race hub frontend
This commit is contained in:
98
frontend/src/components/ClassificationTable.tsx
Normal file
98
frontend/src/components/ClassificationTable.tsx
Normal 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 <key></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>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/DatasetStrip.tsx
Normal file
35
frontend/src/components/DatasetStrip.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
frontend/src/components/DriverCell.tsx
Normal file
17
frontend/src/components/DriverCell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/Nav.tsx
Normal file
16
frontend/src/components/Nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
frontend/src/components/RaceHubHeader.tsx
Normal file
38
frontend/src/components/RaceHubHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
frontend/src/components/StartingGridTable.tsx
Normal file
57
frontend/src/components/StartingGridTable.tsx
Normal 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 <key></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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user