mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
Add data library UI
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
65
frontend/src/components/CliCommands.tsx
Normal file
65
frontend/src/components/CliCommands.tsx
Normal 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}` }]
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
106
frontend/src/components/MeetingDetailPanel.tsx
Normal file
106
frontend/src/components/MeetingDetailPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
17
frontend/src/components/SessionCoverageDots.tsx
Normal file
17
frontend/src/components/SessionCoverageDots.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/SourceBadge.tsx
Normal file
28
frontend/src/components/SourceBadge.tsx
Normal 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'
|
||||
}
|
||||
}
|
||||
76
frontend/src/lib/coverage.ts
Normal file
76
frontend/src/lib/coverage.ts
Normal 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'
|
||||
}
|
||||
307
frontend/src/pages/DataLibraryPage.tsx
Normal file
307
frontend/src/pages/DataLibraryPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
39
frontend/src/test/CliCommands.test.tsx
Normal file
39
frontend/src/test/CliCommands.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
144
frontend/src/test/DataLibraryPage.test.tsx
Normal file
144
frontend/src/test/DataLibraryPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
69
frontend/src/test/coverage.test.ts
Normal file
69
frontend/src/test/coverage.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
34
tests/data-library.spec.ts
Normal file
34
tests/data-library.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user