Add data library UI

This commit is contained in:
2026-05-25 02:45:02 -04:00
parent f2339a00a9
commit 0992fc03e8
16 changed files with 1214 additions and 122 deletions

View File

@@ -75,8 +75,6 @@ not implementation tickets yet.
slice for making one command ingest a whole race weekend into the local DB.
- [20 Phase 12 Data Library UI](20-phase-12-data-library-ui.md): frontend slice
for showing local ingestion coverage and next CLI actions.
- [Cursor Phase 12 Prompt](cursor-phase-12-data-library-ui-prompt.md): current
handoff prompt for the next frontend phase.
## External References

View File

@@ -1,71 +0,0 @@
# Prompt For Cursor: Phase 12 Data Library UI
You are working in the `box-box` repository on Phase 12. Build a practical Web
UI surface for inspecting local data coverage and ingestion status.
## Read First
Open these files first:
- `documentations/refactor/20-phase-12-data-library-ui.md`
- `frontend/src/components/LocalDataNavigator.tsx`
- `frontend/src/pages/RaceHubPage.tsx`
- `frontend/src/api.ts`
- `frontend/src/types.ts`
- `frontend/src/main.tsx`
- `frontend/src/styles/app.css`
- `tests/race-hub.spec.ts`
Only open older docs if you are blocked.
## Goal
Let a user inspect what is in the local database and understand what is missing
without needing to open every Race Hub session manually.
## APIs Available
- `GET /api/v1/seasons`
- `GET /api/v1/meetings?year=<year>&source=local`
- `GET /api/v1/weekend?meeting_key=<key>`
- `GET /api/v1/race-hub?session_key=<key>`
Do not fetch OpenF1 from React.
## Work To Do
1. Add a Data Library route or reachable section in the React app.
2. Show local years/meetings/sessions and dataset coverage.
3. Make partial vs complete sessions visually clear.
4. Include copyable/reference CLI commands, such as:
- `box-box --ingest-year 2025`
- `box-box --ingest-meeting <meeting_key>`
- `box-box --ingest-session <session_key>`
5. Preserve direct Race Hub navigation.
6. Add focused frontend tests and update e2e if stable.
## Design Notes
- Keep it operational and table-like.
- Avoid decorative cards and generic dashboard clutter.
- Use the existing dataset/status visual language where possible.
- Mobile should remain usable.
## Verification
Run:
```bash
cd frontend && npm test -- --run
cd frontend && npm run build
npm run test:e2e
```
## Report Back
Summarize:
- files changed;
- Data Library behavior added;
- tests run and results;
- follow-up API or design gaps.

View File

@@ -0,0 +1,65 @@
import { useState } from 'react'
interface Command {
comment?: string
cmd: string
}
interface Props {
commands: Command[]
}
export function CliCommands({ commands }: Props) {
return (
<div className="cli-block" data-testid="cli-commands">
{commands.map(({ comment, cmd }, i) => (
<div key={cmd} className="cli-entry">
{comment && <div className="cli-comment">{comment}</div>}
<CliCommandLine cmd={cmd} />
{i < commands.length - 1 && <div className="cli-spacer" />}
</div>
))}
</div>
)
}
function CliCommandLine({ cmd }: { cmd: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
await navigator.clipboard.writeText(cmd)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} catch {
// clipboard may be unavailable in tests
}
}
return (
<div className="cli-cmd-row">
<code className="cli-cmd">{cmd}</code>
<button type="button" className="cli-copy-btn" onClick={handleCopy} aria-label={`Copy ${cmd}`}>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
)
}
export function ingestYearCommands(year: number): Command[] {
return [
{ comment: '# Ingest all meetings for a season', cmd: `box-box --ingest-year ${year}` },
{ comment: '# Preview without downloading', cmd: `box-box --ingest-year ${year} --dry-run` },
]
}
export function ingestMeetingCommands(meetingKey: number): Command[] {
return [
{ comment: '# Full weekend ingest (all sessions)', cmd: `box-box --ingest-meeting ${meetingKey}` },
{ comment: '# Preview without downloading', cmd: `box-box --ingest-meeting ${meetingKey} --dry-run` },
]
}
export function ingestSessionCommands(sessionKey: number): Command[] {
return [{ comment: '# Race Hub datasets for one session', cmd: `box-box --ingest-session ${sessionKey}` }]
}

