diff --git a/frontend/src/components/CommandCenterHero.tsx b/frontend/src/components/CommandCenterHero.tsx
new file mode 100644
index 0000000..a67391f
--- /dev/null
+++ b/frontend/src/components/CommandCenterHero.tsx
@@ -0,0 +1,415 @@
+import { Link } from '@tanstack/react-router'
+import { Activity, ArrowRight, Play } from 'lucide-react'
+import { sessionTypeAbbrev } from '../lib/coverage'
+import { countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity'
+import { classifySessionStatus, type HeroStateKind } from '../lib/hero'
+import { sortLiveTimingRows, trackStatusInfo } from '../lib/live'
+import {
+ formatCountdown,
+ formatSessionScheduleTime,
+ meetingStartTime,
+ sessionStartTime,
+ type FocusMeetingKind,
+} from '../lib/schedule'
+import type { EnrichedResult, LiveStreamData, Meeting, Session, WeekendSession } from '../types'
+import '../styles/hero.css'
+
+export interface CommandCenterHeroProps {
+ state: HeroStateKind
+ now: Date
+ accent: string
+ liveActive: boolean
+ liveData: LiveStreamData | null | undefined
+ focusMeeting: Meeting
+ focusKind: FocusMeetingKind
+ sessions: WeekendSession[]
+ currentSession: Session | null
+ nextSession: Session | null
+ analysisSessionKey?: number
+ analysisSessionName?: string
+ lastRaceName: string
+ lastRacePodium: EnrichedResult[]
+ lastRaceSessionKey?: number
+ nextMeeting: Meeting | null
+}
+
+export function CommandCenterHero({
+ state,
+ now,
+ accent,
+ liveActive,
+ liveData,
+ focusMeeting,
+ focusKind,
+ sessions,
+ currentSession,
+ nextSession,
+ analysisSessionKey,
+ analysisSessionName,
+ lastRaceName,
+ lastRacePodium,
+ lastRaceSessionKey,
+ nextMeeting,
+}: CommandCenterHeroProps) {
+ const accentStyle = {
+ '--gp-accent': accent,
+ '--hero-accent': accent,
+ } as React.CSSProperties
+
+ return (
+
@@ -483,65 +449,3 @@ function FormSparkline({ form, color }: { form: number[], color: string }) {
)
}
-function WeekendKindLabel({ kind }: { kind: WeekendStatusKind }) {
- switch (kind) {
- case 'live':
- return
● Live now
- case 'current':
- return
Current weekend
- case 'next':
- return
Next weekend
- case 'recent':
- return
Recent weekend
- default:
- return
Weekend
- }
-}
-
-interface CountdownBlockProps {
- liveActive: boolean
- currentSession: Session | null
- nextSession: Session | null
- meeting: Meeting
- now: Date
-}
-
-function CountdownBlock({ liveActive, currentSession, nextSession, meeting, now }: CountdownBlockProps) {
- if (liveActive) {
- return (
-
-
SignalR
-
LIVE
-
{currentSession?.session_name ?? 'Feed connected'}
-
- )
- }
- if (currentSession) {
- return (
-
-
On Track
-
{currentSession.session_name}
-
In session
-
- )
- }
- if (nextSession && sessionStartTime(nextSession)) {
- return (
-
-
Next · {nextSession.session_name}
-
{formatCountdown(sessionStartTime(nextSession)!, now)}
-
{formatSessionScheduleTime(nextSession.date_start)}
-
- )
- }
- if (meetingHasStarted(meeting, now)) {
- return (
-
-
Status
-
Complete
-
Weekend finished
-
- )
- }
- return null
-}
diff --git a/frontend/src/styles/hero.css b/frontend/src/styles/hero.css
new file mode 100644
index 0000000..7f1a8c1
--- /dev/null
+++ b/frontend/src/styles/hero.css
@@ -0,0 +1,373 @@
+.hero-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s4);
+}
+
+.hero-card {
+ display: flex;
+ background: var(--surface);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ overflow: hidden;
+ min-height: 124px;
+}
+
+.hero-accent {
+ width: 4px;
+ flex-shrink: 0;
+ box-shadow: 0 0 12px var(--hero-accent, var(--gp-accent));
+ background: var(--hero-accent, var(--gp-accent));
+}
+
+.hero-accent--live {
+ animation: hero-pulse 1.4s ease-in-out infinite;
+}
+
+@keyframes hero-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.55; }
+}
+
+.hero-body {
+ flex: 1;
+ padding: var(--s5);
+ min-width: 0;
+ position: relative;
+ overflow: hidden;
+ background:
+ radial-gradient(ellipse at top right, color-mix(in srgb, var(--gp-accent) 15%, transparent), transparent 60%),
+ linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
+ var(--surface);
+ background-size: 100% 100%, 20px 20px, 20px 20px, 100% 100%;
+}
+
+.hero-body::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(90deg, transparent 0%, rgba(0, 0, 0, 0.3) 100%);
+ pointer-events: none;
+}
+
+.hero-inner {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--s5);
+}
+
+.hero-row {
+ display: flex;
+ align-items: stretch;
+ gap: var(--s6);
+ min-width: 0;
+ flex-wrap: wrap;
+}
+
+.hero-identity {
+ flex: 1;
+ min-width: 0;
+}
+
+.hero-decal {
+ font-size: 56px;
+ font-weight: 800;
+ line-height: 0.85;
+ letter-spacing: -0.04em;
+ color: color-mix(in srgb, var(--gp-accent) 35%, var(--text-3));
+ opacity: 0.9;
+ flex-shrink: 0;
+}
+
+.hero-eyebrow {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin-bottom: var(--s2);
+}
+
+.hero-eyebrow--live {
+ color: var(--red);
+}
+
+.hero-title {
+ font-size: 28px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1.1;
+ margin: 0;
+}
+
+.hero-sub {
+ font-size: 12px;
+ color: var(--text-2);
+ margin-top: var(--s2);
+}
+
+.hero-countdown {
+ text-align: right;
+ flex-shrink: 0;
+}
+
+.hero-cd-label {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--text-3);
+ margin-bottom: var(--s2);
+}
+
+.hero-cd-value {
+ font-size: 22px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ color: var(--text);
+}
+
+.hero-cd-value--live {
+ color: var(--red);
+ font-size: 28px;
+ letter-spacing: 0.08em;
+}
+
+.hero-cd-sub {
+ font-size: 11px;
+ color: var(--text-3);
+ margin-top: var(--s2);
+}
+
+.hero-track-status {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s3);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ padding: var(--s2) var(--s3);
+ border-radius: 4px;
+ margin-top: var(--s3);
+}
+
+.hero-track-status--green { color: #3dd68c; background: rgba(61, 214, 140, 0.1); }
+.hero-track-status--yellow { color: #f5c842; background: rgba(245, 200, 66, 0.1); }
+.hero-track-status--sc,
+.hero-track-status--vsc { color: #f5a623; background: rgba(245, 166, 35, 0.12); }
+.hero-track-status--red { color: var(--red); background: rgba(225, 6, 0, 0.12); }
+.hero-track-status--unknown { color: var(--text-3); background: rgba(255, 255, 255, 0.05); }
+
+.hero-timing {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s2);
+ min-width: 200px;
+}
+
+.hero-timing-row {
+ display: grid;
+ grid-template-columns: 28px 1fr auto;
+ align-items: center;
+ gap: var(--s3);
+ padding: var(--s2) var(--s3);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.hero-timing-pos {
+ font-weight: 700;
+ color: var(--text-3);
+ font-variant-numeric: tabular-nums;
+}
+
+.hero-timing-pos--p1 { color: #ffd700; }
+.hero-timing-pos--p2 { color: #c0c0c0; }
+.hero-timing-pos--p3 { color: #cd7f32; }
+
+.hero-timing-bar {
+ width: 3px;
+ height: 14px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+.hero-timing-driver {
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+ font-weight: 600;
+ font-family: var(--f-mono);
+}
+
+.hero-timing-gap {
+ font-size: 11px;
+ color: var(--text-3);
+ font-family: var(--f-mono);
+ font-variant-numeric: tabular-nums;
+}
+
+.hero-cta {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s3);
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--red);
+ text-decoration: none;
+ transition: opacity 0.1s;
+}
+
+.hero-cta:hover {
+ opacity: 0.85;
+}
+
+.hero-cta--primary {
+ padding: var(--s3) var(--s5);
+ background: rgba(225, 6, 0, 0.12);
+ border: 1px solid rgba(225, 6, 0, 0.35);
+ border-radius: 6px;
+}
+
+.hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s3);
+ align-items: center;
+}
+
+.hero-secondary-link {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-2);
+ text-decoration: none;
+}
+
+.hero-secondary-link:hover {
+ color: var(--text);
+}
+
+.hero-schedule-strip {
+ display: flex;
+ gap: var(--s3);
+ overflow-x: auto;
+ padding-bottom: var(--s2);
+}
+
+.hero-schedule-card {
+ flex: 0 0 auto;
+ min-width: 120px;
+ padding: var(--s3) var(--s4);
+ border-radius: 6px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(255, 255, 255, 0.02);
+ text-decoration: none;
+ color: inherit;
+ transition: background 0.1s, border-color 0.1s;
+}
+
+.hero-schedule-card:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.hero-schedule-card.is-next {
+ border-color: color-mix(in srgb, var(--gp-accent) 50%, transparent);
+ background: color-mix(in srgb, var(--gp-accent) 8%, transparent);
+}
+
+.hero-schedule-card.is-done {
+ opacity: 0.55;
+}
+
+.hero-schedule-card.is-live {
+ border-color: rgba(225, 6, 0, 0.4);
+ background: rgba(225, 6, 0, 0.08);
+}
+
+.hero-schedule-abbrev {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ color: var(--text-3);
+}
+
+.hero-schedule-name {
+ font-size: 12px;
+ font-weight: 600;
+ margin: var(--s2) 0;
+}
+
+.hero-schedule-time {
+ font-size: 10px;
+ color: var(--text-3);
+}
+
+.hero-schedule-marker {
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ margin-top: var(--s2);
+ color: var(--text-3);
+}
+
+.hero-schedule-marker--next { color: var(--gp-accent); }
+.hero-schedule-marker--live { color: var(--red); }
+
+.hero-podium {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s2);
+ min-width: 200px;
+}
+
+.hero-podium-row {
+ display: grid;
+ grid-template-columns: 32px 1fr;
+ align-items: center;
+ gap: var(--s3);
+ padding: var(--s2) var(--s3);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.hero-podium-pos {
+ font-weight: 700;
+ font-family: var(--f-mono);
+ color: var(--text-3);
+}
+
+.hero-podium-pos--p1 { color: #ffd700; }
+.hero-podium-pos--p2 { color: #c0c0c0; }
+.hero-podium-pos--p3 { color: #cd7f32; }
+
+.hero-podium-driver {
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+ font-weight: 600;
+ font-family: var(--f-mono);
+}
+
+.hero-between-next {
+ display: flex;
+ align-items: center;
+ gap: var(--s4);
+}
+
+.hero-flag {
+ font-size: 32px;
+ line-height: 1;
+}
+
+@media (min-width: 700px) {
+ .hero-row {
+ flex-wrap: nowrap;
+ }
+}
diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx
index 4ec99f8..1519c59 100644
--- a/frontend/src/test/CommandCenterPage.test.tsx
+++ b/frontend/src/test/CommandCenterPage.test.tsx
@@ -12,9 +12,20 @@ vi.mock('../api', () => ({
fetchSessions: vi.fn(),
fetchWeekend: vi.fn(),
fetchLiveState: vi.fn(),
+ fetchChampionshipHub: vi.fn(),
+ fetchRaceHub: vi.fn(),
}))
-import { fetchSeasons, fetchLocalMeetings, fetchSeasonMeetings, fetchSessions, fetchWeekend, fetchLiveState } from '../api'
+import {
+ fetchSeasons,
+ fetchLocalMeetings,
+ fetchSeasonMeetings,
+ fetchSessions,
+ fetchWeekend,
+ fetchLiveState,
+ fetchChampionshipHub,
+ fetchRaceHub,
+} from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
@@ -22,6 +33,8 @@ const mockFetchSeasonMeetings = vi.mocked(fetchSeasonMeetings)
const mockFetchSessions = vi.mocked(fetchSessions)
const mockFetchWeekend = vi.mocked(fetchWeekend)
const mockFetchLiveState = vi.mocked(fetchLiveState)
+const mockFetchChampionshipHub = vi.mocked(fetchChampionshipHub)
+const mockFetchRaceHub = vi.mocked(fetchRaceHub)
const meeting: Meeting = {
meeting_key: 1229,
@@ -102,6 +115,48 @@ describe('CommandCenterPage', () => {
vi.clearAllMocks()
mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
mockFetchSessions.mockResolvedValue([])
+ mockFetchChampionshipHub.mockResolvedValue({
+ season: 2025,
+ round: 1,
+ total_rounds: 1,
+ rounds_left: 0,
+ last_race: 'Monaco',
+ round_labels: ['R1'],
+ drivers: [],
+ teams: [],
+ })
+ mockFetchRaceHub.mockResolvedValue({
+ source: 'local',
+ session_key: 9472,
+ datasets: fullDatasets,
+ results: [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671c6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 7200,
+ gap_to_leader: null,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+ ],
+ starting_grid: [],
+ drivers: [],
+ stints: [],
+ pit_stops: [],
+ positions: [],
+ race_control: [],
+ weather: [],
+ laps: [],
+ })
})
it('shows empty state when no seasons are ingested', async () => {
@@ -131,11 +186,10 @@ describe('CommandCenterPage', () => {
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
})
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
- expect(screen.getByTestId('cc-focus')).toHaveTextContent('MON')
expect(screen.getByTestId('cc-season-calendar')).toHaveTextContent('Season Calendar')
expect(screen.getByTestId('cc-calendar-1229')).toHaveTextContent('R01')
expect(screen.getByText('No live session')).toBeInTheDocument()
- expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race')
+ expect(screen.getByTestId('hero-last-race-link')).toHaveTextContent('Monaco')
})
it('uses OpenF1 calendar metadata to focus the current weekend when local ingest is behind', async () => {
@@ -181,9 +235,9 @@ describe('CommandCenterPage', () => {
await waitFor(() => {
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
})
- expect(screen.getByTestId('cc-focus')).toHaveTextContent('Current weekend')
+ expect(screen.getByTestId('cc-focus')).toHaveTextContent('Live now')
expect(screen.getByTestId('cc-session-9602')).toHaveTextContent('On track')
- expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Qualifying')
+ expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
vi.useRealTimers()
})
diff --git a/frontend/src/test/hero.test.ts b/frontend/src/test/hero.test.ts
new file mode 100644
index 0000000..21af353
--- /dev/null
+++ b/frontend/src/test/hero.test.ts
@@ -0,0 +1,123 @@
+import { describe, it, expect } from 'vitest'
+import { heroState, classifySessionStatus } from '../lib/hero'
+import { currentAndNextSession } from '../lib/schedule'
+import type { Session } from '../types'
+
+const session = (overrides: Partial
= {}): Session => ({
+ session_key: 9472,
+ 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: '02:00:00',
+ ...overrides,
+})
+
+describe('heroState', () => {
+ it('returns live when the live snapshot is active', () => {
+ const now = new Date('2025-05-24T12:00:00Z')
+ expect(
+ heroState({
+ now,
+ liveActive: true,
+ currentSession: null,
+ focusKind: 'current',
+ }),
+ ).toBe('live')
+ })
+
+ it('returns live when a session is in progress', () => {
+ const now = new Date('2025-05-25T14:00:00Z')
+ const sessions = [session()]
+ const { current } = currentAndNextSession(sessions, now)
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: current,
+ focusKind: 'current',
+ }),
+ ).toBe('live')
+ })
+
+ it('returns upcoming during a focus weekend gap before the next session', () => {
+ const now = new Date('2025-05-24T12:00:00Z')
+ const sessions = [
+ session({
+ session_key: 1,
+ session_name: 'FP1',
+ date_start: '2025-05-23T10:00:00+00:00',
+ date_end: '2025-05-23T11:00:00+00:00',
+ }),
+ session({
+ session_key: 2,
+ session_name: 'Race',
+ date_start: '2025-05-25T13:00:00+00:00',
+ date_end: '2025-05-25T15:00:00+00:00',
+ }),
+ ]
+ const { current, next } = currentAndNextSession(sessions, now)
+ expect(current).toBeNull()
+ expect(next?.session_key).toBe(2)
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: current,
+ focusKind: 'current',
+ }),
+ ).toBe('upcoming')
+ })
+
+ it('returns upcoming when the weekend just ended but is still the focus weekend', () => {
+ const now = new Date('2025-05-25T16:00:00Z')
+ const sessions = [session()]
+ const { current } = currentAndNextSession(sessions, now)
+ expect(current).toBeNull()
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: current,
+ focusKind: 'current',
+ }),
+ ).toBe('upcoming')
+ })
+
+ it('returns between before the next grand prix weekend', () => {
+ const now = new Date('2025-06-01T12:00:00Z')
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: null,
+ focusKind: 'next',
+ }),
+ ).toBe('between')
+ })
+
+ it('returns between after the season when only a recent meeting remains', () => {
+ const now = new Date('2026-01-01T00:00:00Z')
+ expect(
+ heroState({
+ now,
+ liveActive: false,
+ currentSession: null,
+ focusKind: 'recent',
+ }),
+ ).toBe('between')
+ })
+})
+
+describe('classifySessionStatus', () => {
+ it('marks a session as done immediately after it ends', () => {
+ const now = new Date('2025-05-25T15:00:00Z')
+ expect(classifySessionStatus(session(), now)).toBe('done')
+ })
+
+ it('marks a session as live during its window', () => {
+ const now = new Date('2025-05-25T14:00:00Z')
+ expect(classifySessionStatus(session(), now)).toBe('live')
+ })
+})
diff --git a/frontend/src/test/hero.test.tsx b/frontend/src/test/hero.test.tsx
new file mode 100644
index 0000000..3db06d1
--- /dev/null
+++ b/frontend/src/test/hero.test.tsx
@@ -0,0 +1,257 @@
+import { describe, it, expect, vi } from 'vitest'
+import type { ComponentProps, ReactNode } from 'react'
+import { render, screen } from '@testing-library/react'
+import type { EnrichedResult, LiveStreamData, Meeting, WeekendSession } from '../types'
+
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({
+ to,
+ children,
+ ...rest
+ }: {
+ to: string
+ children: ReactNode
+ className?: string
+ 'data-testid'?: string
+ }) => (
+
+ {children}
+
+ ),
+}))
+
+import { CommandCenterHero } from '../components/CommandCenterHero'
+
+const focusMeeting: Meeting = {
+ meeting_key: 1229,
+ meeting_name: 'Monaco',
+ meeting_official_name: 'FORMULA 1 GRAND PRIX DE MONACO 2025',
+ location: 'Monaco',
+ country_name: 'Monaco',
+ country_code: 'MON',
+ country_flag: '',
+ circuit_short_name: 'Monaco',
+ date_start: '2025-05-23T00:00:00+00:00',
+ date_end: '2025-05-25T00:00:00+00:00',
+ year: 2025,
+}
+
+const weekendSessions: WeekendSession[] = [
+ {
+ session: {
+ session_key: 9472,
+ session_name: 'Race',
+ session_type: 'Race',
+ meeting_key: 1229,
+ date_start: '2025-05-25T13:00:00+00:00',
+ date_end: '2025-05-25T15:00:00+00:00',
+ gmt_offset: '02:00:00',
+ },
+ source: 'local',
+ datasets: {} as WeekendSession['datasets'],
+ },
+]
+
+const liveData: LiveStreamData = {
+ Drivers: {
+ '1': {
+ RacingNumber: '1',
+ Position: 1,
+ PrevPosition: 1,
+ GapToLeader: '',
+ Interval: '',
+ LastLapTime: '1:14.000',
+ LastLapPB: false,
+ LastLapOB: false,
+ BestLapTime: '1:13.500',
+ BestLapPB: false,
+ BestLapOB: false,
+ BestLapNum: 10,
+ InPit: false,
+ PitOut: false,
+ Retired: false,
+ KnockedOut: false,
+ Cutoff: false,
+ OnFlyingLap: false,
+ NumberOfLaps: 10,
+ SpeedTrap: '',
+ Sectors: [],
+ },
+ '44': {
+ RacingNumber: '44',
+ Position: 2,
+ PrevPosition: 2,
+ GapToLeader: '+1.2',
+ Interval: '+1.2',
+ LastLapTime: '1:14.200',
+ LastLapPB: false,
+ LastLapOB: false,
+ BestLapTime: '1:13.700',
+ BestLapPB: false,
+ BestLapOB: false,
+ BestLapNum: 10,
+ InPit: false,
+ PitOut: false,
+ Retired: false,
+ KnockedOut: false,
+ Cutoff: false,
+ OnFlyingLap: false,
+ NumberOfLaps: 10,
+ SpeedTrap: '',
+ Sectors: [],
+ },
+ },
+ DriverInfo: {
+ '1': {
+ RacingNumber: '1',
+ BroadcastName: 'M VERSTAPPEN',
+ Tla: 'VER',
+ TeamName: 'Red Bull Racing',
+ TeamColour: '3671c6',
+ FirstName: 'Max',
+ LastName: 'Verstappen',
+ },
+ '44': {
+ RacingNumber: '44',
+ BroadcastName: 'L HAMILTON',
+ Tla: 'HAM',
+ TeamName: 'Ferrari',
+ TeamColour: 'e8002d',
+ FirstName: 'Lewis',
+ LastName: 'Hamilton',
+ },
+ },
+ Tyres: {},
+ RCMessages: [],
+ Weather: {
+ AirTemp: 20,
+ TrackTemp: 30,
+ Humidity: 50,
+ WindSpeed: 1,
+ WindDir: 0,
+ Rainfall: false,
+ },
+ Session: {
+ MeetingName: 'Monaco',
+ CircuitName: 'Monaco',
+ SessionType: 'Race',
+ SessionName: 'Race',
+ },
+ TrackStatus: '1',
+ CurrentLap: 10,
+ TotalLaps: 78,
+ Clock: '',
+ ClockRefTime: '',
+ ClockExtrapolating: false,
+ Stints: {},
+}
+
+const podium: EnrichedResult[] = [
+ {
+ driver_number: 1,
+ position: 1,
+ name_acronym: 'VER',
+ full_name: 'Max Verstappen',
+ team_name: 'Red Bull Racing',
+ team_colour: '3671c6',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 7200,
+ gap_to_leader: null,
+ number_of_laps: 78,
+ points: 25,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+ {
+ driver_number: 44,
+ position: 2,
+ name_acronym: 'HAM',
+ full_name: 'Lewis Hamilton',
+ team_name: 'Ferrari',
+ team_colour: 'e8002d',
+ dnf: false,
+ dns: false,
+ dsq: false,
+ duration: 7205,
+ gap_to_leader: 3.2,
+ number_of_laps: 78,
+ points: 18,
+ session_key: 9472,
+ meeting_key: 1229,
+ },
+]
+
+function renderHero(overrides: Partial> = {}) {
+ const props: ComponentProps = {
+ state: 'upcoming',
+ now: new Date('2025-05-24T12:00:00Z'),
+ accent: '#d61a3e',
+ liveActive: false,
+ liveData: null,
+ focusMeeting,
+ focusKind: 'current',
+ sessions: weekendSessions,
+ currentSession: null,
+ nextSession: weekendSessions[0].session,
+ lastRaceName: '',
+ lastRacePodium: [],
+ nextMeeting: null,
+ ...overrides,
+ }
+
+ return render()
+}
+
+describe('CommandCenterHero', () => {
+ it('renders live timing link and top three in live state', () => {
+ renderHero({
+ state: 'live',
+ liveActive: true,
+ liveData,
+ currentSession: weekendSessions[0].session,
+ })
+
+ expect(screen.getByTestId('hero-live-link')).toHaveAttribute('href', '/live')
+ expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('VER')
+ expect(screen.getByTestId('hero-live-timing')).toHaveTextContent('HAM')
+ expect(screen.getByText('TRACK CLEAR')).toBeInTheDocument()
+ })
+
+ it('renders countdown and schedule strip in upcoming state', () => {
+ renderHero({
+ state: 'upcoming',
+ nextSession: weekendSessions[0].session,
+ })
+
+ expect(screen.getByTestId('hero-countdown')).toHaveTextContent('1d 01h 00m 00s')
+ expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Race')
+ expect(screen.getByTestId('hero-schedule-strip')).toHaveTextContent('Next')
+ })
+
+ it('renders last race podium and next GP countdown in between state', () => {
+ const nextMeeting: Meeting = {
+ ...focusMeeting,
+ meeting_key: 1301,
+ meeting_name: 'Canada',
+ country_code: 'CAN',
+ date_start: '2025-06-06T00:00:00+00:00',
+ date_end: '2025-06-08T00:00:00+00:00',
+ }
+
+ renderHero({
+ state: 'between',
+ lastRaceName: 'Monaco',
+ lastRacePodium: podium,
+ nextMeeting,
+ now: new Date('2025-06-01T12:00:00Z'),
+ })
+
+ expect(screen.getByTestId('hero-last-race')).toHaveTextContent('After Monaco')
+ expect(screen.getByTestId('hero-podium')).toHaveTextContent('VER')
+ expect(screen.getByTestId('hero-podium')).toHaveTextContent('HAM')
+ expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('Canada')
+ expect(screen.getByTestId('hero-next-gp-countdown')).toHaveTextContent('4d')
+ })
+})
diff --git a/tests/command-center.spec.ts b/tests/command-center.spec.ts
index a744e23..a63e327 100644
--- a/tests/command-center.spec.ts
+++ b/tests/command-center.spec.ts
@@ -9,7 +9,7 @@ test.describe('Command Center', () => {
await expect(page.getByTestId('command-center')).toBeVisible()
await expect(page.getByTestId('cc-focus')).toBeVisible()
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
- await expect(page.getByTestId('cc-actions')).toBeVisible()
+ await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
})
test('nav link reaches command center from race hub', async ({ page }) => {
@@ -21,8 +21,8 @@ test.describe('Command Center', () => {
test('open analysis action opens race hub for the focus session', async ({ page }) => {
await page.goto('/')
- await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION))
- await page.getByTestId('cc-action-race-hub').click()
+ await expect(page.getByTestId('hero-last-race-link')).toBeVisible()
+ await page.getByTestId('hero-last-race-link').click()
await expect(page).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
await expect(page.getByTestId('rh-identity')).toBeVisible()
await expect(page.getByTestId(`rh-session-${FULL_SESSION}`)).toBeVisible()