2026-05-25 10:29:31 -04:00
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
|
import { useQueries, useQuery } from '@tanstack/react-query'
|
|
|
|
|
import { Link } from '@tanstack/react-router'
|
2026-07-03 23:59:38 -04:00
|
|
|
import {
|
|
|
|
|
fetchChampionshipHub,
|
|
|
|
|
fetchLiveState,
|
|
|
|
|
fetchLocalMeetings,
|
|
|
|
|
fetchRaceHub,
|
|
|
|
|
fetchSeasonMeetings,
|
|
|
|
|
fetchSeasons,
|
|
|
|
|
fetchSessions,
|
|
|
|
|
fetchWeekend,
|
|
|
|
|
} from '../api'
|
|
|
|
|
import { CommandCenterHero } from '../components/CommandCenterHero'
|
|
|
|
|
import { PaddockBriefing } from '../components/PaddockBriefing'
|
2026-06-21 19:53:42 -07:00
|
|
|
import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
2026-07-03 23:59:38 -04:00
|
|
|
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
|
|
|
|
import { classifySessionStatus, heroState } from '../lib/hero'
|
2026-05-25 10:29:31 -04:00
|
|
|
import {
|
|
|
|
|
currentAndNextSession,
|
|
|
|
|
focusMeetingKind,
|
|
|
|
|
formatSessionScheduleTime,
|
|
|
|
|
meetingHasStarted,
|
2026-07-03 23:59:38 -04:00
|
|
|
mostRecentPastMeeting,
|
|
|
|
|
nextUpcomingMeeting,
|
2026-05-25 10:29:31 -04:00
|
|
|
pickFocusMeeting,
|
|
|
|
|
sortSessionsByStart,
|
|
|
|
|
} from '../lib/schedule'
|
2026-05-25 11:51:16 -04:00
|
|
|
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
|
2026-07-03 23:59:38 -04:00
|
|
|
import { Trophy } from 'lucide-react'
|
2026-05-25 10:29:31 -04:00
|
|
|
|
2026-06-21 19:53:42 -07:00
|
|
|
const missingDatasets = Object.fromEntries(
|
|
|
|
|
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
|
|
|
|
|
) as WeekendSession['datasets']
|
|
|
|
|
|
2026-05-25 18:14:55 -04:00
|
|
|
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
|
|
|
|
|
if (meeting.meeting_key === focusKey) return 'focus'
|
|
|
|
|
if (meetingHasStarted(meeting, now)) return 'past'
|
|
|
|
|
return 'future'
|
2026-05-25 10:29:31 -04:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 11:51:16 -04:00
|
|
|
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]
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 10:29:31 -04:00
|
|
|
export function CommandCenterPage() {
|
|
|
|
|
const [now, setNow] = useState(() => Date.now())
|
|
|
|
|
|
|
|
|
|
const seasonsQuery = useQuery({
|
|
|
|
|
queryKey: ['seasons'],
|
|
|
|
|
queryFn: fetchSeasons,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const latestSeason = seasonsQuery.data?.[0] ?? null
|
|
|
|
|
|
|
|
|
|
const meetingsQuery = useQuery({
|
|
|
|
|
queryKey: ['meetings', latestSeason],
|
|
|
|
|
queryFn: () => fetchLocalMeetings(latestSeason!),
|
|
|
|
|
enabled: latestSeason != null,
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-25 18:14:55 -04:00
|
|
|
const seasonMeetingsQuery = useQuery({
|
|
|
|
|
queryKey: ['season-meetings', latestSeason],
|
|
|
|
|
queryFn: () => fetchSeasonMeetings(latestSeason!),
|
|
|
|
|
enabled: latestSeason != null,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const localMeetings = meetingsQuery.data ?? []
|
|
|
|
|
const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
|
2026-06-21 19:53:42 -07:00
|
|
|
const focusMeetings = seasonMeetings.length > 0 ? seasonMeetings : localMeetings
|
2026-05-25 10:29:31 -04:00
|
|
|
|
|
|
|
|
const weekendQueries = useQueries({
|
2026-06-21 19:53:42 -07:00
|
|
|
queries: localMeetings.map((meeting) => ({
|
2026-05-25 10:29:31 -04:00
|
|
|
queryKey: ['weekend', meeting.meeting_key],
|
|
|
|
|
queryFn: () => fetchWeekend(meeting.meeting_key),
|
2026-06-21 19:53:42 -07:00
|
|
|
enabled: localMeetings.length > 0,
|
2026-05-25 10:29:31 -04:00
|
|
|
staleTime: 60_000,
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const liveQuery = useQuery({
|
|
|
|
|
queryKey: ['live-state'],
|
|
|
|
|
queryFn: fetchLiveState,
|
|
|
|
|
staleTime: 5_000,
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-03 02:25:20 -04:00
|
|
|
const championshipQuery = useQuery({
|
|
|
|
|
queryKey: ['championship-hub', latestSeason],
|
|
|
|
|
queryFn: () => fetchChampionshipHub(latestSeason!),
|
|
|
|
|
enabled: latestSeason != null,
|
|
|
|
|
})
|
|
|
|
|
const champHub = championshipQuery.data
|
|
|
|
|
|
2026-05-25 10:29:31 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
|
|
|
|
return () => window.clearInterval(timer)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const nowDate = useMemo(() => new Date(now), [now])
|
|
|
|
|
|
|
|
|
|
const weekendsByKey = useMemo(() => {
|
|
|
|
|
const map = new Map<number, Weekend>()
|
2026-06-21 19:53:42 -07:00
|
|
|
localMeetings.forEach((meeting, i) => {
|
2026-05-25 10:29:31 -04:00
|
|
|
const data = weekendQueries[i]?.data
|
|
|
|
|
if (data) map.set(meeting.meeting_key, data)
|
|
|
|
|
})
|
|
|
|
|
return map
|
2026-06-21 19:53:42 -07:00
|
|
|
}, [localMeetings, weekendQueries])
|
2026-05-25 10:29:31 -04:00
|
|
|
|
|
|
|
|
const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
|
|
|
|
|
const meetingStats = countWeekendStats(weekendList)
|
2026-06-21 19:53:42 -07:00
|
|
|
const focusMeeting = pickFocusMeeting(focusMeetings, nowDate)
|
2026-05-25 10:29:31 -04:00
|
|
|
const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
|
2026-06-21 19:53:42 -07:00
|
|
|
const openF1SessionsQuery = useQuery({
|
|
|
|
|
queryKey: ['sessions', focusMeeting?.meeting_key, 'openf1'],
|
|
|
|
|
queryFn: () => fetchSessions(focusMeeting!.meeting_key, 'openf1'),
|
|
|
|
|
enabled: focusMeeting != null && focusWeekend == null,
|
|
|
|
|
staleTime: 60_000,
|
|
|
|
|
})
|
|
|
|
|
const openF1WeekendSessions: WeekendSession[] = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
(openF1SessionsQuery.data ?? []).map((session) => ({
|
|
|
|
|
session,
|
|
|
|
|
source: 'none',
|
|
|
|
|
datasets: missingDatasets,
|
|
|
|
|
})),
|
|
|
|
|
[openF1SessionsQuery.data],
|
|
|
|
|
)
|
|
|
|
|
const focusWeekendSessions = focusWeekend?.sessions ?? openF1WeekendSessions
|
2026-05-25 10:29:31 -04:00
|
|
|
const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
|
2026-06-21 19:53:42 -07:00
|
|
|
const focusSessions: Session[] = sortSessionsByStart(focusWeekendSessions.map((s) => s.session))
|
2026-05-25 10:29:31 -04:00
|
|
|
const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate)
|
2026-05-25 11:51:16 -04:00
|
|
|
|
|
|
|
|
const analysisSession = pickAnalysisSession(focusWeekend)
|
2026-06-21 19:53:42 -07:00
|
|
|
const actionSession =
|
|
|
|
|
analysisSession?.session ??
|
|
|
|
|
currentSession ??
|
|
|
|
|
nextSession ??
|
|
|
|
|
focusWeekendSessions.find((s) => s.session.session_key === focusWeekend?.default_session_key)?.session ??
|
|
|
|
|
focusWeekendSessions[0]?.session
|
|
|
|
|
const analysisSessionKey = actionSession?.session_key
|
2026-05-25 11:51:16 -04:00
|
|
|
|
2026-07-03 23:59:38 -04:00
|
|
|
const liveActive = liveQuery.data?.is_live === true
|
|
|
|
|
const heroStateKind = heroState({
|
|
|
|
|
now: nowDate,
|
|
|
|
|
liveActive,
|
|
|
|
|
currentSession,
|
|
|
|
|
focusKind,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const lastPastMeeting = useMemo(
|
|
|
|
|
() => mostRecentPastMeeting(focusMeetings, nowDate),
|
|
|
|
|
[focusMeetings, nowDate],
|
|
|
|
|
)
|
|
|
|
|
const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined
|
|
|
|
|
const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend)
|
|
|
|
|
const lastRaceSessionKey = lastRaceAnalysis?.session.session_key
|
|
|
|
|
|
|
|
|
|
const lastRaceHubQuery = useQuery({
|
|
|
|
|
queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'],
|
|
|
|
|
queryFn: () => fetchRaceHub(lastRaceSessionKey!),
|
|
|
|
|
enabled: lastRaceSessionKey != null && heroStateKind === 'between',
|
|
|
|
|
staleTime: 60_000,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const nextMeetingForHero = useMemo(() => {
|
|
|
|
|
if (heroStateKind !== 'between') return null
|
|
|
|
|
return nextUpcomingMeeting(focusMeetings, nowDate)
|
|
|
|
|
}, [heroStateKind, focusMeetings, nowDate])
|
|
|
|
|
|
|
|
|
|
const lastRacePodium = lastRaceHubQuery.data?.results ?? []
|
|
|
|
|
const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? ''
|
2026-05-25 10:29:31 -04:00
|
|
|
|
|
|
|
|
if (seasonsQuery.isLoading) {
|
|
|
|
|
return <div className="page loading-state">loading command center…</div>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (seasonsQuery.isError) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="page error-box">
|
|
|
|
|
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const seasons = seasonsQuery.data ?? []
|
|
|
|
|
|
|
|
|
|
if (seasons.length === 0) {
|
|
|
|
|
return (
|
2026-05-25 11:51:16 -04:00
|
|
|
<div className="cc-page cc-empty" data-testid="command-center-empty">
|
|
|
|
|
<div className="cc-empty-band">
|
|
|
|
|
<span className="cc-empty-eyebrow mono">box-box · command center</span>
|
|
|
|
|
<h1 className="cc-empty-title">No local data yet</h1>
|
|
|
|
|
<p className="cc-empty-sub">
|
|
|
|
|
Ingest a race weekend from the CLI to populate this screen with live status, next-session
|
|
|
|
|
countdowns, and analysis links.
|
|
|
|
|
</p>
|
2026-05-25 10:29:31 -04:00
|
|
|
</div>
|
2026-05-25 11:51:16 -04:00
|
|
|
<div className="cc-empty-actions">
|
|
|
|
|
<Link to="/live" className="cc-action cc-action-live">
|
|
|
|
|
<span className="cc-action-label">Live Timing</span>
|
|
|
|
|
<span className="cc-action-meta">Standby</span>
|
|
|
|
|
</Link>
|
|
|
|
|
<Link to="/admin" className="cc-action">
|
|
|
|
|
<span className="cc-action-label">Admin · Data Health</span>
|
|
|
|
|
<span className="cc-action-meta">Ingestion guidance</span>
|
|
|
|
|
</Link>
|
2026-05-25 10:29:31 -04:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 11:51:16 -04:00
|
|
|
const accent = countryAccent(focusMeeting ?? null)
|
|
|
|
|
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
2026-05-25 10:29:31 -04:00
|
|
|
|
|
|
|
|
return (
|
2026-05-25 11:51:16 -04:00
|
|
|
<div className="cc-page" data-testid="command-center" style={accentStyle}>
|
|
|
|
|
<div className="cc-topbar">
|
|
|
|
|
<span className="cc-topbar-label mono">box-box · command center</span>
|
|
|
|
|
<span className="cc-topbar-meta mono">
|
|
|
|
|
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
|
|
|
|
|
</span>
|
|
|
|
|
<span className="cc-live-pill" data-testid="cc-live-status">
|
2026-05-25 10:29:31 -04:00
|
|
|
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
|
|
|
|
{liveActive ? 'Live session active' : 'No live session'}
|
2026-05-25 11:51:16 -04:00
|
|
|
</span>
|
2026-05-25 10:29:31 -04:00
|
|
|
</div>
|
|
|
|
|
|
2026-05-25 11:51:16 -04:00
|
|
|
{!focusMeeting && (
|
|
|
|
|
<div className="missing-notice">
|
|
|
|
|
No meetings ingested for {latestSeason}. Run{' '}
|
|
|
|
|
<code>box-box --ingest-year {latestSeason}</code>
|
2026-05-25 10:29:31 -04:00
|
|
|
</div>
|
2026-05-25 11:51:16 -04:00
|
|
|
)}
|
|
|
|
|
|
2026-07-03 23:59:38 -04:00
|
|
|
{focusMeeting && focusKind && (
|
|
|
|
|
<CommandCenterHero
|
|
|
|
|
state={heroStateKind}
|
|
|
|
|
now={nowDate}
|
|
|
|
|
accent={accent}
|
|
|
|
|
liveActive={liveActive}
|
|
|
|
|
liveData={liveQuery.data?.data}
|
|
|
|
|
focusMeeting={focusMeeting}
|
|
|
|
|
focusKind={focusKind}
|
|
|
|
|
sessions={focusWeekendSessions}
|
|
|
|
|
currentSession={currentSession}
|
|
|
|
|
nextSession={nextSession}
|
|
|
|
|
analysisSessionKey={analysisSessionKey}
|
|
|
|
|
analysisSessionName={actionSession?.session_name}
|
|
|
|
|
lastRaceName={lastRaceName}
|
|
|
|
|
lastRacePodium={lastRacePodium}
|
|
|
|
|
lastRaceSessionKey={lastRaceSessionKey}
|
|
|
|
|
nextMeeting={nextMeetingForHero}
|
|
|
|
|
/>
|
2026-05-25 11:51:16 -04:00
|
|
|
)}
|
2026-05-25 10:29:31 -04:00
|
|
|
|
2026-07-03 02:25:20 -04:00
|
|
|
<div className="cc-dashboard-grid">
|
|
|
|
|
<div className="cc-dashboard-main">
|
|
|
|
|
<div
|
|
|
|
|
className="cc-ambient-aura"
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
style={focusMeeting ? { '--aura-accent': countryAccent(focusMeeting) } as React.CSSProperties : undefined}
|
|
|
|
|
/>
|
|
|
|
|
<div className="cc-dashboard-panel">
|
|
|
|
|
{seasonMeetings.length > 0 && (
|
|
|
|
|
<section className="cc-season-calendar" data-testid="cc-season-calendar">
|
|
|
|
|
<div className="sec-header">
|
|
|
|
|
<span className="sec-title">Season Calendar</span>
|
|
|
|
|
<span className="sec-meta mono">
|
|
|
|
|
{seasonMeetings.length} rounds · {meetingStats.full}/{meetingStats.total || 0} local
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="cc-calendar-grid" role="list">
|
|
|
|
|
{seasonMeetings.map((meeting, index) => {
|
|
|
|
|
const weekend = weekendsByKey.get(meeting.meeting_key)
|
|
|
|
|
const target = pickAnalysisSession(weekend)
|
|
|
|
|
const targetKey = target?.session.session_key ?? weekend?.default_session_key
|
|
|
|
|
const status = meetingStatus(meeting, focusMeeting?.meeting_key, nowDate)
|
|
|
|
|
const cardAccent = countryAccent(meeting)
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
key={meeting.meeting_key}
|
|
|
|
|
to="/race-hub"
|
|
|
|
|
search={targetKey ? { session_key: targetKey } : {}}
|
|
|
|
|
className={`cc-calendar-card cc-calendar-${status}`}
|
|
|
|
|
data-testid={`cc-calendar-${meeting.meeting_key}`}
|
|
|
|
|
role="listitem"
|
|
|
|
|
style={{ '--gp-card-accent': cardAccent } as React.CSSProperties}
|
|
|
|
|
>
|
|
|
|
|
<div className="cc-calendar-accent" aria-hidden="true" />
|
|
|
|
|
<div className="cc-calendar-top mono">
|
|
|
|
|
<span className="cc-calendar-round">R{String(index + 1).padStart(2, '0')}</span>
|
|
|
|
|
<span className={`cc-cov-dot cc-cov-${weekend?.source ?? 'none'}`} aria-hidden="true" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="cc-calendar-id">
|
|
|
|
|
{countryFlag(meeting) && <span className="cc-calendar-flag">{countryFlag(meeting)}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="cc-calendar-decal mono">{countryDecal(meeting)}</span>
|
|
|
|
|
<div className="cc-calendar-copy">
|
|
|
|
|
<div className="cc-calendar-name">{meeting.meeting_name}</div>
|
|
|
|
|
<div className="cc-calendar-circuit mono">{meeting.circuit_short_name || meeting.location}</div>
|
|
|
|
|
<div className="cc-calendar-date mono">{formatGpDateRange(meeting)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
{seasonMeetingsQuery.isError && (
|
|
|
|
|
<div className="cc-side-empty">Using local meetings because the full calendar could not load.</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
2026-05-25 11:51:16 -04:00
|
|
|
</div>
|
2026-07-03 02:25:20 -04:00
|
|
|
</div>
|
2026-05-25 10:29:31 -04:00
|
|
|
|
2026-07-03 02:25:20 -04:00
|
|
|
<div className="cc-dashboard-sidebar">
|
|
|
|
|
{champHub && (
|
|
|
|
|
<section className="cc-champ-snapshot glass-panel" data-testid="cc-champ-snapshot" style={{ padding: 'var(--s5)', borderRadius: '12px' }}>
|
|
|
|
|
<div className="cc-champ-header">
|
|
|
|
|
<span>Championship Leaders</span>
|
|
|
|
|
<Link to="/championship" className="cc-champ-link">Full Standings →</Link>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="cc-champ-group-label">Drivers</div>
|
|
|
|
|
<div className="cc-champ-list">
|
|
|
|
|
{champHub.drivers.slice(0, 3).map((d) => (
|
|
|
|
|
<div key={d.driver_number} className="cc-champ-row">
|
|
|
|
|
<div className="cc-champ-left">
|
|
|
|
|
<div className="cc-champ-color" style={{ color: `#${d.team_colour}`, background: 'currentColor' }} />
|
|
|
|
|
<span className="cc-champ-name">{d.name_acronym}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="cc-champ-form" title="Last 5 races">
|
|
|
|
|
<FormSparkline form={d.form} color={`#${d.team_colour}`} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="cc-champ-right">
|
|
|
|
|
<span className="cc-champ-gap">
|
|
|
|
|
{d.position === 1 ? '—' : `-${champHub.drivers[0].points - d.points}`}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="cc-champ-pts">{d.points}</span>
|
|
|
|
|
</div>
|
2026-05-25 18:14:55 -04:00
|
|
|
</div>
|
2026-07-03 02:25:20 -04:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="cc-champ-group-label">Constructors</div>
|
|
|
|
|
<div className="cc-champ-list">
|
|
|
|
|
{champHub.teams.slice(0, 3).map((t, i) => (
|
|
|
|
|
<div key={t.team_name} className="cc-champ-row">
|
|
|
|
|
<div className="cc-champ-left">
|
|
|
|
|
<div className="cc-champ-color" style={{ color: `#${t.team_colour}`, background: 'currentColor' }} />
|
|
|
|
|
<span className="cc-champ-name">{t.team_name}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="cc-champ-form" style={{ opacity: 0.4, fontSize: '11px', display: 'flex', gap: '4px', alignItems: 'center' }} title={`${t.wins} Wins`}>
|
|
|
|
|
<Trophy size={12} /> {t.wins}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="cc-champ-right">
|
|
|
|
|
<span className="cc-champ-gap">
|
|
|
|
|
{i === 0 ? '—' : `-${champHub.teams[0].points - t.points}`}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="cc-champ-pts">{t.points}</span>
|
|
|
|
|
</div>
|
2026-05-25 18:14:55 -04:00
|
|
|
</div>
|
2026-07-03 02:25:20 -04:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{focusWeekendSessions.length > 0 && (
|
|
|
|
|
<section className="cc-schedule" data-testid="cc-schedule">
|
|
|
|
|
<div className="sec-header">
|
|
|
|
|
<span className="sec-title">Weekend Schedule</span>
|
|
|
|
|
<span className="sec-meta mono">{focusWeekendSessions.length} sessions</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="cc-session-strip" role="list">
|
|
|
|
|
{focusWeekendSessions.map(({ session, source, datasets }) => {
|
|
|
|
|
const status = classifySessionStatus(session, nowDate)
|
|
|
|
|
const isNext = nextSession?.session_key === session.session_key
|
|
|
|
|
const isCurrent = currentSession?.session_key === session.session_key
|
|
|
|
|
const isLive = isCurrent && liveActive
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
key={session.session_key}
|
|
|
|
|
to="/race-hub"
|
|
|
|
|
search={{ session_key: session.session_key }}
|
|
|
|
|
className={`cc-session-card ui-card interactive cc-status-${status}${isNext ? ' is-next' : ''}${
|
|
|
|
|
isLive ? ' is-live glass-panel' : ''
|
|
|
|
|
}`}
|
|
|
|
|
data-testid={`cc-session-${session.session_key}`}
|
|
|
|
|
role="listitem"
|
|
|
|
|
>
|
|
|
|
|
<div className="cc-session-card-head">
|
|
|
|
|
<span className="cc-session-abbrev mono">
|
|
|
|
|
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className={`cc-session-status cc-status-pill-${status}`}>
|
|
|
|
|
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="cc-session-name">{session.session_name}</div>
|
|
|
|
|
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
|
|
|
|
|
<div className="cc-session-cov mono">
|
|
|
|
|
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
|
|
|
|
{formatCoverageHint(datasets)}
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-05-25 11:51:16 -04:00
|
|
|
)}
|
2026-05-25 13:04:29 -04:00
|
|
|
|
2026-07-03 02:25:20 -04:00
|
|
|
<PaddockBriefing />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-25 10:29:31 -04:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-05-25 11:51:16 -04:00
|
|
|
|
2026-07-03 02:25:20 -04:00
|
|
|
function FormSparkline({ form, color }: { form: number[], color: string }) {
|
|
|
|
|
if (!form || form.length === 0) return null
|
|
|
|
|
const max = Math.max(...form, 26) // 26 is standard max for a race win
|
|
|
|
|
const width = 45
|
|
|
|
|
const height = 14
|
|
|
|
|
const step = width / Math.max(form.length - 1, 1)
|
|
|
|
|
|
|
|
|
|
const points = form.map((val, i) => {
|
|
|
|
|
const x = i * step
|
|
|
|
|
const y = height - (val / max) * height
|
|
|
|
|
return `${x},${y}`
|
|
|
|
|
}).join(' ')
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<svg width={width} height={height} style={{ overflow: 'visible' }}>
|
|
|
|
|
<polyline
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke={color}
|
|
|
|
|
strokeWidth="1.5"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
points={points}
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|