mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
feat(#73): Adaptive Weekend shell and responsive navigation
Implemented by claude via .agents/dev dispatch.
This commit is contained in:
@@ -15,6 +15,7 @@ import type {
|
||||
Session,
|
||||
TrackOutline,
|
||||
Weekend,
|
||||
WeekendContext,
|
||||
} from './types'
|
||||
|
||||
export async function fetchRaceHub(sessionKey: number): Promise<RaceHub> {
|
||||
@@ -114,6 +115,24 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
|
||||
// (sibling backend story #72). It resolves to null when the endpoint is not yet
|
||||
// available (404 / server without the handler) so the Weekend page can fall back
|
||||
// to client-side derivation from existing endpoints. Any other HTTP error throws.
|
||||
export async function fetchWeekendContext(): Promise<WeekendContext | null> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/api/v1/weekend-context')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (res.status === 404) return null
|
||||
if (!res.ok) {
|
||||
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchChampionshipHub(year?: number): Promise<ChampionshipHub> {
|
||||
const params = new URLSearchParams({ source: 'auto' })
|
||||
if (year) params.set('year', year.toString())
|
||||
|
||||
@@ -1,36 +1,57 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Compass, FileText, Home, Trophy } from 'lucide-react'
|
||||
|
||||
const PRIMARY = [
|
||||
{ to: '/', label: 'Weekend', icon: Home, exact: true },
|
||||
{ to: '/championship', label: 'Championship', icon: Trophy, exact: false },
|
||||
{ to: '/briefing', label: 'Briefing', icon: FileText, exact: false },
|
||||
{ to: '/explore', label: 'Explore', icon: Compass, exact: false },
|
||||
] as const
|
||||
|
||||
export function Nav() {
|
||||
return (
|
||||
<nav className="app-nav">
|
||||
<Link to="/" className="nav-logo">
|
||||
box<em>-</em>box
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
||||
Command
|
||||
<>
|
||||
<nav className="app-nav" aria-label="Primary">
|
||||
<Link to="/" className="nav-logo">
|
||||
box<em>-</em>box
|
||||
</Link>
|
||||
<Link to="/live" activeProps={{ className: 'active' }}>
|
||||
Live
|
||||
</Link>
|
||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||
Race Hub
|
||||
</Link>
|
||||
<Link to="/preview" activeProps={{ className: 'active' }}>
|
||||
Preview
|
||||
</Link>
|
||||
<Link to="/championship" activeProps={{ className: 'active' }}>
|
||||
Championship
|
||||
</Link>
|
||||
<Link to="/briefing" activeProps={{ className: 'active' }}>
|
||||
Briefing
|
||||
</Link>
|
||||
</div>
|
||||
<div className="nav-utility">
|
||||
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}>
|
||||
Admin
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="nav-links">
|
||||
{PRIMARY.map(({ to, label, exact }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
activeProps={{ className: 'active' }}
|
||||
activeOptions={exact ? { exact: true } : undefined}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="nav-utility">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="nav-utility-link"
|
||||
activeProps={{ className: 'nav-utility-link active' }}
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<nav className="app-bottom-nav" aria-label="Primary mobile">
|
||||
{PRIMARY.map(({ to, label, icon: Icon, exact }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="bottom-nav-link"
|
||||
activeProps={{ className: 'bottom-nav-link active' }}
|
||||
activeOptions={exact ? { exact: true } : undefined}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
121
frontend/src/components/weekend/BetweenRacesView.tsx
Normal file
121
frontend/src/components/weekend/BetweenRacesView.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight, Flag as FlagIcon } from 'lucide-react'
|
||||
import type { WeekendContext } from '../../types'
|
||||
import {
|
||||
BriefingStrip,
|
||||
ChampionshipImpactCard,
|
||||
CountdownDisplay,
|
||||
EventPodium,
|
||||
Flag,
|
||||
SeasonNavStrip,
|
||||
} from './shared'
|
||||
import { parseScheduleTime } from '../../lib/schedule'
|
||||
|
||||
const EYEBROW: Record<string, string> = {
|
||||
between_races: 'Between races',
|
||||
post_weekend: 'Post-weekend',
|
||||
season_complete: 'Season complete',
|
||||
}
|
||||
|
||||
function formatSessionLine(name: string | undefined, start: string | undefined): string {
|
||||
if (!name) return 'Schedule to be confirmed'
|
||||
const date = start ? parseScheduleTime(start) : null
|
||||
if (!date) return name
|
||||
const when = date.toLocaleString('en-GB', {
|
||||
weekday: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
return `${name} · ${when}`
|
||||
}
|
||||
|
||||
export function BetweenRacesView({ context, now }: { context: WeekendContext; now: Date }) {
|
||||
const { last_event, next_event, championship_impact, season_rounds, briefing } = context
|
||||
const eyebrow = EYEBROW[context.state] ?? 'Between races'
|
||||
|
||||
return (
|
||||
<div className="wk-between" data-testid="weekend-between-races" data-state={context.state}>
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">{eyebrow}</div>
|
||||
|
||||
<div className="wk-top-grid">
|
||||
{last_event && (
|
||||
<section className="wk-event-card wk-event-last" data-testid="wk-last-event">
|
||||
<header className="wk-event-head">
|
||||
<div className="wk-event-id">
|
||||
<Flag code={last_event.country_code} flag={last_event.country_flag} />
|
||||
<h2 className="wk-event-name">{last_event.meeting_name}</h2>
|
||||
</div>
|
||||
<span className="wk-event-tag wk-tag-done mono">
|
||||
<FlagIcon size={13} aria-hidden="true" /> Completed
|
||||
</span>
|
||||
</header>
|
||||
<div className="wk-event-body">
|
||||
<EventPodium event={last_event} />
|
||||
<div className="wk-story-card">
|
||||
<span className="wk-story-label">What decided it?</span>
|
||||
{last_event.story && <p className="wk-story-text">{last_event.story}</p>}
|
||||
<Link
|
||||
to="/race-hub"
|
||||
search={{ session_key: last_event.analysis_session_key }}
|
||||
className="wk-cta wk-cta-primary"
|
||||
data-testid="wk-explore-race-story"
|
||||
>
|
||||
Explore Race Story <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{next_event ? (
|
||||
<section className="wk-event-card wk-event-next" data-testid="wk-next-event">
|
||||
<header className="wk-event-head">
|
||||
<div className="wk-event-id">
|
||||
<Flag code={next_event.country_code} flag={next_event.country_flag} />
|
||||
<h2 className="wk-event-name">{next_event.meeting_name}</h2>
|
||||
</div>
|
||||
<span className="wk-event-tag wk-tag-next mono">Next event</span>
|
||||
</header>
|
||||
<div className="wk-next-body">
|
||||
<CountdownDisplay target={next_event.next_session_start ?? next_event.date_start} now={now} />
|
||||
<div className="wk-next-session">
|
||||
<span className="wk-next-label mono">Next session</span>
|
||||
<span className="wk-next-value">
|
||||
{formatSessionLine(next_event.next_session_name, next_event.next_session_start)}
|
||||
</span>
|
||||
</div>
|
||||
<Link to="/preview" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-prepare">
|
||||
Prepare for {next_event.meeting_name.replace(/ Grand Prix$/i, '')}
|
||||
<ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="wk-event-card wk-event-next" data-testid="wk-season-complete-card">
|
||||
<header className="wk-event-head">
|
||||
<h2 className="wk-event-name">That's a wrap</h2>
|
||||
<span className="wk-event-tag wk-tag-next mono">Off-season</span>
|
||||
</header>
|
||||
<div className="wk-next-body">
|
||||
<p className="wk-season-complete-copy">
|
||||
The {context.season} calendar is complete. Explore the season's races or revisit the championship
|
||||
battle while the next schedule is confirmed.
|
||||
</p>
|
||||
<Link to="/explore" className="wk-cta wk-cta-primary wk-cta-wide">
|
||||
Explore the season <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="wk-mid-grid">
|
||||
{championship_impact && <ChampionshipImpactCard impact={championship_impact} />}
|
||||
{season_rounds && season_rounds.length > 0 && <SeasonNavStrip rounds={season_rounds} />}
|
||||
</div>
|
||||
|
||||
{briefing && briefing.length > 0 && <BriefingStrip items={briefing} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/weekend/BetweenSessionsView.tsx
Normal file
67
frontend/src/components/weekend/BetweenSessionsView.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import type { WeekendContext } from '../../types'
|
||||
import { BriefingStrip, CountdownDisplay, EventPodium, SessionTimeline } from './shared'
|
||||
import { parseScheduleTime } from '../../lib/schedule'
|
||||
|
||||
function nextSessionWhen(start: string | undefined): string {
|
||||
const date = start ? parseScheduleTime(start) : null
|
||||
if (!date) return 'Time to be confirmed'
|
||||
return date.toLocaleString('en-GB', {
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function BetweenSessionsView({ context, now }: { context: WeekendContext; now: Date }) {
|
||||
const settling = context.state === 'session_settling'
|
||||
const { active_meeting_name, active_circuit_short_name, sessions, last_session, next_session } = context
|
||||
|
||||
return (
|
||||
<div className="wk-sessions" data-testid="weekend-between-sessions" data-state={context.state}>
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">
|
||||
{settling ? 'Session settling' : 'Between sessions'}
|
||||
</div>
|
||||
|
||||
<header className="wk-sessions-head">
|
||||
<h1 className="wk-sessions-title">{active_meeting_name ?? 'Race weekend'}</h1>
|
||||
{active_circuit_short_name && <p className="wk-sessions-circuit mono">{active_circuit_short_name}</p>}
|
||||
</header>
|
||||
|
||||
{sessions && <SessionTimeline sessions={sessions} />}
|
||||
|
||||
{last_session && (
|
||||
<section className="wk-recap-card" data-testid="wk-last-session">
|
||||
<span className="wk-recap-eyebrow mono">
|
||||
{last_session.label} · {settling ? 'Settling' : 'Complete'}
|
||||
</span>
|
||||
<h2 className="wk-recap-title">What happened in {last_session.label}</h2>
|
||||
<EventPodium event={last_session} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{next_session && (
|
||||
<section className="wk-upnext-card" data-testid="wk-next-session">
|
||||
<span className="wk-upnext-eyebrow mono">Up next</span>
|
||||
<h2 className="wk-upnext-title">{next_session.session_name}</h2>
|
||||
<CountdownDisplay target={next_session.date_start} now={now} compact />
|
||||
<p className="wk-upnext-when mono">{nextSessionWhen(next_session.date_start)}</p>
|
||||
{last_session && (
|
||||
<Link
|
||||
to="/race-hub"
|
||||
search={{ session_key: last_session.analysis_session_key }}
|
||||
className="wk-cta wk-cta-primary wk-cta-wide"
|
||||
data-testid="wk-view-recap"
|
||||
>
|
||||
View {last_session.label} recap <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{context.briefing && context.briefing.length > 0 && <BriefingStrip items={context.briefing} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
frontend/src/components/weekend/LiveHandoffView.tsx
Normal file
42
frontend/src/components/weekend/LiveHandoffView.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight, Radio } from 'lucide-react'
|
||||
import type { WeekendContext } from '../../types'
|
||||
import { EventPodium, Flag, SessionTimeline } from './shared'
|
||||
|
||||
export function LiveHandoffView({ context }: { context: WeekendContext }) {
|
||||
const { active_meeting_name, active_circuit_short_name, sessions, last_session } = context
|
||||
const liveSession = sessions?.find((s) => s.status === 'live')
|
||||
|
||||
return (
|
||||
<div className="wk-live" data-testid="weekend-live" data-state={context.state}>
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Session live</div>
|
||||
|
||||
<section className="wk-live-card" data-testid="wk-live-card">
|
||||
<div className="wk-live-head">
|
||||
<div className="wk-event-id">
|
||||
<Flag code={context.last_event?.country_code} flag={context.last_event?.country_flag} />
|
||||
<h1 className="wk-sessions-title">{active_meeting_name ?? 'Live session'}</h1>
|
||||
</div>
|
||||
<span className="wk-live-pulse" aria-hidden="true" />
|
||||
</div>
|
||||
{active_circuit_short_name && <p className="wk-sessions-circuit mono">{active_circuit_short_name}</p>}
|
||||
<p className="wk-live-lead">
|
||||
{liveSession ? `${liveSession.session_name} is on track now.` : 'A session is running now.'}
|
||||
</p>
|
||||
<Link to="/live" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-watch-live">
|
||||
<Radio size={15} aria-hidden="true" /> Watch live timing <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{sessions && <SessionTimeline sessions={sessions} />}
|
||||
|
||||
{last_session && (
|
||||
<section className="wk-recap-card" data-testid="wk-last-session">
|
||||
<span className="wk-recap-eyebrow mono">{last_session.label} · Complete</span>
|
||||
<h2 className="wk-recap-title">What happened in {last_session.label}</h2>
|
||||
<EventPodium event={last_session} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/weekend/PreSessionView.tsx
Normal file
49
frontend/src/components/weekend/PreSessionView.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import type { WeekendContext } from '../../types'
|
||||
import { RacePreviewPage } from '../../pages/RacePreviewPage'
|
||||
import { CountdownDisplay, Flag, SeasonNavStrip, SessionTimeline } from './shared'
|
||||
|
||||
/**
|
||||
* PreSessionView folds the race preview surface into the Weekend home so there is
|
||||
* no separate Preview destination. It reuses the existing preview page content and
|
||||
* frames it with the next-session countdown and compact season navigation.
|
||||
*/
|
||||
export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) {
|
||||
const next = context.next_event
|
||||
const nextStart = context.next_session?.date_start ?? next?.next_session_start ?? next?.date_start
|
||||
|
||||
return (
|
||||
<div className="wk-pre" data-testid="weekend-pre-session" data-state={context.state}>
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
|
||||
|
||||
{next && (
|
||||
<header className="wk-pre-head" data-testid="wk-pre-head">
|
||||
<div className="wk-event-id">
|
||||
<Flag code={next.country_code} flag={next.country_flag} />
|
||||
<h1 className="wk-sessions-title">{next.meeting_name}</h1>
|
||||
</div>
|
||||
<div className="wk-pre-countdown">
|
||||
<span className="wk-next-label mono">
|
||||
{context.next_session?.session_name ?? next.next_session_name ?? 'Next session'}
|
||||
</span>
|
||||
<CountdownDisplay target={nextStart} now={now} />
|
||||
</div>
|
||||
<Link to="/live" className="wk-cta wk-cta-ghost" data-testid="wk-pre-live">
|
||||
Live timing <ChevronRight size={14} aria-hidden="true" />
|
||||
</Link>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{context.sessions && context.sessions.length > 0 && <SessionTimeline sessions={context.sessions} />}
|
||||
|
||||
<div className="wk-pre-preview" data-testid="wk-pre-preview">
|
||||
<RacePreviewPage />
|
||||
</div>
|
||||
|
||||
{context.season_rounds && context.season_rounds.length > 0 && (
|
||||
<SeasonNavStrip rounds={context.season_rounds} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
frontend/src/components/weekend/StatusViews.tsx
Normal file
36
frontend/src/components/weekend/StatusViews.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function WeekendLoading() {
|
||||
return (
|
||||
<div className="wk-status wk-status-loading" data-testid="weekend-loading" aria-busy="true">
|
||||
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||
<p className="wk-status-title">Reading the weekend…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WeekendError({ message }: { message?: string }) {
|
||||
return (
|
||||
<div className="wk-status wk-status-error" data-testid="weekend-error" role="alert">
|
||||
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||
<p className="wk-status-title">Weekend unavailable</p>
|
||||
<p className="wk-status-sub">{message ?? 'Something went wrong loading the weekend context.'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WeekendLimited({ message, season }: { message?: string; season: number }) {
|
||||
return (
|
||||
<div className="wk-status wk-status-limited" data-testid="weekend-limited">
|
||||
<span className="wk-status-eyebrow mono">box-box · weekend</span>
|
||||
<p className="wk-status-title">Nothing to show yet</p>
|
||||
<p className="wk-status-sub">
|
||||
{message ?? `No weekend data for the ${season || 'current'} season is available right now.`}
|
||||
</p>
|
||||
<div className="wk-status-actions">
|
||||
<Link to="/live" className="wk-cta wk-cta-ghost">Live timing</Link>
|
||||
<Link to="/explore" className="wk-cta wk-cta-ghost">Explore</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
240
frontend/src/components/weekend/shared.tsx
Normal file
240
frontend/src/components/weekend/shared.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { fetchRaceHub } from '../../api'
|
||||
import { countryFlag } from '../../lib/gpIdentity'
|
||||
import { parseScheduleTime } from '../../lib/schedule'
|
||||
import { podiumFromResults } from '../../lib/weekendContext'
|
||||
import { teamColor } from '../../utils'
|
||||
import type {
|
||||
WeekendChampionshipImpact,
|
||||
WeekendCompletedEvent,
|
||||
WeekendBriefingItem,
|
||||
WeekendPodiumEntry,
|
||||
WeekendSeasonRound,
|
||||
WeekendTimelineSession,
|
||||
} from '../../types'
|
||||
|
||||
export function Flag({ code, flag }: { code?: string; flag?: string }) {
|
||||
const glyph = countryFlag({ country_code: code ?? '', country_flag: flag ?? '' })
|
||||
if (!glyph) return null
|
||||
return <span className="wk-flag" aria-hidden="true">{glyph}</span>
|
||||
}
|
||||
|
||||
interface CountdownParts {
|
||||
days: number
|
||||
hours: number
|
||||
minutes: number
|
||||
seconds: number
|
||||
reached: boolean
|
||||
}
|
||||
|
||||
export function countdownParts(target: string | undefined, now: Date): CountdownParts | null {
|
||||
const date = target ? parseScheduleTime(target) : null
|
||||
if (!date) return null
|
||||
const diff = date.getTime() - now.getTime()
|
||||
const clamped = Math.max(0, diff)
|
||||
const totalSeconds = Math.floor(clamped / 1000)
|
||||
return {
|
||||
days: Math.floor(totalSeconds / 86400),
|
||||
hours: Math.floor((totalSeconds % 86400) / 3600),
|
||||
minutes: Math.floor((totalSeconds % 3600) / 60),
|
||||
seconds: totalSeconds % 60,
|
||||
reached: diff <= 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function CountdownDisplay({
|
||||
target,
|
||||
now,
|
||||
compact,
|
||||
}: {
|
||||
target: string | undefined
|
||||
now: Date
|
||||
compact?: boolean
|
||||
}) {
|
||||
const parts = countdownParts(target, now)
|
||||
if (!parts) return null
|
||||
if (parts.reached) {
|
||||
return <span className="wk-countdown-live" data-testid="wk-countdown">Starting now</span>
|
||||
}
|
||||
if (compact || parts.days === 0) {
|
||||
const hh = String(parts.days * 24 + parts.hours).padStart(2, '0')
|
||||
return (
|
||||
<span className="wk-countdown-clock mono" data-testid="wk-countdown">
|
||||
{hh}:{String(parts.minutes).padStart(2, '0')}:{String(parts.seconds).padStart(2, '0')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="wk-countdown" data-testid="wk-countdown">
|
||||
<span className="wk-count-unit"><b>{parts.days}</b>D</span>
|
||||
<span className="wk-count-unit"><b>{String(parts.hours).padStart(2, '0')}</b>H</span>
|
||||
<span className="wk-count-unit"><b>{String(parts.minutes).padStart(2, '0')}</b>M</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function EventPodium({ event }: { event: WeekendCompletedEvent }) {
|
||||
const needsFetch = event.podium.length === 0 && event.analysis_session_key > 0
|
||||
const query = useQuery({
|
||||
queryKey: ['race-hub', event.analysis_session_key, 'podium'],
|
||||
queryFn: () => fetchRaceHub(event.analysis_session_key),
|
||||
enabled: needsFetch,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const podium: WeekendPodiumEntry[] =
|
||||
event.podium.length > 0 ? event.podium : podiumFromResults(query.data?.results ?? [])
|
||||
|
||||
if (podium.length === 0) {
|
||||
return (
|
||||
<div className="wk-podium-empty" data-testid="wk-podium-empty">
|
||||
{query.isLoading ? 'Loading result…' : 'Result not available yet.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="wk-podium" data-testid="wk-podium">
|
||||
{podium.map((p) => (
|
||||
<li key={p.driver_number} className="wk-podium-row" style={{ ['--wk-team' as string]: teamColor(p.team_colour) }}>
|
||||
<span className="wk-podium-pos mono">{p.position}</span>
|
||||
<span className="wk-podium-code">{p.name_acronym}</span>
|
||||
<span className="wk-podium-gap mono">{p.gap}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChampionshipImpactCard({ impact }: { impact: WeekendChampionshipImpact }) {
|
||||
const leader = impact.leaders[0]
|
||||
return (
|
||||
<section className="wk-champ" data-testid="wk-champ-impact">
|
||||
<header className="wk-card-head">
|
||||
<span className="wk-card-title">Championship impact</span>
|
||||
<Link to="/championship" className="wk-card-link">Standings →</Link>
|
||||
</header>
|
||||
<ol className="wk-champ-list">
|
||||
{impact.leaders.map((d) => {
|
||||
const gap = leader && d.position !== 1 ? leader.points - d.points : 0
|
||||
return (
|
||||
<li key={d.driver_number} className="wk-champ-row" style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}>
|
||||
<span className="wk-champ-pos mono">{d.position}</span>
|
||||
<span className="wk-champ-code">{d.name_acronym}</span>
|
||||
<span className="wk-champ-pts mono">{d.points}</span>
|
||||
<span className="wk-champ-gap mono">{d.position === 1 ? '—' : `-${gap}`}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
{impact.note && <p className="wk-champ-note">{impact.note}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function BriefingStrip({ items }: { items: WeekendBriefingItem[] }) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<section className="wk-briefing" data-testid="wk-briefing">
|
||||
<header className="wk-card-head">
|
||||
<span className="wk-card-title">Since the chequered flag</span>
|
||||
<Link to="/briefing" className="wk-card-link">All briefing →</Link>
|
||||
</header>
|
||||
<ul className="wk-briefing-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.url} className="wk-briefing-item">
|
||||
<a href={item.url} target="_blank" rel="noreferrer" className="wk-briefing-link">
|
||||
{item.category && <span className="wk-briefing-cat mono">{item.category}</span>}
|
||||
<span className="wk-briefing-title">{item.title}</span>
|
||||
<span className="wk-briefing-source mono">{item.source}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function SeasonNavStrip({ rounds }: { rounds: WeekendSeasonRound[] }) {
|
||||
if (rounds.length === 0) return null
|
||||
return (
|
||||
<section className="wk-season-nav" data-testid="wk-season-nav" aria-label="Season calendar">
|
||||
<ol className="wk-season-strip" role="list">
|
||||
{rounds.map((round) => {
|
||||
const inner = (
|
||||
<>
|
||||
<span className="wk-round-num mono">R{String(round.round).padStart(2, '0')}</span>
|
||||
<span className="wk-round-flag">{countryFlag({ country_code: round.country_code, country_flag: round.country_flag }) || round.country_code}</span>
|
||||
<span className={`wk-round-dot wk-round-${round.status}`} aria-hidden="true" />
|
||||
</>
|
||||
)
|
||||
const className = `wk-round wk-round-status-${round.status}`
|
||||
return (
|
||||
<li key={round.meeting_key} className="wk-round-item" role="listitem">
|
||||
{round.analysis_session_key ? (
|
||||
<Link
|
||||
to="/race-hub"
|
||||
search={{ session_key: round.analysis_session_key }}
|
||||
className={className}
|
||||
aria-label={`Round ${round.round} ${round.country_code} — ${round.status}`}
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={className} aria-label={`Round ${round.round} ${round.country_code} — ${round.status}`}>
|
||||
{inner}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
<div className="wk-season-legend mono" aria-hidden="true">
|
||||
<span><i className="wk-round-dot wk-round-completed" /> Completed</span>
|
||||
<span><i className="wk-round-dot wk-round-next" /> Next</span>
|
||||
<span><i className="wk-round-dot wk-round-upcoming" /> Upcoming</span>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTimeline({ sessions }: { sessions: WeekendTimelineSession[] }) {
|
||||
if (sessions.length === 0) return null
|
||||
return (
|
||||
<ol className="wk-timeline" data-testid="wk-timeline" role="list">
|
||||
{sessions.map((s) => (
|
||||
<li key={s.session_key} className={`wk-timeline-node wk-timeline-${s.status}`} role="listitem">
|
||||
<span className="wk-timeline-dot" aria-hidden="true" />
|
||||
<span className="wk-timeline-name">{shortSessionName(s.session_name)}</span>
|
||||
<span className="wk-timeline-state mono">{stateLabel(s.status)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function shortSessionName(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'Practice 1': 'FP1',
|
||||
'Practice 2': 'FP2',
|
||||
'Practice 3': 'FP3',
|
||||
Qualifying: 'Quali',
|
||||
'Sprint Qualifying': 'SQ',
|
||||
'Sprint Shootout': 'SQ',
|
||||
}
|
||||
return map[name] ?? name
|
||||
}
|
||||
|
||||
function stateLabel(status: WeekendTimelineSession['status']): string {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return 'Complete'
|
||||
case 'live':
|
||||
return 'Live'
|
||||
case 'next':
|
||||
return 'Next'
|
||||
default:
|
||||
return 'Upcoming'
|
||||
}
|
||||
}
|
||||
148
frontend/src/hooks/useWeekendContext.ts
Normal file
148
frontend/src/hooks/useWeekendContext.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchLiveState,
|
||||
fetchLocalMeetings,
|
||||
fetchNews,
|
||||
fetchSeasonMeetings,
|
||||
fetchSeasons,
|
||||
fetchSessions,
|
||||
fetchWeekend,
|
||||
fetchWeekendContext,
|
||||
} from '../api'
|
||||
import { pickFocusMeeting } from '../lib/schedule'
|
||||
import { deriveWeekendContext } from '../lib/weekendContext'
|
||||
import type { NewsItem, Weekend, WeekendContext } from '../types'
|
||||
|
||||
export interface UseWeekendContextResult {
|
||||
context: WeekendContext
|
||||
/** True when the payload came from the canonical /api/v1/weekend-context endpoint. */
|
||||
fromEndpoint: boolean
|
||||
now: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* useWeekendContext prefers the canonical /api/v1/weekend-context endpoint and
|
||||
* falls back to deriving the same contract client-side from existing endpoints
|
||||
* so the Weekend home works even when the backend handler (sibling story #72) is
|
||||
* not yet deployed.
|
||||
*/
|
||||
export function useWeekendContext(): UseWeekendContextResult {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
const nowDate = useMemo(() => new Date(now), [now])
|
||||
|
||||
const contextQuery = useQuery({
|
||||
queryKey: ['weekend-context'],
|
||||
queryFn: fetchWeekendContext,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||
const season = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const localMeetingsQuery = useQuery({
|
||||
queryKey: ['meetings', season, 'local'],
|
||||
queryFn: () => fetchLocalMeetings(season!),
|
||||
enabled: season != null,
|
||||
})
|
||||
|
||||
const seasonMeetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', season],
|
||||
queryFn: () => fetchSeasonMeetings(season!),
|
||||
enabled: season != null,
|
||||
})
|
||||
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', season],
|
||||
queryFn: () => fetchChampionshipHub(season!),
|
||||
enabled: season != null,
|
||||
})
|
||||
|
||||
const liveQuery = useQuery({ queryKey: ['live-state'], queryFn: fetchLiveState, staleTime: 5_000 })
|
||||
|
||||
const newsQuery = useQuery({ queryKey: ['news', 6], queryFn: () => fetchNews(6) })
|
||||
|
||||
const localMeetings = useMemo(() => localMeetingsQuery.data ?? [], [localMeetingsQuery.data])
|
||||
const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
|
||||
|
||||
const weekendQueries = useQueries({
|
||||
queries: localMeetings.map((meeting) => ({
|
||||
queryKey: ['weekend', meeting.meeting_key],
|
||||
queryFn: () => fetchWeekend(meeting.meeting_key),
|
||||
enabled: localMeetings.length > 0,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
})
|
||||
|
||||
const weekendsByKey = useMemo(() => {
|
||||
const map = new Map<number, Weekend>()
|
||||
localMeetings.forEach((meeting, i) => {
|
||||
const data = weekendQueries[i]?.data
|
||||
if (data) map.set(meeting.meeting_key, data)
|
||||
})
|
||||
return map
|
||||
}, [localMeetings, weekendQueries])
|
||||
|
||||
const focusMeeting = useMemo(() => pickFocusMeeting(seasonMeetings, nowDate), [seasonMeetings, nowDate])
|
||||
const focusHasLocal = focusMeeting ? weekendsByKey.has(focusMeeting.meeting_key) : false
|
||||
|
||||
const focusSessionsQuery = useQuery({
|
||||
queryKey: ['sessions', focusMeeting?.meeting_key, 'openf1'],
|
||||
queryFn: () => fetchSessions(focusMeeting!.meeting_key, 'openf1'),
|
||||
enabled: focusMeeting != null && !focusHasLocal,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const news: NewsItem[] = newsQuery.data ?? []
|
||||
|
||||
const derived = useMemo(
|
||||
() =>
|
||||
deriveWeekendContext({
|
||||
season,
|
||||
meetings: seasonMeetings,
|
||||
weekendsByKey,
|
||||
championship: championshipQuery.data,
|
||||
liveActive: liveQuery.data?.is_live === true,
|
||||
news,
|
||||
focusSessions: focusSessionsQuery.data,
|
||||
now: nowDate,
|
||||
}),
|
||||
[
|
||||
season,
|
||||
seasonMeetings,
|
||||
weekendsByKey,
|
||||
championshipQuery.data,
|
||||
liveQuery.data,
|
||||
news,
|
||||
focusSessionsQuery.data,
|
||||
nowDate,
|
||||
],
|
||||
)
|
||||
|
||||
if (contextQuery.data) {
|
||||
return { context: contextQuery.data, fromEndpoint: true, now: nowDate }
|
||||
}
|
||||
|
||||
if (seasonsQuery.isLoading || (season != null && seasonMeetingsQuery.isLoading && localMeetingsQuery.isLoading)) {
|
||||
return { context: { state: 'loading', season: season ?? 0 }, fromEndpoint: false, now: nowDate }
|
||||
}
|
||||
|
||||
if (seasonsQuery.isError) {
|
||||
return {
|
||||
context: {
|
||||
state: 'error',
|
||||
season: 0,
|
||||
message: seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load Weekend',
|
||||
},
|
||||
fromEndpoint: false,
|
||||
now: nowDate,
|
||||
}
|
||||
}
|
||||
|
||||
return { context: derived, fromEndpoint: false, now: nowDate }
|
||||
}
|
||||
390
frontend/src/lib/weekendContext.ts
Normal file
390
frontend/src/lib/weekendContext.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import type {
|
||||
ChampionshipHub,
|
||||
EnrichedResult,
|
||||
Meeting,
|
||||
NewsItem,
|
||||
Session,
|
||||
Weekend,
|
||||
WeekendChampionshipImpact,
|
||||
WeekendChampionshipMover,
|
||||
WeekendCompletedEvent,
|
||||
WeekendContext,
|
||||
WeekendPodiumEntry,
|
||||
WeekendSeasonRound,
|
||||
WeekendState,
|
||||
WeekendTimelineSession,
|
||||
WeekendUpcomingEvent,
|
||||
} from '../types'
|
||||
import {
|
||||
currentMeeting,
|
||||
meetingEndTime,
|
||||
meetingStartTime,
|
||||
mostRecentPastMeeting,
|
||||
nextUpcomingMeeting,
|
||||
sessionEndTime,
|
||||
sessionStartTime,
|
||||
sortSessionsByStart,
|
||||
} from './schedule'
|
||||
|
||||
// A session that finished within this window is still "settling" — results and
|
||||
// analysis are landing, so the Weekend surfaces a settling handoff rather than a
|
||||
// fully-formed recap (see sibling live-settling story #74).
|
||||
const SETTLING_MS = 45 * 60 * 1000
|
||||
// A race that finished within this window keeps the Weekend in its immediate
|
||||
// post-weekend aftermath before it relaxes into the general between-races cadence.
|
||||
const POST_WEEKEND_MS = 48 * 60 * 60 * 1000
|
||||
|
||||
export interface WeekendContextInputs {
|
||||
season: number | null
|
||||
meetings: Meeting[]
|
||||
weekendsByKey: Map<number, Weekend>
|
||||
championship?: ChampionshipHub
|
||||
liveActive: boolean
|
||||
news: NewsItem[]
|
||||
/** Sessions for the focus meeting when no local weekend is ingested. */
|
||||
focusSessions?: Session[]
|
||||
now: Date
|
||||
}
|
||||
|
||||
function isRaceSession(session: Session): boolean {
|
||||
const type = (session.session_type || '').toLowerCase()
|
||||
const name = (session.session_name || '').toLowerCase()
|
||||
return type.includes('race') || name === 'race' || name === 'sprint'
|
||||
}
|
||||
|
||||
/** Pick the session that best represents this weekend's primary analysis. */
|
||||
function pickAnalysisSession(weekend: Weekend | undefined, fallback: Session[]): Session | undefined {
|
||||
if (weekend && weekend.sessions.length > 0) {
|
||||
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) => isRaceSession(s.session))
|
||||
if (race) return race.session
|
||||
const quali = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
|
||||
if (quali) return quali.session
|
||||
const byDefault = weekend.sessions.find((s) => s.session.session_key === weekend.default_session_key)
|
||||
return (byDefault ?? pool[0])?.session
|
||||
}
|
||||
const race = fallback.find(isRaceSession)
|
||||
return race ?? fallback[0]
|
||||
}
|
||||
|
||||
function weekendSessions(weekend: Weekend | undefined, focusSessions: Session[] | undefined): Session[] {
|
||||
if (weekend && weekend.sessions.length > 0) return weekend.sessions.map((s) => s.session)
|
||||
return focusSessions ?? []
|
||||
}
|
||||
|
||||
function timeline(sessions: Session[], now: Date): WeekendTimelineSession[] {
|
||||
const sorted = sortSessionsByStart(sessions)
|
||||
const nextKey = sorted.find((s) => {
|
||||
const start = sessionStartTime(s)
|
||||
return start != null && start > now
|
||||
})?.session_key
|
||||
return sorted.map((session) => {
|
||||
const start = sessionStartTime(session)
|
||||
const end = sessionEndTime(session)
|
||||
let status: WeekendTimelineSession['status'] = 'upcoming'
|
||||
if (start && end && now >= start && now < end) status = 'live'
|
||||
else if (start && now >= start) status = 'done'
|
||||
else if (session.session_key === nextKey) status = 'next'
|
||||
return {
|
||||
session_key: session.session_key,
|
||||
session_name: session.session_name,
|
||||
session_type: session.session_type,
|
||||
date_start: session.date_start,
|
||||
date_end: session.date_end,
|
||||
status,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Most recently completed session in a weekend, by end time. */
|
||||
function lastCompletedSession(sessions: Session[], now: Date): Session | undefined {
|
||||
let selected: Session | undefined
|
||||
let latest = -Infinity
|
||||
for (const session of sessions) {
|
||||
const end = sessionEndTime(session)
|
||||
if (!end || end > now) continue
|
||||
if (end.getTime() > latest) {
|
||||
latest = end.getTime()
|
||||
selected = session
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
function meetingRound(meetings: Meeting[], meeting: Meeting): number {
|
||||
const active = meetings.filter((m) => !m.is_cancelled)
|
||||
const idx = active.findIndex((m) => m.meeting_key === meeting.meeting_key)
|
||||
return idx >= 0 ? idx + 1 : 0
|
||||
}
|
||||
|
||||
export function podiumFromResults(results: EnrichedResult[]): WeekendPodiumEntry[] {
|
||||
return [...results]
|
||||
.filter((r) => r.position >= 1)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.slice(0, 3)
|
||||
.map((r) => ({
|
||||
position: r.position,
|
||||
driver_number: r.driver_number,
|
||||
name_acronym: r.name_acronym,
|
||||
team_name: r.team_name,
|
||||
team_colour: r.team_colour,
|
||||
gap: formatResultGap(r),
|
||||
}))
|
||||
}
|
||||
|
||||
function formatResultGap(r: EnrichedResult): string {
|
||||
if (r.dnf) return 'DNF'
|
||||
if (r.dns) return 'DNS'
|
||||
if (r.dsq) return 'DSQ'
|
||||
if (r.position === 1) {
|
||||
if (typeof r.duration === 'number' && r.duration > 0) return formatDuration(r.duration)
|
||||
return 'Winner'
|
||||
}
|
||||
const gap = Array.isArray(r.gap_to_leader) ? r.gap_to_leader[r.gap_to_leader.length - 1] : r.gap_to_leader
|
||||
if (typeof gap === 'number') return `+${gap.toFixed(3)}`
|
||||
if (typeof gap === 'string' && gap) return gap.startsWith('+') ? gap : `+${gap}`
|
||||
return '—'
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`
|
||||
return `${m}:${s.toFixed(3).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
function completedEvent(
|
||||
meeting: Meeting,
|
||||
meetings: Meeting[],
|
||||
weekend: Weekend | undefined,
|
||||
fallback: Session[],
|
||||
): WeekendCompletedEvent | undefined {
|
||||
const analysis = pickAnalysisSession(weekend, fallback)
|
||||
if (!analysis) return undefined
|
||||
return {
|
||||
meeting_key: meeting.meeting_key,
|
||||
meeting_name: meeting.meeting_name,
|
||||
country_code: meeting.country_code,
|
||||
country_flag: meeting.country_flag,
|
||||
circuit_short_name: meeting.circuit_short_name || meeting.location,
|
||||
round: meetingRound(meetings, meeting) || undefined,
|
||||
analysis_session_key: analysis.session_key,
|
||||
analysis_session_name: analysis.session_name,
|
||||
label: analysis.session_name,
|
||||
podium: [],
|
||||
}
|
||||
}
|
||||
|
||||
function upcomingEvent(
|
||||
meeting: Meeting,
|
||||
meetings: Meeting[],
|
||||
sessions: Session[],
|
||||
now: Date,
|
||||
): WeekendUpcomingEvent {
|
||||
const sorted = sortSessionsByStart(sessions)
|
||||
const nextSession = sorted.find((s) => {
|
||||
const start = sessionStartTime(s)
|
||||
return start != null && start > now
|
||||
})
|
||||
return {
|
||||
meeting_key: meeting.meeting_key,
|
||||
meeting_name: meeting.meeting_name,
|
||||
country_code: meeting.country_code,
|
||||
country_flag: meeting.country_flag,
|
||||
circuit_short_name: meeting.circuit_short_name || meeting.location,
|
||||
circuit_key: meeting.circuit_key,
|
||||
round: meetingRound(meetings, meeting) || undefined,
|
||||
date_start: meeting.date_start,
|
||||
next_session_name: nextSession?.session_name,
|
||||
next_session_start: nextSession?.date_start,
|
||||
}
|
||||
}
|
||||
|
||||
function championshipImpact(hub: ChampionshipHub | undefined): WeekendChampionshipImpact | undefined {
|
||||
if (!hub || hub.drivers.length === 0) return undefined
|
||||
const leaders: WeekendChampionshipMover[] = hub.drivers.slice(0, 3).map((d) => {
|
||||
const cumulative = d.cumulative ?? []
|
||||
const delta =
|
||||
cumulative.length >= 2 ? cumulative[cumulative.length - 1] - cumulative[cumulative.length - 2] : undefined
|
||||
return {
|
||||
position: d.position,
|
||||
driver_number: d.driver_number,
|
||||
name_acronym: d.name_acronym,
|
||||
team_colour: d.team_colour,
|
||||
points: d.points,
|
||||
delta,
|
||||
}
|
||||
})
|
||||
const note = hub.last_race ? `Standings after ${hub.last_race}.` : undefined
|
||||
return { leaders, note }
|
||||
}
|
||||
|
||||
function seasonRounds(
|
||||
meetings: Meeting[],
|
||||
weekendsByKey: Map<number, Weekend>,
|
||||
focusKey: number | undefined,
|
||||
now: Date,
|
||||
): WeekendSeasonRound[] {
|
||||
const active = meetings.filter((m) => !m.is_cancelled)
|
||||
return active.map((meeting, index) => {
|
||||
const start = meetingStartTime(meeting)
|
||||
const end = meetingEndTime(meeting)
|
||||
let status: WeekendSeasonRound['status'] = 'upcoming'
|
||||
if (meeting.meeting_key === focusKey) status = 'next'
|
||||
else if (start && end && now > end) status = 'completed'
|
||||
else if (start && now >= start) status = 'next'
|
||||
const weekend = weekendsByKey.get(meeting.meeting_key)
|
||||
const analysis = pickAnalysisSession(weekend, [])
|
||||
return {
|
||||
round: index + 1,
|
||||
meeting_key: meeting.meeting_key,
|
||||
country_code: meeting.country_code,
|
||||
country_flag: meeting.country_flag,
|
||||
status,
|
||||
analysis_session_key: analysis?.session_key,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function briefingItems(news: NewsItem[]): WeekendContext['briefing'] {
|
||||
return news.slice(0, 3).map((item) => ({
|
||||
category: item.category,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
source: item.source,
|
||||
published_at: item.published_at,
|
||||
image_url: item.og_image_url,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* deriveWeekendContext resolves the current temporal Weekend state and its
|
||||
* skeleton payload from locally-available data. It is a pure function of its
|
||||
* inputs and `now`, mirroring the canonical /api/v1/weekend-context contract so
|
||||
* the two are interchangeable. Podium / story detail is fetched per-state by the
|
||||
* view components from existing analysis endpoints.
|
||||
*/
|
||||
export function deriveWeekendContext(inputs: WeekendContextInputs): WeekendContext {
|
||||
const { season, meetings, weekendsByKey, championship, liveActive, news, focusSessions, now } = inputs
|
||||
|
||||
const base = (state: WeekendState, extra: Partial<WeekendContext> = {}): WeekendContext => ({
|
||||
state,
|
||||
season: season ?? championship?.season ?? 0,
|
||||
live: liveActive,
|
||||
championship_impact: championshipImpact(championship),
|
||||
briefing: briefingItems(news),
|
||||
...extra,
|
||||
})
|
||||
|
||||
if (!season || meetings.length === 0) {
|
||||
return base('limited_data', {
|
||||
message: 'No season data yet. Ingest a race weekend to populate the Weekend view.',
|
||||
})
|
||||
}
|
||||
|
||||
const rounds = seasonRounds(meetings, weekendsByKey, undefined, now)
|
||||
const current = currentMeeting(meetings, now)
|
||||
const next = nextUpcomingMeeting(meetings, now)
|
||||
const last = mostRecentPastMeeting(meetings, now)
|
||||
|
||||
const focusKey = current?.meeting_key ?? next?.meeting_key
|
||||
const withRounds = seasonRounds(meetings, weekendsByKey, focusKey, now)
|
||||
|
||||
// ── Inside a current race weekend window ──
|
||||
if (current) {
|
||||
const weekend = weekendsByKey.get(current.meeting_key)
|
||||
const sessions = weekendSessions(weekend, focusSessions)
|
||||
const line = timeline(sessions, now)
|
||||
const nextSession = line.find((s) => s.status === 'next' || s.status === 'live')
|
||||
const completed = lastCompletedSession(sessions, now)
|
||||
const lastSession = completed
|
||||
? completedEvent(current, meetings, weekend, sessions)
|
||||
: undefined
|
||||
if (lastSession && completed) {
|
||||
lastSession.analysis_session_key = completed.session_key
|
||||
lastSession.analysis_session_name = completed.session_name
|
||||
lastSession.label = completed.session_name
|
||||
}
|
||||
const anyStarted = sessions.some((s) => {
|
||||
const start = sessionStartTime(s)
|
||||
return start != null && now >= start
|
||||
})
|
||||
const allDone = sessions.length > 0 && line.every((s) => s.status === 'done')
|
||||
const settling =
|
||||
completed != null && sessionEndTime(completed) != null &&
|
||||
now.getTime() - (sessionEndTime(completed) as Date).getTime() <= SETTLING_MS
|
||||
|
||||
const shared: Partial<WeekendContext> = {
|
||||
active_meeting_name: current.meeting_name,
|
||||
active_circuit_short_name: current.circuit_short_name || current.location,
|
||||
sessions: line,
|
||||
next_session: nextSession,
|
||||
last_session: lastSession,
|
||||
}
|
||||
|
||||
if (liveActive || line.some((s) => s.status === 'live')) {
|
||||
return base('session_live', shared)
|
||||
}
|
||||
if (settling) {
|
||||
return base('session_settling', shared)
|
||||
}
|
||||
if (allDone) {
|
||||
// Race weekend finished but still inside its window — treat as post-weekend.
|
||||
return base('post_weekend', {
|
||||
...shared,
|
||||
last_event: lastSession,
|
||||
next_event: next ? upcomingEvent(next, meetings, [], now) : undefined,
|
||||
season_rounds: withRounds,
|
||||
})
|
||||
}
|
||||
if (lastSession && nextSession) {
|
||||
return base('between_sessions', shared)
|
||||
}
|
||||
if (!anyStarted) {
|
||||
return base('pre_session', {
|
||||
...shared,
|
||||
next_event: upcomingEvent(current, meetings, sessions, now),
|
||||
})
|
||||
}
|
||||
return base('between_sessions', shared)
|
||||
}
|
||||
|
||||
// ── Between weekends ──
|
||||
if (last) {
|
||||
const lastWeekend = weekendsByKey.get(last.meeting_key)
|
||||
const lastEvent = completedEvent(last, meetings, lastWeekend, [])
|
||||
const lastEnd = meetingEndTime(last)
|
||||
const isPostWeekend =
|
||||
lastEnd != null && now.getTime() - lastEnd.getTime() <= POST_WEEKEND_MS
|
||||
|
||||
if (next) {
|
||||
const nextWeekend = weekendsByKey.get(next.meeting_key)
|
||||
const nextSessions = weekendSessions(nextWeekend, undefined)
|
||||
return base(isPostWeekend ? 'post_weekend' : 'between_races', {
|
||||
last_event: lastEvent,
|
||||
next_event: upcomingEvent(next, meetings, nextSessions, now),
|
||||
season_rounds: withRounds,
|
||||
})
|
||||
}
|
||||
// Completed races but nothing left on the calendar.
|
||||
return base('season_complete', {
|
||||
last_event: lastEvent,
|
||||
season_rounds: rounds,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Season hasn't started yet: preview the opener ──
|
||||
if (next) {
|
||||
const nextWeekend = weekendsByKey.get(next.meeting_key)
|
||||
const nextSessions = weekendSessions(nextWeekend, focusSessions)
|
||||
return base('pre_session', {
|
||||
next_event: upcomingEvent(next, meetings, nextSessions, now),
|
||||
season_rounds: withRounds,
|
||||
})
|
||||
}
|
||||
|
||||
return base('season_complete', { season_rounds: rounds })
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchLiveState,
|
||||
fetchLocalMeetings,
|
||||
fetchRaceHub,
|
||||
fetchSeasonMeetings,
|
||||
fetchSeasons,
|
||||
fetchSessions,
|
||||
fetchWeekend,
|
||||
} from '../api'
|
||||
import { CommandCenterHero } from '../components/CommandCenterHero'
|
||||
import { PaddockBriefing } from '../components/PaddockBriefing'
|
||||
import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
||||
import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import { classifySessionStatus, heroState } from '../lib/hero'
|
||||
import {
|
||||
currentAndNextSession,
|
||||
focusMeetingKind,
|
||||
formatSessionScheduleTime,
|
||||
meetingHasStarted,
|
||||
mostRecentPastMeeting,
|
||||
nextUpcomingMeeting,
|
||||
pickFocusMeeting,
|
||||
sortSessionsByStart,
|
||||
} from '../lib/schedule'
|
||||
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
|
||||
import { Trophy } from 'lucide-react'
|
||||
|
||||
const missingDatasets = Object.fromEntries(
|
||||
RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]),
|
||||
) as WeekendSession['datasets']
|
||||
|
||||
function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) {
|
||||
if (meeting.meeting_key === focusKey) return 'focus'
|
||||
if (meetingHasStarted(meeting, now)) return 'past'
|
||||
return 'future'
|
||||
}
|
||||
|
||||
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 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,
|
||||
})
|
||||
|
||||
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
|
||||
const focusMeetings = seasonMeetings.length > 0 ? seasonMeetings : localMeetings
|
||||
|
||||
const weekendQueries = useQueries({
|
||||
queries: localMeetings.map((meeting) => ({
|
||||
queryKey: ['weekend', meeting.meeting_key],
|
||||
queryFn: () => fetchWeekend(meeting.meeting_key),
|
||||
enabled: localMeetings.length > 0,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
})
|
||||
|
||||
const liveQuery = useQuery({
|
||||
queryKey: ['live-state'],
|
||||
queryFn: fetchLiveState,
|
||||
staleTime: 5_000,
|
||||
})
|
||||
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', latestSeason],
|
||||
queryFn: () => fetchChampionshipHub(latestSeason!),
|
||||
enabled: latestSeason != null,
|
||||
})
|
||||
const champHub = championshipQuery.data
|
||||
|
||||
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>()
|
||||
localMeetings.forEach((meeting, i) => {
|
||||
const data = weekendQueries[i]?.data
|
||||
if (data) map.set(meeting.meeting_key, data)
|
||||
})
|
||||
return map
|
||||
}, [localMeetings, weekendQueries])
|
||||
|
||||
const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
|
||||
const meetingStats = countWeekendStats(weekendList)
|
||||
const focusMeeting = pickFocusMeeting(focusMeetings, nowDate)
|
||||
const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
|
||||
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
|
||||
const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
|
||||
const focusSessions: Session[] = sortSessionsByStart(focusWeekendSessions.map((s) => s.session))
|
||||
const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate)
|
||||
|
||||
const analysisSession = pickAnalysisSession(focusWeekend)
|
||||
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
|
||||
|
||||
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 ?? ''
|
||||
|
||||
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 (
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const accent = countryAccent(focusMeeting ?? null)
|
||||
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
||||
{liveActive ? 'Live session active' : 'No live session'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!focusMeeting && (
|
||||
<div className="missing-notice">
|
||||
No meetings ingested for {latestSeason}. Run{' '}
|
||||
<code>box-box --ingest-year {latestSeason}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<PaddockBriefing />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
156
frontend/src/pages/ExplorePage.tsx
Normal file
156
frontend/src/pages/ExplorePage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
fetchChampionshipHub,
|
||||
fetchLocalMeetings,
|
||||
fetchSeasonMeetings,
|
||||
fetchSeasons,
|
||||
fetchWeekend,
|
||||
} from '../api'
|
||||
import { countryFlag, formatGpDateRange } from '../lib/gpIdentity'
|
||||
import { meetingHasStarted } from '../lib/schedule'
|
||||
import { teamColor } from '../utils'
|
||||
import type { Weekend } from '../types'
|
||||
import '../styles/weekend.css'
|
||||
|
||||
function analysisKey(weekend: Weekend | undefined): number | undefined {
|
||||
if (!weekend) return undefined
|
||||
const race = weekend.sessions.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
||||
return race?.session.session_key ?? weekend.default_session_key
|
||||
}
|
||||
|
||||
export function ExplorePage() {
|
||||
const now = useMemo(() => new Date(), [])
|
||||
|
||||
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons })
|
||||
const season = seasonsQuery.data?.[0] ?? null
|
||||
|
||||
const localMeetingsQuery = useQuery({
|
||||
queryKey: ['meetings', season, 'local'],
|
||||
queryFn: () => fetchLocalMeetings(season!),
|
||||
enabled: season != null,
|
||||
})
|
||||
const seasonMeetingsQuery = useQuery({
|
||||
queryKey: ['season-meetings', season],
|
||||
queryFn: () => fetchSeasonMeetings(season!),
|
||||
enabled: season != null,
|
||||
})
|
||||
const championshipQuery = useQuery({
|
||||
queryKey: ['championship-hub', season],
|
||||
queryFn: () => fetchChampionshipHub(season!),
|
||||
enabled: season != null,
|
||||
})
|
||||
|
||||
const localMeetings = localMeetingsQuery.data ?? []
|
||||
const meetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
|
||||
|
||||
const weekendQueries = useQueries({
|
||||
queries: localMeetings.map((m) => ({
|
||||
queryKey: ['weekend', m.meeting_key],
|
||||
queryFn: () => fetchWeekend(m.meeting_key),
|
||||
enabled: localMeetings.length > 0,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
})
|
||||
const weekendsByKey = useMemo(() => {
|
||||
const map = new Map<number, Weekend>()
|
||||
localMeetings.forEach((m, i) => {
|
||||
const data = weekendQueries[i]?.data
|
||||
if (data) map.set(m.meeting_key, data)
|
||||
})
|
||||
return map
|
||||
}, [localMeetings, weekendQueries])
|
||||
|
||||
const drivers = championshipQuery.data?.drivers ?? []
|
||||
const activeMeetings = meetings.filter((m) => !m.is_cancelled)
|
||||
|
||||
if (seasonsQuery.isLoading) {
|
||||
return <div className="wk-status" data-testid="explore-loading"><p className="wk-status-title">Loading…</p></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="wk-page wk-explore" data-testid="explore-page">
|
||||
<div className="wk-eyebrow mono">Explore · {season ?? 'season'}</div>
|
||||
|
||||
<section className="wk-explore-section" aria-labelledby="explore-calendar-title">
|
||||
<header className="wk-card-head">
|
||||
<span className="wk-card-title" id="explore-calendar-title">Season races</span>
|
||||
<span className="wk-card-link mono">{activeMeetings.length} rounds</span>
|
||||
</header>
|
||||
{activeMeetings.length === 0 ? (
|
||||
<p className="wk-status-sub" data-testid="explore-calendar-empty">No season calendar available yet.</p>
|
||||
) : (
|
||||
<ol className="wk-explore-grid" role="list">
|
||||
{activeMeetings.map((meeting, index) => {
|
||||
const weekend = weekendsByKey.get(meeting.meeting_key)
|
||||
const target = analysisKey(weekend)
|
||||
const done = meetingHasStarted(meeting, now)
|
||||
const card = (
|
||||
<>
|
||||
<div className="wk-explore-top mono">
|
||||
<span>R{String(index + 1).padStart(2, '0')}</span>
|
||||
<span className="wk-explore-flag">
|
||||
{countryFlag(meeting) || meeting.country_code}
|
||||
</span>
|
||||
</div>
|
||||
<div className="wk-explore-name">{meeting.meeting_name}</div>
|
||||
<div className="wk-explore-meta mono">{meeting.circuit_short_name || meeting.location}</div>
|
||||
<div className="wk-explore-meta mono">{formatGpDateRange(meeting)}</div>
|
||||
<span className={`wk-explore-state ${done ? 'is-done' : 'is-upcoming'}`}>
|
||||
{done ? 'Completed' : 'Upcoming'}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<li key={meeting.meeting_key} className="wk-explore-item" role="listitem">
|
||||
{target ? (
|
||||
<Link
|
||||
to="/race-hub"
|
||||
search={{ session_key: target }}
|
||||
className="wk-explore-card is-link"
|
||||
data-testid={`explore-race-${meeting.meeting_key}`}
|
||||
>
|
||||
{card}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="wk-explore-card" data-testid={`explore-race-${meeting.meeting_key}`}>{card}</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="wk-explore-section" aria-labelledby="explore-drivers-title">
|
||||
<header className="wk-card-head">
|
||||
<span className="wk-card-title" id="explore-drivers-title">Drivers</span>
|
||||
<Link to="/championship" className="wk-card-link">Championship →</Link>
|
||||
</header>
|
||||
{drivers.length === 0 ? (
|
||||
<p className="wk-status-sub" data-testid="explore-drivers-empty">Driver data not available yet.</p>
|
||||
) : (
|
||||
<ol className="wk-explore-drivers" role="list">
|
||||
{drivers.map((d) => (
|
||||
<li key={d.driver_number} role="listitem">
|
||||
<Link
|
||||
to="/drivers/$driverNumber"
|
||||
params={{ driverNumber: String(d.driver_number) }}
|
||||
search={season ? { year: season } : {}}
|
||||
className="wk-driver-chip"
|
||||
style={{ ['--wk-team' as string]: teamColor(d.team_colour) }}
|
||||
data-testid={`explore-driver-${d.driver_number}`}
|
||||
>
|
||||
<span className="wk-driver-pos mono">{d.position}</span>
|
||||
<span className="wk-driver-code">{d.name_acronym}</span>
|
||||
<span className="wk-driver-pts mono">{d.points}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
42
frontend/src/pages/WeekendPage.tsx
Normal file
42
frontend/src/pages/WeekendPage.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useWeekendContext } from '../hooks/useWeekendContext'
|
||||
import { BetweenRacesView } from '../components/weekend/BetweenRacesView'
|
||||
import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView'
|
||||
import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
|
||||
import { PreSessionView } from '../components/weekend/PreSessionView'
|
||||
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
|
||||
import type { WeekendContext } from '../types'
|
||||
import '../styles/weekend.css'
|
||||
|
||||
function renderState(context: WeekendContext, now: Date) {
|
||||
switch (context.state) {
|
||||
case 'loading':
|
||||
return <WeekendLoading />
|
||||
case 'error':
|
||||
return <WeekendError message={context.message} />
|
||||
case 'limited_data':
|
||||
return <WeekendLimited message={context.message} season={context.season} />
|
||||
case 'between_races':
|
||||
case 'post_weekend':
|
||||
case 'season_complete':
|
||||
return <BetweenRacesView context={context} now={now} />
|
||||
case 'between_sessions':
|
||||
case 'session_settling':
|
||||
return <BetweenSessionsView context={context} now={now} />
|
||||
case 'session_live':
|
||||
return <LiveHandoffView context={context} />
|
||||
case 'pre_session':
|
||||
return <PreSessionView context={context} now={now} />
|
||||
default:
|
||||
return <WeekendLimited season={context.season} />
|
||||
}
|
||||
}
|
||||
|
||||
export function WeekendPage() {
|
||||
const { context, now } = useWeekendContext()
|
||||
|
||||
return (
|
||||
<main className="wk-page" data-testid="weekend-page" data-state={context.state}>
|
||||
{renderState(context, now)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
|
||||
import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@tanstack/react-router'
|
||||
import { Nav } from './components/Nav'
|
||||
import { CommandCenterPage } from './pages/CommandCenterPage'
|
||||
import { WeekendPage } from './pages/WeekendPage'
|
||||
import { ExplorePage } from './pages/ExplorePage'
|
||||
import { RaceHubPage } from './pages/RaceHubPage'
|
||||
import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||
import { BriefingPage } from './pages/BriefingPage'
|
||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||
import { RacePreviewPage } from './pages/RacePreviewPage'
|
||||
|
||||
type RaceHubSearch = {
|
||||
session_key?: number
|
||||
@@ -26,10 +26,17 @@ const rootRoute = createRootRoute({
|
||||
),
|
||||
})
|
||||
|
||||
export const commandCenterRoute = createRoute({
|
||||
// Weekend is the adaptive home. `/` renders the current temporal state.
|
||||
export const weekendRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: CommandCenterPage,
|
||||
component: WeekendPage,
|
||||
})
|
||||
|
||||
export const exploreRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/explore',
|
||||
component: ExplorePage,
|
||||
})
|
||||
|
||||
export const raceHubRoute = createRoute({
|
||||
@@ -90,14 +97,19 @@ export const briefingRoute = createRoute({
|
||||
component: BriefingPage,
|
||||
})
|
||||
|
||||
// Preview folds into the Weekend home. Keep /preview as a stable redirect so saved
|
||||
// links resolve into the appropriate Weekend state.
|
||||
export const previewRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/preview',
|
||||
component: RacePreviewPage,
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/' })
|
||||
},
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
commandCenterRoute,
|
||||
weekendRoute,
|
||||
exploreRoute,
|
||||
raceHubRoute,
|
||||
adminRoute,
|
||||
dataLibraryRoute,
|
||||
|
||||
@@ -142,6 +142,46 @@ a { color: inherit; text-decoration: none; }
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* ── Mobile bottom nav ── */
|
||||
.app-bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
height: 60px;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
background: rgba(18, 18, 18, 0.92);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.bottom-nav-link {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.bottom-nav-link.active { color: var(--red); }
|
||||
.bottom-nav-link:hover { color: var(--text-2); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-bottom-nav { display: flex; }
|
||||
}
|
||||
|
||||
/* ── Focus visibility (accessibility) ── */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--red);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ── Page ── */
|
||||
.page {
|
||||
max-width: 1040px;
|
||||
|
||||
399
frontend/src/styles/weekend.css
Normal file
399
frontend/src/styles/weekend.css
Normal file
@@ -0,0 +1,399 @@
|
||||
/* ── Weekend adaptive home ── */
|
||||
.wk-page {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: var(--s6) var(--s6) 64px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wk-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
.wk-eyebrow::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--red), transparent);
|
||||
}
|
||||
|
||||
.wk-card-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--s4);
|
||||
margin-bottom: var(--s4);
|
||||
}
|
||||
.wk-card-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.wk-card-link {
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wk-card-link:hover { color: var(--text); }
|
||||
|
||||
.wk-flag { font-size: 22px; line-height: 1; }
|
||||
|
||||
/* ── CTAs ── */
|
||||
.wk-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s4) var(--s5);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
transition: background 0.12s, border-color 0.12s, transform 0.12s;
|
||||
}
|
||||
.wk-cta-primary { background: var(--red); color: #fff; }
|
||||
.wk-cta-primary:hover { background: #ff3b3f; }
|
||||
.wk-cta-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--border-2);
|
||||
}
|
||||
.wk-cta-ghost:hover { color: var(--text); border-color: var(--text-3); }
|
||||
.wk-cta-wide { width: 100%; }
|
||||
|
||||
/* ── Between-races top grid ── */
|
||||
.wk-top-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--s5);
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
.wk-mid-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 340px) minmax(0, 1fr);
|
||||
gap: var(--s5);
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
.wk-event-card {
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s5);
|
||||
}
|
||||
.wk-event-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--s4);
|
||||
}
|
||||
.wk-event-id { display: flex; align-items: center; gap: var(--s4); min-width: 0; }
|
||||
.wk-event-name {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wk-event-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wk-tag-done { color: var(--text-2); }
|
||||
.wk-tag-next { color: var(--red); }
|
||||
|
||||
.wk-event-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 200px);
|
||||
gap: var(--s5);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* ── Podium ── */
|
||||
.wk-podium { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
|
||||
.wk-podium-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
padding: var(--s3) var(--s4);
|
||||
background: var(--surface-h);
|
||||
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||
border-radius: 4px;
|
||||
}
|
||||
.wk-podium-pos { font-weight: 700; color: var(--text-2); }
|
||||
.wk-podium-code { font-weight: 700; color: var(--wk-team, var(--text)); overflow: hidden; text-overflow: ellipsis; }
|
||||
.wk-podium-gap { color: var(--text-2); font-size: 12px; }
|
||||
.wk-podium-empty { color: var(--text-3); font-size: 12px; padding: var(--s4); }
|
||||
|
||||
.wk-story-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s3);
|
||||
padding: var(--s4);
|
||||
background: rgba(230, 36, 41, 0.05);
|
||||
border: 1px solid rgba(230, 36, 41, 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.wk-story-label { font-size: 13px; font-weight: 700; color: var(--red); }
|
||||
.wk-story-text { font-size: 12px; color: var(--text-2); flex: 1; }
|
||||
|
||||
/* ── Next event ── */
|
||||
.wk-next-body { display: flex; flex-direction: column; gap: var(--s5); }
|
||||
.wk-countdown { display: flex; gap: var(--s5); align-items: baseline; }
|
||||
.wk-count-unit { font-size: 13px; color: var(--text-2); font-family: var(--f-mono); }
|
||||
.wk-count-unit b { font-size: 40px; color: var(--red); font-weight: 700; margin-right: 2px; }
|
||||
.wk-countdown-clock { font-size: 32px; font-weight: 700; color: var(--blue); }
|
||||
.wk-countdown-live { font-size: 20px; font-weight: 700; color: var(--red); }
|
||||
.wk-next-session { display: flex; flex-direction: column; gap: 2px; }
|
||||
.wk-next-label { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-3); }
|
||||
.wk-next-value { font-size: 15px; font-weight: 600; }
|
||||
.wk-season-complete-copy { font-size: 13px; color: var(--text-2); }
|
||||
|
||||
/* ── Championship impact ── */
|
||||
.wk-champ, .wk-briefing, .wk-season-nav {
|
||||
min-width: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: var(--s5);
|
||||
}
|
||||
.wk-champ-list { list-style: none; display: flex; flex-direction: column; gap: var(--s3); }
|
||||
.wk-champ-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
padding: var(--s2) var(--s3);
|
||||
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||
}
|
||||
.wk-champ-pos { color: var(--text-3); }
|
||||
.wk-champ-code { font-weight: 700; color: var(--wk-team, var(--text)); }
|
||||
.wk-champ-pts { font-weight: 700; }
|
||||
.wk-champ-gap { color: var(--text-3); font-size: 11px; min-width: 34px; text-align: right; }
|
||||
.wk-champ-note { margin-top: var(--s4); font-size: 11px; color: var(--text-3); }
|
||||
|
||||
/* ── Season nav strip ── */
|
||||
.wk-season-strip {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: var(--s3);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--s3);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.wk-season-strip::-webkit-scrollbar { height: 4px; }
|
||||
.wk-round-item { flex: 0 0 auto; }
|
||||
.wk-round {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
width: 54px;
|
||||
padding: var(--s3) 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wk-round-status-next { border-color: var(--red); color: var(--text); }
|
||||
.wk-round-status-completed { border-color: var(--border-2); }
|
||||
.wk-round-num { font-size: 10px; font-weight: 700; }
|
||||
.wk-round-flag { font-size: 16px; }
|
||||
.wk-round-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.wk-round-completed { background: var(--green); }
|
||||
.wk-round-next { background: var(--red); }
|
||||
.wk-round-upcoming { background: var(--surface-3); border: 1px solid var(--border-2); }
|
||||
.wk-season-legend {
|
||||
display: flex;
|
||||
gap: var(--s5);
|
||||
margin-top: var(--s4);
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wk-season-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.wk-season-legend i { width: 7px; height: 7px; }
|
||||
|
||||
/* ── Briefing ── */
|
||||
.wk-briefing-list { list-style: none; display: flex; flex-direction: column; }
|
||||
.wk-briefing-item { border-top: 1px solid var(--border); }
|
||||
.wk-briefing-item:first-child { border-top: none; }
|
||||
.wk-briefing-link {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--s4);
|
||||
padding: var(--s4) 0;
|
||||
}
|
||||
.wk-briefing-link:hover .wk-briefing-title { color: var(--red); }
|
||||
.wk-briefing-cat { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--red); white-space: nowrap; }
|
||||
.wk-briefing-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wk-briefing-source { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
||||
|
||||
/* ── Between-sessions / live ── */
|
||||
.wk-sessions, .wk-live, .wk-pre { display: flex; flex-direction: column; gap: var(--s5); }
|
||||
.wk-sessions-head { display: flex; flex-direction: column; gap: 2px; }
|
||||
.wk-sessions-title { font-size: 28px; font-weight: 800; letter-spacing: -0.01em; }
|
||||
.wk-sessions-circuit { font-size: 12px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
|
||||
.wk-timeline {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: var(--s3);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--s3);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.wk-timeline-node {
|
||||
flex: 1 0 auto;
|
||||
min-width: 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
padding: var(--s3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.wk-timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--surface-3); }
|
||||
.wk-timeline-done .wk-timeline-dot { background: var(--green); }
|
||||
.wk-timeline-live .wk-timeline-dot { background: var(--red); box-shadow: 0 0 0 3px rgba(230,36,41,0.25); }
|
||||
.wk-timeline-next { border-color: var(--red); }
|
||||
.wk-timeline-next .wk-timeline-dot { border: 2px solid var(--red); }
|
||||
.wk-timeline-name { font-size: 12px; font-weight: 600; }
|
||||
.wk-timeline-state { font-size: 9px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-3); }
|
||||
.wk-timeline-done .wk-timeline-state { color: var(--green); }
|
||||
.wk-timeline-live .wk-timeline-state, .wk-timeline-next .wk-timeline-state { color: var(--red); }
|
||||
|
||||
.wk-recap-card, .wk-upnext-card, .wk-live-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: var(--s5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
.wk-recap-eyebrow, .wk-upnext-eyebrow { font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--green); }
|
||||
.wk-recap-title, .wk-upnext-title { font-size: 20px; font-weight: 700; }
|
||||
.wk-upnext-when { font-size: 12px; color: var(--text-3); }
|
||||
.wk-upnext-card { border-color: rgba(230,36,41,0.3); }
|
||||
|
||||
.wk-live-card { border-color: rgba(230,36,41,0.35); }
|
||||
.wk-live-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.wk-live-pulse { width: 12px; height: 12px; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 4px rgba(230,36,41,0.2); }
|
||||
.wk-live-lead { font-size: 14px; color: var(--text-2); }
|
||||
|
||||
/* ── Pre-session ── */
|
||||
.wk-pre-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wk-pre-countdown { display: flex; flex-direction: column; gap: 2px; margin-left: auto; }
|
||||
.wk-pre-preview { min-width: 0; }
|
||||
/* The folded preview page ships its own max-width page; reset it inside Weekend. */
|
||||
.wk-pre-preview .preview-page { max-width: 100%; padding: 0; }
|
||||
|
||||
/* ── Status views ── */
|
||||
.wk-status {
|
||||
max-width: 640px;
|
||||
margin: 64px auto;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
align-items: center;
|
||||
}
|
||||
.wk-status-eyebrow { font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-3); }
|
||||
.wk-status-title { font-size: 22px; font-weight: 700; }
|
||||
.wk-status-sub { font-size: 13px; color: var(--text-2); }
|
||||
.wk-status-actions { display: flex; gap: var(--s4); margin-top: var(--s4); }
|
||||
.wk-status-error .wk-status-title { color: var(--red); }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 900px) {
|
||||
.wk-top-grid, .wk-mid-grid { grid-template-columns: 1fr; }
|
||||
.wk-event-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wk-page { padding: var(--s5) var(--s5) 88px; }
|
||||
.wk-event-name { font-size: 20px; }
|
||||
.wk-sessions-title { font-size: 22px; }
|
||||
.wk-count-unit b { font-size: 32px; }
|
||||
.wk-pre-head { flex-direction: column; align-items: flex-start; }
|
||||
.wk-pre-countdown { margin-left: 0; }
|
||||
}
|
||||
|
||||
/* ── Explore ── */
|
||||
.wk-explore { display: flex; flex-direction: column; gap: var(--s6); }
|
||||
.wk-explore-section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: var(--s5);
|
||||
}
|
||||
.wk-explore-grid {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--s4);
|
||||
}
|
||||
.wk-explore-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
padding: var(--s4);
|
||||
min-width: 0;
|
||||
background: var(--surface-h);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
.wk-explore-card.is-link { transition: border-color 0.12s, background 0.12s; }
|
||||
.wk-explore-card.is-link:hover { border-color: var(--text-3); background: var(--surface-2); }
|
||||
.wk-explore-top { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-3); }
|
||||
.wk-explore-flag { font-size: 18px; }
|
||||
.wk-explore-name { font-size: 14px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
|
||||
.wk-explore-meta { font-size: 11px; color: var(--text-3); }
|
||||
.wk-explore-state { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; margin-top: var(--s2); }
|
||||
.wk-explore-state.is-done { color: var(--green); }
|
||||
.wk-explore-state.is-upcoming { color: var(--text-3); }
|
||||
|
||||
.wk-explore-drivers { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s3); }
|
||||
.wk-driver-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
padding: var(--s3) var(--s4);
|
||||
background: var(--surface-h);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--wk-team, var(--border-2));
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.wk-driver-chip:hover { border-color: var(--text-3); }
|
||||
.wk-driver-pos { color: var(--text-3); font-size: 11px; }
|
||||
.wk-driver-code { font-weight: 700; color: var(--wk-team, var(--text)); }
|
||||
.wk-driver-pts { color: var(--text-2); font-size: 12px; }
|
||||
|
||||
/* ── Reduced motion ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wk-cta, .wk-briefing-title, .wk-live-pulse, .wk-timeline-live .wk-timeline-dot { transition: none; }
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
|
||||
import { CommandCenterPage } from '../pages/CommandCenterPage'
|
||||
import type { DatasetInfo, Meeting, Weekend } from '../types'
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
fetchSeasons: vi.fn(),
|
||||
fetchLocalMeetings: vi.fn(),
|
||||
fetchSeasonMeetings: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
fetchWeekend: vi.fn(),
|
||||
fetchLiveState: vi.fn(),
|
||||
fetchChampionshipHub: vi.fn(),
|
||||
fetchRaceHub: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
fetchSeasons,
|
||||
fetchLocalMeetings,
|
||||
fetchSeasonMeetings,
|
||||
fetchSessions,
|
||||
fetchWeekend,
|
||||
fetchLiveState,
|
||||
fetchChampionshipHub,
|
||||
fetchRaceHub,
|
||||
} from '../api'
|
||||
|
||||
const mockFetchSeasons = vi.mocked(fetchSeasons)
|
||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||
const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
|
||||
const mockFetchSessions = vi.mocked(fetchSessions)
|
||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||
const mockFetchLiveState = vi.mocked(fetchLiveState)
|
||||
const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
|
||||
const mockFetchRaceHub = vi.mocked(fetchRaceHub)
|
||||
|
||||
const meeting: Meeting = {
|
||||
meeting_key: 1229,
|
||||
meeting_name: 'Monaco',
|
||||
meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
|
||||
location: 'Monaco',
|
||||
country_name: 'Monaco',
|
||||
country_code: 'MON',
|
||||
country_flag: '',
|
||||
circuit_short_name: 'Monaco',
|
||||
date_start: '2025-05-23T00:00:00+00:00',
|
||||
date_end: '2025-05-25T00:00:00+00:00',
|
||||
year: 2025,
|
||||
}
|
||||
|
||||
const fullDatasets: Record<string, DatasetInfo> = {
|
||||
meeting: { status: 'available', source: 'local', count: 1 },
|
||||
session: { status: 'available', source: 'local', count: 1 },
|
||||
drivers: { status: 'available', source: 'local', count: 20 },
|
||||
results: { status: 'available', source: 'local', count: 20 },
|
||||
starting_grid: { status: 'available', source: 'local', count: 20 },
|
||||
stints: { status: 'available', source: 'local', count: 2 },
|
||||
pit_stops: { status: 'available', source: 'local', count: 1 },
|
||||
positions: { status: 'available', source: 'local', count: 3 },
|
||||
race_control: { status: 'available', source: 'local', count: 1 },
|
||||
weather: { status: 'available', source: 'local', count: 1 },
|
||||
laps: { status: 'available', source: 'local', count: 1 },
|
||||
}
|
||||
|
||||
const weekend: Weekend = {
|
||||
source: 'local',
|
||||
meeting_key: 1229,
|
||||
meeting,
|
||||
default_session_key: 9472,
|
||||
sessions: [
|
||||
{
|
||||
session: {
|
||||
session_key: 9472,
|
||||
session_name: 'Race',
|
||||
session_type: 'Race',
|
||||
meeting_key: 1229,
|
||||
date_start: '2025-05-25T13:00:00+00:00',
|
||||
date_end: '2025-05-25T15:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
source: 'local',
|
||||
datasets: fullDatasets,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CommandCenterPage />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: CommandCenterPage,
|
||||
})
|
||||
|
||||
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||
|
||||
return render(<RouterProvider router={router} />)
|
||||
}
|
||||
|
||||
describe('CommandCenterPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
|
||||
mockFetchSessions.mockResolvedValue([])
|
||||
mockFetchChampionshipHub.mockResolvedValue({
|
||||
season: 2025,
|
||||
round: 1,
|
||||
total_rounds: 1,
|
||||
rounds_left: 0,
|
||||
last_race: 'Monaco',
|
||||
round_labels: ['R1'],
|
||||
drivers: [],
|
||||
teams: [],
|
||||
})
|
||||
mockFetchRaceHub.mockResolvedValue({
|
||||
source: 'local',
|
||||
session_key: 9472,
|
||||
datasets: fullDatasets,
|
||||
results: [
|
||||
{
|
||||
driver_number: 1,
|
||||
position: 1,
|
||||
name_acronym: 'VER',
|
||||
full_name: 'Max Verstappen',
|
||||
team_name: 'Red Bull Racing',
|
||||
team_colour: '3671c6',
|
||||
dnf: false,
|
||||
dns: false,
|
||||
dsq: false,
|
||||
duration: 7200,
|
||||
gap_to_leader: null,
|
||||
number_of_laps: 78,
|
||||
points: 25,
|
||||
session_key: 9472,
|
||||
meeting_key: 1229,
|
||||
},
|
||||
],
|
||||
starting_grid: [],
|
||||
drivers: [],
|
||||
stints: [],
|
||||
pit_stops: [],
|
||||
positions: [],
|
||||
race_control: [],
|
||||
weather: [],
|
||||
laps: [],
|
||||
chapters: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty state when no seasons are ingested', async () => {
|
||||
mockFetchSeasons.mockResolvedValue([])
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('command-center-empty')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('No local data yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows weekend identity band and schedule when data exists', async () => {
|
||||
mockFetchSeasons.mockResolvedValue([2025])
|
||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||
mockFetchSeasonMeetings.mockResolvedValue([meeting])
|
||||
mockFetchWeekend.mockResolvedValue(weekend)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('command-center')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
|
||||
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
|
||||
expect(screen.getByText('No live session')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
|
||||
})
|
||||
|
||||
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
|
||||
const canada = {
|
||||
...meeting,
|
||||
meeting_key: 1301,
|
||||
meeting_name: 'Canada',
|
||||
country_name: 'Canada',
|
||||
country_code: 'CAN',
|
||||
circuit_short_name: 'Montreal',
|
||||
date_start: '2026-05-22T00:00:00+00:00',
|
||||
date_end: '2026-05-24T23:59:59+00:00',
|
||||
year: 2026,
|
||||
}
|
||||
const monaco = {
|
||||
...meeting,
|
||||
meeting_key: 1302,
|
||||
meeting_name: 'Monaco',
|
||||
date_start: '2026-06-05T00:00:00+00:00',
|
||||
date_end: '2026-06-07T23:59:59+00:00',
|
||||
year: 2026,
|
||||
}
|
||||
|
||||
vi.setSystemTime(new Date('2026-06-06T15:15:00Z'))
|
||||
mockFetchSeasons.mockResolvedValue([2026])
|
||||
mockFetchLocalMeetings.mockResolvedValue([canada])
|
||||
mockFetchSeasonMeetings.mockResolvedValue([canada, monaco])
|
||||
mockFetchWeekend.mockResolvedValue({ ...weekend, meeting: canada, meeting_key: canada.meeting_key })
|
||||
mockFetchSessions.mockResolvedValue([
|
||||
{
|
||||
session_key: 9602,
|
||||
session_name: 'Qualifying',
|
||||
session_type: 'Qualifying',
|
||||
meeting_key: monaco.meeting_key,
|
||||
date_start: '2026-06-06T15:00:00+00:00',
|
||||
date_end: '2026-06-06T16:00:00+00:00',
|
||||
gmt_offset: '02:00:00',
|
||||
},
|
||||
])
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||
})
|
||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
|
||||
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
|
||||
expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -448,6 +448,120 @@ export interface DriverSummary {
|
||||
rounds: DriverSummaryRound[]
|
||||
}
|
||||
|
||||
// ── Weekend Context ──
|
||||
// Canonical contract for the adaptive Weekend home. Served by
|
||||
// /api/v1/weekend-context (sibling backend story #72) and, until that endpoint
|
||||
// ships, derived client-side from existing endpoints (see lib/weekendContext.ts).
|
||||
export type WeekendState =
|
||||
| 'loading'
|
||||
| 'error'
|
||||
| 'limited_data'
|
||||
| 'between_races'
|
||||
| 'pre_session'
|
||||
| 'between_sessions'
|
||||
| 'session_live'
|
||||
| 'session_settling'
|
||||
| 'post_weekend'
|
||||
| 'season_complete'
|
||||
|
||||
export interface WeekendPodiumEntry {
|
||||
position: number
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
team_name: string
|
||||
team_colour: string
|
||||
gap: string
|
||||
}
|
||||
|
||||
export type WeekendSessionStatus = 'done' | 'live' | 'next' | 'upcoming'
|
||||
|
||||
export interface WeekendTimelineSession {
|
||||
session_key: number
|
||||
session_name: string
|
||||
session_type: string
|
||||
date_start: string
|
||||
date_end: string
|
||||
status: WeekendSessionStatus
|
||||
}
|
||||
|
||||
export interface WeekendCompletedEvent {
|
||||
meeting_key: number
|
||||
meeting_name: string
|
||||
country_code: string
|
||||
country_flag: string
|
||||
circuit_short_name: string
|
||||
round?: number
|
||||
analysis_session_key: number
|
||||
analysis_session_name: string
|
||||
label: string
|
||||
podium: WeekendPodiumEntry[]
|
||||
story?: string
|
||||
}
|
||||
|
||||
export interface WeekendUpcomingEvent {
|
||||
meeting_key: number
|
||||
meeting_name: string
|
||||
country_code: string
|
||||
country_flag: string
|
||||
circuit_short_name: string
|
||||
circuit_key?: number
|
||||
round?: number
|
||||
date_start?: string
|
||||
next_session_name?: string
|
||||
next_session_start?: string
|
||||
}
|
||||
|
||||
export interface WeekendChampionshipMover {
|
||||
position: number
|
||||
driver_number: number
|
||||
name_acronym: string
|
||||
team_colour: string
|
||||
points: number
|
||||
delta?: number
|
||||
}
|
||||
|
||||
export interface WeekendChampionshipImpact {
|
||||
leaders: WeekendChampionshipMover[]
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface WeekendBriefingItem {
|
||||
category?: string
|
||||
title: string
|
||||
url: string
|
||||
source: string
|
||||
published_at?: string
|
||||
image_url?: string
|
||||
}
|
||||
|
||||
export type WeekendRoundStatus = 'completed' | 'next' | 'upcoming'
|
||||
|
||||
export interface WeekendSeasonRound {
|
||||
round: number
|
||||
meeting_key: number
|
||||
country_code: string
|
||||
country_flag: string
|
||||
status: WeekendRoundStatus
|
||||
analysis_session_key?: number
|
||||
}
|
||||
|
||||
export interface WeekendContext {
|
||||
state: WeekendState
|
||||
season: number
|
||||
message?: string
|
||||
live?: boolean
|
||||
active_meeting_name?: string
|
||||
active_circuit_short_name?: string
|
||||
last_event?: WeekendCompletedEvent
|
||||
next_event?: WeekendUpcomingEvent
|
||||
last_session?: WeekendCompletedEvent
|
||||
next_session?: WeekendTimelineSession
|
||||
sessions?: WeekendTimelineSession[]
|
||||
championship_impact?: WeekendChampionshipImpact
|
||||
season_rounds?: WeekendSeasonRound[]
|
||||
briefing?: WeekendBriefingItem[]
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
source: string
|
||||
title: string
|
||||
|
||||
Reference in New Issue
Block a user