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 && ( +
+ + + + + + + + + + + {focusWeekend.sessions.map(({ session, source, datasets }) => { + const isCurrent = currentSession?.session_key === session.session_key + const isNext = nextSession?.session_key === session.session_key + return ( + + + + + + + ) + })} + +
SessionStartCoverageOpen
+ + {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} +
+ )} +
+ + +
+
+ ) +} 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')