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

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