Rework command center for race weekends
@@ -109,9 +109,10 @@ BOXBOX_API_PORT=18080 npm run dev --prefix frontend
|
|||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/` | **Command Center** — local season/weekend coverage, focus weekend, quick links to Live / Race Hub / Data Library |
|
| `/` | **Command Center** — fan-facing race-weekend home with GP identity, live status, session schedule, and analysis links |
|
||||||
| `/race-hub?session_key=<key>` | **Race Hub** — classification, grid, strategy, positions, laps, race control, weather (from local DB) |
|
| `/race-hub?session_key=<key>` | **Race Hub** — classification, grid, strategy, positions, laps, race control, weather (from local DB) |
|
||||||
| `/data-library` | **Data Library** — ingestion coverage and suggested CLI commands |
|
| `/admin` | **Admin / Data Health** — ingestion coverage, local data status, and suggested CLI commands |
|
||||||
|
| `/data-library` | Legacy alias for Admin / Data Health |
|
||||||
| `/live` | **Live Timing** — timing tower and race control via SSE when a session is live |
|
| `/live` | **Live Timing** — timing tower and race control via SSE when a session is live |
|
||||||
|
|
||||||
Example after seeding: `http://localhost:5173/race-hub?session_key=9472`
|
Example after seeding: `http://localhost:5173/race-hub?session_key=9472`
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ checkpoint for what has been completed and what remains.
|
|||||||
- React + TypeScript frontend added with TanStack Query and Router.
|
- React + TypeScript frontend added with TanStack Query and Router.
|
||||||
- Race Hub route added for classification, grid, strategy, positions, laps,
|
- Race Hub route added for classification, grid, strategy, positions, laps,
|
||||||
race control, weather, and dataset status.
|
race control, weather, and dataset status.
|
||||||
- Data Library route added for local season/weekend coverage and CLI guidance.
|
- Admin / Data Health route added for local season/weekend coverage and CLI
|
||||||
|
guidance; `/data-library` remains a legacy alias.
|
||||||
- Live Timing route added for current backend live snapshot/SSE state.
|
- Live Timing route added for current backend live snapshot/SSE state.
|
||||||
- Go web mode serves the built React app from `frontend/dist` and falls back to
|
- Go web mode serves the built React app from `frontend/dist` and falls back to
|
||||||
embedded legacy assets when no build is present.
|
embedded legacy assets when no build is present.
|
||||||
@@ -54,7 +55,8 @@ BOXBOX_API_PORT=18080 npm run dev --prefix frontend -- --host 127.0.0.1 --port 1
|
|||||||
Then open:
|
Then open:
|
||||||
|
|
||||||
- `http://127.0.0.1:15173/race-hub?session_key=9472`
|
- `http://127.0.0.1:15173/race-hub?session_key=9472`
|
||||||
- `http://127.0.0.1:15173/data-library`
|
- `http://127.0.0.1:15173/admin`
|
||||||
|
- `http://127.0.0.1:15173/data-library` (legacy alias)
|
||||||
- `http://127.0.0.1:15173/live`
|
- `http://127.0.0.1:15173/live`
|
||||||
|
|
||||||
### Production web smoke (Go serves built React)
|
### Production web smoke (Go serves built React)
|
||||||
@@ -70,7 +72,8 @@ BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db /tmp/boxbox-mvp.db --port
|
|||||||
Then open:
|
Then open:
|
||||||
|
|
||||||
- `http://127.0.0.1:18080/race-hub?session_key=9472`
|
- `http://127.0.0.1:18080/race-hub?session_key=9472`
|
||||||
- `http://127.0.0.1:18080/data-library`
|
- `http://127.0.0.1:18080/admin`
|
||||||
|
- `http://127.0.0.1:18080/data-library` (legacy alias)
|
||||||
- `http://127.0.0.1:18080/live`
|
- `http://127.0.0.1:18080/live`
|
||||||
|
|
||||||
Automated production-serving coverage:
|
Automated production-serving coverage:
|
||||||
|
|||||||
97
documentations/refactor/25-phase-18-fan-command-center.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Phase 18: Fan Command Center and Admin Split
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Shift the Web UI from a local-data console toward an F1 fan race-weekend
|
||||||
|
command center, while keeping the dark, dense, ops-room aesthetic. Move
|
||||||
|
local-data and ingestion concerns into a thin Admin / Data Health area so they
|
||||||
|
no longer dominate the fan-facing first screen.
|
||||||
|
|
||||||
|
## Completed Scope
|
||||||
|
|
||||||
|
- Reworked `/` (`CommandCenterPage`) around the focus race weekend:
|
||||||
|
- GP identity band with country decal, GP name, location, circuit, date
|
||||||
|
range, status eyebrow (Live now / Current / Next / Recent), and a
|
||||||
|
country-derived left-edge accent strip.
|
||||||
|
- Countdown block — LIVE state, current-session label, next-session
|
||||||
|
`Xd HHh MMm SSs` countdown, or "Weekend finished" — anchored to the band.
|
||||||
|
- Primary actions row: Watch Live, Open Analysis (preselects race → qual →
|
||||||
|
first local session), Schedule jump.
|
||||||
|
- Session schedule as responsive cards instead of a table, with per-session
|
||||||
|
coverage dot, status pill, and direct Race Hub link (keeps the existing
|
||||||
|
`cc-session-{key}` testid).
|
||||||
|
- Recent local weekends rendered as a chip strip with country decals.
|
||||||
|
- Empty state reframed: short eyebrow, single-paragraph instruction, and
|
||||||
|
links to Live + Admin (no inline CLI on the fan-facing surface).
|
||||||
|
- Added `frontend/src/lib/gpIdentity.ts` for country accent, 3-letter decal,
|
||||||
|
and short date-range formatting.
|
||||||
|
- Reframed `DataLibraryPage` as **Admin · Data Health**: page header,
|
||||||
|
utility-style stats banner (seasons / full / partial / missing), and a
|
||||||
|
back-link to Command Center in the footer.
|
||||||
|
- Added `/admin` route rendering the same Data Health page. `/data-library`
|
||||||
|
remains as a legacy alias so existing links keep working.
|
||||||
|
- Updated `Nav` so primary fan destinations (Command / Live / Race Hub) sit
|
||||||
|
next to the logo, and a small monospace **Admin** chip is anchored to the
|
||||||
|
far right as a utility link.
|
||||||
|
|
||||||
|
## What Moved Into Admin / Data Health
|
||||||
|
|
||||||
|
- Five-stat coverage strip (Seasons / Full / Partial / Missing / Sessions
|
||||||
|
Local) — now lives in the admin banner.
|
||||||
|
- CLI ingest guidance — only shown under Admin and inside meeting detail.
|
||||||
|
- Per-meeting weekend table, per-session dataset status panel, and ingest
|
||||||
|
command blocks — unchanged content, but no longer reachable from the fan
|
||||||
|
nav directly.
|
||||||
|
|
||||||
|
## What Did Not Change
|
||||||
|
|
||||||
|
- Race Hub session-key table flow is unchanged (deeper rework is deferred).
|
||||||
|
- No new backend endpoints. Command Center still calls the existing local-first
|
||||||
|
APIs (`/api/v1/seasons`, `/api/v1/meetings`, `/api/v1/weekend`,
|
||||||
|
`/api/v1/live/state`).
|
||||||
|
- Live SignalR bridge, Live Timing page, and TUI live mode are untouched.
|
||||||
|
|
||||||
|
## Tests and Visual Coverage
|
||||||
|
|
||||||
|
Updated:
|
||||||
|
|
||||||
|
- `frontend/src/test/CommandCenterPage.test.tsx` — covers new band, decal,
|
||||||
|
empty-state copy, and analysis action label.
|
||||||
|
- `tests/command-center.spec.ts` — exercises new actions container and the
|
||||||
|
reframed admin route alongside existing routes.
|
||||||
|
- `tests/data-library.spec.ts` — renamed describe block, exercises both
|
||||||
|
`/admin` and the `/data-library` alias, uses the new Admin nav link.
|
||||||
|
- `tests/production-smoke.spec.ts` — covers `/admin`, the legacy
|
||||||
|
`/data-library` route, and the updated nav labels.
|
||||||
|
- `tests/visual/__snapshots__/{desktop,tablet,mobile}/{command-center,data-library}.png`
|
||||||
|
regenerated against the new layout.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
All commands above were run in this branch and pass: 94 unit tests, 15 E2E,
|
||||||
|
6 prod smoke, 12 visual baseline, 12 prod visual baseline.
|
||||||
|
|
||||||
|
## Limitations and Follow-ups
|
||||||
|
|
||||||
|
- The Schedule action only renders when a next session exists. During a
|
||||||
|
live session the row shows two actions instead of three; intentional.
|
||||||
|
- Country accent palette in `gpIdentity.ts` is a hand-tuned subset of country
|
||||||
|
codes; unknown codes fall back to a neutral gray.
|
||||||
|
- Race Hub remains the next target — its session-selection flow is still the
|
||||||
|
most dated part of the fan path, especially on tablet.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)
|
||||||
|
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md)
|
||||||
|
- [23 Phase 15 Command Center](23-phase-15-command-center.md)
|
||||||
|
- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md)
|
||||||
@@ -89,6 +89,9 @@ not implementation tickets yet.
|
|||||||
entry screen for local coverage, weekend focus, live status, and next actions.
|
entry screen for local coverage, weekend focus, live status, and next actions.
|
||||||
- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md): denser
|
- [24 Phase 16 Live Timing Polish](24-phase-16-live-timing-polish.md): denser
|
||||||
React live timing layout, status treatment, and race-control polish.
|
React live timing layout, status treatment, and race-control polish.
|
||||||
|
- [25 Phase 18 Fan Command Center](25-phase-18-fan-command-center.md): reworks
|
||||||
|
`/` around race-weekend identity and splits ingestion/admin concerns into
|
||||||
|
the new `/admin` (Data Health) route.
|
||||||
|
|
||||||
## External References
|
## External References
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ export function Nav() {
|
|||||||
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
<Link to="/" activeProps={{ className: 'active' }} activeOptions={{ exact: true }}>
|
||||||
Command
|
Command
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
|
||||||
Race Hub
|
|
||||||
</Link>
|
|
||||||
<Link to="/live" activeProps={{ className: 'active' }}>
|
<Link to="/live" activeProps={{ className: 'active' }}>
|
||||||
Live
|
Live
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/data-library" activeProps={{ className: 'active' }}>
|
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
|
||||||
Data Library
|
Race Hub
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="nav-utility">
|
||||||
|
<Link to="/admin" className="nav-utility-link" activeProps={{ className: 'nav-utility-link active' }}>
|
||||||
|
Admin
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
66
frontend/src/lib/gpIdentity.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Meeting } from '../types'
|
||||||
|
|
||||||
|
const COUNTRY_ACCENTS: Record<string, string> = {
|
||||||
|
ITA: '#1f7a4d',
|
||||||
|
GBR: '#3a6cf5',
|
||||||
|
USA: '#d62828',
|
||||||
|
MON: '#d61a3e',
|
||||||
|
ESP: '#e7a90b',
|
||||||
|
FRA: '#2353d6',
|
||||||
|
GER: '#cdc7c2',
|
||||||
|
DEU: '#cdc7c2',
|
||||||
|
BEL: '#e0a01b',
|
||||||
|
NLD: '#ef7c1a',
|
||||||
|
HUN: '#3f8a4b',
|
||||||
|
AUT: '#c61f1f',
|
||||||
|
AZE: '#1f7eaf',
|
||||||
|
CAN: '#c8202c',
|
||||||
|
AUS: '#1b6b86',
|
||||||
|
JPN: '#c8202c',
|
||||||
|
SGP: '#c8202c',
|
||||||
|
BRA: '#2fa84f',
|
||||||
|
BHR: '#a8132b',
|
||||||
|
SAU: '#0a7a3b',
|
||||||
|
ARE: '#4d6b2a',
|
||||||
|
QAT: '#7a1e3e',
|
||||||
|
CHN: '#c8202c',
|
||||||
|
MEX: '#1f7a4d',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ACCENT = '#9aa0a6'
|
||||||
|
|
||||||
|
export function countryAccent(meeting: Meeting | undefined | null): string {
|
||||||
|
if (!meeting) return DEFAULT_ACCENT
|
||||||
|
const code = meeting.country_code?.toUpperCase()
|
||||||
|
if (code && COUNTRY_ACCENTS[code]) return COUNTRY_ACCENTS[code]
|
||||||
|
return DEFAULT_ACCENT
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countryDecal(meeting: Meeting | undefined | null): string {
|
||||||
|
const code = meeting?.country_code?.toUpperCase()
|
||||||
|
if (code && code.length >= 2) return code.slice(0, 3)
|
||||||
|
const name = meeting?.country_name ?? ''
|
||||||
|
return name.slice(0, 3).toUpperCase() || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatGpDateRange(meeting: Meeting | undefined | null): string {
|
||||||
|
if (!meeting) return ''
|
||||||
|
const start = meeting.date_start?.slice(0, 10)
|
||||||
|
const end = meeting.date_end?.slice(0, 10)
|
||||||
|
if (!start && !end) return ''
|
||||||
|
const fmt = (iso: string): string => {
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
if (start && end && start !== end) {
|
||||||
|
const left = fmt(start)
|
||||||
|
const right = fmt(end)
|
||||||
|
const year = end.slice(0, 4)
|
||||||
|
return `${left} – ${right} ${year}`
|
||||||
|
}
|
||||||
|
if (start) {
|
||||||
|
return `${fmt(start)} ${start.slice(0, 4)}`
|
||||||
|
}
|
||||||
|
return end ? `${fmt(end)} ${end.slice(0, 4)}` : ''
|
||||||
|
}
|
||||||
@@ -6,61 +6,56 @@ import { countWeekendStats, formatCoverageHint, sessionTypeAbbrev } from '../lib
|
|||||||
import {
|
import {
|
||||||
currentAndNextSession,
|
currentAndNextSession,
|
||||||
focusMeetingKind,
|
focusMeetingKind,
|
||||||
focusMeetingLabel,
|
|
||||||
formatCountdown,
|
formatCountdown,
|
||||||
formatSessionScheduleTime,
|
formatSessionScheduleTime,
|
||||||
meetingHasStarted,
|
meetingHasStarted,
|
||||||
pickFocusMeeting,
|
pickFocusMeeting,
|
||||||
|
sessionEndTime,
|
||||||
sessionStartTime,
|
sessionStartTime,
|
||||||
sortSessionsByStart,
|
sortSessionsByStart,
|
||||||
} from '../lib/schedule'
|
} from '../lib/schedule'
|
||||||
import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
|
import { countryAccent, countryDecal, formatGpDateRange } from '../lib/gpIdentity'
|
||||||
import { CliCommands } from '../components/CliCommands'
|
import type { Meeting, Session, Weekend, WeekendSession } from '../types'
|
||||||
import type { Meeting, Weekend, WeekendSession } from '../types'
|
|
||||||
|
|
||||||
function formatMeetingDates(meeting: Meeting): string {
|
type WeekendStatusKind = 'live' | 'current' | 'next' | 'recent' | 'fallback'
|
||||||
const start = meeting.date_start?.slice(0, 10)
|
|
||||||
const end = meeting.date_end?.slice(0, 10)
|
function classifySessionStatus(session: Session, now: Date): 'live' | 'done' | 'upcoming' {
|
||||||
if (start && end && start !== end) return `${start} – ${end}`
|
const start = sessionStartTime(session)
|
||||||
return start || end || '—'
|
const end = sessionEndTime(session)
|
||||||
|
if (start && end && now >= start && now < end) return 'live'
|
||||||
|
if (start && now >= start) return 'done'
|
||||||
|
return 'upcoming'
|
||||||
}
|
}
|
||||||
|
|
||||||
function countSessionStats(weekends: (Weekend | undefined)[]) {
|
function collectRecentWeekends(weekends: (Weekend | undefined)[], focusKey: number | null, limit = 6) {
|
||||||
let local = 0
|
const rows: Weekend[] = []
|
||||||
let partial = 0
|
|
||||||
let total = 0
|
|
||||||
|
|
||||||
for (const weekend of weekends) {
|
for (const weekend of weekends) {
|
||||||
if (!weekend) continue
|
if (!weekend) continue
|
||||||
for (const entry of weekend.sessions) {
|
if (focusKey != null && weekend.meeting_key === focusKey) continue
|
||||||
total++
|
if (weekend.source === 'none') continue
|
||||||
if (entry.source === 'local') local++
|
rows.push(weekend)
|
||||||
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
|
return rows
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const left = Date.parse(a.session.date_start)
|
const left = Date.parse(a.meeting.date_start ?? '')
|
||||||
const right = Date.parse(b.session.date_start)
|
const right = Date.parse(b.meeting.date_start ?? '')
|
||||||
return right - left
|
return right - left
|
||||||
})
|
})
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickAnalysisSession(weekend: Weekend | undefined): WeekendSession | undefined {
|
||||||
|
if (!weekend) return undefined
|
||||||
|
const local = weekend.sessions.filter((s) => s.source === 'local')
|
||||||
|
const partial = weekend.sessions.filter((s) => s.source === 'partial')
|
||||||
|
const pool = local.length > 0 ? local : partial.length > 0 ? partial : weekend.sessions
|
||||||
|
const race = pool.find((s) => s.session.session_type?.toLowerCase().includes('race'))
|
||||||
|
if (race) return race
|
||||||
|
const qual = pool.find((s) => s.session.session_type?.toLowerCase().includes('qualifying'))
|
||||||
|
if (qual) return qual
|
||||||
|
return pool[0]
|
||||||
|
}
|
||||||
|
|
||||||
export function CommandCenterPage() {
|
export function CommandCenterPage() {
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
@@ -112,14 +107,21 @@ export function CommandCenterPage() {
|
|||||||
|
|
||||||
const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
|
const weekendList = useMemo(() => weekendQueries.map((q) => q.data), [weekendQueries])
|
||||||
const meetingStats = countWeekendStats(weekendList)
|
const meetingStats = countWeekendStats(weekendList)
|
||||||
const sessionStats = countSessionStats(weekendList)
|
|
||||||
const focusMeeting = pickFocusMeeting(meetings, nowDate)
|
const focusMeeting = pickFocusMeeting(meetings, nowDate)
|
||||||
const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
|
const focusWeekend = focusMeeting ? weekendsByKey.get(focusMeeting.meeting_key) : undefined
|
||||||
const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
|
const focusKind = focusMeeting ? focusMeetingKind(focusMeeting, nowDate) : null
|
||||||
const focusSessions = focusWeekend ? sortSessionsByStart(focusWeekend.sessions.map((s) => s.session)) : []
|
const focusSessions: Session[] = focusWeekend
|
||||||
|
? sortSessionsByStart(focusWeekend.sessions.map((s) => s.session))
|
||||||
|
: []
|
||||||
const { current: currentSession, next: nextSession } = currentAndNextSession(focusSessions, nowDate)
|
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 recentWeekends = collectRecentWeekends(weekendList, focusMeeting?.meeting_key ?? null)
|
||||||
|
const analysisSession = pickAnalysisSession(focusWeekend)
|
||||||
|
const analysisSessionKey =
|
||||||
|
analysisSession?.session.session_key ??
|
||||||
|
focusWeekend?.default_session_key ??
|
||||||
|
focusWeekend?.sessions[0]?.session.session_key
|
||||||
|
|
||||||
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
|
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
|
||||||
|
|
||||||
if (seasonsQuery.isLoading) {
|
if (seasonsQuery.isLoading) {
|
||||||
@@ -138,276 +140,284 @@ export function CommandCenterPage() {
|
|||||||
|
|
||||||
if (seasons.length === 0) {
|
if (seasons.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="cc-page" data-testid="command-center-empty">
|
<div className="cc-page cc-empty" data-testid="command-center-empty">
|
||||||
<div className="cc-header">
|
<div className="cc-empty-band">
|
||||||
<h1 className="cc-title">Command Center</h1>
|
<span className="cc-empty-eyebrow mono">box-box · command center</span>
|
||||||
<span className="cc-subtitle">Local-first F1 operations</span>
|
<h1 className="cc-empty-title">No local data yet</h1>
|
||||||
|
<p className="cc-empty-sub">
|
||||||
|
Ingest a race weekend from the CLI to populate this screen with live status, next-session
|
||||||
|
countdowns, and analysis links.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="empty-state">
|
<div className="cc-empty-actions">
|
||||||
<div className="empty-state-title">No ingested seasons yet</div>
|
<Link to="/live" className="cc-action cc-action-live">
|
||||||
<div className="empty-state-desc">
|
<span className="cc-action-label">Live Timing</span>
|
||||||
Ingest a season or session from the CLI, then return here for coverage and navigation.
|
<span className="cc-action-meta">Standby</span>
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
<Link to="/admin" className="cc-action">
|
||||||
<div className="cc-cli-section">
|
<span className="cc-action-label">Admin · Data Health</span>
|
||||||
<div className="sec-header">
|
<span className="cc-action-meta">Ingestion guidance</span>
|
||||||
<span className="sec-title">Get Started</span>
|
</Link>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const liveActive = liveQuery.data?.is_live === true
|
const liveActive = liveQuery.data?.is_live === true
|
||||||
|
const statusKind: WeekendStatusKind = liveActive
|
||||||
|
? 'live'
|
||||||
|
: focusKind === 'current'
|
||||||
|
? 'current'
|
||||||
|
: focusKind === 'next'
|
||||||
|
? 'next'
|
||||||
|
: focusKind === 'recent'
|
||||||
|
? 'recent'
|
||||||
|
: 'fallback'
|
||||||
|
|
||||||
|
const accent = countryAccent(focusMeeting ?? null)
|
||||||
|
const decal = countryDecal(focusMeeting ?? null)
|
||||||
|
const accentStyle = { '--gp-accent': accent } as React.CSSProperties
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cc-page" data-testid="command-center">
|
<div className="cc-page" data-testid="command-center" style={accentStyle}>
|
||||||
<div className="cc-header">
|
<div className="cc-topbar">
|
||||||
<div>
|
<span className="cc-topbar-label mono">box-box · command center</span>
|
||||||
<h1 className="cc-title">Command Center</h1>
|
<span className="cc-topbar-meta mono">
|
||||||
<span className="cc-subtitle">
|
{latestSeason} season · {meetingStats.full}/{meetingStats.total || 0} weekends full
|
||||||
{latestSeason} season · {seasons.length} season{seasons.length === 1 ? '' : 's'} local
|
</span>
|
||||||
</span>
|
<span className="cc-live-pill" data-testid="cc-live-status">
|
||||||
</div>
|
|
||||||
<div className="cc-live-pill" data-testid="cc-live-status">
|
|
||||||
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
<span className={`cc-live-dot ${liveActive ? 'live' : ''}`} />
|
||||||
{liveActive ? 'Live session active' : 'No live session'}
|
{liveActive ? 'Live session active' : 'No live session'}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cc-summary">
|
{!focusMeeting && (
|
||||||
<div className="cc-stat">
|
<div className="missing-notice">
|
||||||
<span className="cc-stat-label">Seasons</span>
|
No meetings ingested for {latestSeason}. Run{' '}
|
||||||
<span className="cc-stat-val">{seasons.length}</span>
|
<code>box-box --ingest-year {latestSeason}</code>
|
||||||
</div>
|
</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">
|
{focusMeeting && (
|
||||||
<section className="cc-panel" data-testid="cc-focus">
|
<section className="cc-weekend-band" data-testid="cc-focus">
|
||||||
<div className="sec-header">
|
<div className="cc-band-accent" aria-hidden="true" />
|
||||||
<span className="sec-title">{focusKind ? focusMeetingLabel(focusKind) : 'Weekend'}</span>
|
<div className="cc-band-body">
|
||||||
{focusWeekend && (
|
<div className="cc-band-row">
|
||||||
<SourceBadge source={focusWeekend.source} label={weekendStatusLabel(focusWeekend.source)} />
|
<span className="cc-band-decal mono">{decal}</span>
|
||||||
)}
|
<div className="cc-band-titles">
|
||||||
</div>
|
<div className="cc-band-eyebrow mono">
|
||||||
|
<WeekendKindLabel kind={statusKind} />
|
||||||
{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>
|
</div>
|
||||||
{focusMeeting.circuit_short_name && (
|
<h1 className="cc-band-name">{focusMeeting.meeting_name}</h1>
|
||||||
<span className="cc-circuit mono">{focusMeeting.circuit_short_name}</span>
|
<div className="cc-band-sub mono">
|
||||||
)}
|
{[focusMeeting.location, focusMeeting.circuit_short_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</div>
|
||||||
|
<div className="cc-band-sub mono cc-band-dates">{formatGpDateRange(focusMeeting)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CountdownBlock
|
||||||
|
liveActive={liveActive}
|
||||||
|
currentSession={currentSession}
|
||||||
|
nextSession={nextSession}
|
||||||
|
meeting={focusMeeting}
|
||||||
|
now={nowDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="cc-focus-status">
|
{focusMeeting && (
|
||||||
{liveActive && (
|
<div className="cc-actions-row" data-testid="cc-actions">
|
||||||
<div className="cc-status-row">
|
<Link
|
||||||
<span className="badge badge-live">LIVE</span>
|
to="/live"
|
||||||
<span>SignalR feed connected — open Live Timing</span>
|
className={`cc-pri-action ${liveActive ? 'is-live' : ''}`}
|
||||||
</div>
|
data-testid="cc-action-live"
|
||||||
)}
|
>
|
||||||
{currentSession && (
|
<span className="cc-pri-label">Watch Live</span>
|
||||||
<div className="cc-status-row">
|
<span className="cc-pri-meta mono">{liveActive ? 'Feed active' : 'Standby'}</span>
|
||||||
<span className="badge badge-live">ON TRACK</span>
|
</Link>
|
||||||
<span>{currentSession.session_name}</span>
|
<Link
|
||||||
</div>
|
to="/race-hub"
|
||||||
)}
|
search={analysisSessionKey ? { session_key: analysisSessionKey } : {}}
|
||||||
{!currentSession && nextSession && sessionStartTime(nextSession) && (
|
className="cc-pri-action"
|
||||||
<div className="cc-status-row">
|
data-testid="cc-action-race-hub"
|
||||||
<span className="cc-status-label">Next session</span>
|
>
|
||||||
<span className="cc-status-value">{nextSession.session_name}</span>
|
<span className="cc-pri-label">Open Analysis</span>
|
||||||
<span className="cc-countdown mono">
|
<span className="cc-pri-meta mono">
|
||||||
{formatCountdown(sessionStartTime(nextSession)!, nowDate)}
|
{analysisSession
|
||||||
|
? `${analysisSession.session.session_name} · session ${analysisSession.session.session_key}`
|
||||||
|
: 'Pick a session'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{nextSession && sessionStartTime(nextSession) && (
|
||||||
|
<a
|
||||||
|
href="#cc-schedule"
|
||||||
|
className="cc-pri-action"
|
||||||
|
data-testid="cc-action-schedule"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
document.getElementById('cc-schedule')?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="cc-pri-label">Schedule</span>
|
||||||
|
<span className="cc-pri-meta mono">
|
||||||
|
next: {nextSession.session_name}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{focusWeekend && focusWeekend.sessions.length > 0 && (
|
||||||
|
<section className="cc-schedule" id="cc-schedule">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Weekend Schedule</span>
|
||||||
|
<span className="sec-meta mono">{focusWeekend.sessions.length} sessions</span>
|
||||||
|
</div>
|
||||||
|
<div className="cc-session-strip" role="list">
|
||||||
|
{focusWeekend.sessions.map(({ session, source, datasets }) => {
|
||||||
|
const status = classifySessionStatus(session, nowDate)
|
||||||
|
const isNext = nextSession?.session_key === session.session_key
|
||||||
|
const isCurrent = currentSession?.session_key === session.session_key
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={session.session_key}
|
||||||
|
to="/race-hub"
|
||||||
|
search={{ session_key: session.session_key }}
|
||||||
|
className={`cc-session-card cc-status-${status}${isNext ? ' is-next' : ''}${
|
||||||
|
isCurrent ? ' is-current' : ''
|
||||||
|
}`}
|
||||||
|
data-testid={`cc-session-${session.session_key}`}
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<div className="cc-session-card-head">
|
||||||
|
<span className="cc-session-abbrev mono">
|
||||||
|
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
||||||
|
</span>
|
||||||
|
<span className={`cc-session-status cc-status-pill-${status}`}>
|
||||||
|
{isCurrent ? 'On track' : status === 'done' ? 'Done' : isNext ? 'Next' : 'Upcoming'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="cc-session-name">{session.session_name}</div>
|
||||||
{!currentSession && !nextSession && focusMeeting && meetingHasStarted(focusMeeting, nowDate) && (
|
<div className="cc-session-time mono">{formatSessionScheduleTime(session.date_start)}</div>
|
||||||
<div className="cc-status-row muted">Weekend finished</div>
|
<div className="cc-session-cov mono">
|
||||||
)}
|
<span className={`cc-cov-dot cc-cov-${source}`} aria-hidden="true" />
|
||||||
{!currentSession && !nextSession && focusKind === 'recent' && (
|
{formatCoverageHint(datasets)}
|
||||||
<div className="cc-status-row muted">Historical weekend — local data available</div>
|
</div>
|
||||||
)}
|
</Link>
|
||||||
</div>
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{weekendsLoading && !focusWeekend && (
|
{(weekendsLoading || recentWeekends.length > 0) && (
|
||||||
<div className="loading-state">loading weekend schedule…</div>
|
<section className="cc-recent">
|
||||||
)}
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Recent Local Weekends</span>
|
||||||
{focusWeekend && focusWeekend.sessions.length > 0 && (
|
<span className="sec-meta mono">{recentWeekends.length}</span>
|
||||||
<div className="scroll-x">
|
</div>
|
||||||
<table className="data-table cc-schedule-table">
|
{weekendsLoading && recentWeekends.length === 0 ? (
|
||||||
<thead>
|
<div className="loading-state">loading weekends…</div>
|
||||||
<tr>
|
) : recentWeekends.length === 0 ? (
|
||||||
<th>Session</th>
|
<div className="cc-side-empty">No additional local weekends.</div>
|
||||||
<th>Start</th>
|
) : (
|
||||||
<th>Coverage</th>
|
<div className="cc-recent-strip" role="list">
|
||||||
<th className="r">Open</th>
|
{recentWeekends.map((weekend) => {
|
||||||
</tr>
|
const finished = meetingHasStarted(weekend.meeting, nowDate)
|
||||||
</thead>
|
const target = pickAnalysisSession(weekend)
|
||||||
<tbody>
|
const target_key = target?.session.session_key ?? weekend.default_session_key
|
||||||
{focusWeekend.sessions.map(({ session, source, datasets }) => {
|
return (
|
||||||
const isCurrent = currentSession?.session_key === session.session_key
|
<Link
|
||||||
const isNext = nextSession?.session_key === session.session_key
|
key={weekend.meeting_key}
|
||||||
return (
|
to="/race-hub"
|
||||||
<tr
|
search={target_key ? { session_key: target_key } : {}}
|
||||||
key={session.session_key}
|
className="cc-recent-card"
|
||||||
className={isCurrent ? 'cc-row-live' : isNext ? 'cc-row-next' : ''}
|
data-testid={`cc-weekend-${weekend.meeting_key}`}
|
||||||
>
|
role="listitem"
|
||||||
<td>
|
>
|
||||||
<span className="cc-session-type">
|
<div className="cc-recent-head mono">
|
||||||
{sessionTypeAbbrev(session.session_type, session.session_name)}
|
<span className="cc-recent-decal">{countryDecal(weekend.meeting)}</span>
|
||||||
</span>
|
<span className={`cc-cov-dot cc-cov-${weekend.source}`} aria-hidden="true" />
|
||||||
<span style={{ fontWeight: 600 }}>{session.session_name}</span>
|
</div>
|
||||||
{session.session_key === focusWeekend.default_session_key && (
|
<div className="cc-recent-name">{weekend.meeting.meeting_name}</div>
|
||||||
<span className="nav-sub">default</span>
|
<div className="cc-recent-meta mono">
|
||||||
)}
|
{formatGpDateRange(weekend.meeting)}
|
||||||
</td>
|
{' · '}
|
||||||
<td className="mono" style={{ color: 'var(--text-2)' }}>
|
{finished ? 'Past' : 'Upcoming'}
|
||||||
{formatSessionScheduleTime(session.date_start)}
|
</div>
|
||||||
</td>
|
</Link>
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WeekendKindLabel({ kind }: { kind: WeekendStatusKind }) {
|
||||||
|
switch (kind) {
|
||||||
|
case 'live':
|
||||||
|
return <span className="cc-kind cc-kind-live">● Live now</span>
|
||||||
|
case 'current':
|
||||||
|
return <span className="cc-kind cc-kind-current">Current weekend</span>
|
||||||
|
case 'next':
|
||||||
|
return <span className="cc-kind cc-kind-next">Next weekend</span>
|
||||||
|
case 'recent':
|
||||||
|
return <span className="cc-kind cc-kind-recent">Recent weekend</span>
|
||||||
|
default:
|
||||||
|
return <span className="cc-kind">Weekend</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountdownBlockProps {
|
||||||
|
liveActive: boolean
|
||||||
|
currentSession: Session | null
|
||||||
|
nextSession: Session | null
|
||||||
|
meeting: Meeting
|
||||||
|
now: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
function CountdownBlock({ liveActive, currentSession, nextSession, meeting, now }: CountdownBlockProps) {
|
||||||
|
if (liveActive) {
|
||||||
|
return (
|
||||||
|
<div className="cc-countdown-block">
|
||||||
|
<div className="cc-cd-label mono">SignalR</div>
|
||||||
|
<div className="cc-cd-value cc-cd-live">LIVE</div>
|
||||||
|
<div className="cc-cd-sub mono">{currentSession?.session_name ?? 'Feed connected'}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (currentSession) {
|
||||||
|
return (
|
||||||
|
<div className="cc-countdown-block">
|
||||||
|
<div className="cc-cd-label mono">On Track</div>
|
||||||
|
<div className="cc-cd-value cc-cd-current">{currentSession.session_name}</div>
|
||||||
|
<div className="cc-cd-sub mono">In session</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (nextSession && sessionStartTime(nextSession)) {
|
||||||
|
return (
|
||||||
|
<div className="cc-countdown-block">
|
||||||
|
<div className="cc-cd-label mono">Next · {nextSession.session_name}</div>
|
||||||
|
<div className="cc-cd-value mono">{formatCountdown(sessionStartTime(nextSession)!, now)}</div>
|
||||||
|
<div className="cc-cd-sub mono">{formatSessionScheduleTime(nextSession.date_start)}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (meetingHasStarted(meeting, now)) {
|
||||||
|
return (
|
||||||
|
<div className="cc-countdown-block">
|
||||||
|
<div className="cc-cd-label mono">Status</div>
|
||||||
|
<div className="cc-cd-value cc-cd-done">Complete</div>
|
||||||
|
<div className="cc-cd-sub mono">Weekend finished</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ export function DataLibraryPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="page" data-testid="data-library-empty">
|
<div className="page" data-testid="data-library-empty">
|
||||||
<div className="dl-page-header">
|
<div className="dl-page-header">
|
||||||
<h1 className="dl-page-title">Data Library</h1>
|
<span className="dl-page-eyebrow mono">box-box · admin</span>
|
||||||
|
<h1 className="dl-page-title">Data Health</h1>
|
||||||
|
<span className="dl-page-sub mono">Local SQLite domain store · ingestion guidance</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-title">No ingested seasons yet</div>
|
<div className="empty-state-title">No ingested seasons yet</div>
|
||||||
@@ -120,6 +122,26 @@ export function DataLibraryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dl-page" data-testid="data-library">
|
<div className="dl-page" data-testid="data-library">
|
||||||
|
<div className="dl-page-banner">
|
||||||
|
<div className="dl-banner-titles">
|
||||||
|
<span className="dl-page-eyebrow mono">box-box · admin</span>
|
||||||
|
<h1 className="dl-page-title">Data Health</h1>
|
||||||
|
</div>
|
||||||
|
<div className="dl-banner-stats mono">
|
||||||
|
<span>
|
||||||
|
<em>{seasons.length}</em> season{seasons.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<em className="dl-stat-full">{stats.full}</em> full
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<em className="dl-stat-partial">{stats.partial}</em> partial
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<em>{stats.missing}</em> missing
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="dl-layout">
|
<div className="dl-layout">
|
||||||
<aside className="dl-nav">
|
<aside className="dl-nav">
|
||||||
<div>
|
<div>
|
||||||
@@ -298,6 +320,9 @@ export function DataLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dl-footer-link">
|
<div className="dl-footer-link">
|
||||||
|
<Link to="/" className="dl-footer-back">
|
||||||
|
← Command Center
|
||||||
|
</Link>
|
||||||
<Link to="/race-hub" search={{}}>
|
<Link to="/race-hub" search={{}}>
|
||||||
Open Race Hub →
|
Open Race Hub →
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export const raceHubRoute = createRoute({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const adminRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/admin',
|
||||||
|
component: DataLibraryPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Legacy alias — kept so any saved /data-library links keep working.
|
||||||
export const dataLibraryRoute = createRoute({
|
export const dataLibraryRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/data-library',
|
path: '/data-library',
|
||||||
@@ -49,7 +56,13 @@ export const liveTimingRoute = createRoute({
|
|||||||
component: LiveTimingPage,
|
component: LiveTimingPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([commandCenterRoute, raceHubRoute, dataLibraryRoute, liveTimingRoute])
|
const routeTree = rootRoute.addChildren([
|
||||||
|
commandCenterRoute,
|
||||||
|
raceHubRoute,
|
||||||
|
adminRoute,
|
||||||
|
dataLibraryRoute,
|
||||||
|
liveTimingRoute,
|
||||||
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,36 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
.nav-links a:hover { color: var(--text); background: var(--surface-h); }
|
||||||
.nav-links a.active { color: var(--text); background: var(--surface-2); }
|
.nav-links a.active { color: var(--text); background: var(--surface-2); }
|
||||||
|
|
||||||
|
.nav-utility {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-utility-link {
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: color 0.1s, border-color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
.nav-utility-link:hover {
|
||||||
|
color: var(--text-2);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
background: var(--surface-h);
|
||||||
|
}
|
||||||
|
.nav-utility-link.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Page ── */
|
/* ── Page ── */
|
||||||
.page {
|
.page {
|
||||||
max-width: 1040px;
|
max-width: 1040px;
|
||||||
@@ -713,6 +743,17 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.dl-page-header {
|
.dl-page-header {
|
||||||
padding: var(--s5) var(--s6);
|
padding: var(--s5) var(--s6);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-page-eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dl-page-title {
|
.dl-page-title {
|
||||||
@@ -720,6 +761,57 @@ a { color: inherit; text-decoration: none; }
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dl-page-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-page-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s5);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--s4) var(--s6);
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-banner-titles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-banner-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s5);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.dl-banner-stats em {
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.dl-banner-stats em.dl-stat-full { color: var(--green); }
|
||||||
|
.dl-banner-stats em.dl-stat-partial { color: var(--yellow); }
|
||||||
|
|
||||||
|
.dl-footer-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.dl-footer-back { color: var(--text-3); }
|
||||||
|
.dl-footer-back:hover { color: var(--text); }
|
||||||
|
|
||||||
.dl-layout {
|
.dl-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px 1fr;
|
grid-template-columns: 220px 1fr;
|
||||||
@@ -1062,38 +1154,43 @@ a { color: inherit; text-decoration: none; }
|
|||||||
|
|
||||||
/* ── Command Center ── */
|
/* ── Command Center ── */
|
||||||
.cc-page {
|
.cc-page {
|
||||||
max-width: 1120px;
|
max-width: 1160px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: var(--s5) var(--s6);
|
padding: var(--s5) var(--s6);
|
||||||
min-height: calc(100vh - var(--nav-h));
|
min-height: calc(100vh - var(--nav-h));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
--gp-accent: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-header {
|
.cc-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--s5);
|
gap: var(--s5);
|
||||||
margin-bottom: var(--s5);
|
flex-wrap: wrap;
|
||||||
padding-bottom: var(--s5);
|
padding-bottom: var(--s4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-title {
|
.cc-topbar-label {
|
||||||
font-size: 16px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 2px;
|
letter-spacing: 0.14em;
|
||||||
}
|
text-transform: uppercase;
|
||||||
|
|
||||||
.cc-subtitle {
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--f-mono);
|
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cc-topbar-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
.cc-live-pill {
|
.cc-live-pill {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--s3);
|
gap: var(--s3);
|
||||||
|
margin-left: auto;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: var(--f-mono);
|
font-family: var(--f-mono);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
@@ -1118,143 +1215,332 @@ a { color: inherit; text-decoration: none; }
|
|||||||
border: 1px solid rgba(225, 6, 0, 0.35);
|
border: 1px solid rgba(225, 6, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-summary {
|
/* GP identity band */
|
||||||
display: grid;
|
.cc-weekend-band {
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
display: flex;
|
||||||
gap: 1px;
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
margin-bottom: var(--s5);
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 124px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-stat {
|
.cc-band-accent {
|
||||||
padding: var(--s4);
|
width: 6px;
|
||||||
background: var(--surface);
|
background: var(--gp-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-band-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--s5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-band-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--s6);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-band-decal {
|
||||||
|
font-size: 56px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
line-height: 0.9;
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 0.9;
|
||||||
|
align-self: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-band-titles {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-stat-label {
|
.cc-band-eyebrow {
|
||||||
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;
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-status-value { font-weight: 600; color: var(--text); }
|
.cc-kind {
|
||||||
|
|
||||||
.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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.cc-kind-live { color: var(--red); }
|
||||||
|
.cc-kind-current { color: var(--text); }
|
||||||
|
.cc-kind-next { color: var(--text-2); }
|
||||||
|
.cc-kind-recent { color: var(--text-3); }
|
||||||
|
|
||||||
|
.cc-band-name {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-band-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-band-dates {
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-countdown-block {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding-left: var(--s6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 28px;
|
align-items: flex-end;
|
||||||
padding: 1px 4px;
|
gap: 4px;
|
||||||
margin-right: var(--s3);
|
text-align: right;
|
||||||
font-family: var(--f-mono);
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-cd-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-cd-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.cc-cd-live { color: var(--red); }
|
||||||
|
.cc-cd-current { color: var(--text); }
|
||||||
|
.cc-cd-done { color: var(--text-2); }
|
||||||
|
|
||||||
|
.cc-cd-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary actions */
|
||||||
|
.cc-actions-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pri-action {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--s4) var(--s5);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid var(--gp-accent);
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.cc-pri-action:hover {
|
||||||
|
background: var(--surface-h);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
border-left-color: var(--gp-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pri-action.is-live {
|
||||||
|
border-left-color: var(--red);
|
||||||
|
background: rgba(225, 6, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pri-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pri-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schedule strip */
|
||||||
|
.cc-schedule { display: flex; flex-direction: column; gap: var(--s3); }
|
||||||
|
|
||||||
|
.cc-session-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-session-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
.cc-session-card:hover {
|
||||||
|
background: var(--surface-h);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
.cc-session-card.is-current {
|
||||||
|
border-color: rgba(225, 6, 0, 0.4);
|
||||||
|
background: rgba(225, 6, 0, 0.06);
|
||||||
|
}
|
||||||
|
.cc-session-card.is-next {
|
||||||
|
border-color: rgba(255, 214, 0, 0.35);
|
||||||
|
background: rgba(255, 214, 0, 0.04);
|
||||||
|
}
|
||||||
|
.cc-status-done { opacity: 0.78; }
|
||||||
|
|
||||||
|
.cc-session-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-session-abbrev {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-2);
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-row-live td { background: rgba(225, 6, 0, 0.06); }
|
.cc-session-status {
|
||||||
.cc-row-next td { background: rgba(255, 214, 0, 0.04); }
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cc-status-pill-live { color: var(--red); }
|
||||||
|
.cc-status-pill-upcoming { color: var(--yellow); }
|
||||||
|
.cc-status-pill-done { color: var(--text-3); }
|
||||||
|
|
||||||
.cc-actions {
|
.cc-session-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-session-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-session-cov {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-cov-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cc-cov-local { background: var(--green); }
|
||||||
|
.cc-cov-partial { background: var(--yellow); }
|
||||||
|
.cc-cov-none { background: var(--text-3); opacity: 0.5; }
|
||||||
|
|
||||||
|
/* Recent weekends strip */
|
||||||
|
.cc-recent { display: flex; flex-direction: column; gap: var(--s3); }
|
||||||
|
|
||||||
|
.cc-recent-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-recent-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 4px;
|
||||||
|
padding: var(--s3) var(--s4);
|
||||||
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.cc-recent-card:hover {
|
||||||
|
background: var(--surface-h);
|
||||||
|
border-color: var(--border-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-recent-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-recent-decal {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-recent-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-recent-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-3);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-side-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
padding: var(--s4) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-action {
|
.cc-action {
|
||||||
@@ -1262,62 +1548,77 @@ a { color: inherit; text-decoration: none; }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: var(--s4);
|
padding: var(--s4);
|
||||||
background: var(--surface-h);
|
background: var(--surface);
|
||||||
transition: background 0.1s;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
}
|
}
|
||||||
.cc-action:hover { background: var(--surface-2); }
|
.cc-action:hover { background: var(--surface-h); border-color: var(--border-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-action-live { border-left: 3px solid var(--red); }
|
||||||
|
|
||||||
.cc-action-label {
|
/* Empty command center */
|
||||||
font-size: 13px;
|
.cc-empty {
|
||||||
font-weight: 600;
|
max-width: 720px;
|
||||||
color: var(--text);
|
gap: var(--s5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-action-meta {
|
.cc-empty-band {
|
||||||
font-size: 11px;
|
padding: var(--s6) var(--s5);
|
||||||
font-family: var(--f-mono);
|
background: var(--surface);
|
||||||
color: var(--text-3);
|
border: 1px solid var(--border);
|
||||||
}
|
border-left: 3px solid var(--red);
|
||||||
|
border-radius: 4px;
|
||||||
.cc-session-list {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: var(--s2);
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-session-row {
|
.cc-empty-eyebrow {
|
||||||
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;
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-side-empty,
|
.cc-empty-title {
|
||||||
.cc-cli-section {
|
font-size: 22px;
|
||||||
font-size: 12px;
|
font-weight: 700;
|
||||||
color: var(--text-3);
|
}
|
||||||
|
|
||||||
|
.cc-empty-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-2);
|
||||||
|
max-width: 56ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-empty-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--s3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono { font-family: var(--f-mono); }
|
.mono { font-family: var(--f-mono); }
|
||||||
|
|
||||||
@media (min-width: 1101px) {
|
/* Tablet */
|
||||||
.cc-grid { grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr); }
|
@media (max-width: 900px) {
|
||||||
|
.cc-band-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s4);
|
||||||
|
}
|
||||||
|
.cc-band-decal { font-size: 44px; align-self: flex-start; }
|
||||||
|
.cc-countdown-block {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: var(--s4);
|
||||||
|
align-items: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.cc-actions-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobile ── */
|
/* ── Mobile ── */
|
||||||
@@ -1370,7 +1671,15 @@ a { color: inherit; text-decoration: none; }
|
|||||||
.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-page { padding: var(--s4); gap: var(--s4); }
|
||||||
.cc-header { flex-direction: column; align-items: flex-start; }
|
.cc-topbar { gap: var(--s3); }
|
||||||
.cc-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.cc-live-pill { margin-left: 0; }
|
||||||
|
.cc-band-body { padding: var(--s4); }
|
||||||
|
.cc-band-name { font-size: 18px; }
|
||||||
|
.cc-band-decal { font-size: 36px; }
|
||||||
|
.cc-cd-value { font-size: 20px; }
|
||||||
|
.cc-countdown-block { min-width: 0; }
|
||||||
|
.cc-actions-row { grid-template-columns: 1fr; }
|
||||||
|
.cc-empty-actions { grid-template-columns: 1fr; }
|
||||||
|
.cc-empty-title { font-size: 19px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,10 +107,10 @@ describe('CommandCenterPage', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('command-center-empty')).toBeInTheDocument()
|
expect(screen.getByTestId('command-center-empty')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
expect(screen.getByText('No ingested seasons yet')).toBeInTheDocument()
|
expect(screen.getByText('No local data yet')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows coverage summary and focus weekend when data exists', async () => {
|
it('shows weekend identity band and schedule when data exists', async () => {
|
||||||
mockFetchSeasons.mockResolvedValue([2025])
|
mockFetchSeasons.mockResolvedValue([2025])
|
||||||
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
mockFetchLocalMeetings.mockResolvedValue([meeting])
|
||||||
mockFetchWeekend.mockResolvedValue(weekend)
|
mockFetchWeekend.mockResolvedValue(weekend)
|
||||||
@@ -121,11 +121,12 @@ describe('CommandCenterPage', () => {
|
|||||||
expect(screen.getByTestId('command-center')).toBeInTheDocument()
|
expect(screen.getByTestId('command-center')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByText('Command Center')).toBeInTheDocument()
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
|
expect(screen.getByTestId('cc-session-9472')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
expect(screen.getByTestId('cc-focus')).toHaveTextContent('Monaco')
|
||||||
|
expect(screen.getByTestId('cc-focus')).toHaveTextContent('MON')
|
||||||
expect(screen.getByText('No live session')).toBeInTheDocument()
|
expect(screen.getByText('No live session')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('cc-action-race-hub')).toHaveTextContent('Race')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { test, expect } from '@playwright/test'
|
|||||||
const FULL_SESSION = 9472
|
const FULL_SESSION = 9472
|
||||||
|
|
||||||
test.describe('Command Center', () => {
|
test.describe('Command Center', () => {
|
||||||
test('loads as default route with local coverage summary', async ({ page }) => {
|
test('loads as default route with weekend identity band', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
|
||||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
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-focus')).toBeVisible()
|
||||||
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
await expect(page.getByTestId('cc-session-9472')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('cc-actions')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('nav link reaches command center from race hub', async ({ page }) => {
|
test('nav link reaches command center from race hub', async ({ page }) => {
|
||||||
@@ -19,7 +19,7 @@ test.describe('Command Center', () => {
|
|||||||
await expect(page.getByTestId('command-center')).toBeVisible()
|
await expect(page.getByTestId('command-center')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('quick action opens race hub for default session', async ({ page }) => {
|
test('open analysis action opens race hub for the focus session', async ({ page }) => {
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION))
|
await expect(page.getByTestId('cc-action-race-hub')).toContainText(String(FULL_SESSION))
|
||||||
await page.getByTestId('cc-action-race-hub').click()
|
await page.getByTestId('cc-action-race-hub').click()
|
||||||
@@ -31,7 +31,7 @@ test.describe('Command Center', () => {
|
|||||||
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
|
||||||
await expect(page.getByText('Final Classification')).toBeVisible()
|
await expect(page.getByText('Final Classification')).toBeVisible()
|
||||||
|
|
||||||
await page.goto('/data-library')
|
await page.goto('/admin')
|
||||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||||
|
|
||||||
await page.goto('/live')
|
await page.goto('/live')
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { test, expect } from '@playwright/test'
|
|||||||
|
|
||||||
const FULL_SESSION = 9472
|
const FULL_SESSION = 9472
|
||||||
|
|
||||||
test.describe('Data Library', () => {
|
test.describe('Admin · Data Health', () => {
|
||||||
test('shows local coverage and ingest commands', async ({ page }) => {
|
test('shows local coverage and ingest commands at /admin', async ({ page }) => {
|
||||||
await page.goto('/data-library')
|
await page.goto('/admin')
|
||||||
|
|
||||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Data Health' })).toBeVisible()
|
||||||
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
|
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
|
||||||
await expect(page.getByTestId('meeting-detail')).toBeVisible()
|
await expect(page.getByTestId('meeting-detail')).toBeVisible()
|
||||||
await expect(page.getByTestId('cli-commands').first()).toBeVisible()
|
await expect(page.getByTestId('cli-commands').first()).toBeVisible()
|
||||||
@@ -14,15 +15,22 @@ test.describe('Data Library', () => {
|
|||||||
await expect(page.getByText('box-box --ingest-session 9472', { exact: true })).toBeVisible()
|
await expect(page.getByText('box-box --ingest-session 9472', { exact: true })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('nav link reaches data library from race hub', async ({ page }) => {
|
test('legacy /data-library route still renders the same page', async ({ page }) => {
|
||||||
|
await page.goto('/data-library')
|
||||||
|
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Data Health' })).toBeVisible()
|
||||||
|
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('admin nav link reaches data health from race hub', async ({ page }) => {
|
||||||
await page.goto('/race-hub')
|
await page.goto('/race-hub')
|
||||||
await page.getByRole('link', { name: 'Data Library' }).click()
|
await page.getByRole('link', { name: 'Admin', exact: true }).click()
|
||||||
await expect(page).toHaveURL(/\/data-library/)
|
await expect(page).toHaveURL(/\/admin/)
|
||||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('race hub link from data library footer works', async ({ page }) => {
|
test('race hub link from data health footer works', async ({ page }) => {
|
||||||
await page.goto('/data-library')
|
await page.goto('/admin')
|
||||||
await page.getByRole('link', { name: /Open Race Hub/i }).click()
|
await page.getByRole('link', { name: /Open Race Hub/i }).click()
|
||||||
await expect(page).toHaveURL(/\/race-hub/)
|
await expect(page).toHaveURL(/\/race-hub/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ test.describe('Production serving (Go + built React)', () => {
|
|||||||
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('serves data library route', async ({ page }) => {
|
test('serves admin / data health route', async ({ page }) => {
|
||||||
|
await page.goto('/admin')
|
||||||
|
|
||||||
|
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Data Health' })).toBeVisible()
|
||||||
|
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy /data-library route still works', async ({ page }) => {
|
||||||
await page.goto('/data-library')
|
await page.goto('/data-library')
|
||||||
|
|
||||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||||
@@ -36,10 +44,10 @@ test.describe('Production serving (Go + built React)', () => {
|
|||||||
await page.locator('.app-nav').getByRole('link', { name: 'Race Hub' }).click()
|
await page.locator('.app-nav').getByRole('link', { name: 'Race Hub' }).click()
|
||||||
await expect(page).toHaveURL(/\/race-hub/)
|
await expect(page).toHaveURL(/\/race-hub/)
|
||||||
|
|
||||||
await page.locator('.app-nav').getByRole('link', { name: 'Data Library' }).click()
|
await page.locator('.app-nav').getByRole('link', { name: 'Admin', exact: true }).click()
|
||||||
await expect(page).toHaveURL(/\/data-library/)
|
await expect(page).toHaveURL(/\/admin/)
|
||||||
|
|
||||||
await page.getByRole('link', { name: 'Live' }).click()
|
await page.getByRole('link', { name: 'Live', exact: true }).click()
|
||||||
await expect(page).toHaveURL(/\/live/)
|
await expect(page).toHaveURL(/\/live/)
|
||||||
await expect(page.getByTestId('live-empty')).toBeVisible()
|
await expect(page.getByTestId('live-empty')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 102 KiB |
@@ -30,7 +30,7 @@ export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): P
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function gotoDataLibraryReady(page: Page): Promise<void> {
|
export async function gotoDataLibraryReady(page: Page): Promise<void> {
|
||||||
await page.goto('/data-library')
|
await page.goto('/admin')
|
||||||
await expect(page.getByTestId('data-library')).toBeVisible()
|
await expect(page.getByTestId('data-library')).toBeVisible()
|
||||||
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
|
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
|
||||||
await expect(page.getByTestId('meeting-detail')).toBeVisible()
|
await expect(page.getByTestId('meeting-detail')).toBeVisible()
|
||||||
|
|||||||