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

@@ -116,17 +116,12 @@ export async function fetchWeekend(meetingKey: number): Promise<Weekend> {
} }
// fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint // fetchWeekendContext consumes the canonical /api/v1/weekend-context endpoint
// (sibling backend story #72). It resolves to null when the endpoint is not yet // (backend story #72). The response is the authoritative WeekendContext shape and
// available (404 / server without the handler) so the Weekend page can fall back // is used verbatim as the Weekend home's source of truth. Any HTTP error throws
// to client-side derivation from existing endpoints. Any other HTTP error throws. // so the hook can surface an explicit error state; there is no client-side
export async function fetchWeekendContext(): Promise<WeekendContext | null> { // re-derivation of the contract.
let res: Response export async function fetchWeekendContext(): Promise<WeekendContext> {
try { const res = await fetch('/api/v1/weekend-context')
res = await fetch('/api/v1/weekend-context')
} catch {
return null
}
if (res.status === 404) return null
if (!res.ok) { if (!res.ok) {
throw new Error(`API ${res.status}: ${res.statusText}`) throw new Error(`API ${res.status}: ${res.statusText}`)
} }

View File

@@ -8,14 +8,23 @@ const PRIMARY = [
{ to: '/explore', label: 'Explore', icon: Compass, exact: false }, { to: '/explore', label: 'Explore', icon: Compass, exact: false },
] as const ] 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() { export function Nav() {
return ( return (
<> <>
<nav className="app-nav" aria-label="Primary"> <header className="app-nav">
<Link to="/" className="nav-logo"> <Link to="/" className="nav-logo">
box<em>-</em>box box<em>-</em>box
</Link> </Link>
<div className="nav-links"> <nav className="nav-links" aria-label="Primary">
{PRIMARY.map(({ to, label, exact }) => ( {PRIMARY.map(({ to, label, exact }) => (
<Link <Link
key={to} key={to}
@@ -26,8 +35,8 @@ export function Nav() {
{label} {label}
</Link> </Link>
))} ))}
</div> </nav>
<div className="nav-utility"> <div className="nav-utility" role="toolbar" aria-label="Operator utilities">
<Link <Link
to="/admin" to="/admin"
className="nav-utility-link" className="nav-utility-link"
@@ -36,9 +45,9 @@ export function Nav() {
Admin Admin
</Link> </Link>
</div> </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 }) => ( {PRIMARY.map(({ to, label, icon: Icon, exact }) => (
<Link <Link
key={to} key={to}

View File

@@ -1,18 +1,24 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { ChevronRight, Flag as FlagIcon } from 'lucide-react' import { ChevronRight, Flag as FlagIcon } from 'lucide-react'
import type { WeekendContext } from '../../types' import type {
WeekendBriefingItem,
WeekendChampionshipImpact,
WeekendContext,
WeekendViewState,
} from '../../types'
import { import {
BriefingStrip, BriefingStrip,
ChampionshipImpactCard, ChampionshipImpactCard,
ChampionshipRoundStrip,
CountdownDisplay, CountdownDisplay,
EventPodium, EventPodium,
Flag, Flag,
SeasonNavStrip,
} from './shared' } from './shared'
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
import { parseScheduleTime } from '../../lib/schedule' import { parseScheduleTime } from '../../lib/schedule'
const EYEBROW: Record<string, string> = { const EYEBROW: Record<string, string> = {
between_races: 'Between races', between_weekends: 'Between races',
post_weekend: 'Post-weekend', post_weekend: 'Post-weekend',
season_complete: 'Season complete', season_complete: 'Season complete',
} }
@@ -30,63 +36,85 @@ function formatSessionLine(name: string | undefined, start: string | undefined):
return `${name} · ${when}` return `${name} · ${when}`
} }
export function BetweenRacesView({ context, now }: { context: WeekendContext; now: Date }) { export function BetweenRacesView({
const { last_event, next_event, championship_impact, season_rounds, briefing } = context context,
const eyebrow = EYEBROW[context.state] ?? 'Between races' 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 ( 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-eyebrow mono" data-testid="wk-eyebrow">{eyebrow}</div>
<div className="wk-top-grid"> <div className="wk-top-grid">
{last_event && ( {previousMeeting && (
<section className="wk-event-card wk-event-last" data-testid="wk-last-event"> <section className="wk-event-card wk-event-last" data-testid="wk-last-event">
<header className="wk-event-head"> <header className="wk-event-head">
<div className="wk-event-id"> <div className="wk-event-id">
<Flag code={last_event.country_code} flag={last_event.country_flag} /> <Flag code={previousMeeting.country_code} flag={previousMeeting.country_flag} />
<h2 className="wk-event-name">{last_event.meeting_name}</h2> <h2 className="wk-event-name">{previousMeeting.meeting_name}</h2>
</div> </div>
<span className="wk-event-tag wk-tag-done mono"> <span className="wk-event-tag wk-tag-done mono">
<FlagIcon size={13} aria-hidden="true" /> Completed <FlagIcon size={13} aria-hidden="true" /> Completed
</span> </span>
</header> </header>
<div className="wk-event-body"> <div className="wk-event-body">
<EventPodium event={last_event} /> <EventPodium sessionKey={analysisKey} />
<div className="wk-story-card"> <div className="wk-story-card">
<span className="wk-story-label">What decided it?</span> <span className="wk-story-label">What decided it?</span>
{last_event.story && <p className="wk-story-text">{last_event.story}</p>} {analysisKey ? (
<Link <Link
to="/race-hub" to="/race-hub"
search={{ session_key: last_event.analysis_session_key }} search={{ session_key: analysisKey }}
className="wk-cta wk-cta-primary" className="wk-cta wk-cta-primary"
data-testid="wk-explore-race-story" data-testid="wk-explore-race-story"
> >
Explore Race Story <ChevronRight size={15} aria-hidden="true" /> Explore Race Story <ChevronRight size={15} aria-hidden="true" />
</Link> </Link>
) : (
<p className="wk-story-text" data-testid="wk-no-analysis">
Analysis for this session is not available locally yet.
</p>
)}
</div> </div>
</div> </div>
</section> </section>
)} )}
{next_event ? ( {nextMeeting ? (
<section className="wk-event-card wk-event-next" data-testid="wk-next-event"> <section className="wk-event-card wk-event-next" data-testid="wk-next-event">
<header className="wk-event-head"> <header className="wk-event-head">
<div className="wk-event-id"> <div className="wk-event-id">
<Flag code={next_event.country_code} flag={next_event.country_flag} /> <Flag code={nextMeeting.country_code} flag={nextMeeting.country_flag} />
<h2 className="wk-event-name">{next_event.meeting_name}</h2> <h2 className="wk-event-name">{nextMeeting.meeting_name}</h2>
</div> </div>
<span className="wk-event-tag wk-tag-next mono">Next event</span> <span className="wk-event-tag wk-tag-next mono">Next event</span>
</header> </header>
<div className="wk-next-body"> <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"> <div className="wk-next-session">
<span className="wk-next-label mono">Next session</span> <span className="wk-next-label mono">Next session</span>
<span className="wk-next-value"> <span className="wk-next-value">
{formatSessionLine(next_event.next_session_name, next_event.next_session_start)} {formatSessionLine(nextSession?.session_name, nextSession?.date_start)}
</span> </span>
</div> </div>
<Link to="/preview" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-prepare"> <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" /> <ChevronRight size={15} aria-hidden="true" />
</Link> </Link>
</div> </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"> <section className="wk-event-card wk-event-next" data-testid="wk-season-complete-card">
<header className="wk-event-head"> <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> <span className="wk-event-tag wk-tag-next mono">Off-season</span>
</header> </header>
<div className="wk-next-body"> <div className="wk-next-body">
<p className="wk-season-complete-copy"> <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. battle while the next schedule is confirmed.
</p> </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" /> Explore the season <ChevronRight size={15} aria-hidden="true" />
</Link> </Link>
</div> </div>
@@ -111,11 +139,14 @@ export function BetweenRacesView({ context, now }: { context: WeekendContext; no
</div> </div>
<div className="wk-mid-grid"> <div className="wk-mid-grid">
{championship_impact && <ChampionshipImpactCard impact={championship_impact} />} {championship && <ChampionshipImpactCard impact={championship} />}
{season_rounds && season_rounds.length > 0 && <SeasonNavStrip rounds={season_rounds} />} <ChampionshipRoundStrip
round={context.championship_round}
total={context.total_championship_rounds}
/>
</div> </div>
{briefing && briefing.length > 0 && <BriefingStrip items={briefing} />} {briefing.length > 0 && <BriefingStrip items={briefing} />}
</div> </div>
) )
} }

View File

@@ -1,7 +1,8 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react' import { ChevronRight } from 'lucide-react'
import type { WeekendContext } from '../../types' import type { WeekendBriefingItem, WeekendContext, WeekendViewState } from '../../types'
import { BriefingStrip, CountdownDisplay, EventPodium, SessionTimeline } from './shared' import { BriefingStrip, CountdownDisplay, EventPodium, SessionRail, railNodes } from './shared'
import { analysisSessionKey, meetingIdentity } from '../../lib/weekendContext'
import { parseScheduleTime } from '../../lib/schedule' import { parseScheduleTime } from '../../lib/schedule'
function nextSessionWhen(start: string | undefined): string { 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 }) { export function BetweenSessionsView({
const settling = context.state === 'session_settling' context,
const { active_meeting_name, active_circuit_short_name, sessions, last_session, next_session } = 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 ( 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"> <div className="wk-eyebrow mono" data-testid="wk-eyebrow">
{settling ? 'Session settling' : 'Between sessions'} {settling ? 'Session settling' : 'Between sessions'}
</div> </div>
<header className="wk-sessions-head"> <header className="wk-sessions-head">
<h1 className="wk-sessions-title">{active_meeting_name ?? 'Race weekend'}</h1> <h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Race weekend'}</h1>
{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>}
</header> </header>
{sessions && <SessionTimeline sessions={sessions} />} <SessionRail nodes={nodes} />
{last_session && ( {previous && (
<section className="wk-recap-card" data-testid="wk-last-session"> <section className="wk-recap-card" data-testid="wk-last-session">
<span className="wk-recap-eyebrow mono"> <span className="wk-recap-eyebrow mono">
{last_session.label} · {settling ? 'Settling' : 'Complete'} {previousName} · {settling ? 'Settling' : 'Complete'}
</span> </span>
<h2 className="wk-recap-title">What happened in {last_session.label}</h2> <h2 className="wk-recap-title">What happened in {previousName}</h2>
<EventPodium event={last_session} /> <EventPodium sessionKey={analysisKey} />
</section> </section>
)} )}
{next_session && ( {next && (
<section className="wk-upnext-card" data-testid="wk-next-session"> <section className="wk-upnext-card" data-testid="wk-next-session">
<span className="wk-upnext-eyebrow mono">Up next</span> <span className="wk-upnext-eyebrow mono">Up next</span>
<h2 className="wk-upnext-title">{next_session.session_name}</h2> <h2 className="wk-upnext-title">{next.session_name}</h2>
<CountdownDisplay target={next_session.date_start} now={now} compact /> <CountdownDisplay target={next.date_start} now={now} compact />
<p className="wk-upnext-when mono">{nextSessionWhen(next_session.date_start)}</p> <p className="wk-upnext-when mono">{nextSessionWhen(next.date_start)}</p>
{last_session && ( {analysisKey && (
<Link <Link
to="/race-hub" to="/race-hub"
search={{ session_key: last_session.analysis_session_key }} search={{ session_key: analysisKey }}
className="wk-cta wk-cta-primary wk-cta-wide" className="wk-cta wk-cta-primary wk-cta-wide"
data-testid="wk-view-recap" 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> </Link>
)} )}
</section> </section>
)} )}
{context.briefing && context.briefing.length > 0 && <BriefingStrip items={context.briefing} />} {briefing.length > 0 && <BriefingStrip items={briefing} />}
</div> </div>
) )
} }

View File

@@ -1,40 +1,45 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { ChevronRight, Radio } from 'lucide-react' import { ChevronRight, Radio } from 'lucide-react'
import type { WeekendContext } from '../../types' 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 }) { export function LiveHandoffView({ context }: { context: WeekendContext }) {
const { active_meeting_name, active_circuit_short_name, sessions, last_session } = context const focus = meetingIdentity(context.focus_meeting ?? context.active_session?.meeting)
const liveSession = sessions?.find((s) => s.status === 'live') 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 ( 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> <div className="wk-eyebrow mono" data-testid="wk-eyebrow">Session live</div>
<section className="wk-live-card" data-testid="wk-live-card"> <section className="wk-live-card" data-testid="wk-live-card">
<div className="wk-live-head"> <div className="wk-live-head">
<div className="wk-event-id"> <div className="wk-event-id">
<Flag code={context.last_event?.country_code} flag={context.last_event?.country_flag} /> <Flag code={focus?.country_code} flag={focus?.country_flag} />
<h1 className="wk-sessions-title">{active_meeting_name ?? 'Live session'}</h1> <h1 className="wk-sessions-title">{focus?.meeting_name ?? 'Live session'}</h1>
</div> </div>
<span className="wk-live-pulse" aria-hidden="true" /> <span className="wk-live-pulse" aria-hidden="true" />
</div> </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"> <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> </p>
<Link to="/live" className="wk-cta wk-cta-primary wk-cta-wide" data-testid="wk-watch-live"> <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" /> <Radio size={15} aria-hidden="true" /> Watch live timing <ChevronRight size={15} aria-hidden="true" />
</Link> </Link>
</section> </section>
{sessions && <SessionTimeline sessions={sessions} />} <SessionRail nodes={nodes} />
{last_session && ( {previous && (
<section className="wk-recap-card" data-testid="wk-last-session"> <section className="wk-recap-card" data-testid="wk-last-session">
<span className="wk-recap-eyebrow mono">{last_session.label} · Complete</span> <span className="wk-recap-eyebrow mono">{previousName} · Complete</span>
<h2 className="wk-recap-title">What happened in {last_session.label}</h2> <h2 className="wk-recap-title">What happened in {previousName}</h2>
<EventPodium event={last_session} /> <EventPodium sessionKey={analysisKey} />
</section> </section>
)} )}
</div> </div>

View File

@@ -2,31 +2,37 @@ import { Link } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react' import { ChevronRight } from 'lucide-react'
import type { WeekendContext } from '../../types' import type { WeekendContext } from '../../types'
import { RacePreviewPage } from '../../pages/RacePreviewPage' 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 * 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 * 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 }) { export function PreSessionView({ context, now }: { context: WeekendContext; now: Date }) {
const next = context.next_event const meeting = meetingIdentity(context.next_meeting ?? context.focus_meeting)
const nextStart = context.next_session?.date_start ?? next?.next_session_start ?? next?.date_start 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 ( 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> <div className="wk-eyebrow mono" data-testid="wk-eyebrow">Pre-session</div>
{next && ( {meeting && (
<header className="wk-pre-head" data-testid="wk-pre-head"> <header className="wk-pre-head" data-testid="wk-pre-head">
<div className="wk-event-id"> <div className="wk-event-id">
<Flag code={next.country_code} flag={next.country_flag} /> <Flag code={meeting.country_code} flag={meeting.country_flag} />
<h1 className="wk-sessions-title">{next.meeting_name}</h1> <h1 className="wk-sessions-title">{meeting.meeting_name}</h1>
</div> </div>
<div className="wk-pre-countdown"> <div className="wk-pre-countdown">
<span className="wk-next-label mono"> <span className="wk-next-label mono">{next?.session_name ?? 'Next session'}</span>
{context.next_session?.session_name ?? next.next_session_name ?? 'Next session'}
</span>
<CountdownDisplay target={nextStart} now={now} /> <CountdownDisplay target={nextStart} now={now} />
</div> </div>
<Link to="/live" className="wk-cta wk-cta-ghost" data-testid="wk-pre-live"> <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> </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"> <div className="wk-pre-preview" data-testid="wk-pre-preview">
<RacePreviewPage /> <RacePreviewPage />
</div> </div>
{context.season_rounds && context.season_rounds.length > 0 && ( <ChampionshipRoundStrip
<SeasonNavStrip rounds={context.season_rounds} /> round={context.championship_round}
)} total={context.total_championship_rounds}
/>
</div> </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 ( return (
<div className="wk-status wk-status-limited" data-testid="weekend-limited"> <div className="wk-status wk-status-limited" data-testid="weekend-limited">
<span className="wk-status-eyebrow mono">box-box · weekend</span> <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 { podiumFromResults } from '../../lib/weekendContext'
import { teamColor } from '../../utils' import { teamColor } from '../../utils'
import type { import type {
ContextSession,
WeekendChampionshipImpact, WeekendChampionshipImpact,
WeekendCompletedEvent,
WeekendBriefingItem, WeekendBriefingItem,
WeekendPodiumEntry, WeekendPodiumEntry,
WeekendSeasonRound,
WeekendTimelineSession,
} from '../../types' } from '../../types'
export function Flag({ code, flag }: { code?: string; flag?: string }) { export function Flag({ code, flag }: { code?: string; flag?: string }) {
@@ -53,7 +51,13 @@ export function CountdownDisplay({
compact?: boolean compact?: boolean
}) { }) {
const parts = countdownParts(target, now) 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) { if (parts.reached) {
return <span className="wk-countdown-live" data-testid="wk-countdown">Starting now</span> 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({ const query = useQuery({
queryKey: ['race-hub', event.analysis_session_key, 'podium'], queryKey: ['race-hub', sessionKey, 'podium'],
queryFn: () => fetchRaceHub(event.analysis_session_key), queryFn: () => fetchRaceHub(sessionKey as number),
enabled: needsFetch, enabled,
staleTime: 60_000, staleTime: 60_000,
}) })
const podium: WeekendPodiumEntry[] = const podium: WeekendPodiumEntry[] = podiumFromResults(query.data?.results ?? [])
event.podium.length > 0 ? event.podium : 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) { if (podium.length === 0) {
return ( 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 ( return (
<section className="wk-season-nav" data-testid="wk-season-nav" aria-label="Season calendar"> <section className="wk-season-nav" data-testid="wk-season-nav" aria-label="Season progress">
<ol className="wk-season-strip" role="list"> <div className="wk-season-progress">
{rounds.map((round) => { <div className="wk-season-progress-head">
const inner = ( <span className="wk-card-title">Season progress</span>
<> <Link to="/explore" className="wk-card-link" data-testid="wk-season-explore">
<span className="wk-round-num mono">R{String(round.round).padStart(2, '0')}</span> Full calendar
<span className="wk-round-flag">{countryFlag({ country_code: round.country_code, country_flag: round.country_flag }) || round.country_code}</span> </Link>
<span className={`wk-round-dot wk-round-${round.status}`} aria-hidden="true" /> </div>
</> <div
) className="wk-season-bar"
const className = `wk-round wk-round-status-${round.status}` role="progressbar"
return ( aria-valuenow={round}
<li key={round.meeting_key} className="wk-round-item" role="listitem"> aria-valuemin={1}
{round.analysis_session_key ? ( aria-valuemax={total}
<Link aria-label={`Round ${round} of ${total}`}
to="/race-hub" >
search={{ session_key: round.analysis_session_key }} <span className="wk-season-bar-fill" style={{ width: `${pct}%` }} aria-hidden="true" />
className={className} </div>
aria-label={`Round ${round.round} ${round.country_code}${round.status}`} <p className="wk-season-progress-label mono">
> Round {round} of {total}
{inner} </p>
</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> </div>
</section> </section>
) )
} }
export function SessionTimeline({ sessions }: { sessions: WeekendTimelineSession[] }) { interface TimelineNode {
if (sessions.length === 0) return null 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 ( return (
<ol className="wk-timeline" data-testid="wk-timeline" role="list"> <ol className="wk-timeline" data-testid="wk-timeline" role="list">
{sessions.map((s) => ( {nodes.map((n) => (
<li key={s.session_key} className={`wk-timeline-node wk-timeline-${s.status}`} role="listitem"> <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-dot" aria-hidden="true" />
<span className="wk-timeline-name">{shortSessionName(s.session_name)}</span> <span className="wk-timeline-name">{shortSessionName(n.session_name)}</span>
<span className="wk-timeline-state mono">{stateLabel(s.status)}</span> <span className="wk-timeline-state mono">{stateLabel(n.status)}</span>
</li> </li>
))} ))}
</ol> </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 { function shortSessionName(name: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
'Practice 1': 'FP1', 'Practice 1': 'FP1',
@@ -226,15 +265,13 @@ function shortSessionName(name: string): string {
return map[name] ?? name return map[name] ?? name
} }
function stateLabel(status: WeekendTimelineSession['status']): string { function stateLabel(status: TimelineNode['status']): string {
switch (status) { switch (status) {
case 'done': case 'done':
return 'Complete' return 'Complete'
case 'live': case 'live':
return 'Live' return 'Live'
case 'next':
return 'Next'
default: default:
return 'Upcoming' return 'Next'
} }
} }

View File

@@ -1,32 +1,38 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { import { fetchChampionshipHub, fetchNews, fetchWeekendContext } from '../api'
fetchChampionshipHub, import { championshipImpact, briefingItems } from '../lib/weekendContext'
fetchLiveState, import type {
fetchLocalMeetings, WeekendChampionshipImpact,
fetchNews, WeekendBriefingItem,
fetchSeasonMeetings, WeekendContext,
fetchSeasons, } from '../types'
fetchSessions,
fetchWeekend, export type WeekendLoadState = 'loading' | 'error' | 'ready'
fetchWeekendContext,
} from '../api'
import { pickFocusMeeting } from '../lib/schedule'
import { deriveWeekendContext } from '../lib/weekendContext'
import type { NewsItem, Weekend, WeekendContext } from '../types'
export interface UseWeekendContextResult { export interface UseWeekendContextResult {
context: WeekendContext /** Canonical context, present only when loadState === 'ready'. */
/** True when the payload came from the canonical /api/v1/weekend-context endpoint. */ context: WeekendContext | null
fromEndpoint: boolean loadState: WeekendLoadState
error?: Error
/** Supplementary championship movers (not part of the #72 contract). */
championship?: WeekendChampionshipImpact
/** Supplementary briefing items (not part of the #72 contract). */
briefing: WeekendBriefingItem[]
now: Date now: Date
} }
/** /**
* useWeekendContext prefers the canonical /api/v1/weekend-context endpoint and * useWeekendContext reads the canonical /api/v1/weekend-context endpoint as the
* falls back to deriving the same contract client-side from existing endpoints * single source of truth for the Weekend home. When the canonical read succeeds
* so the Weekend home works even when the backend handler (sibling story #72) is * it layers on two pieces of supplementary data that the contract intentionally
* not yet deployed. * omits — championship movers and the paddock briefing — and nothing else.
*
* It deliberately does NOT fan out to season / meetings / per-weekend / OpenF1
* session / live-state queries: those would defeat the local-first canonical read
* model and can hit the rate-limited OpenF1 REST surface during active sessions.
* Supplementary queries are gated on a successful canonical read so a failing or
* pending endpoint issues no extra requests.
*/ */
export function useWeekendContext(): UseWeekendContextResult { export function useWeekendContext(): UseWeekendContextResult {
const [now, setNow] = useState(() => Date.now()) const [now, setNow] = useState(() => Date.now())
@@ -42,107 +48,42 @@ export function useWeekendContext(): UseWeekendContextResult {
staleTime: 30_000, staleTime: 30_000,
}) })
const seasonsQuery = useQuery({ queryKey: ['seasons'], queryFn: fetchSeasons }) const canonical = contextQuery.data ?? null
const season = seasonsQuery.data?.[0] ?? null const canonicalReady = canonical != null
const season = canonical?.season
const localMeetingsQuery = useQuery({
queryKey: ['meetings', season, 'local'],
queryFn: () => fetchLocalMeetings(season!),
enabled: season != null,
})
const seasonMeetingsQuery = useQuery({
queryKey: ['season-meetings', season],
queryFn: () => fetchSeasonMeetings(season!),
enabled: season != null,
})
// Supplementary data — only fetched once the canonical context has resolved,
// so a pending/failed canonical read never triggers a request fan-out.
const championshipQuery = useQuery({ const championshipQuery = useQuery({
queryKey: ['championship-hub', season], queryKey: ['championship-hub', season ?? 'current'],
queryFn: () => fetchChampionshipHub(season!), queryFn: () => fetchChampionshipHub(season),
enabled: season != null, enabled: canonicalReady,
})
const liveQuery = useQuery({ queryKey: ['live-state'], queryFn: fetchLiveState, staleTime: 5_000 })
const newsQuery = useQuery({ queryKey: ['news', 6], queryFn: () => fetchNews(6) })
const localMeetings = useMemo(() => localMeetingsQuery.data ?? [], [localMeetingsQuery.data])
const seasonMeetings = seasonMeetingsQuery.data?.length ? seasonMeetingsQuery.data : localMeetings
const weekendQueries = useQueries({
queries: localMeetings.map((meeting) => ({
queryKey: ['weekend', meeting.meeting_key],
queryFn: () => fetchWeekend(meeting.meeting_key),
enabled: localMeetings.length > 0,
staleTime: 60_000,
})),
})
const weekendsByKey = useMemo(() => {
const map = new Map<number, Weekend>()
localMeetings.forEach((meeting, i) => {
const data = weekendQueries[i]?.data
if (data) map.set(meeting.meeting_key, data)
})
return map
}, [localMeetings, weekendQueries])
const focusMeeting = useMemo(() => pickFocusMeeting(seasonMeetings, nowDate), [seasonMeetings, nowDate])
const focusHasLocal = focusMeeting ? weekendsByKey.has(focusMeeting.meeting_key) : false
const focusSessionsQuery = useQuery({
queryKey: ['sessions', focusMeeting?.meeting_key, 'openf1'],
queryFn: () => fetchSessions(focusMeeting!.meeting_key, 'openf1'),
enabled: focusMeeting != null && !focusHasLocal,
staleTime: 60_000, staleTime: 60_000,
}) })
const news: NewsItem[] = newsQuery.data ?? [] const newsQuery = useQuery({
queryKey: ['news', 6],
queryFn: () => fetchNews(6),
enabled: canonicalReady,
staleTime: 60_000,
})
const derived = useMemo( const championship = useMemo(
() => () => championshipImpact(championshipQuery.data),
deriveWeekendContext({ [championshipQuery.data],
season,
meetings: seasonMeetings,
weekendsByKey,
championship: championshipQuery.data,
liveActive: liveQuery.data?.is_live === true,
news,
focusSessions: focusSessionsQuery.data,
now: nowDate,
}),
[
season,
seasonMeetings,
weekendsByKey,
championshipQuery.data,
liveQuery.data,
news,
focusSessionsQuery.data,
nowDate,
],
) )
const briefing = useMemo(() => briefingItems(newsQuery.data ?? []), [newsQuery.data])
if (contextQuery.data) { let loadState: WeekendLoadState = 'loading'
return { context: contextQuery.data, fromEndpoint: true, now: nowDate } if (contextQuery.isError) loadState = 'error'
else if (canonicalReady) loadState = 'ready'
return {
context: canonical,
loadState,
error: contextQuery.error instanceof Error ? contextQuery.error : undefined,
championship,
briefing,
now: nowDate,
} }
if (seasonsQuery.isLoading || (season != null && seasonMeetingsQuery.isLoading && localMeetingsQuery.isLoading)) {
return { context: { state: 'loading', season: season ?? 0 }, fromEndpoint: false, now: nowDate }
}
if (seasonsQuery.isError) {
return {
context: {
state: 'error',
season: 0,
message: seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load Weekend',
},
fromEndpoint: false,
now: nowDate,
}
}
return { context: derived, fromEndpoint: false, now: nowDate }
} }

View File

@@ -1,122 +1,110 @@
import type { import type {
ChampionshipHub, ChampionshipHub,
ContextSession,
EnrichedResult, EnrichedResult,
Meeting, Meeting,
NewsItem, NewsItem,
Session, Session,
Weekend, WeekendBriefingItem,
WeekendChampionshipImpact, WeekendChampionshipImpact,
WeekendChampionshipMover, WeekendChampionshipMover,
WeekendCompletedEvent,
WeekendContext, WeekendContext,
WeekendPodiumEntry, WeekendPodiumEntry,
WeekendSeasonRound, WeekendViewState,
WeekendState,
WeekendTimelineSession,
WeekendUpcomingEvent,
} from '../types' } from '../types'
import {
currentMeeting,
meetingEndTime,
meetingStartTime,
mostRecentPastMeeting,
nextUpcomingMeeting,
sessionEndTime,
sessionStartTime,
sortSessionsByStart,
} from './schedule'
// A session that finished within this window is still "settling" — results and /**
// analysis are landing, so the Weekend surfaces a settling handoff rather than a * resolveViewState maps the canonical `temporal_state` onto the rendered Weekend
// fully-formed recap (see sibling live-settling story #74). * view. It is a total function: every valid canonical `temporal_state` resolves
const SETTLING_MS = 45 * 60 * 1000 * to a concrete view, so a well-formed payload never falls through to a
// A race that finished within this window keeps the Weekend in its immediate * limited/empty placeholder. Loading and error are hook-level states that are
// post-weekend aftermath before it relaxes into the general between-races cadence. * not part of the #72 contract.
const POST_WEEKEND_MS = 48 * 60 * 60 * 1000 */
export function resolveViewState(context: WeekendContext): WeekendViewState {
export interface WeekendContextInputs { switch (context.temporal_state) {
season: number | null case 'no_season':
meetings: Meeting[] return 'no_season'
weekendsByKey: Map<number, Weekend> case 'between_weekends':
championship?: ChampionshipHub return 'between_weekends'
liveActive: boolean case 'pre_session':
news: NewsItem[] return 'pre_session'
/** Sessions for the focus meeting when no local weekend is ingested. */ case 'session_live':
focusSessions?: Session[] return 'session_live'
now: Date case 'session_settling':
} return 'session_settling'
case 'between_sessions':
function isRaceSession(session: Session): boolean { return 'between_sessions'
const type = (session.session_type || '').toLowerCase() case 'post_weekend':
const name = (session.session_name || '').toLowerCase() return 'post_weekend'
return type.includes('race') || name === 'race' || name === 'sprint' case 'season_complete':
} return 'season_complete'
default:
/** Pick the session that best represents this weekend's primary analysis. */ // Unknown/absent temporal_state is an invalid payload — treat as no_season
function pickAnalysisSession(weekend: Weekend | undefined, fallback: Session[]): Session | undefined { // rather than inventing a state. The hook still shows an explicit surface.
if (weekend && weekend.sessions.length > 0) { return 'no_season'
const local = weekend.sessions.filter((s) => s.source === 'local')
const partial = weekend.sessions.filter((s) => s.source === 'partial')
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
const race = pool.find((s) => isRaceSession(s.session))
if (race) return race.session
const quali = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
if (quali) return quali.session
const byDefault = weekend.sessions.find((s) => s.session.session_key === weekend.default_session_key)
return (byDefault ?? pool[0])?.session
} }
const race = fallback.find(isRaceSession)
return race ?? fallback[0]
} }
function weekendSessions(weekend: Weekend | undefined, focusSessions: Session[] | undefined): Session[] { export interface MeetingIdentity {
if (weekend && weekend.sessions.length > 0) return weekend.sessions.map((s) => s.session) meeting_key: number
return focusSessions ?? [] meeting_name: string
short_name: string
country_code: string
country_flag: string
circuit_short_name: string
circuit_key?: number
date_start?: string
date_end?: string
} }
function timeline(sessions: Session[], now: Date): WeekendTimelineSession[] { /** Presentation identity for a meeting, tolerant of partial/absent fields. */
const sorted = sortSessionsByStart(sessions) export function meetingIdentity(meeting: Meeting | undefined): MeetingIdentity | undefined {
const nextKey = sorted.find((s) => { if (!meeting) return undefined
const start = sessionStartTime(s) return {
return start != null && start > now meeting_key: meeting.meeting_key,
})?.session_key meeting_name: meeting.meeting_name || meeting.location || 'Grand Prix',
return sorted.map((session) => { short_name: (meeting.meeting_name || meeting.location || 'Grand Prix').replace(/ Grand Prix$/i, ''),
const start = sessionStartTime(session) country_code: meeting.country_code,
const end = sessionEndTime(session) country_flag: meeting.country_flag,
let status: WeekendTimelineSession['status'] = 'upcoming' circuit_short_name: meeting.circuit_short_name || meeting.location || '',
if (start && end && now >= start && now < end) status = 'live' circuit_key: meeting.circuit_key,
else if (start && now >= start) status = 'done' date_start: meeting.date_start || undefined,
else if (session.session_key === nextKey) status = 'next' date_end: meeting.date_end || undefined,
return {
session_key: session.session_key,
session_name: session.session_name,
session_type: session.session_type,
date_start: session.date_start,
date_end: session.date_end,
status,
}
})
}
/** Most recently completed session in a weekend, by end time. */
function lastCompletedSession(sessions: Session[], now: Date): Session | undefined {
let selected: Session | undefined
let latest = -Infinity
for (const session of sessions) {
const end = sessionEndTime(session)
if (!end || end > now) continue
if (end.getTime() > latest) {
latest = end.getTime()
selected = session
}
} }
return selected
} }
function meetingRound(meetings: Meeting[], meeting: Meeting): number { /** Session identity paired with its resolved meeting for a canonical ContextSession. */
const active = meetings.filter((m) => !m.is_cancelled) export function sessionMeeting(ref: ContextSession | undefined): Meeting | undefined {
const idx = active.findIndex((m) => m.meeting_key === meeting.meeting_key) return ref?.meeting
return idx >= 0 ? idx + 1 : 0 }
export function sessionOf(ref: ContextSession | undefined): Session | undefined {
return ref?.session
}
/**
* analysisSessionKey resolves the session a user should open to read analysis for
* a completed event. It prefers the canonical default analysis session, falling
* back to the previous completed session. Returns undefined when nothing analysable
* is available (e.g. an archived result with no local analysis).
*/
export function analysisSessionKey(context: WeekendContext): number | undefined {
const key =
context.default_analysis_session?.session.session_key ??
context.previous_completed_session?.session.session_key
return key && key > 0 ? key : undefined
}
/** True when a ContextSession has local analysis worth linking to. */
export function hasLocalAnalysis(ref: ContextSession | undefined): boolean {
const status = ref?.availability.local_analysis
return status === 'complete' || status === 'partial'
}
/** Countdown target for a session reference — its scheduled start, if known. */
export function sessionStart(ref: ContextSession | undefined): string | undefined {
const start = ref?.session.date_start
return start ? start : undefined
} }
export function podiumFromResults(results: EnrichedResult[]): WeekendPodiumEntry[] { export function podiumFromResults(results: EnrichedResult[]): WeekendPodiumEntry[] {
@@ -156,54 +144,12 @@ function formatDuration(seconds: number): string {
return `${m}:${s.toFixed(3).padStart(6, '0')}` return `${m}:${s.toFixed(3).padStart(6, '0')}`
} }
function completedEvent( /**
meeting: Meeting, * championshipImpact derives the top-of-standings movers from the championship
meetings: Meeting[], * hub. This is supplementary data layered onto the canonical context, not part of
weekend: Weekend | undefined, * the #72 contract.
fallback: Session[], */
): WeekendCompletedEvent | undefined { export function championshipImpact(hub: ChampionshipHub | undefined): WeekendChampionshipImpact | undefined {
const analysis = pickAnalysisSession(weekend, fallback)
if (!analysis) return undefined
return {
meeting_key: meeting.meeting_key,
meeting_name: meeting.meeting_name,
country_code: meeting.country_code,
country_flag: meeting.country_flag,
circuit_short_name: meeting.circuit_short_name || meeting.location,
round: meetingRound(meetings, meeting) || undefined,
analysis_session_key: analysis.session_key,
analysis_session_name: analysis.session_name,
label: analysis.session_name,
podium: [],
}
}
function upcomingEvent(
meeting: Meeting,
meetings: Meeting[],
sessions: Session[],
now: Date,
): WeekendUpcomingEvent {
const sorted = sortSessionsByStart(sessions)
const nextSession = sorted.find((s) => {
const start = sessionStartTime(s)
return start != null && start > now
})
return {
meeting_key: meeting.meeting_key,
meeting_name: meeting.meeting_name,
country_code: meeting.country_code,
country_flag: meeting.country_flag,
circuit_short_name: meeting.circuit_short_name || meeting.location,
circuit_key: meeting.circuit_key,
round: meetingRound(meetings, meeting) || undefined,
date_start: meeting.date_start,
next_session_name: nextSession?.session_name,
next_session_start: nextSession?.date_start,
}
}
function championshipImpact(hub: ChampionshipHub | undefined): WeekendChampionshipImpact | undefined {
if (!hub || hub.drivers.length === 0) return undefined if (!hub || hub.drivers.length === 0) return undefined
const leaders: WeekendChampionshipMover[] = hub.drivers.slice(0, 3).map((d) => { const leaders: WeekendChampionshipMover[] = hub.drivers.slice(0, 3).map((d) => {
const cumulative = d.cumulative ?? [] const cumulative = d.cumulative ?? []
@@ -222,34 +168,8 @@ function championshipImpact(hub: ChampionshipHub | undefined): WeekendChampionsh
return { leaders, note } return { leaders, note }
} }
function seasonRounds( /** At most three briefing items, mapped from the news feed. */
meetings: Meeting[], export function briefingItems(news: NewsItem[]): WeekendBriefingItem[] {
weekendsByKey: Map<number, Weekend>,
focusKey: number | undefined,
now: Date,
): WeekendSeasonRound[] {
const active = meetings.filter((m) => !m.is_cancelled)
return active.map((meeting, index) => {
const start = meetingStartTime(meeting)
const end = meetingEndTime(meeting)
let status: WeekendSeasonRound['status'] = 'upcoming'
if (meeting.meeting_key === focusKey) status = 'next'
else if (start && end && now > end) status = 'completed'
else if (start && now >= start) status = 'next'
const weekend = weekendsByKey.get(meeting.meeting_key)
const analysis = pickAnalysisSession(weekend, [])
return {
round: index + 1,
meeting_key: meeting.meeting_key,
country_code: meeting.country_code,
country_flag: meeting.country_flag,
status,
analysis_session_key: analysis?.session_key,
}
})
}
function briefingItems(news: NewsItem[]): WeekendContext['briefing'] {
return news.slice(0, 3).map((item) => ({ return news.slice(0, 3).map((item) => ({
category: item.category, category: item.category,
title: item.title, title: item.title,
@@ -259,132 +179,3 @@ function briefingItems(news: NewsItem[]): WeekendContext['briefing'] {
image_url: item.og_image_url, image_url: item.og_image_url,
})) }))
} }
/**
* deriveWeekendContext resolves the current temporal Weekend state and its
* skeleton payload from locally-available data. It is a pure function of its
* inputs and `now`, mirroring the canonical /api/v1/weekend-context contract so
* the two are interchangeable. Podium / story detail is fetched per-state by the
* view components from existing analysis endpoints.
*/
export function deriveWeekendContext(inputs: WeekendContextInputs): WeekendContext {
const { season, meetings, weekendsByKey, championship, liveActive, news, focusSessions, now } = inputs
const base = (state: WeekendState, extra: Partial<WeekendContext> = {}): WeekendContext => ({
state,
season: season ?? championship?.season ?? 0,
live: liveActive,
championship_impact: championshipImpact(championship),
briefing: briefingItems(news),
...extra,
})
if (!season || meetings.length === 0) {
return base('limited_data', {
message: 'No season data yet. Ingest a race weekend to populate the Weekend view.',
})
}
const rounds = seasonRounds(meetings, weekendsByKey, undefined, now)
const current = currentMeeting(meetings, now)
const next = nextUpcomingMeeting(meetings, now)
const last = mostRecentPastMeeting(meetings, now)
const focusKey = current?.meeting_key ?? next?.meeting_key
const withRounds = seasonRounds(meetings, weekendsByKey, focusKey, now)
// ── Inside a current race weekend window ──
if (current) {
const weekend = weekendsByKey.get(current.meeting_key)
const sessions = weekendSessions(weekend, focusSessions)
const line = timeline(sessions, now)
const nextSession = line.find((s) => s.status === 'next' || s.status === 'live')
const completed = lastCompletedSession(sessions, now)
const lastSession = completed
? completedEvent(current, meetings, weekend, sessions)
: undefined
if (lastSession && completed) {
lastSession.analysis_session_key = completed.session_key
lastSession.analysis_session_name = completed.session_name
lastSession.label = completed.session_name
}
const anyStarted = sessions.some((s) => {
const start = sessionStartTime(s)
return start != null && now >= start
})
const allDone = sessions.length > 0 && line.every((s) => s.status === 'done')
const settling =
completed != null && sessionEndTime(completed) != null &&
now.getTime() - (sessionEndTime(completed) as Date).getTime() <= SETTLING_MS
const shared: Partial<WeekendContext> = {
active_meeting_name: current.meeting_name,
active_circuit_short_name: current.circuit_short_name || current.location,
sessions: line,
next_session: nextSession,
last_session: lastSession,
}
if (liveActive || line.some((s) => s.status === 'live')) {
return base('session_live', shared)
}
if (settling) {
return base('session_settling', shared)
}
if (allDone) {
// Race weekend finished but still inside its window — treat as post-weekend.
return base('post_weekend', {
...shared,
last_event: lastSession,
next_event: next ? upcomingEvent(next, meetings, [], now) : undefined,
season_rounds: withRounds,
})
}
if (lastSession && nextSession) {
return base('between_sessions', shared)
}
if (!anyStarted) {
return base('pre_session', {
...shared,
next_event: upcomingEvent(current, meetings, sessions, now),
})
}
return base('between_sessions', shared)
}
// ── Between weekends ──
if (last) {
const lastWeekend = weekendsByKey.get(last.meeting_key)
const lastEvent = completedEvent(last, meetings, lastWeekend, [])
const lastEnd = meetingEndTime(last)
const isPostWeekend =
lastEnd != null && now.getTime() - lastEnd.getTime() <= POST_WEEKEND_MS
if (next) {
const nextWeekend = weekendsByKey.get(next.meeting_key)
const nextSessions = weekendSessions(nextWeekend, undefined)
return base(isPostWeekend ? 'post_weekend' : 'between_races', {
last_event: lastEvent,
next_event: upcomingEvent(next, meetings, nextSessions, now),
season_rounds: withRounds,
})
}
// Completed races but nothing left on the calendar.
return base('season_complete', {
last_event: lastEvent,
season_rounds: rounds,
})
}
// ── Season hasn't started yet: preview the opener ──
if (next) {
const nextWeekend = weekendsByKey.get(next.meeting_key)
const nextSessions = weekendSessions(nextWeekend, focusSessions)
return base('pre_session', {
next_event: upcomingEvent(next, meetings, nextSessions, now),
season_rounds: withRounds,
})
}
return base('season_complete', { season_rounds: rounds })
}

View File

@@ -4,24 +4,52 @@ import { BetweenSessionsView } from '../components/weekend/BetweenSessionsView'
import { LiveHandoffView } from '../components/weekend/LiveHandoffView' import { LiveHandoffView } from '../components/weekend/LiveHandoffView'
import { PreSessionView } from '../components/weekend/PreSessionView' import { PreSessionView } from '../components/weekend/PreSessionView'
import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews' import { WeekendError, WeekendLimited, WeekendLoading } from '../components/weekend/StatusViews'
import type { WeekendContext } from '../types' import { resolveViewState } from '../lib/weekendContext'
import type {
WeekendBriefingItem,
WeekendChampionshipImpact,
WeekendContext,
WeekendViewState,
} from '../types'
import '../styles/weekend.css' import '../styles/weekend.css'
function renderState(context: WeekendContext, now: Date) { interface RenderArgs {
switch (context.state) { context: WeekendContext
case 'loading': now: Date
return <WeekendLoading /> championship?: WeekendChampionshipImpact
case 'error': briefing: WeekendBriefingItem[]
return <WeekendError message={context.message} /> /** When true (the /preview alias), foreground the preparation surface. */
case 'limited_data': preview: boolean
return <WeekendLimited message={context.message} season={context.season} /> }
case 'between_races':
function renderState(view: WeekendViewState, args: RenderArgs) {
const { context, now, championship, briefing, preview } = args
// The /preview alias always resolves to the preparation surface as long as
// there is a next event to prepare for, regardless of the temporal state. This
// keeps saved /preview links and the "Prepare for …" CTA meaningful instead of
// redirecting straight back to the same between-races screen.
if (preview && context.next_meeting) {
return <PreSessionView context={context} now={now} />
}
switch (view) {
case 'no_season':
return <WeekendLimited season={context.season} />
case 'between_weekends':
case 'post_weekend': case 'post_weekend':
case 'season_complete': case 'season_complete':
return <BetweenRacesView context={context} now={now} /> return (
<BetweenRacesView
context={context}
now={now}
view={view}
championship={championship}
briefing={briefing}
/>
)
case 'between_sessions': case 'between_sessions':
case 'session_settling': case 'session_settling':
return <BetweenSessionsView context={context} now={now} /> return <BetweenSessionsView context={context} now={now} view={view} briefing={briefing} />
case 'session_live': case 'session_live':
return <LiveHandoffView context={context} /> return <LiveHandoffView context={context} />
case 'pre_session': case 'pre_session':
@@ -31,12 +59,36 @@ function renderState(context: WeekendContext, now: Date) {
} }
} }
export function WeekendPage() { export function WeekendPage({ preview = false }: { preview?: boolean }) {
const { context, now } = useWeekendContext() const { context, loadState, error, championship, briefing, now } = useWeekendContext()
if (loadState === 'loading') {
return (
<main className="wk-page" data-testid="weekend-page" data-state="loading">
<WeekendLoading />
</main>
)
}
if (loadState === 'error' || context == null) {
return (
<main className="wk-page" data-testid="weekend-page" data-state="error">
<WeekendError message={error?.message} />
</main>
)
}
const view = resolveViewState(context)
return ( return (
<main className="wk-page" data-testid="weekend-page" data-state={context.state}> <main
{renderState(context, now)} className="wk-page"
data-testid="weekend-page"
data-state={view}
data-temporal-state={context.temporal_state}
data-preview={preview ? 'true' : undefined}
>
{renderState(view, { context, now, championship, briefing, preview })}
</main> </main>
) )
} }

View File

@@ -1,4 +1,4 @@
import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@tanstack/react-router' import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
import { Nav } from './components/Nav' import { Nav } from './components/Nav'
import { WeekendPage } from './pages/WeekendPage' import { WeekendPage } from './pages/WeekendPage'
import { ExplorePage } from './pages/ExplorePage' import { ExplorePage } from './pages/ExplorePage'
@@ -97,13 +97,15 @@ export const briefingRoute = createRoute({
component: BriefingPage, component: BriefingPage,
}) })
// Preview folds into the Weekend home. Keep /preview as a stable redirect so saved // Preview folds into the Weekend home. /preview is a stable alias that renders the
// links resolve into the appropriate Weekend state. // Weekend page in its preparation surface (PreSessionView) whenever there is a next
// event — so saved preview links and the "Prepare for …" CTA reach real preview
// content instead of redirecting back to the same between-races screen.
export const previewRoute = createRoute({ export const previewRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/preview', path: '/preview',
beforeLoad: () => { component: function PreviewRoute() {
throw redirect({ to: '/' }) return <WeekendPage preview />
}, },
}) })

View File

@@ -172,7 +172,14 @@ a { color: inherit; text-decoration: none; }
.bottom-nav-link:hover { color: var(--text-2); } .bottom-nav-link:hover { color: var(--text-2); }
@media (max-width: 640px) { @media (max-width: 640px) {
/* On mobile the bottom bar is the single primary navigation system. The top
bar's Primary links are hidden so the two are never both shown; the top bar
keeps only the logo and the Admin operator utility. */
.app-bottom-nav { display: flex; } .app-bottom-nav { display: flex; }
.app-nav .nav-links { display: none; }
.app-nav { justify-content: space-between; }
/* Keep page content clear of the fixed bottom bar. */
.wk-page { padding-bottom: calc(60px + env(safe-area-inset-bottom, 0) + var(--s4)); }
} }
/* ── Focus visibility (accessibility) ── */ /* ── Focus visibility (accessibility) ── */

View File

@@ -189,45 +189,24 @@
.wk-champ-gap { color: var(--text-3); font-size: 11px; min-width: 34px; text-align: right; } .wk-champ-gap { color: var(--text-3); font-size: 11px; min-width: 34px; text-align: right; }
.wk-champ-note { margin-top: var(--s4); font-size: 11px; color: var(--text-3); } .wk-champ-note { margin-top: var(--s4); font-size: 11px; color: var(--text-3); }
/* ── Season nav strip ── */ /* ── Season progress strip ── */
.wk-season-strip { .wk-countdown-tbc { font-size: 14px; color: var(--text-3); }
list-style: none; .wk-season-progress { display: flex; flex-direction: column; gap: var(--s3); }
display: flex; .wk-season-progress-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
gap: var(--s3); .wk-season-bar {
overflow-x: auto; position: relative;
padding-bottom: var(--s3); height: 8px;
scrollbar-width: thin; border-radius: 999px;
background: var(--surface-3);
overflow: hidden;
} }
.wk-season-strip::-webkit-scrollbar { height: 4px; } .wk-season-bar-fill {
.wk-round-item { flex: 0 0 auto; } position: absolute;
.wk-round { inset: 0 auto 0 0;
display: flex; background: var(--red);
flex-direction: column; border-radius: 999px;
align-items: center;
gap: var(--s2);
width: 54px;
padding: var(--s3) 0;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-2);
} }
.wk-round-status-next { border-color: var(--red); color: var(--text); } .wk-season-progress-label { font-size: 11px; color: var(--text-3); }
.wk-round-status-completed { border-color: var(--border-2); }
.wk-round-num { font-size: 10px; font-weight: 700; }
.wk-round-flag { font-size: 16px; }
.wk-round-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.wk-round-completed { background: var(--green); }
.wk-round-next { background: var(--red); }
.wk-round-upcoming { background: var(--surface-3); border: 1px solid var(--border-2); }
.wk-season-legend {
display: flex;
gap: var(--s5);
margin-top: var(--s4);
font-size: 10px;
color: var(--text-3);
}
.wk-season-legend span { display: inline-flex; align-items: center; gap: 5px; }
.wk-season-legend i { width: 7px; height: 7px; }
/* ── Briefing ── */ /* ── Briefing ── */
.wk-briefing-list { list-style: none; display: flex; flex-direction: column; } .wk-briefing-list { list-style: none; display: flex; flex-direction: column; }

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import {
Outlet,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
} from '@tanstack/react-router'
import { Nav } from '../components/Nav'
function renderNav() {
const rootRoute = createRootRoute({
component: () => (
<>
<Nav />
<Outlet />
</>
),
})
const stub = (p: string, id: string) =>
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
const router = createRouter({
routeTree: rootRoute.addChildren([
stub('/', 'home'),
stub('/championship', 'championship'),
stub('/briefing', 'briefing'),
stub('/explore', 'explore'),
stub('/admin', 'admin'),
]),
history: createMemoryHistory({ initialEntries: ['/'] }),
})
return render(<RouterProvider router={router} />)
}
const DESTINATIONS = ['Weekend', 'Championship', 'Briefing', 'Explore']
describe('Nav — one primary navigation system per breakpoint', () => {
it('exposes exactly two Primary landmarks (one per breakpoint), never more', async () => {
renderNav()
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' })).toHaveLength(2))
})
it('every Primary landmark contains all four destinations', async () => {
renderNav()
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
for (const nav of primaries) {
for (const label of DESTINATIONS) {
expect(within(nav).getByRole('link', { name: new RegExp(`^${label}$`) })).toBeInTheDocument()
}
}
})
it('keeps Admin out of every Primary landmark (operator utility only)', async () => {
renderNav()
await waitFor(() => expect(screen.getAllByRole('navigation', { name: 'Primary' }).length).toBe(2))
const primaries = screen.getAllByRole('navigation', { name: 'Primary' })
for (const nav of primaries) {
expect(within(nav).queryByRole('link', { name: /Admin/i })).not.toBeInTheDocument()
}
// Admin lives in the operator utilities toolbar.
const toolbar = screen.getByRole('toolbar', { name: 'Operator utilities' })
expect(within(toolbar).getByRole('link', { name: /Admin/i })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,309 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
Outlet,
RouterProvider,
createRouter,
createRootRoute,
createRoute,
createMemoryHistory,
} from '@tanstack/react-router'
import { WeekendPage } from '../pages/WeekendPage'
import type {
ChampionshipHub,
ContextAvailability,
ContextSession,
Meeting,
RaceHub,
Session,
TemporalState,
WeekendContext,
} from '../types'
vi.mock('../api', () => ({
fetchWeekendContext: vi.fn(),
fetchChampionshipHub: vi.fn(),
fetchNews: vi.fn(),
fetchRaceHub: vi.fn(),
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
fetchSeasons: vi.fn(),
fetchMeetings: vi.fn(),
fetchSessions: vi.fn(),
fetchResults: vi.fn(),
fetchStartingGrid: vi.fn(),
fetchTrackOutline: vi.fn(),
}))
import {
fetchWeekendContext,
fetchChampionshipHub,
fetchNews,
fetchRaceHub,
fetchSeasons,
fetchMeetings,
fetchSessions,
fetchResults,
fetchStartingGrid,
fetchTrackOutline,
} from '../api'
const mockContext = vi.mocked(fetchWeekendContext)
const mockHub = vi.mocked(fetchChampionshipHub)
const mockNews = vi.mocked(fetchNews)
const mockRaceHub = vi.mocked(fetchRaceHub)
const mockSeasons = vi.mocked(fetchSeasons)
const mockMeetings = vi.mocked(fetchMeetings)
const mockSessions = vi.mocked(fetchSessions)
const mockResults = vi.mocked(fetchResults)
const mockGrid = vi.mocked(fetchStartingGrid)
const mockTrack = vi.mocked(fetchTrackOutline)
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
return {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
...overrides,
}
}
function meeting(overrides: Partial<Meeting> = {}): Meeting {
return {
meeting_key: 1,
meeting_name: 'British Grand Prix',
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
location: 'Silverstone',
country_name: 'United Kingdom',
country_code: 'GBR',
country_flag: '',
circuit_key: 2,
circuit_short_name: 'Silverstone',
date_start: '2026-07-03T09:00:00Z',
date_end: '2026-07-05T16:00:00Z',
year: 2026,
...overrides,
}
}
function session(overrides: Partial<Session> = {}): Session {
return {
session_key: 11,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-07-05T14:00:00Z',
date_end: '2026-07-05T16:00:00Z',
gmt_offset: '',
...overrides,
}
}
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
return {
session: session(),
meeting: meeting(),
availability: availability(),
...overrides,
}
}
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
return {
season: 2026,
temporal_state: 'between_weekends',
championship_round: 5,
total_championship_rounds: 24,
...overrides,
}
}
const hub: ChampionshipHub = {
season: 2026,
round: 5,
total_rounds: 24,
rounds_left: 19,
last_race: 'British GP',
round_labels: [],
drivers: [
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 120, position: 1, wins: 4, podiums: 5, poles: 3, form: [], cumulative: [95, 120], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
],
teams: [],
}
const raceHub = {
session_key: 11,
results: [
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null },
],
} as unknown as RaceHub
function renderAt(path: string) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
),
})
const homeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => <WeekendPage /> })
const previewRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/preview',
component: () => <WeekendPage preview />,
})
const raceHubRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/race-hub',
validateSearch: (s: Record<string, unknown>) => {
const sk = Number(s.session_key)
return Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}
},
component: () => <div data-testid="race-hub-stub" />,
})
const stub = (p: string, id: string) =>
createRoute({ getParentRoute: () => rootRoute, path: p, component: () => <div data-testid={id} /> })
const router = createRouter({
routeTree: rootRoute.addChildren([
homeRoute,
previewRoute,
raceHubRoute,
stub('/live', 'live-stub'),
stub('/explore', 'explore-stub'),
stub('/championship', 'championship-stub'),
stub('/briefing', 'briefing-stub'),
]),
history: createMemoryHistory({ initialEntries: [path] }),
})
return render(<RouterProvider router={router} />)
}
describe('WeekendPage canonical contract rendering', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-07-08T12:00:00Z'))
mockHub.mockResolvedValue(hub)
mockNews.mockResolvedValue([])
mockRaceHub.mockResolvedValue(raceHub)
mockSeasons.mockResolvedValue([2026])
mockMeetings.mockResolvedValue([meeting()])
mockSessions.mockResolvedValue([session()])
mockResults.mockResolvedValue([])
mockGrid.mockResolvedValue([])
mockTrack.mockResolvedValue(null)
})
afterEach(() => {
vi.useRealTimers()
})
it('shows the loading surface before the canonical context resolves', async () => {
mockContext.mockReturnValue(new Promise(() => {}))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
})
it('shows an explicit error surface when the canonical endpoint fails', async () => {
mockContext.mockRejectedValue(new Error('API 500: boom'))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-error')).toBeInTheDocument())
expect(screen.getByTestId('weekend-error')).toHaveTextContent('boom')
})
const stateCases: Array<[TemporalState, string]> = [
['between_weekends', 'weekend-between-races'],
['post_weekend', 'weekend-between-races'],
['season_complete', 'weekend-between-races'],
['pre_session', 'weekend-pre-session'],
['between_sessions', 'weekend-between-sessions'],
['session_settling', 'weekend-between-sessions'],
['session_live', 'weekend-live'],
]
it.each(stateCases)('renders a designed surface for canonical temporal_state %s', async (temporal, testid) => {
mockContext.mockResolvedValue(
context({
temporal_state: temporal,
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
active_session: temporal === 'session_live'
? ctxSession({ session: session({ session_key: 12, session_name: 'Race' }), availability: availability({ live_session: 'active' }) })
: undefined,
focus_meeting: meeting(),
}),
)
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-temporal-state', temporal))
expect(screen.getByTestId(testid)).toBeInTheDocument()
})
it('renders the limited surface for no_season and never falls through to it for a valid payload', async () => {
mockContext.mockResolvedValue(context({ temporal_state: 'no_season', championship_round: 0, total_championship_rounds: 0 }))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-limited')).toBeInTheDocument())
})
it('between-races pairs the completed analysis CTA with the next-event countdown and championship impact', async () => {
mockContext.mockResolvedValue(
context({
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
}),
)
renderAt('/')
await waitFor(() => expect(screen.getByTestId('wk-last-event')).toBeInTheDocument())
expect(screen.getByTestId('wk-next-event')).toBeInTheDocument()
const story = screen.getByTestId('wk-explore-race-story')
expect(story).toHaveAttribute('href', expect.stringContaining('session_key=11'))
expect(screen.getByTestId('wk-prepare')).toHaveAttribute('href', '/preview')
await waitFor(() => expect(screen.getByTestId('wk-champ-impact')).toBeInTheDocument())
expect(screen.getByTestId('wk-season-nav')).toHaveTextContent('Round 5 of 24')
})
it('does not fan out to season/meeting/OpenF1/live queries when the canonical context succeeds', async () => {
mockContext.mockResolvedValue(context({ temporal_state: 'between_weekends' }))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-between-races')).toBeInTheDocument())
// Only supplementary championship + news reads are allowed; no season /
// meetings / sessions / live fan-out from the Weekend home hook.
expect(mockSeasons).not.toHaveBeenCalled()
expect(mockSessions).not.toHaveBeenCalled()
// fetchMeetings may still be reached only through the folded preview surface,
// which is not mounted in the between-races state.
expect(mockMeetings).not.toHaveBeenCalled()
})
it('supplementary reads stay dormant while the canonical context is pending', async () => {
mockContext.mockReturnValue(new Promise(() => {}))
renderAt('/')
await waitFor(() => expect(screen.getByTestId('weekend-loading')).toBeInTheDocument())
expect(mockHub).not.toHaveBeenCalled()
expect(mockNews).not.toHaveBeenCalled()
})
it('the /preview alias renders the preparation surface instead of looping back', async () => {
mockContext.mockResolvedValue(
context({
temporal_state: 'between_weekends',
next_meeting: meeting({ meeting_key: 2, meeting_name: 'Hungarian Grand Prix', date_start: '2026-07-24T09:00:00Z' }),
next_session: ctxSession({ session: session({ session_key: 21, session_name: 'Practice 1', date_start: '2026-07-24T09:00:00Z' }) }),
}),
)
renderAt('/preview')
await waitFor(() => expect(screen.getByTestId('weekend-pre-session')).toBeInTheDocument())
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-preview', 'true')
// The between-races surface must NOT be what /preview renders.
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,223 @@
import { describe, it, expect } from 'vitest'
import {
analysisSessionKey,
briefingItems,
championshipImpact,
hasLocalAnalysis,
meetingIdentity,
podiumFromResults,
resolveViewState,
} from '../lib/weekendContext'
import type {
ChampionshipHub,
ContextAvailability,
ContextSession,
EnrichedResult,
Meeting,
NewsItem,
Session,
TemporalState,
WeekendContext,
} from '../types'
function availability(overrides: Partial<ContextAvailability> = {}): ContextAvailability {
return {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
...overrides,
}
}
function meeting(overrides: Partial<Meeting> = {}): Meeting {
return {
meeting_key: 1,
meeting_name: 'British Grand Prix',
meeting_official_name: 'FORMULA 1 BRITISH GRAND PRIX',
location: 'Silverstone',
country_name: 'United Kingdom',
country_code: 'GBR',
country_flag: '',
circuit_key: 2,
circuit_short_name: 'Silverstone',
date_start: '2026-07-03T09:00:00Z',
date_end: '2026-07-05T16:00:00Z',
year: 2026,
...overrides,
}
}
function session(overrides: Partial<Session> = {}): Session {
return {
session_key: 11,
session_name: 'Race',
session_type: 'Race',
meeting_key: 1,
date_start: '2026-07-05T14:00:00Z',
date_end: '2026-07-05T16:00:00Z',
gmt_offset: '',
...overrides,
}
}
function ctxSession(overrides: Partial<ContextSession> = {}): ContextSession {
return {
session: session(),
meeting: meeting(),
availability: availability(),
...overrides,
}
}
function context(overrides: Partial<WeekendContext> = {}): WeekendContext {
return {
season: 2026,
temporal_state: 'between_weekends',
championship_round: 1,
total_championship_rounds: 24,
...overrides,
}
}
describe('resolveViewState', () => {
const cases: Array<[TemporalState, string]> = [
['no_season', 'no_season'],
['between_weekends', 'between_weekends'],
['pre_session', 'pre_session'],
['session_live', 'session_live'],
['session_settling', 'session_settling'],
['between_sessions', 'between_sessions'],
['post_weekend', 'post_weekend'],
['season_complete', 'season_complete'],
]
it.each(cases)('maps canonical temporal_state %s to view %s', (temporal, expected) => {
expect(resolveViewState(context({ temporal_state: temporal }))).toBe(expected)
})
it('treats an unknown/absent temporal_state as no_season rather than inventing a state', () => {
expect(resolveViewState(context({ temporal_state: 'garbage' as TemporalState }))).toBe('no_season')
})
})
describe('analysisSessionKey', () => {
it('prefers the default analysis session', () => {
const ctx = context({
default_analysis_session: ctxSession({ session: session({ session_key: 32 }) }),
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
})
expect(analysisSessionKey(ctx)).toBe(32)
})
it('falls back to the previous completed session', () => {
const ctx = context({
previous_completed_session: ctxSession({ session: session({ session_key: 11 }) }),
})
expect(analysisSessionKey(ctx)).toBe(11)
})
it('returns undefined when there is no analysable session', () => {
expect(analysisSessionKey(context())).toBeUndefined()
})
it('returns undefined for a zero/synthetic session key', () => {
const ctx = context({
previous_completed_session: ctxSession({ session: session({ session_key: 0 }) }),
})
expect(analysisSessionKey(ctx)).toBeUndefined()
})
})
describe('hasLocalAnalysis', () => {
it('is true for complete or partial local analysis', () => {
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'complete' }) }))).toBe(true)
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'partial' }) }))).toBe(true)
})
it('is false for pending/not_applicable/absent', () => {
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'pending' }) }))).toBe(false)
expect(hasLocalAnalysis(ctxSession({ availability: availability({ local_analysis: 'not_applicable' }) }))).toBe(false)
expect(hasLocalAnalysis(undefined)).toBe(false)
})
})
describe('meetingIdentity', () => {
it('derives a short name and tolerates partial fields', () => {
const id = meetingIdentity(meeting({ meeting_name: 'Monaco Grand Prix', circuit_short_name: '', location: 'Monaco' }))
expect(id?.short_name).toBe('Monaco')
expect(id?.circuit_short_name).toBe('Monaco')
})
it('returns undefined for an absent meeting', () => {
expect(meetingIdentity(undefined)).toBeUndefined()
})
it('drops empty date strings', () => {
const id = meetingIdentity(meeting({ date_start: '', date_end: '' }))
expect(id?.date_start).toBeUndefined()
expect(id?.date_end).toBeUndefined()
})
})
describe('championshipImpact', () => {
const hub: ChampionshipHub = {
season: 2026,
round: 2,
total_rounds: 24,
rounds_left: 22,
last_race: 'British GP',
round_labels: ['R1', 'R2'],
drivers: [
{ driver_number: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', points: 50, position: 1, wins: 2, podiums: 2, poles: 1, form: [], cumulative: [25, 50], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
{ driver_number: 4, name_acronym: 'NOR', full_name: 'Lando', team_name: 'McL', team_colour: 'ff8000', points: 40, position: 2, wins: 1, podiums: 2, poles: 0, form: [], cumulative: [18, 40], teammate_wins: 0, teammate_losses: 0, round_positions: [] },
],
teams: [],
}
it('maps top-3 movers with deltas and a note', () => {
const impact = championshipImpact(hub)
expect(impact?.leaders).toHaveLength(2)
expect(impact?.leaders[0].delta).toBe(25)
expect(impact?.note).toContain('British GP')
})
it('returns undefined for an empty hub', () => {
expect(championshipImpact(undefined)).toBeUndefined()
expect(championshipImpact({ ...hub, drivers: [] })).toBeUndefined()
})
})
describe('briefingItems', () => {
it('caps at three items and maps fields', () => {
const news: NewsItem[] = Array.from({ length: 5 }, (_, i) => ({
source: 'src',
title: `Item ${i}`,
url: `https://x/${i}`,
fetched_at: '2026-07-06T00:00:00Z',
category: 'news',
og_image_url: 'img',
}))
const items = briefingItems(news)
expect(items).toHaveLength(3)
expect(items[0]).toMatchObject({ title: 'Item 0', image_url: 'img' })
})
})
describe('podiumFromResults', () => {
it('sorts by position, caps at three, and formats gaps', () => {
const results: EnrichedResult[] = [
{ driver_number: 44, position: 2, name_acronym: 'HAM', full_name: 'Lewis', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 5.123 } as EnrichedResult,
{ driver_number: 1, position: 1, name_acronym: 'VER', full_name: 'Max', team_name: 'RB', team_colour: '3671c6', dnf: false, dns: false, dsq: false, duration: 5400, gap_to_leader: null } as EnrichedResult,
{ driver_number: 16, position: 3, name_acronym: 'LEC', full_name: 'Charles', team_name: 'Ferrari', team_colour: 'e8002d', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: '+10.5' } as EnrichedResult,
{ driver_number: 55, position: 4, name_acronym: 'SAI', full_name: 'Carlos', team_name: 'W', team_colour: 'fff', dnf: false, dns: false, dsq: false, duration: null, gap_to_leader: 20 } as EnrichedResult,
]
const podium = podiumFromResults(results)
expect(podium.map((p) => p.name_acronym)).toEqual(['VER', 'HAM', 'LEC'])
expect(podium[1].gap).toBe('+5.123')
expect(podium[2].gap).toBe('+10.5')
})
})

View File

@@ -449,67 +449,73 @@ export interface DriverSummary {
} }
// ── Weekend Context ── // ── Weekend Context ──
// Canonical contract for the adaptive Weekend home. Served by // Canonical local-first contract for the adaptive Weekend home, served verbatim
// /api/v1/weekend-context (sibling backend story #72) and, until that endpoint // by /api/v1/weekend-context (backend story #72, internal/query/context.go).
// ships, derived client-side from existing endpoints (see lib/weekendContext.ts). // The frontend consumes this shape as the single source of truth; view-model
export type WeekendState = // derivation lives in lib/weekendContext.ts.
| 'loading'
| 'error' // TemporalState mirrors query.TemporalState exactly (the JSON `temporal_state`).
| 'limited_data' export type TemporalState =
| 'between_races' | 'no_season'
| 'between_weekends'
| 'pre_session' | 'pre_session'
| 'between_sessions'
| 'session_live' | 'session_live'
| 'session_settling' | 'session_settling'
| 'between_sessions'
| 'post_weekend' | 'post_weekend'
| 'season_complete' | 'season_complete'
export interface WeekendPodiumEntry { // ContextAvailability mirrors query.ContextAvailability. Every field is present
position: number // in a canonical payload except the optional `observed_at`.
driver_number: number export interface ContextAvailability {
name_acronym: string schedule: string
team_name: string live_transport: string
team_colour: string live_session: string
gap: string archive: string
local_analysis: string
freshness: string
observed_at?: string
limitations: string[]
} }
export type WeekendSessionStatus = 'done' | 'live' | 'next' | 'upcoming' // ContextSession mirrors query.ContextSession: a session identity coupled with
// its meeting and structured availability contract.
export interface WeekendTimelineSession { export interface ContextSession {
session_key: number session: Session
session_name: string meeting?: Meeting
session_type: string availability: ContextAvailability
date_start: string
date_end: string
status: WeekendSessionStatus
} }
export interface WeekendCompletedEvent { // WeekendContext mirrors query.WeekendContext (the canonical endpoint body).
meeting_key: number export interface WeekendContext {
meeting_name: string season?: number
country_code: string temporal_state: TemporalState
country_flag: string previous_meeting?: Meeting
circuit_short_name: string focus_meeting?: Meeting
round?: number next_meeting?: Meeting
analysis_session_key: number previous_completed_session?: ContextSession
analysis_session_name: string active_session?: ContextSession
label: string next_session?: ContextSession
podium: WeekendPodiumEntry[] default_analysis_session?: ContextSession
story?: string championship_round: number
total_championship_rounds: number
} }
export interface WeekendUpcomingEvent { // ── Weekend view model (client-only) ──
meeting_key: number // The rendered Weekend home layers a small set of non-canonical UI states
meeting_name: string // (loading/error) plus supplementary data (championship movers, briefing) on top
country_code: string // of the canonical context. None of these are part of the #72 contract.
country_flag: string export type WeekendViewState =
circuit_short_name: string | 'loading'
circuit_key?: number | 'error'
round?: number | 'no_season'
date_start?: string | 'between_weekends'
next_session_name?: string | 'pre_session'
next_session_start?: string | 'session_live'
} | 'session_settling'
| 'between_sessions'
| 'post_weekend'
| 'season_complete'
export interface WeekendChampionshipMover { export interface WeekendChampionshipMover {
position: number position: number
@@ -534,32 +540,13 @@ export interface WeekendBriefingItem {
image_url?: string image_url?: string
} }
export type WeekendRoundStatus = 'completed' | 'next' | 'upcoming' export interface WeekendPodiumEntry {
position: number
export interface WeekendSeasonRound { driver_number: number
round: number name_acronym: string
meeting_key: number team_name: string
country_code: string team_colour: string
country_flag: string gap: string
status: WeekendRoundStatus
analysis_session_key?: number
}
export interface WeekendContext {
state: WeekendState
season: number
message?: string
live?: boolean
active_meeting_name?: string
active_circuit_short_name?: string
last_event?: WeekendCompletedEvent
next_event?: WeekendUpcomingEvent
last_session?: WeekendCompletedEvent
next_session?: WeekendTimelineSession
sessions?: WeekendTimelineSession[]
championship_impact?: WeekendChampionshipImpact
season_rounds?: WeekendSeasonRound[]
briefing?: WeekendBriefingItem[]
} }
export interface NewsItem { export interface NewsItem {

View File

@@ -1,77 +0,0 @@
import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472
test.describe('Command Center', () => {
test('loads as default route with weekend identity band', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
})
test('nav link reaches command center from race hub', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await page.getByRole('link', { name: 'Command' }).click()
await expect(page).toHaveURL('/')
await expect(page.getByTestId('command-center')).toBeVisible()
})
test('open analysis action opens race hub for the focus session', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
await page.getByTestId('hero-last-race-link').click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()
})
test('archived live snapshot does not mark command center live', async ({ page }) => {
await page.route('**/api/v1/live/state', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
is_live: false,
data: null,
last_snapshot: {
Drivers: { '1': { RacingNumber: '1', Position: 1 } },
DriverInfo: { '1': { RacingNumber: '1', Tla: 'VER', TeamColour: '3671C6' } },
Tyres: {},
RCMessages: [],
Weather: {},
Session: { MeetingName: 'Archived GP', SessionName: 'Race', SessionType: 'Race' },
TeamRadio: [],
SessionStatus: 'Finished',
TrackStatus: '1',
CurrentLap: 57,
TotalLaps: 57,
Clock: '',
ClockRefTime: '',
ClockExtrapolating: false,
Stints: {},
},
last_snapshot_at: '2026-07-04T14:00:00Z',
}),
}),
)
await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-live-status')).toContainText('No live session')
await expect(page.getByTestId('cc-live-status')).not.toContainText('Live session active')
})
test('existing routes continue to work', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-overview')).toBeVisible()
await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible()
await page.goto('/live')
await expect(page.getByTestId('live-empty')).toBeVisible()
})
})

