feat(#73): Adaptive Weekend shell and responsive navigation

Implemented by claude via .agents/dev dispatch.
This commit is contained in:
2026-07-12 18:09:43 -04:00
parent ba55945b6f
commit 06223d992f
18 changed files with 1932 additions and 732 deletions

View File

@@ -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())

View File

@@ -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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'
}
}

View 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 }
}

View 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 })
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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,

View File

@@ -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;

View 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; }
}

View File

@@ -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()
})
})

View File

@@ -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