View File

@@ -2,43 +2,10 @@ import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import type { DatasetInfo, Meeting, WeekendSession } from '../types'
const RACE_HUB_DATASETS = [
'meeting',
'session',
'drivers',
'results',
'starting_grid',
'stints',
'pit_stops',
'positions',
'race_control',
'weather',
'laps',
] as const
export function countRaceHubDatasets(datasets: Record<string, DatasetInfo>): { available: number; total: number } {
const total = RACE_HUB_DATASETS.length
const available = RACE_HUB_DATASETS.filter((key) => datasets[key]?.status === 'available').length
return { available, total }
}
export function formatCoverageHint(datasets: Record<string, DatasetInfo>): string {
const { available, total } = countRaceHubDatasets(datasets)
return `${available}/${total}`
}
function sourceBadge(source: WeekendSession['source']) {
switch (source) {
case 'local':
return <span className="badge badge-local">Local</span>
case 'partial':
return <span className="badge badge-partial">Partial</span>
default:
return <span className="badge badge-none">None</span>
}
}
import { formatCoverageHint } from '../lib/coverage'
import { SourceBadge } from './SourceBadge'
import { SessionCoverageDots } from './SessionCoverageDots'
import type { Meeting, WeekendSession } from '../types'
function formatMeetingDates(meeting: Meeting): string {
const start = meeting.date_start?.slice(0, 10)
@@ -47,6 +14,10 @@ function formatMeetingDates(meeting: Meeting): string {
return start || end || '—'
}
function sessionSourceBadge(source: WeekendSession['source']) {
return <SourceBadge source={source} />
}
interface Props {
onSelectSession?: (sessionKey: number) => void
}
@@ -269,7 +240,7 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
</span>
<SessionCoverageDots datasets={datasets} />
</td>
<td>{sourceBadge(source)}</td>
<td>{sessionSourceBadge(source)}</td>
<td className="r">
<button
type="button"
@@ -293,13 +264,5 @@ export function LocalDataNavigator({ onSelectSession }: Props) {
)
}
function SessionCoverageDots({ datasets }: { datasets: Record<string, DatasetInfo> }) {
return (
<span className="coverage-dots" aria-hidden="true">
{RACE_HUB_DATASETS.map((key) => {
const available = datasets[key]?.status === 'available'
return <span key={key} className={`coverage-dot ${available ? 'on' : 'off'}`} />
})}
</span>
)
}
// Re-export helpers used by tests
export { countRaceHubDatasets, formatCoverageHint } from '../lib/coverage'

View File

