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:
2026-07-12 18:38:08 -04:00
parent 06223d992f
commit 07e5857760
30 changed files with 1468 additions and 811 deletions

View File

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

View File

@@ -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&apos;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&apos;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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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