diff --git a/documentations/refactor/21-mvp-completion-checklist.md b/documentations/refactor/21-mvp-completion-checklist.md
index a455319..e203e5c 100644
--- a/documentations/refactor/21-mvp-completion-checklist.md
+++ b/documentations/refactor/21-mvp-completion-checklist.md
@@ -111,8 +111,7 @@ Snapshots are stored under `tests/visual/__snapshots__/`. See
- Add track outline ingestion/read models to the React app if the local data
source is reliable enough.
- Expand from weekend/session ingestion toward safe full-season backfill.
-- Add Command Center, Drivers, Standings, and Settings as separate product
- phases.
+- Add Drivers, Standings, and Settings as separate product phases.
- Revisit static archive feasibility after source mapping is proven.
## Notes
diff --git a/documentations/refactor/23-phase-15-command-center.md b/documentations/refactor/23-phase-15-command-center.md
new file mode 100644
index 0000000..15ebcc2
--- /dev/null
+++ b/documentations/refactor/23-phase-15-command-center.md
@@ -0,0 +1,48 @@
+# Phase 15: Command Center V1
+
+## Goal
+
+Make the Web UI default route a useful local-first operations screen instead of
+requiring users to know a raw Race Hub session key.
+
+## Completed Scope
+
+- Added `/` as the Command Center route.
+- Added a top-level Command nav item while preserving Race Hub, Live, and Data
+ Library routes.
+- Shows local season coverage, weekend coverage, local session counts, and live
+ availability.
+- Selects a focus weekend from local data using current, upcoming, then recent
+ weekend priority.
+- Provides quick actions into Live Timing, Race Hub for the default local
+ session, and Data Library.
+- Lists recent local sessions with direct Race Hub links.
+- Added unit coverage for schedule selection helpers and the Command Center
+ page.
+- Added Playwright E2E and production smoke coverage for `/`.
+- Added visual regression coverage for Command Center at desktop, tablet, and
+ mobile viewports.
+
+## Constraints
+
+- React continues to call only local-first Go APIs; no direct OpenF1 reads were
+ added.
+- Live state remains read-only status from the existing Web live endpoint.
+- The page stays dense and operational rather than becoming a marketing landing
+ page.
+
+## Verification
+
+```bash
+npm --prefix frontend test -- --run
+npm --prefix frontend run build
+npm run test:e2e
+npm run test:e2e:prod
+npm run test:visual
+npm run test:visual:prod
+```
+
+## Related
+
+- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
+- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)
diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md
index aa1a64e..a12a162 100644
--- a/documentations/refactor/README.md
+++ b/documentations/refactor/README.md
@@ -79,6 +79,8 @@ not implementation tickets yet.
implementation status, verification commands, and remaining post-MVP work.
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md): Playwright
screenshot coverage for MVP routes and responsive viewports.
+- [23 Phase 15 Command Center](23-phase-15-command-center.md): default Web
+ entry screen for local coverage, weekend focus, live status, and next actions.
## External References
diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx
index c4ef195..8d37bf4 100644
--- a/frontend/src/components/Nav.tsx
+++ b/frontend/src/components/Nav.tsx
@@ -7,6 +7,9 @@ export function Nav() {
box- box
+
+ Command
+
Race Hub
diff --git a/frontend/src/lib/schedule.ts b/frontend/src/lib/schedule.ts
new file mode 100644
index 0000000..926af49
--- /dev/null
+++ b/frontend/src/lib/schedule.ts
@@ -0,0 +1,168 @@
+import type { Meeting, Session } from '../types'
+
+const DEFAULT_SESSION_DURATION_MS = 3 * 60 * 60 * 1000
+
+export function parseScheduleTime(value: string): Date | null {
+ if (!value) return null
+ const parsed = Date.parse(value)
+ if (!Number.isNaN(parsed)) return new Date(parsed)
+ const dateOnly = Date.parse(value.slice(0, 10))
+ return Number.isNaN(dateOnly) ? null : new Date(dateOnly)
+}
+
+export function meetingStartTime(meeting: Meeting): Date | null {
+ return parseScheduleTime(meeting.date_start)
+}
+
+export function meetingEndTime(meeting: Meeting): Date | null {
+ const end = parseScheduleTime(meeting.date_end)
+ if (end) return end
+ const start = meetingStartTime(meeting)
+ return start ? new Date(start.getTime() + 72 * 60 * 60 * 1000) : null
+}
+
+export function sessionStartTime(session: Session): Date | null {
+ return parseScheduleTime(session.date_start)
+}
+
+export function sessionEndTime(session: Session): Date | null {
+ const end = parseScheduleTime(session.date_end)
+ if (end) return end
+ const start = sessionStartTime(session)
+ return start ? new Date(start.getTime() + DEFAULT_SESSION_DURATION_MS) : null
+}
+
+export function sortSessionsByStart(sessions: Session[]): Session[] {
+ return [...sessions].sort((a, b) => {
+ const left = sessionStartTime(a)?.getTime() ?? 0
+ const right = sessionStartTime(b)?.getTime() ?? 0
+ if (left !== right) return left - right
+ return a.date_start.localeCompare(b.date_start)
+ })
+}
+
+export function currentMeeting(meetings: Meeting[], now: Date): Meeting | null {
+ let selected: Meeting | null = null
+ let latest = 0
+
+ for (const meeting of meetings) {
+ const start = meetingStartTime(meeting)
+ if (!start || start > now) continue
+ const end = meetingEndTime(meeting)
+ if (!end || now > new Date(end.getTime() + 24 * 60 * 60 * 1000)) continue
+ const startMs = start.getTime()
+ if (!selected || startMs > latest) {
+ selected = meeting
+ latest = startMs
+ }
+ }
+
+ return selected
+}
+
+export function nextUpcomingMeeting(meetings: Meeting[], now: Date): Meeting | null {
+ for (const meeting of meetings) {
+ const start = meetingStartTime(meeting)
+ if (start && start > now) return meeting
+ }
+ return null
+}
+
+export function mostRecentPastMeeting(meetings: Meeting[], now: Date): Meeting | null {
+ let selected: Meeting | null = null
+ let latest = 0
+
+ for (const meeting of meetings) {
+ const start = meetingStartTime(meeting)
+ if (!start || start > now) continue
+ const startMs = start.getTime()
+ if (!selected || startMs > latest) {
+ selected = meeting
+ latest = startMs
+ }
+ }
+
+ return selected
+}
+
+export function pickFocusMeeting(meetings: Meeting[], now: Date): Meeting | null {
+ return (
+ currentMeeting(meetings, now) ??
+ nextUpcomingMeeting(meetings, now) ??
+ mostRecentPastMeeting(meetings, now) ??
+ meetings[0] ??
+ null
+ )
+}
+
+export function meetingHasStarted(meeting: Meeting, now: Date): boolean {
+ const start = meetingStartTime(meeting)
+ return start != null && now >= start
+}
+
+export function currentAndNextSession(
+ sessions: Session[],
+ now: Date,
+): { current: Session | null; next: Session | null } {
+ const sorted = sortSessionsByStart(sessions)
+
+ for (const session of sorted) {
+ const start = sessionStartTime(session)
+ const end = sessionEndTime(session)
+ if (!start || !end) continue
+
+ if (now >= start && now < end) {
+ return { current: session, next: null }
+ }
+ if (now < start) {
+ return { current: null, next: session }
+ }
+ }
+
+ return { current: null, next: null }
+}
+
+export function formatCountdown(target: Date, now: Date): string {
+ const diffMs = Math.max(0, target.getTime() - now.getTime())
+ const totalSeconds = Math.floor(diffMs / 1000)
+ const days = Math.floor(totalSeconds / 86400)
+ const hours = Math.floor((totalSeconds % 86400) / 3600)
+ const mins = Math.floor((totalSeconds % 3600) / 60)
+ const secs = totalSeconds % 60
+ return `${days}d ${String(hours).padStart(2, '0')}h ${String(mins).padStart(2, '0')}m ${String(secs).padStart(2, '0')}s`
+}
+
+export function formatSessionScheduleTime(value: string): string {
+ const date = parseScheduleTime(value)
+ if (!date) return '—'
+ return date.toLocaleString('en-GB', {
+ weekday: 'short',
+ day: '2-digit',
+ month: 'short',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })
+}
+
+export type FocusMeetingKind = 'current' | 'next' | 'recent' | 'fallback'
+
+export function focusMeetingKind(meeting: Meeting, now: Date): FocusMeetingKind {
+ if (currentMeeting([meeting], now)) return 'current'
+ if (nextUpcomingMeeting([meeting], now)) return 'next'
+ if (mostRecentPastMeeting([meeting], now)) return 'recent'
+ return 'fallback'
+}
+
+export function focusMeetingLabel(kind: FocusMeetingKind): string {
+ switch (kind) {
+ case 'current':
+ return 'Current Weekend'
+ case 'next':
+ return 'Next Weekend'
+ case 'recent':
+ return 'Recent Local Weekend'
+ default:
+ return 'Weekend'
+ }
+}
diff --git a/frontend/src/pages/CommandCenterPage.tsx b/frontend/src/pages/CommandCenterPage.tsx
new file mode 100644
index 0000000..1ff52d7
--- /dev/null
+++ b/frontend/src/pages/CommandCenterPage.tsx
@@ -0,0 +1,413 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useQueries, useQuery } from '@tanstack/react-query'
+import { Link } from '@tanstack/react-router'
+import { fetchLiveState, fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
+import { countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib/coverage'
+import {
+ currentAndNextSession,
+ focusMeetingKind,
+ focusMeetingLabel,
+ formatCountdown,
+ formatSessionScheduleTime,
+ meetingHasStarted,
+ pickFocusMeeting,
+ sessionStartTime,
+ sortSessionsByStart,
+} from '../lib/schedule'
+import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
+import { CliCommands } from '../components/CliCommands'
+import type { Meeting, Weekend, WeekendSession } from '../types'
+
+function formatMeetingDates(meeting: Meeting): string {
+ const start = meeting.date_start?.slice(0, 10)
+ const end = meeting.date_end?.slice(0, 10)
+ if (start && end && start !== end) return `${start} – ${end}`
+ return start || end || '—'
+}
+
+function countSessionStats(weekends: (Weekend | undefined)[]) {
+ let local = 0
+ let partial = 0
+ let total = 0
+
+ for (const weekend of weekends) {
+ if (!weekend) continue
+ for (const entry of weekend.sessions) {
+ total++
+ if (entry.source === 'local') local++
+ else if (entry.source === 'partial') partial++
+ }
+ }
+
+ return { local, partial, total }
+}
+
+function collectRecentSessions(weekends: (Weekend | undefined)[], limit = 8) {
+ const rows: Array
= []
+
+ for (const weekend of weekends) {
+ if (!weekend) continue
+ for (const entry of weekend.sessions) {
+ rows.push({ ...entry, meeting: weekend.meeting })
+ }
+ }
+
+ return rows
+ .sort((a, b) => {
+ const left = Date.parse(a.session.date_start)
+ const right = Date.parse(b.session.date_start)
+ return right - left
+ })
+ .slice(0, limit)
+}
+
+export function CommandCenterPage() {
+ const [now, setNow] = useState(() => Date.now())
+
+ const seasonsQuery = useQuery({
+ queryKey: ['seasons'],
+ queryFn: fetchSeasons,
+ })
+
+ const latestSeason = seasonsQuery.data?.[0] ?? null
+
+ const meetingsQuery = useQuery({
+ queryKey: ['meetings', latestSeason],
+ queryFn: () => fetchLocalMeetings(latestSeason!),
+ enabled: latestSeason != null,
+ })
+
+ const meetings = meetingsQuery.data ?? []
+
+ const weekendQueries = useQueries({
+ queries: meetings.map((meeting) => ({
+ queryKey: ['weekend', meeting.meeting_key],
+ queryFn: () => fetchWeekend(meeting.meeting_key),
+ enabled: meetings.length > 0,
+ staleTime: 60_000,
+ })),
+ })
+
+ const liveQuery = useQuery({
+ queryKey: ['live-state'],
+ queryFn: fetchLiveState,
+ staleTime: 5_000,
+ })
+
+ useEffect(() => {
+ const timer = window.setInterval(() => setNow(Date.now()), 1000)
+ return () => window.clearInterval(timer)
+ }, [])
+
+ const nowDate = useMemo(() => new Date(now), [now])
+
+ const weekendsByKey = useMemo(() => {
+ const map = new Map()
+ meetings.forEach((meeting, i) => {
+ const data = weekendQueries[i]?.data
+ if (data) map.set(meeting.meeting_key, data)
+ })
+ return map
+ }, [meetings, weekendQueries])
+
+ const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
+ const meetingStats = countWeekendStats(weekendList)
+ const sessionStats = countSessionStats(weekendList)
+ const focusMeeting = pickFocusMeeting(meetings, nowDate)
+ const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
+ const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
+ const focusSessions = focusWeekend ? sortSessionsByStart(focusWeekend.sessions.map((s) => s.session)) : []
+ const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate)
+ const defaultSessionKey = focusWeekend?.default_session_key ?? focusWeekend?.sessions[0]?.session.session_key
+ const recentSessions = collectRecentSessions(weekendList)
+ const weekendsLoading = weekendQueries.some((q) => q.isLoading)
+
+ if (seasonsQuery.isLoading) {
+ return loading command center…
+ }
+
+ if (seasonsQuery.isError) {
+ return (
+
+ {seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
+
+ )
+ }
+
+ const seasons = seasonsQuery.data ?? []
+
+ if (seasons.length === 0) {
+ return (
+
+
+
Command Center
+ Local-first F1 operations
+
+
+
No ingested seasons yet
+
+ Ingest a season or session from the CLI, then return here for coverage and navigation.
+
+
+
+
+ Get Started
+
+
' },
+ ]}
+ />
+
+
+ )
+ }
+
+ const liveActive = liveQuery.data?.is_live === true
+
+ return (
+
+
+
+
Command Center
+
+ {latestSeason} season · {seasons.length} season{seasons.length === 1 ? '' : 's'} local
+
+
+
+
+ {liveActive ? 'Live session active' : 'No live session'}
+
+
+
+
+
+ Seasons
+ {seasons.length}
+
+
+ Weekends Full
+ {meetingStats.full}
+
+
+ Partial
+ {meetingStats.partial}
+
+
+ Missing
+ {meetingStats.missing}
+
+
+ Sessions Local
+
+ {sessionStats.local}/{sessionStats.total || '—'}
+
+
+
+
+
+
+
+ {focusKind ? focusMeetingLabel(focusKind) : 'Weekend'}
+ {focusWeekend && (
+
+ )}
+
+
+ {meetingsQuery.isLoading && loading meetings…
}
+
+ {meetingsQuery.isError && (
+
+ {meetingsQuery.error instanceof Error
+ ? meetingsQuery.error.message
+ : 'Failed to load meetings'}
+
+ )}
+
+ {!meetingsQuery.isLoading && !meetingsQuery.isError && focusMeeting && (
+ <>
+
+
+
{focusMeeting.meeting_name}
+
+ {focusMeeting.location}
+ {focusMeeting.country_code ? ` · ${focusMeeting.country_code}` : ''}
+ {' · '}
+ {formatMeetingDates(focusMeeting)}
+
+
+ {focusMeeting.circuit_short_name && (
+
{focusMeeting.circuit_short_name}
+ )}
+
+
+
+ {liveActive && (
+
+ LIVE
+ SignalR feed connected — open Live Timing
+
+ )}
+ {currentSession && (
+
+ ON TRACK
+ {currentSession.session_name}
+
+ )}
+ {!currentSession && nextSession && sessionStartTime(nextSession) && (
+
+ Next session
+ {nextSession.session_name}
+
+ {formatCountdown(sessionStartTime(nextSession)!, nowDate)}
+
+
+ )}
+ {!currentSession && !nextSession && focusMeeting && meetingHasStarted(focusMeeting, nowDate) && (
+
Weekend finished
+ )}
+ {!currentSession && !nextSession && focusKind === 'recent' && (
+
Historical weekend — local data available
+ )}
+
+
+ {weekendsLoading && !focusWeekend && (
+ loading weekend schedule…
+ )}
+
+ {focusWeekend && focusWeekend.sessions.length > 0 && (
+
+
+
+
+ Session
+ Start
+ Coverage
+ Open
+
+
+
+ {focusWeekend.sessions.map(({ session, source, datasets }) => {
+ const isCurrent = currentSession?.session_key === session.session_key
+ const isNext = nextSession?.session_key === session.session_key
+ return (
+
+
+
+ {sessionTypeAbbrev(session.session_type, session.session_name)}
+
+ {session.session_name}
+ {session.session_key === focusWeekend.default_session_key && (
+ default
+ )}
+
+
+ {formatSessionScheduleTime(session.date_start)}
+
+
+
+ {formatCoverageHint(datasets)}
+
+
+
+
+
+ Race Hub
+
+
+
+ )
+ })}
+
+
+
+ )}
+ >
+ )}
+
+ {!meetingsQuery.isLoading && meetings.length === 0 && (
+
+ No meetings ingested for {latestSeason}. Run{' '}
+ box-box --ingest-year {latestSeason}
+
+ )}
+
+
+
+
+
+ Quick Actions
+
+
+
+ Live Timing
+ {liveActive ? 'Session active' : 'Standby'}
+
+
+ Race Hub
+
+ {defaultSessionKey ? `session ${defaultSessionKey}` : 'Pick a session'}
+
+
+
+ Data Library
+
+ {meetingStats.full}/{meetingStats.total || 0} weekends full
+
+
+
+
+
+
+
+ Local Sessions
+ {recentSessions.length}
+
+ {weekendsLoading && recentSessions.length === 0 && (
+ loading sessions…
+ )}
+ {recentSessions.length === 0 && !weekendsLoading && (
+ No local sessions ingested yet.
+ )}
+ {recentSessions.length > 0 && (
+
+ {recentSessions.map(({ session, source, datasets, meeting }) => (
+
+
+
+ {meeting.meeting_name} · {session.session_name}
+
+
+ {session.session_key} · {formatCoverageHint(datasets)}
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
index 8e0af01..00ae1a6 100644
--- a/frontend/src/router.tsx
+++ b/frontend/src/router.tsx
@@ -1,5 +1,6 @@
-import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@tanstack/react-router'
+import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
import { Nav } from './components/Nav'
+import { CommandCenterPage } from './pages/CommandCenterPage'
import { RaceHubPage } from './pages/RaceHubPage'
import { DataLibraryPage } from './pages/DataLibraryPage'
import { LiveTimingPage } from './pages/LiveTimingPage'
@@ -17,12 +18,10 @@ const rootRoute = createRootRoute({
),
})
-const indexRoute = createRoute({
+export const commandCenterRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
- beforeLoad: () => {
- throw redirect({ to: '/race-hub', search: {} })
- },
+ component: CommandCenterPage,
})
export const raceHubRoute = createRoute({
@@ -50,7 +49,7 @@ export const liveTimingRoute = createRoute({
component: LiveTimingPage,
})
-const routeTree = rootRoute.addChildren([indexRoute, raceHubRoute, dataLibraryRoute, liveTimingRoute])
+const routeTree = rootRoute.addChildren([commandCenterRoute, raceHubRoute, dataLibraryRoute, liveTimingRoute])
export const router = createRouter({ routeTree })
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css
index 4ff4a63..f106129 100644
--- a/frontend/src/styles/app.css
+++ b/frontend/src/styles/app.css
@@ -970,6 +970,266 @@ a { color: inherit; text-decoration: none; }
.analysis-notice strong { color: var(--text); }
.analysis-notice code { font-family: var(--f-mono); font-size: 11px; color: var(--text-3); }
+/* ── Command Center ── */
+.cc-page {
+ max-width: 1120px;
+ margin: 0 auto;
+ padding: var(--s5) var(--s6);
+ min-height: calc(100vh - var(--nav-h));
+}
+
+.cc-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--s5);
+ margin-bottom: var(--s5);
+ padding-bottom: var(--s5);
+ border-bottom: 1px solid var(--border);
+}
+
+.cc-title {
+ font-size: 16px;
+ font-weight: 700;
+ margin-bottom: 2px;
+}
+
+.cc-subtitle {
+ font-size: 12px;
+ font-family: var(--f-mono);
+ color: var(--text-3);
+}
+
+.cc-live-pill {
+ display: flex;
+ align-items: center;
+ gap: var(--s3);
+ font-size: 11px;
+ font-family: var(--f-mono);
+ color: var(--text-2);
+ white-space: nowrap;
+}
+
+.cc-live-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: var(--text-3);
+ flex-shrink: 0;
+}
+.cc-live-dot.live {
+ background: var(--red);
+ box-shadow: 0 0 6px rgba(225, 6, 0, 0.6);
+}
+
+.badge-live {
+ background: rgba(225, 6, 0, 0.15);
+ color: var(--red);
+ border: 1px solid rgba(225, 6, 0, 0.35);
+}
+
+.cc-summary {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 1px;
+ border: 1px solid var(--border);
+ margin-bottom: var(--s5);
+}
+
+.cc-stat {
+ padding: var(--s4);
+ background: var(--surface);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.cc-stat-label {
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-3);
+}
+
+.cc-stat-val {
+ font-family: var(--f-mono);
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-2);
+}
+.cc-stat-full { color: var(--green); }
+.cc-stat-partial { color: var(--yellow); }
+
+.cc-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--s5);
+ align-items: start;
+}
+
+.cc-panel {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ padding: var(--s5);
+}
+
+.cc-side {
+ display: flex;
+ flex-direction: column;
+ gap: var(--s5);
+}
+
+.cc-focus-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--s4);
+ margin-bottom: var(--s4);
+}
+
+.cc-focus-name {
+ font-size: 15px;
+ font-weight: 700;
+ margin-bottom: 2px;
+}
+
+.cc-focus-meta {
+ font-size: 11px;
+ color: var(--text-3);
+}
+
+.cc-circuit {
+ font-size: 11px;
+ color: var(--text-2);
+ padding: 2px 8px;
+ border: 1px solid var(--border);
+ border-radius: 2px;
+}
+
+.cc-focus-status {
+ margin-bottom: var(--s5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--s3);
+}
+
+.cc-status-row {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--s3);
+ font-size: 12px;
+ color: var(--text-2);
+}
+.cc-status-row.muted { color: var(--text-3); }
+
+.cc-status-label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-3);
+}
+
+.cc-status-value { font-weight: 600; color: var(--text); }
+
+.cc-countdown {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--red);
+}
+
+.cc-schedule-table { margin-top: var(--s3); }
+.cc-schedule-table .badge { margin-left: var(--s3); }
+
+.cc-session-type {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 28px;
+ padding: 1px 4px;
+ margin-right: var(--s3);
+ font-family: var(--f-mono);
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--text-3);
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ border-radius: 2px;
+}
+
+.cc-row-live td { background: rgba(225, 6, 0, 0.06); }
+.cc-row-next td { background: rgba(255, 214, 0, 0.04); }
+
+.cc-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ border: 1px solid var(--border);
+}
+
+.cc-action {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: var(--s4);
+ background: var(--surface-h);
+ transition: background 0.1s;
+}
+.cc-action:hover { background: var(--surface-2); }
+
+.cc-action-label {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.cc-action-meta {
+ font-size: 11px;
+ font-family: var(--f-mono);
+ color: var(--text-3);
+}
+
+.cc-session-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ border: 1px solid var(--border);
+}
+
+.cc-session-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--s4);
+ padding: var(--s3) var(--s4);
+ background: var(--surface-h);
+ transition: background 0.1s;
+}
+.cc-session-row:hover { background: var(--surface-2); }
+
+.cc-session-row-title {
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.cc-session-row-meta {
+ font-size: 10px;
+ color: var(--text-3);
+ margin-top: 1px;
+}
+
+.cc-side-empty,
+.cc-cli-section {
+ font-size: 12px;
+ color: var(--text-3);
+}
+
+.mono { font-family: var(--f-mono); }
+
+@media (min-width: 1101px) {
+ .cc-grid { grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr); }
+}
+
/* ── Mobile ── */
@media (max-width: 640px) {
.page { padding: var(--s3); }
@@ -1017,4 +1277,8 @@ a { color: inherit; text-decoration: none; }
.dl-detail-wrap { border-top: 1px solid var(--border); }
.dl-content-header { padding: var(--s3) var(--s4); }
.dl-content-meta { font-size: 11px; }
+
+ .cc-page { padding: var(--s4); }
+ .cc-header { flex-direction: column; align-items: flex-start; }
+ .cc-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
diff --git a/frontend/src/test/CommandCenterPage.test.tsx b/frontend/src/test/CommandCenterPage.test.tsx
new file mode 100644
index 0000000..0f3a443
--- /dev/null
+++ b/frontend/src/test/CommandCenterPage.test.tsx
@@ -0,0 +1,131 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
+import { CommandCenterPage } from '../pages/CommandCenterPage'
+import type { DatasetInfo, Meeting, Weekend } from '../types'
+
+vi.mock('../api', () => ({
+ fetchSeasons: vi.fn(),
+ fetchLocalMeetings: vi.fn(),
+ fetchWeekend: vi.fn(),
+ fetchLiveState: vi.fn(),
+}))
+
+import { fetchSeasons, fetchLocalMeetings, fetchWeekend, fetchLiveState } from '../api'
+
+const mockFetchSeasons = vi.mocked(fetchSeasons)
+const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
+const mockFetchWeekend = vi.mocked(fetchWeekend)
+const mockFetchLiveState = vi.mocked(fetchLiveState)
+
+const meeting: 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 fullDatasets: Record = {
+ meeting: { status: 'available', source: 'local', count: 1 },
+ session: { status: 'available', source: 'local', count: 1 },
+ drivers: { status: 'available', source: 'local', count: 20 },
+ results: { status: 'available', source: 'local', count: 20 },
+ starting_grid: { status: 'available', source: 'local', count: 20 },
+ stints: { status: 'available', source: 'local', count: 2 },
+ pit_stops: { status: 'available', source: 'local', count: 1 },
+ positions: { status: 'available', source: 'local', count: 3 },
+ race_control: { status: 'available', source: 'local', count: 1 },
+ weather: { status: 'available', source: 'local', count: 1 },
+ laps: { status: 'available', source: 'local', count: 1 },
+}
+
+const weekend: Weekend = {
+ source: 'local',
+ meeting_key: 1229,
+ meeting,
+ default_session_key: 9472,
+ sessions: [
+ {
+ 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: fullDatasets,
+ },
+ ],
+}
+
+function renderPage() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+
+ const rootRoute = createRootRoute({
+ component: () => (
+
+
+
+ ),
+ })
+
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: CommandCenterPage,
+ })
+
+ const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
+
+ return render( )
+}
+
+describe('CommandCenterPage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFetchLiveState.mockResolvedValue({ is_live: false, data: null })
+ })
+
+ it('shows empty state when no seasons are ingested', async () => {
+ mockFetchSeasons.mockResolvedValue([])
+
+ renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('command-center-empty')).toBeInTheDocument()
+ })
+ expect(screen.getByText('No ingested seasons yet')).toBeInTheDocument()
+ })
+
+ it('shows coverage summary and focus weekend when data exists', async () => {
+ mockFetchSeasons.mockResolvedValue([2025])
+ mockFetchLocalMeetings.mockResolvedValue([meeting])
+ mockFetchWeekend.mockResolvedValue(weekend)
+
+ renderPage()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('command-center')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('Command Center')).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
+ })
+ expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
+ expect(screen.getByText('No live session')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/test/schedule.test.ts b/frontend/src/test/schedule.test.ts
new file mode 100644
index 0000000..1bf05d6
--- /dev/null
+++ b/frontend/src/test/schedule.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect } from 'vitest'
+import {
+ currentMeeting,
+ currentAndNextSession,
+ focusMeetingKind,
+ focusMeetingLabel,
+ formatCountdown,
+ nextUpcomingMeeting,
+ pickFocusMeeting,
+} from '../lib/schedule'
+import type { Meeting, Session } from '../types'
+
+const meeting = (overrides: Partial = {}): Meeting => ({
+ meeting_key: 1,
+ meeting_name: 'Monaco',
+ meeting_official_name: 'Monaco GP',
+ 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-25T23:59:59+00:00',
+ year: 2025,
+ ...overrides,
+})
+
+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('schedule helpers', () => {
+ it('picks current meeting when now is inside the weekend window', () => {
+ const now = new Date('2025-05-24T12:00:00Z')
+ const meetings = [meeting()]
+ expect(currentMeeting(meetings, now)?.meeting_key).toBe(1)
+ expect(pickFocusMeeting(meetings, now)?.meeting_key).toBe(1)
+ })
+
+ it('picks next upcoming meeting when all meetings are in the future', () => {
+ const now = new Date('2025-01-01T00:00:00Z')
+ const meetings = [meeting()]
+ expect(nextUpcomingMeeting(meetings, now)?.meeting_key).toBe(1)
+ expect(pickFocusMeeting(meetings, now)?.meeting_key).toBe(1)
+ expect(focusMeetingKind(meetings[0], now)).toBe('next')
+ expect(focusMeetingLabel('next')).toBe('Next Weekend')
+ })
+
+ it('falls back to most recent past meeting for historical local data', () => {
+ const now = new Date('2026-01-01T00:00:00Z')
+ const meetings = [meeting()]
+ expect(pickFocusMeeting(meetings, now)?.meeting_key).toBe(1)
+ expect(focusMeetingKind(meetings[0], now)).toBe('recent')
+ })
+
+ it('detects current and next sessions', () => {
+ 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 duringRace = new Date('2025-05-25T14:00:00+00:00')
+ expect(currentAndNextSession(sessions, duringRace).current?.session_key).toBe(2)
+
+ const beforeRace = new Date('2025-05-24T12:00:00+00:00')
+ expect(currentAndNextSession(sessions, beforeRace).next?.session_key).toBe(2)
+ })
+
+ it('formats countdown strings', () => {
+ const now = new Date('2025-05-25T12:00:00+00:00')
+ const target = new Date('2025-05-25T13:00:00+00:00')
+ expect(formatCountdown(target, now)).toBe('0d 01h 00m 00s')
+ })
+})
diff --git a/tests/command-center.spec.ts b/tests/command-center.spec.ts
new file mode 100644
index 0000000..3a29f57
--- /dev/null
+++ b/tests/command-center.spec.ts
@@ -0,0 +1,40 @@
+import { test, expect } from '@playwright/test'
+
+const FULL_SESSION = 9472
+
+test.describe('Command Center', () => {
+ test('loads as default route with local coverage summary', async ({ page }) => {
+ await page.goto('/')
+
+ await expect(page.getByTestId('command-center')).toBeVisible()
+ await expect(page.getByText('Command Center')).toBeVisible()
+ await expect(page.getByTestId('cc-focus')).toBeVisible()
+ await expect(page.getByTestId('cc-session-9472')).toBeVisible()
+ })
+
+ test('nav link reaches command center from race hub', async ({ page }) => {
+ await page.goto('/race-hub')
+ await page.getByRole('link', { name: 'Command' }).click()
+ await expect(page).toHaveURL('/')
+ await expect(page.getByTestId('command-center')).toBeVisible()
+ })
+
+ test('quick action opens race hub for default 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).toHaveURL(new RegExp(`/race-hub\\?session_key=${FULL_SESSION}`))
+ await expect(page.getByText('Final Classification')).toBeVisible()
+ })
+
+ test('existing routes continue to work', async ({ page }) => {
+ await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
+ await expect(page.getByText('Final Classification')).toBeVisible()
+
+ await page.goto('/data-library')
+ await expect(page.getByTestId('data-library')).toBeVisible()
+
+ await page.goto('/live')
+ await expect(page.getByTestId('live-empty')).toBeVisible()
+ })
+})
diff --git a/tests/production-smoke.spec.ts b/tests/production-smoke.spec.ts
index 05ed147..5c98949 100644
--- a/tests/production-smoke.spec.ts
+++ b/tests/production-smoke.spec.ts
@@ -3,6 +3,13 @@ import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472
test.describe('Production serving (Go + built React)', () => {
+ test('serves command center as default route', async ({ page }) => {
+ await page.goto('/')
+
+ await expect(page.getByTestId('command-center')).toBeVisible()
+ await expect(page.getByTestId('cc-session-9472')).toBeVisible()
+ })
+
test('serves race hub with classification from built assets', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
@@ -25,8 +32,11 @@ test.describe('Production serving (Go + built React)', () => {
})
test('nav links work from built SPA', async ({ page }) => {
- await page.goto('/race-hub')
- await page.getByRole('link', { name: 'Data Library' }).click()
+ await page.goto('/')
+ await page.locator('.app-nav').getByRole('link', { name: 'Race Hub' }).click()
+ await expect(page).toHaveURL(/\/race-hub/)
+
+ await page.locator('.app-nav').getByRole('link', { name: 'Data Library' }).click()
await expect(page).toHaveURL(/\/data-library/)
await page.getByRole('link', { name: 'Live' }).click()
diff --git a/tests/visual/__snapshots__/desktop/command-center.png b/tests/visual/__snapshots__/desktop/command-center.png
new file mode 100644
index 0000000..6519995
Binary files /dev/null and b/tests/visual/__snapshots__/desktop/command-center.png differ
diff --git a/tests/visual/__snapshots__/mobile/command-center.png b/tests/visual/__snapshots__/mobile/command-center.png
new file mode 100644
index 0000000..f5cbc64
Binary files /dev/null and b/tests/visual/__snapshots__/mobile/command-center.png differ
diff --git a/tests/visual/__snapshots__/tablet/command-center.png b/tests/visual/__snapshots__/tablet/command-center.png
new file mode 100644
index 0000000..25ebad8
Binary files /dev/null and b/tests/visual/__snapshots__/tablet/command-center.png differ
diff --git a/tests/visual/helpers.ts b/tests/visual/helpers.ts
index fb56d35..8d9d227 100644
--- a/tests/visual/helpers.ts
+++ b/tests/visual/helpers.ts
@@ -14,6 +14,14 @@ export async function waitForScreenshotReady(page: Page): Promise {
await page.waitForTimeout(150)
}
+export async function gotoCommandCenterReady(page: Page): Promise {
+ await page.goto('/')
+ await expect(page.getByTestId('command-center')).toBeVisible()
+ await expect(page.getByTestId('cc-focus')).toBeVisible()
+ await expect(page.getByTestId('cc-session-9472')).toBeVisible()
+ await waitForScreenshotReady(page)
+}
+
export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): Promise {
await page.goto(`/race-hub?session_key=${sessionKey}`)
await expect(page.getByText('Final Classification')).toBeVisible()
diff --git a/tests/visual/mvp-screens.spec.ts b/tests/visual/mvp-screens.spec.ts
index 0d20c15..b6844c4 100644
--- a/tests/visual/mvp-screens.spec.ts
+++ b/tests/visual/mvp-screens.spec.ts
@@ -1,5 +1,6 @@
import { test } from '@playwright/test'
import {
+ gotoCommandCenterReady,
gotoDataLibraryReady,
gotoLiveEmptyReady,
gotoRaceHubReady,
@@ -7,6 +8,11 @@ import {
} from './helpers'
test.describe('MVP visual regression', () => {
+ test('command-center', async ({ page }) => {
+ await gotoCommandCenterReady(page)
+ await screenshotPage(page, 'command-center')
+ })
+
test('race-hub', async ({ page }) => {
await gotoRaceHubReady(page)
await screenshotPage(page, 'race-hub')