mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Rework Race Hub as weekend workspace
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, 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 {
|
||||
fetchLocalMeetings,
|
||||
fetchRaceHub,
|
||||
fetchSeasons,
|
||||
fetchWeekend,
|
||||
} from '../api'
|
||||
import { DatasetStrip } from '../components/DatasetStrip'
|
||||
import { ClassificationTable } from '../components/ClassificationTable'
|
||||
import { StartingGridTable } from '../components/StartingGridTable'
|
||||
@@ -14,123 +17,339 @@ import { PositionEvolutionView } from '../components/PositionEvolutionView'
|
||||
import { LapsView } from '../components/LapsView'
|
||||
import { RaceControlView } from '../components/RaceControlView'
|
||||
import { WeatherView } from '../components/WeatherView'
|
||||
import { OverviewView } from '../components/OverviewView'
|
||||
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
||||
import { SourceBadge } from '../components/SourceBadge'
|
||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
||||
import {
|
||||
formatSessionScheduleTime,
|
||||
pickFocusMeeting,
|
||||
sortSessionsByStart,
|
||||
} from '../lib/schedule'
|
||||
import type { Weekend, WeekendSession } from '../types'
|
||||
|
||||
interface Props {
|
||||
sessionKey: number
|
||||
}
|
||||
|
||||
type RaceStorySubview = 'classification' | 'grid' | 'positions'
|
||||
|
||||
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
|
||||
if (!weekend) return undefined
|
||||
const local = weekend.sessions.filter((s) => s.source === 'local')
|
||||
const partial = weekend.sessions.filter((s) => s.source === 'partial')
|
||||
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
|
||||
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
||||
if (race) return race
|
||||
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
|
||||
if (qual) return qual
|
||||
return pool[0]
|
||||
}
|
||||
|
||||
export function RaceHubPage({ sessionKey }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const [inputVal, setInputVal] = useState(sessionKey > 0 ? String(sessionKey) : '')
|
||||
const [activeTab, setActiveTab] = useState<Tab>('results')
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||
const [storyView, setStoryView] = useState<RaceStorySubview>('classification')
|
||||
const [switcherOpen, setSwitcherOpen] = useState(false)
|
||||
|
||||
// ─── Auto-redirect when no session_key is supplied ───
|
||||
const seasonsQuery = useQuery({
|
||||
queryKey: ['seasons'],
|
||||
queryFn: fetchSeasons,
|
||||
enabled: sessionKey === 0,
|
||||
})
|
||||
|
||||
const latestSeason = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const meetingsQuery = useQuery({
|
||||
queryKey: ['meetings', latestSeason],
|
||||
queryFn: () => fetchLocalMeetings(latestSeason!),
|
||||
enabled: sessionKey === 0 && latestSeason != null,
|
||||
})
|
||||
|
||||
const focusMeeting = useMemo(() => {
|
||||
if (sessionKey !== 0 || !meetingsQuery.data) return null
|
||||
return pickFocusMeeting(meetingsQuery.data, new Date())
|
||||
}, [sessionKey, meetingsQuery.data])
|
||||
|
||||
const fallbackWeekendQuery = useQuery({
|
||||
queryKey: ['weekend', focusMeeting?.meeting_key],
|
||||
queryFn: () => fetchWeekend(focusMeeting!.meeting_key),
|
||||
enabled: sessionKey === 0 && focusMeeting != null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setInputVal(sessionKey > 0 ? String(sessionKey) : '')
|
||||
}, [sessionKey])
|
||||
if (sessionKey !== 0) return
|
||||
const weekend = fallbackWeekendQuery.data
|
||||
if (!weekend) return
|
||||
const target = pickAnalysisSession(weekend)?.session.session_key
|
||||
?? weekend.default_session_key
|
||||
?? weekend.sessions[0]?.session.session_key
|
||||
if (target) {
|
||||
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
|
||||
}
|
||||
}, [sessionKey, fallbackWeekendQuery.data, navigate])
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
// ─── Active session payload ───
|
||||
const raceHubQuery = useQuery({
|
||||
queryKey: ['race-hub', sessionKey],
|
||||
queryFn: () => fetchRaceHub(sessionKey),
|
||||
enabled: sessionKey > 0,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
function handleLoad(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const key = parseInt(inputVal, 10)
|
||||
if (key > 0) {
|
||||
navigate({ to: '/race-hub', search: { session_key: key } })
|
||||
const meetingKey = raceHubQuery.data?.meeting?.meeting_key
|
||||
const weekendQuery = useQuery({
|
||||
queryKey: ['weekend', meetingKey],
|
||||
queryFn: () => fetchWeekend(meetingKey!),
|
||||
enabled: meetingKey != null && meetingKey > 0,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const data = raceHubQuery.data
|
||||
const weekend = weekendQuery.data
|
||||
const accent = countryAccent(data?.meeting ?? null)
|
||||
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||
|
||||
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
|
||||
if (sessionKey === 0) {
|
||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="loading-state">resolving latest local weekend…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const seasons = seasonsQuery.data ?? []
|
||||
if (seasons.length === 0) {
|
||||
return (
|
||||
<div className="rh-page rh-empty" data-testid="race-hub-empty" style={accentStyle}>
|
||||
<div className="rh-empty-band">
|
||||
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
||||
<h1 className="rh-empty-title">No local sessions yet</h1>
|
||||
<p className="rh-empty-sub">
|
||||
The Race Hub reads from local ingest only. Once a weekend is ingested
|
||||
it will open here automatically.
|
||||
</p>
|
||||
<div className="rh-empty-actions">
|
||||
<a href="/admin" className="rh-empty-action">Open Admin · Data Health</a>
|
||||
<a href="/" className="rh-empty-action">Back to Command Center</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="loading-state">resolving latest local weekend…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{/* Session key input */}
|
||||
<form className="session-bar" onSubmit={handleLoad}>
|
||||
<label htmlFor="sk-input">Session Key</label>
|
||||
<input
|
||||
id="sk-input"
|
||||
type="number"
|
||||
placeholder="e.g. 9472"
|
||||
value={inputVal}
|
||||
onChange={(e) => setInputVal(e.target.value)}
|
||||
/>
|
||||
<button type="submit">Load</button>
|
||||
{sessionKey > 0 && (
|
||||
<span style={{ fontFamily: 'var(--f-mono)', fontSize: 10, color: 'var(--text-3)' }}>
|
||||
key {sessionKey}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Local data browser when no session loaded */}
|
||||
{sessionKey === 0 && <LocalDataNavigator />}
|
||||
|
||||
{/* Loading */}
|
||||
{sessionKey > 0 && isLoading && (
|
||||
// ─── Loading / error for the requested session_key ───
|
||||
if (raceHubQuery.isLoading) {
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="loading-state">loading session {sessionKey}…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (raceHubQuery.isError || !data) {
|
||||
return (
|
||||
<div className="rh-page" style={accentStyle}>
|
||||
<div className="error-box">
|
||||
{raceHubQuery.error instanceof Error
|
||||
? raceHubQuery.error.message
|
||||
: `Failed to load session ${sessionKey}.`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const decal = countryDecal(data.meeting ?? null)
|
||||
const sessions = weekend ? sortSessionsByStart(weekend.sessions.map((w) => w.session)) : []
|
||||
const sessionMeta = weekend
|
||||
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
||||
: {}
|
||||
const activeSessionMeta = sessionMeta[sessionKey]
|
||||
|
||||
return (
|
||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||
{/* Topbar */}
|
||||
<div className="rh-topbar">
|
||||
<span className="rh-topbar-label mono">
|
||||
box-box · race hub
|
||||
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
|
||||
</span>
|
||||
<span className="rh-topbar-spacer" />
|
||||
<SourceBadge source={data.source} />
|
||||
<button
|
||||
type="button"
|
||||
className={`rh-switcher-toggle${switcherOpen ? ' active' : ''}`}
|
||||
onClick={() => setSwitcherOpen((v) => !v)}
|
||||
aria-expanded={switcherOpen}
|
||||
data-testid="rh-switch-weekend"
|
||||
>
|
||||
{switcherOpen ? 'Close' : 'Switch Weekend'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{switcherOpen && (
|
||||
<WeekendSwitcher
|
||||
currentMeetingKey={meetingKey}
|
||||
currentSessionKey={sessionKey}
|
||||
onClose={() => setSwitcherOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && (
|
||||
<div className="error-box">
|
||||
{error instanceof Error ? error.message : 'Failed to load race hub data'}
|
||||
{/* GP Identity band */}
|
||||
{data.meeting && (
|
||||
<section className="rh-identity" data-testid="rh-identity">
|
||||
<div className="rh-identity-accent" aria-hidden="true" />
|
||||
<div className="rh-identity-body">
|
||||
<span className="rh-identity-decal mono">{decal}</span>
|
||||
<div className="rh-identity-titles">
|
||||
<h1 className="rh-identity-name">{data.meeting.meeting_name}</h1>
|
||||
<div className="rh-identity-sub mono">
|
||||
{[data.meeting.location, data.meeting.circuit_short_name]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</div>
|
||||
<div className="rh-identity-sub mono rh-identity-dates">
|
||||
{formatGpDateRange(data.meeting)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Session rail */}
|
||||
{sessions.length > 0 && (
|
||||
<nav className="rh-session-rail" aria-label="Weekend sessions" data-testid="rh-session-rail">
|
||||
{sessions.map((session) => {
|
||||
const meta = sessionMeta[session.session_key]
|
||||
const active = session.session_key === sessionKey
|
||||
return (
|
||||
<button
|
||||
key={session.session_key}
|
||||
type="button"
|
||||
className={`rh-session-chip${active ? ' active' : ''}`}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: '/race-hub',
|
||||
search: { session_key: session.session_key },
|
||||
})
|
||||
}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
data-testid={`rh-session-${session.session_key}`}
|
||||
>
|
||||
<span className="rh-session-abbrev mono">
|
||||
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
||||
</span>
|
||||
<span className="rh-session-name">{session.session_name}</span>
|
||||
<span className="rh-session-time mono">
|
||||
{formatSessionScheduleTime(session.date_start)}
|
||||
</span>
|
||||
{meta && (
|
||||
<span className="rh-session-cov mono">
|
||||
<span
|
||||
className={`cc-cov-dot cc-cov-${meta.source}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{formatCoverageHint(meta.datasets)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Active session sub-bar */}
|
||||
{data.session && (
|
||||
<div className="rh-active-bar" data-testid="rh-active-bar">
|
||||
<span className="rh-active-name">{data.session.session_name}</span>
|
||||
<span className="rh-active-meta mono">
|
||||
{formatSessionScheduleTime(data.session.date_start)}
|
||||
</span>
|
||||
{activeSessionMeta && (
|
||||
<span className="rh-active-cov mono">
|
||||
<span
|
||||
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
|
||||
</span>
|
||||
)}
|
||||
<span className="rh-active-key mono">key {sessionKey}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data */}
|
||||
{data && (
|
||||
<>
|
||||
<RaceHubHeader
|
||||
meeting={data.meeting}
|
||||
session={data.session}
|
||||
source={data.source}
|
||||
/>
|
||||
<DatasetStrip datasets={data.datasets} />
|
||||
|
||||
<DatasetStrip datasets={data.datasets} />
|
||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||
{activeTab === 'overview' && <OverviewView data={data} />}
|
||||
|
||||
{activeTab === 'results' && (
|
||||
<div className="data-section">
|
||||
{activeTab === 'race_story' && (
|
||||
<div className="data-section">
|
||||
<div className="rh-story-controls" role="tablist" aria-label="Race story view">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={storyView === 'classification'}
|
||||
className={`rh-story-btn${storyView === 'classification' ? ' active' : ''}`}
|
||||
onClick={() => setStoryView('classification')}
|
||||
>
|
||||
Classification
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={storyView === 'grid'}
|
||||
className={`rh-story-btn${storyView === 'grid' ? ' active' : ''}`}
|
||||
onClick={() => setStoryView('grid')}
|
||||
>
|
||||
Starting Grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={storyView === 'positions'}
|
||||
className={`rh-story-btn${storyView === 'positions' ? ' active' : ''}`}
|
||||
onClick={() => setStoryView('positions')}
|
||||
>
|
||||
Positions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{storyView === 'classification' && (
|
||||
<>
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Final Classification</span>
|
||||
{data.results.length > 0 && (
|
||||
<span className="sec-meta">{data.results.length} drivers</span>
|
||||
<span className="sec-meta mono">{data.results.length} drivers</span>
|
||||
)}
|
||||
</div>
|
||||
<ClassificationTable results={data.results} grid={data.starting_grid} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'grid' && (
|
||||
<div className="data-section">
|
||||
{storyView === 'grid' && (
|
||||
<>
|
||||
<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>
|
||||
<span className="sec-meta mono">{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}
|
||||
stints={data.stints}
|
||||
pit_stops={data.pit_stops}
|
||||
hasStints={data.datasets['stints']?.status === 'available'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'positions' && (
|
||||
<div className="data-section">
|
||||
{storyView === 'positions' && (
|
||||
<>
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Position Evolution</span>
|
||||
</div>
|
||||
@@ -141,54 +360,68 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
laps={data.laps}
|
||||
hasPositions={data.datasets['positions']?.status === 'available'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'laps' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Laps</span>
|
||||
{data.laps.length > 0 && (
|
||||
<span className="sec-meta">{data.laps.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<LapsView laps={data.laps} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'strategy' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Race Strategy</span>
|
||||
</div>
|
||||
<StrategyView
|
||||
results={data.results}
|
||||
stints={data.stints}
|
||||
pit_stops={data.pit_stops}
|
||||
hasStints={data.datasets['stints']?.status === 'available'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'race_control' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Race Control</span>
|
||||
{data.race_control.length > 0 && (
|
||||
<span className="sec-meta">{data.race_control.length} messages</span>
|
||||
)}
|
||||
</div>
|
||||
<RaceControlView messages={data.race_control} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'lap_data' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Lap Data</span>
|
||||
{data.laps.length > 0 && (
|
||||
<span className="sec-meta mono">{data.laps.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<LapsView laps={data.laps} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'weather' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Weather</span>
|
||||
{data.weather.length > 0 && (
|
||||
<span className="sec-meta">{data.weather.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<WeatherView weather={data.weather} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'conditions' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Conditions</span>
|
||||
{data.weather.length > 0 && (
|
||||
<span className="sec-meta mono">{data.weather.length} samples</span>
|
||||
)}
|
||||
</div>
|
||||
<WeatherView weather={data.weather} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'datasets' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Dataset Status</span>
|
||||
</div>
|
||||
<DatasetStatusView datasets={data.datasets} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{activeTab === 'race_control' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Race Control</span>
|
||||
{data.race_control.length > 0 && (
|
||||
<span className="sec-meta mono">{data.race_control.length} messages</span>
|
||||
)}
|
||||
</div>
|
||||
<RaceControlView messages={data.race_control} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'data_status' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Data Status</span>
|
||||
</div>
|
||||
<DatasetStatusView datasets={data.datasets} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user