View File

@@ -3,11 +3,11 @@ import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472 const FULL_SESSION = 9472
test.describe('Production serving (Go + built React)', () => { test.describe('Production serving (Go + built React)', () => {
test('serves command center as default route', async ({ page }) => { test('serves the Weekend home as default route', async ({ page }) => {
await page.goto('/') await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible() await expect(page.getByTestId('weekend-page')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible() await expect(page.getByTestId('weekend-between-races')).toBeVisible()
}) })
test('serves race hub workspace from built assets', async ({ page }) => { test('serves race hub workspace from built assets', async ({ page }) => {
@@ -44,16 +44,21 @@ test.describe('Production serving (Go + built React)', () => {
await expect(page.getByText('No live session active')).toBeVisible() await expect(page.getByText('No live session active')).toBeVisible()
}) })
test('nav links work from built SPA', async ({ page }) => { test('primary nav links and Admin utility work from built SPA', async ({ page }) => {
await page.goto('/') await page.goto('/')
await page.locator('.app-nav').getByRole('link', { name: 'Race Hub' }).click() const primary = page.getByRole('navigation', { name: 'Primary' }).first()
await expect(page).toHaveURL(/\/race-hub/)
await page.locator('.app-nav').getByRole('link', { name: 'Admin', exact: true }).click() await primary.getByRole('link', { name: 'Championship', exact: true }).click()
await expect(page).toHaveURL(/\/championship/)
await primary.getByRole('link', { name: 'Explore', exact: true }).click()
await expect(page).toHaveURL(/\/explore/)
await primary.getByRole('link', { name: 'Weekend', exact: true }).click()
await expect(page).toHaveURL('/')
// Admin is an operator utility outside the Primary landmark.
await page.getByRole('toolbar', { name: 'Operator utilities' }).getByRole('link', { name: 'Admin' }).click()
await expect(page).toHaveURL(/\/admin/) await expect(page).toHaveURL(/\/admin/)
await page.getByRole('link', { name: 'Live', exact: true }).click()
await expect(page).toHaveURL(/\/live/)
await expect(page.getByTestId('live-empty')).toBeVisible()
}) })
}) })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -14,16 +14,13 @@ export async function waitForScreenshotReady(page: Page): Promise<void> {
await page.waitForTimeout(150) await page.waitForTimeout(150)
} }
export async function gotoCommandCenterReady(page: Page): Promise<void> { export async function gotoWeekendReady(page: Page): Promise<void> {
await page.goto('/') await page.goto('/')
await expect(page.getByTestId('command-center')).toBeVisible() await expect(page.getByTestId('weekend-page')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible() // The seeded hermetic DB (Monaco 2025, completed) resolves to season_complete,
await expect(page.getByTestId('cc-session-9472')).toBeVisible() // rendered by the between-races surface.
// The e2e stack runs with an unreachable OpenF1 base URL, so wait for the await expect(page.getByTestId('weekend-between-races')).toBeVisible()
// season-calendar query to settle on its local fallback before screenshotting. await expect(page.getByTestId('wk-last-event')).toBeVisible()
await expect(
page.getByText('Using local meetings because the full calendar could not load.'),
).toBeVisible()
await waitForScreenshotReady(page) await waitForScreenshotReady(page)
} }

View File

@@ -1,6 +1,6 @@
import { test } from '@playwright/test' import { test } from '@playwright/test'
import { import {
gotoCommandCenterReady, gotoWeekendReady,
gotoDataLibraryReady, gotoDataLibraryReady,
gotoLiveEmptyReady, gotoLiveEmptyReady,
gotoRaceHubReady, gotoRaceHubReady,
@@ -8,9 +8,9 @@ import {
} from './helpers' } from './helpers'
test.describe('MVP visual regression', () => { test.describe('MVP visual regression', () => {
test('command-center', async ({ page }) => { test('weekend', async ({ page }) => {
await gotoCommandCenterReady(page) await gotoWeekendReady(page)
await screenshotPage(page, 'command-center') await screenshotPage(page, 'weekend')
}) })
test('race-hub', async ({ page }) => { test('race-hub', async ({ page }) => {

View File

@@ -0,0 +1,156 @@
import { test, expect, type Page } from '@playwright/test'
// These journeys inject the canonical /api/v1/weekend-context payload directly so
// each temporal state is exercised deterministically and hermetically, regardless
// of the seeded database clock. The payloads mirror internal/query.WeekendContext.
const availability = {
schedule: 'available',
live_transport: 'unknown',
live_session: 'inactive',
archive: 'unavailable',
local_analysis: 'complete',
freshness: 'fresh',
limitations: [],
}
function meeting(key: number, name: string, start: string) {
return {
meeting_key: key,
meeting_name: name,
meeting_official_name: name,
location: name,
country_code: 'GBR',
country_name: 'United Kingdom',
country_flag: '',
circuit_key: key,
circuit_short_name: name,
date_start: start,
date_end: start,
year: 2026,
}
}
function ctxSession(key: number, name: string, start: string, meetingKey: number) {
return {
session: {
session_key: key,
session_name: name,
session_type: name,
meeting_key: meetingKey,
date_start: start,
date_end: start,
gmt_offset: '',
},
meeting: meeting(meetingKey, 'British Grand Prix', start),
availability,
}
}
async function stubContext(page: Page, body: Record<string, unknown>): Promise<void> {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) }),
)
// Keep supplementary reads hermetic and empty.
await page.route('**/api/v1/news**', (route) =>
route.fulfill({ contentType: 'application/json', body: '[]' }),
)
}
test.describe('Weekend canonical temporal states (injected)', () => {
test('between_weekends pairs the last completed event with the next-event countdown and Prepare CTA', async ({ page }) => {
await stubContext(page, {
season: 2026,
temporal_state: 'between_weekends',
previous_meeting: meeting(1, 'British Grand Prix', '2026-07-05T14:00:00Z'),
previous_completed_session: ctxSession(11, 'Race', '2026-07-05T14:00:00Z', 1),
default_analysis_session: ctxSession(11, 'Race', '2026-07-05T14:00:00Z', 1),
next_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
focus_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
championship_round: 12,
total_championship_rounds: 24,
})
await page.goto('/')
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
await expect(page.getByTestId('wk-last-event')).toBeVisible()
await expect(page.getByTestId('wk-next-event')).toBeVisible()
await expect(page.getByTestId('wk-prepare')).toHaveAttribute('href', '/preview')
await expect(page.getByTestId('wk-season-nav')).toContainText('Round 12 of 24')
})
test('between_races Prepare CTA folds into the preparation surface instead of looping', async ({ page }) => {
await stubContext(page, {
season: 2026,
temporal_state: 'between_weekends',
next_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
championship_round: 12,
total_championship_rounds: 24,
})
await page.goto('/')
await page.getByTestId('wk-prepare').click()
await expect(page).toHaveURL(/\/preview$/)
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
await expect(page.getByTestId('weekend-between-races')).toHaveCount(0)
})
test('pre_session surfaces the preview and the next-session countdown', async ({ page }) => {
await stubContext(page, {
season: 2026,
temporal_state: 'pre_session',
next_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
next_session: ctxSession(21, 'Practice 1', '2026-07-24T09:00:00Z', 2),
focus_meeting: meeting(2, 'Hungarian Grand Prix', '2026-07-24T09:00:00Z'),
championship_round: 12,
total_championship_rounds: 24,
})
await page.goto('/')
await expect(page.getByTestId('weekend-pre-session')).toBeVisible()
await expect(page.getByTestId('wk-pre-head')).toBeVisible()
})
test('between_sessions shows the last result recap and the next-session countdown together', async ({ page }) => {
await stubContext(page, {
season: 2026,
temporal_state: 'between_sessions',
focus_meeting: meeting(1, 'British Grand Prix', '2026-07-04T10:00:00Z'),
previous_completed_session: ctxSession(11, 'Sprint', '2026-07-04T10:00:00Z', 1),
default_analysis_session: ctxSession(11, 'Sprint', '2026-07-04T10:00:00Z', 1),
next_session: ctxSession(12, 'Race', '2026-07-05T14:00:00Z', 1),
championship_round: 12,
total_championship_rounds: 24,
})
await page.goto('/')
await expect(page.getByTestId('weekend-between-sessions')).toBeVisible()
await expect(page.getByTestId('wk-last-session')).toBeVisible()
await expect(page.getByTestId('wk-next-session')).toBeVisible()
})
test('session_live hands off to live timing', async ({ page }) => {
await stubContext(page, {
season: 2026,
temporal_state: 'session_live',
focus_meeting: meeting(1, 'British Grand Prix', '2026-07-05T14:00:00Z'),
active_session: ctxSession(11, 'Race', '2026-07-05T14:00:00Z', 1),
championship_round: 12,
total_championship_rounds: 24,
})
await page.goto('/')
await expect(page.getByTestId('weekend-live')).toBeVisible()
await expect(page.getByTestId('wk-watch-live')).toHaveAttribute('href', '/live')
})
test('an error from the canonical endpoint shows an explicit error surface', async ({ page }) => {
await page.route('**/api/v1/weekend-context', (route) =>
route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"boom"}' }),
)
await page.goto('/')
await expect(page.getByTestId('weekend-error')).toBeVisible()
})
})

118
tests/weekend.spec.ts Normal file
View File

@@ -0,0 +1,118 @@
import { test, expect, type Page } from '@playwright/test'
const FULL_SESSION = 9472
async function documentOverflow(page: Page): Promise<number> {
return page.evaluate(() => {
const doc = document.documentElement
return doc.scrollWidth - doc.clientWidth
})
}
test.describe('Weekend home (seeded canonical context)', () => {
test('renders the Weekend home from the canonical endpoint, not the limited fallback', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('weekend-page')).toBeVisible()
// The seeded DB (Monaco 2025, completed) resolves to season_complete, driven
// by the canonical /api/v1/weekend-context endpoint.
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-temporal-state', 'season_complete')
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
await expect(page.getByTestId('weekend-limited')).toHaveCount(0)
// No ingest/dataset terminology on the Weekend surface.
await expect(page.getByText(/ingest/i)).toHaveCount(0)
})
test('the completed-event CTA opens the canonical analysis session in Race Hub', async ({ page }) => {
await page.goto('/')
await expect(page.getByTestId('wk-last-event')).toBeVisible()
await page.getByTestId('wk-explore-race-story').click()
await expect(page).toHaveURL(/\/race-hub\?session_key=\d+/)
await expect(page.getByTestId('race-hub')).toBeVisible()
})
test('/preview alias resolves without looping back to the same screen', async ({ page }) => {
await page.goto('/preview')
// In the seeded season-complete state there is no next event, so /preview
// resolves to the Weekend home rather than a redirect loop.
await expect(page.getByTestId('weekend-page')).toBeVisible()
await expect(page).toHaveURL(/\/preview$/)
})
test('existing deep-link routes remain valid', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByTestId('race-hub')).toBeVisible()
await expect(page.getByTestId('rh-overview')).toBeVisible()
await page.goto('/admin')
await expect(page.getByTestId('data-library')).toBeVisible()
await page.goto('/live')
await expect(page.getByTestId('live-empty')).toBeVisible()
await page.goto('/explore')
await expect(page.getByTestId('explore-page')).toBeVisible()
})
})
test.describe('Weekend navigation hierarchy', () => {
test('all four destinations are reachable from primary navigation', async ({ page }) => {
await page.goto('/')
const primary = page.getByRole('navigation', { name: 'Primary' }).first()
for (const [label, url] of [
['Championship', /\/championship/],
['Briefing', /\/briefing/],
['Explore', /\/explore/],
['Weekend', /\/$/],
] as const) {
await primary.getByRole('link', { name: label, exact: true }).click()
await expect(page).toHaveURL(url)
}
})
test('Admin is an operator utility outside the Primary landmark', async ({ page }) => {
await page.goto('/')
const primaries = page.getByRole('navigation', { name: 'Primary' })
await expect(primaries.first()).toBeVisible()
// Admin must not appear inside any Primary landmark.
await expect(primaries.getByRole('link', { name: /Admin/i })).toHaveCount(0)
await expect(
page.getByRole('toolbar', { name: 'Operator utilities' }).getByRole('link', { name: 'Admin' }),
).toBeVisible()
})
})
test.describe('Weekend responsive — no global horizontal overflow', () => {
for (const { name, width, height } of [
{ name: 'mobile 390', width: 390, height: 844 },
{ name: 'tablet 768', width: 768, height: 1024 },
{ name: 'desktop 1440', width: 1440, height: 900 },
]) {
test(`no document overflow at ${name}`, async ({ page }) => {
await page.setViewportSize({ width, height })
await page.goto('/')
await expect(page.getByTestId('weekend-between-races')).toBeVisible()
expect(await documentOverflow(page)).toBeLessThanOrEqual(1)
})
}
test('mobile shows exactly one visible primary navigation (bottom bar)', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/')
await expect(page.getByTestId('weekend-page')).toBeVisible()
// The bottom bar is the single visible primary nav; the top bar's links are
// hidden by CSS at this breakpoint.
const bottom = page.locator('.app-bottom-nav')
await expect(bottom).toBeVisible()
await expect(bottom.getByRole('link')).toHaveCount(4)
const topLinks = page.locator('.app-nav .nav-links')
await expect(topLinks).toBeHidden()
// All four destinations remain reachable via the bottom bar.
for (const label of ['Weekend', 'Championship', 'Briefing', 'Explore']) {
await expect(bottom.getByRole('link', { name: new RegExp(label) })).toBeVisible()
}
})
})