mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
feat(#73): Adaptive Weekend shell and responsive navigation
Implemented by claude via .agents/dev dispatch.
This commit is contained in:
@@ -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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user