feat(race-hub): trustworthy defaults and fan-facing analysis hierarchy (#75)
Make bare /race-hub resolve to a completed session and never open empty post-session analysis for a future race. - Backend: add `default_analysis_session` to the Weekend context. It never resolves to a future session (picks the richest completed session, ties toward the later one; 0 when everything is upcoming). Existing `default_session_key` and deep links are unchanged. - Frontend default resolution prefers the most recently completed weekend (`pickAnalysisFocusMeeting`) and consumes `default_analysis_session`, falling back to the switcher when only upcoming sessions exist. - New `sessionState` lib maps timing + coverage to user language (upcoming/live/preparing/partial/ready/cancelled); the session rail, active sub-bar, and WeekendSwitcher now label states instead of raw x/11 counts. - Future sessions render a purpose-built PreSessionView (expected availability + countdown) instead of empty Winner/Podium/Pole/Strategy/Compare cards. - Analysis navigation regrouped into Story / Analysis / Data & Context; every existing tab is preserved. Diagnostics (renamed from Data Status) is now a secondary action and the raw dataset strip is hidden behind an explicit toggle, so operational coverage no longer precedes fan content. - Loading/error states offer Retry and a path back to Weekend. Tests: Go query tests for future-exclusion; Vitest for default selection, future pre-session, partial state, error/retry, grouped nav, and sessionState; hermetic Playwright for bare/completed/future/return-to-Weekend; new race-hub-future visual snapshots. Seed adds a far-future session inside the Monaco meeting (kept in-meeting so Command Center focus is unaffected). Note: `default_analysis_session` is an additive field on the existing `/weekend` contract (no new endpoint), per the spec's "context contract" scope. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -148,7 +148,7 @@ export function OverviewView({ data }: Props) {
|
|||||||
? 'Every Race Hub dataset is local for this session.'
|
? 'Every Race Hub dataset is local for this session.'
|
||||||
: `${coverage.total - coverage.available} dataset${
|
: `${coverage.total - coverage.available} dataset${
|
||||||
coverage.total - coverage.available === 1 ? '' : 's'
|
coverage.total - coverage.available === 1 ? '' : 's'
|
||||||
} not ingested yet — see Data Status tab.`}
|
} not ingested yet — see Diagnostics.`}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
72
frontend/src/components/PreSessionView.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { Session } from '../types'
|
||||||
|
import { RACE_HUB_DATASETS } from '../lib/coverage'
|
||||||
|
import { formatCountdown, formatSessionScheduleTime, sessionStartTime } from '../lib/schedule'
|
||||||
|
|
||||||
|
const EXPECTED_LABELS: Record<string, string> = {
|
||||||
|
results: 'Final results',
|
||||||
|
starting_grid: 'Starting grid',
|
||||||
|
stints: 'Tyre strategy',
|
||||||
|
pit_stops: 'Pit stops',
|
||||||
|
positions: 'Position changes',
|
||||||
|
laps: 'Lap times',
|
||||||
|
race_control: 'Race control',
|
||||||
|
weather: 'Track conditions',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
session: Session
|
||||||
|
sessionName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purpose-built view for a session that has not run yet. Instead of rendering
|
||||||
|
* empty Winner / Podium / Pole / Strategy / Compare cards, it explains that the
|
||||||
|
* session is upcoming and previews the analysis that will appear once the data
|
||||||
|
* is ingested.
|
||||||
|
*/
|
||||||
|
export function PreSessionView({ session, sessionName }: Props) {
|
||||||
|
const start = sessionStartTime(session)
|
||||||
|
const [now, setNow] = useState(() => new Date())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!start) return
|
||||||
|
const id = setInterval(() => setNow(new Date()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [start])
|
||||||
|
|
||||||
|
const expected = RACE_HUB_DATASETS.filter((key) => EXPECTED_LABELS[key])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rh-presession" data-testid="rh-presession">
|
||||||
|
<section className="rh-presession-band">
|
||||||
|
<span className="rh-presession-eyebrow mono">Upcoming session</span>
|
||||||
|
<h2 className="rh-presession-title">{sessionName}</h2>
|
||||||
|
<p className="rh-presession-sub">
|
||||||
|
This session hasn’t run yet, so there’s no result to analyse. Winner,
|
||||||
|
podium, pole, strategy and comparison views will appear here once the
|
||||||
|
session completes and its data is ingested.
|
||||||
|
</p>
|
||||||
|
<div className="rh-presession-countdown mono" data-testid="rh-presession-countdown">
|
||||||
|
{start
|
||||||
|
? `Starts ${formatSessionScheduleTime(session.date_start)} · in ${formatCountdown(start, now)}`
|
||||||
|
: 'Start time to be confirmed.'}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="data-section">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Expected once complete</span>
|
||||||
|
</div>
|
||||||
|
<div className="rh-expected-grid">
|
||||||
|
{expected.map((key) => (
|
||||||
|
<div key={key} className="rh-expected-card">
|
||||||
|
<span className="rh-expected-dot" aria-hidden="true" />
|
||||||
|
<span>{EXPECTED_LABELS[key]}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,15 +8,46 @@ export type Tab =
|
|||||||
| 'race_control'
|
| 'race_control'
|
||||||
| 'data_status'
|
| 'data_status'
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string }[] = [
|
interface TabDef {
|
||||||
{ id: 'overview', label: 'Overview' },
|
id: Tab
|
||||||
{ id: 'race_story', label: 'Race Story' },
|
label: string
|
||||||
{ id: 'strategy', label: 'Strategy' },
|
}
|
||||||
{ id: 'compare', label: 'Compare' },
|
|
||||||
{ id: 'lap_data', label: 'Lap Data' },
|
interface TabGroup {
|
||||||
{ id: 'conditions', label: 'Conditions' },
|
id: string
|
||||||
{ id: 'race_control', label: 'Race Control' },
|
label: string
|
||||||
{ id: 'data_status', label: 'Data Status' },
|
tabs: TabDef[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan-facing hierarchy: Story first, then Analysis, then Data/Context. Every
|
||||||
|
// existing capability is preserved — only the grouping and ordering change.
|
||||||
|
const TAB_GROUPS: TabGroup[] = [
|
||||||
|
{
|
||||||
|
id: 'story',
|
||||||
|
label: 'Story',
|
||||||
|
tabs: [
|
||||||
|
{ id: 'overview', label: 'Overview' },
|
||||||
|
{ id: 'race_story', label: 'Race Story' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'analysis',
|
||||||
|
label: 'Analysis',
|
||||||
|
tabs: [
|
||||||
|
{ id: 'strategy', label: 'Strategy' },
|
||||||
|
{ id: 'compare', label: 'Compare' },
|
||||||
|
{ id: 'lap_data', label: 'Lap Data' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'context',
|
||||||
|
label: 'Data & Context',
|
||||||
|
tabs: [
|
||||||
|
{ id: 'conditions', label: 'Conditions' },
|
||||||
|
{ id: 'race_control', label: 'Race Control' },
|
||||||
|
{ id: 'data_status', label: 'Diagnostics' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,17 +57,26 @@ interface Props {
|
|||||||
|
|
||||||
export function TabBar({ active, onChange }: Props) {
|
export function TabBar({ active, onChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="tab-bar" role="tablist">
|
<div className="tab-bar tab-bar-grouped" role="tablist" data-testid="rh-tabbar">
|
||||||
{TABS.map((t) => (
|
{TAB_GROUPS.map((group) => (
|
||||||
<button
|
<div key={group.id} className="tab-group" data-testid={`rh-tabgroup-${group.id}`}>
|
||||||
key={t.id}
|
<span className="tab-group-label mono" aria-hidden="true">
|
||||||
role="tab"
|
{group.label}
|
||||||
aria-selected={active === t.id}
|
</span>
|
||||||
className={`tab-btn${active === t.id ? ' active' : ''}`}
|
<div className="tab-group-btns">
|
||||||
onClick={() => onChange(t.id)}
|
{group.tabs.map((t) => (
|
||||||
>
|
<button
|
||||||
{t.label}
|
key={t.id}
|
||||||
</button>
|
role="tab"
|
||||||
|
aria-selected={active === t.id}
|
||||||
|
className={`tab-btn${active === t.id ? ' active' : ''}`}
|
||||||
|
onClick={() => onChange(t.id)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
|
||||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||||
|
import { sessionState, sessionStateDotClass, sessionStateLabel } from '../lib/sessionState'
|
||||||
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,11 +20,24 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
currentMeetingKey ?? null,
|
currentMeetingKey ?? null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const weekendQuery = useQuery({
|
||||||
|
queryKey: ['weekend', openMeetingKey],
|
||||||
|
queryFn: () => fetchWeekend(openMeetingKey!),
|
||||||
|
enabled: openMeetingKey != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Default the visible season to the current weekend's year so the current
|
||||||
|
// meeting card is actually rendered (seasons are newest-first, which can be a
|
||||||
|
// future season). Fall back to the newest season only when there's no context.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (year == null && seasonsQuery.data?.length) {
|
if (year != null) return
|
||||||
|
const currentYear = weekendQuery.data?.meeting?.year
|
||||||
|
if (currentYear) {
|
||||||
|
setYear(currentYear)
|
||||||
|
} else if (seasonsQuery.data?.length) {
|
||||||
setYear(seasonsQuery.data[0])
|
setYear(seasonsQuery.data[0])
|
||||||
}
|
}
|
||||||
}, [seasonsQuery.data, year])
|
}, [seasonsQuery.data, weekendQuery.data, year])
|
||||||
|
|
||||||
const meetingsQuery = useQuery({
|
const meetingsQuery = useQuery({
|
||||||
queryKey: ['meetings', year],
|
queryKey: ['meetings', year],
|
||||||
@@ -31,15 +45,10 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
enabled: year != null,
|
enabled: year != null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const weekendQuery = useQuery({
|
|
||||||
queryKey: ['weekend', openMeetingKey],
|
|
||||||
queryFn: () => fetchWeekend(openMeetingKey!),
|
|
||||||
enabled: openMeetingKey != null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const seasons = seasonsQuery.data ?? []
|
const seasons = seasonsQuery.data ?? []
|
||||||
const meetings = meetingsQuery.data ?? []
|
const meetings = meetingsQuery.data ?? []
|
||||||
const weekend = weekendQuery.data
|
const weekend = weekendQuery.data
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
function openSession(sessionKey: number) {
|
function openSession(sessionKey: number) {
|
||||||
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
|
navigate({ to: '/race-hub', search: { session_key: sessionKey } })
|
||||||
@@ -107,8 +116,10 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
<div className="rh-switcher-empty">loading sessions…</div>
|
<div className="rh-switcher-empty">loading sessions…</div>
|
||||||
)}
|
)}
|
||||||
{weekend && weekend.meeting_key === m.meeting_key &&
|
{weekend && weekend.meeting_key === m.meeting_key &&
|
||||||
weekend.sessions.map(({ session, source, datasets }) => {
|
weekend.sessions.map((weekendSession) => {
|
||||||
|
const { session } = weekendSession
|
||||||
const active = session.session_key === currentSessionKey
|
const active = session.session_key === currentSessionKey
|
||||||
|
const state = sessionState(weekendSession, now)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.session_key}
|
key={session.session_key}
|
||||||
@@ -122,8 +133,11 @@ export function WeekendSwitcher({ currentMeetingKey, currentSessionKey, onClose
|
|||||||
</span>
|
</span>
|
||||||
<span className="rh-switcher-sess-name">{session.session_name}</span>
|
<span className="rh-switcher-sess-name">{session.session_name}</span>
|
||||||
<span className="rh-switcher-sess-cov mono">
|
<span className="rh-switcher-sess-cov mono">
|
||||||
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
<span
|
||||||
{formatCoverageHint(datasets)}
|
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{sessionStateLabel(state)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ export function pickFocusMeeting(meetings: Meeting[], now: Date): Meeting | null
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meeting a fan-facing default landing (bare `/race-hub`) should open. Unlike
|
||||||
|
* `pickFocusMeeting`, it prefers a completed weekend over an upcoming one so the
|
||||||
|
* default never lands on a future race with no analysis. Falls back to the next
|
||||||
|
* upcoming meeting only when nothing has happened yet.
|
||||||
|
*/
|
||||||
|
export function pickAnalysisFocusMeeting(meetings: Meeting[], now: Date): Meeting | null {
|
||||||
|
return (
|
||||||
|
currentMeeting(meetings, now) ??
|
||||||
|
mostRecentPastMeeting(meetings, now) ??
|
||||||
|
nextUpcomingMeeting(meetings, now) ??
|
||||||
|
meetings[0] ??
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function meetingHasStarted(meeting: Meeting, now: Date): boolean {
|
export function meetingHasStarted(meeting: Meeting, now: Date): boolean {
|
||||||
const start = meetingStartTime(meeting)
|
const start = meetingStartTime(meeting)
|
||||||
return start != null && now >= start
|
return start != null && now >= start
|
||||||
|
|||||||
100
frontend/src/lib/sessionState.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { WeekendSession } from '../types'
|
||||||
|
import { isSessionComplete } from './coverage'
|
||||||
|
import { sessionEndTime, sessionStartTime } from './schedule'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-facing lifecycle state for a weekend session. Combines the schedule
|
||||||
|
* (has it started / finished) with local dataset coverage so the UI can speak
|
||||||
|
* in fan language instead of raw `x/11` coverage counts.
|
||||||
|
*
|
||||||
|
* - `upcoming` — starts in the future; render a pre-session view.
|
||||||
|
* - `live` — currently running (started, not yet finished).
|
||||||
|
* - `preparing` — finished (or unknown timing) but no local analysis yet.
|
||||||
|
* - `partial` — finished with some, but not all, local datasets.
|
||||||
|
* - `ready` — finished with full local coverage; analysis is trustworthy.
|
||||||
|
* - `cancelled` — session was cancelled.
|
||||||
|
*/
|
||||||
|
export type SessionState =
|
||||||
|
| 'upcoming'
|
||||||
|
| 'live'
|
||||||
|
| 'preparing'
|
||||||
|
| 'partial'
|
||||||
|
| 'ready'
|
||||||
|
| 'cancelled'
|
||||||
|
|
||||||
|
export function sessionState(session: WeekendSession, now: Date): SessionState {
|
||||||
|
if (session.source === 'cancelled') return 'cancelled'
|
||||||
|
|
||||||
|
const start = sessionStartTime(session.session)
|
||||||
|
const end = sessionEndTime(session.session)
|
||||||
|
|
||||||
|
if (start && start > now) return 'upcoming'
|
||||||
|
if (start && end && now >= start && now < end) return 'live'
|
||||||
|
|
||||||
|
// Session has started/finished (or timing unknown) — describe it by coverage.
|
||||||
|
if (isSessionComplete(session.datasets)) return 'ready'
|
||||||
|
if (session.source === 'none') return 'preparing'
|
||||||
|
return 'partial'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short label suitable for chips and the session switcher. */
|
||||||
|
export function sessionStateLabel(state: SessionState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'upcoming':
|
||||||
|
return 'Upcoming'
|
||||||
|
case 'live':
|
||||||
|
return 'Live'
|
||||||
|
case 'preparing':
|
||||||
|
return 'Preparing'
|
||||||
|
case 'partial':
|
||||||
|
return 'Partial'
|
||||||
|
case 'ready':
|
||||||
|
return 'Ready'
|
||||||
|
case 'cancelled':
|
||||||
|
return 'Cancelled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longer, sentence-style description for headers and empty states. */
|
||||||
|
export function sessionStateDescription(state: SessionState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'upcoming':
|
||||||
|
return 'Session has not started yet.'
|
||||||
|
case 'live':
|
||||||
|
return 'Session is running now.'
|
||||||
|
case 'preparing':
|
||||||
|
return 'Analysis is being prepared — no local data ingested yet.'
|
||||||
|
case 'partial':
|
||||||
|
return 'Partial analysis available — some datasets are still missing.'
|
||||||
|
case 'ready':
|
||||||
|
return 'Full analysis is ready.'
|
||||||
|
case 'cancelled':
|
||||||
|
return 'This session was cancelled.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class suffix used for the coverage dot, so the rail can colour a session by
|
||||||
|
* its lifecycle state rather than only by data source.
|
||||||
|
*/
|
||||||
|
export function sessionStateDotClass(state: SessionState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'ready':
|
||||||
|
return 'rh-state-ready'
|
||||||
|
case 'partial':
|
||||||
|
return 'rh-state-partial'
|
||||||
|
case 'live':
|
||||||
|
return 'rh-state-live'
|
||||||
|
case 'upcoming':
|
||||||
|
return 'rh-state-upcoming'
|
||||||
|
case 'cancelled':
|
||||||
|
return 'rh-state-cancelled'
|
||||||
|
default:
|
||||||
|
return 'rh-state-preparing'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a session should render the pre-session (expected availability) view. */
|
||||||
|
export function isPreSession(state: SessionState): boolean {
|
||||||
|
return state === 'upcoming'
|
||||||
|
}
|
||||||
@@ -17,37 +17,63 @@ import { CompareView } from '../components/CompareView'
|
|||||||
import { RaceControlView } from '../components/RaceControlView'
|
import { RaceControlView } from '../components/RaceControlView'
|
||||||
import { WeatherView } from '../components/WeatherView'
|
import { WeatherView } from '../components/WeatherView'
|
||||||
import { OverviewView } from '../components/OverviewView'
|
import { OverviewView } from '../components/OverviewView'
|
||||||
|
import { PreSessionView } from '../components/PreSessionView'
|
||||||
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
import { WeekendSwitcher } from '../components/WeekendSwitcher'
|
||||||
import { SourceBadge } from '../components/SourceBadge'
|
import { SourceBadge } from '../components/SourceBadge'
|
||||||
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
import { formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
|
import { sessionTypeAbbrev } from '../lib/coverage'
|
||||||
import {
|
import {
|
||||||
formatSessionScheduleTime,
|
formatSessionScheduleTime,
|
||||||
pickFocusMeeting,
|
pickAnalysisFocusMeeting,
|
||||||
|
sessionStartTime,
|
||||||
sortSessionsByStart,
|
sortSessionsByStart,
|
||||||
} from '../lib/schedule'
|
} from '../lib/schedule'
|
||||||
|
import {
|
||||||
|
isPreSession,
|
||||||
|
sessionState,
|
||||||
|
sessionStateDotClass,
|
||||||
|
sessionStateLabel,
|
||||||
|
} from '../lib/sessionState'
|
||||||
import type { Weekend, WeekendSession } from '../types'
|
import type { Weekend, WeekendSession } from '../types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionKey: number
|
sessionKey: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
|
/**
|
||||||
|
* Resolve the session a bare `/race-hub` should open. Prefers the canonical
|
||||||
|
* Weekend Context `default_analysis_session` (which never points at a future
|
||||||
|
* session), then any completed session with the richest coverage. Returns
|
||||||
|
* `undefined` when every session is still upcoming so the caller can fall back
|
||||||
|
* to the switcher instead of opening empty analysis.
|
||||||
|
*/
|
||||||
|
function pickAnalysisSession(weekend: Weekend | undefined, now: Date): number | undefined {
|
||||||
if (!weekend) return undefined
|
if (!weekend) return undefined
|
||||||
const local = weekend.sessions.filter((s) => s.source === 'local')
|
if (weekend.default_analysis_session && weekend.default_analysis_session > 0) {
|
||||||
const partial = weekend.sessions.filter((s) => s.source === 'partial')
|
return weekend.default_analysis_session
|
||||||
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
|
}
|
||||||
|
|
||||||
|
const started = weekend.sessions.filter((s) => {
|
||||||
|
const start = sessionStartTime(s.session)
|
||||||
|
return !start || start <= now
|
||||||
|
})
|
||||||
|
if (started.length === 0) return undefined
|
||||||
|
|
||||||
|
const local = started.filter((s) => s.source === 'local')
|
||||||
|
const partial = started.filter((s) => s.source === 'partial')
|
||||||
|
const pool = local.length > 0 ? local : partial.length > 0 ? partial : started
|
||||||
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
||||||
if (race) return race
|
if (race) return race.session.session_key
|
||||||
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
|
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
|
||||||
if (qual) return qual
|
if (qual) return qual.session.session_key
|
||||||
return pool[0]
|
return pool[pool.length - 1]?.session.session_key
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RaceHubPage({ sessionKey }: Props) {
|
export function RaceHubPage({ sessionKey }: Props) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
const [activeTab, setActiveTab] = useState<Tab>('overview')
|
||||||
const [switcherOpen, setSwitcherOpen] = useState(false)
|
const [switcherOpen, setSwitcherOpen] = useState(false)
|
||||||
|
const now = useMemo(() => new Date(), [])
|
||||||
|
|
||||||
// ─── Auto-redirect when no session_key is supplied ───
|
// ─── Auto-redirect when no session_key is supplied ───
|
||||||
const seasonsQuery = useQuery({
|
const seasonsQuery = useQuery({
|
||||||
@@ -66,8 +92,8 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
|
|
||||||
const focusMeeting = useMemo(() => {
|
const focusMeeting = useMemo(() => {
|
||||||
if (sessionKey !== 0 || !meetingsQuery.data) return null
|
if (sessionKey !== 0 || !meetingsQuery.data) return null
|
||||||
return pickFocusMeeting(meetingsQuery.data, new Date())
|
return pickAnalysisFocusMeeting(meetingsQuery.data, now)
|
||||||
}, [sessionKey, meetingsQuery.data])
|
}, [sessionKey, meetingsQuery.data, now])
|
||||||
|
|
||||||
const fallbackWeekendQuery = useQuery({
|
const fallbackWeekendQuery = useQuery({
|
||||||
queryKey: ['weekend', focusMeeting?.meeting_key],
|
queryKey: ['weekend', focusMeeting?.meeting_key],
|
||||||
@@ -79,13 +105,11 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
if (sessionKey !== 0) return
|
if (sessionKey !== 0) return
|
||||||
const weekend = fallbackWeekendQuery.data
|
const weekend = fallbackWeekendQuery.data
|
||||||
if (!weekend) return
|
if (!weekend) return
|
||||||
const target = pickAnalysisSession(weekend)?.session.session_key
|
const target = pickAnalysisSession(weekend, now)
|
||||||
?? weekend.default_session_key
|
|
||||||
?? weekend.sessions[0]?.session.session_key
|
|
||||||
if (target) {
|
if (target) {
|
||||||
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
|
navigate({ to: '/race-hub', search: { session_key: target }, replace: true })
|
||||||
}
|
}
|
||||||
}, [sessionKey, fallbackWeekendQuery.data, navigate])
|
}, [sessionKey, fallbackWeekendQuery.data, navigate, now])
|
||||||
|
|
||||||
// ─── Active session payload ───
|
// ─── Active session payload ───
|
||||||
const raceHubQuery = useQuery({
|
const raceHubQuery = useQuery({
|
||||||
@@ -108,6 +132,8 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const accent = countryAccent(data?.meeting ?? null)
|
const accent = countryAccent(data?.meeting ?? null)
|
||||||
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||||
|
|
||||||
|
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
||||||
|
|
||||||
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
|
// ─── No session_key: show resolving state, fall back to switcher if no local data ───
|
||||||
if (sessionKey === 0) {
|
if (sessionKey === 0) {
|
||||||
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
|
if (seasonsQuery.isLoading || meetingsQuery.isLoading || fallbackWeekendQuery.isLoading) {
|
||||||
@@ -136,6 +162,38 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Weekend resolved but every session is upcoming — offer the switcher instead
|
||||||
|
// of silently opening empty analysis.
|
||||||
|
if (fallbackWeekendQuery.data && !pickAnalysisSession(fallbackWeekendQuery.data, now)) {
|
||||||
|
return (
|
||||||
|
<div className="rh-page rh-empty" data-testid="race-hub-no-analysis" style={accentStyle}>
|
||||||
|
<div className="rh-empty-band">
|
||||||
|
<span className="rh-empty-eyebrow mono">box-box · race hub</span>
|
||||||
|
<h1 className="rh-empty-title">No completed session to analyse yet</h1>
|
||||||
|
<p className="rh-empty-sub">
|
||||||
|
The next weekend hasn’t run. Pick a past session to review, or check
|
||||||
|
back once it’s complete.
|
||||||
|
</p>
|
||||||
|
<div className="rh-empty-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rh-empty-action"
|
||||||
|
onClick={() => setSwitcherOpen(true)}
|
||||||
|
>
|
||||||
|
Browse Weekends
|
||||||
|
</button>
|
||||||
|
<a href="/" className="rh-empty-action">Back to Command Center</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{switcherOpen && (
|
||||||
|
<WeekendSwitcher
|
||||||
|
currentMeetingKey={fallbackWeekendQuery.data.meeting_key}
|
||||||
|
onClose={() => setSwitcherOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
<div className="loading-state">resolving latest local weekend…</div>
|
<div className="loading-state">resolving latest local weekend…</div>
|
||||||
@@ -143,7 +201,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Loading / error for the requested session_key ───
|
// ─── Loading / error for the requested session_key (retry + back to weekend) ───
|
||||||
if (raceHubQuery.isLoading) {
|
if (raceHubQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" style={accentStyle}>
|
||||||
@@ -153,11 +211,26 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
}
|
}
|
||||||
if (raceHubQuery.isError || !data) {
|
if (raceHubQuery.isError || !data) {
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub-error" style={accentStyle}>
|
||||||
<div className="error-box">
|
<div className="rh-recover">
|
||||||
{raceHubQuery.error instanceof Error
|
<div className="error-box">
|
||||||
? raceHubQuery.error.message
|
{raceHubQuery.error instanceof Error
|
||||||
: `Failed to load session ${sessionKey}.`}
|
? raceHubQuery.error.message
|
||||||
|
: `Failed to load session ${sessionKey}.`}
|
||||||
|
</div>
|
||||||
|
<div className="rh-recover-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rh-recover-btn primary"
|
||||||
|
onClick={() => raceHubQuery.refetch()}
|
||||||
|
data-testid="rh-retry"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<a href="/race-hub" className="rh-recover-btn" data-testid="rh-back-weekend">
|
||||||
|
Back to Weekend
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -168,7 +241,9 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
const sessionMeta = weekend
|
const sessionMeta = weekend
|
||||||
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
? Object.fromEntries(weekend.sessions.map((w) => [w.session.session_key, w]))
|
||||||
: {}
|
: {}
|
||||||
const activeSessionMeta = sessionMeta[sessionKey]
|
const activeSessionMeta: WeekendSession | undefined = sessionMeta[sessionKey]
|
||||||
|
const activeState = activeSessionMeta ? sessionState(activeSessionMeta, now) : undefined
|
||||||
|
const preSession = activeState != null && isPreSession(activeState)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
<div className="rh-page" data-testid="race-hub" style={accentStyle}>
|
||||||
@@ -226,6 +301,7 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const meta = sessionMeta[session.session_key]
|
const meta = sessionMeta[session.session_key]
|
||||||
const active = session.session_key === sessionKey
|
const active = session.session_key === sessionKey
|
||||||
|
const state = meta ? sessionState(meta, now) : undefined
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.session_key}
|
key={session.session_key}
|
||||||
@@ -247,13 +323,13 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<span className="rh-session-time mono">
|
<span className="rh-session-time mono">
|
||||||
{formatSessionScheduleTime(session.date_start)}
|
{formatSessionScheduleTime(session.date_start)}
|
||||||
</span>
|
</span>
|
||||||
{meta && (
|
{state && (
|
||||||
<span className="rh-session-cov mono">
|
<span className="rh-session-cov mono">
|
||||||
<span
|
<span
|
||||||
className={`cc-cov-dot cc-cov-${meta.source}`}
|
className={`cc-cov-dot ${sessionStateDotClass(state)}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{formatCoverageHint(meta.datasets)}
|
{sessionStateLabel(state)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -269,101 +345,119 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
<span className="rh-active-meta mono">
|
<span className="rh-active-meta mono">
|
||||||
{formatSessionScheduleTime(data.session.date_start)}
|
{formatSessionScheduleTime(data.session.date_start)}
|
||||||
</span>
|
</span>
|
||||||
{activeSessionMeta && (
|
{activeState && (
|
||||||
<span className="rh-active-cov mono">
|
<span className="rh-active-cov mono" data-testid="rh-active-state">
|
||||||
<span
|
<span
|
||||||
className={`cc-cov-dot cc-cov-${activeSessionMeta.source}`}
|
className={`cc-cov-dot ${sessionStateDotClass(activeState)}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{formatCoverageHint(activeSessionMeta.datasets)} datasets local
|
{sessionStateLabel(activeState)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="rh-active-key mono">key {sessionKey}</span>
|
<span className="rh-active-key mono">key {sessionKey}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DatasetStrip datasets={data.datasets} />
|
{preSession && data.session ? (
|
||||||
|
<PreSessionView session={data.session} sessionName={data.session.session_name} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TabBar active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
<TabBar active={activeTab} onChange={setActiveTab} />
|
{activeTab === 'overview' && <OverviewView data={data} />}
|
||||||
|
|
||||||
{activeTab === 'overview' && <OverviewView data={data} />}
|
{activeTab === 'race_story' && (
|
||||||
|
<div className="data-section">
|
||||||
|
<RaceStoryCanvas data={data} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'race_story' && (
|
{activeTab === 'strategy' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<RaceStoryCanvas data={data} />
|
<div className="sec-header">
|
||||||
</div>
|
<span className="sec-title">Race Strategy</span>
|
||||||
)}
|
</div>
|
||||||
|
<StrategyView
|
||||||
|
results={data.results}
|
||||||
|
stints={data.stints}
|
||||||
|
pit_stops={data.pit_stops}
|
||||||
|
hasStints={data.datasets['stints']?.status === 'available'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'strategy' && (
|
{activeTab === 'compare' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Race Strategy</span>
|
<span className="sec-title">Driver Compare</span>
|
||||||
</div>
|
</div>
|
||||||
<StrategyView
|
<CompareView
|
||||||
results={data.results}
|
sessionKey={sessionKey}
|
||||||
stints={data.stints}
|
results={data.results}
|
||||||
pit_stops={data.pit_stops}
|
drivers={data.drivers}
|
||||||
hasStints={data.datasets['stints']?.status === 'available'}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'compare' && (
|
{activeTab === 'lap_data' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Driver Compare</span>
|
<span className="sec-title">Lap Data</span>
|
||||||
</div>
|
{data.laps.length > 0 && (
|
||||||
<CompareView
|
<span className="sec-meta mono">{data.laps.length} samples</span>
|
||||||
sessionKey={sessionKey}
|
)}
|
||||||
results={data.results}
|
</div>
|
||||||
drivers={data.drivers}
|
<LapsView laps={data.laps} drivers={data.drivers} />
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'lap_data' && (
|
{activeTab === 'conditions' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Lap Data</span>
|
<span className="sec-title">Conditions</span>
|
||||||
{data.laps.length > 0 && (
|
{data.weather.length > 0 && (
|
||||||
<span className="sec-meta mono">{data.laps.length} samples</span>
|
<span className="sec-meta mono">{data.weather.length} samples</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<LapsView laps={data.laps} drivers={data.drivers} />
|
<WeatherView weather={data.weather} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'conditions' && (
|
{activeTab === 'race_control' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Conditions</span>
|
<span className="sec-title">Race Control</span>
|
||||||
{data.weather.length > 0 && (
|
{data.race_control.length > 0 && (
|
||||||
<span className="sec-meta mono">{data.weather.length} samples</span>
|
<span className="sec-meta mono">{data.race_control.length} messages</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<WeatherView weather={data.weather} />
|
<RaceControlView messages={data.race_control} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'race_control' && (
|
{activeTab === 'data_status' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
<span className="sec-title">Race Control</span>
|
<span className="sec-title">Diagnostics</span>
|
||||||
{data.race_control.length > 0 && (
|
<button
|
||||||
<span className="sec-meta mono">{data.race_control.length} messages</span>
|
type="button"
|
||||||
)}
|
className={`rh-diagnostics-toggle${showDiagnostics ? ' active' : ''}`}
|
||||||
</div>
|
onClick={() => setShowDiagnostics((v) => !v)}
|
||||||
<RaceControlView messages={data.race_control} />
|
aria-expanded={showDiagnostics}
|
||||||
</div>
|
data-testid="rh-diagnostics-toggle"
|
||||||
)}
|
>
|
||||||
|
{showDiagnostics ? 'Hide dataset coverage' : 'Show dataset coverage'}
|
||||||
{activeTab === 'data_status' && (
|
</button>
|
||||||
<div className="data-section">
|
</div>
|
||||||
<div className="sec-header">
|
<DatasetStatusView datasets={data.datasets} />
|
||||||
<span className="sec-title">Data Status</span>
|
{showDiagnostics && (
|
||||||
</div>
|
<div style={{ marginTop: 'var(--s5)' }} data-testid="rh-dataset-strip">
|
||||||
<DatasetStatusView datasets={data.datasets} />
|
<DatasetStrip datasets={data.datasets} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1820,6 +1820,141 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.tab-btn:hover { color: var(--text-2); }
|
.tab-btn:hover { color: var(--text-2); }
|
||||||
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
|
.tab-btn.active { color: var(--text); border-bottom-color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Grouped analysis navigation ── */
|
||||||
|
.tab-bar-grouped {
|
||||||
|
gap: var(--s5);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.tab-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tab-group + .tab-group {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding-left: var(--s5);
|
||||||
|
}
|
||||||
|
.tab-group-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
opacity: 0.7;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
.tab-group-btns {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Session lifecycle state dots ── */
|
||||||
|
.rh-state-ready { background: var(--green); }
|
||||||
|
.rh-state-partial { background: var(--yellow); }
|
||||||
|
.rh-state-live { background: var(--red); }
|
||||||
|
.rh-state-upcoming { background: var(--text-3); }
|
||||||
|
.rh-state-preparing { background: var(--text-3); opacity: 0.5; }
|
||||||
|
.rh-state-cancelled { background: var(--text-3); opacity: 0.35; }
|
||||||
|
|
||||||
|
/* ── Pre-session (expected availability) view ── */
|
||||||
|
.rh-presession {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
.rh-presession-band {
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--gp-accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
.rh-presession-eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
.rh-presession-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.rh-presession-sub { color: var(--text-2); max-width: 60ch; }
|
||||||
|
.rh-presession-countdown {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-top: var(--s3);
|
||||||
|
}
|
||||||
|
.rh-expected-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.rh-expected-card {
|
||||||
|
padding: var(--s4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s3);
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.rh-expected-card .rh-expected-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-3);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading / error recovery ── */
|
||||||
|
.rh-recover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--s4);
|
||||||
|
padding: var(--s6) var(--s5);
|
||||||
|
}
|
||||||
|
.rh-recover-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.rh-recover-btn {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.rh-recover-btn:hover { border-color: var(--gp-accent); }
|
||||||
|
.rh-recover-btn.primary { border-color: var(--red); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Diagnostics (secondary) action ── */
|
||||||
|
.rh-diagnostics-toggle {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rh-diagnostics-toggle:hover { color: var(--text-2); border-color: var(--gp-accent); }
|
||||||
|
.rh-diagnostics-toggle.active { color: var(--text); }
|
||||||
|
|
||||||
/* ── Dataset status view ── */
|
/* ── Dataset status view ── */
|
||||||
.ds-legend {
|
.ds-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,9 @@ const mockFetchSeasons = vi.mocked(fetchSeasons)
|
|||||||
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
|
||||||
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
const mockFetchWeekend = vi.mocked(fetchWeekend)
|
||||||
|
|
||||||
|
// Use a fixed clock so upcoming/completed states are deterministic in tests.
|
||||||
|
const NOW = new Date('2025-06-01T00:00:00Z')
|
||||||
|
|
||||||
const meeting: Meeting = {
|
const meeting: Meeting = {
|
||||||
meeting_key: 1229,
|
meeting_key: 1229,
|
||||||
meeting_name: 'Monaco Grand Prix',
|
meeting_name: 'Monaco Grand Prix',
|
||||||
@@ -59,6 +62,17 @@ const qualSession: Session = {
|
|||||||
gmt_offset: '02:00:00',
|
gmt_offset: '02:00:00',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A session scheduled far in the future relative to NOW.
|
||||||
|
const futureSession: Session = {
|
||||||
|
session_key: 9600,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1300,
|
||||||
|
date_start: '2099-05-25T13:00:00+00:00',
|
||||||
|
date_end: '2099-05-25T15:00:00+00:00',
|
||||||
|
gmt_offset: '02:00:00',
|
||||||
|
}
|
||||||
|
|
||||||
const fullDatasets: Record<string, DatasetInfo> = {
|
const fullDatasets: Record<string, DatasetInfo> = {
|
||||||
meeting: { status: 'available', source: 'local', count: 1 },
|
meeting: { status: 'available', source: 'local', count: 1 },
|
||||||
session: { status: 'available', source: 'local', count: 1 },
|
session: { status: 'available', source: 'local', count: 1 },
|
||||||
@@ -163,6 +177,7 @@ const weekend: Weekend = {
|
|||||||
meeting_key: 1229,
|
meeting_key: 1229,
|
||||||
meeting,
|
meeting,
|
||||||
default_session_key: 9472,
|
default_session_key: 9472,
|
||||||
|
default_analysis_session: 9472,
|
||||||
sessions: [
|
sessions: [
|
||||||
{ session: qualSession, source: 'local', datasets: fullDatasets },
|
{ session: qualSession, source: 'local', datasets: fullDatasets },
|
||||||
{ session: raceSession, source: 'local', datasets: fullDatasets },
|
{ session: raceSession, source: 'local', datasets: fullDatasets },
|
||||||
@@ -206,12 +221,18 @@ function renderRaceHub(sessionKey: number) {
|
|||||||
describe('RaceHubPage', () => {
|
describe('RaceHubPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
|
vi.setSystemTime(NOW)
|
||||||
mockFetchSeasons.mockResolvedValue([2025])
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||||
mockFetchWeekend.mockResolvedValue(weekend)
|
mockFetchWeekend.mockResolvedValue(weekend)
|
||||||
mockFetchRaceHub.mockResolvedValue(raceHub)
|
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
|
it('renders the workspace identity band, session rail, and overview for a known session', async () => {
|
||||||
renderRaceHub(9472)
|
renderRaceHub(9472)
|
||||||
|
|
||||||
@@ -239,14 +260,44 @@ describe('RaceHubPage', () => {
|
|||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps Data Status accessible and free of inline CLI guidance', async () => {
|
it('keeps Diagnostics accessible behind a secondary action, free of inline CLI guidance', async () => {
|
||||||
renderRaceHub(9472)
|
renderRaceHub(9472)
|
||||||
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Data Status' }))
|
fireEvent.click(screen.getByRole('tab', { name: 'Diagnostics' }))
|
||||||
|
|
||||||
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
|
expect(screen.getByTestId('rh-data-status')).toBeInTheDocument()
|
||||||
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
expect(screen.queryByText(/ingest-session/i)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Raw dataset coverage strip is hidden until explicitly requested.
|
||||||
|
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByTestId('rh-diagnostics-toggle'))
|
||||||
|
expect(screen.getByTestId('rh-dataset-strip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the raw dataset strip before fan-facing content', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Overview (fan content) is present, but the raw diagnostics strip is not.
|
||||||
|
expect(screen.getByTestId('rh-overview')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('rh-dataset-strip')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groups analysis navigation into Story, Analysis, and Data & Context', async () => {
|
||||||
|
renderRaceHub(9472)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
|
||||||
|
// Every capability preserved
|
||||||
|
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles the inline weekend switcher', async () => {
|
it('toggles the inline weekend switcher', async () => {
|
||||||
@@ -256,4 +307,83 @@ describe('RaceHubPage', () => {
|
|||||||
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
fireEvent.click(screen.getByTestId('rh-switch-weekend'))
|
||||||
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
expect(await screen.findByTestId('rh-switcher')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('resolves bare /race-hub through the default analysis session (never a future one)', async () => {
|
||||||
|
const futureMeeting: Meeting = { ...meeting, meeting_key: 1300, meeting_name: 'Future GP' }
|
||||||
|
mockFetchLocalMeetings.mockResolvedValue([futureMeeting])
|
||||||
|
mockFetchWeekend.mockResolvedValue({
|
||||||
|
source: 'partial',
|
||||||
|
meeting_key: 1300,
|
||||||
|
meeting: futureMeeting,
|
||||||
|
// Backend excludes the future session; falls back to the completed quali.
|
||||||
|
default_session_key: 9600,
|
||||||
|
default_analysis_session: 9471,
|
||||||
|
sessions: [
|
||||||
|
{ session: { ...qualSession, meeting_key: 1300 }, source: 'local', datasets: fullDatasets },
|
||||||
|
{ session: futureSession, source: 'none', datasets: {} },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(0)
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockFetchRaceHub).toHaveBeenCalledWith(9471))
|
||||||
|
expect(mockFetchRaceHub).not.toHaveBeenCalledWith(9600)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a pre-session view instead of empty analysis for a future session', async () => {
|
||||||
|
mockFetchRaceHub.mockResolvedValue({
|
||||||
|
...raceHub,
|
||||||
|
session_key: 9600,
|
||||||
|
source: 'none',
|
||||||
|
session: futureSession,
|
||||||
|
meeting: { ...meeting, meeting_key: 1300 },
|
||||||
|
results: [],
|
||||||
|
starting_grid: [],
|
||||||
|
datasets: {},
|
||||||
|
})
|
||||||
|
mockFetchWeekend.mockResolvedValue({
|
||||||
|
source: 'none',
|
||||||
|
meeting_key: 1300,
|
||||||
|
meeting: { ...meeting, meeting_key: 1300 },
|
||||||
|
sessions: [{ session: futureSession, source: 'none', datasets: {} }],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(9600)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
expect(await screen.findByTestId('rh-presession')).toBeInTheDocument()
|
||||||
|
// No Winner analysis card for an unrun session.
|
||||||
|
expect(screen.queryByTestId('rh-overview')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Winner')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels a completed but partial session as Partial in the active state', async () => {
|
||||||
|
mockFetchWeekend.mockResolvedValue({
|
||||||
|
...weekend,
|
||||||
|
sessions: [
|
||||||
|
{ session: qualSession, source: 'local', datasets: fullDatasets },
|
||||||
|
{ session: raceSession, source: 'partial', datasets: { drivers: fullDatasets.drivers } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('rh-active-state')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('rh-active-state')).toHaveTextContent('Partial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers retry and back-to-Weekend on an error', async () => {
|
||||||
|
mockFetchRaceHub.mockRejectedValue(new Error('boom'))
|
||||||
|
|
||||||
|
renderRaceHub(9472)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub-error')).toBeInTheDocument())
|
||||||
|
expect(screen.getByTestId('rh-retry')).toBeInTheDocument()
|
||||||
|
const back = screen.getByTestId('rh-back-weekend')
|
||||||
|
expect(back).toHaveAttribute('href', '/race-hub')
|
||||||
|
|
||||||
|
mockFetchRaceHub.mockResolvedValue(raceHub)
|
||||||
|
fireEvent.click(screen.getByTestId('rh-retry'))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('race-hub')).toBeInTheDocument())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|||||||
import { TabBar } from '../components/TabBar'
|
import { TabBar } from '../components/TabBar'
|
||||||
|
|
||||||
describe('TabBar', () => {
|
describe('TabBar', () => {
|
||||||
it('renders all Race Hub workspace tabs', () => {
|
it('renders all Race Hub workspace tabs grouped into a hierarchy', () => {
|
||||||
render(<TabBar active="overview" onChange={() => {}} />)
|
render(<TabBar active="overview" onChange={() => {}} />)
|
||||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
||||||
@@ -12,7 +12,11 @@ describe('TabBar', () => {
|
|||||||
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Data Status' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Diagnostics' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-story')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-analysis')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('rh-tabgroup-context')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks the active tab with aria-selected', () => {
|
it('marks the active tab with aria-selected', () => {
|
||||||
|
|||||||
82
frontend/src/test/sessionState.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { sessionState, sessionStateLabel } from '../lib/sessionState'
|
||||||
|
import type { DatasetInfo, Session, WeekendSession } from '../types'
|
||||||
|
|
||||||
|
const NOW = new Date('2025-06-01T00:00:00Z')
|
||||||
|
|
||||||
|
function mk(
|
||||||
|
overrides: Partial<Session>,
|
||||||
|
source: WeekendSession['source'],
|
||||||
|
datasets: Record<string, DatasetInfo> = {},
|
||||||
|
): WeekendSession {
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
session_key: 1,
|
||||||
|
session_name: 'Race',
|
||||||
|
session_type: 'Race',
|
||||||
|
meeting_key: 1,
|
||||||
|
date_start: '2025-05-25T13:00:00+00:00',
|
||||||
|
date_end: '2025-05-25T15:00:00+00:00',
|
||||||
|
gmt_offset: '00:00:00',
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
datasets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FULL: Record<string, DatasetInfo> = Object.fromEntries(
|
||||||
|
[
|
||||||
|
'meeting',
|
||||||
|
'session',
|
||||||
|
'drivers',
|
||||||
|
'results',
|
||||||
|
'starting_grid',
|
||||||
|
'stints',
|
||||||
|
'pit_stops',
|
||||||
|
'positions',
|
||||||
|
'race_control',
|
||||||
|
'weather',
|
||||||
|
'laps',
|
||||||
|
].map((k) => [k, { status: 'available', source: 'local', count: 1 }]),
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('sessionState', () => {
|
||||||
|
it('marks a future session as upcoming', () => {
|
||||||
|
const s = mk({ date_start: '2099-05-25T13:00:00+00:00', date_end: '2099-05-25T15:00:00+00:00' }, 'none')
|
||||||
|
expect(sessionState(s, NOW)).toBe('upcoming')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a running session as live', () => {
|
||||||
|
const start = new Date(NOW.getTime() - 60_000).toISOString()
|
||||||
|
const end = new Date(NOW.getTime() + 60_000).toISOString()
|
||||||
|
const s = mk({ date_start: start, date_end: end }, 'partial')
|
||||||
|
expect(sessionState(s, NOW)).toBe('live')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a finished session with full local data as ready', () => {
|
||||||
|
const s = mk({}, 'local', FULL)
|
||||||
|
expect(sessionState(s, NOW)).toBe('ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a finished session with no data as preparing', () => {
|
||||||
|
const s = mk({}, 'none', {})
|
||||||
|
expect(sessionState(s, NOW)).toBe('preparing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a finished session with partial data as partial', () => {
|
||||||
|
const s = mk({}, 'partial', { drivers: { status: 'available', source: 'local', count: 20 } })
|
||||||
|
expect(sessionState(s, NOW)).toBe('partial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a cancelled session as cancelled', () => {
|
||||||
|
const s = mk({}, 'cancelled')
|
||||||
|
expect(sessionState(s, NOW)).toBe('cancelled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses user language labels rather than coverage counts', () => {
|
||||||
|
expect(sessionStateLabel('ready')).toBe('Ready')
|
||||||
|
expect(sessionStateLabel('upcoming')).toBe('Upcoming')
|
||||||
|
expect(sessionStateLabel('partial')).toBe('Partial')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -215,6 +215,12 @@ export interface Weekend {
|
|||||||
meeting: Meeting
|
meeting: Meeting
|
||||||
sessions: WeekendSession[]
|
sessions: WeekendSession[]
|
||||||
default_session_key?: number
|
default_session_key?: number
|
||||||
|
/**
|
||||||
|
* Fan-facing default landing session. Never resolves to a future session, so
|
||||||
|
* bare `/race-hub` never opens empty post-session analysis. `0`/undefined
|
||||||
|
* means every session is still upcoming.
|
||||||
|
*/
|
||||||
|
default_analysis_session?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveStateResponse {
|
export interface LiveStateResponse {
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ package query
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/store"
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// weekendNow is the clock used to decide whether a session has started. It is a
|
||||||
|
// package var so tests can pin it deterministically.
|
||||||
|
var weekendNow = time.Now
|
||||||
|
|
||||||
// ErrMeetingNotFound is returned when a meeting is not in the local store.
|
// ErrMeetingNotFound is returned when a meeting is not in the local store.
|
||||||
var ErrMeetingNotFound = errors.New("meeting not found")
|
var ErrMeetingNotFound = errors.New("meeting not found")
|
||||||
|
|
||||||
@@ -25,6 +30,10 @@ type Weekend struct {
|
|||||||
Meeting models.Meeting `json:"meeting"`
|
Meeting models.Meeting `json:"meeting"`
|
||||||
Sessions []WeekendSession `json:"sessions"`
|
Sessions []WeekendSession `json:"sessions"`
|
||||||
DefaultSessionKey int `json:"default_session_key,omitempty"`
|
DefaultSessionKey int `json:"default_session_key,omitempty"`
|
||||||
|
// DefaultAnalysisSession is the session a fan-facing default landing should
|
||||||
|
// open. Unlike DefaultSessionKey it never resolves to a future session, so
|
||||||
|
// bare /race-hub never renders empty post-session analysis.
|
||||||
|
DefaultAnalysisSession int `json:"default_analysis_session,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSeasons returns years with ingested meetings, newest first.
|
// ListSeasons returns years with ingested meetings, newest first.
|
||||||
@@ -85,6 +94,7 @@ func (s *Service) GetWeekend(meetingKey int) (Weekend, error) {
|
|||||||
out.Source = weekendSource(out.Sessions)
|
out.Source = weekendSource(out.Sessions)
|
||||||
}
|
}
|
||||||
out.DefaultSessionKey = pickDefaultSession(out.Sessions)
|
out.DefaultSessionKey = pickDefaultSession(out.Sessions)
|
||||||
|
out.DefaultAnalysisSession = pickDefaultAnalysisSession(out.Sessions, weekendNow())
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +151,51 @@ func pickDefaultSession(sessions []WeekendSession) int {
|
|||||||
return sessions[bestIdx].Session.SessionKey
|
return sessions[bestIdx].Session.SessionKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pickDefaultAnalysisSession chooses the session a fan should land on by default.
|
||||||
|
// It never returns a future session: among sessions that have already started
|
||||||
|
// (or whose start time is unknown) it prefers the one with the richest local
|
||||||
|
// dataset coverage, breaking ties toward the later session. When every session
|
||||||
|
// is still upcoming it returns 0 so callers render a pre-session view instead of
|
||||||
|
// empty analysis.
|
||||||
|
func pickDefaultAnalysisSession(sessions []WeekendSession, now time.Time) int {
|
||||||
|
bestKey := 0
|
||||||
|
bestScore := -1
|
||||||
|
var bestStart time.Time
|
||||||
|
for _, sess := range sessions {
|
||||||
|
start, ok := parseSessionStart(sess.Session.DateStart)
|
||||||
|
// Skip sessions that are clearly in the future; unknown start times are
|
||||||
|
// treated as eligible so historical data without timestamps still works.
|
||||||
|
if ok && start.After(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
score := datasetScore(sess.Datasets)
|
||||||
|
if score > bestScore || (score == bestScore && ok && start.After(bestStart)) {
|
||||||
|
bestScore = score
|
||||||
|
bestKey = sess.Session.SessionKey
|
||||||
|
if ok {
|
||||||
|
bestStart = start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSessionStart(value string) (time.Time, bool) {
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02T15:04:05", value); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02", value[:min(len(value), 10)]); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func datasetScore(datasets map[string]DatasetInfo) int {
|
func datasetScore(datasets map[string]DatasetInfo) int {
|
||||||
score := 0
|
score := 0
|
||||||
for _, info := range datasets {
|
for _, info := range datasets {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/store"
|
"github.com/AmanTahiliani/box-box/internal/store"
|
||||||
@@ -376,6 +377,88 @@ func TestGetWeekendWithSessions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPickDefaultAnalysisSessionSkipsFuture(t *testing.T) {
|
||||||
|
now := mustTime(t, "2025-05-24T18:00:00Z")
|
||||||
|
sessions := []WeekendSession{
|
||||||
|
{ // completed qualifying, partial coverage
|
||||||
|
Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"},
|
||||||
|
Datasets: map[string]DatasetInfo{"results": availableLocal(1)},
|
||||||
|
},
|
||||||
|
{ // future race with the richest coverage — must NOT be selected
|
||||||
|
Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"},
|
||||||
|
Datasets: map[string]DatasetInfo{
|
||||||
|
"results": availableLocal(1),
|
||||||
|
"laps": availableLocal(1),
|
||||||
|
"stints": availableLocal(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := pickDefaultAnalysisSession(sessions, now)
|
||||||
|
if got != 100 {
|
||||||
|
t.Fatalf("pickDefaultAnalysisSession() = %d, want 100 (never a future session)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickDefaultAnalysisSessionAllFuture(t *testing.T) {
|
||||||
|
now := mustTime(t, "2025-05-20T00:00:00Z")
|
||||||
|
sessions := []WeekendSession{
|
||||||
|
{Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"}},
|
||||||
|
{Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := pickDefaultAnalysisSession(sessions, now); got != 0 {
|
||||||
|
t.Fatalf("pickDefaultAnalysisSession() = %d, want 0 (everything upcoming)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickDefaultAnalysisSessionPrefersRichestCompleted(t *testing.T) {
|
||||||
|
now := mustTime(t, "2025-05-26T00:00:00Z")
|
||||||
|
sessions := []WeekendSession{
|
||||||
|
{
|
||||||
|
Session: models.Session{SessionKey: 100, DateStart: "2025-05-24T14:00:00+00:00"},
|
||||||
|
Datasets: map[string]DatasetInfo{"results": availableLocal(1)},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Session: models.Session{SessionKey: 200, DateStart: "2025-05-25T13:00:00+00:00"},
|
||||||
|
Datasets: map[string]DatasetInfo{
|
||||||
|
"results": availableLocal(1),
|
||||||
|
"laps": availableLocal(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := pickDefaultAnalysisSession(sessions, now); got != 200 {
|
||||||
|
t.Fatalf("pickDefaultAnalysisSession() = %d, want 200 (richest completed)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWeekendSetsDefaultAnalysisSession(t *testing.T) {
|
||||||
|
prev := weekendNow
|
||||||
|
weekendNow = func() time.Time { return mustTime(t, "2025-05-26T00:00:00Z") }
|
||||||
|
t.Cleanup(func() { weekendNow = prev })
|
||||||
|
|
||||||
|
svc := openTestService(t)
|
||||||
|
seedRaceHubData(t, svc.store)
|
||||||
|
|
||||||
|
weekend, err := svc.GetWeekend(1229)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetWeekend() error = %v", err)
|
||||||
|
}
|
||||||
|
if weekend.DefaultAnalysisSession != 9472 {
|
||||||
|
t.Fatalf("DefaultAnalysisSession = %d, want 9472", weekend.DefaultAnalysisSession)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustTime(t *testing.T, value string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse time %q: %v", value, err)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetChampionshipInputsIncludesSprintPoints(t *testing.T) {
|
func TestGetChampionshipInputsIncludesSprintPoints(t *testing.T) {
|
||||||
// Regression for #57: Race-only aggregation dropped Sprint points.
|
// Regression for #57: Race-only aggregation dropped Sprint points.
|
||||||
// Setup: same meeting 1229 has Race (9472) 25pts + Sprint (9473) 8pts => total 33.
|
// Setup: same meeting 1229 has Race (9472) 25pts + Sprint (9473) 8pts => total 33.
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ func main() {
|
|||||||
const meetingKey = 1229
|
const meetingKey = 1229
|
||||||
const fullSessionKey = 9472
|
const fullSessionKey = 9472
|
||||||
const coreOnlySessionKey = 9000
|
const coreOnlySessionKey = 9000
|
||||||
|
// A far-future session inside the same Monaco meeting so bare /race-hub never
|
||||||
|
// lands on it (default_analysis_session picks the completed race) yet an
|
||||||
|
// explicit deep link renders the dedicated pre-session view. Kept in the same
|
||||||
|
// meeting so the Command Center's focus selection is unaffected.
|
||||||
|
const futureSessionKey = 9600
|
||||||
|
|
||||||
if err := seedMeeting(st, meetingKey); err != nil {
|
if err := seedMeeting(st, meetingKey); err != nil {
|
||||||
fail(err)
|
fail(err)
|
||||||
@@ -59,6 +64,10 @@ func main() {
|
|||||||
fail(err)
|
fail(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := seedFutureSession(st, futureSessionKey, meetingKey); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("seeded e2e db at %s\n", *dbPath)
|
fmt.Printf("seeded e2e db at %s\n", *dbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +104,20 @@ func seedSession(st *store.Store, sessionKey, meetingKey int, name string) error
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedFutureSession(st *store.Store, sessionKey, meetingKey int) error {
|
||||||
|
// Far-future date so this session is always "upcoming" relative to the wall
|
||||||
|
// clock and renders the pre-session view on an explicit deep link.
|
||||||
|
return st.UpsertSession(store.Session{
|
||||||
|
SessionKey: sessionKey,
|
||||||
|
MeetingKey: meetingKey,
|
||||||
|
SessionName: "Future Sprint",
|
||||||
|
SessionType: "Race",
|
||||||
|
CircuitKey: 10,
|
||||||
|
DateStart: "2099-05-25T13:00:00+00:00",
|
||||||
|
DateEnd: "2099-05-25T15:00:00+00:00",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func seedDrivers(st *store.Store, sessionKey, meetingKey int) error {
|
func seedDrivers(st *store.Store, sessionKey, meetingKey int) error {
|
||||||
drivers := []store.Driver{
|
drivers := []store.Driver{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test'
|
|||||||
|
|
||||||
const FULL_SESSION = 9472
|
const FULL_SESSION = 9472
|
||||||
const CORE_ONLY_SESSION = 9000
|
const CORE_ONLY_SESSION = 9000
|
||||||
|
const FUTURE_SESSION = 9600
|
||||||
|
|
||||||
test.describe('Race Hub Weekend Workspace', () => {
|
test.describe('Race Hub Weekend Workspace', () => {
|
||||||
test('lands on the Overview tab with workspace identity', async ({ page }) => {
|
test('lands on the Overview tab with workspace identity', async ({ page }) => {
|
||||||
@@ -91,20 +92,56 @@ test.describe('Race Hub Weekend Workspace', () => {
|
|||||||
await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible()
|
await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Data Status tab points at admin instead of inline CLI hints', async ({ page }) => {
|
test('Diagnostics is a secondary action and points at admin, not inline CLI hints', async ({ page }) => {
|
||||||
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
await page.goto(`/race-hub?session_key=${CORE_ONLY_SESSION}`)
|
||||||
await page.getByRole('tab', { name: 'Data Status' }).click()
|
await page.getByRole('tab', { name: 'Diagnostics' }).click()
|
||||||
|
|
||||||
await expect(page.getByTestId('rh-data-status')).toBeVisible()
|
await expect(page.getByTestId('rh-data-status')).toBeVisible()
|
||||||
await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
|
await expect(page.getByRole('link', { name: /manage ingestion/i })).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'/admin',
|
'/admin',
|
||||||
)
|
)
|
||||||
|
// Raw coverage strip stays hidden until explicitly requested.
|
||||||
|
await expect(page.getByTestId('rh-dataset-strip')).toHaveCount(0)
|
||||||
|
await page.getByTestId('rh-diagnostics-toggle').click()
|
||||||
|
await expect(page.getByTestId('rh-dataset-strip')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('bare /race-hub redirects to the focus session', async ({ page }) => {
|
test('groups analysis navigation into Story, Analysis, and Data & Context', async ({ page }) => {
|
||||||
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
|
await expect(page.getByTestId('rh-tabgroup-story')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-tabgroup-analysis')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-tabgroup-context')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bare /race-hub resolves to a completed session, never a future one', async ({ page }) => {
|
||||||
await page.goto('/race-hub')
|
await page.goto('/race-hub')
|
||||||
await expect(page).toHaveURL(/session_key=\d+/)
|
await expect(page).toHaveURL(/session_key=\d+/)
|
||||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
// It must not land on the future session.
|
||||||
|
await expect(page).not.toHaveURL(new RegExp(`session_key=${FUTURE_SESSION}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explicit completed session deep link stays stable and shows analysis', async ({ page }) => {
|
||||||
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`session_key=${FULL_SESSION}`))
|
||||||
|
await expect(page.getByTestId('rh-overview')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('explicit future session renders the pre-session view, not empty analysis', async ({ page }) => {
|
||||||
|
await page.goto(`/race-hub?session_key=${FUTURE_SESSION}`)
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-presession')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-overview')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returning to Weekend from an analysis view preserves the meeting context', async ({ page }) => {
|
||||||
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
|
await page.getByRole('tab', { name: 'Strategy' }).click()
|
||||||
|
|
||||||
|
await page.getByTestId('rh-switch-weekend').click()
|
||||||
|
await expect(page.getByTestId('rh-switcher')).toBeVisible()
|
||||||
|
// The current session remains reachable/selected from the switcher.
|
||||||
|
await expect(page.getByTestId(`rh-switcher-session-${FULL_SESSION}`)).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 142 KiB |
BIN
tests/visual/__snapshots__/desktop/race-hub-future.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 120 KiB |
BIN
tests/visual/__snapshots__/mobile/race-hub-future.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 132 KiB |
BIN
tests/visual/__snapshots__/tablet/race-hub-future.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -7,6 +7,7 @@ export const VIEWPORTS = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const FULL_SESSION = 9472
|
export const FULL_SESSION = 9472
|
||||||
|
export const FUTURE_SESSION = 9600
|
||||||
|
|
||||||
/** Wait for web fonts and layout to settle before screenshots. */
|
/** Wait for web fonts and layout to settle before screenshots. */
|
||||||
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
export async function waitForScreenshotReady(page: Page): Promise<void> {
|
||||||
@@ -33,6 +34,16 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
|
|||||||
await waitForScreenshotReady(page)
|
await waitForScreenshotReady(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function gotoRaceHubFutureReady(
|
||||||
|
page: Page,
|
||||||
|
sessionKey = FUTURE_SESSION,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
||||||
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('rh-presession')).toBeVisible()
|
||||||
|
await waitForScreenshotReady(page)
|
||||||
|
}
|
||||||
|
|
||||||
export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
|
export async function gotoRaceStoryReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
|
||||||
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
||||||
await expect(page.getByTestId('race-hub')).toBeVisible()
|
await expect(page.getByTestId('race-hub')).toBeVisible()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
gotoWeekendReady,
|
gotoWeekendReady,
|
||||||
gotoDataLibraryReady,
|
gotoDataLibraryReady,
|
||||||
gotoLiveEmptyReady,
|
gotoLiveEmptyReady,
|
||||||
|
gotoRaceHubFutureReady,
|
||||||
gotoRaceHubReady,
|
gotoRaceHubReady,
|
||||||
screenshotPage,
|
screenshotPage,
|
||||||
} from './helpers'
|
} from './helpers'
|
||||||
@@ -18,6 +19,14 @@ test.describe('MVP visual regression', () => {
|
|||||||
await screenshotPage(page, 'race-hub')
|
await screenshotPage(page, 'race-hub')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('race-hub-future', async ({ page }) => {
|
||||||
|
await gotoRaceHubFutureReady(page)
|
||||||
|
// The countdown ticks every second — mask it so the snapshot stays stable.
|
||||||
|
await screenshotPage(page, 'race-hub-future', {
|
||||||
|
mask: [page.getByTestId('rh-presession-countdown')],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('data-library', async ({ page }) => {
|
test('data-library', async ({ page }) => {
|
||||||
await gotoDataLibraryReady(page)
|
await gotoDataLibraryReady(page)
|
||||||
await screenshotPage(page, 'data-library')
|
await screenshotPage(page, 'data-library')
|
||||||
|
|||||||