mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Add command center screen
This commit is contained in:
@@ -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
|
- Add track outline ingestion/read models to the React app if the local data
|
||||||
source is reliable enough.
|
source is reliable enough.
|
||||||
- Expand from weekend/session ingestion toward safe full-season backfill.
|
- Expand from weekend/session ingestion toward safe full-season backfill.
|
||||||
- Add Command Center, Drivers, Standings, and Settings as separate product
|
- Add Drivers, Standings, and Settings as separate product phases.
|
||||||
phases.
|
|
||||||
- Revisit static archive feasibility after source mapping is proven.
|
- Revisit static archive feasibility after source mapping is proven.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
48
documentations/refactor/23-phase-15-command-center.md
Normal file
48
documentations/refactor/23-phase-15-command-center.md
Normal file
@@ -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)
|
||||||
@@ -79,6 +79,8 @@ not implementation tickets yet.
|
|||||||
implementation status, verification commands, and remaining post-MVP work.
|
implementation status, verification commands, and remaining post-MVP work.
|
||||||
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md): Playwright
|
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md): Playwright
|
||||||
screenshot coverage for MVP routes and responsive viewports.
|
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
|
## External References
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export function Nav() {
|
|||||||
box<em>-</em>box
|
box<em>-</em>box
|
||||||
</Link>
|
</Link>
|
||||||
<div className="nav-links">
|
<div className="nav-links">
|
||||||
|
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
||||||
|
Command
|
||||||
|
</Link>
|
||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
Race Hub
|
Race Hub
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
168
frontend/src/lib/schedule.ts
Normal file
168
frontend/src/lib/schedule.ts
Normal file
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
413
frontend/src/pages/CommandCenterPage.tsx
Normal file
413
frontend/src/pages/CommandCenterPage.tsx
Normal file
@@ -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<WeekendSession & { meeting: Meeting }> = []
|
||||||
|
|
||||||
|
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<number, Weekend>()
|
||||||
|
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 <div className="page loading-state">loading command center…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="page error-box">
|
||||||
|
{seasonsQuery.error instanceof Error ? seasonsQuery.error.message : 'Failed to load seasons'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasons = seasonsQuery.data ?? []
|
||||||
|
|
||||||
|
if (seasons.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="cc-page" data-testid="command-center-empty">
|
||||||
|
<div className="cc-header">
|
||||||
|
<h1 className="cc-title">Command Center</h1>
|
||||||
|
<span className="cc-subtitle">Local-first F1 operations</span>
|
||||||
|
</div>
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-title">No ingested seasons yet</div>
|
||||||
|
<div className="empty-state-desc">
|
||||||
|
Ingest a season or session from the CLI, then return here for coverage and navigation.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cc-cli-section">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Get Started</span>
|
||||||
|
</div>
|
||||||
|
<CliCommands
|
||||||
|
commands={[
|
||||||
|
{ comment: '# Discover season meetings and sessions', cmd: 'box-box --ingest-year 2025' },
|
||||||
|
{ comment: '# Ingest a full weekend or single session', cmd: 'box-box --ingest-meeting <meeting_key>' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveActive = liveQuery.data?.is_live === true
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cc-page" data-testid="command-center">
|
||||||
|
<div className="cc-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="cc-title">Command Center</h1>
|
||||||
|
<span className="cc-subtitle">
|
||||||
|
{latestSeason} season · {seasons.length} season{seasons.length === 1 ? '' : 's'} local
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="cc-live-pill" data-testid="cc-live-status">
|
||||||
|
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
||||||
|
{liveActive ? 'Live session active' : 'No live session'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cc-summary">
|
||||||
|
<div className="cc-stat">
|
||||||
|
<span className="cc-stat-label">Seasons</span>
|
||||||
|
<span className="cc-stat-val">{seasons.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="cc-stat">
|
||||||
|
<span className="cc-stat-label">Weekends Full</span>
|
||||||
|
<span className="cc-stat-val cc-stat-full">{meetingStats.full}</span>
|
||||||
|
</div>
|
||||||
|
<div className="cc-stat">
|
||||||
|
<span className="cc-stat-label">Partial</span>
|
||||||
|
<span className="cc-stat-val cc-stat-partial">{meetingStats.partial}</span>
|
||||||
|
</div>
|
||||||
|
<div className="cc-stat">
|
||||||
|
<span className="cc-stat-label">Missing</span>
|
||||||
|
<span className="cc-stat-val">{meetingStats.missing}</span>
|
||||||
|
</div>
|
||||||
|
<div className="cc-stat">
|
||||||
|
<span className="cc-stat-label">Sessions Local</span>
|
||||||
|
<span className="cc-stat-val">
|
||||||
|
{sessionStats.local}/{sessionStats.total || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cc-grid">
|
||||||
|
<section className="cc-panel" data-testid="cc-focus">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">{focusKind ? focusMeetingLabel(focusKind) : 'Weekend'}</span>
|
||||||
|
{focusWeekend && (
|
||||||
|
<SourceBadge source={focusWeekend.source} label={weekendStatusLabel(focusWeekend.source)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{meetingsQuery.isLoading && <div className="loading-state">loading meetings…</div>}
|
||||||
|
|
||||||
|
{meetingsQuery.isError && (
|
||||||
|
<div className="error-box">
|
||||||
|
{meetingsQuery.error instanceof Error
|
||||||
|
? meetingsQuery.error.message
|
||||||
|
: 'Failed to load meetings'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!meetingsQuery.isLoading && !meetingsQuery.isError && focusMeeting && (
|
||||||
|
<>
|
||||||
|
<div className="cc-focus-head">
|
||||||
|
<div>
|
||||||
|
<div className="cc-focus-name">{focusMeeting.meeting_name}</div>
|
||||||
|
<div className="cc-focus-meta mono">
|
||||||
|
{focusMeeting.location}
|
||||||
|
{focusMeeting.country_code ? ` · ${focusMeeting.country_code}` : ''}
|
||||||
|
{' · '}
|
||||||
|
{formatMeetingDates(focusMeeting)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{focusMeeting.circuit_short_name && (
|
||||||
|
<span className="cc-circuit mono">{focusMeeting.circuit_short_name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cc-focus-status">
|
||||||
|
{liveActive && (
|
||||||
|
<div className="cc-status-row">
|
||||||
|
<span className="badge badge-live">LIVE</span>
|
||||||
|
<span>SignalR feed connected — open Live Timing</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentSession && (
|
||||||
|
<div className="cc-status-row">
|
||||||
|
<span className="badge badge-live">ON TRACK</span>
|
||||||
|
<span>{currentSession.session_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!currentSession && nextSession && sessionStartTime(nextSession) && (
|
||||||
|
<div className="cc-status-row">
|
||||||
|
<span className="cc-status-label">Next session</span>
|
||||||
|
<span className="cc-status-value">{nextSession.session_name}</span>
|
||||||
|
<span className="cc-countdown mono">
|
||||||
|
{formatCountdown(sessionStartTime(nextSession)!, nowDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!currentSession && !nextSession && focusMeeting && meetingHasStarted(focusMeeting, nowDate) && (
|
||||||
|
<div className="cc-status-row muted">Weekend finished</div>
|
||||||
|
)}
|
||||||
|
{!currentSession && !nextSession && focusKind === 'recent' && (
|
||||||
|
<div className="cc-status-row muted">Historical weekend — local data available</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weekendsLoading && !focusWeekend && (
|
||||||
|
<div className="loading-state">loading weekend schedule…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{focusWeekend && focusWeekend.sessions.length > 0 && (
|
||||||
|
<div className="scroll-x">
|
||||||
|
<table className="data-table cc-schedule-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>Coverage</th>
|
||||||
|
<th className="r">Open</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{focusWeekend.sessions.map(({ session, source, datasets }) => {
|
||||||
|
const isCurrent = currentSession?.session_key === session.session_key
|
||||||
|
const isNext = nextSession?.session_key === session.session_key
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={session.session_key}
|
||||||
|
className={isCurrent ? 'cc-row-live' : isNext ? 'cc-row-next' : ''}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span className="cc-session-type">
|
||||||
|
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{session.session_name}</span>
|
||||||
|
{session.session_key === focusWeekend.default_session_key && (
|
||||||
|
<span className="nav-sub">default</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="mono" style={{ color: 'var(--text-2)' }}>
|
||||||
|
{formatSessionScheduleTime(session.date_start)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="mono" style={{ color: 'var(--text-2)' }}>
|
||||||
|
{formatCoverageHint(datasets)}
|
||||||
|
</span>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</td>
|
||||||
|
<td className="r">
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: session.session_key }}
|
||||||
|
className="nav-action-btn nav-action-primary"
|
||||||
|
>
|
||||||
|
Race Hub
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!meetingsQuery.isLoading && meetings.length === 0 && (
|
||||||
|
<div className="missing-notice">
|
||||||
|
No meetings ingested for {latestSeason}. Run{' '}
|
||||||
|
<code>box-box --ingest-year {latestSeason}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="cc-side">
|
||||||
|
<section className="cc-panel">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Quick Actions</span>
|
||||||
|
</div>
|
||||||
|
<div className="cc-actions">
|
||||||
|
<Link to="/live" className="cc-action" data-testid="cc-action-live">
|
||||||
|
<span className="cc-action-label">Live Timing</span>
|
||||||
|
<span className="cc-action-meta">{liveActive ? 'Session active' : 'Standby'}</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/race-hub"
|
||||||
|
search={defaultSessionKey ? { session_key: defaultSessionKey } : {}}
|
||||||
|
className="cc-action"
|
||||||
|
data-testid="cc-action-race-hub"
|
||||||
|
>
|
||||||
|
<span className="cc-action-label">Race Hub</span>
|
||||||
|
<span className="cc-action-meta">
|
||||||
|
{defaultSessionKey ? `session ${defaultSessionKey}` : 'Pick a session'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<Link to="/data-library" className="cc-action" data-testid="cc-action-data-library">
|
||||||
|
<span className="cc-action-label">Data Library</span>
|
||||||
|
<span className="cc-action-meta">
|
||||||
|
{meetingStats.full}/{meetingStats.total || 0} weekends full
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="cc-panel">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Local Sessions</span>
|
||||||
|
<span className="sec-meta">{recentSessions.length}</span>
|
||||||
|
</div>
|
||||||
|
{weekendsLoading && recentSessions.length === 0 && (
|
||||||
|
<div className="loading-state">loading sessions…</div>
|
||||||
|
)}
|
||||||
|
{recentSessions.length === 0 && !weekendsLoading && (
|
||||||
|
<div className="cc-side-empty">No local sessions ingested yet.</div>
|
||||||
|
)}
|
||||||
|
{recentSessions.length > 0 && (
|
||||||
|
<div className="cc-session-list">
|
||||||
|
{recentSessions.map(({ session, source, datasets, meeting }) => (
|
||||||
|
<Link
|
||||||
|
key={session.session_key}
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: session.session_key }}
|
||||||
|
className="cc-session-row"
|
||||||
|
data-testid={`cc-session-${session.session_key}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="cc-session-row-title">
|
||||||
|
{meeting.meeting_name} · {session.session_name}
|
||||||
|
</div>
|
||||||
|
<div className="cc-session-row-meta mono">
|
||||||
|
{session.session_key} · {formatCoverageHint(datasets)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { Nav } from './components/Nav'
|
||||||
|
import { CommandCenterPage } from './pages/CommandCenterPage'
|
||||||
import { RaceHubPage } from './pages/RaceHubPage'
|
import { RaceHubPage } from './pages/RaceHubPage'
|
||||||
import { DataLibraryPage } from './pages/DataLibraryPage'
|
import { DataLibraryPage } from './pages/DataLibraryPage'
|
||||||
import { LiveTimingPage } from './pages/LiveTimingPage'
|
import { LiveTimingPage } from './pages/LiveTimingPage'
|
||||||
@@ -17,12 +18,10 @@ const rootRoute = createRootRoute({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
export const commandCenterRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
beforeLoad: () => {
|
component: CommandCenterPage,
|
||||||
throw redirect({ to: '/race-hub', search: {} })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const raceHubRoute = createRoute({
|
export const raceHubRoute = createRoute({
|
||||||
@@ -50,7 +49,7 @@ export const liveTimingRoute = createRoute({
|
|||||||
component: LiveTimingPage,
|
component: LiveTimingPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([indexRoute, raceHubRoute, dataLibraryRoute, liveTimingRoute])
|
const routeTree = rootRoute.addChildren([commandCenterRoute, raceHubRoute, dataLibraryRoute, liveTimingRoute])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|
||||||
|
|||||||
@@ -970,6 +970,266 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.analysis-notice strong { color: var(--text); }
|
.analysis-notice strong { color: var(--text); }
|
||||||
.analysis-notice code { font-family: var(--f-mono); font-size: 11px; color: var(--text-3); }
|
.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 ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.page { padding: var(--s3); }
|
.page { padding: var(--s3); }
|
||||||
@@ -1017,4 +1277,8 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.dl-detail-wrap { border-top: 1px solid var(--border); }
|
.dl-detail-wrap { border-top: 1px solid var(--border); }
|
||||||
.dl-content-header { padding: var(--s3) var(--s4); }
|
.dl-content-header { padding: var(--s3) var(--s4); }
|
||||||
.dl-content-meta { font-size: 11px; }
|
.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)); }
|
||||||
}
|
}
|
||||||
|
|||||||
131
frontend/src/test/CommandCenterPage.test.tsx
Normal file
131
frontend/src/test/CommandCenterPage.test.tsx
Normal file
@@ -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<string, DatasetInfo> = {
|
||||||
|
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: () => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<CommandCenterPage />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: CommandCenterPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
|
||||||
|
|
||||||
|
return render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
81
frontend/src/test/schedule.test.ts
Normal file
81
frontend/src/test/schedule.test.ts
Normal file
@@ -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 => ({
|
||||||
|
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 => ({
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
40
tests/command-center.spec.ts
Normal file
40
tests/command-center.spec.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,13 @@ import { test, expect } from '@playwright/test'
|
|||||||
const FULL_SESSION = 9472
|
const FULL_SESSION = 9472
|
||||||
|
|
||||||
test.describe('Production serving (Go + built React)', () => {
|
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 }) => {
|
test('serves race hub with classification from built assets', async ({ page }) => {
|
||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
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 }) => {
|
test('nav links work from built SPA', async ({ page }) => {
|
||||||
await page.goto('/race-hub')
|
await page.goto('/')
|
||||||
await page.getByRole('link', { name: 'Data Library' }).click()
|
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 expect(page).toHaveURL(/\/data-library/)
|
||||||
|
|
||||||
await page.getByRole('link', { name: 'Live' }).click()
|
await page.getByRole('link', { name: 'Live' }).click()
|
||||||
|
|||||||
BIN
tests/visual/__snapshots__/desktop/command-center.png
Normal file
BIN
tests/visual/__snapshots__/desktop/command-center.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
tests/visual/__snapshots__/mobile/command-center.png
Normal file
BIN
tests/visual/__snapshots__/mobile/command-center.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
tests/visual/__snapshots__/tablet/command-center.png
Normal file
BIN
tests/visual/__snapshots__/tablet/command-center.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -14,6 +14,14 @@ export async function waitForScreenshotReady(page: Page): Promise<void> {
|
|||||||
await page.waitForTimeout(150)
|
await page.waitForTimeout(150)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function gotoCommandCenterReady(page: Page): Promise<void> {
|
||||||
|
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<void> {
|
export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
|
||||||
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
await page.goto(`/race-hub?session_key=${sessionKey}`)
|
||||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
await expect(page.getByText('Final Classification')).toBeVisible()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test } from '@playwright/test'
|
import { test } from '@playwright/test'
|
||||||
import {
|
import {
|
||||||
|
gotoCommandCenterReady,
|
||||||
gotoDataLibraryReady,
|
gotoDataLibraryReady,
|
||||||
gotoLiveEmptyReady,
|
gotoLiveEmptyReady,
|
||||||
gotoRaceHubReady,
|
gotoRaceHubReady,
|
||||||
@@ -7,6 +8,11 @@ import {
|
|||||||
} from './helpers'
|
} from './helpers'
|
||||||
|
|
||||||
test.describe('MVP visual regression', () => {
|
test.describe('MVP visual regression', () => {
|
||||||
|
test('command-center', async ({ page }) => {
|
||||||
|
await gotoCommandCenterReady(page)
|
||||||
|
await screenshotPage(page, 'command-center')
|
||||||
|
})
|
||||||
|
|
||||||
test('race-hub', async ({ page }) => {
|
test('race-hub', async ({ page }) => {
|
||||||
await gotoRaceHubReady(page)
|
await gotoRaceHubReady(page)
|
||||||
await screenshotPage(page, 'race-hub')
|
await screenshotPage(page, 'race-hub')
|
||||||
|
|||||||
Reference in New Issue
Block a user