mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Complete React live and Race Hub views
This commit is contained in:
@@ -109,8 +109,8 @@ export function DataLibraryPage() {
|
||||
</div>
|
||||
<CliCommands
|
||||
commands={[
|
||||
{ comment: '# Ingest a full season', cmd: 'box-box --ingest-year 2025' },
|
||||
{ comment: '# Or a single session', cmd: 'box-box --ingest-session <session_key>' },
|
||||
{ comment: '# Discover season meetings and sessions', cmd: 'box-box --ingest-year 2025' },
|
||||
{ comment: '# Then ingest a full weekend or single session', cmd: 'box-box --ingest-meeting <meeting_key>' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -206,7 +206,7 @@ export function DataLibraryPage() {
|
||||
|
||||
{!meetingsQuery.isLoading && !meetingsQuery.isError && meetings.length === 0 && (
|
||||
<div className="missing-notice">
|
||||
No meetings ingested for {selectedYear}. Run{' '}
|
||||
No meetings discovered for {selectedYear}. Run{' '}
|
||||
<code>box-box --ingest-year {selectedYear}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
106
frontend/src/pages/LiveTimingPage.tsx
Normal file
106
frontend/src/pages/LiveTimingPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchLiveState } from '../api'
|
||||
import type { LiveStreamData } from '../types'
|
||||
import { parseLiveStateEvent } from '../lib/live'
|
||||
import { SessionBanner } from '../components/live/SessionBanner'
|
||||
import { TimingTower } from '../components/live/TimingTower'
|
||||
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
||||
|
||||
type StreamStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
|
||||
export function LiveTimingPage() {
|
||||
const [snapshot, setSnapshot] = useState<LiveStreamData | null>(null)
|
||||
const [isLive, setIsLive] = useState(false)
|
||||
const [streamStatus, setStreamStatus] = useState<StreamStatus>('connecting')
|
||||
const [now, setNow] = useState(Date.now())
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['live-state'],
|
||||
queryFn: fetchLiveState,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
setIsLive(data.is_live)
|
||||
setSnapshot(data.data)
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!('EventSource' in window)) {
|
||||
setStreamStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const events = new EventSource('/api/v1/live/stream')
|
||||
setStreamStatus('connecting')
|
||||
|
||||
events.onopen = () => {
|
||||
if (!cancelled) setStreamStatus('connected')
|
||||
}
|
||||
|
||||
events.addEventListener('snapshot', (event) => {
|
||||
const state = parseLiveStateEvent(event.data)
|
||||
if (!state || cancelled) return
|
||||
setIsLive(state.is_live)
|
||||
setSnapshot(state.data)
|
||||
setStreamStatus('connected')
|
||||
})
|
||||
|
||||
events.addEventListener('heartbeat', () => {
|
||||
if (!cancelled) setStreamStatus('connected')
|
||||
})
|
||||
|
||||
events.onerror = () => {
|
||||
if (!cancelled) setStreamStatus('disconnected')
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
events.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="page live-page">
|
||||
{isError && (
|
||||
<div className="error-box">
|
||||
{error instanceof Error ? error.message : 'Failed to load live timing state'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streamStatus === 'disconnected' && (
|
||||
<div className="missing-notice">Live stream disconnected. Showing the last received snapshot.</div>
|
||||
)}
|
||||
|
||||
{isLoading && !snapshot && <div className="loading-state">connecting to live timing…</div>}
|
||||
|
||||
{!isLoading && !snapshot && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-title">No live session active</div>
|
||||
<div className="empty-state-desc">Check back during an F1 race weekend.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot && (
|
||||
<>
|
||||
<SessionBanner isLive={isLive} snapshot={snapshot} connection={streamStatus} now={now} />
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
<span className="sec-title">Timing Tower</span>
|
||||
</div>
|
||||
<TimingTower snapshot={snapshot} />
|
||||
</div>
|
||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import { TabBar, type Tab } from '../components/TabBar'
|
||||
import { DatasetStatusView } from '../components/DatasetStatusView'
|
||||
import { StrategyView } from '../components/StrategyView'
|
||||
import { PositionEvolutionView } from '../components/PositionEvolutionView'
|
||||
import { LapsView } from '../components/LapsView'
|
||||
import { RaceControlView } from '../components/RaceControlView'
|
||||
import { WeatherView } from '../components/WeatherView'
|
||||
|
||||
interface Props {
|
||||
sessionKey: number
|
||||
@@ -141,6 +144,42 @@ export function RaceHubPage({ sessionKey }: Props) {
|
||||
</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 === '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 === '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 === 'datasets' && (
|
||||
<div className="data-section">
|
||||
<div className="sec-header">
|
||||
|
||||
Reference in New Issue
Block a user