fix(#75): preserve Race Hub meeting/session on Weekend return
Add an explicit Back to Weekend link that deep-links to /?meeting_key&session_key, restore that focus on the adaptive Weekend home with a Continue analysis CTA, and regenerate Race Hub visual baselines against the integrated #80 nav. Co-authored-by: Cursor <cursoragent@cursor.com>
65
frontend/src/components/weekend/WeekendFocusBanner.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import { fetchWeekend } from '../../api'
|
||||||
|
import { meetingIdentity } from '../../lib/weekendContext'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores Race Hub → Weekend meeting/session context from the URL search
|
||||||
|
* contract. Deep-linkable and reload-safe — no hidden component memory.
|
||||||
|
*/
|
||||||
|
export function WeekendFocusBanner({
|
||||||
|
meetingKey,
|
||||||
|
sessionKey,
|
||||||
|
}: {
|
||||||
|
meetingKey?: number
|
||||||
|
sessionKey?: number
|
||||||
|
}) {
|
||||||
|
const hasFocus = (meetingKey != null && meetingKey > 0) || (sessionKey != null && sessionKey > 0)
|
||||||
|
const weekendQuery = useQuery({
|
||||||
|
queryKey: ['weekend', meetingKey],
|
||||||
|
queryFn: () => fetchWeekend(meetingKey!),
|
||||||
|
enabled: meetingKey != null && meetingKey > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!hasFocus) return null
|
||||||
|
|
||||||
|
const weekend = weekendQuery.data
|
||||||
|
const meeting = meetingIdentity(weekend?.meeting)
|
||||||
|
const session = weekend?.sessions.find((s) => s.session.session_key === sessionKey)?.session
|
||||||
|
const meetingLabel = meeting?.meeting_name ?? (meetingKey ? `Meeting ${meetingKey}` : 'Selected weekend')
|
||||||
|
const sessionLabel = session?.session_name ?? (sessionKey ? `Session ${sessionKey}` : null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="wk-focus"
|
||||||
|
data-testid="wk-focus-context"
|
||||||
|
data-meeting-key={meetingKey ?? undefined}
|
||||||
|
data-session-key={sessionKey ?? undefined}
|
||||||
|
aria-label="Selected weekend context"
|
||||||
|
>
|
||||||
|
<div className="wk-focus-copy">
|
||||||
|
<span className="wk-focus-eyebrow mono">From Race Hub</span>
|
||||||
|
<h2 className="wk-focus-title" data-testid="wk-focus-meeting">
|
||||||
|
{meetingLabel}
|
||||||
|
</h2>
|
||||||
|
{sessionLabel && (
|
||||||
|
<p className="wk-focus-session" data-testid="wk-focus-session">
|
||||||
|
Selected session · {sessionLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sessionKey != null && sessionKey > 0 && (
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: sessionKey }}
|
||||||
|
className="wk-cta wk-cta-primary wk-focus-cta"
|
||||||
|
data-testid="wk-return-analysis"
|
||||||
|
>
|
||||||
|
Continue analysis <ChevronRight size={15} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
frontend/src/lib/routeSearch.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/** Shared URL search contracts for deep-linkable Weekend ↔ Race Hub navigation. */
|
||||||
|
|
||||||
|
export type WeekendSearch = {
|
||||||
|
meeting_key?: number
|
||||||
|
session_key?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RaceHubSearch = {
|
||||||
|
session_key?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveInt(value: unknown): number | undefined {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWeekendSearch(search: Record<string, unknown>): WeekendSearch {
|
||||||
|
const meeting_key = parsePositiveInt(search.meeting_key)
|
||||||
|
const session_key = parsePositiveInt(search.session_key)
|
||||||
|
return {
|
||||||
|
...(meeting_key ? { meeting_key } : {}),
|
||||||
|
...(session_key ? { session_key } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRaceHubSearch(search: Record<string, unknown>): RaceHubSearch {
|
||||||
|
const session_key = parsePositiveInt(search.session_key)
|
||||||
|
return session_key ? { session_key } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build Weekend `/` search so a Race Hub return is reload-safe and deep-linkable. */
|
||||||
|
export function weekendFocusSearch(
|
||||||
|
meetingKey?: number | null,
|
||||||
|
sessionKey?: number | null,
|
||||||
|
): WeekendSearch {
|
||||||
|
return {
|
||||||
|
...(meetingKey && meetingKey > 0 ? { meeting_key: meetingKey } : {}),
|
||||||
|
...(sessionKey && sessionKey > 0 ? { session_key: sessionKey } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
|
import { fetchRaceHub, fetchWeekend, fetchWeekendContext } from '../api'
|
||||||
|
import { weekendFocusSearch } from '../lib/routeSearch'
|
||||||
import { DatasetStrip } from '../components/DatasetStrip'
|
import { DatasetStrip } from '../components/DatasetStrip'
|
||||||
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
import { RaceStoryCanvas } from '../components/RaceStoryCanvas'
|
||||||
import { TabBar, type Tab } from '../components/TabBar'
|
import { TabBar, type Tab } from '../components/TabBar'
|
||||||
@@ -123,9 +124,9 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
<a href="/" className="rh-recover-btn">
|
<Link to="/" search={{}} className="rh-recover-btn" data-testid="rh-back-weekend">
|
||||||
Back to Command Center
|
Back to Weekend
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,9 +151,9 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
>
|
>
|
||||||
Browse Weekends
|
Browse Weekends
|
||||||
</button>
|
</button>
|
||||||
<a href="/" className="rh-empty-action">
|
<Link to="/" search={{}} className="rh-empty-action" data-testid="rh-back-weekend">
|
||||||
Back to Command Center
|
Back to Weekend
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{switcherOpen && (
|
{switcherOpen && (
|
||||||
@@ -183,14 +184,10 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (raceHubQuery.isError || !data) {
|
if (raceHubQuery.isError || !data) {
|
||||||
const backMeeting = context?.focus_meeting?.meeting_key
|
const backMeeting =
|
||||||
const backSession =
|
context?.focus_meeting?.meeting_key ??
|
||||||
defaultAnalysisSessionKey(context) ??
|
context?.previous_completed_session?.meeting?.meeting_key
|
||||||
context?.previous_completed_session?.session.session_key
|
const backSearch = weekendFocusSearch(backMeeting, sessionKey)
|
||||||
const backHref =
|
|
||||||
backSession && backSession > 0
|
|
||||||
? `/race-hub?session_key=${backSession}`
|
|
||||||
: '/race-hub'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||||
@@ -209,14 +206,16 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
<a
|
<Link
|
||||||
href={backHref}
|
to="/"
|
||||||
|
search={backSearch}
|
||||||
className="rh-recover-btn"
|
className="rh-recover-btn"
|
||||||
data-testid="rh-back-weekend"
|
data-testid="rh-back-weekend"
|
||||||
data-meeting-key={backMeeting ?? undefined}
|
data-meeting-key={backMeeting ?? undefined}
|
||||||
|
data-session-key={sessionKey}
|
||||||
>
|
>
|
||||||
Back to Weekend
|
Back to Weekend
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,6 +241,16 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||||
<div className="rh-topbar">
|
<div className="rh-topbar">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
search={weekendFocusSearch(meetingKey, sessionKey)}
|
||||||
|
className="rh-back-weekend"
|
||||||
|
data-testid="rh-back-weekend"
|
||||||
|
data-meeting-key={meetingKey ?? undefined}
|
||||||
|
data-session-key={sessionKey}
|
||||||
|
>
|
||||||
|
Back to Weekend
|
||||||
|
</Link>
|
||||||
<span className="rh-topbar-label mono">
|
<span className="rh-topbar-label mono">
|
||||||
box-box · race hub
|
box-box · race hub
|
||||||
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
|
{data.meeting?.year ? ` · ${data.meeting.year}` : ''}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { WeekendFocusBanner } from '../components/weekend/WeekendFocusBanner'
|
||||||
import { resolveViewState } from '../lib/weekendContext'
|
import { resolveViewState } from '../lib/weekendContext'
|
||||||
import type {
|
import type {
|
||||||
WeekendBriefingItem,
|
WeekendBriefingItem,
|
||||||
@@ -59,8 +60,21 @@ function renderState(view: WeekendViewState, args: RenderArgs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WeekendPage({ preview = false }: { preview?: boolean }) {
|
export function WeekendPage({
|
||||||
|
preview = false,
|
||||||
|
focusMeetingKey,
|
||||||
|
focusSessionKey,
|
||||||
|
}: {
|
||||||
|
preview?: boolean
|
||||||
|
/** Restored from `/?meeting_key=` when returning from Race Hub analysis. */
|
||||||
|
focusMeetingKey?: number
|
||||||
|
/** Restored from `/?session_key=` when returning from Race Hub analysis. */
|
||||||
|
focusSessionKey?: number
|
||||||
|
}) {
|
||||||
const { context, loadState, error, championship, briefing, now } = useWeekendContext()
|
const { context, loadState, error, championship, briefing, now } = useWeekendContext()
|
||||||
|
const hasFocus =
|
||||||
|
(focusMeetingKey != null && focusMeetingKey > 0) ||
|
||||||
|
(focusSessionKey != null && focusSessionKey > 0)
|
||||||
|
|
||||||
if (loadState === 'loading') {
|
if (loadState === 'loading') {
|
||||||
return (
|
return (
|
||||||
@@ -87,7 +101,12 @@ export function WeekendPage({ preview = false }: { preview?: boolean }) {
|
|||||||
data-state={view}
|
data-state={view}
|
||||||
data-temporal-state={context.temporal_state}
|
data-temporal-state={context.temporal_state}
|
||||||
data-preview={preview ? 'true' : undefined}
|
data-preview={preview ? 'true' : undefined}
|
||||||
|
data-meeting-key={focusMeetingKey ?? undefined}
|
||||||
|
data-session-key={focusSessionKey ?? undefined}
|
||||||
>
|
>
|
||||||
|
{hasFocus && (
|
||||||
|
<WeekendFocusBanner meetingKey={focusMeetingKey} sessionKey={focusSessionKey} />
|
||||||
|
)}
|
||||||
{renderState(view, { context, now, championship, briefing, preview })}
|
{renderState(view, { context, now, championship, briefing, preview })}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ import { LiveTimingPage } from './pages/LiveTimingPage'
|
|||||||
import { BriefingPage } from './pages/BriefingPage'
|
import { BriefingPage } from './pages/BriefingPage'
|
||||||
import { ChampionshipPage } from './pages/ChampionshipPage'
|
import { ChampionshipPage } from './pages/ChampionshipPage'
|
||||||
import { DriverProfilePage } from './pages/DriverProfilePage'
|
import { DriverProfilePage } from './pages/DriverProfilePage'
|
||||||
|
import { parseRaceHubSearch, parseWeekendSearch } from './lib/routeSearch'
|
||||||
type RaceHubSearch = {
|
|
||||||
session_key?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type DriverProfileSearch = {
|
type DriverProfileSearch = {
|
||||||
year?: number
|
year?: number
|
||||||
@@ -27,10 +24,18 @@ const rootRoute = createRootRoute({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Weekend is the adaptive home. `/` renders the current temporal state.
|
// Weekend is the adaptive home. `/` renders the current temporal state.
|
||||||
|
// Optional meeting_key/session_key search restores Race Hub return context
|
||||||
|
// (deep-linkable, reload-safe — no hidden component memory).
|
||||||
export const weekendRoute = createRoute({
|
export const weekendRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
component: WeekendPage,
|
validateSearch: (search: Record<string, unknown>) => parseWeekendSearch(search),
|
||||||
|
component: function WeekendRoute() {
|
||||||
|
const { meeting_key, session_key } = weekendRoute.useSearch()
|
||||||
|
return (
|
||||||
|
<WeekendPage focusMeetingKey={meeting_key} focusSessionKey={session_key} />
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const exploreRoute = createRoute({
|
export const exploreRoute = createRoute({
|
||||||
@@ -42,10 +47,7 @@ export const exploreRoute = createRoute({
|
|||||||
export const raceHubRoute = createRoute({
|
export const raceHubRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/race-hub',
|
path: '/race-hub',
|
||||||
validateSearch: (search: Record<string, unknown>): RaceHubSearch => {
|
validateSearch: (search: Record<string, unknown>) => parseRaceHubSearch(search),
|
||||||
const sessionKey = Number(search.session_key)
|
|
||||||
return Number.isFinite(sessionKey) && sessionKey > 0 ? { session_key: sessionKey } : {}
|
|
||||||
},
|
|
||||||
component: function RaceHubRoute() {
|
component: function RaceHubRoute() {
|
||||||
const { session_key } = raceHubRoute.useSearch()
|
const { session_key } = raceHubRoute.useSearch()
|
||||||
return <RaceHubPage sessionKey={session_key ?? 0} />
|
return <RaceHubPage sessionKey={session_key ?? 0} />
|
||||||
|
|||||||
@@ -2826,6 +2826,31 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
.rh-topbar-spacer { flex: 1; }
|
.rh-topbar-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.rh-back-weekend {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: color 0.1s, border-color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
.rh-back-weekend:hover,
|
||||||
|
.rh-back-weekend:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--gp-accent);
|
||||||
|
background: var(--surface-h);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.rh-back-weekend:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--gp-accent) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.rh-switcher-toggle {
|
.rh-switcher-toggle {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -3401,7 +3426,10 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.rh-overview-row { grid-template-columns: 1fr; }
|
.rh-overview-row { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.rh-page { padding: var(--s4); gap: var(--s4); }
|
.rh-page { padding: var(--s4); gap: var(--s4); padding-bottom: 88px; }
|
||||||
|
.rh-topbar { flex-wrap: wrap; gap: var(--s3); }
|
||||||
|
.rh-topbar-spacer { display: none; }
|
||||||
|
.rh-back-weekend { order: -1; }
|
||||||
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
|
.rh-identity-body { padding: var(--s3) var(--s4); gap: var(--s3); }
|
||||||
.rh-identity-decal { font-size: 28px; }
|
.rh-identity-decal { font-size: 28px; }
|
||||||
.rh-identity-name { font-size: 17px; }
|
.rh-identity-name { font-size: 17px; }
|
||||||
|
|||||||
@@ -6,6 +6,36 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wk-focus {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s5);
|
||||||
|
margin-bottom: var(--s5);
|
||||||
|
padding: var(--s5);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--red);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.wk-focus-copy { display: flex; flex-direction: column; gap: var(--s2); min-width: 0; }
|
||||||
|
.wk-focus-eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.wk-focus-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.wk-focus-session {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.wk-focus-cta { flex-shrink: 0; }
|
||||||
|
|
||||||
.wk-eyebrow {
|
.wk-eyebrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -313,6 +343,11 @@
|
|||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.wk-page { padding: var(--s5) var(--s5) 88px; }
|
.wk-page { padding: var(--s5) var(--s5) 88px; }
|
||||||
|
.wk-focus {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.wk-focus-cta { width: 100%; justify-content: center; }
|
||||||
.wk-event-name { font-size: 20px; }
|
.wk-event-name { font-size: 20px; }
|
||||||
.wk-sessions-title { font-size: 22px; }
|
.wk-sessions-title { font-size: 22px; }
|
||||||
.wk-count-unit b { font-size: 32px; }
|
.wk-count-unit b { font-size: 32px; }
|
||||||
|
|||||||
@@ -243,6 +243,19 @@ function renderRaceHub(sessionKey: number) {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
const weekendRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
validateSearch: (search: Record<string, unknown>) => {
|
||||||
|
const meetingKey = Number(search.meeting_key)
|
||||||
|
const sk = Number(search.session_key)
|
||||||
|
return {
|
||||||
|
...(Number.isFinite(meetingKey) && meetingKey > 0 ? { meeting_key: meetingKey } : {}),
|
||||||
|
...(Number.isFinite(sk) && sk > 0 ? { session_key: sk } : {}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: () => <div data-testid="weekend-stub" />,
|
||||||
|
})
|
||||||
const raceHubRoute = createRoute({
|
const raceHubRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/race-hub',
|
path: '/race-hub',
|
||||||
@@ -256,7 +269,7 @@ function renderRaceHub(sessionKey: number) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree: rootRoute.addChildren([raceHubRoute]),
|
routeTree: rootRoute.addChildren([weekendRoute, raceHubRoute]),
|
||||||
history: undefined,
|
history: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -541,10 +554,22 @@ describe('RaceHubPage', () => {
|
|||||||
await waitFor(() => expect(screen.getByTestId('race-hub-error')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('race-hub-error')).toBeInTheDocument())
|
||||||
expect(screen.getByTestId('rh-retry')).toBeInTheDocument()
|
expect(screen.getByTestId('rh-retry')).toBeInTheDocument()
|
||||||
const back = screen.getByTestId('rh-back-weekend')
|
const back = screen.getByTestId('rh-back-weekend')
|
||||||
expect(back).toHaveAttribute('href', '/race-hub?session_key=9472')
|
expect(back).toHaveAttribute('href', expect.stringMatching(/meeting_key=1229/))
|
||||||
|
expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/))
|
||||||
|
expect(back.getAttribute('href')).toMatch(/^\//)
|
||||||
|
|
||||||
mockFetchRaceHub.mockResolvedValue(raceHub)
|
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||||
fireEvent.click(screen.getByTestId('rh-retry'))
|
fireEvent.click(screen.getByTestId('rh-retry'))
|
||||||
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exposes a Back to Weekend link that carries meeting and session context', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
const back = screen.getByRole('link', { name: 'Back to Weekend' })
|
||||||
|
expect(back).toHaveAttribute('data-testid', 'rh-back-weekend')
|
||||||
|
expect(back).toHaveAttribute('href', expect.stringMatching(/meeting_key=1229/))
|
||||||
|
expect(back).toHaveAttribute('href', expect.stringMatching(/session_key=9472/))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ import type {
|
|||||||
TemporalState,
|
TemporalState,
|
||||||
WeekendContext,
|
WeekendContext,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
import { parseWeekendSearch } from '../lib/routeSearch'
|
||||||
|
|
||||||
vi.mock('../api', () => ({
|
vi.mock('../api', () => ({
|
||||||
fetchWeekendContext: vi.fn(),
|
fetchWeekendContext: vi.fn(),
|
||||||
fetchChampionshipHub: vi.fn(),
|
fetchChampionshipHub: vi.fn(),
|
||||||
fetchNews: vi.fn(),
|
fetchNews: vi.fn(),
|
||||||
fetchRaceHub: vi.fn(),
|
fetchRaceHub: vi.fn(),
|
||||||
|
fetchWeekend: vi.fn(),
|
||||||
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
|
// Consumed transitively by RacePreviewPage (folded into PreSessionView):
|
||||||
fetchSeasons: vi.fn(),
|
fetchSeasons: vi.fn(),
|
||||||
fetchMeetings: vi.fn(),
|
fetchMeetings: vi.fn(),
|
||||||
@@ -40,6 +42,7 @@ import {
|
|||||||
fetchChampionshipHub,
|
fetchChampionshipHub,
|
||||||
fetchNews,
|
fetchNews,
|
||||||
fetchRaceHub,
|
fetchRaceHub,
|
||||||
|
fetchWeekend,
|
||||||
fetchSeasons,
|
fetchSeasons,
|
||||||
fetchMeetings,
|
fetchMeetings,
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
@@ -52,6 +55,7 @@ const mockContext = vi.mocked(fetchWeekendContext)
|
|||||||
const mockHub = vi.mocked(fetchChampionshipHub)
|
const mockHub = vi.mocked(fetchChampionshipHub)
|
||||||
const mockNews = vi.mocked(fetchNews)
|
const mockNews = vi.mocked(fetchNews)
|
||||||
const mockRaceHub = vi.mocked(fetchRaceHub)
|
const mockRaceHub = vi.mocked(fetchRaceHub)
|
||||||
|
const mockWeekend = vi.mocked(fetchWeekend)
|
||||||
const mockSeasons = vi.mocked(fetchSeasons)
|
const mockSeasons = vi.mocked(fetchSeasons)
|
||||||
const mockMeetings = vi.mocked(fetchMeetings)
|
const mockMeetings = vi.mocked(fetchMeetings)
|
||||||
const mockSessions = vi.mocked(fetchSessions)
|
const mockSessions = vi.mocked(fetchSessions)
|
||||||
@@ -152,7 +156,15 @@ function renderAt(path: string) {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
const homeRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: () => <WeekendPage /> })
|
const homeRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
validateSearch: (s: Record<string, unknown>) => parseWeekendSearch(s),
|
||||||
|
component: function HomeRoute() {
|
||||||
|
const { meeting_key, session_key } = homeRoute.useSearch()
|
||||||
|
return <WeekendPage focusMeetingKey={meeting_key} focusSessionKey={session_key} />
|
||||||
|
},
|
||||||
|
})
|
||||||
const previewRoute = createRoute({
|
const previewRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/preview',
|
path: '/preview',
|
||||||
@@ -192,6 +204,19 @@ describe('WeekendPage canonical contract rendering', () => {
|
|||||||
mockHub.mockResolvedValue(hub)
|
mockHub.mockResolvedValue(hub)
|
||||||
mockNews.mockResolvedValue([])
|
mockNews.mockResolvedValue([])
|
||||||
mockRaceHub.mockResolvedValue(raceHub)
|
mockRaceHub.mockResolvedValue(raceHub)
|
||||||
|
mockWeekend.mockResolvedValue({
|
||||||
|
source: 'local',
|
||||||
|
meeting_key: 1,
|
||||||
|
meeting: meeting(),
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
session: session({ session_key: 11, session_name: 'Race' }),
|
||||||
|
source: 'local',
|
||||||
|
datasets: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default_session_key: 11,
|
||||||
|
})
|
||||||
mockSeasons.mockResolvedValue([2026])
|
mockSeasons.mockResolvedValue([2026])
|
||||||
mockMeetings.mockResolvedValue([meeting()])
|
mockMeetings.mockResolvedValue([meeting()])
|
||||||
mockSessions.mockResolvedValue([session()])
|
mockSessions.mockResolvedValue([session()])
|
||||||
@@ -306,4 +331,27 @@ describe('WeekendPage canonical contract rendering', () => {
|
|||||||
// The between-races surface must NOT be what /preview renders.
|
// The between-races surface must NOT be what /preview renders.
|
||||||
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('weekend-between-races')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('restores Race Hub meeting/session focus from the Weekend URL search contract', async () => {
|
||||||
|
mockContext.mockResolvedValue(
|
||||||
|
context({
|
||||||
|
temporal_state: 'season_complete',
|
||||||
|
previous_completed_session: ctxSession({ session: session({ session_key: 11, session_name: 'Race' }) }),
|
||||||
|
default_analysis_session: ctxSession({ session: session({ session_key: 11 }) }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderAt('/?meeting_key=1&session_key=11')
|
||||||
|
await waitFor(() => expect(screen.getByTestId('wk-focus-context')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1')
|
||||||
|
expect(screen.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '11')
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('wk-focus-meeting')).toHaveTextContent('British Grand Prix'),
|
||||||
|
)
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId('wk-focus-session')).toHaveTextContent('Race'),
|
||||||
|
)
|
||||||
|
const continueAnalysis = screen.getByRole('link', { name: /Continue analysis/i })
|
||||||
|
expect(continueAnalysis).toHaveAttribute('href', expect.stringContaining('session_key=11'))
|
||||||
|
expect(mockWeekend).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
24
frontend/src/test/routeSearch.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseRaceHubSearch, parseWeekendSearch, weekendFocusSearch } from '../lib/routeSearch'
|
||||||
|
|
||||||
|
describe('routeSearch', () => {
|
||||||
|
it('parses positive weekend meeting/session keys and drops invalid values', () => {
|
||||||
|
expect(parseWeekendSearch({ meeting_key: '1229', session_key: '9000' })).toEqual({
|
||||||
|
meeting_key: 1229,
|
||||||
|
session_key: 9000,
|
||||||
|
})
|
||||||
|
expect(parseWeekendSearch({ meeting_key: '0', session_key: '-1', foo: 'bar' })).toEqual({})
|
||||||
|
expect(parseWeekendSearch({})).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses race-hub session keys', () => {
|
||||||
|
expect(parseRaceHubSearch({ session_key: '9472' })).toEqual({ session_key: 9472 })
|
||||||
|
expect(parseRaceHubSearch({ session_key: 'nope' })).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds Weekend focus search for Race Hub returns', () => {
|
||||||
|
expect(weekendFocusSearch(1229, 9000)).toEqual({ meeting_key: 1229, session_key: 9000 })
|
||||||
|
expect(weekendFocusSearch(null, 9000)).toEqual({ session_key: 9000 })
|
||||||
|
expect(weekendFocusSearch(1229, undefined)).toEqual({ meeting_key: 1229 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -150,11 +150,49 @@ test.describe('Race Hub Weekend Workspace', () => {
|
|||||||
await expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`))
|
await expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`))
|
||||||
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
|
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
|
||||||
|
|
||||||
// Back to Weekend via bare /race-hub — Weekend Context should restore the
|
// Explicit Race Hub → Weekend return carries meeting/session in the URL.
|
||||||
// same meeting's default analysis session.
|
const back = page.getByRole('link', { name: 'Back to Weekend' })
|
||||||
await page.goto('/race-hub')
|
await expect(back).toBeVisible()
|
||||||
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
|
await expect(back).toHaveAttribute('href', /meeting_key=1229/)
|
||||||
|
await expect(back).toHaveAttribute('href', /session_key=9000/)
|
||||||
|
await back.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/\?.*meeting_key=1229/)
|
||||||
|
await expect(page).toHaveURL(/session_key=9000/)
|
||||||
|
await expect(page.getByTestId('weekend-page')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1229')
|
||||||
|
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '9000')
|
||||||
|
await expect(page.getByTestId('wk-focus-context')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('wk-focus-meeting')).toContainText('Monaco')
|
||||||
|
await expect(page.getByTestId('wk-focus-session')).toContainText('Core Only')
|
||||||
|
|
||||||
|
// Contextual action returns to the exact selected analysis session.
|
||||||
|
const continueAnalysis = page.getByRole('link', { name: /Continue analysis/i })
|
||||||
|
await expect(continueAnalysis).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
new RegExp(`session_key=${CORE_ONLY_SESSION}`),
|
||||||
|
)
|
||||||
|
await continueAnalysis.click()
|
||||||
|
await expect(page).toHaveURL(new RegExp(`session_key=${CORE_ONLY_SESSION}`))
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
|
await expect(page.getByTestId('rh-identity')).toContainText('Monaco')
|
||||||
await expect(page.getByTestId('rh-overview')).toBeVisible()
|
})
|
||||||
|
|
||||||
|
test('Back to Weekend is keyboard-focusable and works at mobile width', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 })
|
||||||
|
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
|
||||||
|
const back = page.getByRole('link', { name: 'Back to Weekend' })
|
||||||
|
await expect(back).toBeVisible()
|
||||||
|
await back.focus()
|
||||||
|
await expect(back).toBeFocused()
|
||||||
|
await page.keyboard.press('Enter')
|
||||||
|
|
||||||
|
await expect(page.getByTestId('weekend-page')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-meeting-key', '1229')
|
||||||
|
await expect(page.getByTestId('weekend-page')).toHaveAttribute('data-session-key', '9000')
|
||||||
|
await expect(page.getByTestId('wk-focus-context')).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: /Continue analysis/i })).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |