mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
fix(#73): consume canonical Weekend Context contract and repair navigation
Address the independent review blockers on PR #80 after rebasing onto the authoritative #72 Weekend Context API. - Replace the invented frontend WeekendContext with the exact backend contract (temporal_state, previous/focus/next meetings, previous_completed/active/next/ default_analysis sessions with availability). Every valid canonical payload now maps to a designed state via a total resolveViewState; a well-formed response can never fall through to the limited-data placeholder. - Make the canonical read the single source of truth: useWeekendContext no longer fans out to season/meetings/per-weekend/OpenF1/live queries. Only supplementary championship + news reads run, and only once the canonical context resolves. - Fix the Prepare/analysis flow: /preview is a stable alias that renders the preparation surface (PreSessionView) instead of redirecting back to the same between-races screen. - One primary navigation system per breakpoint: the mobile top-bar links are hidden so the bottom bar is the sole primary nav, and Admin is moved out of every Primary landmark into an operator-utilities toolbar. - Add Vitest coverage for the contract mapping, every temporal state, loading/ error/limited surfaces, the no-fanout guarantee, the /preview CTA, and the nav hierarchy; add hermetic Playwright journeys (seeded + injected canonical payloads), 390/768/1440 overflow checks, and Weekend visual snapshots. Retire the stale Command Center specs/snapshots. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,14 +8,23 @@ const PRIMARY = [
|
||||
{ to: '/explore', label: 'Explore', icon: Compass, exact: false },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Nav renders one primary navigation system per breakpoint:
|
||||
* - Desktop/tablet: the top bar's `aria-label="Primary"` links.
|
||||
* - Mobile (≤640px): the bottom `aria-label="Primary"` bar; the top bar's links
|
||||
* are hidden via CSS so the two are never both active at once.
|
||||
*
|
||||
* Admin is an operator utility, deliberately outside every Primary landmark — it
|
||||
* lives in a plain toolbar slot and never appears in the mobile bottom nav.
|
||||
*/
|
||||
export function Nav() {
|
||||
return (
|
||||
<>
|
||||
<nav className="app-nav" aria-label="Primary">
|
||||
<header className="app-nav">
|
||||
<Link to="/" className="nav-logo">
|
||||
box<em>-</em>box
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
<nav className="nav-links" aria-label="Primary">
|
||||
{PRIMARY.map(({ to, label, exact }) => (
|
||||
<Link
|
||||
key={to}
|
||||
@@ -26,8 +35,8 @@ export function Nav() {
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="nav-utility">
|
||||
</nav>
|
||||
<div className="nav-utility" role="toolbar" aria-label="Operator utilities">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="nav-utility-link"
|
||||
@@ -36,9 +45,9 @@ export function Nav() {
|
||||
Admin
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<nav className="app-bottom-nav" aria-label="Primary mobile">
|
||||
<nav className="app-bottom-nav" aria-label="Primary">
|
||||
{PRIMARY.map(({ to, label, icon: Icon, exact }) => (
|
||||
<Link
|
||||
key={to}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight, Flag as FlagIcon } from 'lucide-react'
|
||||
import type { WeekendContext } from '../../types'
|
||||
import type {
|
||||
WeekendBriefingItem,
|
||||
WeekendChampionshipImpact,
|
||||
WeekendContext,
|
||||
WeekendViewState,
|
||||
} from '../../types'
|
||||
import {
|
||||
BriefingStrip,
|
||||
ChampionshipImpactCard,
|
||||
ChampionshipRoundStrip,
|
||||
CountdownDisplay,
|
||||
EventPodium,
|
||||
Flag,
|
||||
SeasonNavStrip,
|
||||
} from './shared'
|
||||
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||
import { parseScheduleTime } from '../../lib/schedule'
|
||||
|
||||
const EYEBROW: Record<string, string> = {
|
||||
between_races: 'Between races',
|
||||
between_weekends: 'Between races',
|
||||
post_weekend: 'Post-weekend',
|
||||
season_complete: 'Season complete',
|
||||
}
|
||||
@@ -30,63 +36,85 @@ function formatSessionLine(name: string | undefined, start: string | undefined):
|
||||
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'
|
||||
export function BetweenRacesView({
|
||||
context,
|
||||
now,
|
||||
view,
|
||||
championship,
|
||||
briefing,
|
||||
}: {
|
||||
context: WeekendContext
|
||||
now: Date
|
||||
view: WeekendViewState
|
||||
championship?: WeekendChampionshipImpact
|
||||
briefing: WeekendBriefingItem[]
|
||||
}) {
|
||||
const eyebrow = EYEBROW[view] ?? 'Between races'
|
||||
|
||||
const previous = context.previous_completed_session
|
||||
const previousMeeting = meetingIdentity(previous?.meeting ?? context.previous_meeting)
|
||||
const nextMeeting = meetingIdentity(context.next_meeting)
|
||||
const nextSession = context.next_session?.session
|
||||
const analysisKey = analysisSessionKey(context)
|
||||
|
||||
return (
|
||||
<div className="wk-between" data-testid="weekend-between-races" data-state={context.state}>
|
||||
<div className="wk-between" data-testid="weekend-between-races" data-state={view}>
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">{eyebrow}</div>
|
||||
|
||||
<div className="wk-top-grid">
|
||||
{last_event && (
|
||||
{previousMeeting && (
|
||||
<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>
|
||||
<Flag code={previousMeeting.country_code} flag={previousMeeting.country_flag} />
|
||||
<h2 className="wk-event-name">{previousMeeting.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} />
|
||||
<EventPodium sessionKey={analysisKey} />
|
||||
<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>
|
||||
{analysisKey ? (
|
||||
<Link
|
||||
to="/race-hub"
|
||||
search={{ session_key: analysisKey }}
|
||||
className="wk-cta wk-cta-primary"
|
||||
data-testid="wk-explore-race-story"
|
||||
>
|
||||
Explore Race Story <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
) : (
|
||||
<p className="wk-story-text" data-testid="wk-no-analysis">
|
||||
Analysis for this session is not available locally yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{next_event ? (
|
||||
{nextMeeting ? (
|
||||
<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>
|
||||
<Flag code={nextMeeting.country_code} flag={nextMeeting.country_flag} />
|
||||
<h2 className="wk-event-name">{nextMeeting.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} />
|
||||
<CountdownDisplay target={nextSession?.date_start ?? nextMeeting.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)}
|
||||
{formatSessionLine(nextSession?.session_name, nextSession?.date_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, '')}
|
||||
Prepare for {nextMeeting.short_name}
|
||||
<ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -94,15 +122,15 @@ export function BetweenRacesView({ context, now }: { context: WeekendContext; no
|
||||
) : (
|
||||
<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>
|
||||
<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
|
||||
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">
|
||||
<Link to="/explore" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-season-complete-explore">
|
||||
Explore the season <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -111,11 +139,14 @@ export function BetweenRacesView({ context, now }: { context: WeekendContext; no
|
||||
</div>
|
||||
|
||||
<div className="wk-mid-grid">
|
||||
{championship_impact && <ChampionshipImpactCard impact={championship_impact} />}
|
||||
{season_rounds && season_rounds.length > 0 && <SeasonNavStrip rounds={season_rounds} />}
|
||||
{championship && <ChampionshipImpactCard impact={championship} />}
|
||||
<ChampionshipRoundStrip
|
||||
round={context.championship_round}
|
||||
total={context.total_championship_rounds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{briefing && briefing.length > 0 && <BriefingStrip items={briefing} />}
|
||||
{briefing.length > 0 && <BriefingStrip items={briefing} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import type { WeekendContext } from '../../types'
|
||||
import { BriefingStrip, CountdownDisplay, EventPodium, SessionTimeline } from './shared'
|
||||
import type { WeekendBriefingItem, WeekendContext, WeekendViewState } from '../../types'
|
||||
import { BriefingStrip, CountdownDisplay, EventPodium, SessionRail, railNodes } from './shared'
|
||||
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||
import { parseScheduleTime } from '../../lib/schedule'
|
||||
|
||||
function nextSessionWhen(start: string | undefined): string {
|
||||
@@ -15,53 +16,68 @@ function nextSessionWhen(start: string | undefined): string {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
export function BetweenSessionsView({
|
||||
context,
|
||||
now,
|
||||
view,
|
||||
briefing,
|
||||
}: {
|
||||
context: WeekendContext
|
||||
now: Date
|
||||
view: WeekendViewState
|
||||
briefing: WeekendBriefingItem[]
|
||||
}) {
|
||||
const settling = view === 'session_settling'
|
||||
const focus = meetingIdentity(context.focus_meeting)
|
||||
const previous = context.previous_completed_session
|
||||
const previousName = previous?.session.session_name ?? 'Last session'
|
||||
const next = context.next_session?.session
|
||||
const analysisKey = analysisSessionKey(context)
|
||||
const nodes = railNodes(previous, context.active_session, context.next_session)
|
||||
|
||||
return (
|
||||
<div className="wk-sessions" data-testid="weekend-between-sessions" data-state={context.state}>
|
||||
<div className="wk-sessions" data-testid="weekend-between-sessions" data-state={view}>
|
||||
<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>}
|
||||
<h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Race weekend'}</h1>
|
||||
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
|
||||
</header>
|
||||
|
||||
{sessions && <SessionTimeline sessions={sessions} />}
|
||||
<SessionRail nodes={nodes} />
|
||||
|
||||
{last_session && (
|
||||
{previous && (
|
||||
<section className="wk-recap-card" data-testid="wk-last-session">
|
||||
<span className="wk-recap-eyebrow mono">
|
||||
{last_session.label} · {settling ? 'Settling' : 'Complete'}
|
||||
{previousName} · {settling ? 'Settling' : 'Complete'}
|
||||
</span>
|
||||
<h2 className="wk-recap-title">What happened in {last_session.label}</h2>
|
||||
<EventPodium event={last_session} />
|
||||
<h2 className="wk-recap-title">What happened in {previousName}</h2>
|
||||
<EventPodium sessionKey={analysisKey} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{next_session && (
|
||||
{next && (
|
||||
<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 && (
|
||||
<h2 className="wk-upnext-title">{next.session_name}</h2>
|
||||
<CountdownDisplay target={next.date_start} now={now} compact />
|
||||
<p className="wk-upnext-when mono">{nextSessionWhen(next.date_start)}</p>
|
||||
{analysisKey && (
|
||||
<Link
|
||||
to="/race-hub"
|
||||
search={{ session_key: last_session.analysis_session_key }}
|
||||
search={{ session_key: analysisKey }}
|
||||
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" />
|
||||
View {previousName} recap <ChevronRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{context.briefing && context.briefing.length > 0 && <BriefingStrip items={context.briefing} />}
|
||||
{briefing.length > 0 && <BriefingStrip items={briefing} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronRight, Radio } from 'lucide-react'
|
||||
import type { WeekendContext } from '../../types'
|
||||
import { EventPodium, Flag, SessionTimeline } from './shared'
|
||||
import { EventPodium, Flag, SessionRail, railNodes } from './shared'
|
||||
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
|
||||
|
||||
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')
|
||||
const focus = meetingIdentity(context.focus_meeting ?? context.active_session?.meeting)
|
||||
const active = context.active_session?.session
|
||||
const previous = context.previous_completed_session
|
||||
const previousName = previous?.session.session_name ?? 'Last session'
|
||||
const analysisKey = analysisSessionKey(context)
|
||||
const nodes = railNodes(previous, context.active_session, context.next_session)
|
||||
|
||||
return (
|
||||
<div className="wk-live" data-testid="weekend-live" data-state={context.state}>
|
||||
<div className="wk-live" data-testid="weekend-live" data-state="session_live">
|
||||
<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>
|
||||
<Flag code={focus?.country_code} flag={focus?.country_flag} />
|
||||
<h1 className="wk-sessions-title">{focus?.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>}
|
||||
{focus?.circuit_short_name && <p className="wk-sessions-circuit mono">{focus.circuit_short_name}</p>}
|
||||
<p className="wk-live-lead">
|
||||
{liveSession ? `${liveSession.session_name} is on track now.` : 'A session is running now.'}
|
||||
{active?.session_name ? `${active.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} />}
|
||||
<SessionRail nodes={nodes} />
|
||||
|
||||
{last_session && (
|
||||
{previous && (
|
||||
<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} />
|
||||
<span className="wk-recap-eyebrow mono">{previousName} · Complete</span>
|
||||
<h2 className="wk-recap-title">What happened in {previousName}</h2>
|
||||
<EventPodium sessionKey={analysisKey} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,31 +2,37 @@ 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'
|
||||
import { ChampionshipRoundStrip, CountdownDisplay, Flag, SessionRail, railNodes } from './shared'
|
||||
import { meetingIdentity } from '../../lib/weekendContext'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* frames it with the next-session countdown and compact season navigation. It also
|
||||
* backs the /preview alias, so saved preview links resolve here instead of looping.
|
||||
*/
|
||||
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
|
||||
const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting)
|
||||
const next = context.next_session?.session
|
||||
const nextStart = next?.date_start ?? meeting?.date_start
|
||||
const nodes = railNodes(
|
||||
context.previous_completed_session,
|
||||
context.active_session,
|
||||
context.next_session,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="wk-pre" data-testid="weekend-pre-session" data-state={context.state}>
|
||||
<div className="wk-pre" data-testid="weekend-pre-session" data-state="pre_session">
|
||||
<div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
|
||||
|
||||
{next && (
|
||||
{meeting && (
|
||||
<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>
|
||||
<Flag code={meeting.country_code} flag={meeting.country_flag} />
|
||||
<h1 className="wk-sessions-title">{meeting.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>
|
||||
<span className="wk-next-label mono">{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">
|
||||
@@ -35,15 +41,16 @@ export function PreSessionView({ context, now }: { context: WeekendContext; now:
|
||||
</header>
|
||||
)}
|
||||
|
||||
{context.sessions && context.sessions.length > 0 && <SessionTimeline sessions={context.sessions} />}
|
||||
{nodes.length > 0 && <SessionRail nodes={nodes} />}
|
||||
|
||||
<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} />
|
||||
)}
|
||||
<ChampionshipRoundStrip
|
||||
round={context.championship_round}
|
||||
total={context.total_championship_rounds}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function WeekendError({ message }: { message?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function WeekendLimited({ message, season }: { message?: string; season: number }) {
|
||||
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>
|
||||
|
||||
@@ -6,12 +6,10 @@ import { parseScheduleTime } from '../../lib/schedule'
|
||||
import { podiumFromResults } from '../../lib/weekendContext'
|
||||
import { teamColor } from '../../utils'
|
||||
import type {
|
||||
ContextSession,
|
||||
WeekendChampionshipImpact,
|
||||
WeekendCompletedEvent,
|
||||
WeekendBriefingItem,
|
||||
WeekendPodiumEntry,
|
||||
WeekendSeasonRound,
|
||||
WeekendTimelineSession,
|
||||
} from '../../types'
|
||||
|
||||
export function Flag({ code, flag }: { code?: string; flag?: string }) {
|
||||
@@ -53,7 +51,13 @@ export function CountdownDisplay({
|
||||
compact?: boolean
|
||||
}) {
|
||||
const parts = countdownParts(target, now)
|
||||
if (!parts) return null
|
||||
if (!parts) {
|
||||
return (
|
||||
<span className="wk-countdown-tbc mono" data-testid="wk-countdown">
|
||||
Schedule TBC
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (parts.reached) {
|
||||
return <span className="wk-countdown-live" data-testid="wk-countdown">Starting now</span>
|
||||
}
|
||||
@@ -74,17 +78,29 @@ export function CountdownDisplay({
|
||||
)
|
||||
}
|
||||
|
||||
export function EventPodium({ event }: { event: WeekendCompletedEvent }) {
|
||||
const needsFetch = event.podium.length === 0 && event.analysis_session_key > 0
|
||||
/**
|
||||
* EventPodium fetches and renders the podium for a completed analysis session.
|
||||
* When no analysable session key is available (e.g. an archived result with no
|
||||
* local analysis), it renders an explicit empty state rather than fetching.
|
||||
*/
|
||||
export function EventPodium({ sessionKey }: { sessionKey?: number }) {
|
||||
const enabled = typeof sessionKey === 'number' && sessionKey > 0
|
||||
const query = useQuery({
|
||||
queryKey: ['race-hub', event.analysis_session_key, 'podium'],
|
||||
queryFn: () => fetchRaceHub(event.analysis_session_key),
|
||||
enabled: needsFetch,
|
||||
queryKey: ['race-hub', sessionKey, 'podium'],
|
||||
queryFn: () => fetchRaceHub(sessionKey as number),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const podium: WeekendPodiumEntry[] =
|
||||
event.podium.length > 0 ? event.podium : podiumFromResults(query.data?.results ?? [])
|
||||
const podium: WeekendPodiumEntry[] = podiumFromResults(query.data?.results ?? [])
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="wk-podium-empty" data-testid="wk-podium-empty">
|
||||
Result not available yet.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (podium.length === 0) {
|
||||
return (
|
||||
@@ -156,64 +172,87 @@ export function BriefingStrip({ items }: { items: WeekendBriefingItem[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SeasonNavStrip({ rounds }: { rounds: WeekendSeasonRound[] }) {
|
||||
if (rounds.length === 0) return null
|
||||
/**
|
||||
* ChampionshipRoundStrip is a compact, progressively-disclosed round indicator.
|
||||
* The canonical contract exposes only the current round and total, so the strip
|
||||
* shows "Round N of M" and links out to Explore for the full calendar rather than
|
||||
* reintroducing the whole calendar on the Weekend home.
|
||||
*/
|
||||
export function ChampionshipRoundStrip({ round, total }: { round: number; total: number }) {
|
||||
if (round <= 0 || total <= 0) return null
|
||||
const pct = Math.min(100, Math.round((round / total) * 100))
|
||||
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>
|
||||
<section className="wk-season-nav" data-testid="wk-season-nav" aria-label="Season progress">
|
||||
<div className="wk-season-progress">
|
||||
<div className="wk-season-progress-head">
|
||||
<span className="wk-card-title">Season progress</span>
|
||||
<Link to="/explore" className="wk-card-link" data-testid="wk-season-explore">
|
||||
Full calendar →
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
className="wk-season-bar"
|
||||
role="progressbar"
|
||||
aria-valuenow={round}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={total}
|
||||
aria-label={`Round ${round} of ${total}`}
|
||||
>
|
||||
<span className="wk-season-bar-fill" style={{ width: `${pct}%` }} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="wk-season-progress-label mono">
|
||||
Round {round} of {total}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionTimeline({ sessions }: { sessions: WeekendTimelineSession[] }) {
|
||||
if (sessions.length === 0) return null
|
||||
interface TimelineNode {
|
||||
key: string
|
||||
session_name: string
|
||||
status: 'done' | 'live' | 'next'
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionRail renders the previous/active/next sessions the canonical context
|
||||
* exposes as a compact three-node rail. It is intentionally derived only from the
|
||||
* canonical refs — the contract does not enumerate a full weekend session list.
|
||||
*/
|
||||
export function SessionRail({ nodes }: { nodes: TimelineNode[] }) {
|
||||
if (nodes.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">
|
||||
{nodes.map((n) => (
|
||||
<li key={n.key} className={`wk-timeline-node wk-timeline-${n.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>
|
||||
<span className="wk-timeline-name">{shortSessionName(n.session_name)}</span>
|
||||
<span className="wk-timeline-state mono">{stateLabel(n.status)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
/** Build the compact session rail from the canonical previous/active/next refs. */
|
||||
export function railNodes(
|
||||
previous: ContextSession | undefined,
|
||||
active: ContextSession | undefined,
|
||||
next: ContextSession | undefined,
|
||||
): TimelineNode[] {
|
||||
const nodes: TimelineNode[] = []
|
||||
if (previous?.session.session_name) {
|
||||
nodes.push({ key: `prev-${previous.session.session_key}`, session_name: previous.session.session_name, status: 'done' })
|
||||
}
|
||||
if (active?.session.session_name) {
|
||||
nodes.push({ key: `live-${active.session.session_key}`, session_name: active.session.session_name, status: 'live' })
|
||||
}
|
||||
if (next?.session.session_name) {
|
||||
nodes.push({ key: `next-${next.session.session_key}`, session_name: next.session.session_name, status: 'next' })
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
function shortSessionName(name: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'Practice 1': 'FP1',
|
||||
@@ -226,15 +265,13 @@ function shortSessionName(name: string): string {
|
||||
return map[name] ?? name
|
||||
}
|
||||
|
||||
function stateLabel(status: WeekendTimelineSession['status']): string {
|
||||
function stateLabel(status: TimelineNode['status']): string {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return 'Complete'
|
||||
case 'live':
|
||||
return 'Live'
|
||||
case 'next':
|
||||
return 'Next'
|
||||
default:
|
||||
return 'Upcoming'
|
||||
return 'Next'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user