@@ -0,0 +1,106 @@
import { RACE_HUB_DATASETS, formatCoverageHint } from '../lib/coverage'
import { SourceBadge } from './SourceBadge'
import { SessionCoverageDots } from './SessionCoverageDots'
import {
CliCommands,
ingestMeetingCommands,
ingestSessionCommands,
} from './CliCommands'
import type { Weekend, WeekendSession } from '../types'
interface Props {
weekend: Weekend
}
export function MeetingDetailPanel({ weekend }: Props) {
const { meeting, sessions, source } = weekend
return (
<div className="dl-detail" data-testid="meeting-detail">
<div className="detail-header">
<div className="detail-header-row">
<span className="detail-title">{meeting.meeting_name}</span>
<SourceBadge source={source} label={source === 'local' ? 'Full' : undefined} />
</div>
<div className="detail-meta">
{meeting.country_name} · meeting_key {meeting.meeting_key}
</div>
<div className="detail-meta">
{sessions.length} session{sessions.length === 1 ? '' : 's'} stored locally
</div>
</div>
{sessions.length === 0 ? (
<div className="missing-notice">
No sessions ingested for this meeting. Run{' '}
<code>box-box --ingest-meeting {meeting.meeting_key}</code>
</div>
) : (
sessions.map((entry) => (
<SessionDetailBlock key={entry.session.session_key} entry={entry} />
))
)}
<div className="dl-cli-section">
<div className="sec-header">
<span className="sec-title">Ingest Commands</span>
</div>
<CliCommands commands={ingestMeetingCommands(meeting.meeting_key)} />
</div>
</div>
)
}
function SessionDetailBlock({ entry }: { entry: WeekendSession }) {
const { session, source, datasets } = entry
const coverage = formatCoverageHint(datasets)
return (
<div className="session-detail-row" data-testid={`session-detail-${session.session_key}`}>
<div className="session-detail-head">
<SourceBadge source={source} />
<span>{session.session_name}</span>
<span className="session-detail-key mono">{session.session_key}</span>
<span className="session-detail-coverage mono">{coverage}</span>
<SessionCoverageDots datasets={datasets} />
</div>
<table className="data-table ds-detail-table">
<thead>
<tr>
<th>Dataset</th>
<th>Status</th>
<th className="r">Records</th>
</tr>
</thead>
<tbody>
{RACE_HUB_DATASETS.map((key) => {
const info = datasets[key]
const available = info?.status === 'available'
return (
<tr key={key}>
<td className="mono" style={{ color: 'var(--text-2)' }}>
{key}
</td>
<td>
{available ? (
<span className="badge badge-local">Local</span>
) : (
<span className="badge badge-none">Missing</span>
)}
</td>
<td className="r mono" style={{ color: 'var(--text-3)' }}>
{info?.count != null ? info.count : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="session-cli">
<CliCommands commands={ingestSessionCommands(session.session_key)} />
</div>
</div>
)
}

View File

@@ -10,6 +10,9 @@ export function Nav() {
<Link to="/race-hub" search={{}} activeProps={{ className: 'active' }}>
Race Hub
</Link>
<Link to="/data-library" activeProps={{ className: 'active' }}>
Data Library
</Link>
</div>
</nav>
)

View File

@@ -0,0 +1,17 @@
import { RACE_HUB_DATASETS } from '../lib/coverage'
import type { DatasetInfo } from '../types'
interface Props {
datasets: Record<string, DatasetInfo>
}
export function SessionCoverageDots({ datasets }: Props) {
return (
<span className="coverage-dots" aria-hidden="true">
{RACE_HUB_DATASETS.map((key) => {
const available = datasets[key]?.status === 'available'
return <span key={key} className={`coverage-dot ${available ? 'on' : 'off'}`} />
})}
</span>
)
}

View File

@@ -0,0 +1,28 @@
type Source = 'local' | 'partial' | 'none'
interface Props {
source: Source
label?: string
}
export function SourceBadge({ source, label }: Props) {
switch (source) {
case 'local':
return <span className="badge badge-local">{label ?? 'Local'}</span>
case 'partial':
return <span className="badge badge-partial">{label ?? 'Partial'}</span>
default:
return <span className="badge badge-none">{label ?? 'None'}</span>
}
}
export function weekendStatusLabel(source: Source): string {
switch (source) {
case 'local':
return 'Full'
case 'partial':
return 'Partial'
default:
return 'Missing'
}
}

View File

@@ -0,0 +1,76 @@
import type { DatasetInfo, Weekend, WeekendSession } from '../types'
export const RACE_HUB_DATASETS = [
'meeting',
'session',
'drivers',
'results',
'starting_grid',
'stints',
'pit_stops',
'positions',
'race_control',
'weather',
'laps',
] as const
export type RaceHubDatasetKey = (typeof RACE_HUB_DATASETS)[number]
export function countRaceHubDatasets(datasets: Record<string, DatasetInfo>): { available: number; total: number } {
const total = RACE_HUB_DATASETS.length
const available = RACE_HUB_DATASETS.filter((key) => datasets[key]?.status === 'available').length
return { available, total }
}
export function formatCoverageHint(datasets: Record<string, DatasetInfo>): string {
const { available, total } = countRaceHubDatasets(datasets)
return `${available}/${total}`
}
export function isSessionComplete(datasets: Record<string, DatasetInfo>): boolean {
const { available, total } = countRaceHubDatasets(datasets)
return available === total
}
export function sessionTypeAbbrev(sessionType: string, sessionName: string): string {
const type = sessionType.toLowerCase()
const name = sessionName.toLowerCase()
if (type.includes('race') || name === 'race') return 'R'
if (type.includes('qualifying') || name.startsWith('q')) return 'Q'
if (type.includes('sprint')) return 'S'
if (name.includes('fp1') || name.includes('practice 1')) return 'FP1'
if (name.includes('fp2') || name.includes('practice 2')) return 'FP2'
if (name.includes('fp3') || name.includes('practice 3')) return 'FP3'
return sessionName.slice(0, 3).toUpperCase()
}
export function countWeekendStats(weekends: (Weekend | undefined)[]) {
let full = 0
let partial = 0
let missing = 0
for (const weekend of weekends) {
if (!weekend || weekend.sessions.length === 0) {
missing++
continue
}
switch (weekend.source) {
case 'local':
full++
break
case 'partial':
partial++
break
default:
missing++
}
}
return { full, partial, missing, total: weekends.length }
}
export function sessionIconClass(session: WeekendSession): string {
if (session.source === 'none') return 'si-missing'
if (isSessionComplete(session.datasets)) return 'si-full'
return 'si-partial'
}

View File

@@ -0,0 +1,307 @@
import { useEffect, useMemo, useState } from 'react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { fetchLocalMeetings, fetchSeasons, fetchWeekend } from '../api'
import {
countWeekendStats,
formatCoverageHint,
sessionIconClass,
sessionTypeAbbrev,
} from '../lib/coverage'
import { SourceBadge, weekendStatusLabel } from '../components/SourceBadge'
import { CliCommands, ingestYearCommands } from '../components/CliCommands'
import { MeetingDetailPanel } from '../components/MeetingDetailPanel'
import type { Meeting, Weekend } from '../types'
function formatMeetingDate(meeting: Meeting): string {
const start = meeting.date_start?.slice(0, 10)
if (!start) return '—'
const d = new Date(start)
if (Number.isNaN(d.getTime())) return start
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
}
export function DataLibraryPage() {
const [selectedYear, setSelectedYear] = useState<number | null>(null)
const [selectedMeetingKey, setSelectedMeetingKey] = useState<number | null>(null)
const seasonsQuery = useQuery({
queryKey: ['seasons'],
queryFn: fetchSeasons,
})
const meetingsQuery = useQuery({
queryKey: ['meetings', selectedYear],
queryFn: () => fetchLocalMeetings(selectedYear!),
enabled: selectedYear != 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 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 stats = countWeekendStats(weekendQueries.map((q) => q.data))
useEffect(() => {
if (seasonsQuery.data?.length && selectedYear == null) {
setSelectedYear(seasonsQuery.data[0])
}
}, [seasonsQuery.data, selectedYear])
useEffect(() => {
if (meetings.length === 0) {
setSelectedMeetingKey(null)
return
}
if (selectedMeetingKey == null || !meetings.some((m) => m.meeting_key === selectedMeetingKey)) {
setSelectedMeetingKey(meetings[0].meeting_key)
}
}, [meetings, selectedMeetingKey])
const selectedWeekend = selectedMeetingKey != null ? weekendsByKey.get(selectedMeetingKey) : undefined
const weekendsLoading = weekendQueries.some((q) => q.isLoading)
if (seasonsQuery.isLoading) {
return <div className="page loading-state">loading local data library</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="page" data-testid="data-library-empty">
<div className="dl-page-header">
<h1 className="dl-page-title">Data Library</h1>
</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 to inspect coverage.
</div>
</div>
<div className="dl-cli-section">
<div className="sec-header">
<span className="sec-title">Get Started</span>
</div>
<CliCommands
commands={[
{ comment: '# Ingest a full season', cmd: 'box-box --ingest-year 2025' },
{ comment: '# Or a single session', cmd: 'box-box --ingest-session <session_key>' },
]}
/>
</div>
</div>
)
}
return (
<div className="dl-page" data-testid="data-library">
<div className="dl-layout">
<aside className="dl-nav">
<div>
<div className="sec-header">
<span className="sec-title">Seasons</span>
</div>
<div className="season-list" role="listbox" aria-label="Season">
{seasons.map((year) => (
<button
key={year}
type="button"
role="option"
aria-selected={year === selectedYear}
className={`season-row ${year === selectedYear ? 'active' : ''}`}
onClick={() => {
setSelectedYear(year)
setSelectedMeetingKey(null)
}}
>
<span>{year}</span>
</button>
))}
</div>
</div>
{selectedYear != null && meetings.length > 0 && (
<div className="dl-stats">
<div className="dl-stat">
<span className="dl-stat-label">Full</span>
<span className="dl-stat-val dl-stat-full">{stats.full}</span>
</div>
<div className="dl-stat">
<span className="dl-stat-label">Partial</span>
<span className="dl-stat-val dl-stat-partial">{stats.partial}</span>
</div>
<div className="dl-stat">
<span className="dl-stat-label">Missing</span>
<span className="dl-stat-val">{stats.missing}</span>
</div>
<div className="dl-stat">
<span className="dl-stat-label">Total</span>
<span className="dl-stat-val">{stats.total}</span>
</div>
</div>
)}
{selectedYear != null && (
<div className="dl-cli-section">
<div className="sec-header">
<span className="sec-title">Season Ingest</span>
</div>
<CliCommands commands={ingestYearCommands(selectedYear)} />
</div>
)}
</aside>
<div className="dl-content">
<div className="dl-content-header">
<span className="dl-content-title">{selectedYear} Season</span>
{meetings.length > 0 && (
<span className="dl-content-meta">
{meetings.length} meeting{meetings.length === 1 ? '' : 's'}
{!weekendsLoading && (
<>
{' '}
· {stats.full} complete · {stats.partial} partial
</>
)}
</span>
)}
</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 && meetings.length === 0 && (
<div className="missing-notice">
No meetings ingested for {selectedYear}. Run{' '}
<code>box-box --ingest-year {selectedYear}</code>
</div>
)}
{meetings.length > 0 && (
<div className="dl-content-body">
<div className="dl-round-scroll">
<table className="data-table rounds-table">
<thead>
<tr>
<th className="c" style={{ width: 36 }}>
#
</th>
<th>Weekend</th>
<th className="hide-mobile">Date</th>
<th>Status</th>
<th className="hide-mobile">Sessions</th>
<th className="hide-mobile">Coverage</th>
</tr>
</thead>
<tbody>
{meetings.map((meeting, index) => {
const weekend = weekendsByKey.get(meeting.meeting_key)
const selected = meeting.meeting_key === selectedMeetingKey
const source = weekend?.source ?? 'none'
return (
<tr
key={meeting.meeting_key}
className={selected ? 'dl-row-selected' : ''}
data-testid={`dl-meeting-${meeting.meeting_key}`}
onClick={() => setSelectedMeetingKey(meeting.meeting_key)}
>
<td className="c mono" style={{ color: 'var(--text-3)' }}>
{index + 1}
</td>
<td>
<span style={{ fontWeight: 600 }}>{meeting.meeting_name}</span>
<span className="nav-sub mono">key {meeting.meeting_key}</span>
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-3)' }}>
{formatMeetingDate(meeting)}
</td>
<td>
<SourceBadge source={source} label={weekendStatusLabel(source)} />
</td>
<td className="hide-mobile">
{weekend ? (
<div className="session-icons">
{weekend.sessions.map((entry) => (
<span
key={entry.session.session_key}
className={`session-icon ${sessionIconClass(entry)}`}
title={`${entry.session.session_name} (${formatCoverageHint(entry.datasets)})`}
>
{sessionTypeAbbrev(entry.session.session_type, entry.session.session_name)}
</span>
))}
</div>
) : (
<span className="mono" style={{ color: 'var(--text-3)' }}>
</span>
)}
</td>
<td className="hide-mobile mono" style={{ color: 'var(--text-2)' }}>
{weekend && weekend.sessions.length > 0
? `${weekend.sessions.filter((s) => s.source === 'local').length}/${weekend.sessions.length} full`
: '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="dl-detail-wrap">
{weekendsLoading && selectedWeekend == null && (
<div className="loading-state">loading weekend details</div>
)}
{selectedWeekend && <MeetingDetailPanel weekend={selectedWeekend} />}
{selectedMeetingKey != null && !weekendsLoading && selectedWeekend == null && (
<div className="missing-notice">Could not load weekend details.</div>
)}
</div>
</div>
)}
</div>
</div>
<div className="dl-footer-link">
<Link to="/race-hub" search={{}}>
Open Race Hub
</Link>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { createRootRoute, createRoute, createRouter, Outlet, redirect } from '@tanstack/react-router'
import { Nav } from './components/Nav'
import { RaceHubPage } from './pages/RaceHubPage'
import { DataLibraryPage } from './pages/DataLibraryPage'
type RaceHubSearch = {
session_key?: number
@@ -36,7 +37,13 @@ export const raceHubRoute = createRoute({
},
})
const routeTree = rootRoute.addChildren([indexRoute, raceHubRoute])
export const dataLibraryRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/data-library',
component: DataLibraryPage,
})
const routeTree = rootRoute.addChildren([indexRoute, raceHubRoute, dataLibraryRoute])
export const router = createRouter({ routeTree })

View File

@@ -480,6 +480,306 @@ a { color: inherit; text-decoration: none; }
color: var(--text-3);
}
/* ── Data Library ── */
.dl-page {
max-width: none;
padding: 0;
min-height: calc(100vh - var(--nav-h));
display: flex;
flex-direction: column;
}
.dl-page-header {
padding: var(--s5) var(--s6);
border-bottom: 1px solid var(--border);
}
.dl-page-title {
font-size: 16px;
font-weight: 700;
}
.dl-layout {
display: grid;
grid-template-columns: 220px 1fr;
flex: 1;
min-height: 0;
}
.dl-nav {
border-right: 1px solid var(--border);
padding: var(--s5);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--s5);
}
.season-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.season-row {
display: flex;
align-items: center;
gap: var(--s2);
padding: 7px var(--s3);
border-radius: 2px;
font-size: 13px;
font-weight: 600;
color: var(--text-2);
background: none;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
font-family: var(--f-mono);
}
.season-row:hover { background: var(--surface-h); color: var(--text); }
.season-row.active { background: var(--surface-2); color: var(--text); }
.dl-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
border: 1px solid var(--border);
}
.dl-stat {
padding: var(--s3);
background: var(--surface);
display: flex;
flex-direction: column;
gap: 2px;
}
.dl-stat-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-3);
}
.dl-stat-val {
font-family: var(--f-mono);
font-size: 16px;
font-weight: 700;
color: var(--text-2);
}
.dl-stat-full { color: var(--green); }
.dl-stat-partial { color: var(--yellow); }
.dl-content {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.dl-content-header {
display: flex;
align-items: center;
gap: var(--s4);
padding: var(--s3) var(--s5);
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.dl-content-title {
font-size: 13px;
font-weight: 700;
}
.dl-content-meta {
font-size: 12px;
color: var(--text-3);
font-family: var(--f-mono);
}
.dl-content-body {
display: grid;
grid-template-columns: 1fr 340px;
flex: 1;
overflow: hidden;
min-height: 0;
}
.dl-round-scroll {
overflow-y: auto;
border-right: 1px solid var(--border);
}
.rounds-table tbody tr {
cursor: pointer;
}
.dl-row-selected { background: var(--surface-h); }
.session-icons {
display: flex;
gap: 3px;
align-items: center;
flex-wrap: wrap;
}
.session-icon {
min-width: 14px;
height: 14px;
padding: 0 2px;
border-radius: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 8px;
font-weight: 700;
font-family: var(--f-mono);
}
.session-icon.si-full { background: rgba(57,199,58,0.2); color: var(--green); }
.session-icon.si-partial { background: rgba(255,214,0,0.2); color: var(--yellow); }
.session-icon.si-missing { background: rgba(50,50,50,0.5); color: var(--text-3); }
.dl-detail-wrap {
overflow-y: auto;
}
.dl-detail {
padding: var(--s4);
display: flex;
flex-direction: column;
gap: var(--s4);
}
.detail-header {
display: flex;
flex-direction: column;
gap: var(--s2);
padding-bottom: var(--s4);
border-bottom: 1px solid var(--border);
}
.detail-header-row {
display: flex;
align-items: center;
gap: var(--s3);
flex-wrap: wrap;
}
.detail-title {
font-size: 14px;
font-weight: 700;
}
.detail-meta {
font-size: 11px;
color: var(--text-3);
font-family: var(--f-mono);
}
.session-detail-row {
padding-bottom: var(--s4);
border-bottom: 1px solid var(--border);
}
.session-detail-row:last-of-type { border-bottom: none; }
.session-detail-head {
display: flex;
align-items: center;
gap: var(--s3);
margin-bottom: var(--s3);
font-size: 11px;
font-weight: 700;
color: var(--text-2);
flex-wrap: wrap;
}
.session-detail-key {
font-weight: 400;
color: var(--text-3);
}
.session-detail-coverage {
font-weight: 400;
color: var(--text-2);
margin-left: auto;
}
.ds-detail-table { font-size: 11px; }
.ds-detail-table th { font-size: 9px; }
.session-cli { margin-top: var(--s3); }
.dl-cli-section { margin-top: var(--s3); }
.cli-block {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 2px;
padding: var(--s4);
}
.cli-comment {
font-family: var(--f-mono);
font-size: 10px;
color: var(--text-3);
margin-bottom: var(--s2);
}
.cli-cmd-row {
display: flex;
align-items: center;
gap: var(--s3);
}
.cli-cmd {
flex: 1;
font-family: var(--f-mono);
font-size: 11px;
color: var(--text);
word-break: break-all;
}
.cli-copy-btn {
flex-shrink: 0;
padding: 2px 8px;
background: none;
border: 1px solid var(--border-2);
border-radius: 2px;
font-size: 10px;
font-weight: 600;
color: var(--text-3);
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
}
.cli-copy-btn:hover {
border-color: var(--green);
color: var(--green);
}
.cli-spacer { height: var(--s4); }
.dl-footer-link {
padding: var(--s4) var(--s6);
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-3);
}
.dl-footer-link a:hover { color: var(--text); }
.dl-page .empty-state,
.dl-page .missing-notice,
.dl-page .loading-state,
.dl-page .error-box {
margin: var(--s5) var(--s6);
}
.dl-page .dl-cli-section {
margin: 0 var(--s6) var(--s6);
}
/* ── Tab bar ── */
.tab-bar {
display: flex;
@@ -553,4 +853,11 @@ a { color: inherit; text-decoration: none; }
.data-table td { padding: var(--s2); }
.drv-code { min-width: 24px; }
.drv-num { min-width: 14px; }
.dl-layout { grid-template-columns: 1fr; }
.dl-nav { border-right: none; border-bottom: 1px solid var(--border); }
.season-list { flex-direction: row; flex-wrap: wrap; gap: var(--s2); }
.dl-content-body { grid-template-columns: 1fr; }
.dl-round-scroll { border-right: none; max-height: 40vh; }
.dl-detail-wrap { border-top: 1px solid var(--border); }
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CliCommands, ingestMeetingCommands, ingestSessionCommands } from '../components/CliCommands'
describe('CliCommands', () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
})
})
it('renders commands with comments', () => {
render(
<CliCommands
commands={[{ comment: '# test', cmd: 'box-box --ingest-year 2025' }]}
/>,
)
expect(screen.getByText('# test')).toBeInTheDocument()
expect(screen.getByText('box-box --ingest-year 2025')).toBeInTheDocument()
})
it('copies command on button click', async () => {
render(<CliCommands commands={[{ cmd: 'box-box --ingest-session 9472' }]} />)
fireEvent.click(screen.getByRole('button', { name: /Copy/i }))
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('box-box --ingest-session 9472')
expect(await screen.findByText('Copied')).toBeInTheDocument()
})
it('builds meeting ingest commands', () => {
const cmds = ingestMeetingCommands(1229)
expect(cmds[0].cmd).toBe('box-box --ingest-meeting 1229')
expect(cmds[1].cmd).toBe('box-box --ingest-meeting 1229 --dry-run')
})
it('builds session ingest commands', () => {
const cmds = ingestSessionCommands(9472)
expect(cmds[0].cmd).toBe('box-box --ingest-session 9472')
})
})

