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 ( +
+
+
+
+ ) +} + +function LiveHero({ + liveActive, + liveData, + currentSession, + focusMeeting, +}: { + liveActive: boolean + liveData: LiveStreamData | null | undefined + currentSession: Session | null + focusMeeting: Meeting +}) { + const sessionName = + liveData?.Session?.SessionName ?? currentSession?.session_name ?? 'Live session' + const trackStatus = trackStatusInfo(liveData?.TrackStatus) + const topThree = sortLiveTimingRows(liveData).filter((r) => r.Position > 0).slice(0, 3) + const decal = countryDecal(focusMeeting) + + return ( + <> +
+ {decal} +
+
● Live now
+

{sessionName}

+
+ {[focusMeeting.meeting_name, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')} +
+
+ {trackStatus.label} +
+
+ {topThree.length > 0 && ( +
+ {topThree.map((row) => ( +
+ + P{row.Position} + + + + {row.Info?.Tla ?? row.RacingNumber} + + + {row.Position === 1 ? 'LEAD' : row.Driver.Interval || row.Driver.GapToLeader || '—'} + +
+ ))} +
+ )} +
+
{liveActive ? 'SignalR' : 'On track'}
+
LIVE
+
{sessionName}
+
+
+
+ + Open Live Timing + +
+ + ) +} + +function UpcomingHero({ + now, + focusMeeting, + focusKind, + sessions, + currentSession, + nextSession, + liveActive, + analysisSessionKey, + analysisSessionName, +}: { + now: Date + focusMeeting: Meeting + focusKind: FocusMeetingKind + sessions: WeekendSession[] + currentSession: Session | null + nextSession: Session | null + liveActive: boolean + analysisSessionKey?: number + analysisSessionName?: string +}) { + const decal = countryDecal(focusMeeting) + const countdownTarget = + nextSession && sessionStartTime(nextSession) + ? sessionStartTime(nextSession)! + : meetingStartTime(focusMeeting) + + const kindLabel = + focusKind === 'current' + ? 'Current weekend' + : focusKind === 'next' + ? 'Next weekend' + : 'Weekend' + + const sortedSessions = [...sessions].sort((a, b) => { + const left = sessionStartTime(a.session)?.getTime() ?? 0 + const right = sessionStartTime(b.session)?.getTime() ?? 0 + return left - right + }) + + return ( + <> +
+ {decal} +
+
{kindLabel}
+

{focusMeeting.meeting_name}

+
+ {[focusMeeting.location, focusMeeting.circuit_short_name].filter(Boolean).join(' · ')} +
+
{formatGpDateRange(focusMeeting)}
+
+
+ {nextSession ? ( + <> +
Next · {nextSession.session_name}
+ {countdownTarget && ( +
{formatCountdown(countdownTarget, now)}
+ )} +
{formatSessionScheduleTime(nextSession.date_start)}
+ + ) : ( + <> +
Status
+
Complete
+
Weekend finished
+ + )} +
+
+ + {sortedSessions.length > 0 && ( +
+ {sortedSessions.map(({ session }) => { + const status = classifySessionStatus(session, now) + const isNext = nextSession?.session_key === session.session_key + const isCurrent = currentSession?.session_key === session.session_key + const isLive = isCurrent && liveActive + return ( + +
+ {sessionTypeAbbrev(session.session_type, session.session_name)} +
+
{session.session_name}
+
+ {formatSessionScheduleTime(session.date_start)} +
+
+ {isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'} +
+ + ) + })} +
+ )} + +
+ + Watch Live + + {analysisSessionKey != null && ( + + Open Analysis + {analysisSessionName ? ` · ${analysisSessionName}` : ''} + + )} +
+ + ) +} + +function BetweenHero({ + now, + lastRaceName, + lastRacePodium, + lastRaceSessionKey, + nextMeeting, + analysisSessionKey, + analysisSessionName, +}: { + now: Date + lastRaceName: string + lastRacePodium: EnrichedResult[] + lastRaceSessionKey?: number + nextMeeting: Meeting | null + analysisSessionKey?: number + analysisSessionName?: string +}) { + const nextStart = nextMeeting ? meetingStartTime(nextMeeting) : null + const podium = lastRacePodium + .filter((r) => r.position >= 1 && r.position <= 3) + .sort((a, b) => a.position - b.position) + + return ( + <> +
+
+
Between race weekends
+ {lastRaceName && ( +

+ After {lastRaceName} +

+ )} + {!lastRaceName &&

Season pause

} +
+ + {podium.length > 0 && ( +
+ {podium.map((r) => ( +
+ + P{r.position} + + + + {r.name_acronym} + +
+ ))} +
+ )} + + {nextMeeting && ( +
+
+ {countryFlag(nextMeeting) && ( + + )} +
+
Next GP
+
+ {nextMeeting.meeting_name} +
+
+
+ {nextStart && ( + <> +
+ {formatCountdown(nextStart, now)} +
+
{formatGpDateRange(nextMeeting)}
+ + )} +
+ )} +
+ +
+ {lastRaceSessionKey != null && ( + + View {lastRaceName || 'last race'} results → + + )} + {analysisSessionKey != null && analysisSessionKey !== lastRaceSessionKey && ( + + Open Analysis{analysisSessionName ? ` · ${analysisSessionName}` : ''} + + )} + + Live Timing → + +
+ + ) +} diff --git a/frontend/src/lib/hero.ts b/frontend/src/lib/hero.ts new file mode 100644 index 0000000..3c12636 --- /dev/null +++ b/frontend/src/lib/hero.ts @@ -0,0 +1,27 @@ +import type { Session } from '../types' +import { sessionEndTime, sessionStartTime, type FocusMeetingKind } from './schedule' + +export type HeroStateKind = 'live' | 'upcoming' | 'between' + +export interface HeroStateInputs { + now: Date + liveActive: boolean + currentSession: Session | null + focusKind: FocusMeetingKind | null +} + +export function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' { + const start = sessionStartTime(session) + const end = sessionEndTime(session) + if (start && end && now >= start && now < end) return 'live' + if (start && now >= start) return 'done' + return 'upcoming' +} + +export function heroState(inputs: HeroStateInputs): HeroStateKind { + const { liveActive, currentSession, focusKind } = inputs + + if (liveActive || currentSession) return 'live' + if (focusKind === 'current') return 'upcoming' + return 'between' +} diff --git a/frontend/src/pages/CommandCenterPage.tsx b/frontend/src/pages/CommandCenterPage.tsx index 7f54907..29c7493 100644 --- a/frontend/src/pages/CommandCenterPage.tsx +++ b/frontend/src/pages/CommandCenterPage.tsx @@ -1,38 +1,38 @@ import { useEffect, useMemo, useState } from 'react' import { useQueries, useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { fetchLiveState, fetchLocalMeetings, fetchSeasonMeetings, fetchSeasons, fetchSessions, fetchWeekend, fetchChampionshipHub } from '../api' +import { + fetchChampionshipHub, + fetchLiveState, + fetchLocalMeetings, + fetchRaceHub, + fetchSeasonMeetings, + fetchSeasons, + fetchSessions, + fetchWeekend, +} from '../api' +import { CommandCenterHero } from '../components/CommandCenterHero' +import { PaddockBriefing } from '../components/PaddockBriefing' import { RACE_HUB_DATASETS, countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage' +import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity' +import { classifySessionStatus, heroState } from '../lib/hero' import { currentAndNextSession, focusMeetingKind, - formatCountdown, formatSessionScheduleTime, meetingHasStarted, + mostRecentPastMeeting, + nextUpcomingMeeting, pickFocusMeeting, - sessionEndTime, - sessionStartTime, sortSessionsByStart, } from '../lib/schedule' -import { countryAccent, countryDecal, countryFlag, formatGpDateRange } from '../lib/gpIdentity' import type { Meeting, Session, Weekend, WeekendSession } from '../types' -import { PaddockBriefing } from '../components/PaddockBriefing' -import { Play, Activity, Calendar, Trophy } from 'lucide-react' - -type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback' +import { Trophy } from 'lucide-react' const missingDatasets = Object.fromEntries( RACE_HUB_DATASETS.map((dataset) => [dataset, { status: 'missing', source: 'none', count: 0 }]), ) as WeekendSession['datasets'] -function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' { - const start = sessionStartTime(session) - const end = sessionEndTime(session) - if (start && end && now >= start && now < end) return 'live' - if (start && now >= start) return 'done' - return 'upcoming' -} - function meetingStatus(meeting: Meeting, focusKey: number | undefined, now: Date) { if (meeting.meeting_key === focusKey) return 'focus' if (meetingHasStarted(meeting, now)) return 'past' @@ -148,7 +148,36 @@ export function CommandCenterPage() { focusWeekendSessions[0]?.session const analysisSessionKey = actionSession?.session_key - const weekendsLoading = weekendQueries.some((q) => q.isLoading) + const liveActive = liveQuery.data?.is_live === true + const heroStateKind = heroState({ + now: nowDate, + liveActive, + currentSession, + focusKind, + }) + + const lastPastMeeting = useMemo( + () => mostRecentPastMeeting(focusMeetings, nowDate), + [focusMeetings, nowDate], + ) + const lastPastWeekend = lastPastMeeting ? weekendsByKey.get(lastPastMeeting.meeting_key) : undefined + const lastRaceAnalysis = pickAnalysisSession(lastPastWeekend) + const lastRaceSessionKey = lastRaceAnalysis?.session.session_key + + const lastRaceHubQuery = useQuery({ + queryKey: ['race-hub', lastRaceSessionKey, 'hero-podium'], + queryFn: () => fetchRaceHub(lastRaceSessionKey!), + enabled: lastRaceSessionKey != null && heroStateKind === 'between', + staleTime: 60_000, + }) + + const nextMeetingForHero = useMemo(() => { + if (heroStateKind !== 'between') return null + return nextUpcomingMeeting(focusMeetings, nowDate) + }, [heroStateKind, focusMeetings, nowDate]) + + const lastRacePodium = lastRaceHubQuery.data?.results ?? [] + const lastRaceName = champHub?.last_race ?? lastPastMeeting?.meeting_name ?? '' if (seasonsQuery.isLoading) { return
loading command center…
@@ -189,19 +218,7 @@ export function CommandCenterPage() { ) } - const liveActive = liveQuery.data?.is_live === true - const statusKind: WeekendStatusKind = liveActive - ? 'live' - : focusKind === 'current' - ? 'current' - : focusKind === 'next' - ? 'next' - : focusKind === 'recent' - ? 'recent' - : 'fallback' - const accent = countryAccent(focusMeeting ?? null) - const decal = countryDecal(focusMeeting ?? null) const accentStyle = { '--gp-accent': accent } as React.CSSProperties return ( @@ -224,76 +241,25 @@ export function CommandCenterPage() { )} - {focusMeeting && ( -
-
-
- -
- - Watch Live - {liveActive ? 'Feed active' : 'Standby'} - - - Open Analysis - - {actionSession - ? `${actionSession.session_name} · session ${actionSession.session_key}` - : 'Pick a session'} - - - {nextSession && sessionStartTime(nextSession) && ( - { - e.preventDefault() - document.getElementById('cc-schedule')?.scrollIntoView({ behavior: 'smooth' }) - }} - > - Schedule - - next: {nextSession.session_name} - - - )} -
-
+ {focusMeeting && focusKind && ( + )}
@@ -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()