mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
fix(#73): consume canonical Weekend Context contract and repair navigation
Address the independent review blockers on PR #80 after rebasing onto the authoritative #72 Weekend Context API. - Replace the invented frontend WeekendContext with the exact backend contract (temporal_state, previous/focus/next meetings, previous_completed/active/next/ default_analysis sessions with availability). Every valid canonical payload now maps to a designed state via a total resolveViewState; a well-formed response can never fall through to the limited-data placeholder. - Make the canonical read the single source of truth: useWeekendContext no longer fans out to season/meetings/per-weekend/OpenF1/live queries. Only supplementary championship + news reads run, and only once the canonical context resolves. - Fix the Prepare/analysis flow: /preview is a stable alias that renders the preparation surface (PreSessionView) instead of redirecting back to the same between-races screen. - One primary navigation system per breakpoint: the mobile top-bar links are hidden so the bottom bar is the sole primary nav, and Admin is moved out of every Primary landmark into an operator-utilities toolbar. - Add Vitest coverage for the contract mapping, every temporal state, loading/ error/limited surfaces, the no-fanout guarantee, the /preview CTA, and the nav hierarchy; add hermetic Playwright journeys (seeded + injected canonical payloads), 390/768/1440 overflow checks, and Weekend visual snapshots. Retire the stale Command Center specs/snapshots. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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) ── */
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
67
frontend/src/test/Nav.test.tsx
Normal file
67
frontend/src/test/Nav.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
309
frontend/src/test/WeekendPage.test.tsx
Normal file
309
frontend/src/test/WeekendPage.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
223
frontend/src/test/weekendContext.test.ts
Normal file
223
frontend/src/test/weekendContext.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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 |
BIN
tests/visual/__snapshots__/desktop/weekend.png
Normal file
BIN
tests/visual/__snapshots__/desktop/weekend.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB |
BIN
tests/visual/__snapshots__/mobile/weekend.png
Normal file
BIN
tests/visual/__snapshots__/mobile/weekend.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 129 KiB |
BIN
tests/visual/__snapshots__/tablet/weekend.png
Normal file
BIN
tests/visual/__snapshots__/tablet/weekend.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
156
tests/weekend-states.spec.ts
Normal file
156
tests/weekend-states.spec.ts
Normal 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
118
tests/weekend.spec.ts
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user