View File

@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter, createRootRoute, createRoute } from '@tanstack/react-router'
import { DataLibraryPage } from '../pages/DataLibraryPage'
import type { DatasetInfo, Meeting, Weekend } from '../types'
vi.mock('../api', () => ({
fetchSeasons: vi.fn(),
fetchLocalMeetings: vi.fn(),
fetchWeekend: vi.fn(),
}))
import { fetchSeasons, fetchLocalMeetings, fetchWeekend } from '../api'
const mockFetchSeasons = vi.mocked(fetchSeasons)
const mockFetchLocalMeetings = vi.mocked(fetchLocalMeetings)
const mockFetchWeekend = vi.mocked(fetchWeekend)
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}>
<DataLibraryPage />
</QueryClientProvider>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => null,
})
const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]) })
return render(<RouterProvider router={router} />)
}
describe('DataLibraryPage', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
})
})
it('shows empty state when no seasons exist', async () => {
mockFetchSeasons.mockResolvedValue([])
renderPage()
expect(await screen.findByTestId('data-library-empty')).toBeInTheDocument()
expect(screen.getByText(/No ingested seasons yet/i)).toBeInTheDocument()
expect(screen.getByText('box-box --ingest-year 2025')).toBeInTheDocument()
})
it('shows meetings and detail panel with CLI commands', async () => {
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(weekend)
renderPage()
expect(await screen.findByTestId('data-library')).toBeInTheDocument()
await waitFor(() => {
expect(mockFetchLocalMeetings).toHaveBeenCalledWith(2025)
})
expect(await screen.findByText('Monaco')).toBeInTheDocument()
expect(await screen.findByTestId('meeting-detail')).toBeInTheDocument()
expect(screen.getByText('11/11')).toBeInTheDocument()
expect(screen.getByText('box-box --ingest-meeting 1229')).toBeInTheDocument()
expect(screen.getByText('box-box --ingest-session 9472')).toBeInTheDocument()
})
it('shows partial badge for partial weekends', async () => {
const partialWeekend: Weekend = {
...weekend,
source: 'partial',
sessions: [{ ...weekend.sessions[0], source: 'partial', datasets: { meeting: { status: 'available', source: 'local' } } }],
}
mockFetchSeasons.mockResolvedValue([2025])
mockFetchLocalMeetings.mockResolvedValue([meeting])
mockFetchWeekend.mockResolvedValue(partialWeekend)
renderPage()
expect(await screen.findByText('Partial')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import {
countRaceHubDatasets,
formatCoverageHint,
countWeekendStats,
sessionTypeAbbrev,
isSessionComplete,
} from '../lib/coverage'
import type { DatasetInfo, Weekend } from '../types'
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 },
}
describe('coverage helpers', () => {
it('counts available Race Hub datasets', () => {
expect(countRaceHubDatasets(fullDatasets)).toEqual({ available: 11, total: 11 })
expect(countRaceHubDatasets({ meeting: { status: 'available', source: 'local' } })).toEqual({
available: 1,
total: 11,
})
})
it('formats coverage hint', () => {
expect(formatCoverageHint(fullDatasets)).toBe('11/11')
})
it('detects complete sessions', () => {
expect(isSessionComplete(fullDatasets)).toBe(true)
expect(isSessionComplete({ meeting: { status: 'available', source: 'local' } })).toBe(false)
})
it('abbreviates session types', () => {
expect(sessionTypeAbbrev('Race', 'Race')).toBe('R')
expect(sessionTypeAbbrev('Qualifying', 'Qualifying')).toBe('Q')
expect(sessionTypeAbbrev('Practice', 'Practice 1')).toBe('FP1')
})
it('counts weekend stats', () => {
const local: Weekend = {
source: 'local',
meeting_key: 1,
meeting: {} as Weekend['meeting'],
sessions: [{ session: {} as Weekend['sessions'][0]['session'], source: 'local', datasets: fullDatasets }],
}
const partial: Weekend = {
source: 'partial',
meeting_key: 2,
meeting: {} as Weekend['meeting'],
sessions: [{ session: {} as Weekend['sessions'][0]['session'], source: 'partial', datasets: {} }],
}
expect(countWeekendStats([local, partial, undefined])).toEqual({
full: 1,
partial: 1,
missing: 1,
total: 3,
})
})
})

View File

@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test'
const FULL_SESSION = 9472
test.describe('Data Library', () => {
test('shows local coverage and ingest commands', async ({ page }) => {
await page.goto('/data-library')
await expect(page.getByTestId('data-library')).toBeVisible()
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
await expect(page.getByTestId('meeting-detail')).toBeVisible()
await expect(page.getByTestId('cli-commands').first()).toBeVisible()
await expect(page.getByText('box-box --ingest-meeting 1229', { 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 }) => {
await page.goto('/race-hub')
await page.getByRole('link', { name: 'Data Library' }).click()
await expect(page).toHaveURL(/\/data-library/)
await expect(page.getByTestId('data-library')).toBeVisible()
})
test('race hub link from data library footer works', async ({ page }) => {
await page.goto('/data-library')
await page.getByRole('link', { name: /Open Race Hub/i }).click()
await expect(page).toHaveURL(/\/race-hub/)
})
test('direct race hub session link still works', async ({ page }) => {
await page.goto(`/race-hub?session_key=${FULL_SESSION}`)
await expect(page.getByText('Final Classification')).toBeVisible()
})
})