feat(frontend): add session-aware Command Center hero (#14)

Replace the static weekend band with a three-state hero (live, upcoming,
between weekends) driven by heroState() and existing page queries. Between-weekend
podium uses the existing race-hub endpoint for the last ingested race session
since championship hub does not expose finish positions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-03 23:59:38 -04:00
parent 75ca5f4deb
commit ba7f653e3c
8 changed files with 1323 additions and 170 deletions

View File

@@ -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()
})

View File

@@ -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 => ({
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')
})
})

View File

@@ -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
}) => (
<a href={to} {...rest}>
{children}
</a>
),
}))
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<ComponentProps<typeof CommandCenterHero>> = {}) {
const props: ComponentProps<typeof CommandCenterHero> = {
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(<CommandCenterHero {...props} />)
}
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')
})
})