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

@@ -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'
}
}